9e84d89823
Replaces the abandoned PSWSMan approach (New-PSSession hangs on Linux) with a Python-native implementation using the proven pypsrp WinRmTransport. - New `template prepare-win` command in commands/template.py: starts VM, waits for WinRM, uploads and runs Install-CIToolchain-*.ps1, handles Windows Update 3010 reboot loop (max 10 iterations), installs CI-StaticIp scheduled task, runs post-setup validation, graceful shutdown, snapshot. - Helpers: _ps_escape, _wait_tcp, _parse_exit_marker, _wait_winrm. - 40 new tests in test_commands_template.py covering all major branches. - Fix test_commands_job.py: patch load_config in _patch_common so tests don't pick up live /var/lib/ci/config.toml ip_pool and fail on lock. - Coverage gate: 90.06% (was 88.95% before fix). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
918 lines
30 KiB
Python
918 lines
30 KiB
Python
"""Tests for ``ci_orchestrator template`` sub-commands."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
import types
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import click
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
import ci_orchestrator.commands.template as tmpl_mod
|
|
from ci_orchestrator.__main__ import cli
|
|
from ci_orchestrator.transport.winrm import WinRmResult
|
|
|
|
|
|
# ── fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture()
|
|
def template_dir(tmp_path: Path) -> Path:
|
|
"""A fake template directory with a couple of files."""
|
|
t = tmp_path / "templates" / "WinBuild2025"
|
|
t.mkdir(parents=True)
|
|
(t / "WinBuild2025.vmx").write_text("vmx content")
|
|
(t / "WinBuild2025.vmdk").write_bytes(b"\x00" * 1024)
|
|
return t
|
|
|
|
|
|
@pytest.fixture()
|
|
def backup_dir(tmp_path: Path) -> Path:
|
|
d = tmp_path / "backups"
|
|
d.mkdir()
|
|
return d
|
|
|
|
|
|
def _fake_compress(source: Path, archive: Path) -> None:
|
|
"""Simulate 7z by writing a small placeholder file."""
|
|
archive.write_bytes(b"fake-7z-archive")
|
|
|
|
|
|
@pytest.fixture()
|
|
def _no_systemctl(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Prevent real systemctl and 7z calls in tests."""
|
|
monkeypatch.setattr(tmpl_mod, "_stop_runner", lambda **_kw: False)
|
|
monkeypatch.setattr(tmpl_mod, "_start_runner", lambda **_kw: None)
|
|
monkeypatch.setattr(tmpl_mod, "_compress_7z", _fake_compress)
|
|
|
|
|
|
# ── basic backup ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_backup_creates_timestamped_archive(
|
|
template_dir: Path, backup_dir: Path, _no_systemctl: None
|
|
) -> None:
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template",
|
|
"backup",
|
|
"--template-path",
|
|
str(template_dir),
|
|
"--backup-base-dir",
|
|
str(backup_dir),
|
|
"--skip-runner-stop",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
archives = list(backup_dir.iterdir())
|
|
assert len(archives) == 1
|
|
assert archives[0].name.startswith("Template_")
|
|
assert archives[0].suffix == ".7z"
|
|
|
|
|
|
def test_backup_missing_template_raises(
|
|
backup_dir: Path, tmp_path: Path, _no_systemctl: None
|
|
) -> None:
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template",
|
|
"backup",
|
|
"--template-path",
|
|
str(tmp_path / "nonexistent"),
|
|
"--backup-base-dir",
|
|
str(backup_dir),
|
|
"--skip-runner-stop",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "not found" in result.output
|
|
|
|
|
|
# ── --all-templates ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_all_templates_backs_up_every_subdir(
|
|
tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
|
|
) -> None:
|
|
tmpl_root = tmp_path / "templates"
|
|
for name in ("WinBuild2025", "WinBuild2022", "LinuxBuild2404"):
|
|
d = tmpl_root / name
|
|
d.mkdir(parents=True)
|
|
(d / f"{name}.vmx").write_text("vmx")
|
|
|
|
monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template",
|
|
"backup",
|
|
"--all-templates",
|
|
"--backup-base-dir",
|
|
str(backup_dir),
|
|
"--skip-runner-stop",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
names = {f.name for f in backup_dir.iterdir() if f.suffix == ".7z"}
|
|
assert any("WinBuild2025" in n for n in names)
|
|
assert any("WinBuild2022" in n for n in names)
|
|
assert any("LinuxBuild2404" in n for n in names)
|
|
|
|
|
|
def test_all_templates_empty_dir_raises(
|
|
tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
|
|
) -> None:
|
|
tmpl_root = tmp_path / "templates"
|
|
tmpl_root.mkdir()
|
|
monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template",
|
|
"backup",
|
|
"--all-templates",
|
|
"--backup-base-dir",
|
|
str(backup_dir),
|
|
"--skip-runner-stop",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "No template" in result.output
|
|
|
|
|
|
# ── pruning ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_prune_keeps_only_keep_count(
|
|
template_dir: Path, backup_dir: Path, _no_systemctl: None
|
|
) -> None:
|
|
for i in range(3):
|
|
old = backup_dir / f"Template_2025010{i}_120000.7z"
|
|
old.write_bytes(b"old-archive")
|
|
mtime = time.time() - (3 - i) * 3600
|
|
os.utime(old, (mtime, mtime))
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template",
|
|
"backup",
|
|
"--template-path",
|
|
str(template_dir),
|
|
"--backup-base-dir",
|
|
str(backup_dir),
|
|
"--keep-count",
|
|
"2",
|
|
"--skip-runner-stop",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
remaining = [f for f in backup_dir.iterdir() if f.suffix == ".7z"]
|
|
assert len(remaining) == 2, f"Expected 2 archives, got {len(remaining)}: {[f.name for f in remaining]}"
|
|
assert "Pruning" in result.output
|
|
|
|
|
|
# ── --what-if ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_what_if_no_copy(template_dir: Path, backup_dir: Path, _no_systemctl: None) -> None:
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template",
|
|
"backup",
|
|
"--template-path",
|
|
str(template_dir),
|
|
"--backup-base-dir",
|
|
str(backup_dir),
|
|
"--skip-runner-stop",
|
|
"--what-if",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert not list(backup_dir.iterdir()), "--what-if must not create anything"
|
|
assert "WhatIf" in result.output
|
|
|
|
|
|
# ── runner stop/start ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_stop_runner_called_when_active(
|
|
template_dir: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
stopped: list[bool] = []
|
|
started: list[bool] = []
|
|
|
|
monkeypatch.setattr(tmpl_mod, "_stop_runner", lambda **kw: stopped.append(True) or True)
|
|
monkeypatch.setattr(tmpl_mod, "_start_runner", lambda **kw: started.append(True))
|
|
monkeypatch.setattr(tmpl_mod, "_compress_7z", _fake_compress)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template",
|
|
"backup",
|
|
"--template-path",
|
|
str(template_dir),
|
|
"--backup-base-dir",
|
|
str(backup_dir),
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert stopped, "_stop_runner should have been called"
|
|
assert started, "_start_runner should have been called"
|
|
|
|
|
|
def test_skip_runner_stop_skips_systemctl(
|
|
template_dir: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
stopped: list[bool] = []
|
|
monkeypatch.setattr(tmpl_mod, "_stop_runner", lambda **kw: stopped.append(True) or True)
|
|
monkeypatch.setattr(tmpl_mod, "_compress_7z", _fake_compress)
|
|
|
|
CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template",
|
|
"backup",
|
|
"--template-path",
|
|
str(template_dir),
|
|
"--backup-base-dir",
|
|
str(backup_dir),
|
|
"--skip-runner-stop",
|
|
],
|
|
)
|
|
assert not stopped, "--skip-runner-stop should prevent _stop_runner from being called"
|
|
|
|
|
|
# ── unit helpers: _stop_runner / _start_runner ───────────────────────────────
|
|
|
|
|
|
def test_stop_runner_noop_when_inactive(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess,
|
|
"run",
|
|
lambda cmd, **_kw: type("R", (), {"stdout": "inactive\n", "returncode": 0})(),
|
|
)
|
|
assert tmpl_mod._stop_runner(what_if=False) is False
|
|
|
|
|
|
def test_stop_runner_returns_true_when_active(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls: list[list[str]] = []
|
|
|
|
def _fake_run(cmd: list[str], **_kw: object) -> object:
|
|
calls.append(cmd)
|
|
return type("R", (), {"stdout": "active\n", "returncode": 0})()
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", _fake_run)
|
|
result = tmpl_mod._stop_runner(what_if=False)
|
|
assert result is True
|
|
assert any("stop" in c for c in calls)
|
|
|
|
|
|
def test_stop_runner_what_if_does_not_call_stop(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls: list[list[str]] = []
|
|
|
|
def _fake_run(cmd: list[str], **_kw: object) -> object:
|
|
calls.append(cmd)
|
|
return type("R", (), {"stdout": "active\n", "returncode": 0})()
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", _fake_run)
|
|
result = tmpl_mod._stop_runner(what_if=True)
|
|
assert result is True
|
|
assert not any("stop" in c for c in calls), "what_if=True must not call systemctl stop"
|
|
|
|
|
|
# ── _compress_7z args ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_compress_7z_uses_mx1(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
calls: list[list[str]] = []
|
|
|
|
def _fake_run(cmd: list[str], **_kw: object) -> object:
|
|
calls.append(cmd)
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", _fake_run)
|
|
tmpl_mod._compress_7z(tmp_path / "src", tmp_path / "out.7z")
|
|
assert calls, "subprocess.run should have been called"
|
|
cmd = calls[0]
|
|
assert cmd[0] == "7z"
|
|
assert "-mx=1" in cmd
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# template prepare-win — helper unit tests
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def test_ps_escape_no_quotes() -> None:
|
|
assert tmpl_mod._ps_escape("hello world") == "hello world"
|
|
|
|
|
|
def test_ps_escape_single_quote() -> None:
|
|
assert tmpl_mod._ps_escape("it's") == "it''s"
|
|
|
|
|
|
def test_ps_escape_multiple_quotes() -> None:
|
|
assert tmpl_mod._ps_escape("it's Tom's") == "it''s Tom''s"
|
|
|
|
|
|
def test_parse_exit_marker_present() -> None:
|
|
assert tmpl_mod._parse_exit_marker("output\nCI_EXITCODE:3010\n") == 3010
|
|
|
|
|
|
def test_parse_exit_marker_absent() -> None:
|
|
assert tmpl_mod._parse_exit_marker("no marker here") == 0
|
|
|
|
|
|
def test_parse_exit_marker_last_line_wins() -> None:
|
|
assert tmpl_mod._parse_exit_marker("CI_EXITCODE:1\nCI_EXITCODE:0") == 0
|
|
|
|
|
|
def test_parse_exit_marker_malformed() -> None:
|
|
assert tmpl_mod._parse_exit_marker("CI_EXITCODE:abc") == 0
|
|
|
|
|
|
def test_wait_tcp_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class _FakeConn:
|
|
def __enter__(self) -> "_FakeConn":
|
|
return self
|
|
|
|
def __exit__(self, *_: object) -> None:
|
|
pass
|
|
|
|
monkeypatch.setattr(tmpl_mod.socket, "create_connection", lambda *a, **kw: _FakeConn())
|
|
assert tmpl_mod._wait_tcp("127.0.0.1", 9999, 5.0) is True
|
|
|
|
|
|
def test_wait_tcp_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _refuse(*a: object, **kw: object) -> None:
|
|
raise OSError("refused")
|
|
|
|
monkeypatch.setattr(tmpl_mod.socket, "create_connection", _refuse)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
assert tmpl_mod._wait_tcp("127.0.0.1", 9999, 0.0) is False
|
|
|
|
|
|
def test_wait_winrm_success() -> None:
|
|
transport = MagicMock()
|
|
transport.is_ready.return_value = True
|
|
tmpl_mod._wait_winrm(transport, 30) # must not raise
|
|
|
|
|
|
def test_wait_winrm_timeout() -> None:
|
|
transport = MagicMock()
|
|
transport.is_ready.return_value = False
|
|
with pytest.raises(click.ClickException, match="not ready"):
|
|
tmpl_mod._wait_winrm(transport, 0)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# template prepare-win — command tests
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _make_config() -> types.SimpleNamespace:
|
|
return types.SimpleNamespace(vmrun_path=None, ip_pool=None)
|
|
|
|
|
|
def _make_backend(is_running_seq: list[bool]) -> MagicMock:
|
|
b: MagicMock = MagicMock()
|
|
b.is_running.side_effect = is_running_seq
|
|
b.get_ip.return_value = "192.168.1.1"
|
|
b.list_snapshots.return_value = []
|
|
b.vmrun_path = "vmrun"
|
|
return b
|
|
|
|
|
|
def _make_transport() -> MagicMock:
|
|
val_json = json.dumps(
|
|
{
|
|
k: True
|
|
for k in [
|
|
"WinRMRunning", "UserExists", "UserIsAdmin", "UACDisabled",
|
|
"FirewallOff", "DotNetExists", "PythonExists", "MSBuildExists",
|
|
"CIDirsPresent", "StaticIpTask", "StaticIpScript",
|
|
]
|
|
}
|
|
)
|
|
|
|
def _run(script: str = "", **kwargs: object) -> WinRmResult:
|
|
if "ConvertTo-Json" in script:
|
|
return WinRmResult(stdout=val_json, stderr="", returncode=0)
|
|
return WinRmResult(stdout="CI_EXITCODE:0", stderr="", returncode=0)
|
|
|
|
t: MagicMock = MagicMock()
|
|
t.run.side_effect = _run
|
|
t.is_ready.return_value = True
|
|
return t
|
|
|
|
|
|
def _setup_prepare_env(
|
|
tmp_path: Path,
|
|
*,
|
|
with_static_ip: bool = True,
|
|
) -> Path:
|
|
"""Create fake VMX + toolchain script layout under tmp_path."""
|
|
vmx = tmp_path / "WinBuild2025.vmx"
|
|
vmx.write_text("vmx content")
|
|
tmpl_dir = tmp_path / "template"
|
|
tmpl_dir.mkdir()
|
|
(tmpl_dir / "Install-CIToolchain-WinBuild2025.ps1").write_text("# fake toolchain")
|
|
if with_static_ip:
|
|
sip_dir = tmpl_dir / "guest-setup" / "windows"
|
|
sip_dir.mkdir(parents=True)
|
|
(sip_dir / "ci-static-ip.ps1").write_text("# fake static ip")
|
|
return vmx
|
|
|
|
|
|
def test_prepare_win_vmx_not_found(tmp_path: Path) -> None:
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-win", "--vmx", str(tmp_path / "missing.vmx")],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "VMX not found" in result.output
|
|
|
|
|
|
def test_prepare_win_toolchain_not_found(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
vmx = tmp_path / "WinBuild2025.vmx"
|
|
vmx.write_text("vmx")
|
|
monkeypatch.chdir(tmp_path)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-win", "--vmx", str(vmx)],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "not found" in result.output
|
|
|
|
|
|
def test_prepare_win_explicit_toolchain_not_found(tmp_path: Path) -> None:
|
|
vmx = tmp_path / "WinBuild2025.vmx"
|
|
vmx.write_text("vmx")
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--toolchain-script", str(tmp_path / "nope.ps1"),
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "not found" in result.output
|
|
|
|
|
|
def _patch_prepare(monkeypatch: pytest.MonkeyPatch, backend: MagicMock, transport: MagicMock) -> None:
|
|
monkeypatch.setattr(tmpl_mod, "load_config", lambda: _make_config())
|
|
monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **_: backend)
|
|
monkeypatch.setattr(tmpl_mod, "WinRmTransport", lambda **_: transport)
|
|
monkeypatch.setattr(tmpl_mod, "_wait_tcp", lambda *_: True)
|
|
monkeypatch.setattr(tmpl_mod, "_wait_winrm", lambda *_: None)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", lambda *a, **_: None)
|
|
|
|
|
|
def test_prepare_win_happy_path(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
vmx = _setup_prepare_env(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True, False])
|
|
transport = _make_transport()
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Provisioning complete" in result.output
|
|
assert "All post-setup checks passed" in result.output
|
|
|
|
|
|
def test_prepare_win_no_static_ip_warning(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True, False])
|
|
transport = _make_transport()
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "WARNING" in result.output
|
|
|
|
|
|
def test_prepare_win_vm_started_when_not_running(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
vmx = _setup_prepare_env(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([False, False]) # not running → start; then powered off
|
|
transport = _make_transport()
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
backend.start.assert_called_once()
|
|
|
|
|
|
def test_prepare_win_tcp_timeout(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
vmx = _setup_prepare_env(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True])
|
|
_patch_prepare(monkeypatch, backend, MagicMock())
|
|
monkeypatch.setattr(tmpl_mod, "_wait_tcp", lambda *_: False)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "not reachable" in result.output
|
|
|
|
|
|
def test_prepare_win_toolchain_fails(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
vmx = _setup_prepare_env(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True])
|
|
transport: MagicMock = MagicMock()
|
|
transport.is_ready.return_value = True
|
|
transport.run.return_value = WinRmResult(
|
|
stdout="CI_EXITCODE:1", stderr="err", returncode=0
|
|
)
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "failed" in result.output
|
|
|
|
|
|
def test_prepare_win_snapshot_exists_skip(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
vmx = _setup_prepare_env(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True, False])
|
|
backend.list_snapshots.return_value = ["BaseClean"]
|
|
transport = _make_transport()
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
# User declines deletion with "N"
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
input="N\n",
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Keeping existing snapshot" in result.output
|
|
|
|
|
|
def test_prepare_win_snapshot_exists_recreate(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
vmx = _setup_prepare_env(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True, False])
|
|
backend.list_snapshots.return_value = ["BaseClean"]
|
|
transport = _make_transport()
|
|
sp_calls: list[list[str]] = []
|
|
monkeypatch.setattr(tmpl_mod, "load_config", lambda: _make_config())
|
|
monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **_: backend)
|
|
monkeypatch.setattr(tmpl_mod, "WinRmTransport", lambda **_: transport)
|
|
monkeypatch.setattr(tmpl_mod, "_wait_tcp", lambda *_: True)
|
|
monkeypatch.setattr(tmpl_mod, "_wait_winrm", lambda *_: None)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
|
|
def _sp_run(cmd: list[str], **_: object) -> None:
|
|
sp_calls.append(cmd)
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", _sp_run)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
input="Y\n",
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert any("deleteSnapshot" in " ".join(c) for c in sp_calls)
|
|
assert any("snapshot" in " ".join(c) and "deleteSnapshot" not in " ".join(c) for c in sp_calls)
|
|
|
|
|
|
def test_prepare_win_skip_flags(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""--skip-windows-update/tier2/cleanup flags reach the invoke script."""
|
|
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True, False])
|
|
|
|
captured_scripts: list[str] = []
|
|
|
|
def _run(script: str = "", **kwargs: object) -> WinRmResult:
|
|
captured_scripts.append(script)
|
|
if "ConvertTo-Json" in script:
|
|
return WinRmResult(stdout="{}", stderr="", returncode=0)
|
|
return WinRmResult(stdout="CI_EXITCODE:0", stderr="", returncode=0)
|
|
|
|
transport: MagicMock = MagicMock()
|
|
transport.run.side_effect = _run
|
|
transport.is_ready.return_value = True
|
|
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
"--skip-windows-update",
|
|
"--skip-tier2",
|
|
"--skip-cleanup",
|
|
],
|
|
)
|
|
invoke_script = next(
|
|
(s for s in captured_scripts if "SkipWindowsUpdate" in s or "CI_EXITCODE" in s),
|
|
"",
|
|
)
|
|
assert "SkipWindowsUpdate" in invoke_script
|
|
assert "SkipTier2" in invoke_script
|
|
assert "SkipCleanup" in invoke_script
|
|
|
|
|
|
def test_prepare_win_auto_detect_ip(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""No --ip supplied — IP auto-detected via backend.get_ip()."""
|
|
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True, False])
|
|
backend.get_ip.return_value = "10.0.0.5"
|
|
transport = _make_transport()
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "10.0.0.5" in result.output
|
|
|
|
|
|
def test_prepare_win_auto_detect_ip_fails(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""backend.get_ip() returns None → ClickException."""
|
|
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True])
|
|
backend.get_ip.return_value = None
|
|
_patch_prepare(monkeypatch, backend, MagicMock())
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "guest IP" in result.output
|
|
|
|
|
|
def test_prepare_win_3010_reboot_then_success(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Toolchain returns 3010 once, then 0 — reboot loop executes."""
|
|
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True, False])
|
|
|
|
call_count = [0]
|
|
|
|
def _run(script: str = "", **kwargs: object) -> WinRmResult:
|
|
call_count[0] += 1
|
|
if "ConvertTo-Json" in script:
|
|
return WinRmResult(stdout="{}", stderr="", returncode=0)
|
|
# First invoke → 3010; subsequent → 0
|
|
code = 3010 if call_count[0] <= 2 else 0
|
|
return WinRmResult(stdout=f"CI_EXITCODE:{code}", stderr="", returncode=0)
|
|
|
|
transport: MagicMock = MagicMock()
|
|
transport.run.side_effect = _run
|
|
transport.is_ready.return_value = True
|
|
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "reboot" in result.output.lower()
|
|
|
|
|
|
def test_prepare_win_transport_error_treated_as_3010(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""TransportError during toolchain run → treated as 3010, reboot, then success."""
|
|
from ci_orchestrator.transport.errors import TransportConnectError
|
|
|
|
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True, False])
|
|
|
|
raised = [False]
|
|
|
|
def _run(script: str = "", **kwargs: object) -> WinRmResult:
|
|
if not raised[0] and "CI_EXITCODE" in script:
|
|
raised[0] = True
|
|
raise TransportConnectError("VM rebooted")
|
|
if "ConvertTo-Json" in script:
|
|
return WinRmResult(stdout="{}", stderr="", returncode=0)
|
|
return WinRmResult(stdout="CI_EXITCODE:0", stderr="", returncode=0)
|
|
|
|
transport: MagicMock = MagicMock()
|
|
transport.run.side_effect = _run
|
|
transport.is_ready.return_value = True
|
|
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Transport error" in result.output
|
|
|
|
|
|
def test_prepare_win_validation_with_failures(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Validation JSON contains False values → WARNING printed but exit 0."""
|
|
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True, False])
|
|
|
|
partial_json = json.dumps(
|
|
{k: (k != "MSBuildExists") for k in [
|
|
"WinRMRunning", "UserExists", "UserIsAdmin", "UACDisabled",
|
|
"FirewallOff", "DotNetExists", "PythonExists", "MSBuildExists",
|
|
"CIDirsPresent", "StaticIpTask", "StaticIpScript",
|
|
]}
|
|
)
|
|
|
|
def _run(script: str = "", **kwargs: object) -> WinRmResult:
|
|
if "ConvertTo-Json" in script:
|
|
return WinRmResult(stdout=partial_json, stderr="", returncode=0)
|
|
return WinRmResult(stdout="CI_EXITCODE:0", stderr="", returncode=0)
|
|
|
|
transport: MagicMock = MagicMock()
|
|
transport.run.side_effect = _run
|
|
transport.is_ready.return_value = True
|
|
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "WARNING" in result.output
|
|
assert "MSBuildExists" in result.output
|
|
|
|
|
|
def test_prepare_win_vm_shutdown_timeout(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""VM never powers off within 120 s → ClickException."""
|
|
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
backend = _make_backend([True]) # always "running"
|
|
backend.is_running.side_effect = None # override side_effect
|
|
backend.is_running.return_value = True # always reports as running
|
|
|
|
transport = _make_transport()
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
# Patch monotonic so the 120 s deadline expires immediately
|
|
_orig = tmpl_mod.time.monotonic
|
|
call_n = [0]
|
|
|
|
def _fast_mono() -> float:
|
|
call_n[0] += 1
|
|
# After the 5th call (inside shutdown-wait loop) return a huge number
|
|
return _orig() if call_n[0] < 20 else _orig() + 200
|
|
|
|
monkeypatch.setattr(tmpl_mod.time, "monotonic", _fast_mono)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "power off" in result.output
|