Skip to content

Commit 267e4be

Browse files
authored
fix(container): parse_container_config accepts unwrapped inner-mapping shape (#113)
Regression in 0.7.2.dev83 (introduced when ``_load_container_config`` was added to support config-only invocation): the CLI now extracts the inner ``containers:`` mapping from argus.yml before passing it to ``ContainerEngine``, but ``parse_container_config`` still required the wrapped form (``config["containers"]["images"]``). The shape mismatch silently dropped every config-defined target — ``argus scan container --config argus.yml`` would enter the lifecycle, then immediately report "No container targets found" despite a valid config. Why the engine and the parser disagreed - ContainerEngine accesses other config keys at the top level (``self.config.get("images")``, ``self.config.get("search_paths")``, ``self.config.get("scanners")``). The CLI returns the unwrapped inner mapping to match those — that's been the contract for everything except this one parser. - ``parse_container_config`` was the only outlier — strict on wrapped shape — so it became the lone failure point on the config-driven path. Fix - Make ``parse_container_config`` accept BOTH shapes: - Wrapped: ``{"containers": {"images": [...], ...}}`` (full argus.yml mapping, the historical contract) - Unwrapped: ``{"images": [...], ...}`` (inner-mapping shape, the CLI's actual hand-off) - Wrapped shape takes precedence when both are present, preserving the historical contract for any direct programmatic callers. Tests (+5) - New ``TestParseContainerConfigUnwrappedShape`` class: - ``test_unwrapped_shape_resolves_explicit_images`` — the user's exact repro (top-level ``containers.images`` with image, dockerfile, context). - ``test_unwrapped_shape_with_discover`` — same path with ``discover: true`` + ``search_paths``. - ``test_wrapped_shape_takes_precedence_when_both_keys_present`` — defensive guard for the historical contract. - ``test_unwrapped_with_digest_pin_preserved`` — round-trip the recommended ``@sha256:...`` form. Validation - Full SDK suite: 1438 passed (+5), 8 skipped. - Manual end-to-end against the user's exact repro shape: now resolves the ``docker:argus-scan`` target with dockerfile and context preserved. Co-authored-by: eFAILution <eFAILution@users.noreply.github.com>
1 parent f4e39fd commit 267e4be

2 files changed

Lines changed: 104 additions & 14 deletions

File tree

argus/container/discovery.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,21 +59,33 @@ def discover_dockerfiles(search_paths: list[str]) -> list[ContainerTarget]:
5959
def parse_container_config(config: dict) -> list[ContainerTarget]:
6060
"""Parse container targets from argus.yml config.
6161
62-
Config format::
63-
64-
containers:
65-
images:
66-
- image: myapp:latest
67-
dockerfile: Dockerfile
68-
- image: worker:latest
69-
dockerfile: docker/Dockerfile.worker
70-
context: .
71-
discover: true
72-
search_paths: [".", "docker/"]
62+
Accepts both shapes:
63+
64+
Wrapped — full ``argus.yml`` mapping with the top-level
65+
``containers:`` key still in place::
66+
67+
{"containers": {"images": [...], "discover": true, ...}}
68+
69+
Unwrapped — just the inner ``containers:`` mapping (the shape the
70+
CLI's ``_load_container_config`` returns after extracting the
71+
section and merging CLI overrides in place)::
72+
73+
{"images": [...], "discover": true, ...}
74+
75+
The engine's other config accessors (``self.config.get("images")``,
76+
``self.config.get("search_paths")``, etc.) operate on the
77+
unwrapped shape — leaving this function strict on the wrapped
78+
shape silently dropped config-driven targets when the CLI handed
79+
in the unwrapped form. Tolerate both so the parser can't be the
80+
only place in the dispatch path that disagrees about config
81+
layout.
7382
"""
74-
containers = config.get("containers", {})
75-
if not isinstance(containers, dict):
76-
return []
83+
nested = config.get("containers")
84+
if isinstance(nested, dict):
85+
containers = nested
86+
else:
87+
# Unwrapped shape — treat the input as already-the-inner mapping.
88+
containers = config if isinstance(config, dict) else {}
7789

7890
targets: list[ContainerTarget] = []
7991

argus/tests/test_container_discovery.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,81 @@ def test_name_derives_from_image_ref(self):
197197
}
198198
targets = parse_container_config(config)
199199
assert targets[0].name == "org-myapp"
200+
201+
202+
class TestParseContainerConfigUnwrappedShape:
203+
"""Regression: ``parse_container_config`` must accept the inner-mapping
204+
shape returned by the CLI's ``_load_container_config``.
205+
206+
The bug: dispatch path for ``argus scan container --config argus.yml``
207+
extracted just the inner ``containers:`` mapping (matching the
208+
rest of the engine's top-level key access — ``images``,
209+
``search_paths``, ``scanners``) and passed that to
210+
``ContainerEngine``. The parser's strict ``config.get("containers")``
211+
lookup found nothing and the scan dropped all config-defined
212+
targets with a "No container targets found" error despite a
213+
well-formed config.
214+
"""
215+
216+
def test_unwrapped_shape_resolves_explicit_images(self):
217+
# The user's exact repro from the bug report — top-level
218+
# ``containers:`` block in argus.yml with one image entry,
219+
# extracted into the inner-mapping shape by the CLI before
220+
# being passed to the engine.
221+
config = {
222+
"images": [
223+
{
224+
"image": "docker:argus-scan",
225+
"dockerfile": "docker/Dockerfile",
226+
"context": ".",
227+
},
228+
],
229+
}
230+
targets = parse_container_config(config)
231+
assert len(targets) == 1
232+
target = targets[0]
233+
assert target.image_ref == "docker:argus-scan"
234+
assert target.dockerfile == Path("docker/Dockerfile")
235+
assert target.context == Path(".")
236+
237+
def test_unwrapped_shape_with_discover(self, tmp_path):
238+
# Verifies the unwrapped path also honors ``discover: true`` +
239+
# ``search_paths``, not just explicit images. Combined with
240+
# the test above this covers both target sources end-to-end.
241+
(tmp_path / "Dockerfile").write_text("FROM scratch\n")
242+
config = {
243+
"discover": True,
244+
"search_paths": [str(tmp_path)],
245+
}
246+
targets = parse_container_config(config)
247+
assert len(targets) == 1
248+
assert targets[0].dockerfile == (tmp_path / "Dockerfile").resolve()
249+
250+
def test_wrapped_shape_takes_precedence_when_both_keys_present(self):
251+
# Defensive: if a config has BOTH a top-level ``containers:``
252+
# mapping AND top-level ``images``/``discover`` keys, prefer
253+
# the wrapped form to match the historical contract. The
254+
# unwrapped fallback only fires when ``containers`` isn't a
255+
# mapping at the top level.
256+
config = {
257+
"containers": {
258+
"images": [{"image": "wrapped:1.0"}],
259+
},
260+
"images": [{"image": "unwrapped:1.0"}],
261+
}
262+
targets = parse_container_config(config)
263+
assert len(targets) == 1
264+
assert targets[0].image_ref == "wrapped:1.0"
265+
266+
def test_unwrapped_with_digest_pin_preserved(self):
267+
# Digest-pinned refs are the recommended form (per
268+
# argus.example.yml). Round-trip through the unwrapped path
269+
# without losing the @sha256: suffix.
270+
ref = (
271+
"ghcr.io/myorg/app:1.0@sha256:"
272+
"f1e2d3c4b5a6f7e8d9c0b1a2c3d4e5f6"
273+
"a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2"
274+
)
275+
targets = parse_container_config({"images": [{"image": ref}]})
276+
assert len(targets) == 1
277+
assert targets[0].image_ref == ref

0 commit comments

Comments
 (0)