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>
366 lines
11 KiB
Python
366 lines
11 KiB
Python
"""Tests for ``ci_orchestrator build run``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
import ci_orchestrator.commands.build as build_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 = "", stderr: str = "", returncode: int = 0) -> None:
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
self.returncode = returncode
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.returncode == 0
|
|
|
|
|
|
class _FakeTransport:
|
|
"""Records every ``run/copy/fetch`` call."""
|
|
|
|
instances: ClassVar[list[_FakeTransport]] = []
|
|
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
self.args = args
|
|
self.kwargs = kwargs
|
|
self.runs: list[tuple[str, bool]] = []
|
|
self.copies: list[tuple[str, str]] = []
|
|
self.fetches: list[tuple[str, str]] = []
|
|
self.run_results: dict[int, _FakeResult] = {}
|
|
self.default_result = _FakeResult(stdout="ok")
|
|
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, check))
|
|
result = self.run_results.get(len(self.runs) - 1, self.default_result)
|
|
if check and not result.ok:
|
|
raise TransportCommandError(result.returncode, result.stdout, result.stderr)
|
|
return result
|
|
|
|
def copy(self, local_path: str, remote_path: str) -> None:
|
|
self.copies.append((str(local_path), remote_path))
|
|
|
|
def fetch(self, remote_path: str, local_path: str) -> None:
|
|
self.fetches.append((remote_path, str(local_path)))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_instances() -> None:
|
|
_FakeTransport.instances = []
|
|
|
|
|
|
def _patch(monkeypatch: pytest.MonkeyPatch) -> type[_FakeTransport]:
|
|
class _Store:
|
|
def get(self, _t: str) -> Credential:
|
|
return Credential("ci", "pwd")
|
|
|
|
monkeypatch.setattr(build_module, "WinRmTransport", _FakeTransport)
|
|
monkeypatch.setattr(build_module, "SshTransport", _FakeTransport)
|
|
monkeypatch.setattr(build_module, "KeyringCredentialStore", lambda: _Store())
|
|
return _FakeTransport
|
|
|
|
|
|
# ── parameter validation ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_build_run_requires_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_patch(monkeypatch)
|
|
result = CliRunner().invoke(
|
|
cli, ["build", "run", "--ip-address", "10.0.0.1"]
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "either --host-source-dir or --clone-url" in result.output
|
|
|
|
|
|
def test_build_run_rejects_both_modes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
_patch(monkeypatch)
|
|
src = tmp_path / "src"
|
|
src.mkdir()
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"build",
|
|
"run",
|
|
"--ip-address",
|
|
"10.0.0.1",
|
|
"--host-source-dir",
|
|
str(src),
|
|
"--clone-url",
|
|
"https://example/repo.git",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "not both" in result.output
|
|
|
|
|
|
def test_build_run_rejects_missing_source_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
_patch(monkeypatch)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"build",
|
|
"run",
|
|
"--ip-address",
|
|
"10.0.0.1",
|
|
"--host-source-dir",
|
|
str(tmp_path / "ghost"),
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "does not exist" in result.output
|
|
|
|
|
|
def test_build_run_rejects_bad_extra_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
_patch(monkeypatch)
|
|
src = tmp_path / "src"
|
|
src.mkdir()
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"build",
|
|
"run",
|
|
"--ip-address",
|
|
"10.0.0.1",
|
|
"--guest-os",
|
|
"linux",
|
|
"--host-source-dir",
|
|
str(src),
|
|
"--build-command",
|
|
"make",
|
|
"--extra-env",
|
|
"NO_EQUALS",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "KEY=VALUE" in result.output
|
|
|
|
|
|
# ── happy path: Linux ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_build_run_linux_clone_url_happy_path(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_patch(monkeypatch)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"build",
|
|
"run",
|
|
"--ip-address",
|
|
"10.0.0.5",
|
|
"--guest-os",
|
|
"linux",
|
|
"--clone-url",
|
|
"https://example/repo.git",
|
|
"--clone-branch",
|
|
"feature/x",
|
|
"--clone-commit",
|
|
"deadbeef",
|
|
"--clone-submodules",
|
|
"--build-command",
|
|
"make all",
|
|
"--guest-linux-work-dir",
|
|
"/work",
|
|
"--guest-linux-output-dir",
|
|
"/out",
|
|
"--guest-artifact-source",
|
|
"build",
|
|
"--extra-env",
|
|
"FOO=bar baz",
|
|
"--extra-env",
|
|
"TOKEN=secret",
|
|
"--ssh-key-path",
|
|
str(tmp_path / "key"),
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
instance = _FakeTransport.instances[0]
|
|
cmds = [c[0] for c in instance.runs]
|
|
assert any("rm -rf /work && mkdir -p /work" in c for c in cmds)
|
|
assert any("git clone --depth 1 --branch feature/x" in c for c in cmds)
|
|
assert any("--recurse-submodules" in c for c in cmds)
|
|
assert any("checkout deadbeef" in c for c in cmds)
|
|
# extra-env exported before build
|
|
assert any("export FOO='bar baz'" in c and "export TOKEN=secret" in c for c in cmds)
|
|
assert any("cd /work && make all" in c for c in cmds)
|
|
# artifact stage uses the requested source dir
|
|
assert any("/work/build" in c for c in cmds)
|
|
|
|
|
|
def test_build_run_linux_host_source_dir(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_patch(monkeypatch)
|
|
src = tmp_path / "src"
|
|
src.mkdir()
|
|
(src / "hello.txt").write_text("hi", encoding="utf-8")
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"build",
|
|
"run",
|
|
"--ip-address",
|
|
"10.0.0.5",
|
|
"--guest-os",
|
|
"linux",
|
|
"--host-source-dir",
|
|
str(src),
|
|
"--build-command",
|
|
"true",
|
|
"--ssh-key-path",
|
|
str(tmp_path / "key"),
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
instance = _FakeTransport.instances[0]
|
|
# A single tarball was uploaded.
|
|
assert len(instance.copies) == 1
|
|
local, remote = instance.copies[0]
|
|
assert local.endswith(".tar.gz")
|
|
assert remote.startswith("/tmp/")
|
|
# Extraction command was invoked.
|
|
assert any("tar -xzf" in c[0] for c in instance.runs)
|
|
|
|
|
|
def test_build_run_linux_skip_artifact(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_patch(monkeypatch)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"build",
|
|
"run",
|
|
"--ip-address",
|
|
"10.0.0.5",
|
|
"--guest-os",
|
|
"linux",
|
|
"--clone-url",
|
|
"https://example/repo.git",
|
|
"--build-command",
|
|
"make",
|
|
"--skip-artifact",
|
|
"--ssh-key-path",
|
|
str(tmp_path / "key"),
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
cmds = [c[0] for c in _FakeTransport.instances[0].runs]
|
|
# No staging command should run.
|
|
assert not any("/opt/ci/output" in c for c in cmds)
|
|
|
|
|
|
def test_build_run_linux_build_failure_exits_nonzero(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_patch(monkeypatch)
|
|
# Make the build command (4th run: rm/git clone/checkout-skipped/build)
|
|
# return non-zero. Index of build run varies; easiest: make any non-zero
|
|
# result for the run whose script contains 'cd '.
|
|
src = tmp_path / "src"
|
|
src.mkdir()
|
|
|
|
class _BuildFails(_FakeTransport):
|
|
def run(self, script: str, *, check: bool = True) -> _FakeResult:
|
|
if "cd /opt/ci/build && make" in script:
|
|
self.runs.append((script, check))
|
|
return _FakeResult(stdout="boom\n", stderr="err\n", returncode=2)
|
|
return super().run(script, check=check)
|
|
|
|
monkeypatch.setattr(build_module, "SshTransport", _BuildFails)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"build",
|
|
"run",
|
|
"--ip-address",
|
|
"10.0.0.5",
|
|
"--guest-os",
|
|
"linux",
|
|
"--host-source-dir",
|
|
str(src),
|
|
"--ssh-key-path",
|
|
str(tmp_path / "key"),
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "build command failed (exit 2)" in result.output
|
|
|
|
|
|
# ── happy path: Windows ────────────────────────────────────────────────────
|
|
|
|
|
|
def test_build_run_windows_clone_url_default_dotnet(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_patch(monkeypatch)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"build",
|
|
"run",
|
|
"--ip-address",
|
|
"10.0.0.7",
|
|
"--guest-os",
|
|
"windows",
|
|
"--clone-url",
|
|
"https://example/repo.git",
|
|
"--use-shared-cache",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
cmds = [c[0] for c in _FakeTransport.instances[0].runs]
|
|
assert any("git clone --depth 1" in c for c in cmds)
|
|
assert any("dotnet restore" in c for c in cmds)
|
|
assert any("NUGET_PACKAGES" in c for c in cmds)
|
|
|
|
|
|
def test_build_run_windows_host_source_dir(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
_patch(monkeypatch)
|
|
src = tmp_path / "src"
|
|
src.mkdir()
|
|
(src / "a.cs").write_text("//", encoding="utf-8")
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"build",
|
|
"run",
|
|
"--ip-address",
|
|
"10.0.0.7",
|
|
"--host-source-dir",
|
|
str(src),
|
|
"--build-command",
|
|
"echo built > dist\\out.txt",
|
|
"--guest-artifact-source",
|
|
"dist",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
instance = _FakeTransport.instances[0]
|
|
assert len(instance.copies) == 1
|
|
assert instance.copies[0][1] == "C:\\CI\\src-transfer.zip"
|
|
cmds = [c[0] for c in instance.runs]
|
|
assert any("Expand-Archive" in c for c in cmds)
|
|
# 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 not any("Compress-Archive" in c and "'dist'" in c for c in cmds)
|