diff --git a/core/src/castle_core/relations.py b/core/src/castle_core/relations.py index e557515..91a0941 100644 --- a/core/src/castle_core/relations.py +++ b/core/src/castle_core/relations.py @@ -15,6 +15,7 @@ dependencies — env is generated *from* requirements, not the reverse. from __future__ import annotations import shutil +import subprocess from collections import Counter from dataclasses import dataclass, field from pathlib import Path @@ -134,10 +135,25 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]: return out +def _dpkg_installed(pkg: str) -> bool: + """Whether a dpkg package is installed (the authoritative 'installed' check on + Debian/Ubuntu). Returns False where dpkg isn't available.""" + dpkg = shutil.which("dpkg") + if not dpkg: + return False + r = subprocess.run([dpkg, "-s", pkg], capture_output=True, text=True) + return r.returncode == 0 and "install ok installed" in r.stdout + + def _check(config: CastleConfig, req: Requirement) -> bool: """Is a single requirement satisfied? (The check is fixed by its kind.)""" if req.kind == "system": - return shutil.which(req.ref) is not None + # `system_dependencies` holds PACKAGE names, not executables. A PATH lookup + # only coincides with 'installed' when the package name equals its command + # (pandoc, rsync) — for names like poppler-utils / texlive-latex-base / + # docker-compose-plugin it never matches. Fast-path `which`, then ask the + # package manager (the real meaning of 'installed'). + return shutil.which(req.ref) is not None or _dpkg_installed(req.ref) if req.kind == "deployment": return req.ref in config.deployments return True diff --git a/core/tests/test_relations.py b/core/tests/test_relations.py index 45f1a11..b709a93 100644 --- a/core/tests/test_relations.py +++ b/core/tests/test_relations.py @@ -91,13 +91,27 @@ def test_functional_predicate_reports_unmet(monkeypatch: pytest.MonkeyPatch) -> {"web": prog, "api": ProgramSpec(id="api")}, {"web": _dep("web"), "api": _dep("api")}, ) - monkeypatch.setattr(R.shutil, "which", lambda _: None) # nothing installed + monkeypatch.setattr(R.shutil, "which", lambda _: None) # not on PATH + monkeypatch.setattr(R, "_dpkg_installed", lambda _: False) # nor as a package m = R.build_model(cfg, check=True) web = next(n for n in m.nodes if n.name == "web") assert web.unmet == ["system:pandoc"] # deployment:api exists → satisfied assert web.functional is False +def test_system_requirement_satisfied_by_package_not_on_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A package name that isn't a command (poppler-utils) is satisfied when the + package is installed — a PATH lookup alone would wrongly report it unmet.""" + prog = ProgramSpec(id="t", system_dependencies=["poppler-utils"]) + cfg = _cfg({"t": prog}, {"t": _dep("t")}) + monkeypatch.setattr(R.shutil, "which", lambda _: None) # no `poppler-utils` binary + monkeypatch.setattr(R, "_dpkg_installed", lambda pkg: pkg == "poppler-utils") + t = next(n for n in R.build_model(cfg, check=True).nodes if n.name == "t") + assert t.functional is True and t.unmet == [] + + def test_missing_deployment_requirement_is_unmet() -> None: prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")]) cfg = _cfg({"web": prog}, {"web": _dep("web")})