8dc7a4fb99
- 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.
556 lines
19 KiB
Python
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
|