From 3970e919d0db9b44849334103ea6bca9f993868a Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Tue, 6 Oct 2026 16:13:05 -0400 Subject: [PATCH 1/4] feat(analyze): add image layer bloat and rootfs artifact auditor - Add `dbuild analyze` (alias `audit`) to inspect container images for layer size distribution, large layers, and "zombie cleanup" layers (standalone RUN rm/pkg clean instructions that fail to reclaim parent layer space). - Run lightweight in-container rootfs probe using standard FreeBSD base utilities to audit leftover build toolchains/compilers (llvm, gcc, rust, go, cmake), static library archives (*.a), development headers (/usr/local/include), documentation/manuals, and leftover package caches. - Provide actionable optimization recommendations and potential recoverable space. - Support `--json` output with suppressed log statements for clean piping to jq/CI. - Add Containerfile lint rules to warn on uncleaned `pkg install` and standalone cleanup layers. - Add full unit test suite (15 new tests in test_analyze.py and 3 in test_lint.py) and update man page. --- dbuild/analyze.py | 490 ++++++++++++++++++++++++++++++++++++++++++ dbuild/cli.py | 55 ++++- dbuild/lint.py | 53 +++++ dbuild/podman.py | 10 +- docs/dbuild.1 | 54 ++++- tests/test_analyze.py | 268 +++++++++++++++++++++++ tests/test_lint.py | 77 +++++++ 7 files changed, 1000 insertions(+), 7 deletions(-) create mode 100644 dbuild/analyze.py create mode 100644 tests/test_analyze.py diff --git a/dbuild/analyze.py b/dbuild/analyze.py new file mode 100644 index 0000000..5ea3a41 --- /dev/null +++ b/dbuild/analyze.py @@ -0,0 +1,490 @@ +"""Image layer and filesystem bloat analysis. + +Inspects container image layers for size distribution and zombie layers +(cleanup instructions in separate layers that fail to reclaim space), +and audits the in-container rootfs for leftover build toolchains, static +libraries, development headers, and package caches. +""" + +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import asdict, dataclass, field +from typing import Any + +from dbuild import log, podman +from dbuild.config import Config + + +def human_size(num_bytes: int) -> str: + """Format bytes as a human-readable string (e.g. 14.2 MB, 1.3 GB).""" + if num_bytes < 1024: + return f"{num_bytes} B" + num = float(num_bytes) + for unit in ("KB", "MB", "GB", "TB"): + num /= 1024.0 + if num < 1024.0 or unit == "TB": + return f"{num:.1f} {unit}" + return f"{num:.1f} PB" + + +# Regex patterns matching build-only tools that shouldn't linger in production +_BUILD_TOOL_PATTERNS = [ + re.compile(r"^devel/llvm\d*", re.IGNORECASE), + re.compile(r"^devel/gcc\d*", re.IGNORECASE), + re.compile(r"^devel/binutils", re.IGNORECASE), + re.compile(r"^lang/rust", re.IGNORECASE), + re.compile(r"^lang/go\d*", re.IGNORECASE), + re.compile(r"^devel/cmake", re.IGNORECASE), + re.compile(r"^devel/ninja", re.IGNORECASE), + re.compile(r"^devel/meson", re.IGNORECASE), + re.compile(r"^devel/gmake", re.IGNORECASE), + re.compile(r"^devel/bison", re.IGNORECASE), + re.compile(r"^devel/flex", re.IGNORECASE), + re.compile(r"^devel/m4", re.IGNORECASE), + re.compile(r"^devel/git-tiny", re.IGNORECASE), + re.compile(r"^devel/git", re.IGNORECASE), +] + +_STANDALONE_CLEANUP_RE = re.compile( + r"(?:/bin/sh -c\s+)?(?:rm\s+-[a-zA-Z]*r[a-zA-Z]*f|pkg\s+clean)", + re.IGNORECASE, +) + + +@dataclass +class LayerInfo: + id: str + size_bytes: int + size_human: str + created_by: str + comment: str = "" + is_nop: bool = False + + +@dataclass +class BloatItem: + category: str + title: str + size_bytes: int + size_human: str + details: list[str] = field(default_factory=list) + recommendation: str = "" + + +@dataclass +class AnalysisReport: + image: str + total_size_bytes: int + total_size_human: str + layers: list[LayerInfo] = field(default_factory=list) + bloat_items: list[BloatItem] = field(default_factory=list) + zombie_layers: list[dict[str, Any]] = field(default_factory=list) + potential_savings_bytes: int = 0 + potential_savings_human: str = "0 B" + rootfs_audit_available: bool = True + error_message: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def parse_history_json(raw_history: list[dict[str, Any]]) -> list[LayerInfo]: + """Parse output of ``podman history --format json`` into LayerInfo models.""" + layers: list[LayerInfo] = [] + for item in raw_history: + layer_id = str(item.get("id") or item.get("Id") or "") + raw_size = item.get("size") + if raw_size is None: + raw_size = item.get("Size", 0) + size_bytes = int(raw_size) + created_by = str(item.get("CreatedBy") or item.get("created_by") or "") + comment = str(item.get("comment") or item.get("Comment") or "") + is_nop = "#(nop)" in created_by or "# (nop)" in created_by + + layers.append( + LayerInfo( + id=layer_id, + size_bytes=size_bytes, + size_human=human_size(size_bytes), + created_by=created_by.strip(), + comment=comment.strip(), + is_nop=is_nop, + ) + ) + return layers + + +def detect_zombie_layers(layers: list[LayerInfo]) -> list[dict[str, Any]]: + """Detect separate cleanup RUN layers that cannot reclaim disk space.""" + zombies: list[dict[str, Any]] = [] + for idx, layer in enumerate(layers): + cmd = layer.created_by + # Check for standalone cleanup commands like `RUN rm -rf /var/cache/pkg` + if _STANDALONE_CLEANUP_RE.search(cmd) and "pkg install" not in cmd and "make" not in cmd and "fetch" not in cmd and "curl" not in cmd: + zombies.append({ + "layer_index": idx, + "layer_id": layer.id[:12] if len(layer.id) >= 12 else layer.id, + "command": cmd, + "reason": ( + "Separate cleanup layer does not reclaim disk space from parent " + "layers due to OCI copy-on-write semantics. Combine file deletion " + "into the same RUN instruction using '&&'." + ), + }) + return zombies + + +# In-container probe script using only standard FreeBSD base utilities (/bin/sh, du, find, pkg) +_PROBE_SCRIPT = ( + "echo '===PKGS===' && " + "pkg query '%o\t%n\t%v\t%sb\t%a' 2>/dev/null || true; " + "echo '===STATIC_LIBS===' && " + "find /usr/local /usr/lib -name '*.a' -exec du -k {} + 2>/dev/null || true; " + "echo '===DIRECTORIES===' && " + "du -sk /usr/local/include /usr/local/share/doc /usr/local/share/man " + "/var/cache/pkg /var/db/pkg/repos /tmp /var/tmp 2>/dev/null || true; " + "echo '===REPO_SQLITE===' && " + "du -sk /var/db/pkg/repo-*.sqlite 2>/dev/null || true; " + "echo '===END==='" +) + + +def parse_probe_output(output: str, threshold_bytes: int = 1024 * 1024) -> list[BloatItem]: + """Parse raw probe output into categorized BloatItems.""" + bloat: list[BloatItem] = [] + + sections: dict[str, list[str]] = {} + current_sec = "NONE" + for line in output.splitlines(): + line = line.strip() + if line.startswith("===") and line.endswith("==="): + current_sec = line.strip("=") + sections[current_sec] = [] + elif current_sec != "NONE": + sections[current_sec].append(line) + + # 1. Build Toolchain & Compilers + pkg_lines = sections.get("PKGS", []) + build_pkgs: list[tuple[str, str, int]] = [] + build_bytes = 0 + + for pl in pkg_lines: + parts = pl.split("\t") + if len(parts) >= 4: + origin, name, _ver, size_str = parts[0], parts[1], parts[2], parts[3] + try: + pkg_size = int(size_str) + except ValueError: + continue + + for pat in _BUILD_TOOL_PATTERNS: + if pat.search(origin) or pat.search(name): + build_pkgs.append((origin, name, pkg_size)) + build_bytes += pkg_size + break + + if build_pkgs and build_bytes >= threshold_bytes: + details = [ + f"{orig} ({name}): {human_size(sz)}" + for orig, name, sz in sorted(build_pkgs, key=lambda x: x[2], reverse=True)[:10] + ] + bloat.append( + BloatItem( + category="toolchain", + title=f"Build Toolchains & Compilers ({len(build_pkgs)} packages)", + size_bytes=build_bytes, + size_human=human_size(build_bytes), + details=details, + recommendation=( + "Use a multi-stage build (e.g. 'AS builder') to compile artifacts, " + "or purge build tools in the same layer before finalizing: " + f"'pkg delete -y {' '.join(p[1] for p in build_pkgs[:5])}'" + ), + ) + ) + + # 2. Static Libraries (*.a) + static_lines = sections.get("STATIC_LIBS", []) + static_files: list[tuple[str, int]] = [] + static_bytes = 0 + + for sl in static_lines: + parts = sl.split("\t", 1) + if len(parts) == 2: + try: + kb = int(parts[0]) + static_bytes += kb * 1024 + static_files.append((parts[1], kb * 1024)) + except ValueError: + continue + + if static_files and static_bytes >= threshold_bytes: + details = [ + f"{path}: {human_size(sz)}" + for path, sz in sorted(static_files, key=lambda x: x[1], reverse=True)[:8] + ] + if len(static_files) > 8: + details.append(f"... and {len(static_files) - 8} more static archives") + bloat.append( + BloatItem( + category="static_libs", + title=f"Static Libraries ({len(static_files)} files)", + size_bytes=static_bytes, + size_human=human_size(static_bytes), + details=details, + recommendation="Add 'find /usr/local -name \"*.a\" -delete' to your installation RUN layer.", + ) + ) + + # 3. Directories (Headers, Doc, Man, Cache) + dir_lines = sections.get("DIRECTORIES", []) + dir_sizes: dict[str, int] = {} + for dl in dir_lines: + parts = dl.split("\t", 1) + if len(parts) == 2: + try: + dir_sizes[parts[1]] = int(parts[0]) * 1024 + except ValueError: + continue + + # Development Headers + inc_bytes = dir_sizes.get("/usr/local/include", 0) + if inc_bytes >= threshold_bytes: + bloat.append( + BloatItem( + category="headers", + title="Development Headers (/usr/local/include)", + size_bytes=inc_bytes, + size_human=human_size(inc_bytes), + details=[f"/usr/local/include: {human_size(inc_bytes)}"], + recommendation="Remove C/C++ header files with 'rm -rf /usr/local/include' in the install layer.", + ) + ) + + # Documentation & Man Pages + doc_bytes = dir_sizes.get("/usr/local/share/doc", 0) + dir_sizes.get("/usr/local/share/man", 0) + if doc_bytes >= threshold_bytes: + details = [] + if dir_sizes.get("/usr/local/share/doc"): + details.append(f"/usr/local/share/doc: {human_size(dir_sizes['usr/local/share/doc'] if 'usr/local/share/doc' in dir_sizes else dir_sizes.get('/usr/local/share/doc', 0))}") + if dir_sizes.get("/usr/local/share/man"): + details.append(f"/usr/local/share/man: {human_size(dir_sizes.get('/usr/local/share/man', 0))}") + bloat.append( + BloatItem( + category="docs", + title="Manual & Documentation Pages", + size_bytes=doc_bytes, + size_human=human_size(doc_bytes), + details=details, + recommendation="Remove documentation with 'rm -rf /usr/local/share/doc /usr/local/share/man'.", + ) + ) + + # Package Caches & Repo Catalogs + cache_bytes = dir_sizes.get("/var/cache/pkg", 0) + dir_sizes.get("/var/db/pkg/repos", 0) + # Repo sqlite files + for rline in sections.get("REPO_SQLITE", []): + rparts = rline.split("\t", 1) + if len(rparts) == 2: + try: + cache_bytes += int(rparts[0]) * 1024 + except ValueError: + continue + + # Flag cache if > 64 KB + if cache_bytes > 64 * 1024: + bloat.append( + BloatItem( + category="cache", + title="Package Cache & Catalogs (/var/cache/pkg)", + size_bytes=cache_bytes, + size_human=human_size(cache_bytes), + details=[f"Leftover pkg cache/catalogs: {human_size(cache_bytes)}"], + recommendation="Ensure your RUN layer ends with 'pkg clean -ay && rm -rf /var/cache/pkg/* /var/db/pkg/repos/* /var/db/pkg/repo-*.sqlite'.", + ) + ) + + return bloat + + +def analyze_image( + image: str, + threshold_mb: float = 5.0, + *, + skip_container_audit: bool = False, + quiet: bool = False, +) -> AnalysisReport: + """Analyze an image's layer history and in-container filesystem bloat.""" + threshold_bytes = int(threshold_mb * 1024 * 1024) + + # 1. Layer History + try: + raw_history = podman.history(image, quiet=quiet) + except Exception as e: + return AnalysisReport( + image=image, + total_size_bytes=0, + total_size_human="0 B", + error_message=f"Failed to inspect image history: {e}", + rootfs_audit_available=False, + ) + + if not raw_history: + return AnalysisReport( + image=image, + total_size_bytes=0, + total_size_human="0 B", + error_message=f"Image {image!r} not found or has empty history", + rootfs_audit_available=False, + ) + + layers = parse_history_json(raw_history) + total_size = sum(layer.size_bytes for layer in layers) + zombies = detect_zombie_layers(layers) + + # 2. In-Container Rootfs Probe + bloat_items: list[BloatItem] = [] + audit_available = True + + if not skip_container_audit: + try: + probe_out = podman.run_in(image, _PROBE_SCRIPT, quiet=quiet) + bloat_items = parse_probe_output(probe_out, threshold_bytes=threshold_bytes) + except Exception as e: + if not quiet: + log.warn(f"Could not execute in-container audit on {image}: {e}") + audit_available = False + + potential_savings = sum(b.size_bytes for b in bloat_items) + + return AnalysisReport( + image=image, + total_size_bytes=total_size, + total_size_human=human_size(total_size), + layers=layers, + bloat_items=bloat_items, + zombie_layers=zombies, + potential_savings_bytes=potential_savings, + potential_savings_human=human_size(potential_savings), + rootfs_audit_available=audit_available, + ) + + +def print_report(report: AnalysisReport, threshold_mb: float = 5.0) -> None: + """Print an analysis report to terminal.""" + if report.error_message: + log.error(report.error_message) + return + + print() + log.step(f"Image Analysis: {report.image}") + log.info(f"Total Image Size: {report.total_size_human} ({report.total_size_bytes:,} bytes)") + log.info(f"Total Layers: {len(report.layers)}") + + # Zombie layers + if report.zombie_layers: + print() + log.warn(f"Detected {len(report.zombie_layers)} Zombie / Dead Cleanup Layer(s):") + for z in report.zombie_layers: + log.warn(f" • Layer [{z['layer_id']}]: {z['command']}") + log.info(f" ↳ {z['reason']}") + + # Top largest layers + sorted_layers = sorted(report.layers, key=lambda layer: layer.size_bytes, reverse=True) + large_layers = [layer for layer in sorted_layers if layer.size_bytes >= threshold_mb * 1024 * 1024] + + print() + log.step(f"Largest Layers (>= {threshold_mb} MB)") + if not large_layers: + log.info(f" All layers are under {threshold_mb} MB.") + else: + for idx, layer in enumerate(large_layers[:6], 1): + pct = (layer.size_bytes / report.total_size_bytes * 100) if report.total_size_bytes > 0 else 0 + layer_id = layer.id[:12] if len(layer.id) >= 12 else layer.id + cmd = layer.created_by + if len(cmd) > 80: + cmd = cmd[:77] + "..." + log.info(f" {idx}. {layer.size_human:>9} ({pct:4.1f}%) [{layer_id}] {cmd}") + + # Rootfs bloat findings + print() + log.step("Rootfs Bloat & Artifact Audit") + if not report.rootfs_audit_available: + log.warn(" (In-container audit skipped or unavailable; layer analysis only)") + elif not report.bloat_items: + log.success(" No toolchains, static libraries, or uncleaned caches detected!") + else: + for b in report.bloat_items: + pct = (b.size_bytes / report.total_size_bytes * 100) if report.total_size_bytes > 0 else 0 + log.warn(f" • {b.title} — {b.size_human} ({pct:.1f}% of image)") + for d in b.details: + log.info(f" - {d}") + if b.recommendation: + log.success(f" Recommendation: {b.recommendation}") + + print() + log.step("Optimization Summary") + savings_pct = ( + (report.potential_savings_bytes / report.total_size_bytes * 100) + if report.total_size_bytes > 0 + else 0 + ) + log.success( + f"Potential Recoverable Space: {report.potential_savings_human} ({savings_pct:.1f}% reduction)" + ) + print() + + +def run_analyze(cfg: Config, args: argparse.Namespace) -> int: + """CLI entrypoint for 'dbuild analyze'.""" + image = getattr(args, "image", None) + img_name = getattr(cfg, "image", None) or getattr(cfg, "name", None) or "image" + + if not image: + # Default to the first detected/configured variant + variants = getattr(cfg, "variants", []) + variant_filter = getattr(args, "variant", None) + if variant_filter: + want = set(variant_filter.split(",")) + variants = [v for v in variants if v.tag in want] + + if variants: + image = f"localhost/{img_name}:{variants[0].tag}" + if not podman.image_exists(image): + # Try build-{tag} staging name + build_image = f"localhost/{img_name}:build-{variants[0].tag}" + if podman.image_exists(build_image): + image = build_image + else: + image = f"localhost/{img_name}:latest" + if not podman.image_exists(image): + build_image = f"localhost/{img_name}:build-latest" + if podman.image_exists(build_image): + image = build_image + else: + log.error( + f"Image {image!r} not found in local storage. " + "Specify an image to analyze (e.g. 'dbuild analyze '), " + "or run 'dbuild build' first." + ) + return 1 + + threshold_mb = getattr(args, "threshold_mb", 5.0) + skip_container = getattr(args, "skip_container", False) + is_json = bool(getattr(args, "json_output", False)) + report = analyze_image( + image, + threshold_mb=threshold_mb, + skip_container_audit=skip_container, + quiet=is_json, + ) + + if is_json: + print(json.dumps(report.to_dict(), indent=2)) + else: + print_report(report, threshold_mb=threshold_mb) + + return 1 if report.error_message else 0 diff --git a/dbuild/cli.py b/dbuild/cli.py index 92bdb84..b2aaf73 100644 --- a/dbuild/cli.py +++ b/dbuild/cli.py @@ -459,6 +459,46 @@ def _make_parser() -> argparse.ArgumentParser: help="save screenshot to FILE (default: .daemonless/baseline.png)", ) + # -- analyze -- + analyze_parser = sub.add_parser( + "analyze", + aliases=["audit"], + help="analyze image layers and rootfs for bloat and wasted space", + description=( + "Inspect container image layers, detect zombie layers (cleanup commands " + "that don't reclaim space), scan for leftover compilers, development " + "headers, static libraries, and package caches." + ), + ) + analyze_parser.add_argument( + "image", + nargs="?", + metavar="IMAGE", + help="image name or tag to analyze (defaults to current project's built image)", + ) + analyze_parser.add_argument("--variants", "--variant", **variant_kw) + analyze_parser.add_argument("--arch", **arch_kw) + analyze_parser.add_argument( + "--json", + action="store_true", + default=False, + dest="json_output", + help="output analysis as JSON", + ) + analyze_parser.add_argument( + "--threshold-mb", + type=float, + default=5.0, + metavar="MB", + help="flag bloat items exceeding this threshold in MB (default: 5.0)", + ) + analyze_parser.add_argument( + "--skip-container", + action="store_true", + default=False, + help="skip in-container rootfs audit (layer history analysis only)", + ) + # -- ci-prepare -- ci_prepare_parser = sub.add_parser( "ci-prepare", @@ -675,7 +715,9 @@ def _dispatch_prune(cfg: Config, args: argparse.Namespace) -> int: } # Commands that run without loading project config -_NO_CONFIG_COMMANDS: set[str] = {"init", "ci-prepare", "ci-test-env", "lint", "screenshot", "logo", "help"} +_NO_CONFIG_COMMANDS: set[str] = { + "init", "ci-prepare", "ci-test-env", "lint", "screenshot", "logo", "help", "analyze", "audit", +} # ── Entry point ─────────────────────────────────────────────────────── @@ -732,6 +774,17 @@ def main(argv: list[str] | None = None) -> None: elif args.command == "logo": from dbuild import upstream_assets rc = upstream_assets.run_logo(args) + elif args.command in ("analyze", "audit"): + from dbuild import analyze + cfg = None + if not getattr(args, "image", None): + try: + cfg = load_config(Path.cwd()) + except Exception: + cfg = None + if cfg is None: + cfg = Config(image=Path.cwd().name, registry="localhost") + rc = analyze.run_analyze(cfg, args) elif args.command == "help": subparsers = next( action for action in parser._actions diff --git a/dbuild/lint.py b/dbuild/lint.py index 9f1e77c..51b7dbe 100644 --- a/dbuild/lint.py +++ b/dbuild/lint.py @@ -23,6 +23,58 @@ # parsing) don't start their pattern with '_'. _VERSION_STRIP_RE = re.compile(r"s/_[^/]*//") +_STANDALONE_CLEANUP_RE = re.compile( + r"^\s*(?:rm\s+-[a-zA-Z]*r[a-zA-Z]*f\s+/(?:var/cache|var/db/pkg|tmp|usr/local/include)|pkg\s+clean)", + re.IGNORECASE, +) + + +def _lint_containerfile_bloat(cf_path: Path, lines: list[str]) -> list[str]: + """Flag uncleaned pkg caches and standalone cleanup layers in Containerfiles.""" + warnings: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + stripped = line.strip() + if stripped.startswith("RUN ") or stripped == "RUN": + start_line = i + 1 + cmd_parts = [stripped[4:].strip() if stripped.startswith("RUN ") else ""] + curr = lines[i] + while curr.rstrip().endswith("\\") and i + 1 < len(lines): + i += 1 + curr = lines[i] + cmd_parts.append(curr.strip().rstrip("\\")) + full_cmd = " ".join(cmd_parts) + + # Standalone cleanup checks + if ( + _STANDALONE_CLEANUP_RE.search(full_cmd) + and "pkg install" not in full_cmd + and "make" not in full_cmd + and "fetch" not in full_cmd + and "curl" not in full_cmd + ): + warnings.append( + f"{cf_path.name}:{start_line}: Standalone cleanup 'RUN' does not" + " recover disk space from earlier layers due to copy-on-write" + " semantics — combine file deletion into the command that created" + " them using '&&'" + ) + + # Uncleaned pkg install checks + if "pkg install" in full_cmd or "pkg upgrade" in full_cmd: + has_clean = "pkg clean" in full_cmd + has_rm_cache = "/var/cache/pkg" in full_cmd + if not (has_clean and has_rm_cache): + warnings.append( + f"{cf_path.name}:{start_line}: RUN contains 'pkg install'" + " without cleaning package caches in the same layer — add" + " 'pkg clean -ay && rm -rf /var/cache/pkg/* /var/db/pkg/repos/*'" + " to avoid bloating the layer" + ) + i += 1 + return warnings + REQUIRED_X_DAEMONLESS_FIELDS = [ "title", "icon", @@ -370,6 +422,7 @@ def lint_repo(repo_path: Path, verbose: bool = False) -> tuple[list[str], list[s continue if "BASE_VERSION=15.0" in cf_text or "base:15.0" in cf_text: warnings.append(f"{cf_path.name} uses stale FreeBSD 15.0 baseline — update to 15.1") + warnings.extend(_lint_containerfile_bloat(cf_path, cf_text.splitlines())) if verbose: print(" checking for stale generated files (drift check)") diff --git a/dbuild/podman.py b/dbuild/podman.py index d2ff13b..1f6ad70 100644 --- a/dbuild/podman.py +++ b/dbuild/podman.py @@ -141,7 +141,7 @@ def build( return tag -def run_in(image: str, cmd: list[str] | str) -> str: +def run_in(image: str, cmd: list[str] | str, *, quiet: bool = False) -> str: """Run *cmd* inside a disposable container and return stdout. Uses ``--entrypoint=""`` to bypass s6-overlay or other entrypoints @@ -152,7 +152,7 @@ def run_in(image: str, cmd: list[str] | str) -> str: run_cmd += ["sh", "-c", cmd] else: run_cmd += cmd - result = _run(run_cmd) + result = _run(run_cmd, quiet=quiet) return result.stdout.strip() @@ -207,6 +207,12 @@ def image_exists(ref: str) -> bool: return result.returncode == 0 +def history(image: str, *, quiet: bool = False) -> list[dict[str, Any]]: + """Return image layer history as parsed JSON.""" + result = _run(["podman", "history", "--format", "json", image], quiet=quiet) + return json.loads(result.stdout) if result.stdout.strip() else [] + + def run_detached( image: str, *, diff --git a/docs/dbuild.1 b/docs/dbuild.1 index 5702294..afa26bb 100644 --- a/docs/dbuild.1 +++ b/docs/dbuild.1 @@ -1,4 +1,4 @@ -.TH DBUILD 1 "2026-10-03" "dbuild 1.10.1" "User Commands" +.TH DBUILD 1 "2026-10-06" "dbuild 1.10.1" "User Commands" .SH NAME dbuild \- FreeBSD OCI container image build tool .SH SYNOPSIS @@ -8,6 +8,29 @@ dbuild \- FreeBSD OCI container image build tool dbuild provides a streamlined interface for creating and managing OCI-compliant containers that run natively in FreeBSD Jails. It eliminates the friction of configuring ocijail, networking, and jail templates by using a 'Registry-First' approach. Configuration is automatically derived from Git remotes and standard Compose files, allowing for reproducible builds across local and CI environments. .SH COMMANDS .TP +\fBanalyze\fR +Inspect container image layers, detect zombie layers (cleanup commands that don't reclaim space), scan for leftover compilers, development headers, static libraries, and package caches. +.RS +.TP +\fB-h\fR, \fB--help\fR +show this help message and exit +.TP +\fB--variants\fR, \fB--variant\fR +filter to one or more variants by tag (comma-separated, e.g. pkg,pkg-latest) +.TP +\fB--arch\fR +override target architecture (e.g. amd64, aarch64) +.TP +\fB--json\fR +output analysis as JSON +.TP +\fB--threshold-mb\fR +flag bloat items exceeding this threshold in MB (default: 5.0) +.TP +\fB--skip-container\fR +skip in-container rootfs audit (layer history analysis only) +.RE +.TP \fBappjail-bundle\fR Render the AppJail files from compose.yaml metadata into a directory -- the same bundle daemonless.io publishes. Emits nothing for images without appjail enabled (meta.appjail unset). .RS @@ -22,6 +45,29 @@ output directory (default: ./appjail) override the Makejail 'from' image ref (e.g. a local build for testing) .RE .TP +\fBaudit\fR +Inspect container image layers, detect zombie layers (cleanup commands that don't reclaim space), scan for leftover compilers, development headers, static libraries, and package caches. +.RS +.TP +\fB-h\fR, \fB--help\fR +show this help message and exit +.TP +\fB--variants\fR, \fB--variant\fR +filter to one or more variants by tag (comma-separated, e.g. pkg,pkg-latest) +.TP +\fB--arch\fR +override target architecture (e.g. amd64, aarch64) +.TP +\fB--json\fR +output analysis as JSON +.TP +\fB--threshold-mb\fR +flag bloat items exceeding this threshold in MB (default: 5.0) +.TP +\fB--skip-container\fR +skip in-container rootfs audit (layer history analysis only) +.RE +.TP \fBbaseline\fR Start a container, wait for it to be ready, and capture a baseline screenshot. .RS @@ -343,12 +389,12 @@ The primary source of truth for metadata and documentation. \fI.daemonless/config.yaml\fR Project-specific build and test overrides. .TP -\fI/usr/local/etc/daemonless.yaml\fR +\fI$PREFIX/etc/daemonless.yaml\fR Optional host-local defaults, including pkg_cache_url for local pkg-cache injection. .SH GLOBAL CONFIGURATION -The \fI/usr/local/etc/daemonless.yaml\fR file is an optional host-local config file for settings that apply on this builder, not in every image repo. To use a local pkg cache, set \fIpkg_cache_url\fR. dbuild injects that value into builds as \fIPKG_CACHE_URL\fR, and opt-in Containerfiles can use it to point FreeBSD pkg at the local cache during build. If the file or key is missing, nothing is injected. +The \fI$PREFIX/etc/daemonless.yaml\fR file is an optional host-local config file for settings that apply on this builder, not in every image repo. To use a local pkg cache, set \fIpkg_cache_url\fR. dbuild injects that value into builds as \fIPKG_CACHE_URL\fR, and opt-in Containerfiles can use it to point FreeBSD pkg at the local cache during build. If the file or key is missing, nothing is injected. .PP -Example \fI/usr/local/etc/daemonless.yaml\fR: +Example \fI$PREFIX/etc/daemonless.yaml\fR: .IP .nf pkg_cache_url: pkg-cache.example.lan:8080 diff --git a/tests/test_analyze.py b/tests/test_analyze.py new file mode 100644 index 0000000..356aecb --- /dev/null +++ b/tests/test_analyze.py @@ -0,0 +1,268 @@ +"""Tests for dbuild.analyze (image bloat and layer audit).""" + +from __future__ import annotations + +import argparse +import json +import unittest +from unittest.mock import patch + +from dbuild.analyze import ( + AnalysisReport, + BloatItem, + LayerInfo, + analyze_image, + detect_zombie_layers, + human_size, + parse_history_json, + parse_probe_output, + print_report, + run_analyze, +) +from dbuild.config import Config, Variant + + +class TestHumanSize(unittest.TestCase): + """Test byte formatting utility.""" + + def test_bytes(self): + self.assertEqual(human_size(500), "500 B") + + def test_kilobytes(self): + self.assertEqual(human_size(2048), "2.0 KB") + + def test_megabytes(self): + self.assertEqual(human_size(10 * 1024 * 1024), "10.0 MB") + + def test_gigabytes(self): + self.assertEqual(human_size(int(1.5 * 1024 * 1024 * 1024)), "1.5 GB") + + +class TestParseHistoryJson(unittest.TestCase): + """Test parsing podman history JSON.""" + + def test_parse_valid_history(self): + raw = [ + { + "id": "abc1234567890", + "size": 150000000, + "CreatedBy": "/bin/sh -c pkg install -y llvm19", + "comment": "", + }, + { + "id": "", + "size": 0, + "CreatedBy": "/bin/sh -c #(nop) ENV TZ=UTC", + "comment": "", + }, + ] + layers = parse_history_json(raw) + self.assertEqual(len(layers), 2) + self.assertEqual(layers[0].id, "abc1234567890") + self.assertEqual(layers[0].size_bytes, 150000000) + self.assertFalse(layers[0].is_nop) + self.assertTrue(layers[1].is_nop) + + def test_parse_empty_history(self): + self.assertEqual(parse_history_json([]), []) + + +class TestDetectZombieLayers(unittest.TestCase): + """Test identifying standalone cleanup layers that fail to reclaim space.""" + + def test_detects_standalone_rm_and_pkg_clean(self): + layers = [ + LayerInfo( + id="1", + size_bytes=200000000, + size_human="200 MB", + created_by="/bin/sh -c pkg install -y vim", + ), + LayerInfo( + id="2", + size_bytes=0, + size_human="0 B", + created_by="/bin/sh -c rm -rf /var/cache/pkg/*", + ), + LayerInfo( + id="3", + size_bytes=0, + size_human="0 B", + created_by="/bin/sh -c pkg clean -ay", + ), + ] + zombies = detect_zombie_layers(layers) + self.assertEqual(len(zombies), 2) + self.assertEqual(zombies[0]["layer_index"], 1) + self.assertEqual(zombies[1]["layer_index"], 2) + self.assertIn("copy-on-write", zombies[0]["reason"]) + + def test_ignores_combined_run_layers(self): + layers = [ + LayerInfo( + id="1", + size_bytes=200000000, + size_human="200 MB", + created_by="/bin/sh -c pkg install -y vim && pkg clean -ay && rm -rf /var/cache/pkg/*", + ), + ] + zombies = detect_zombie_layers(layers) + self.assertEqual(len(zombies), 0) + + +class TestParseProbeOutput(unittest.TestCase): + """Test parsing in-container probe script output into bloat findings.""" + + def test_parses_toolchains_static_libs_and_headers(self): + probe_data = ( + "===PKGS===\n" + "devel/llvm19\tllvm19\t19.1.7\t750000000\t0\n" + "devel/git\tgit\t2.48.1\t45000000\t1\n" + "sysutils/s6\ts6\t2.14.0.1\t2000000\t0\n" + "===STATIC_LIBS===\n" + "15000\t/usr/local/lib/libcrypto.a\n" + "8000\t/usr/local/lib/libssl.a\n" + "===DIRECTORIES===\n" + "12000\t/usr/local/include\n" + "6000\t/usr/local/share/doc\n" + "4000\t/usr/local/share/man\n" + "500\t/var/cache/pkg\n" + "===REPO_SQLITE===\n" + "200\t/var/db/pkg/repo-FreeBSD.sqlite\n" + "===END===\n" + ) + items = parse_probe_output(probe_data, threshold_bytes=1024 * 1024) + categories = {item.category for item in items} + + self.assertIn("toolchain", categories) + self.assertIn("static_libs", categories) + self.assertIn("headers", categories) + self.assertIn("docs", categories) + self.assertIn("cache", categories) + + # Check toolchain item details + toolchain_item = next(i for i in items if i.category == "toolchain") + self.assertEqual(len(toolchain_item.details), 2) # llvm19 and git + self.assertIn("llvm19", toolchain_item.recommendation) + + # Check static libraries item + static_item = next(i for i in items if i.category == "static_libs") + self.assertIn("libcrypto.a", static_item.details[0]) + self.assertIn("*.a", static_item.recommendation) + + +class TestAnalyzeImage(unittest.TestCase): + """Test analyze_image integration with history and probe execution.""" + + @patch("dbuild.analyze.podman.history") + @patch("dbuild.analyze.podman.run_in") + def test_analyze_image_success(self, mock_run_in, mock_history): + mock_history.return_value = [ + {"id": "layer1", "size": 100000000, "CreatedBy": "RUN pkg install -y app"}, + {"id": "layer2", "size": 0, "CreatedBy": "RUN rm -rf /var/cache/pkg"}, + ] + mock_run_in.return_value = ( + "===PKGS===\n" + "devel/llvm19\tllvm19\t19.1.7\t20000000\t0\n" + "===STATIC_LIBS===\n" + "===DIRECTORIES===\n" + "===REPO_SQLITE===\n" + "===END===\n" + ) + + report = analyze_image("test-image:latest", threshold_mb=1.0) + self.assertEqual(report.image, "test-image:latest") + self.assertEqual(report.total_size_bytes, 100000000) + self.assertEqual(len(report.layers), 2) + self.assertEqual(len(report.zombie_layers), 1) + self.assertEqual(len(report.bloat_items), 1) + self.assertTrue(report.rootfs_audit_available) + self.assertEqual(report.bloat_items[0].category, "toolchain") + + @patch("dbuild.analyze.podman.history") + @patch("dbuild.analyze.podman.run_in") + def test_analyze_image_probe_failure_fallback(self, mock_run_in, mock_history): + mock_history.return_value = [ + {"id": "layer1", "size": 50000000, "CreatedBy": "RUN echo hi"}, + ] + mock_run_in.side_effect = Exception("Container cannot start (exec format error)") + + report = analyze_image("test-image:foreign", threshold_mb=1.0) + self.assertEqual(report.total_size_bytes, 50000000) + self.assertFalse(report.rootfs_audit_available) + self.assertEqual(len(report.bloat_items), 0) + + @patch("dbuild.analyze.podman.history") + def test_analyze_image_history_failure(self, mock_history): + mock_history.side_effect = Exception("Image not found") + report = analyze_image("missing:image") + self.assertIsNotNone(report.error_message) + self.assertIn("Failed to inspect", report.error_message) + + +class TestPrintReport(unittest.TestCase): + """Test formatted terminal output.""" + + def test_print_report_does_not_crash(self): + report = AnalysisReport( + image="localhost/app:latest", + total_size_bytes=500000000, + total_size_human="500.0 MB", + layers=[ + LayerInfo("l1", 300000000, "300.0 MB", "RUN pkg install"), + LayerInfo("l2", 0, "0 B", "RUN rm -rf /var/cache/pkg"), + ], + bloat_items=[ + BloatItem("toolchain", "Compilers", 150000000, "150.0 MB", ["llvm19: 150 MB"], "delete it"), + ], + zombie_layers=[{"layer_id": "l2", "command": "RUN rm", "reason": "COW"}], + potential_savings_bytes=150000000, + potential_savings_human="150.0 MB", + rootfs_audit_available=True, + ) + # Should execute cleanly without error + print_report(report) + + +class TestRunAnalyzeCLI(unittest.TestCase): + """Test CLI handler run_analyze.""" + + @patch("dbuild.analyze.analyze_image") + def test_run_analyze_json_output(self, mock_analyze): + mock_analyze.return_value = AnalysisReport( + image="app:latest", + total_size_bytes=100, + total_size_human="100 B", + ) + cfg = Config(image="app", registry="localhost", variants=[Variant(tag="latest")]) + args = argparse.Namespace( + image="app:latest", + json_output=True, + threshold_mb=5.0, + skip_container=True, + ) + + with patch("builtins.print") as mock_print: + rc = run_analyze(cfg, args) + + self.assertEqual(rc, 0) + mock_print.assert_called_once() + output_str = mock_print.call_args[0][0] + data = json.loads(output_str) + self.assertEqual(data["image"], "app:latest") + + @patch("dbuild.analyze.podman.image_exists", return_value=False) + def test_run_analyze_missing_image_error(self, _mock_exists): + cfg = Config(image="nonexistent", registry="localhost", variants=[]) + args = argparse.Namespace( + image=None, + json_output=False, + threshold_mb=5.0, + skip_container=False, + ) + rc = run_analyze(cfg, args) + self.assertEqual(rc, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lint.py b/tests/test_lint.py index a05558f..43a6aa3 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -318,3 +318,80 @@ def test_stack_needs_no_containerfile_or_user(self): errors, _ = lint_repo(p) self.assertNotIn("Missing Containerfile", errors) self.assertFalse([e for e in errors if "x-daemonless.user" in e], errors) + + +class TestContainerfileBloatLint(unittest.TestCase): + """Tests for Containerfile bloat and cache cleanup lint rules.""" + + def test_uncleaned_pkg_install_warns(self): + import tempfile + from pathlib import Path + + from dbuild.lint import lint_repo + + with tempfile.TemporaryDirectory() as d: + p = Path(d) + (p / "compose.yaml").write_text( + "name: app\nx-daemonless:\n title: App\n icon: ':x:'\n category: Utilities\n" + " description: d\n upstream_url: https://example.com\n user: bsd\n" + "services:\n app:\n image: ghcr.io/daemonless/app:latest\n" + ) + (p / "Containerfile").write_text( + "FROM base:15.1\n" + "RUN pkg update && \\\n" + " pkg install -y vim\n" + ) + _, warnings = lint_repo(p) + self.assertTrue( + any("without cleaning package caches" in w for w in warnings), + f"Expected uncleaned pkg install warning, got {warnings}", + ) + + def test_cleaned_pkg_install_no_warn(self): + import tempfile + from pathlib import Path + + from dbuild.lint import lint_repo + + with tempfile.TemporaryDirectory() as d: + p = Path(d) + (p / "compose.yaml").write_text( + "name: app\nx-daemonless:\n title: App\n icon: ':x:'\n category: Utilities\n" + " description: d\n upstream_url: https://example.com\n user: bsd\n" + "services:\n app:\n image: ghcr.io/daemonless/app:latest\n" + ) + (p / "Containerfile").write_text( + "FROM base:15.1\n" + "RUN pkg update && \\\n" + " pkg install -y vim && \\\n" + " pkg clean -ay && \\\n" + " rm -rf /var/cache/pkg/* /var/db/pkg/repos/*\n" + ) + _, warnings = lint_repo(p) + self.assertFalse( + any("without cleaning package caches" in w for w in warnings), + f"Expected no uncleaned pkg install warning, got {warnings}", + ) + + def test_standalone_cleanup_run_warns(self): + import tempfile + from pathlib import Path + + from dbuild.lint import lint_repo + + with tempfile.TemporaryDirectory() as d: + p = Path(d) + (p / "compose.yaml").write_text( + "name: app\nx-daemonless:\n title: App\n icon: ':x:'\n category: Utilities\n" + " description: d\n upstream_url: https://example.com\n user: bsd\n" + "services:\n app:\n image: ghcr.io/daemonless/app:latest\n" + ) + (p / "Containerfile").write_text( + "FROM base:15.1\n" + "RUN rm -rf /var/cache/pkg/*\n" + ) + _, warnings = lint_repo(p) + self.assertTrue( + any("Standalone cleanup 'RUN'" in w for w in warnings), + f"Expected standalone cleanup warning, got {warnings}", + ) From 2baa80c20d1d61a0e16671773ae5b8f4b79a182c Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Tue, 6 Oct 2026 16:39:03 -0400 Subject: [PATCH 2/4] fix(analyze): expand candidate search to registry and staging tags --- dbuild/analyze.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/dbuild/analyze.py b/dbuild/analyze.py index 5ea3a41..8c9df29 100644 --- a/dbuild/analyze.py +++ b/dbuild/analyze.py @@ -451,26 +451,26 @@ def run_analyze(cfg: Config, args: argparse.Namespace) -> int: want = set(variant_filter.split(",")) variants = [v for v in variants if v.tag in want] - if variants: - image = f"localhost/{img_name}:{variants[0].tag}" - if not podman.image_exists(image): - # Try build-{tag} staging name - build_image = f"localhost/{img_name}:build-{variants[0].tag}" - if podman.image_exists(build_image): - image = build_image + tag = variants[0].tag if variants else "latest" + registry = getattr(cfg, "registry", "localhost") + candidates = [ + f"localhost/{img_name}:{tag}", + f"localhost/{img_name}:build-{tag}", + f"{registry}/{img_name}:{tag}", + f"{registry}/{img_name}:build-{tag}", + f"ghcr.io/daemonless/{img_name}:{tag}", + f"ghcr.io/daemonless/{img_name}:build-{tag}", + ] + found = next((c for c in candidates if podman.image_exists(c)), None) + if found: + image = found else: - image = f"localhost/{img_name}:latest" - if not podman.image_exists(image): - build_image = f"localhost/{img_name}:build-latest" - if podman.image_exists(build_image): - image = build_image - else: - log.error( - f"Image {image!r} not found in local storage. " - "Specify an image to analyze (e.g. 'dbuild analyze '), " - "or run 'dbuild build' first." - ) - return 1 + log.error( + f"No local image found for {img_name} (checked {', '.join(candidates[:3])}). " + "Specify an image to analyze (e.g. 'dbuild analyze '), " + "or run 'dbuild build' first." + ) + return 1 threshold_mb = getattr(args, "threshold_mb", 5.0) skip_container = getattr(args, "skip_container", False) From 512bb0f83a4c93f581557c8f3393e2ec3217259c Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Tue, 6 Oct 2026 17:30:59 -0400 Subject: [PATCH 3/4] feat(analyze): add --pull flag and actionable missing image guidance --- dbuild/analyze.py | 23 ++++++++++++++++++++--- dbuild/cli.py | 6 ++++++ dbuild/podman.py | 5 +++++ docs/dbuild.1 | 6 ++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/dbuild/analyze.py b/dbuild/analyze.py index 8c9df29..63d6420 100644 --- a/dbuild/analyze.py +++ b/dbuild/analyze.py @@ -462,15 +462,32 @@ def run_analyze(cfg: Config, args: argparse.Namespace) -> int: f"ghcr.io/daemonless/{img_name}:build-{tag}", ] found = next((c for c in candidates if podman.image_exists(c)), None) + if not found and getattr(args, "pull", False): + pull_target = f"{registry}/{img_name}:{tag}" + log.info(f"Pulling {pull_target} from registry...") + try: + podman.pull(pull_target) + found = pull_target + except Exception as e: + log.error(f"Failed to pull {pull_target}: {e}") + return 1 + if found: image = found else: log.error( - f"No local image found for {img_name} (checked {', '.join(candidates[:3])}). " - "Specify an image to analyze (e.g. 'dbuild analyze '), " - "or run 'dbuild build' first." + f"No local image found for {img_name} (checked {', '.join(candidates[:3])}).\n" + f" ↳ To pull and analyze from the registry: dbuild analyze --pull\n" + f" ↳ Or build locally first: dbuild build" ) return 1 + elif not podman.image_exists(image) and getattr(args, "pull", False): + log.info(f"Pulling {image} from registry...") + try: + podman.pull(image) + except Exception as e: + log.error(f"Failed to pull {image}: {e}") + return 1 threshold_mb = getattr(args, "threshold_mb", 5.0) skip_container = getattr(args, "skip_container", False) diff --git a/dbuild/cli.py b/dbuild/cli.py index b2aaf73..b747029 100644 --- a/dbuild/cli.py +++ b/dbuild/cli.py @@ -498,6 +498,12 @@ def _make_parser() -> argparse.ArgumentParser: default=False, help="skip in-container rootfs audit (layer history analysis only)", ) + analyze_parser.add_argument( + "--pull", + action="store_true", + default=False, + help="pull the image from registry if not found locally in storage", + ) # -- ci-prepare -- ci_prepare_parser = sub.add_parser( diff --git a/dbuild/podman.py b/dbuild/podman.py index 1f6ad70..cf2f9ea 100644 --- a/dbuild/podman.py +++ b/dbuild/podman.py @@ -179,6 +179,11 @@ def login(host: str, username: str, password: str) -> None: raise PodmanError(cmd, result.returncode, stderr) +def pull(image: str) -> None: + """Pull an image from a registry.""" + _run(["podman", "pull", image]) + + def push(image_ref: str) -> None: """Push an image to a registry.""" _run(["podman", "push", image_ref]) diff --git a/docs/dbuild.1 b/docs/dbuild.1 index afa26bb..f89f795 100644 --- a/docs/dbuild.1 +++ b/docs/dbuild.1 @@ -29,6 +29,9 @@ flag bloat items exceeding this threshold in MB (default: 5.0) .TP \fB--skip-container\fR skip in-container rootfs audit (layer history analysis only) +.TP +\fB--pull\fR +pull the image from registry if not found locally in storage .RE .TP \fBappjail-bundle\fR @@ -66,6 +69,9 @@ flag bloat items exceeding this threshold in MB (default: 5.0) .TP \fB--skip-container\fR skip in-container rootfs audit (layer history analysis only) +.TP +\fB--pull\fR +pull the image from registry if not found locally in storage .RE .TP \fBbaseline\fR From d8649e3e69bec92d39114070a110d344ae8858bf Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Tue, 6 Oct 2026 17:58:48 -0400 Subject: [PATCH 4/4] docs(dbuild.1): use /usr/local/etc path for daemonless.yaml --- docs/dbuild.1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/dbuild.1 b/docs/dbuild.1 index f89f795..6c55d31 100644 --- a/docs/dbuild.1 +++ b/docs/dbuild.1 @@ -395,12 +395,12 @@ The primary source of truth for metadata and documentation. \fI.daemonless/config.yaml\fR Project-specific build and test overrides. .TP -\fI$PREFIX/etc/daemonless.yaml\fR +\fI/usr/local/etc/daemonless.yaml\fR Optional host-local defaults, including pkg_cache_url for local pkg-cache injection. .SH GLOBAL CONFIGURATION -The \fI$PREFIX/etc/daemonless.yaml\fR file is an optional host-local config file for settings that apply on this builder, not in every image repo. To use a local pkg cache, set \fIpkg_cache_url\fR. dbuild injects that value into builds as \fIPKG_CACHE_URL\fR, and opt-in Containerfiles can use it to point FreeBSD pkg at the local cache during build. If the file or key is missing, nothing is injected. +The \fI/usr/local/etc/daemonless.yaml\fR file is an optional host-local config file for settings that apply on this builder, not in every image repo. To use a local pkg cache, set \fIpkg_cache_url\fR. dbuild injects that value into builds as \fIPKG_CACHE_URL\fR, and opt-in Containerfiles can use it to point FreeBSD pkg at the local cache during build. If the file or key is missing, nothing is injected. .PP -Example \fI$PREFIX/etc/daemonless.yaml\fR: +Example \fI/usr/local/etc/daemonless.yaml\fR: .IP .nf pkg_cache_url: pkg-cache.example.lan:8080