fix: system requirement check verifies the package, not a PATH command

`system_dependencies` holds apt PACKAGE names; checking `which <name>` only
coincides with "installed" when the name equals its command (pandoc, rsync). Names
like poppler-utils / texlive-latex-base / docker-compose-plugin have no binary of
that name, so the functional? predicate wrongly reported them unmet. Fall back to
`dpkg -s` (the real "installed" check) after the PATH fast-path.
This commit is contained in:
2026-07-05 15:17:03 -07:00
parent add356dcf2
commit b9d38be707
2 changed files with 32 additions and 2 deletions

View File

@@ -15,6 +15,7 @@ dependencies — env is generated *from* requirements, not the reverse.
from __future__ import annotations from __future__ import annotations
import shutil import shutil
import subprocess
from collections import Counter from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -134,10 +135,25 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
return out 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: def _check(config: CastleConfig, req: Requirement) -> bool:
"""Is a single requirement satisfied? (The check is fixed by its kind.)""" """Is a single requirement satisfied? (The check is fixed by its kind.)"""
if req.kind == "system": 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": if req.kind == "deployment":
return req.ref in config.deployments return req.ref in config.deployments
return True return True

View File

@@ -91,13 +91,27 @@ def test_functional_predicate_reports_unmet(monkeypatch: pytest.MonkeyPatch) ->
{"web": prog, "api": ProgramSpec(id="api")}, {"web": prog, "api": ProgramSpec(id="api")},
{"web": _dep("web"), "api": _dep("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) m = R.build_model(cfg, check=True)
web = next(n for n in m.nodes if n.name == "web") web = next(n for n in m.nodes if n.name == "web")
assert web.unmet == ["system:pandoc"] # deployment:api exists → satisfied assert web.unmet == ["system:pandoc"] # deployment:api exists → satisfied
assert web.functional is False 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: def test_missing_deployment_requirement_is_unmet() -> None:
prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")]) prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")])
cfg = _cfg({"web": prog}, {"web": _dep("web")}) cfg = _cfg({"web": prog}, {"web": _dep("web")})