Skip to content

Commit 52dd7dc

Browse files
committed
test(rqts): Cover remaining RQTS error paths
Bring the rqts package to full statement and branch coverage by exercising the paths the existing tests bypassed: - _run_docker reaching subprocess.run with a fixed, non-shell argv - image_present_locally when docker inspect cannot run at all - the Docker daemon ping exiting non-zero, raising, or timing out - RqtsRunner.run aggregating unmet preconditions into one error - build_docker_argv with explicit workdir, artifact and output overrides
1 parent a663063 commit 52dd7dc

4 files changed

Lines changed: 184 additions & 0 deletions

File tree

tests/rqts/test_argv.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,3 +371,33 @@ def test_defaults_use_project_root_and_hyphenated_name():
371371
# No scenario selection: the argv ends at the output directory.
372372
assert "-s" not in argv
373373
assert argv[-2:] == ["-o", CONTAINER_OUTPUT_DIR]
374+
375+
376+
# ===========================================================================
377+
# Explicit overrides of the project-derived defaults
378+
# ===========================================================================
379+
def test_explicit_workdir_artifact_and_output_dir_override_defaults():
380+
"""Supplied workdir/artifact_name/output_dir replace the project defaults.
381+
382+
The defaults are derived from the project (``root`` and
383+
``hypenated_name``); when a caller passes them explicitly, none of the
384+
project-derived values appear in the argv.
385+
"""
386+
project = _make_project("aws-foo-bar", root="/project/root")
387+
388+
argv = build_docker_argv(
389+
"img:ref",
390+
project,
391+
"us-west-2",
392+
workdir="/elsewhere",
393+
artifact_name="custom-artifact.zip",
394+
output_dir="/work/custom-output",
395+
)
396+
397+
assert f"/elsewhere:{CONTAINER_WORKDIR}" in argv
398+
assert f"{CONTAINER_WORKDIR}/custom-artifact.zip" in argv
399+
assert argv[-2:] == ["-o", "/work/custom-output"]
400+
# None of the project-derived defaults leak in.
401+
assert f"/project/root:{CONTAINER_WORKDIR}" not in argv
402+
assert f"{CONTAINER_WORKDIR}/aws-foo-bar.zip" not in argv
403+
assert CONTAINER_OUTPUT_DIR not in argv

tests/rqts/test_image.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,48 @@ def test_image_present_locally_uses_docker_image_inspect(monkeypatch):
286286
absent = _RecordingDocker([1])
287287
monkeypatch.setattr(image_module, "_run_docker", absent)
288288
assert image_module.image_present_locally("ref:tag") is False
289+
290+
291+
# ===========================================================================
292+
# Internal docker seam and inspect failure handling
293+
# ===========================================================================
294+
def test_run_docker_invokes_docker_cli_with_fixed_argv():
295+
"""_run_docker drives the docker CLI with a fixed, non-shell argv.
296+
297+
This is the one place that actually reaches ``subprocess.run``; it is
298+
patched here so no docker process is spawned. A fixed argv list (never a
299+
shell string) is what makes the image reference safe to pass through.
300+
"""
301+
completed = subprocess.CompletedProcess(args=["docker", "info"], returncode=0)
302+
303+
with mock.patch.object(
304+
image_module.subprocess, "run", return_value=completed
305+
) as run:
306+
result = image_module._run_docker( # pylint: disable=protected-access
307+
["image", "inspect", "ref:tag"], timeout=7
308+
)
309+
310+
assert result is completed
311+
run.assert_called_once()
312+
assert run.call_args[0][0] == ["docker", "image", "inspect", "ref:tag"]
313+
assert run.call_args[1]["check"] is False
314+
assert run.call_args[1]["capture_output"] is True
315+
assert run.call_args[1]["timeout"] == 7
316+
317+
318+
@pytest.mark.parametrize(
319+
"docker_error", [OSError("docker not found"), subprocess.SubprocessError("boom")]
320+
)
321+
def test_image_present_locally_false_when_inspect_cannot_run(monkeypatch, docker_error):
322+
"""An inspect that cannot even run is treated as 'not present locally'.
323+
324+
Keeps ``ensure_image``'s fallback decision safe when the docker CLI is
325+
missing or unusable: absent, rather than assumed cached.
326+
"""
327+
328+
def raise_error(_docker_args, timeout=None): # pylint: disable=unused-argument
329+
raise docker_error
330+
331+
monkeypatch.setattr(image_module, "_run_docker", raise_error)
332+
333+
assert image_module.image_present_locally("ref:tag") is False

tests/rqts/test_preconditions.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
"""
1717

1818
import contextlib
19+
import subprocess
1920
from unittest import mock
2021

22+
import pytest
2123
from hypothesis import HealthCheck, given, settings, strategies as st
2224

2325
from rpdk.core.exceptions import CLIMisconfiguredError
@@ -187,3 +189,74 @@ def test_credentials_unavailable_in_isolation(tmp_path):
187189
assert len(failures) == 1
188190
assert MESSAGE_SUBSTRINGS["credentials"] in failures[0]
189191
assert failures
192+
193+
194+
# ---------------------------------------------------------------------------
195+
# Docker daemon ping failure modes (docker CLI present, daemon unreachable).
196+
# ---------------------------------------------------------------------------
197+
198+
199+
@contextlib.contextmanager
200+
def docker_ping_env(work_dir, **run_kwargs):
201+
"""Yield ``(args, project)`` with docker on PATH and ``docker info`` stubbed.
202+
203+
The artifact and credential checks are forced to pass, so any failure the
204+
caller observes comes from the Docker daemon ping alone. ``run_kwargs`` is
205+
forwarded to ``mock.patch`` for ``subprocess.run`` (``return_value`` for an
206+
exit status, ``side_effect`` to raise).
207+
"""
208+
project = FakeProject(work_dir)
209+
(work_dir / f"{project.hypenated_name}.zip").write_bytes(b"zip")
210+
211+
with contextlib.ExitStack() as stack:
212+
stack.enter_context(
213+
mock.patch(
214+
f"{PRECONDITIONS_MODULE}.shutil.which", return_value="/usr/bin/docker"
215+
)
216+
)
217+
stack.enter_context(
218+
mock.patch(f"{PRECONDITIONS_MODULE}.subprocess.run", **run_kwargs)
219+
)
220+
stack.enter_context(
221+
mock.patch(
222+
f"{PRECONDITIONS_MODULE}.create_sdk_session", return_value=mock.Mock()
223+
)
224+
)
225+
yield _make_args(), project
226+
227+
228+
def test_docker_daemon_ping_nonzero_exit_reports_unreachable(tmp_path):
229+
"""docker CLI present but ``docker info`` exits non-zero -> unreachable daemon.
230+
231+
Distinct from the missing-CLI case: the binary exists, so the ping itself is
232+
what fails.
233+
234+
Validates: Requirements 3.2
235+
"""
236+
with docker_ping_env(tmp_path, return_value=mock.Mock(returncode=1)) as (
237+
args,
238+
project,
239+
):
240+
failures = check_preconditions(args, project)
241+
242+
assert len(failures) == 1
243+
assert MESSAGE_SUBSTRINGS["docker"] in failures[0]
244+
245+
246+
@pytest.mark.parametrize(
247+
"ping_error",
248+
[OSError("cannot exec"), subprocess.TimeoutExpired(cmd="docker info", timeout=10)],
249+
)
250+
def test_docker_daemon_ping_error_reports_unreachable(tmp_path, ping_error):
251+
"""A ping that raises (spawn failure or timeout) -> unreachable daemon.
252+
253+
The check converts the exception into a message rather than propagating it,
254+
so a hung or broken daemon still aggregates with other failures.
255+
256+
Validates: Requirements 3.2
257+
"""
258+
with docker_ping_env(tmp_path, side_effect=ping_error) as (args, project):
259+
failures = check_preconditions(args, project)
260+
261+
assert len(failures) == 1
262+
assert MESSAGE_SUBSTRINGS["docker"] in failures[0]

tests/rqts/test_runner.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,3 +369,39 @@ def test_run_logs_full_docker_command_at_debug_on_happy_path(tmp_path, caplog):
369369
assert any(joined_command in message for message in debug_records)
370370
# The pass summary is surfaced (Req 6.1).
371371
assert PASS_SUMMARY in caplog.text
372+
373+
374+
# ===========================================================================
375+
# Precondition enforcement inside the pipeline
376+
# ===========================================================================
377+
def test_run_unmet_preconditions_aggregated_and_halts(tmp_path):
378+
"""Unmet preconditions -> a single error naming every failure; nothing runs.
379+
380+
The runner turns the aggregated list from ``check_preconditions`` into one
381+
``SysExitRecommendedError`` instead of failing on the first problem, and
382+
halts before the image is ensured or any container is started.
383+
384+
Validates: Requirements 3.1, 3.5
385+
"""
386+
project = _make_resource_project(str(tmp_path))
387+
rqts_runner = RqtsRunner(_make_args(), project)
388+
failures = [
389+
"Docker is required and must be running: the Docker daemon could not "
390+
"be reached.",
391+
"artifact package 'aws-foo-bar.zip' not found; build the project first.",
392+
]
393+
394+
with mock.patch(
395+
f"{RUNNER_MODULE}.check_preconditions", return_value=failures
396+
), mock.patch(f"{RUNNER_MODULE}.ensure_image") as ensure, mock.patch(
397+
f"{RUNNER_MODULE}.run_container"
398+
) as run:
399+
with pytest.raises(SysExitRecommendedError) as excinfo:
400+
rqts_runner.run()
401+
402+
message = str(excinfo.value)
403+
assert "preconditions were not met" in message
404+
for failure in failures:
405+
assert failure in message
406+
ensure.assert_not_called()
407+
run.assert_not_called()

0 commit comments

Comments
 (0)