"""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