456db0a3e2
Windows produced an intermediate C:\CI\output\artifacts.zip that upload-artifact then re-zipped (zip-in-zip), while Linux deposited raw files. Make both paths consistent: the build stages raw outputs into a collect dir; collect zips it only as a transport archive, fetches, and extracts into the host artifact dir; actions/upload-artifact produces the single final archive. - _windows_build: param artifact_zip -> output_dir; stage raw files (in-place when source IS the collect dir, else wipe+copy), no Compress-Archive; absolute artifact-source resolved. - _windows_collect: zip guest dir as transport, fetch, extract (mirror _linux_collect); guest_path is now the collect directory. - job.py / build_run CLI: pass output_dir; rename --guest-artifact-zip -> --guest-output-dir. - Tests updated for the raw-file contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
325 lines
10 KiB
Python
325 lines
10 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
|