Files
local-ci-cd-system/tests/python/test_commands_artifacts.py
Simone 8dc7a4fb99
Lint / pssa (push) Successful in 10s
Lint / python (push) Successful in 1m22s
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.
2026-05-26 21:18:20 +02:00

556 lines
19 KiB
Python

"""Tests for ``ci_orchestrator artifacts collect``."""
from __future__ import annotations
import json
import tarfile
import zipfile
from pathlib import Path
from typing import Any, ClassVar
import pytest
from click.testing import CliRunner
import ci_orchestrator.commands.artifacts as artifacts_module
from ci_orchestrator.__main__ import cli
from ci_orchestrator.credentials import Credential
from ci_orchestrator.transport.errors import TransportCommandError
class _FakeResult:
def __init__(self, stdout: str = "OK", returncode: int = 0) -> None:
self.stdout = stdout
self.stderr = ""
self.returncode = returncode
@property
def ok(self) -> bool:
return self.returncode == 0
class _FakeTransport:
instances: ClassVar[list[_FakeTransport]] = []
def __init__(self, *args: Any, **kwargs: Any) -> None:
self.runs: list[str] = []
self.fetched: list[tuple[str, str]] = []
self.fetch_payloads: dict[str, bytes] = {}
self.run_overrides: dict[str, _FakeResult] = {}
type(self).instances.append(self)
def __enter__(self) -> _FakeTransport:
return self
def __exit__(self, *_e: object) -> None:
return None
def run(self, script: str, *, check: bool = True) -> _FakeResult:
self.runs.append(script)
for key, override in self.run_overrides.items():
if key in script:
if check and not override.ok:
raise TransportCommandError(
override.returncode, override.stdout, override.stderr
)
return override
return _FakeResult()
def fetch(self, remote_path: str, local_path: str) -> None:
self.fetched.append((remote_path, str(local_path)))
payload = self.fetch_payloads.get(remote_path, b"data")
Path(local_path).write_bytes(payload)
@pytest.fixture(autouse=True)
def _reset_instances() -> None:
_FakeTransport.instances = []
def _patch(monkeypatch: pytest.MonkeyPatch) -> None:
class _Store:
def get(self, _t: str) -> Credential:
return Credential("ci", "pwd")
monkeypatch.setattr(artifacts_module, "WinRmTransport", _FakeTransport)
monkeypatch.setattr(artifacts_module, "SshTransport", _FakeTransport)
monkeypatch.setattr(artifacts_module, "KeyringCredentialStore", lambda: _Store())
# ── Windows happy path ────────────────────────────────────────────────────
def _zip_payload(tmp_path: Path) -> bytes:
"""Build a real zip whose contents the fake transport will 'fetch'."""
src = tmp_path / "zsrc"
src.mkdir()
(src / "binary.bin").write_bytes(b"\x01\x02\x03")
(src / "log.txt").write_text("hello", encoding="utf-8")
zip_local = tmp_path / "transfer.zip"
with zipfile.ZipFile(zip_local, "w") as zf:
for f in src.iterdir():
zf.write(f, arcname=f.name)
return zip_local.read_bytes()
def _win_transport_with_zip(payload: bytes) -> type:
class _WinTransport(_FakeTransport):
def fetch(self, remote_path: str, local_path: str) -> None:
self.fetched.append((remote_path, str(local_path)))
Path(local_path).write_bytes(payload)
return _WinTransport
def test_collect_windows_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
_patch(monkeypatch)
payload = _zip_payload(tmp_path)
monkeypatch.setattr(
artifacts_module, "WinRmTransport", _win_transport_with_zip(payload)
)
out_dir = tmp_path / "artifacts"
result = CliRunner().invoke(
cli,
[
"artifacts",
"collect",
"--ip-address",
"10.0.0.7",
"--guest-artifact-path",
"C:\\CI\\output",
"--host-artifact-dir",
str(out_dir),
],
)
assert result.exit_code == 0, result.output
# Raw files extracted into host_dir (no zip-in-zip).
assert (out_dir / "binary.bin").read_bytes() == b"\x01\x02\x03"
assert (out_dir / "log.txt").read_text(encoding="utf-8") == "hello"
cmds = _FakeTransport.instances[0].runs
assert any("Compress-Archive" in c for c in cmds)
# Temp transport zip on the guest is cleaned up.
assert any("Remove-Item" in c and "ci-artifacts-transfer.zip" in c for c in cmds)
def test_collect_windows_artifact_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
_patch(monkeypatch)
class _Missing(_FakeTransport):
def __init__(self, *a: Any, **kw: Any) -> None:
super().__init__(*a, **kw)
self.run_overrides["-and (Get-ChildItem"] = _FakeResult(
stdout="MISSING\r\n"
)
monkeypatch.setattr(artifacts_module, "WinRmTransport", _Missing)
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 "artifact not found or empty" in result.output
def test_collect_windows_include_logs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
_patch(monkeypatch)
payload = _zip_payload(tmp_path)
class _WithLogs(_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\\one.log\r\nC:\\CI\\build\\sub\\two.log\r\n"
)
monkeypatch.setattr(artifacts_module, "WinRmTransport", _WithLogs)
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, result.output
fetched_remotes = [r for r, _ in _FakeTransport.instances[0].fetched]
assert "C:\\CI\\build\\one.log" in fetched_remotes
assert "C:\\CI\\build\\sub\\two.log" in fetched_remotes
def test_collect_writes_manifest_when_job_id_set(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_patch(monkeypatch)
payload = _zip_payload(tmp_path)
monkeypatch.setattr(
artifacts_module, "WinRmTransport", _win_transport_with_zip(payload)
)
out = 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),
"--job-id",
"run-42",
"--commit",
"abc1234",
],
)
assert result.exit_code == 0, result.output
manifest_path = out / "manifest.json"
assert manifest_path.is_file()
data = json.loads(manifest_path.read_text(encoding="utf-8"))
assert data["jobId"] == "run-42"
assert data["commit"] == "abc1234"
names = [f["name"] for f in data["files"]]
assert "binary.bin" in names and "log.txt" in names
# ── Linux happy path ──────────────────────────────────────────────────────
def test_collect_linux_extracts_tarball(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_patch(monkeypatch)
# Build a real tarball that the fake transport will "fetch".
payload_dir = tmp_path / "payload"
payload_dir.mkdir()
(payload_dir / "binary.bin").write_bytes(b"\x01\x02\x03")
(payload_dir / "log.txt").write_text("hello", 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()
class _LinuxTransport(_FakeTransport):
def __init__(self, *a: Any, **kw: Any) -> None:
super().__init__(*a, **kw)
# Any fetched path returns the same tarball payload.
self.fetch_payloads["__default__"] = payload_bytes
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", _LinuxTransport)
out = tmp_path / "out"
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(out),
"--ssh-key-path",
str(tmp_path / "key"),
],
)
assert result.exit_code == 0, result.output
assert (out / "binary.bin").read_bytes() == b"\x01\x02\x03"
assert (out / "log.txt").read_text(encoding="utf-8") == "hello"
cmds = _FakeTransport.instances[0].runs
assert any("tar -czf" in c for c in cmds)
# Cleanup of the temp tarball on the guest was attempted.
assert any("rm -f" in c for c in cmds)
def test_collect_linux_no_files_raises(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_patch(monkeypatch)
# Empty tarball => extraction yields nothing.
empty_tar = tmp_path / "empty.tar.gz"
with tarfile.open(empty_tar, "w:gz"):
pass
payload = empty_tar.read_bytes()
class _Empty(_FakeTransport):
def fetch(self, remote_path: str, local_path: str) -> None:
self.fetched.append((remote_path, str(local_path)))
Path(local_path).write_bytes(payload)
monkeypatch.setattr(artifacts_module, "SshTransport", _Empty)
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"),
"--ssh-key-path",
str(tmp_path / "key"),
],
)
assert result.exit_code != 0
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