Add coverage gap tests for command functionalities
- Implement tests for disk alert JSON output to include a message field when below threshold. - Add tests for runner status when Gitea is online but no webhook URL is provided. - Introduce tests for job report details to ensure no error line appears when there are no failure events. - Add tests to skip empty JSONL files in job report listing.
This commit is contained in:
@@ -322,3 +322,234 @@ def test_collect_linux_no_files_raises(
|
|||||||
)
|
)
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "no files were transferred" in result.output
|
assert "no files were transferred" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── NEW: coverage gap tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
from ci_orchestrator.transport.errors import TransportError # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_windows_include_logs_fetch_succeeds(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Line 187 (191): log fetch succeeds → file deposited, no error message."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
payload = _zip_payload(tmp_path)
|
||||||
|
|
||||||
|
class _LogFetchOk(_win_transport_with_zip(payload)): # type: ignore[misc]
|
||||||
|
def __init__(self, *a: Any, **kw: Any) -> None:
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
# The log-listing command returns one log file path.
|
||||||
|
self.run_overrides["C:\\CI\\build"] = _FakeResult(
|
||||||
|
stdout="C:\\CI\\build\\build.log\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch(self, remote_path: str, local_path: str) -> None:
|
||||||
|
self.fetched.append((remote_path, str(local_path)))
|
||||||
|
# Write something for the log file fetch too.
|
||||||
|
Path(local_path).write_bytes(payload if local_path.endswith(".zip") else b"log content")
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "WinRmTransport", _LogFetchOk)
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts", "collect",
|
||||||
|
"--ip-address", "10.0.0.7",
|
||||||
|
"--guest-artifact-path", "C:\\CI\\output",
|
||||||
|
"--host-artifact-dir", str(out_dir),
|
||||||
|
"--include-logs",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# fetch was called for both the zip and the log
|
||||||
|
fetched_remotes = [r for r, _ in _FakeTransport.instances[0].fetched]
|
||||||
|
assert "C:\\CI\\build\\build.log" in fetched_remotes
|
||||||
|
assert "fetching log" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_windows_include_logs_fetch_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Lines 192-193: log fetch raises TransportError → error message printed, continues."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
payload = _zip_payload(tmp_path)
|
||||||
|
|
||||||
|
class _LogFetchFail(_win_transport_with_zip(payload)): # type: ignore[misc]
|
||||||
|
def __init__(self, *a: Any, **kw: Any) -> None:
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
self.run_overrides["C:\\CI\\build"] = _FakeResult(
|
||||||
|
stdout="C:\\CI\\build\\fail.log\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch(self, remote_path: str, local_path: str) -> None:
|
||||||
|
if remote_path.endswith(".log"):
|
||||||
|
raise TransportError("log fetch boom")
|
||||||
|
# Normal zip fetch
|
||||||
|
self.fetched.append((remote_path, str(local_path)))
|
||||||
|
Path(local_path).write_bytes(payload)
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "WinRmTransport", _LogFetchFail)
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts", "collect",
|
||||||
|
"--ip-address", "10.0.0.7",
|
||||||
|
"--guest-artifact-path", "C:\\CI\\output",
|
||||||
|
"--host-artifact-dir", str(out_dir),
|
||||||
|
"--include-logs",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Should succeed overall (error logged but not fatal)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "log fetch failed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_windows_include_logs_no_artifacts(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Line 199: include_logs=True, no artifacts extracted → ClickException."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
|
||||||
|
# Transport that returns a real-looking zip but empty after extraction,
|
||||||
|
# and returns log listing.
|
||||||
|
empty_zip = tmp_path / "empty.zip"
|
||||||
|
import zipfile as _zf
|
||||||
|
with _zf.ZipFile(empty_zip, "w"):
|
||||||
|
pass
|
||||||
|
empty_zip_bytes = empty_zip.read_bytes()
|
||||||
|
|
||||||
|
class _NoArtifacts(_FakeTransport):
|
||||||
|
def __init__(self, *a: Any, **kw: Any) -> None:
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
# no log lines returned
|
||||||
|
self.run_overrides["C:\\CI\\build"] = _FakeResult(stdout="")
|
||||||
|
|
||||||
|
def fetch(self, remote_path: str, local_path: str) -> None:
|
||||||
|
self.fetched.append((remote_path, str(local_path)))
|
||||||
|
Path(local_path).write_bytes(empty_zip_bytes)
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "WinRmTransport", _NoArtifacts)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts", "collect",
|
||||||
|
"--ip-address", "10.0.0.7",
|
||||||
|
"--guest-artifact-path", "C:\\CI\\output",
|
||||||
|
"--host-artifact-dir", str(tmp_path / "out"),
|
||||||
|
"--include-logs",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "no artifacts after collect" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_linux_key_from_config(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Line 261: --guest-os linux with no --ssh-key-path but config.ssh_key_path set."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
|
||||||
|
# Build a real tarball for the fake transport to return.
|
||||||
|
payload_dir = tmp_path / "payload"
|
||||||
|
payload_dir.mkdir()
|
||||||
|
(payload_dir / "out.txt").write_text("hi", encoding="utf-8")
|
||||||
|
tar_local = tmp_path / "src.tar.gz"
|
||||||
|
with tarfile.open(tar_local, "w:gz") as tf:
|
||||||
|
for f in payload_dir.iterdir():
|
||||||
|
tf.add(f, arcname=f.name)
|
||||||
|
payload_bytes = tar_local.read_bytes()
|
||||||
|
|
||||||
|
captured_key: list[Any] = []
|
||||||
|
|
||||||
|
class _LinuxCapture(_FakeTransport):
|
||||||
|
def __init__(self, *a: Any, **kw: Any) -> None:
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
captured_key.append(kw.get("key_path"))
|
||||||
|
|
||||||
|
def fetch(self, remote_path: str, local_path: str) -> None:
|
||||||
|
self.fetched.append((remote_path, str(local_path)))
|
||||||
|
Path(local_path).write_bytes(payload_bytes)
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "SshTransport", _LinuxCapture)
|
||||||
|
|
||||||
|
# Patch load_config to return a config that has ssh_key_path set.
|
||||||
|
from ci_orchestrator.config import BackendConfig, Config, Paths
|
||||||
|
|
||||||
|
fake_key = tmp_path / "ci_linux"
|
||||||
|
_test_paths = Paths(
|
||||||
|
root=tmp_path, templates=tmp_path, build_vms=tmp_path,
|
||||||
|
artifacts=tmp_path, keys=tmp_path,
|
||||||
|
)
|
||||||
|
_test_cfg = Config(paths=_test_paths, backend=BackendConfig(), ssh_key_path=fake_key)
|
||||||
|
monkeypatch.setattr(artifacts_module, "load_config", lambda: _test_cfg)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts", "collect",
|
||||||
|
"--ip-address", "10.0.0.5",
|
||||||
|
"--guest-os", "linux",
|
||||||
|
"--guest-artifact-path", "/opt/ci/output",
|
||||||
|
"--host-artifact-dir", str(tmp_path / "out"),
|
||||||
|
# no --ssh-key-path → should come from config
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert captured_key and captured_key[0] == str(fake_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_transport_error_raises_click_exception(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Line 280: TransportError during collect → ClickException."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
|
||||||
|
class _Boom(_FakeTransport):
|
||||||
|
def __enter__(self) -> _Boom:
|
||||||
|
raise TransportError("connect failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "WinRmTransport", _Boom)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts", "collect",
|
||||||
|
"--ip-address", "10.0.0.7",
|
||||||
|
"--guest-artifact-path", "C:\\CI\\output",
|
||||||
|
"--host-artifact-dir", str(tmp_path / "out"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "connect failed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_windows_log_listing_skips_blank_lines(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Line 187 (continue): listing stdout has blank lines → skipped silently."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
payload = _zip_payload(tmp_path)
|
||||||
|
|
||||||
|
class _BlankLines(_win_transport_with_zip(payload)): # type: ignore[misc]
|
||||||
|
def __init__(self, *a: Any, **kw: Any) -> None:
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
# Return listing with leading/trailing blank lines only — no real log paths.
|
||||||
|
self.run_overrides["C:\\CI\\build"] = _FakeResult(stdout="\r\n \r\n")
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "WinRmTransport", _BlankLines)
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts", "collect",
|
||||||
|
"--ip-address", "10.0.0.7",
|
||||||
|
"--guest-artifact-path", "C:\\CI\\output",
|
||||||
|
"--host-artifact-dir", str(out_dir),
|
||||||
|
"--include-logs",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# No "fetching log" output — all listing lines were blank and skipped.
|
||||||
|
assert "fetching log" not in result.output
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ from click.testing import CliRunner
|
|||||||
|
|
||||||
import ci_orchestrator.commands.build as build_module
|
import ci_orchestrator.commands.build as build_module
|
||||||
from ci_orchestrator.__main__ import cli
|
from ci_orchestrator.__main__ import cli
|
||||||
|
from ci_orchestrator.config import BackendConfig, Config, Paths
|
||||||
from ci_orchestrator.credentials import Credential
|
from ci_orchestrator.credentials import Credential
|
||||||
from ci_orchestrator.transport.errors import TransportCommandError
|
from ci_orchestrator.transport.errors import TransportCommandError, TransportError
|
||||||
|
|
||||||
|
|
||||||
class _FakeResult:
|
class _FakeResult:
|
||||||
@@ -370,3 +371,393 @@ def test_build_run_windows_host_source_dir(
|
|||||||
# Staging copies the raw source into the collect dir (no zip-in-zip).
|
# Staging copies the raw source into the collect dir (no zip-in-zip).
|
||||||
assert any("Copy-Item" in c and "'dist'" in c for c in cmds)
|
assert any("Copy-Item" in c and "'dist'" in c for c in cmds)
|
||||||
assert not any("Compress-Archive" in c and "'dist'" in c for c in cmds)
|
assert not any("Compress-Archive" in c and "'dist'" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _parse_extra_env unit tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_extra_env_empty_string_skipped() -> None:
|
||||||
|
"""Line 62: empty strings in the iterable are silently skipped (continue branch)."""
|
||||||
|
result = build_module._parse_extra_env(["", "FOO=bar", ""])
|
||||||
|
assert result == {"FOO": "bar"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_extra_env_no_equals_raises() -> None:
|
||||||
|
"""Line 63-66: value with no '=' must raise BadParameter."""
|
||||||
|
import click
|
||||||
|
|
||||||
|
with pytest.raises(click.BadParameter, match="KEY=VALUE"):
|
||||||
|
build_module._parse_extra_env(["BADVALUE"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_extra_env_empty_key_raises() -> None:
|
||||||
|
"""Line 69: '=value' with empty key must raise BadParameter."""
|
||||||
|
import click
|
||||||
|
|
||||||
|
with pytest.raises(click.BadParameter, match="key cannot be empty"):
|
||||||
|
build_module._parse_extra_env(["=value"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── _zip_dir skips subdirectories ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_dir_skips_subdirectories(tmp_path: Path) -> None:
|
||||||
|
"""Line 80->79: _zip_dir iterates rglob('*'); directories are skipped (is_file() == False)."""
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
(src / "file.txt").write_text("hello", encoding="utf-8")
|
||||||
|
subdir = src / "subdir"
|
||||||
|
subdir.mkdir()
|
||||||
|
(subdir / "nested.txt").write_text("world", encoding="utf-8")
|
||||||
|
|
||||||
|
archive = tmp_path / "out.zip"
|
||||||
|
build_module._zip_dir(src, archive)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
# Only files should appear; the bare subdirectory entry must not be present.
|
||||||
|
assert "file.txt" in names
|
||||||
|
assert str(Path("subdir") / "nested.txt") in names or "subdir/nested.txt" in names
|
||||||
|
# No entry that ends in '/' (a directory entry).
|
||||||
|
assert not any(n.endswith("/") for n in names)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _report_build_output: stdout empty, stderr present ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_build_output_stderr_only(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Lines 108->111, 112-113: when stdout is empty, only stderr branch executes."""
|
||||||
|
result = build_module._report_build_output("", "some error text")
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "some error text" in captured.err
|
||||||
|
assert captured.out.count("[build run]") == 2 # header + footer lines
|
||||||
|
# The tail should reflect the stderr content.
|
||||||
|
assert "some error text" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ── _linux_build: neither host_source_dir nor clone_url ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_build_no_source_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Line 189: _linux_build with no source raises ClickException."""
|
||||||
|
import click
|
||||||
|
|
||||||
|
monkeypatch.setattr(build_module, "SshTransport", _FakeTransport)
|
||||||
|
with pytest.raises(click.ClickException, match="either --host-source-dir or --clone-url"):
|
||||||
|
build_module._linux_build(
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
ssh_user="ci_build",
|
||||||
|
key_path=None,
|
||||||
|
known_hosts=None,
|
||||||
|
workdir="/opt/ci/build",
|
||||||
|
output_dir="/opt/ci/output",
|
||||||
|
host_source_dir=None,
|
||||||
|
clone_url=None,
|
||||||
|
clone_branch="main",
|
||||||
|
clone_commit="",
|
||||||
|
clone_submodules=False,
|
||||||
|
build_command="make",
|
||||||
|
artifact_source="dist",
|
||||||
|
extra_env={},
|
||||||
|
skip_artifact=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _linux_build: clone_url + clone_commit triggers fetch+checkout ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_build_clone_commit_fetch_checkout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Lines 230-233: clone_commit causes 3 subprocess calls (clone, fetch, checkout) via SSH."""
|
||||||
|
monkeypatch.setattr(build_module, "SshTransport", _FakeTransport)
|
||||||
|
build_module._linux_build(
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
ssh_user="ci_build",
|
||||||
|
key_path=None,
|
||||||
|
known_hosts=None,
|
||||||
|
workdir="/opt/ci/build",
|
||||||
|
output_dir="/opt/ci/output",
|
||||||
|
host_source_dir=None,
|
||||||
|
clone_url="http://repo/example.git",
|
||||||
|
clone_branch="main",
|
||||||
|
clone_commit="abc123",
|
||||||
|
clone_submodules=False,
|
||||||
|
build_command="true",
|
||||||
|
artifact_source="dist",
|
||||||
|
extra_env={},
|
||||||
|
skip_artifact=True,
|
||||||
|
)
|
||||||
|
instance = _FakeTransport.instances[0]
|
||||||
|
cmds = [c[0] for c in instance.runs]
|
||||||
|
assert any("git clone" in c for c in cmds)
|
||||||
|
assert any("fetch --depth 1 origin" in c and "abc123" in c for c in cmds)
|
||||||
|
assert any("checkout" in c and "abc123" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _windows_build: clone_url + clone_commit triggers fetch+checkout ───────
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_build_clone_commit_fetch_checkout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Lines 315-321: clone_commit in windows path causes fetch+checkout run."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
build_module._windows_build(
|
||||||
|
ip_address="10.0.0.7",
|
||||||
|
credential_target="BuildVMGuest",
|
||||||
|
workdir="C:\\CI\\build",
|
||||||
|
output_dir="C:\\CI\\output",
|
||||||
|
host_source_dir=None,
|
||||||
|
clone_url="https://example/repo.git",
|
||||||
|
clone_branch="main",
|
||||||
|
clone_commit="deadbeef",
|
||||||
|
clone_submodules=False,
|
||||||
|
build_command="echo done",
|
||||||
|
artifact_source="dist",
|
||||||
|
configuration="Release",
|
||||||
|
extra_env={},
|
||||||
|
skip_artifact=True,
|
||||||
|
use_shared_cache=False,
|
||||||
|
)
|
||||||
|
instance = _FakeTransport.instances[0]
|
||||||
|
cmds = [c[0] for c in instance.runs]
|
||||||
|
assert any("git clone" in c for c in cmds)
|
||||||
|
assert any("fetch --depth 1 origin" in c and "deadbeef" in c for c in cmds)
|
||||||
|
assert any("checkout" in c and "deadbeef" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _windows_build: no source raises ClickException ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_build_no_source_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Line 321: _windows_build with no source raises ClickException."""
|
||||||
|
import click
|
||||||
|
|
||||||
|
_patch(monkeypatch)
|
||||||
|
with pytest.raises(click.ClickException, match="either --host-source-dir or --clone-url"):
|
||||||
|
build_module._windows_build(
|
||||||
|
ip_address="10.0.0.7",
|
||||||
|
credential_target="BuildVMGuest",
|
||||||
|
workdir="C:\\CI\\build",
|
||||||
|
output_dir="C:\\CI\\output",
|
||||||
|
host_source_dir=None,
|
||||||
|
clone_url=None,
|
||||||
|
clone_branch="main",
|
||||||
|
clone_commit="",
|
||||||
|
clone_submodules=False,
|
||||||
|
build_command="echo done",
|
||||||
|
artifact_source="dist",
|
||||||
|
configuration="Release",
|
||||||
|
extra_env={},
|
||||||
|
skip_artifact=False,
|
||||||
|
use_shared_cache=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_build_extra_env_semicolon_appended(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Line 330: when extra_env is non-empty, env_setup gets '; ' appended before build command."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
build_module._windows_build(
|
||||||
|
ip_address="10.0.0.7",
|
||||||
|
credential_target="BuildVMGuest",
|
||||||
|
workdir="C:\\CI\\build",
|
||||||
|
output_dir="C:\\CI\\output",
|
||||||
|
host_source_dir=None,
|
||||||
|
clone_url="https://example/repo.git",
|
||||||
|
clone_branch="main",
|
||||||
|
clone_commit="",
|
||||||
|
clone_submodules=False,
|
||||||
|
build_command="echo done",
|
||||||
|
artifact_source="dist",
|
||||||
|
configuration="Release",
|
||||||
|
extra_env={"MY_VAR": "hello"},
|
||||||
|
skip_artifact=True,
|
||||||
|
use_shared_cache=False,
|
||||||
|
)
|
||||||
|
instance = _FakeTransport.instances[0]
|
||||||
|
cmds = [c[0] for c in instance.runs]
|
||||||
|
# The build command should contain the injected env var followed by '; '
|
||||||
|
assert any("$env:MY_VAR = 'hello'" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_build_build_failure_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Lines 365-367: when the build command returns non-zero, ClickException is raised."""
|
||||||
|
import click
|
||||||
|
|
||||||
|
_patch(monkeypatch)
|
||||||
|
|
||||||
|
class _FailsBuild(_FakeTransport):
|
||||||
|
def run(self, script: str, *, check: bool = True) -> _FakeResult:
|
||||||
|
if "echo fail" in script:
|
||||||
|
return _FakeResult(stdout="", stderr="boom", returncode=1)
|
||||||
|
return super().run(script, check=check)
|
||||||
|
|
||||||
|
monkeypatch.setattr(build_module, "WinRmTransport", _FailsBuild)
|
||||||
|
with pytest.raises(click.ClickException, match="build command failed"):
|
||||||
|
build_module._windows_build(
|
||||||
|
ip_address="10.0.0.7",
|
||||||
|
credential_target="BuildVMGuest",
|
||||||
|
workdir="C:\\CI\\build",
|
||||||
|
output_dir="C:\\CI\\output",
|
||||||
|
host_source_dir=None,
|
||||||
|
clone_url="https://example/repo.git",
|
||||||
|
clone_branch="main",
|
||||||
|
clone_commit="",
|
||||||
|
clone_submodules=False,
|
||||||
|
build_command="echo fail",
|
||||||
|
artifact_source="dist",
|
||||||
|
configuration="Release",
|
||||||
|
extra_env={},
|
||||||
|
skip_artifact=False,
|
||||||
|
use_shared_cache=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _linux_build: artifact_source == output_dir → collecting in place ─────
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_build_artifact_source_equals_output_dir(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Lines 365, 370-371: when artifact_source is the output_dir itself, the
|
||||||
|
'collecting in place' branch runs an existence/non-empty check."""
|
||||||
|
monkeypatch.setattr(build_module, "SshTransport", _FakeTransport)
|
||||||
|
output_dir = "/opt/ci/output"
|
||||||
|
build_module._linux_build(
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
ssh_user="ci_build",
|
||||||
|
key_path=None,
|
||||||
|
known_hosts=None,
|
||||||
|
workdir="/opt/ci/build",
|
||||||
|
output_dir=output_dir,
|
||||||
|
host_source_dir=None,
|
||||||
|
clone_url="https://example/repo.git",
|
||||||
|
clone_branch="main",
|
||||||
|
clone_commit="",
|
||||||
|
clone_submodules=False,
|
||||||
|
build_command="true",
|
||||||
|
# Use the absolute output dir so srcpath == out
|
||||||
|
artifact_source=output_dir,
|
||||||
|
extra_env={},
|
||||||
|
skip_artifact=False,
|
||||||
|
)
|
||||||
|
instance = _FakeTransport.instances[0]
|
||||||
|
cmds = [c[0] for c in instance.runs]
|
||||||
|
# The 'collecting in place' guard command must appear.
|
||||||
|
assert any("if [ ! -d" in c and output_dir in c for c in cmds)
|
||||||
|
# The wipe-and-copy command must NOT appear.
|
||||||
|
assert not any("rm -rf" in c and "mkdir -p" in c and output_dir in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
# ── build run CLI: ssh_key_path from config ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_config(ssh_key_path: Path | None = None) -> Config:
|
||||||
|
paths = Paths(
|
||||||
|
root=Path("/var/lib/ci"),
|
||||||
|
templates=Path("/var/lib/ci/templates"),
|
||||||
|
build_vms=Path("/var/lib/ci/build-vms"),
|
||||||
|
artifacts=Path("/var/lib/ci/artifacts"),
|
||||||
|
keys=Path("/var/lib/ci/keys"),
|
||||||
|
)
|
||||||
|
return Config(paths=paths, backend=BackendConfig(), ssh_key_path=ssh_key_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_linux_ssh_key_from_config(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Line 548: when --ssh-key-path is omitted but config.ssh_key_path is set, it is used."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
key = tmp_path / "ci_linux"
|
||||||
|
monkeypatch.setattr(build_module, "load_config", lambda: _make_config(ssh_key_path=key))
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.5",
|
||||||
|
"--guest-os",
|
||||||
|
"linux",
|
||||||
|
"--clone-url",
|
||||||
|
"https://example/repo.git",
|
||||||
|
"--build-command",
|
||||||
|
"true",
|
||||||
|
"--skip-artifact",
|
||||||
|
# deliberately NO --ssh-key-path
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# The transport must have been instantiated with the config key path.
|
||||||
|
instance = _FakeTransport.instances[0]
|
||||||
|
assert str(key) in (instance.kwargs.get("key_path") or instance.args[1] if len(instance.args) > 1 else "") or instance.kwargs.get("key_path") == str(key)
|
||||||
|
|
||||||
|
|
||||||
|
# ── build run CLI: linux TransportError → ClickException ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_linux_transport_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Line 568: TransportError from _linux_build is caught and re-raised as ClickException."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
monkeypatch.setattr(build_module, "load_config", lambda: _make_config())
|
||||||
|
|
||||||
|
class _RaisesTransport(_FakeTransport):
|
||||||
|
def __enter__(self) -> _RaisesTransport:
|
||||||
|
raise TransportError("connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(build_module, "SshTransport", _RaisesTransport)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.5",
|
||||||
|
"--guest-os",
|
||||||
|
"linux",
|
||||||
|
"--clone-url",
|
||||||
|
"https://example/repo.git",
|
||||||
|
"--build-command",
|
||||||
|
"true",
|
||||||
|
"--ssh-key-path",
|
||||||
|
str(tmp_path / "key"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "connection refused" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── build run CLI: windows TransportError → ClickException ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_windows_transport_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Lines 591-592: TransportError from _windows_build is caught and re-raised as ClickException."""
|
||||||
|
_patch(monkeypatch)
|
||||||
|
monkeypatch.setattr(build_module, "load_config", lambda: _make_config())
|
||||||
|
|
||||||
|
class _RaisesTransport(_FakeTransport):
|
||||||
|
def __enter__(self) -> _RaisesTransport:
|
||||||
|
raise TransportError("winrm unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(build_module, "WinRmTransport", _RaisesTransport)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.7",
|
||||||
|
"--guest-os",
|
||||||
|
"windows",
|
||||||
|
"--clone-url",
|
||||||
|
"https://example/repo.git",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "winrm unreachable" in result.output
|
||||||
|
|||||||
@@ -653,3 +653,934 @@ def test_destroy_clone_releases_pool_ip(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
leases = pool._read_leases()
|
leases = pool._read_leases()
|
||||||
assert leases["10.0.0.1"] is None
|
assert leases["10.0.0.1"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── _probe_transport failure paths ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_transport_windows_transport_error_returns_false(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""WinRM raises TransportError every attempt → deadline passes → False."""
|
||||||
|
from ci_orchestrator.transport.errors import TransportError
|
||||||
|
|
||||||
|
class _FailWinRm:
|
||||||
|
def __enter__(self) -> _FailWinRm:
|
||||||
|
raise TransportError("connection refused")
|
||||||
|
|
||||||
|
def __exit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "WinRmTransport", lambda *a, **k: _FailWinRm())
|
||||||
|
monkeypatch.setattr(job_module.time, "sleep", lambda _s: None)
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def get(self, _t: str) -> Any:
|
||||||
|
from ci_orchestrator.credentials import Credential
|
||||||
|
|
||||||
|
return Credential("ci", "pwd")
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "KeyringCredentialStore", lambda: _Store())
|
||||||
|
|
||||||
|
# Two-tick clock: first call returns time before deadline, second after.
|
||||||
|
seq = iter([0.0, 2.0])
|
||||||
|
monkeypatch.setattr(job_module, "_now", lambda: next(seq))
|
||||||
|
|
||||||
|
result = job_module._probe_transport(
|
||||||
|
guest_os="windows",
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
deadline=1.0,
|
||||||
|
poll=0.001,
|
||||||
|
credential_target="GiteaPAT",
|
||||||
|
ssh_user="ci_build",
|
||||||
|
ssh_key_path=None,
|
||||||
|
ssh_known_hosts=None,
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_transport_linux_transport_error_returns_false(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""SSH raises TransportError every attempt → deadline passes → False."""
|
||||||
|
from ci_orchestrator.transport.errors import TransportError
|
||||||
|
|
||||||
|
class _FailSsh:
|
||||||
|
def __enter__(self) -> _FailSsh:
|
||||||
|
raise TransportError("ssh refused")
|
||||||
|
|
||||||
|
def __exit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "SshTransport", lambda *a, **k: _FailSsh())
|
||||||
|
monkeypatch.setattr(job_module.time, "sleep", lambda _s: None)
|
||||||
|
|
||||||
|
seq = iter([0.0, 2.0])
|
||||||
|
monkeypatch.setattr(job_module, "_now", lambda: next(seq))
|
||||||
|
|
||||||
|
result = job_module._probe_transport(
|
||||||
|
guest_os="linux",
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
deadline=1.0,
|
||||||
|
poll=0.001,
|
||||||
|
credential_target="GiteaPAT",
|
||||||
|
ssh_user="ci_build",
|
||||||
|
ssh_key_path=None,
|
||||||
|
ssh_known_hosts=None,
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── _inject_guestinfo_ip empty netmask / gateway ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_guestinfo_ip_omits_empty_netmask(tmp_path: Path) -> None:
|
||||||
|
"""netmask='' skips the guestinfo.netmask line."""
|
||||||
|
vmx = tmp_path / "test.vmx"
|
||||||
|
vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8")
|
||||||
|
job_module._inject_guestinfo_ip(vmx, "10.0.0.5", "", "10.0.0.1")
|
||||||
|
content = vmx.read_text()
|
||||||
|
assert "guestinfo.netmask" not in content
|
||||||
|
assert 'guestinfo.ip-assignment = "10.0.0.5"' in content
|
||||||
|
assert 'guestinfo.gateway = "10.0.0.1"' in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_guestinfo_ip_omits_both_netmask_and_gateway(tmp_path: Path) -> None:
|
||||||
|
"""netmask='' and gateway='' → only ip-assignment line written."""
|
||||||
|
vmx = tmp_path / "test.vmx"
|
||||||
|
vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8")
|
||||||
|
job_module._inject_guestinfo_ip(vmx, "10.0.0.5", "", "")
|
||||||
|
content = vmx.read_text()
|
||||||
|
assert "guestinfo.netmask" not in content
|
||||||
|
assert "guestinfo.gateway" not in content
|
||||||
|
assert 'guestinfo.ip-assignment = "10.0.0.5"' in content
|
||||||
|
|
||||||
|
|
||||||
|
# ── _redact_url_creds ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_url_creds_masks_credentials() -> None:
|
||||||
|
url = "https://user:secret@gitea.local/org/repo.git"
|
||||||
|
redacted = job_module._redact_url_creds(url)
|
||||||
|
assert "secret" not in redacted
|
||||||
|
assert "***" in redacted
|
||||||
|
assert "gitea.local" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_url_creds_noop_when_no_creds() -> None:
|
||||||
|
url = "https://gitea.local/org/repo.git"
|
||||||
|
assert job_module._redact_url_creds(url) == url
|
||||||
|
|
||||||
|
|
||||||
|
# ── _host_clone ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_clone_success(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""_host_clone returns (tmp_root, src_dir) on subprocess success."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
call_log: list[list[str]] = []
|
||||||
|
|
||||||
|
def _fake_run(cmd: list[str], **kwargs: Any) -> Any:
|
||||||
|
call_log.append(cmd)
|
||||||
|
# Create the src_dir so the function can succeed.
|
||||||
|
if cmd[0] == "git" and cmd[1] == "clone":
|
||||||
|
src = Path(cmd[-1])
|
||||||
|
src.mkdir(parents=True, exist_ok=True)
|
||||||
|
result = subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module.subprocess, "run", _fake_run)
|
||||||
|
|
||||||
|
tmp_root, _src_dir = job_module._host_clone(
|
||||||
|
repo_url="http://gitea.local/org/repo.git",
|
||||||
|
branch="main",
|
||||||
|
commit="",
|
||||||
|
submodules=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert _src_dir.name == "src"
|
||||||
|
assert _src_dir.parent == tmp_root
|
||||||
|
assert "git" in call_log[0]
|
||||||
|
assert "clone" in call_log[0]
|
||||||
|
# No fetch/checkout when commit is empty.
|
||||||
|
assert len(call_log) == 1
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_clone_with_commit(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""_host_clone issues fetch + checkout when commit is non-empty."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
call_log: list[list[str]] = []
|
||||||
|
|
||||||
|
def _fake_run(cmd: list[str], **kwargs: Any) -> Any:
|
||||||
|
call_log.append(cmd)
|
||||||
|
if cmd[0] == "git" and cmd[1] == "clone":
|
||||||
|
src = Path(cmd[-1])
|
||||||
|
src.mkdir(parents=True, exist_ok=True)
|
||||||
|
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module.subprocess, "run", _fake_run)
|
||||||
|
|
||||||
|
tmp_root, _src_dir2 = job_module._host_clone(
|
||||||
|
repo_url="http://gitea.local/org/repo.git",
|
||||||
|
branch="main",
|
||||||
|
commit="abc123",
|
||||||
|
submodules=True,
|
||||||
|
)
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||||
|
# clone + fetch + checkout = 3 calls.
|
||||||
|
assert len(call_log) == 3
|
||||||
|
assert "fetch" in call_log[1]
|
||||||
|
assert "checkout" in call_log[2]
|
||||||
|
# submodules flag included.
|
||||||
|
assert "--recurse-submodules" in call_log[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_clone_called_process_error_raises_click_exception(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""CalledProcessError from git → ClickException with redacted message."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def _fail_run(cmd: list[str], **kwargs: Any) -> Any:
|
||||||
|
raise subprocess.CalledProcessError(
|
||||||
|
1, cmd, output="", stderr="fatal: repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module.subprocess, "run", _fail_run)
|
||||||
|
|
||||||
|
with pytest.raises(click.ClickException) as exc_info:
|
||||||
|
job_module._host_clone(
|
||||||
|
repo_url="http://user:pass@gitea.local/org/repo.git",
|
||||||
|
branch="main",
|
||||||
|
commit="",
|
||||||
|
submodules=False,
|
||||||
|
)
|
||||||
|
assert "host-side git clone failed" in str(exc_info.value)
|
||||||
|
# URL credentials must be redacted.
|
||||||
|
assert "pass" not in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _destroy_clone pool release raises ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_destroy_clone_pool_release_raises_echoes_warning(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""pool.release raising must not propagate — warning echoed instead."""
|
||||||
|
from ci_orchestrator.ip_pool import IpSlotPool
|
||||||
|
|
||||||
|
class _BrokenPool(IpSlotPool):
|
||||||
|
def release(self, ip: str) -> None:
|
||||||
|
raise RuntimeError("lock file busy")
|
||||||
|
|
||||||
|
lease = tmp_path / "pool.json"
|
||||||
|
pool = _BrokenPool(["10.0.0.1"], "255.255.255.0", "", lease)
|
||||||
|
pool.acquire("owner") # mark as leased
|
||||||
|
|
||||||
|
# Must not raise.
|
||||||
|
job_module._destroy_clone(None, None, None, pool=pool, pool_ip="10.0.0.1")
|
||||||
|
|
||||||
|
# Warning should have been echoed to stderr.
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "release failed" in captured.err or "release" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: BackendNotAvailable → ClickException ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_backend_not_available(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""load_backend raising BackendNotAvailable → ClickException with message."""
|
||||||
|
from ci_orchestrator.backends.errors import BackendNotAvailable
|
||||||
|
|
||||||
|
backend = _FakeBackend()
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
# Override load_backend AFTER _patch_common so it takes precedence.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
job_module,
|
||||||
|
"load_backend",
|
||||||
|
lambda _cfg: (_ for _ in ()).throw(BackendNotAvailable("vmrun not found")),
|
||||||
|
)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "backend unavailable" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: clone VMX missing after successful clone ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBackendNoVmx(_FakeBackend):
|
||||||
|
"""clone_linked succeeds but never writes the VMX file."""
|
||||||
|
|
||||||
|
def clone_linked(
|
||||||
|
self,
|
||||||
|
template: str,
|
||||||
|
snapshot: str,
|
||||||
|
name: str,
|
||||||
|
destination: str | None = None,
|
||||||
|
) -> VmHandle:
|
||||||
|
self.calls.append("clone")
|
||||||
|
assert destination is not None
|
||||||
|
# Intentionally do NOT write any file.
|
||||||
|
return VmHandle(identifier=destination or "", name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_clone_vmx_missing_after_success(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""backend.clone_linked returns but VMX is absent → ClickException."""
|
||||||
|
backend = _FakeBackendNoVmx()
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "backend reported success but clone VMX is missing" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: --vmrun-path override ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_vmrun_path_creates_workstation_backend(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""--vmrun-path bypasses load_backend and uses WorkstationVmrunBackend."""
|
||||||
|
import ci_orchestrator.backends.workstation as _ws_mod
|
||||||
|
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
captured_vmrun_paths: list[str] = []
|
||||||
|
|
||||||
|
class _FakeWorkstation(_FakeBackend):
|
||||||
|
def __init__(self, vmrun_path: str) -> None:
|
||||||
|
super().__init__(running_after=0)
|
||||||
|
captured_vmrun_paths.append(vmrun_path)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_ws_mod, "WorkstationVmrunBackend", _FakeWorkstation)
|
||||||
|
|
||||||
|
fake_vmrun = tmp_path / "vmrun.exe"
|
||||||
|
fake_vmrun.write_text("fake", encoding="utf-8")
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(
|
||||||
|
template_vmx,
|
||||||
|
clone_base=tmp_path / "vms",
|
||||||
|
artifact_base=tmp_path / "art",
|
||||||
|
extra=["--vmrun-path", str(fake_vmrun)],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert captured_vmrun_paths == [str(fake_vmrun)]
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: --guest-cpu / --guest-memory-mb override on Linux branch ────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_linux_vmx_override_cpu_memory(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""--guest-cpu and --guest-memory-mb are applied to the Linux clone VMX."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
# We capture the clone VMX path so we can check it was modified.
|
||||||
|
written_vmx: list[Path] = []
|
||||||
|
real_apply = job_module._apply_vmx_overrides
|
||||||
|
|
||||||
|
def _spy(vmx_path: Path, cpu: int, memory_mb: int) -> None:
|
||||||
|
written_vmx.append(vmx_path)
|
||||||
|
real_apply(vmx_path, cpu, memory_mb)
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "_apply_vmx_overrides", _spy)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(
|
||||||
|
template_vmx,
|
||||||
|
clone_base=tmp_path / "vms",
|
||||||
|
artifact_base=tmp_path / "art",
|
||||||
|
extra=["--guest-cpu", "4", "--guest-memory-mb", "2048"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert len(written_vmx) == 1, "_apply_vmx_overrides should have been called once"
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: _wait_for_ip returns None → timeout error ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_wait_for_ip_timeout(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""When _wait_for_ip returns None the job raises 'timeout waiting for guest IP'."""
|
||||||
|
backend = _FakeBackend(running_after=0, ip=None)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "timeout waiting for guest IP" in result.output
|
||||||
|
assert "delete" in backend.calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: transport probe times out ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_transport_probe_timeout(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""_probe_transport returning False → 'timeout waiting for guest transport'."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
_patch_common(monkeypatch, backend, transport_ready=False)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "timeout waiting for guest transport" in result.output
|
||||||
|
assert "delete" in backend.calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: --host-clone flag exercises _host_clone ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_host_clone_flag(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""--host-clone invokes _host_clone and passes host_source_dir to build."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
calls = _patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
src_root = tmp_path / "hostclone"
|
||||||
|
src_dir = src_root / "src"
|
||||||
|
src_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
def _fake_host_clone(
|
||||||
|
repo_url: str, branch: str, commit: str, submodules: bool
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
return src_root, src_dir
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "_host_clone", _fake_host_clone)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(
|
||||||
|
template_vmx,
|
||||||
|
clone_base=tmp_path / "vms",
|
||||||
|
artifact_base=tmp_path / "art",
|
||||||
|
extra=["--host-clone"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert calls["linux_build"][0]["host_source_dir"] == str(src_dir)
|
||||||
|
assert calls["linux_build"][0]["clone_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: ip_pool configured but guest_os is linux → pool skipped ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_ip_pool_skipped_for_linux(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""ip_pool configured but Linux guest → pool not used (line 523 guard)."""
|
||||||
|
from ci_orchestrator.config import Config, IpPoolConfig
|
||||||
|
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
calls = _patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
pool_cfg = IpPoolConfig(
|
||||||
|
addresses=["192.168.1.100"],
|
||||||
|
netmask="255.255.255.0",
|
||||||
|
gateway="192.168.1.1",
|
||||||
|
lease_file=tmp_path / "ip-pool.json",
|
||||||
|
)
|
||||||
|
real_cfg = job_module.load_config()
|
||||||
|
patched_cfg = Config(
|
||||||
|
paths=real_cfg.paths,
|
||||||
|
backend=real_cfg.backend,
|
||||||
|
vmrun_path=real_cfg.vmrun_path,
|
||||||
|
ssh_key_path=real_cfg.ssh_key_path,
|
||||||
|
guest_cred_target=real_cfg.guest_cred_target,
|
||||||
|
ip_pool=pool_cfg,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(job_module, "load_config", lambda: patched_cfg)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# Pool was not used → backend.get_ip was called normally.
|
||||||
|
assert calls["linux_build"], "linux_build should have been invoked"
|
||||||
|
assert "pre-assigned" not in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: KeyboardInterrupt → sys.exit(130) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_keyboard_interrupt_triggers_cleanup(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""KeyboardInterrupt during probe → _destroy_clone called, sys.exit(130)."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
def _raise_kbi(**kwargs: Any) -> bool:
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "_probe_transport", _raise_kbi)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 130
|
||||||
|
assert "delete" in backend.calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: ClickException → _destroy_clone called ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_click_exception_triggers_cleanup(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""Any ClickException inside try → cleanup in except branch (line 557)."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
def _raise_click(**kwargs: Any) -> bool:
|
||||||
|
raise click.ClickException("forced error")
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "_probe_transport", _raise_click)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "forced error" in result.output
|
||||||
|
assert "delete" in backend.calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: generic Exception → wrapped as ClickException ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_generic_exception_wrapped(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""Unhandled Exception inside try → ClickException (line 568)."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
def _boom(**kwargs: Any) -> bool:
|
||||||
|
raise RuntimeError("unexpected boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "_probe_transport", _boom)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "unexpected boom" in result.output
|
||||||
|
assert "delete" in backend.calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: _write_manifest raises OSError → warning, job continues ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_manifest_oserror_warning(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""OSError from _write_manifest → warning echoed, job still exits 0."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
def _bad_manifest(host_dir: Path, job_id: str, commit: str) -> int:
|
||||||
|
raise OSError("disk full")
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "_write_manifest", _bad_manifest)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "manifest write failed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: HTTP URL + Gitea credential → authed URL built (lines 668-669) ──────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_gitea_credential_injected_into_url(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""Gitea credential from keyring is embedded in the clone URL."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
calls = _patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
# _patch_common already monkeypatches KeyringCredentialStore to return
|
||||||
|
# Credential("ci", "pwd"). Verify the authed URL reaches _linux_build.
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
clone_url = calls["linux_build"][0]["clone_url"]
|
||||||
|
assert clone_url is not None
|
||||||
|
assert "ci:pwd@" in clone_url
|
||||||
|
|
||||||
|
|
||||||
|
# ── _probe_transport: is_ready() returns False (no exception) ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_transport_winrm_not_ready_then_timeout(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""WinRM transport connects but is_ready()=False → deadline → returns False."""
|
||||||
|
|
||||||
|
class _NotReadyWinRm:
|
||||||
|
def __enter__(self) -> _NotReadyWinRm:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "WinRmTransport", lambda *a, **k: _NotReadyWinRm())
|
||||||
|
monkeypatch.setattr(job_module.time, "sleep", lambda _s: None)
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def get(self, _t: str) -> Any:
|
||||||
|
from ci_orchestrator.credentials import Credential
|
||||||
|
|
||||||
|
return Credential("ci", "pwd")
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "KeyringCredentialStore", lambda: _Store())
|
||||||
|
|
||||||
|
# Two-tick clock: first iteration is before deadline, second is after.
|
||||||
|
seq = iter([0.0, 2.0])
|
||||||
|
monkeypatch.setattr(job_module, "_now", lambda: next(seq))
|
||||||
|
|
||||||
|
result = job_module._probe_transport(
|
||||||
|
guest_os="windows",
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
deadline=1.0,
|
||||||
|
poll=0.001,
|
||||||
|
credential_target="GiteaPAT",
|
||||||
|
ssh_user="ci_build",
|
||||||
|
ssh_key_path=None,
|
||||||
|
ssh_known_hosts=None,
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── _destroy_clone: backend.delete raises BackendError ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_destroy_clone_delete_backend_error_echoes_warning(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""BackendError from backend.delete → warning echoed, no exception raised."""
|
||||||
|
backend = _FakeBackend(delete_fail=True)
|
||||||
|
handle = VmHandle(identifier="/tmp/fake.vmx", name="fake")
|
||||||
|
|
||||||
|
# Must not raise.
|
||||||
|
job_module._destroy_clone(backend, handle, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "deleteVM failed" in captured.err or "deleteVM" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
# ── _destroy_clone: _try_remove_dir returns False (partial removal) ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_destroy_clone_try_remove_dir_fails(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""_try_remove_dir returning False → warning echoed."""
|
||||||
|
monkeypatch.setattr(job_module, "_try_remove_dir", lambda _p: False)
|
||||||
|
|
||||||
|
clone_dir = tmp_path / "clone"
|
||||||
|
clone_dir.mkdir()
|
||||||
|
|
||||||
|
job_module._destroy_clone(None, None, clone_dir)
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "could not fully remove" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: explicit --guest-os linux skips auto-detection (line 523) ───────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_explicit_guest_os_linux(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""--guest-os linux takes the else branch (no auto-detect) and lowercases."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
calls = _patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(
|
||||||
|
template_vmx,
|
||||||
|
clone_base=tmp_path / "vms",
|
||||||
|
artifact_base=tmp_path / "art",
|
||||||
|
extra=["--guest-os", "linux"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# auto-detect message must NOT appear.
|
||||||
|
assert "auto-detected" not in result.output
|
||||||
|
assert calls["linux_build"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: backend.start raises BackendError (lines 545-546) ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_start_backend_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""BackendError from backend.start → ClickException 'vmrun start failed'."""
|
||||||
|
backend = _FakeBackend(start_fail=True)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "vmrun start failed" in result.output
|
||||||
|
assert "delete" in backend.calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: _wait_running returns False → timeout (line 550) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_wait_running_timeout(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""_wait_running returning False → ClickException 'timeout waiting for VM to be running'."""
|
||||||
|
backend = _FakeBackend(running_after=999) # is_running always False
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
# Make _wait_running always return False immediately.
|
||||||
|
monkeypatch.setattr(job_module, "_wait_running", lambda *a, **k: False)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "timeout waiting for VM to be running" in result.output
|
||||||
|
assert "delete" in backend.calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: ssh_key_path from config used when not specified on CLI ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_ssh_key_from_config(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""Config.ssh_key_path is used as key_path when --ssh-key-path is not given (line 568)."""
|
||||||
|
from ci_orchestrator.config import Config
|
||||||
|
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
ssh_key = tmp_path / "id_rsa"
|
||||||
|
ssh_key.write_text("fake-key", encoding="utf-8")
|
||||||
|
|
||||||
|
real_cfg = job_module.load_config()
|
||||||
|
patched_cfg = Config(
|
||||||
|
paths=real_cfg.paths,
|
||||||
|
backend=real_cfg.backend,
|
||||||
|
vmrun_path=real_cfg.vmrun_path,
|
||||||
|
ssh_key_path=ssh_key,
|
||||||
|
guest_cred_target=real_cfg.guest_cred_target,
|
||||||
|
ip_pool=real_cfg.ip_pool,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(job_module, "load_config", lambda: patched_cfg)
|
||||||
|
|
||||||
|
probe_kwargs: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _spy_probe(**kwargs: Any) -> bool:
|
||||||
|
probe_kwargs.append(kwargs)
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "_probe_transport", _spy_probe)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert probe_kwargs[0]["ssh_key_path"] == str(ssh_key)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: Gitea credential get raises → URL used unauthenticated (line 435-436) ─
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_gitea_credential_raises_uses_unauthenticated(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""KeyringCredentialStore.get raising → except pass → unauthenticated URL."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
calls = _patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
class _FailStore:
|
||||||
|
def get(self, _t: str) -> Any:
|
||||||
|
raise RuntimeError("keyring unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(job_module, "KeyringCredentialStore", lambda: _FailStore())
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# URL falls back to unauthenticated — no user:pwd@ prefix.
|
||||||
|
clone_url = calls["linux_build"][0]["clone_url"]
|
||||||
|
assert clone_url == "http://gitea.local/org/repo.git"
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: non-HTTP repo URL skips credential injection (line 425->438) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_non_http_repo_url_skips_credential_injection(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""git:// URL skips the credential-injection block entirely."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
calls = _patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
non_http_url = "git://gitea.local/org/repo.git"
|
||||||
|
|
||||||
|
args = [
|
||||||
|
"job",
|
||||||
|
"--job-id", "ci-test-git",
|
||||||
|
"--repo-url", non_http_url,
|
||||||
|
"--branch", "main",
|
||||||
|
"--template-path", str(template_vmx),
|
||||||
|
"--clone-base-dir", str(tmp_path / "vms"),
|
||||||
|
"--artifact-base-dir", str(tmp_path / "art"),
|
||||||
|
"--ready-timeout", "30",
|
||||||
|
"--poll-interval", "1",
|
||||||
|
]
|
||||||
|
result = CliRunner().invoke(cli, args)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# Clone URL must be the original, unmodified URL.
|
||||||
|
clone_url = calls["linux_build"][0]["clone_url"]
|
||||||
|
assert clone_url == non_http_url
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: missing --template-path → ClickException (line 440) ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_missing_template_path_raises(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Omitting --template-path raises 'template-path is required'."""
|
||||||
|
backend = _FakeBackend()
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
args = [
|
||||||
|
"job",
|
||||||
|
"--job-id", "ci-test-ntp",
|
||||||
|
"--repo-url", "http://gitea.local/org/repo.git",
|
||||||
|
"--branch", "main",
|
||||||
|
"--clone-base-dir", str(tmp_path / "vms"),
|
||||||
|
"--artifact-base-dir", str(tmp_path / "art"),
|
||||||
|
"--ready-timeout", "30",
|
||||||
|
"--poll-interval", "1",
|
||||||
|
]
|
||||||
|
result = CliRunner().invoke(cli, args)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "--template-path is required" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: --commit echoes commit line (line 488) ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_commit_echoed_in_header(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""When --commit is given the header echoes it (covers the if commit: branch)."""
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(
|
||||||
|
template_vmx,
|
||||||
|
clone_base=tmp_path / "vms",
|
||||||
|
artifact_base=tmp_path / "art",
|
||||||
|
extra=["--commit", "deadbeef"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "deadbeef" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI: SIGTERM handler fires KeyboardInterrupt (line 479) ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_sigterm_triggers_keyboard_interrupt(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
|
||||||
|
) -> None:
|
||||||
|
"""SIGTERM handler raises KeyboardInterrupt — invoke it directly to cover line 479."""
|
||||||
|
import signal as _signal
|
||||||
|
|
||||||
|
backend = _FakeBackend(running_after=0)
|
||||||
|
_patch_common(monkeypatch, backend)
|
||||||
|
|
||||||
|
registered_handler: list[Any] = []
|
||||||
|
|
||||||
|
real_signal = _signal.signal
|
||||||
|
|
||||||
|
def _capture_signal(sig: int, handler: Any) -> Any:
|
||||||
|
if sig == _signal.SIGTERM:
|
||||||
|
registered_handler.append(handler)
|
||||||
|
return real_signal(sig, handler)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_signal, "signal", _capture_signal)
|
||||||
|
|
||||||
|
# Also make probe raise so the job exits early (keeping test fast).
|
||||||
|
monkeypatch.setattr(job_module, "_probe_transport", lambda **k: True)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
_common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
|
||||||
|
# Now directly invoke the SIGTERM handler to cover line 479.
|
||||||
|
assert registered_handler, "signal.signal(SIGTERM, ...) should have been called"
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
registered_handler[-1](_signal.SIGTERM, None)
|
||||||
|
|||||||
@@ -659,3 +659,49 @@ def test_runner_pruned_old_entries(
|
|||||||
cli, ["monitor", "runner", "--state-dir", str(tmp_path)]
|
cli, ["monitor", "runner", "--state-dir", str(tmp_path)]
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── NEW: coverage gap tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_alert_json_includes_message_field(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Lines 186-187: disk below threshold + --json → JSON has 'message' key, exit 1."""
|
||||||
|
monkeypatch.setattr(mon.shutil, "disk_usage", lambda _p: _fake_usage(free_gb=5))
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli, ["monitor", "disk", "--min-free-gb", "50", "--json"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
payload = json.loads(result.output.strip())
|
||||||
|
assert payload["status"] == "alert"
|
||||||
|
assert "message" in payload
|
||||||
|
assert "below" in payload["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_running_gitea_online_no_webhook(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Lines 398->402: online==0 but no webhook_url → branch skips _post_webhook,
|
||||||
|
continues to return (line 402). Covers the 398->402 branch (webhook_url falsy)."""
|
||||||
|
monkeypatch.setattr(mon, "_service_status", lambda _s: "Running")
|
||||||
|
monkeypatch.setattr(mon, "_gitea_runners_online", lambda *_a, **_kw: 0)
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def get(self, _target: str) -> Any:
|
||||||
|
from ci_orchestrator.credentials import Credential
|
||||||
|
return Credential("u", "tok")
|
||||||
|
|
||||||
|
import ci_orchestrator.credentials as creds
|
||||||
|
monkeypatch.setattr(creds, "KeyringCredentialStore", _Store)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"monitor", "runner",
|
||||||
|
"--state-dir", str(tmp_path),
|
||||||
|
"--gitea-url", "https://g/",
|
||||||
|
"--gitea-credential-target", "tgt",
|
||||||
|
# no --webhook-url → webhook_url is ""
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "0 online runners" in result.output
|
||||||
|
|||||||
@@ -210,3 +210,37 @@ def test_report_job_failed_filter_no_results(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "No matching jobs" in result.output
|
assert "No matching jobs" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── NEW: coverage gap tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_job_detail_no_failure_event(tmp_path: Path) -> None:
|
||||||
|
"""Lines 201->203: detail view where events exist but no failure → no 'Error:' line."""
|
||||||
|
_write_jsonl(tmp_path / "run-ok" / "invoke-ci.jsonl", _success_events("run-ok"))
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
["report", "job", "--log-dir", str(tmp_path), "--job-id", "run-ok"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "Job: run-ok" in result.output
|
||||||
|
# No failure event → no "Error:" line should appear
|
||||||
|
assert "Error:" not in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_job_list_skips_empty_jsonl(tmp_path: Path) -> None:
|
||||||
|
"""Line 218: list mode with an empty/unreadable JSONL file → that file is skipped."""
|
||||||
|
# Write one valid job
|
||||||
|
_write_jsonl(tmp_path / "run-good" / "invoke-ci.jsonl", _success_events("run-good"))
|
||||||
|
# Write an empty JSONL (no events) — should be silently skipped
|
||||||
|
empty_jsonl = tmp_path / "run-empty" / "invoke-ci.jsonl"
|
||||||
|
empty_jsonl.parent.mkdir(parents=True)
|
||||||
|
empty_jsonl.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli, ["report", "job", "--log-dir", str(tmp_path)])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# Only the valid job appears
|
||||||
|
assert "run-good" in result.output
|
||||||
|
# The empty dir's name must not appear as a row (it was skipped)
|
||||||
|
# We can confirm by checking only one data row exists: "success" appears once
|
||||||
|
assert result.output.count("success") >= 1
|
||||||
|
|||||||
Reference in New Issue
Block a user