diff --git a/README.md b/README.md index 5ad807f..e7f6989 100644 --- a/README.md +++ b/README.md @@ -277,11 +277,42 @@ $ dbuild info Scaffold a new dbuild project with starter files. ```sh -dbuild init # config.yaml + Containerfile +dbuild init # config.yaml, compose.yaml, Containerfile.j2, root/ dbuild init --github # + GitHub Actions workflow dbuild init --woodpecker # + Woodpecker CI pipeline ``` +The image is a single process on `base-exec`: `/init` runs the setup script +`root/daemonless/init.d/20-` as root, then execs the launcher +`root/daemonless/libexec/` (the `CMD`) as `bsd`. Health is the base's +`/healthz` against `HEALTHCHECK_URL`. The `Containerfile*.j2` keep the title, +description, category, ports and volumes as placeholders: `dbuild generate` +fills them from `compose.yaml`, which stays the one place to edit them. A +pkg-only scaffold makes `pkg` the default tag with a `latest` alias. + +From a FreeBSD port, the port fills in what it knows: + +```sh +dbuild init --freebsd-port net-p2p/transmission-components +dbuild init --freebsd-port gitea --port 3000 # bare name: looked up in the tree, then pkg +dbuild init --freebsd-port https://www.freshports.org/sysutils/backrest/ +dbuild init --freebsd-port net-p2p/qbittorrent --flavors nox # one pkg tag per flavor +``` + +| From the port | Goes to | +|---|---| +| `COMMENT`, `WWW`, `LICENSE` (+`LICENSE_COMB`) as SPDX, category | compose.yaml, image labels | +| rc script `command=`/`procname=` (through `/usr/sbin/daemon`), plist `bin/` entries | the launcher, with the rc flags and conf dirs as a comment | +| rc `*_port` default or a `localhost:PORT` in pkg-message | `cit.port` and the compose `ports` (else 8080 and a warning) | +| `FLAVORS` | listed; `--flavors a,b` scaffolds `pkg-a`, `pkg-b` | + +Defaults change with `--freebsd-port`: variants are `pkg,pkg-latest`, and from a +directory of image repos the files land in `.//` (override with `--dir`). +Without a ports tree (`$PORTSDIR`, `/usr/ports`) the labels come from `pkg rquery`; +the launcher then needs the binary set by hand. Every scaffold ends with the +items left to review and the next commands (`dbuild logo`, `dbuild generate`, +`dbuild build`, `dbuild test`). + ## Global Options ``` diff --git a/dbuild/cli.py b/dbuild/cli.py index 7436984..e34dde0 100644 --- a/dbuild/cli.py +++ b/dbuild/cli.py @@ -327,7 +327,19 @@ def _make_parser() -> argparse.ArgumentParser: init_parser.add_argument( "--freebsd-port", metavar="PORT", - help="initialize from a FreeBSD port (e.g. net-p2p/bazarr)", + help=( + "scaffold from a FreeBSD port: category/name (net-p2p/transmission), " + "a bare port or package name, or a FreshPorts URL; reads the ports " + "tree ($PORTSDIR, /usr/ports), else the pkg repo" + ), + ) + init_parser.add_argument( + "--dir", + metavar="DIR", + help=( + "directory to scaffold into, created if missing (default: the current " + "directory, or ./ when run from a workspace of image repos)" + ), ) init_parser.add_argument( "--flavors", @@ -363,14 +375,18 @@ def _make_parser() -> argparse.ArgumentParser: init_parser.add_argument( "--port", type=int, - default=8080, - help="default application port (default: 8080)", + help=( + "web/API port (default: the port's rc script or pkg-message " + "with --freebsd-port, else 8080)" + ), ) init_parser.add_argument( "--variants", metavar="LIST", - default="latest", - help="comma-separated list of variants to scaffold (default: latest)", + help=( + "comma-separated list of variants to scaffold " + "(default: latest, or pkg,pkg-latest with --freebsd-port)" + ), ) init_parser.add_argument( "--community", diff --git a/dbuild/init.py b/dbuild/init.py index ddc3367..bfa9976 100644 --- a/dbuild/init.py +++ b/dbuild/init.py @@ -2,6 +2,14 @@ Generates starter files (.daemonless/config.yaml, Containerfile, CI configs) from embedded templates with dynamic placeholder replacement. + +With --freebsd-port the port itself fills in what it knows: name, comment, +license, WWW, category, the daemon binary and flags (from its rc script), +the installed binaries (from its plist) and the web port (rc script or +pkg-message). Without a ports tree the base metadata comes from `pkg rquery`. + +The image is a single process on base-exec: a launcher in +root/daemonless/libexec (the CMD) and a setup script in root/daemonless/init.d. """ from __future__ import annotations @@ -18,6 +26,18 @@ # Templates are co-located in the package _TEMPLATES_DIR = Path(__file__).parent / "templates" +# Placeholders a scaffolded Containerfile.j2 keeps for `dbuild generate`, +# which fills them from compose.yaml on every run; init must not bake the +# values in, or compose.yaml stops being the single source of truth. +_GENERATOR_KEYS = frozenset({"title", "description", "repo_url", "web_url", "category"}) + +_DEFAULT_PORT = 8080 +_PORT_VARIANTS = "pkg,pkg-latest" +_FRESHPORTS_RE = re.compile(r"^https?://(?:www\.)?freshports\.org/([^/]+/[^/]+)/?$") +_RC_VAR_RE = re.compile(r"\$\{(\w+)_port:?=\"?(\d{2,5})") +# "localhost:8080", "http://127.0.0.1:9091/web", "https://:3000" +_MSG_PORT_RE = re.compile(r"(?-]+:(\d{2,5})(?![\d.])") + # FreeBSD port category → daemonless category. # FreeBSD categories don't align with daemonless's vocabulary, so we map @@ -49,74 +69,121 @@ } -# FreeBSD port LICENSE identifiers mapped to SPDX. -# The ports tree (bsd.licenses.db.mk) does not expose SPDX identifiers — it -# only defines human-readable _LICENSE_NAME_* values. This map is maintained -# manually, but FreeBSD port license identifiers are stable and rarely change. +# FreeBSD port LICENSE tokens (Mk/bsd.licenses.db.mk) mapped to SPDX ids. +# The ports tree only carries human-readable names, so this map is kept by +# hand. Keys are the tokens ports actually use ("AGPLv3", "ISCL", "ART20"), +# not SPDX-ish guesses. Tokens with no exact SPDX id (PD, EPL, GFDL, BSD, +# GMGPL, LPPL13, WTFPL1 ...) are deliberately absent: an absent token means +# "UNKNOWN, go read the upstream LICENSE", never a near miss. _LICENSE_SPDX: dict[str, str] = { - # Copyleft — AGPL - "AGPL3": "AGPL-3.0-only", - "AGPL3+": "AGPL-3.0-or-later", - # Artistic - "ARTISTIC": "Artistic-1.0", - "ARTISTIC2": "Artistic-2.0", - # BSD + "AGPLv3": "AGPL-3.0-only", + "AGPLv3+": "AGPL-3.0-or-later", + "APACHE10": "Apache-1.0", + "APACHE11": "Apache-1.1", + "APACHE20": "Apache-2.0", + "ART10": "Artistic-1.0", + "ARTPERL10": "Artistic-1.0-Perl", + "ART20": "Artistic-2.0", + "BSD0CLAUSE": "0BSD", "BSD2CLAUSE": "BSD-2-Clause", "BSD3CLAUSE": "BSD-3-Clause", "BSD4CLAUSE": "BSD-4-Clause", - # Creative Commons - "CC-BY-4.0": "CC-BY-4.0", - "CC-BY-SA-4.0": "CC-BY-SA-4.0", - "CC0-1.0": "CC0-1.0", - # CDDL + "BSL": "BSL-1.0", "CDDL": "CDDL-1.0", - # EUPL + "ClArtistic": "ClArtistic", + "CPAL-1.0": "CPAL-1.0", "EUPL11": "EUPL-1.1", "EUPL12": "EUPL-1.2", - # Copyleft — GPL "GPLv1": "GPL-1.0-only", "GPLv1+": "GPL-1.0-or-later", "GPLv2": "GPL-2.0-only", "GPLv2+": "GPL-2.0-or-later", "GPLv3": "GPL-3.0-only", "GPLv3+": "GPL-3.0-or-later", - # ISC - "ISC": "ISC", - # Copyleft — LGPL + "ISCL": "ISC", "LGPL20": "LGPL-2.0-only", "LGPL20+": "LGPL-2.0-or-later", "LGPL21": "LGPL-2.1-only", "LGPL21+": "LGPL-2.1-or-later", "LGPL3": "LGPL-3.0-only", "LGPL3+": "LGPL-3.0-or-later", - # MIT + "LPPL10": "LPPL-1.0", + "LPPL11": "LPPL-1.1", + "LPPL12": "LPPL-1.2", + "LPPL13a": "LPPL-1.3a", + "LPPL13c": "LPPL-1.3c", "MIT": "MIT", - # Mozilla + "MPL10": "MPL-1.0", + "MPL11": "MPL-1.1", "MPL20": "MPL-2.0", - # Apache - "APACHE20": "Apache-2.0", + "NCSA": "NCSA", + "ODbL": "ODbL-1.0", + "OFL10": "OFL-1.0", + "OFL11": "OFL-1.1", + "OpenSSL": "OpenSSL", + "PHP202": "PHP-2.02", + "PHP30": "PHP-3.0", + "PHP301": "PHP-3.01", + "PostgreSQL": "PostgreSQL", + "PSFL": "PSF-2.0", + "RUBY": "Ruby", + "TclTk": "TCL", + "UNLICENSE": "Unlicense", + "WTFPL": "WTFPL", + "ZLIB": "Zlib", + "ZPL21": "ZPL-2.1", } +# Creative Commons tokens are already SPDX ids (CC-BY-4.0, CC-BY-SA-3.0, CC0-1.0 ...) +_LICENSE_PASSTHROUGH_RE = re.compile(r"^CC(0|-BY(-NC)?(-ND|-SA)?)-\d\.\d$") +# The port explicitly says it doesn't know +_LICENSE_UNSET = {"", "NA", "NONE", "UNKNOWN"} -def _to_spdx(raw: str) -> str: - """Map a FreeBSD port LICENSE token to its SPDX identifier.""" - return _LICENSE_SPDX.get(raw, raw or "UNKNOWN") +def _to_spdx(raw: str, comb: str = "single") -> tuple[str, list[str]]: + """Map a port's LICENSE line (plus LICENSE_COMB) to an SPDX expression. + Returns (expression, unmapped_tokens). Any token without an exact SPDX + mapping makes the whole expression "UNKNOWN" -- a partial expression + would read as authoritative when it isn't. + """ + tokens = raw.split() + if not tokens or any(t in _LICENSE_UNSET for t in tokens): + return "UNKNOWN", tokens + mapped: list[str] = [] + unmapped: list[str] = [] + for tok in tokens: + spdx = _LICENSE_SPDX.get(tok) or (tok if _LICENSE_PASSTHROUGH_RE.match(tok) else None) + (mapped if spdx else unmapped).append(spdx or tok) + if unmapped: + return "UNKNOWN", unmapped + if len(mapped) == 1: + return mapped[0], [] + joiner = " OR " if comb == "dual" else " AND " + return joiner.join(mapped), [] -def _pkg_run_deps(pkgname: str) -> list[str]: - """Return runtime dependency package names via pkg rquery. - Returns an empty list if the package is not found in the repo or pkg - is unavailable — the caller should warn and fall back gracefully. - """ +def _pkg_rquery(fmt: str, pkgname: str) -> str: + """`pkg rquery `; "" if pkg or the package is absent.""" + return _pkg_rquery_argv([fmt, pkgname]) + + +def _pkg_rquery_where(fmt: str, condition: str) -> str: + """`pkg rquery -e `, e.g. every package of one origin.""" + return _pkg_rquery_argv(["-e", condition, fmt]) + + +def _pkg_rquery_argv(argv: list[str]) -> str: try: - out = subprocess.check_output( - ["pkg", "rquery", "%dn", pkgname], - text=True, stderr=subprocess.DEVNULL, - ) - return [line.strip() for line in out.splitlines() if line.strip()] + return subprocess.check_output( + ["pkg", "rquery", *argv], text=True, stderr=subprocess.DEVNULL, + ).strip() except (subprocess.CalledProcessError, FileNotFoundError): - return [] + return "" + + +def _pkg_run_deps(pkgname: str) -> list[str]: + """Runtime dependency package names via pkg rquery ([] if unknown).""" + return [line.strip() for line in _pkg_rquery("%dn", pkgname).splitlines() if line.strip()] def _make_query( @@ -138,11 +205,70 @@ def _make_query( text=True, stderr=subprocess.DEVNULL, env=env, ) result[var] = out.strip() - except subprocess.CalledProcessError: + except (subprocess.CalledProcessError, FileNotFoundError): result[var] = "" return result +def _strip_pkg_version(raw: str) -> str: + """Strip the trailing version (foo-1.2.3 -> foo) from a PKGNAME.""" + return re.sub(r"-[\d].*$", "", raw) + + +def _portsdir() -> Path | None: + """The ports tree, or None when there isn't one (pkg rquery still works).""" + portsdir = Path(os.getenv("PORTSDIR", "/usr/ports")) + return portsdir if (portsdir / "Mk").is_dir() else None + + +def _resolve_port_spec(spec: str, portsdir: Path | None) -> str | None: + """Normalize what the user typed into a category/portname origin. + + Accepts "net-p2p/transmission", a FreshPorts URL, a path inside the + ports tree, or a bare port/package name (looked up in the ports tree, + then in the pkg repo). Logs and returns None when it can't. + """ + spec = spec.strip() + m = _FRESHPORTS_RE.match(spec) + if m: + spec = m.group(1) + if portsdir and spec.startswith(f"{portsdir}/"): + spec = spec[len(str(portsdir)) + 1:] + spec = spec.strip("/") + parts = [p for p in spec.split("/") if p] + + if len(parts) == 2: + return "/".join(parts) + if len(parts) != 1: + log.error( + f"invalid port '{spec}' — expected category/portname " + "(e.g. net-p2p/transmission), a FreshPorts URL, or a bare name" + ) + return None + + name = parts[0] + if portsdir: + hits = sorted( + p for p in portsdir.glob(f"*/{name}") + if (p / "Makefile").is_file() and p.parent.name[0].islower() + ) + if len(hits) == 1: + return f"{hits[0].parent.name}/{name}" + if len(hits) > 1: + log.error( + f"'{name}' is ambiguous — pick one of: " + + ", ".join(f"{p.parent.name}/{name}" for p in hits) + ) + return None + origin = _pkg_rquery("%o", name) + if origin: + log.info(f"{name} → {origin} (from the pkg repo)") + return origin + where = "the ports tree or the pkg repo" if portsdir else "the pkg repo" + log.error(f"no port or package named '{name}' in {where}") + return None + + def _first_paragraph(text: str) -> str: """Return the first non-empty paragraph of text.""" lines = text.splitlines() @@ -158,13 +284,8 @@ def _first_paragraph(text: str) -> str: return " ".join(paragraph) -def _strip_pkg_version(raw: str) -> str: - """Strip the trailing version (foo-1.2.3 -> foo) from a PKGNAME.""" - return re.sub(r"-[\d].*$", "", raw) - - def _resolve_flavors( - full_path: Path, port_path: str, requested: list[str], + full_path: Path, port_path: str, requested: list[str], available: list[str], ) -> dict[str, str] | None: """Validate *requested* flavors against the port and return their pkgnames. @@ -176,7 +297,6 @@ def _resolve_flavors( if not requested: return {} - available = _make_query(full_path, ["FLAVORS"]).get("FLAVORS", "").split() if not available: log.error(f"{port_path} has no FLAVORS — --flavors doesn't apply here") return None @@ -198,107 +318,294 @@ def _resolve_flavors( return flavor_pkgnames -def _fetch_port_metadata( - port_path: str, flavors: list[str] | None = None, -) -> dict[str, object] | None: - """Fetch metadata from a FreeBSD port.""" - if "/" not in port_path or port_path.count("/") != 1: - log.error( - f"invalid port path '{port_path}' — " - "expected category/portname (e.g. devel/ccache)" - ) - return None - - portsdir = os.getenv("PORTSDIR", "/usr/ports") - - if not Path(portsdir).exists(): - log.error(f"{portsdir} not found") - log.error("check out the ports tree first:") - log.error(f" git clone --depth=1 https://git.FreeBSD.org/ports.git {portsdir}") - log.error(" or: portsnap fetch extract") - return None +def _read(path: Path) -> str: + return path.read_text(errors="replace") if path.is_file() else "" - full_path = Path(portsdir) / port_path - if not full_path.exists(): - log.error(f"port not found: {full_path}") - return None - log.info(f"querying port metadata for {port_path}...") +def _continued_lines(text: str, pattern: str) -> list[str]: + """Lines matching *pattern* (anchored at line start), each with its + backslash continuations; `.*(?:\\\n.*)*` can't do this because the + greedy `.*` keeps the backslash and the optional group matches nothing.""" + lines = text.splitlines() + out: list[str] = [] + i = 0 + while i < len(lines): + if re.match(pattern, lines[i]): + while lines[i].rstrip().endswith("\\") and i + 1 < len(lines): + out.append(lines[i].rstrip().replace("\t", " ")) + i += 1 + out.append(lines[i].rstrip().replace("\t", " ")) + i += 1 + return out + + +def _rc_script_info( + port_dir: Path, rc_names: list[str], subst: dict[str, str] | None = None, +) -> dict[str, object]: + """What the port's rc script (files/.in) says about running the daemon. + + Returns {} when there is none. Otherwise: file, binary (the real daemon, + looked through /usr/sbin/daemon wrappers), extra_args (words after the + binary in command=, e.g. "web" for `gitea web`), ports (from *_port + defaults) and hints (the command/flags lines, for the run-script comment). + *subst* maps %%VAR%% placeholders (PREFIX, ETCDIR ...) to their values. + """ + for rc in rc_names: + rc_file = port_dir / "files" / f"{rc}.in" + if rc_file.is_file(): + break + else: + return {} + text = _read(rc_file) + for key, val in {"PREFIX": "/usr/local", "LOCALBASE": "/usr/local", **(subst or {})}.items(): + if val: + text = text.replace(f"%%{key}%%", val) + # Placeholders the port's Makefile would fill (%%GITUSER%%) stay readable + text = re.sub(r"%%(\w+)%%", r"<\1>", text) + # A value continued with a backslash (adguardhome's command_args) is one + # line to the shell; the hints keep the original layout. + joined = re.sub(r"\\\n[ \t]*", " ", text) + + def var(name: str) -> str: + m = re.search(rf'^{name}=["\']?(.*?)["\']?[ \t]*$', joined, re.M) + return m.group(1).strip() if m else "" + + rc_name = var("name") or rc_names[0] + text = text.replace("${name}", rc_name).replace("$name", rc_name) + joined = joined.replace("${name}", rc_name).replace("$name", rc_name) + command = var("command") + procname = var("procname") + command_args = var("command_args") + + binary, extra_args = "", "" + words = command.split() + if words and os.path.basename(words[0]) != "daemon": + binary, extra_args = words[0], " ".join(words[1:]) + else: + # /usr/sbin/daemon wrapper: the real program is in procname or command_args + for src in (procname, command_args): + m = re.search(r"/usr/local/s?bin/[\w.+-]+", src) + if m: + binary = m.group(0) + break + + hints = _continued_lines( + text, rf"(?:command|command_args|procname|{re.escape(rc_name)}_(?:flags|args))=", + ) + # rc.conf defaults (conf dirs, users ...) tell which paths to remap + hints += _continued_lines(text, rf": \$\{{{re.escape(rc_name)}_(?!enable)\w+[:=]") + + return { + "file": rc_file, + "binary": binary, + "extra_args": extra_args, + "ports": [int(p) for _, p in _RC_VAR_RE.findall(text)], + "hints": hints[:20], + } - try: - data = _make_query(full_path, [ - "PORTNAME", "PKGNAME", "PORTVERSION", "COMMENT", - "WWW", "LICENSE", "USE_RC_SUBR", "CATEGORIES", - ]) - except RuntimeError as exc: - log.error(str(exc)) - return None - # SPDX license — first token only (ports can have multiple) - spdx_license = _to_spdx(data.get("LICENSE", "").split()[0] if data.get("LICENSE") else "") +def _plist_binaries(port_dir: Path, plist_files: str) -> list[str]: + """Programs the package installs under /usr/local/{bin,sbin}, from the plist(s).""" + entries = plist_files.split() + for plist in sorted(port_dir.glob("pkg-plist*")): + entries.extend(_read(plist).splitlines()) + found: list[str] = [] + for entry in entries: + m = re.match(r'^"?(?:%%\w+%%)*(?:/usr/local/)?(s?bin/[\w.+-]+)"?$', entry.strip()) + if m and f"/usr/local/{m.group(1)}" not in found: + found.append(f"/usr/local/{m.group(1)}") + return found + + +def _message_ports(port_dir: Path) -> list[int]: + """Ports mentioned as host:port in pkg-message (e.g. "browse to localhost:9091").""" + text = _read(port_dir / "pkg-message") + _read(port_dir / "files" / "pkg-message.in") + ports: list[int] = [] + for p in _MSG_PORT_RE.findall(text): + n = int(p) + if 80 <= n <= 65535 and n not in ports: + ports.append(n) + return ports + + +def _pick_binary( + rc_binary: str, binaries: list[str], names: list[str], +) -> tuple[str, str]: + """The most likely daemon and where it came from: the rc script first, + then the plist (the only program, or one named like a flavor/port/pkg).""" + if rc_binary: + return rc_binary, "rc script" + if len(binaries) == 1: + return binaries[0], "plist" + for candidate in names: + for b in binaries: + if os.path.basename(b) == candidate: + return b, "plist" + return (binaries[0], "plist (first of several)") if binaries else ("", "") - # pkg name: PKGNAME may include version suffix (foo-1.2.3), strip it - pkgname = re.sub(r"-[\d].*$", "", data.get("PKGNAME", "") or data.get("PORTNAME", "")) - # Description: first paragraph of pkg-descr, fallback to COMMENT - description = data.get("COMMENT", "") - if (full_path / "pkg-descr").exists(): - description = _first_paragraph((full_path / "pkg-descr").read_text()) or description +def _fetch_port_metadata( + port_path: str, flavors: list[str] | None = None, want_run_deps: bool = False, +) -> dict[str, object] | None: + """Fetch metadata from a FreeBSD port (ports tree, else the pkg repo).""" + portsdir = _portsdir() + origin = _resolve_port_spec(port_path, portsdir) + if origin is None: + return None - web_url = data.get("WWW", "") - portname = data.get("PORTNAME", pkgname) - raw_cat = data.get("CATEGORIES", "").split()[0] - category = _CATEGORY_MAP.get(raw_cat, "") - rc_name = ( - data.get("USE_RC_SUBR", "").split()[0] - if data.get("USE_RC_SUBR") else portname - ) + if portsdir is None: + if flavors: + log.error("--flavors needs a ports tree (FLAVORS come from the port's Makefile)") + return None + return _fetch_pkg_metadata(origin, want_run_deps) - log.info(f"querying runtime deps for {pkgname}...") - _deps = _pkg_run_deps(pkgname) - if not _deps: - log.warn(f"no runtime deps found for '{pkgname}' via pkg rquery" - " — is the pkg index up to date?") + full_path = portsdir / origin + if not (full_path / "Makefile").is_file(): + log.error(f"port not found: {full_path}") + name = origin.split("/")[-1] + known = sorted( + {f"{p.parent.name}/{name}" for p in portsdir.glob(f"*/{name}") if (p / "Makefile").is_file()} + | {o for o in [_pkg_rquery("%o", name)] if o} + ) + if known: + log.error(f"did you mean: {', '.join(known)}") + return None - flavor_pkgnames = _resolve_flavors(full_path, port_path, flavors or []) + log.info(f"querying port metadata for {origin}...") + data = _make_query(full_path, [ + "PORTNAME", "PKGNAME", "PORTVERSION", "COMMENT", "WWW", "LICENSE", + "LICENSE_COMB", "USE_RC_SUBR", "CATEGORIES", "FLAVORS", "PLIST_FILES", + "ETCDIR", "DATADIR", "WWWDIR", + ]) + + portname = data["PORTNAME"] + pkgname = _strip_pkg_version(data["PKGNAME"] or portname) + available_flavors = data["FLAVORS"].split() + flavor_pkgnames = _resolve_flavors(full_path, origin, flavors or [], available_flavors) if flavor_pkgnames is None: return None - meta = { - "name": portname, - "pkgname": pkgname, - "web_url": web_url, - "upstream_url": web_url, - "description": description, - "license": spdx_license, - "packages": pkgname, - "run_deps": " ".join(_deps), - "rc_name": rc_name, - "freshports_url": f"https://www.freshports.org/{port_path}/", - "category": category, + spdx, unmapped = _to_spdx(data["LICENSE"], data["LICENSE_COMB"] or "single") + raw_cat = (data["CATEGORIES"].split() or [""])[0] + rc_names = data["USE_RC_SUBR"].split() + rc = _rc_script_info( + full_path, rc_names, + {"PORTNAME": portname, **{k: data[k] for k in ("ETCDIR", "DATADIR", "WWWDIR")}}, + ) + binaries = _plist_binaries(full_path, data["PLIST_FILES"]) + binary, binary_source = _pick_binary( + str(rc.get("binary", "")), binaries, + [*flavor_pkgnames.values(), portname, pkgname], + ) + + # Web port: the rc script's *_port default beats a pkg-message mention; + # either is used only when there is exactly one candidate. + port_candidates = list(rc.get("ports", [])) or _message_ports(full_path) + port_source = "rc script" if rc.get("ports") else "pkg-message" + + # WWW may list several URLs: the first is the site, a GitHub one the source + www = data["WWW"].split() + meta: dict[str, object] = { + "origin": origin, + "name": portname, + "pkgname": pkgname, + "version": data["PORTVERSION"], + "web_url": www[0] if www else "", + "upstream_url": next((u for u in www if "github.com/" in u), www[0] if www else ""), + "description": data["COMMENT"], + "long_description": _first_paragraph(_read(full_path / "pkg-descr")), + "license": spdx, + "license_raw": data["LICENSE"], + "license_unmapped": unmapped, + "packages": pkgname, + "run_deps": " ".join(_pkg_run_deps(pkgname)) if want_run_deps else "", + "freshports_url": f"https://www.freshports.org/{origin}/", + "category": _CATEGORY_MAP.get(raw_cat, ""), + "category_raw": raw_cat, "flavor_pkgnames": flavor_pkgnames, + "flavors": available_flavors, + "binary": binary, + "binary_source": binary_source, + "binaries": binaries, + "extra_args": rc.get("extra_args", ""), + "rc_file": rc.get("file"), + "rc_hints": rc.get("hints", []), + "port": port_candidates[0] if len(port_candidates) == 1 else 0, + "port_source": port_source, + "port_candidates": port_candidates, + "in_repo": bool(_pkg_rquery("%n", pkgname)), + "source": "ports tree", } + return meta - desc_preview = meta["description"][:80] + ("..." if len(meta["description"]) > 80 else "") - - log.step("Port metadata") - log.success(f"name: {portname} (pkg: {pkgname})") - log.success(f"description: {desc_preview}") - log.success(f"license: {spdx_license}") - log.success(f"web: {web_url}") - log.success(f"category: {category or f'(unmapped: {raw_cat})'}") - log.success(f"run_deps: {meta['run_deps'] or '(none found)'}") - if not category: - log.warn(f"no daemonless category mapping for '{raw_cat}' — set --category manually") - if not web_url: - log.warn("WWW not set in port — web_url left as placeholder") - if spdx_license == "UNKNOWN": - log.warn("LICENSE not set in port — update org.opencontainers.image.licenses manually") - return meta +def _pick_pkg(names: list[str], portname: str) -> str: + """Which of an origin's packages is the service: the port's own name, then + a daemon/server sub-package (transmission-components -> transmission-daemon).""" + for cand in (portname, portname.removesuffix("-components")): + if cand in names: + return cand + for n in names: + if re.search(r"-(daemon|server|nox)$", n): + return n + return names[0] + + +def _fetch_pkg_metadata(origin: str, want_run_deps: bool) -> dict[str, object] | None: + """Metadata from the pkg repo alone (no ports tree): enough for the labels.""" + log.info(f"no ports tree — querying the pkg repo for {origin}...") + names = _pkg_rquery_where("%n", f"%o = {origin}").split() + if not names: + log.error(f"no package with origin {origin} in the pkg repo") + log.error("for a port that isn't packaged yet, check out the ports tree:") + log.error(" git clone --depth=1 https://git.FreeBSD.org/ports.git /usr/ports") + return None + portname = origin.split("/")[-1] + pkgname = _pick_pkg(names, portname) + # a -daemon/-server sub-package carries the service; the app is the stem + portname = re.sub(r"-(daemon|server|nox|cli)$", "", pkgname) + fields = _pkg_rquery("%v\n%c\n%w\n%l\n%L", pkgname).split("\n") + [""] * 5 + version, comment, www, comb, licenses = fields[:5] + comb = {"and": "multi", "or": "dual"}.get(comb, "single") + spdx, unmapped = _to_spdx(licenses.replace(",", " "), comb) + raw_cat = origin.split("/")[0] + return { + "origin": origin, + "name": portname, + "pkgname": pkgname, + "version": version, + "web_url": www, + "upstream_url": www, + "description": comment, + "long_description": "", + "license": spdx, + "license_raw": licenses, + "license_unmapped": unmapped, + "packages": pkgname, + "run_deps": " ".join(_pkg_run_deps(pkgname)) if want_run_deps else "", + "freshports_url": f"https://www.freshports.org/{origin}/", + "category": _CATEGORY_MAP.get(raw_cat, ""), + "category_raw": raw_cat, + "flavor_pkgnames": {}, + "flavors": [], + "binary": "", + "binary_source": "", + "binaries": [], + "extra_args": "", + "rc_file": None, + "rc_hints": [], + "port": 0, + "port_source": "", + "port_candidates": [], + "in_repo": True, + "sibling_packages": names, + "source": "pkg repo", + } -def _build_variants_block(variants: list[str], flavor_pkgnames: dict[str, str]) -> str: +def _build_variants_block( + variants: list[str], flavor_pkgnames: dict[str, str], default: str | None = None, +) -> str: """Render the `build.variants:` YAML list for config.yaml. Source-type tags (not starting with "pkg") get a plain Containerfile @@ -307,6 +614,9 @@ def _build_variants_block(variants: list[str], flavor_pkgnames: dict[str, str]) pkg -> pkg-lua, pkg-wolfssl), each carrying that flavor's own resolved pkg_name -- flavors are a FreeBSD-package concept, so source variants are never crossed with them. + + *default* names the tag `:latest` points at; a pkg tag in that role + also gets the `latest` alias, so a pkg-only image still publishes one. """ lines: list[str] = [] for tag in variants: @@ -325,11 +635,38 @@ def _build_variants_block(variants: list[str], flavor_pkgnames: dict[str, str]) if is_pkg and is_latest_pkgs: lines.append(" args:") lines.append(' BASE_VERSION: "15.1-latest"') + if out_tag == default: + if out_tag != "latest": + lines.append(' aliases: ["latest"]') + lines.append(" default: true") + return "\n".join(lines) + + +def _build_block(variants: list[str], flavor_pkgnames: dict[str, str], pkgname: str) -> str: + """The `build:` body for config.yaml: the package name once at the top + (never per variant, which hides the image from the status page) and the + variants, the first one the default.""" + lines: list[str] = [] + has_pkg = any(v.startswith("pkg") for v in variants) + if has_pkg and not flavor_pkgnames: + lines.append(f" pkg_name: {pkgname}") + lines.append(" variants:") + default = variants[0] if variants else None + if default and default.startswith("pkg") and flavor_pkgnames: + default = f"{default}-{next(iter(flavor_pkgnames))}" + lines.append(_build_variants_block(variants, flavor_pkgnames, default)) return "\n".join(lines) -def _render_template(template_name: str, context: dict[str, str]) -> str | None: - """Read a template and replace {{ key }} with context[key].""" +def _render_template( + template_name: str, context: dict[str, object], generator: bool = False, +) -> str | None: + """Read a template and replace {{ key }} with context[key]. + + With *generator* the output is itself a Jinja template for `dbuild + generate`: the keys it owns and its `{%- if mlock %}` blocks are left + for it. + """ src = _TEMPLATES_DIR / template_name if not src.exists(): log.error(f"template not found: {template_name}") @@ -337,13 +674,28 @@ def _render_template(template_name: str, context: dict[str, str]) -> str | None: content = src.read_text() for key, val in context.items(): + if generator and key in _GENERATOR_KEYS: + continue content = content.replace(f"{{{{ {key} }}}}", str(val)) + if generator: + return content - # Handle simple conditionals for mlock - if context.get("mlock") == "true": - content = content.replace("{%- if mlock %}", "").replace("{%- endif %}", "") - else: - content = re.sub(r"{%- if mlock %}.*?{%- endif %}", "", content, flags=re.DOTALL) + # {%- if mlock %}...{%- endif %}: drop the block (and the line it starts + # on) when mlock is off; when on, keep it without doubling blank lines + # for a block that opens with its own newline. Inline uses (a trailing + # label continuation) have no leading newline to deal with. + mlock_on = context.get("mlock") == "true" + + def _mlock_sub(m: re.Match) -> str: + if not mlock_on: + return "" + prefix, block = m.group(1) or "", m.group(2).rstrip(" \t\n") + return block if block.startswith("\n") else prefix + block + + content = re.sub( + r"(\n[ \t]*)?\{%- if mlock %\}(.*?)\{%- endif %\}", _mlock_sub, + content, flags=re.DOTALL, + ) # Handle variant conditionals: {%- if "tag" in variants %}...{%- endif %} variants = context.get("variants", []) @@ -362,7 +714,10 @@ def _variant_sub(m: re.Match) -> str: flags=re.DOTALL, ) - return content + # A dropped block leaves its indentation and a doubled blank line behind + content = re.sub(r"[ \t]+\n", "\n", content) + content = re.sub(r"\n{3,}", "\n\n", content) + return content.rstrip("\n") + "\n" def _write_file(path: Path, content: str, dry_run: bool = False) -> bool: @@ -387,13 +742,13 @@ def _write_file(path: Path, content: str, dry_run: bool = False) -> bool: def _scaffold( template: str, path: Path, - context: dict[str, str], + context: dict[str, object], dry_run: bool, executable: bool = False, ) -> int: """Render a template and write it; return 1 if created, 0 otherwise.""" log.debug(f"scaffold {template!r} → {path}") - content = _render_template(template, context) + content = _render_template(template, context, generator=path.suffix == ".j2") if content and _write_file(path, content, dry_run): if executable and not dry_run: path.chmod(0o755) @@ -401,39 +756,208 @@ def _scaffold( return 0 +def _looks_like_workspace_root(d: Path) -> bool: + """A directory holding image repos (not an image repo itself).""" + if (d / "compose.yaml").exists() or (d / ".daemonless").exists(): + return False + try: + images = [p for p in d.iterdir() if (p / ".daemonless" / "config.yaml").is_file()] + except OSError: + return False + return len(images) >= 2 + + +def _run_hints(meta: dict[str, object]) -> str: + """Comment block for the launcher: what the port's rc script does.""" + lines: list[str] = [] + rc_file = meta.get("rc_file") + if rc_file: + rc_path = Path(str(rc_file)) + rel = rc_path.relative_to(rc_path.parents[3]) if len(rc_path.parents) > 3 else rc_path + lines.append(f"# From the port's rc script ({rel}). Map rc variables such as") + lines.append("# conf dirs to container paths (/config, /data); drop daemonizing flags.") + lines.extend(f"# {h}" for h in meta.get("rc_hints", [])) + others = [ + os.path.basename(str(b)) for b in meta.get("binaries", []) if b != meta.get("binary") + ] + if others: + lines.append("# Other programs in the package: " + ", ".join(others)) + if not lines: + return "" + return "\n" + "\n".join(lines) + "\n" + + +_EMPTY_META: dict[str, object] = { + "origin": "", "pkgname": "", "version": "", "source": "", "description": "", + "license": "UNKNOWN", "license_raw": "", "license_unmapped": [], "web_url": "", + "category": "", "category_raw": "", "binary": "", "binary_source": "", + "extra_args": "", "rc_file": None, "port": 0, "port_source": "", + "port_candidates": [], "flavors": [], "in_repo": True, +} + + +def _port_summary(meta: dict[str, object], title: str, port: int, port_from_arg: bool, + variants: list[str], flavors: list[str], + category_from_arg: bool = False) -> list[str]: + """Log the metadata table; return the review items for the next-steps block.""" + review: list[str] = [] + meta = {**_EMPTY_META, **meta} + pkg = f"{meta['pkgname']} {meta['version']}".strip() + log.step("Port metadata") + log.success(f"port: {meta['origin']} ({pkg}, via {meta['source']})") + log.success(f"title: {title} (--title to override)") + log.success(f"description: {meta['description'] or '(no COMMENT)'}") + + if meta["license"] == "UNKNOWN": + raw = meta["license_raw"] or "unset" + log.warn(f"license: UNKNOWN (port says: {raw})") + review.append( + f"license: port LICENSE is '{raw}' with no SPDX mapping — read the upstream " + "LICENSE and set org.opencontainers.image.licenses in Containerfile.pkg.j2" + ) + else: + log.success(f"license: {meta['license']} (port: {meta['license_raw']})") + + if meta["web_url"]: + log.success(f"web: {meta['web_url']}") + else: + log.warn("web: (WWW unset in port)") + review.append("web_url/upstream_url in compose.yaml: the port has no WWW") + + if meta["category"]: + log.success(f"category: {meta['category']} (port: {meta['category_raw']})") + if meta["category_raw"] == "www" and not category_from_arg: + review.append( + "category: www/ ports land in Network; a git forge is Infrastructure, " + "a wiki or notes app Productivity (compose.yaml, or --category)" + ) + else: + log.warn(f"category: Utilities (no mapping for '{meta['category_raw']}')") + review.append("category in compose.yaml (--category next time)") + + if meta["binary"]: + log.success( + f"daemon: {meta['binary']} {meta['extra_args']}".rstrip() + + f" ({meta['binary_source']})" + ) + elif meta["source"] == "pkg repo": + log.warn("daemon: unknown (no ports tree to read the rc script or plist)") + review.append("launcher: set the daemon binary and flags") + else: + log.warn("daemon: no rc script and no bin/ entries in the plist") + review.append("launcher: set the daemon binary and flags") + if meta.get("rc_file"): + review.append("launcher: turn the rc flags in the comment into container flags") + + if port_from_arg: + log.success(f"web port: {port} (--port)") + elif meta["port"]: + log.success(f"web port: {port} (from {meta['port_source']})") + else: + cands = meta["port_candidates"] + hint = f"candidates in {meta['port_source']}: {', '.join(map(str, cands))}" if cands else "nothing in the rc script or pkg-message" + log.warn(f"web port: {port} (default; {hint})") + review.append( + f"web port: {port} is a guess — fix cit.port and the compose ports " + "(the Containerfile follows compose), or pass --port next time" + ) + + log.success(f"variants: {', '.join(variants)}") + if meta["flavors"] and not flavors: + log.info( + f"flavors: {', '.join(meta['flavors'])} " + "(default flavor used; --flavors a,b builds one tag per flavor)" + ) + if meta.get("sibling_packages") and len(meta["sibling_packages"]) > 1: + log.info(f"packages with this origin: {', '.join(meta['sibling_packages'])}") + + if not meta["in_repo"]: + log.warn(f"package '{meta['pkgname']}' is not in the pkg repo — the pkg build will fail until it is published") + if meta["license_unmapped"] and meta["license"] == "UNKNOWN" and meta["license_raw"] not in _LICENSE_UNSET: + log.warn(f"no SPDX mapping for license token(s): {', '.join(meta['license_unmapped'])}") + return review + + +def _next_steps(base: Path, app_name: str, review: list[str], from_port: bool) -> None: + rel = base.relative_to(Path.cwd()) if base != Path.cwd() and base.is_relative_to(Path.cwd()) else None + log.step("Next steps") + n = 0 + if rel: + n += 1 + log.plain(f" {n}. cd {rel}") + n += 1 + log.plain(f" {n}. edit root/daemonless/libexec/{app_name} (and init.d/20-{app_name} for dirs to create)") + for item in review: + log.plain(f" - {item}") + if not from_port: + log.plain(" - set the daemon binary and flags") + n += 1 + log.plain(f" {n}. dbuild logo # .daemonless/logo.svg (or .png)") + n += 1 + log.plain(f" {n}. dbuild generate # Containerfile(.pkg) + README.md from the .j2 templates") + n += 1 + log.plain(f" {n}. dbuild build && dbuild test") + + def run(args: argparse.Namespace) -> int: """Scaffold a new dbuild project in the current directory.""" - base = Path.cwd() - + port_spec = getattr(args, "freebsd_port", None) flavors = [f.strip() for f in (getattr(args, "flavors", None) or "").split(",") if f.strip()] - if flavors and not getattr(args, "freebsd_port", None): + if flavors and not port_spec: log.error("--flavors requires --freebsd-port (flavors are a FreeBSD ports concept)") return 1 + # A port scaffold is a pkg image unless told otherwise; a generic one + # still starts from the upstream-binary template. + variants_arg = getattr(args, "variants", None) or (_PORT_VARIANTS if port_spec else "latest") + variants = [v.strip() for v in variants_arg.split(",") if v.strip()] + has_pkg = any(v.startswith("pkg") for v in variants) + wants_source = "latest" in variants or not has_pkg + port_meta: dict[str, object] = {} - if getattr(args, "freebsd_port", None): - fetched = _fetch_port_metadata(args.freebsd_port, flavors=flavors) + if port_spec: + fetched = _fetch_port_metadata(port_spec, flavors=flavors, want_run_deps=wants_source) if fetched is None: - if flavors: - log.error("aborting: could not resolve --flavors against the port") - return 1 - fetched = {} + log.error("aborting: nothing scaffolded (fix the port spec, or drop --freebsd-port)") + return 1 port_meta = fetched - app_name = args.name or port_meta.get("name") or base.name - title = args.title or app_name.capitalize() - if port_meta and not args.title: - log.warn(f"title set to '{title}' — use --title for proper capitalization (e.g. --title FFmpeg)") + app_name = args.name or port_meta.get("name") or Path.cwd().name + # node_exporter -> Node Exporter; a brand's own casing is a --title job + title = args.title or " ".join(w.capitalize() for w in app_name.split("_")) + category_from_arg = args.category != "Apps" category = ( - args.category if args.category != "Apps" + args.category if category_from_arg else (port_meta.get("category") or "Utilities") ) - port = str(args.port) - variants = [v.strip() for v in args.variants.split(",")] + port_arg = getattr(args, "port", None) + port = int(port_arg) if port_arg else (int(port_meta.get("port") or 0) or _DEFAULT_PORT) dry_run = args.dry_run mlock = "true" if args.type == "dotnet" else "false" + pkgname = str(port_meta.get("pkgname") or app_name) + packages = str(port_meta.get("packages") or app_name) + + target = getattr(args, "dir", None) + if target: + base = Path(target).resolve() + elif port_spec and _looks_like_workspace_root(Path.cwd()): + base = Path.cwd() / app_name + log.info(f"current directory holds image repos — scaffolding into ./{app_name}/") + else: + base = Path.cwd() + + if port_meta: + binary = port_meta.get("binary") or f"/usr/local/bin/{app_name}" + extra = port_meta.get("extra_args") or "" + run_binary = f"{binary} {extra}".rstrip() + run_args = "" + run_hints = _run_hints(port_meta) + else: + run_binary = f"/usr/local/bin/{app_name}" + run_args = " \\\n --config /config \\\n --data /data" + run_hints = "" - context = { + context: dict[str, object] = { "name": app_name, "title": title, "category": category, @@ -450,34 +974,46 @@ def run(args: argparse.Namespace) -> int: ), "community": args.community or DEFAULT_COMMUNITY, "variants": variants, - "pkgname": port_meta.get("pkgname") or app_name, - "packages": port_meta.get("packages") or app_name, + "pkgname": pkgname, + "packages_arg": "${PKG_NAME}" if packages == pkgname else packages, "run_deps": port_meta.get("run_deps") or app_name, "license": port_meta.get("license") or "UNKNOWN", - "rc_name": port_meta.get("rc_name") or app_name, - "variants_block": _build_variants_block( - variants, port_meta.get("flavor_pkgnames") or {} + "run_binary": run_binary, + "run_args": run_args, + "run_hints": run_hints, + "build_block": _build_block( + variants, port_meta.get("flavor_pkgnames") or {}, pkgname, ), } - has_pkg = any(v.startswith("pkg") for v in variants) - log.debug(f"variants: {variants} has_pkg: {has_pkg} dry_run: {dry_run}") + review: list[str] = [] + if port_meta: + review = _port_summary( + port_meta, title, port, bool(port_arg), variants, flavors, category_from_arg, + ) + if wants_source and not port_meta.get("run_deps"): + log.warn(f"no runtime deps found for '{port_meta['pkgname']}' — PACKAGES in Containerfile.j2 needs a manual list") + + log.debug(f"variants: {variants} has_pkg: {has_pkg} dry_run: {dry_run} base: {base}") log.debug("context:\n" + "\n".join(f" {k} = {v!r}" for k, v in context.items())) created = 0 created += _scaffold("config.yaml", base / ".daemonless" / "config.yaml", context, dry_run) created += _scaffold("compose.yaml", base / "compose.yaml", context, dry_run) - if "latest" in variants or not has_pkg: + if wants_source: created += _scaffold("template-upstream.j2", base / "Containerfile.j2", context, dry_run) if has_pkg: created += _scaffold("template-pkg.j2", base / "Containerfile.pkg.j2", context, dry_run) - run_path = base / "root" / "etc" / "services.d" / app_name / "run" - created += _scaffold("run.sh", run_path, context, dry_run, executable=True) - # s6 only opens the readiness FD when notification-fd exists (content '3'); - # without it the run script's s6-ready-when is a silent no-op - created += int(_write_file(run_path.parent / "notification-fd", "3\n", dry_run)) - created += _scaffold("healthz.sh", base / "root" / "healthz", context, dry_run, executable=True) + # base-exec: /init runs init.d as root, then execs the launcher (CMD) as + # bsd. Health is the base's /healthz reading HEALTHCHECK_URL. + daemonless = base / "root" / "daemonless" + created += _scaffold( + "launcher.sh", daemonless / "libexec" / app_name, context, dry_run, executable=True, + ) + created += _scaffold( + "setup.sh", daemonless / "init.d" / f"20-{app_name}", context, dry_run, executable=True, + ) if getattr(args, "woodpecker", False): created += _scaffold("woodpecker.yaml", base / ".woodpecker.yaml", context, dry_run) @@ -490,8 +1026,8 @@ def run(args: argparse.Namespace) -> int: if created == 0: log.info("nothing to do (all files already exist)") - else: - label = "would be created" if dry_run else "created" - log.step(f"scaffolded {created} file(s) ({label})") - + return 0 + label = "would be created" if dry_run else "created" + log.step(f"scaffolded {created} file(s) ({label})") + _next_steps(base, app_name, review, from_port=bool(port_meta)) return 0 diff --git a/dbuild/templates/Containerfile b/dbuild/templates/Containerfile deleted file mode 100644 index d6afb35..0000000 --- a/dbuild/templates/Containerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM ghcr.io/daemonless/base:15.1 - -# Install packages -RUN pkg install -y \ - # app-package-here \ - && pkg clean -ay - -# Copy root overlay (services, config) -COPY root/ / - -# Application port -EXPOSE 8080 - -# Volume for persistent data -VOLUME /config diff --git a/dbuild/templates/compose.yaml b/dbuild/templates/compose.yaml index aba7330..b765cdd 100644 --- a/dbuild/templates/compose.yaml +++ b/dbuild/templates/compose.yaml @@ -26,9 +26,7 @@ services: {{ name }}: image: ghcr.io/daemonless/{{ name }}:latest container_name: {{ name }} - # "always", not "unless-stopped": FreeBSD's podman rc.d only auto-starts - # containers with restart-policy=always at boot (needs podman_enable=YES). - restart: always + restart: unless-stopped {%- if mlock %} annotations: @@ -44,4 +42,4 @@ services: - /path/to/containers/{{ name }}:/config ports: - - {{ port }}:{{ port }} + - "{{ port }}:{{ port }}" diff --git a/dbuild/templates/config.yaml b/dbuild/templates/config.yaml index a92ce0e..745ad0f 100644 --- a/dbuild/templates/config.yaml +++ b/dbuild/templates/config.yaml @@ -4,13 +4,14 @@ # Full reference: https://github.com/daemonless/dbuild build: - variants: -{{ variants_block }} +{{ build_block }} cit: mode: health port: {{ port }} health: / + # A line the app logs once it serves; without a match the test waits out its timeout + # ready: "listening on" {%- if mlock %} annotations: - "org.freebsd.jail.allow.mlock=true" diff --git a/dbuild/templates/healthz.sh b/dbuild/templates/healthz.sh deleted file mode 100644 index 4e1713d..0000000 --- a/dbuild/templates/healthz.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -# Daemonless standard healthcheck for {{ name }} - -fetch -qo /dev/null "http://127.0.0.1:{{ port }}/" >/dev/null 2>&1 diff --git a/dbuild/templates/launcher.sh b/dbuild/templates/launcher.sh new file mode 100644 index 0000000..875f1bf --- /dev/null +++ b/dbuild/templates/launcher.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Start {{ name }}; /init has already dropped to the run user. + +export HOME=/config +{{ run_hints }} +cd /config +exec {{ run_binary }}{{ run_args }} diff --git a/dbuild/templates/run.sh b/dbuild/templates/run.sh deleted file mode 100644 index 77febe9..0000000 --- a/dbuild/templates/run.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -# {{ title }} s6 service - -export HOME=/config - -# Signal readiness once the healthcheck passes (FD 3, see notification-fd) -s6-ready-when /healthz - -echo "[INFO] Starting {{ name }}..." - -cd /config -exec /usr/local/bin/s6-setuidgid bsd \ - /usr/local/bin/{{ name }} \ - --config /config \ - --data /data diff --git a/dbuild/templates/setup.sh b/dbuild/templates/setup.sh new file mode 100644 index 0000000..744a964 --- /dev/null +++ b/dbuild/templates/setup.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Runs as root before {{ name }} starts; a failure stops the container. +set -e + +mkdir -p /config +chown -R bsd:bsd /config diff --git a/dbuild/templates/template-pkg.j2 b/dbuild/templates/template-pkg.j2 index 93cb3f2..f1519ba 100644 --- a/dbuild/templates/template-pkg.j2 +++ b/dbuild/templates/template-pkg.j2 @@ -1,11 +1,14 @@ ARG BASE_VERSION=15.1 -FROM ghcr.io/daemonless/base:${BASE_VERSION} +FROM ghcr.io/daemonless/base-exec:${BASE_VERSION} ARG FREEBSD_ARCH=amd64 ARG PKG_NAME="{{ pkgname }}" -{% if packages == pkgname %}ARG PACKAGES="${PKG_NAME}" -{% else %}ARG PACKAGES="{{ packages }}" -{% endif %} +ARG PACKAGES="{{ packages_arg }}" +ARG HEALTHCHECK_ENDPOINT="http://127.0.0.1:{{ ports[0].port }}/" + +ENV HEALTHCHECK_URL="${HEALTHCHECK_ENDPOINT}" + +# --- Metadata (Injected by Generator) --- LABEL org.opencontainers.image.title="{{ title }}" \ org.opencontainers.image.description="{{ description }}" \ org.opencontainers.image.source="{{ repo_url }}" \ @@ -14,12 +17,16 @@ LABEL org.opencontainers.image.title="{{ title }}" \ org.opencontainers.image.vendor="daemonless" \ org.opencontainers.image.authors="daemonless" \ io.daemonless.category="{{ category }}" \ - io.daemonless.port="{{ port }}" \ + io.daemonless.port="{{ ports[0].port }}" \ + io.daemonless.volumes="{{ volumes | map(attribute='path') | join(',') }}" \ +{%- if mlock %} + org.freebsd.jail.allow.mlock="required" \ +{%- endif %} io.daemonless.arch="${FREEBSD_ARCH}" \ io.daemonless.pkg-source="pkg" \ + io.daemonless.pkg-name="${PKG_NAME}" \ io.daemonless.packages="${PACKAGES}" \ - io.daemonless.pkg-name="${PKG_NAME}"{%- if mlock %} \ - org.freebsd.jail.allow.mlock="required"{%- endif %} + io.daemonless.healthcheck-url="${HEALTHCHECK_ENDPOINT}" # Install {{ name }} RUN pkg update && \ @@ -31,12 +38,12 @@ RUN pkg update && \ # Copy root filesystem COPY root/ / +RUN chmod +x /daemonless/libexec/{{ name }} /daemonless/init.d/* -# Set permissions -RUN chmod +x /etc/services.d/{{ name }}/run /healthz +CMD ["/daemonless/libexec/{{ name }}"] # Expose -EXPOSE {{ port }} +EXPOSE {{ ports | map(attribute='port') | join(' ') }} # Volumes -VOLUME /config +VOLUME {{ volumes | map(attribute='path') | join(' ') }} diff --git a/dbuild/templates/template-upstream.j2 b/dbuild/templates/template-upstream.j2 index cbe865e..ef22d49 100644 --- a/dbuild/templates/template-upstream.j2 +++ b/dbuild/templates/template-upstream.j2 @@ -1,11 +1,17 @@ ARG BASE_VERSION=15.1 -FROM ghcr.io/daemonless/base:${BASE_VERSION} +FROM ghcr.io/daemonless/base-exec:${BASE_VERSION} ARG FREEBSD_ARCH=amd64 ARG PACKAGES="{{ run_deps }}" ARG APP_VERSION="latest" ARG DOWNLOAD_URL="https://example.com/{{ name }}-${APP_VERSION}.tar.gz" +ARG UPSTREAM_URL="https://api.github.com/repos/OWNER/{{ name }}/releases/latest" +ARG UPSTREAM_JQ=".tag_name" +ARG HEALTHCHECK_ENDPOINT="http://127.0.0.1:{{ ports[0].port }}/" +ENV HEALTHCHECK_URL="${HEALTHCHECK_ENDPOINT}" + +# --- Metadata (Injected by Generator) --- LABEL org.opencontainers.image.title="{{ title }}" \ org.opencontainers.image.description="{{ description }}" \ org.opencontainers.image.source="{{ repo_url }}" \ @@ -14,13 +20,16 @@ LABEL org.opencontainers.image.title="{{ title }}" \ org.opencontainers.image.vendor="daemonless" \ org.opencontainers.image.authors="daemonless" \ io.daemonless.category="{{ category }}" \ - io.daemonless.port="{{ port }}" \ - io.daemonless.arch="${FREEBSD_ARCH}" \ - io.daemonless.packages="${PACKAGES}" - + io.daemonless.port="{{ ports[0].port }}" \ + io.daemonless.volumes="{{ volumes | map(attribute='path') | join(',') }}" \ {%- if mlock %} -LABEL org.freebsd.jail.allow.mlock="required" + org.freebsd.jail.allow.mlock="required" \ {%- endif %} + io.daemonless.arch="${FREEBSD_ARCH}" \ + io.daemonless.upstream-url="${UPSTREAM_URL}" \ + io.daemonless.upstream-jq="${UPSTREAM_JQ}" \ + io.daemonless.packages="${PACKAGES}" \ + io.daemonless.healthcheck-url="${HEALTHCHECK_ENDPOINT}" # Install dependencies RUN pkg update && \ @@ -30,19 +39,19 @@ RUN pkg update && \ # Download and install {{ name }} RUN fetch -qo /tmp/{{ name }}.tar.gz "${DOWNLOAD_URL}" && \ - mkdir -p /usr/local/share/{{ name }} && \ + mkdir -p /app /usr/local/share/{{ name }} && \ tar -xzf /tmp/{{ name }}.tar.gz -C /usr/local/share/{{ name }} --strip-components=1 && \ rm /tmp/{{ name }}.tar.gz && \ echo "${APP_VERSION}" > /app/version # Copy root filesystem COPY root/ / +RUN chmod +x /daemonless/libexec/{{ name }} /daemonless/init.d/* -# Set permissions -RUN chmod +x /etc/services.d/{{ name }}/run /healthz +CMD ["/daemonless/libexec/{{ name }}"] # Expose -EXPOSE {{ port }} +EXPOSE {{ ports | map(attribute='port') | join(' ') }} # Volumes -VOLUME /config +VOLUME {{ volumes | map(attribute='path') | join(' ') }} diff --git a/docs/dbuild.1 b/docs/dbuild.1 index 591752a..61964e1 100644 --- a/docs/dbuild.1 +++ b/docs/dbuild.1 @@ -1,4 +1,4 @@ -.TH DBUILD 1 "2026-11-03" "dbuild 1.13.1" "User Commands" +.TH DBUILD 1 "2026-11-12" "dbuild 1.13.1" "User Commands" .SH NAME dbuild \- FreeBSD OCI container image build tool .SH SYNOPSIS @@ -213,7 +213,10 @@ Generate starter files for a new dbuild project. show this help message and exit .TP \fB--freebsd-port\fR -initialize from a FreeBSD port (e.g. net-p2p/bazarr) +scaffold from a FreeBSD port: category/name (net-p2p/transmission), a bare port or package name, or a FreshPorts URL; reads the ports tree ($PORTSDIR, /usr/ports), else the pkg repo +.TP +\fB--dir\fR +directory to scaffold into, created if missing (default: the current directory, or ./ when run from a workspace of image repos) .TP \fB--flavors\fR comma-separated FreeBSD port FLAVORS to scaffold (e.g. lua,wolfssl); crossed with --variants into one tag per combination (e.g. pkg-lua, pkg-wolfssl) @@ -231,10 +234,10 @@ application category (default: Apps) application type (default: generic) .TP \fB--port\fR -default application port (default: 8080) +web/API port (default: the port's rc script or pkg-message with --freebsd-port, else 8080) .TP \fB--variants\fR -comma-separated list of variants to scaffold (default: latest) +comma-separated list of variants to scaffold (default: latest, or pkg,pkg-latest with --freebsd-port) .TP \fB--community\fR override community help link (e.g. Discord:https://...) diff --git a/tests/test_init.py b/tests/test_init.py index c82adf8..799c64a 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -8,6 +8,7 @@ from pathlib import Path from unittest.mock import patch +from dbuild import init from dbuild.init import _build_variants_block, run @@ -25,6 +26,7 @@ def _base_args(**overrides): github=False, freebsd_port=None, flavors=None, + dir=None, ) for k, v in overrides.items(): setattr(args, k, v) @@ -68,22 +70,84 @@ def test_config_yaml_has_no_unrendered_placeholder(self): self.assertIn("- tag: pkg-latest", content) self.assertIn('BASE_VERSION: "15.1-latest"', content) - def test_scaffolds_ready_signal_files(self): - """run.sh uses s6-ready-when, so notification-fd must exist with '3'.""" + def test_scaffolds_base_exec_layout(self): + """The launcher is the image's CMD on base-exec; no s6 service dir.""" with tempfile.TemporaryDirectory() as d, patch( "dbuild.init.Path.cwd", return_value=Path(d) ): - run(_base_args()) - svc = Path(d) / "root" / "etc" / "services.d" / "testapp" - self.assertEqual((svc / "notification-fd").read_text().strip(), "3") - run_text = (svc / "run").read_text() - healthz = (Path(d) / "root" / "healthz").read_text() - self.assertIn("s6-ready-when /healthz", run_text) + run(_base_args(variants="latest,pkg")) + root = Path(d) / "root" + launcher = root / "daemonless" / "libexec" / "testapp" + setup = root / "daemonless" / "init.d" / "20-testapp" + self.assertTrue(launcher.stat().st_mode & 0o111) + self.assertTrue(setup.stat().st_mode & 0o111) + launcher_text = launcher.read_text() + setup_text = setup.read_text() + self.assertFalse((root / "etc" / "services.d").exists()) + self.assertFalse((root / "healthz").exists()) + templates = [ + (Path(d) / n).read_text() for n in ("Containerfile.j2", "Containerfile.pkg.j2") + ] + # /init drops privileges before the launcher; s6 tools aren't in base-exec + self.assertNotIn("s6-", launcher_text) + self.assertNotIn("dsu", launcher_text) # exec must continue onto the arg lines, not drop them - self.assertIn("exec /usr/local/bin/s6-setuidgid bsd \\\n", run_text) - # cloudflared leftovers must not leak into generic scaffolds - self.assertNotIn("TUNNEL_TOKEN", run_text) - self.assertNotIn("TUNNEL_TOKEN", healthz) + self.assertIn("exec /usr/local/bin/testapp \\\n", launcher_text) + self.assertIn("set -e", setup_text) + for cf in templates: + self.assertIn("FROM ghcr.io/daemonless/base-exec:", cf) + self.assertIn('CMD ["/daemonless/libexec/testapp"]', cf) + self.assertIn('ENV HEALTHCHECK_URL="${HEALTHCHECK_ENDPOINT}"', cf) + self.assertNotIn("services.d", cf) + self.assertNotIn("TUNNEL_TOKEN", cf) + + def test_containerfile_keeps_generator_placeholders(self): + """compose.yaml stays the source of the labels: the scaffolded .j2 + keeps {{ title }} and friends for `dbuild generate`, while compose + and config get the real values.""" + with tempfile.TemporaryDirectory() as d, patch( + "dbuild.init.Path.cwd", return_value=Path(d) + ): + run(_base_args(variants="pkg", type="dotnet")) + cf = (Path(d) / "Containerfile.pkg.j2").read_text() + compose = (Path(d) / "compose.yaml").read_text() + config = (Path(d) / ".daemonless" / "config.yaml").read_text() + for key in ("title", "description", "repo_url", "web_url", "category"): + self.assertIn("{{ " + key + " }}", cf) + self.assertIn("{%- if mlock %}", cf) + self.assertIn("{{ ports[0].port }}", cf) + self.assertIn('ARG PACKAGES="${PKG_NAME}"', cf) + self.assertNotIn("{%", compose) + self.assertIn('title: "TestApp"', compose) + self.assertIn("mlock: true", compose) + self.assertIn("restart: unless-stopped", compose) + self.assertIn('- "8080:8080"', compose) + self.assertIn("org.freebsd.jail.allow.mlock=true", config) + + def test_pkg_only_scaffold_publishes_latest(self): + """Without a source variant the pkg tag is the default and carries + the latest alias; the package name is set once, not per variant.""" + with tempfile.TemporaryDirectory() as d, patch( + "dbuild.init.Path.cwd", return_value=Path(d) + ): + run(_base_args(variants="pkg,pkg-latest")) + config = (Path(d) / ".daemonless" / "config.yaml").read_text() + self.assertFalse((Path(d) / "Containerfile.j2").exists()) + self.assertIn("build:\n pkg_name: testapp\n variants:\n", config) + self.assertIn( + " - tag: pkg\n containerfile: Containerfile.pkg\n" + ' aliases: ["latest"]\n default: true\n', + config, + ) + self.assertNotIn("pkg_name: testapp\n ", config.split("variants:")[1]) + + def test_port_lookup_failure_scaffolds_nothing(self): + with tempfile.TemporaryDirectory() as d, patch( + "dbuild.init.Path.cwd", return_value=Path(d) + ), patch("dbuild.init._fetch_port_metadata", return_value=None): + rc = run(_base_args(freebsd_port="audio/nosuchport")) + self.assertEqual(rc, 1) + self.assertEqual(list(Path(d).iterdir()), []) class TestFlavors(unittest.TestCase): @@ -136,10 +200,11 @@ class TestBuildVariantsBlock(unittest.TestCase): """Tests for _build_variants_block() in isolation.""" def test_no_flavors_matches_legacy_template(self): - block = _build_variants_block(["latest", "pkg", "pkg-latest"], {}) + block = _build_variants_block(["latest", "pkg", "pkg-latest"], {}, "latest") self.assertEqual(block, ( " - tag: latest\n" " containerfile: Containerfile\n" + " default: true\n" " - tag: pkg\n" " containerfile: Containerfile.pkg\n" " - tag: pkg-latest\n" @@ -162,5 +227,295 @@ def test_flavors_cross_pkg_latest_suffix_preserved(self): self.assertIn('BASE_VERSION: "15.1-latest"', block) +def _fake_port(root: Path, origin: str, files: dict[str, str]) -> Path: + """A port directory under a fake ports tree (root/Mk marks the tree).""" + (root / "Mk").mkdir(exist_ok=True) + port = root / origin + for rel, content in {"Makefile": "PORTNAME=x\n", **files}.items(): + (port / rel).parent.mkdir(parents=True, exist_ok=True) + (port / rel).write_text(content) + return port + + +_PORT_META = { + "origin": "net-p2p/foo", "name": "foo", "pkgname": "foo-daemon", "version": "1.0", + "source": "ports tree", "description": "Foo daemon", "license": "MIT", + "license_raw": "MIT", "license_unmapped": [], "web_url": "https://foo.example/", + "upstream_url": "https://foo.example/", "category": "Downloaders", + "category_raw": "net-p2p", "binary": "/usr/local/sbin/foo-daemon", + "binary_source": "rc script", "binaries": ["/usr/local/sbin/foo-daemon", "/usr/local/bin/foo-cli"], + "extra_args": "serve", "rc_file": "/usr/ports/net-p2p/foo/files/foo.in", + "rc_hints": ["command=/usr/local/sbin/foo-daemon serve"], "port": 9091, + "port_source": "pkg-message", "port_candidates": [9091], "flavor_pkgnames": {}, + "flavors": [], "in_repo": True, "packages": "foo-daemon", "run_deps": "", + "freshports_url": "https://www.freshports.org/net-p2p/foo/", +} + + +class TestLicense(unittest.TestCase): + """_to_spdx maps real FreeBSD LICENSE tokens, never near misses.""" + + def test_single(self): + self.assertEqual(init._to_spdx("GPLv3+"), ("GPL-3.0-or-later", [])) + self.assertEqual(init._to_spdx("ISCL"), ("ISC", [])) + self.assertEqual(init._to_spdx("AGPLv3"), ("AGPL-3.0-only", [])) + + def test_multi_and_dual(self): + self.assertEqual(init._to_spdx("GPLv3+ LGPL3+", "multi"), ("GPL-3.0-or-later AND LGPL-3.0-or-later", [])) + self.assertEqual(init._to_spdx("MIT APACHE20", "dual"), ("MIT OR Apache-2.0", [])) + + def test_creative_commons_passthrough(self): + self.assertEqual(init._to_spdx("CC-BY-SA-4.0"), ("CC-BY-SA-4.0", [])) + + def test_unset_and_unmapped_are_unknown(self): + self.assertEqual(init._to_spdx("NA"), ("UNKNOWN", ["NA"])) + self.assertEqual(init._to_spdx(""), ("UNKNOWN", [])) + # one unmapped token taints the whole expression: no partial answers + self.assertEqual(init._to_spdx("MIT PD", "multi"), ("UNKNOWN", ["PD"])) + + +class TestPortSpec(unittest.TestCase): + """_resolve_port_spec accepts origins, bare names, URLs and tree paths.""" + + def test_origin_and_url_forms(self): + self.assertEqual(init._resolve_port_spec("net-p2p/transmission", None), "net-p2p/transmission") + self.assertEqual(init._resolve_port_spec("net-p2p/transmission/", None), "net-p2p/transmission") + self.assertEqual( + init._resolve_port_spec("https://www.freshports.org/net-p2p/transmission/", None), + "net-p2p/transmission", + ) + + def test_bare_name_in_tree_and_tree_path(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + _fake_port(root, "www/gitea", {}) + self.assertEqual(init._resolve_port_spec("gitea", root), "www/gitea") + self.assertEqual(init._resolve_port_spec(f"{root}/www/gitea", root), "www/gitea") + + def test_bare_name_ambiguous_or_missing(self): + with tempfile.TemporaryDirectory() as d, patch("dbuild.init._pkg_rquery", return_value=""): + root = Path(d) + _fake_port(root, "www/foo", {}) + _fake_port(root, "net/foo", {}) + self.assertIsNone(init._resolve_port_spec("foo", root)) + self.assertIsNone(init._resolve_port_spec("nope", root)) + self.assertIsNone(init._resolve_port_spec("a/b/c", root)) + + def test_bare_name_falls_back_to_pkg_origin(self): + with patch("dbuild.init._pkg_rquery", return_value="net-p2p/transmission-components"): + self.assertEqual(init._resolve_port_spec("transmission-daemon", None), "net-p2p/transmission-components") + + +class TestRcScript(unittest.TestCase): + """_rc_script_info finds the daemon through rc.d conventions.""" + + def test_direct_command_with_extra_args_and_name(self): + rc = ( + "name=gitea\n" + ': ${gitea_enable:="NO"}\n' + ': ${gitea_custom:="%%PREFIX%%/etc/${name}"}\n' + 'command="%%PREFIX%%/sbin/${name} web"\n' + ) + with tempfile.TemporaryDirectory() as d: + port = _fake_port(Path(d), "www/gitea", {"files/gitea.in": rc}) + info = init._rc_script_info(port, ["gitea"]) + self.assertEqual(info["binary"], "/usr/local/sbin/gitea") + self.assertEqual(info["extra_args"], "web") + self.assertIn('command="/usr/local/sbin/gitea web"', info["hints"]) + self.assertIn(': ${gitea_custom:="/usr/local/etc/gitea"}', info["hints"]) + self.assertNotIn(': ${gitea_enable:="NO"}', info["hints"]) + + def test_daemon_wrapper_procname_port_and_continuations(self): + rc = ( + "name=foo\n" + ': ${foo_port:=8384}\n' + 'procname="%%LOCALBASE%%/bin/foo"\n' + 'command="/usr/sbin/daemon"\n' + 'foo_flags=" \\\n' + "\t-g ${foo_conf_dir} \\\n" + '\t--no-browser"\n' + ) + with tempfile.TemporaryDirectory() as d: + port = _fake_port(Path(d), "net/foo", {"files/foo.in": rc}) + info = init._rc_script_info(port, ["foo"]) + self.assertEqual(info["binary"], "/usr/local/bin/foo") + self.assertEqual(info["ports"], [8384]) + self.assertEqual( + [h for h in info["hints"] if "foo_flags" in h or h.startswith(" -")], + ['foo_flags=" \\', " -g ${foo_conf_dir} \\", ' --no-browser"'], + ) + + def test_no_rc_script(self): + with tempfile.TemporaryDirectory() as d: + port = _fake_port(Path(d), "net/foo", {}) + self.assertEqual(init._rc_script_info(port, []), {}) + self.assertEqual(init._rc_script_info(port, ["foo"]), {}) + + def test_continued_command_args_and_placeholders(self): + """adguardhome: the binary sits on the line after command_args=\"... \\".""" + rc = ( + 'name="adguardhome"\n' + ': ${adguardhome_user:="%%AGHUSER%%"}\n' + 'command="/usr/sbin/daemon"\n' + 'command_args="-o ${logfile} -t ${name} -P ${pidfile} \\\n' + "\t%%PREFIX%%/bin/adguardhome -c %%ETCDIR%%/AdGuardHome.yaml --no-check-update\"\n" + ) + with tempfile.TemporaryDirectory() as d: + port = _fake_port(Path(d), "www/adguardhome", {"files/adguardhome.in": rc}) + info = init._rc_script_info(port, ["adguardhome"], {"ETCDIR": "/usr/local/etc/adguardhome"}) + self.assertEqual(info["binary"], "/usr/local/bin/adguardhome") + self.assertIn(': ${adguardhome_user:=""}', info["hints"]) + # the hints keep the continuation as two lines + self.assertIn(" /usr/local/bin/adguardhome -c /usr/local/etc/adguardhome/AdGuardHome.yaml --no-check-update\"", info["hints"]) + + +class TestPortProbes(unittest.TestCase): + def test_wrong_category_suggests_the_right_origin(self): + with tempfile.TemporaryDirectory() as d, patch( + "dbuild.init._portsdir", return_value=Path(d) + ), patch("dbuild.init._pkg_rquery", return_value="www/adguardhome"), patch( + "dbuild.init.log.error" + ) as err: + _fake_port(Path(d), "www/adguardhome", {}) + self.assertIsNone(init._fetch_port_metadata("dns/adguardhome")) + self.assertIn("did you mean: www/adguardhome", [c.args[0] for c in err.call_args_list]) + + def test_plist_binaries(self): + with tempfile.TemporaryDirectory() as d: + port = _fake_port(Path(d), "net/foo", { + "pkg-plist": "bin/foo\n%%NOX%%bin/foo-nox\nlib/libfoo.so\n@sample etc/foo.conf.sample\n", + }) + bins = init._plist_binaries(port, '"@dir /var/db/foo" sbin/food /usr/local/bin/foo') + self.assertEqual(bins, ["/usr/local/sbin/food", "/usr/local/bin/foo", "/usr/local/bin/foo-nox"]) + + def test_message_ports(self): + with tempfile.TemporaryDirectory() as d: + port = _fake_port(Path(d), "net/foo", { + "pkg-message": "Browse to http://localhost:9091/web (not 127.0.0.1:9091).\nVersion 1.2.3\n", + }) + self.assertEqual(init._message_ports(port), [9091]) + + def test_pick_binary(self): + self.assertEqual(init._pick_binary("/usr/local/bin/x", ["/usr/local/bin/y"], ["y"]), ("/usr/local/bin/x", "rc script")) + self.assertEqual(init._pick_binary("", ["/usr/local/bin/y"], []), ("/usr/local/bin/y", "plist")) + bins = ["/usr/local/bin/foo", "/usr/local/bin/foo-nox"] + self.assertEqual(init._pick_binary("", bins, ["foo-nox", "foo"]), ("/usr/local/bin/foo-nox", "plist")) + self.assertEqual(init._pick_binary("", bins, ["zzz"]), ("/usr/local/bin/foo", "plist (first of several)")) + self.assertEqual(init._pick_binary("", [], ["foo"]), ("", "")) + + +class TestPortScaffold(unittest.TestCase): + """run() with --freebsd-port: port-aware defaults and the run script.""" + + def _scaffold(self, d: Path, **overrides): + with patch("dbuild.init.Path.cwd", return_value=d), patch( + "dbuild.init._fetch_port_metadata", return_value=dict(_PORT_META) + ): + fields = dict(name=None, title=None, port=None, variants=None, + freebsd_port="net-p2p/foo") + fields.update(overrides) + args = _base_args(**fields) + self.assertEqual(run(args), 0) + + def test_port_defaults_and_run_script(self): + with tempfile.TemporaryDirectory() as d: + self._scaffold(Path(d)) + root = Path(d) + config = (root / ".daemonless" / "config.yaml").read_text() + launcher = (root / "root" / "daemonless" / "libexec" / "foo").read_text() + compose = (root / "compose.yaml").read_text() + self.assertFalse((root / "Containerfile.j2").exists()) + self.assertTrue((root / "Containerfile.pkg.j2").exists()) + self.assertFalse((root / "root" / "healthz").exists()) + self.assertIn(" pkg_name: foo-daemon\n", config) + self.assertIn("- tag: pkg\n", config) + self.assertIn("- tag: pkg-latest", config) + self.assertNotIn("- tag: latest", config) + self.assertIn("port: 9091", config) + self.assertIn('- "9091:9091"', compose) + self.assertIn('description: "Foo daemon"', compose) + self.assertIn("exec /usr/local/sbin/foo-daemon serve\n", launcher) + self.assertIn("# command=/usr/local/sbin/foo-daemon serve", launcher) + self.assertIn("# Other programs in the package: foo-cli", launcher) + self.assertNotIn("--config /config", launcher) + + def test_title_and_www_review(self): + meta = dict(_PORT_META, name="node_exporter", category="Network", category_raw="www") + with tempfile.TemporaryDirectory() as d, patch( + "dbuild.init.Path.cwd", return_value=Path(d) + ), patch("dbuild.init._fetch_port_metadata", return_value=meta), patch( + "dbuild.init.log.plain" + ) as plain: + run(_base_args(name=None, title=None, port=None, variants=None, freebsd_port="www/node_exporter")) + compose = (Path(d) / "compose.yaml").read_text() + self.assertIn('title: "Node Exporter"', compose) + self.assertIn('category: "Network"', compose) + lines = [c.args[0] for c in plain.call_args_list] + self.assertTrue(any("www/ ports land in Network" in ln for ln in lines), lines) + self.assertTrue(any("edit root/daemonless/libexec/node_exporter" in ln for ln in lines), lines) + + def test_explicit_flags_win(self): + with tempfile.TemporaryDirectory() as d: + self._scaffold(Path(d), port=3000, variants="latest,pkg", title="FooBar") + root = Path(d) + config = (root / ".daemonless" / "config.yaml").read_text() + compose = (root / "compose.yaml").read_text() + self.assertTrue((root / "Containerfile.j2").exists()) + self.assertIn("port: 3000", config) + self.assertIn("- tag: latest", config) + self.assertIn('title: "FooBar"', compose) + + def test_workspace_root_scaffolds_into_subdir(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + for name in ("a", "b"): + (root / name / ".daemonless").mkdir(parents=True) + (root / name / ".daemonless" / "config.yaml").write_text("") + self.assertTrue(init._looks_like_workspace_root(root)) + self._scaffold(root) + self.assertTrue((root / "foo" / "compose.yaml").exists()) + self.assertFalse((root / "compose.yaml").exists()) + self.assertFalse(init._looks_like_workspace_root(root / "foo")) + + def test_dir_flag(self): + with tempfile.TemporaryDirectory() as d: + self._scaffold(Path(d), dir=str(Path(d) / "sub" / "foo")) + self.assertTrue((Path(d) / "sub" / "foo" / "compose.yaml").exists()) + + +class TestMlockRendering(unittest.TestCase): + """The mlock conditional must not leave doubled blank lines or junk whitespace.""" + + def _render(self, mlock: str) -> tuple[str, str, str]: + ctx = {k: "x" for k in ( + "name", "title", "category", "description", "upstream_url", "web_url", + "freshports_url", "community", "repo_url", "license", "pkgname", "packages", + )} + ctx.update(port="8080", mlock=mlock, mlock_bool=mlock, packages_arg="${PKG_NAME}", + build_block=" variants:\n - tag: pkg\n containerfile: Containerfile.pkg") + return ( + init._render_template("compose.yaml", ctx), + init._render_template("config.yaml", ctx), + init._render_template("template-pkg.j2", ctx, generator=True), + ) + + def test_off(self): + compose, config, cf = self._render("false") + self.assertNotIn("\n\n\n", compose) + self.assertNotIn("annotations", compose) + self.assertNotIn("annotations", config) + self.assertNotRegex(config, r"\n[ \t]+\n") + self.assertNotRegex(compose, r"\n[ \t]+\n") + # the Containerfile's mlock block is the generator's, from compose.yaml + self.assertIn('{%- if mlock %}\n org.freebsd.jail.allow.mlock="required" \\\n{%- endif %}', cf) + + def test_on(self): + compose, config, _ = self._render("true") + self.assertNotIn("\n\n\n", compose) + self.assertIn('restart: unless-stopped\n\n annotations:\n org.freebsd.jail.allow.mlock: "true"\n\n environment:', compose) + self.assertIn(' # ready: "listening on"\n annotations:\n - "org.freebsd.jail.allow.mlock=true"\n', config) + self.assertNotRegex(config, r"\n[ \t]+\n") + + if __name__ == "__main__": unittest.main()