Skip to content

Commit f4254df

Browse files
committed
feat(linter): make HadolintLinter docker-aware so lint-dockerfile works without local hadolint
The original framing in PR #120 was that ``lint-dockerfile`` requires the user to install hadolint locally OR wait for the FileDiscoveryScanner template. The user pushed back — argus has the official ``hadolint/hadolint:v2.14.0`` Docker image declared on the linter, so the engine should be using it instead of complaining the local binary is missing. Two changes make that work: 1. Engine: in the auto/no-build_args defer path, hand off to ``scanner.scan(path, config)`` unconditionally instead of falling through to the is_available() gate. Scanners without build_args are signaling that they own dispatch internally — including the choice between local execution and the docker fallback. The is_available() gate was preventing scan() from ever being called when the local binary was absent, even though scan() could have handled it. 2. HadolintLinter.scan(): when ``self.is_available()`` returns False, construct a ``docker run`` command against ``self.container_image`` instead of trying to invoke ``hadolint`` directly. Workspace mounts read-only at /workspace; discovered Dockerfile paths get translated to their /workspace/... equivalents. Hadolint accepts multiple file paths in one invocation, so the batched-call shape from the prior commit carries through cleanly. Bug along the way: the hadolint image has empty ENTRYPOINT and ``CMD = ["/bin/hadolint", "-"]``. Passing args at the end of ``docker run`` replaces CMD entirely, so the first arg becomes the command. Include the binary name explicitly as the first arg. Verified end-to-end against this repo's checkout: $ argus scan lint-dockerfile --severity-threshold none INFO DL3018 /workspace/docker/Dockerfile.cli:19 - Pin versions... INFO DL4006 /workspace/docker/Dockerfile.cli:29 - Set the SHELL option... ... 11 findings across 3 Dockerfiles Status: PASS Real lint findings flowing through, no local install required. Doesn't ship a unit test for the docker subprocess path because mocking ``shutil.which("docker")`` plus the ``docker run`` invocation reliably across pytest runs requires more plumbing than the value justifies for a 25-line method that's verified end-to-end above. The test_auto_backend_defers_to_scan_when_no_build_args test from this PR's prior commit covers the engine handoff.
1 parent 46244f0 commit f4254df

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

argus/core/engine.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -992,12 +992,19 @@ def _run_scanner(
992992
f"set backend to 'auto'/'local' to use the "
993993
f"scanner's own scan() method."
994994
)
995+
# auto: the scanner takes ownership of dispatch. It
996+
# likely has a custom flow (file-discovery linters that
997+
# walk the workspace and run their tool per-batch) and
998+
# handles local vs container internally — including the
999+
# docker-run fallback when the local binary is absent.
1000+
# We hand off to scan() unconditionally rather than
1001+
# falling through to the is_available() gate.
9951002
logger.debug(
9961003
"Backend 'auto': scanner '%s' has no build_args/"
997-
"container_args — deferring to scanner.scan() instead "
998-
"of the container path",
1004+
"container_args — handing off to scanner.scan()",
9991005
scanner.name,
10001006
)
1007+
return scanner.scan(path, config)
10011008

10021009
# docker backend requires containers — fail explicitly
10031010
if backend == "docker":

argus/linters/hadolint.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ def scan(self, path: str, config: dict | None = None) -> ScanResult:
2727
file's findings. Doing one batched call beats spawning
2828
``len(dockerfiles)`` subprocesses by N startup costs and keeps
2929
the per-finding ``file`` field intact in the parsed output.
30+
31+
When the local ``hadolint`` binary isn't installed, falls back
32+
to the official ``hadolint/hadolint`` Docker image (declared as
33+
``container_image`` and pulled by argus's standard mechanism).
34+
Hadolint is a single Haskell binary with no Python wrapper on
35+
PyPI — the container is the cleanest way to run it on a
36+
machine that doesn't have the binary, and avoids requiring
37+
contributors to install yet another tool just to lint our
38+
Dockerfiles.
3039
"""
3140
config = config or {}
3241
target = Path(path)
@@ -38,7 +47,24 @@ def scan(self, path: str, config: dict | None = None) -> ScanResult:
3847
metadata={"info": "No Dockerfiles found"},
3948
)
4049

41-
cmd = self._build_command(dockerfiles, config)
50+
if self.is_available():
51+
cmd = self._build_command(dockerfiles, config)
52+
else:
53+
cmd = self._build_docker_command(target, dockerfiles, config)
54+
if cmd is None:
55+
return ScanResult(
56+
scanner=self.name,
57+
metadata={
58+
"execution_failed": True,
59+
"execution_failure_reason": (
60+
"hadolint not installed and Docker not available. "
61+
"Install hadolint from "
62+
"https://github.com/hadolint/hadolint/releases "
63+
"or install Docker to use the container backend."
64+
),
65+
},
66+
)
67+
4268
result = subprocess.run(cmd, capture_output=True, text=True)
4369

4470
# hadolint exits 0 when clean, non-zero when findings exist —
@@ -90,6 +116,47 @@ def _find_dockerfiles(self, target: Path) -> list[Path]:
90116
return [target]
91117
return sorted(target.rglob("Dockerfile*"))
92118

119+
def _build_docker_command(
120+
self, target: Path, dockerfiles: list[Path], config: dict,
121+
) -> list[str] | None:
122+
"""Build a ``docker run hadolint ...`` command — local fallback for the container.
123+
124+
Returns ``None`` when Docker isn't available (the caller turns
125+
that into a clean ``execution_failed`` row with both install
126+
paths in the diagnostic). The mount is read-only; hadolint
127+
only reads the Dockerfile contents and writes JSON to stdout.
128+
"""
129+
if shutil.which("docker") is None:
130+
return None
131+
132+
target_abs = target.resolve()
133+
# Translate discovered host paths to their container-side
134+
# equivalent under /workspace. dockerfiles came back from
135+
# ``rglob`` on ``target`` so they're guaranteed to be under it.
136+
container_paths = [
137+
f"/workspace/{df.resolve().relative_to(target_abs).as_posix()}"
138+
for df in dockerfiles
139+
]
140+
141+
# The hadolint/hadolint image has no ENTRYPOINT — its CMD is
142+
# ``["/bin/hadolint", "-"]`` so passing args at the end of
143+
# ``docker run`` replaces CMD entirely. Include the binary
144+
# name explicitly as the first arg.
145+
cmd = [
146+
"docker", "run", "--rm",
147+
"-v", f"{target_abs}:/workspace:ro",
148+
self.container_image,
149+
"hadolint",
150+
"--format", "json",
151+
]
152+
config_file = config.get("config_file")
153+
if config_file:
154+
cmd.extend(["--config", f"/workspace/{config_file}"])
155+
for rule in config.get("ignore_rules", []) or []:
156+
cmd.extend(["--ignore", rule])
157+
cmd.extend(container_paths)
158+
return cmd
159+
93160
def _build_command(
94161
self, dockerfiles: list[Path], config: dict
95162
) -> list[str]:

0 commit comments

Comments
 (0)