Four defects, each of which could waste a full template rebuild: 1. getGuestIPAddress was called without -wait, so vmrun answered "The VMware Tools are not running" unless the Tools happened to have registered the IP at that exact instant. A guest that was booted, on the network and answering SSH could still burn the entire 10-minute Step 7 budget. This race is the likely cause of every intermittent "no guest IP" incident so far, including ones previously attributed to wedged vmnet DHCP. AGENTS.md #10 already prescribed -wait; the code did not follow it. 2. --force did not check whether the target VM was powered on. It deleted the live .vmdk.lck as a stale artifact and Step 4 rewrote the base VMDK under the running guest, killing it; the damage surfaced ten minutes later as an IP timeout because `vmrun list` still showed the OLD instance. 3. Step 6 accepted "VM appears in vmrun list" as proof of power-on, which a pre-existing instance satisfies. Check the exit code of `vmrun start`. 4. The cloud-init seed ISO was detached while the VM was still running, and VMware rewrote the VMX from its in-memory copy at power-off, restoring ide1:0.present = "TRUE". Every clone would boot with the seed attached (AGENTS.md #6). Detach it in prepare, after power-off and before the snapshot. Also in this change: - --then-prepare chains deploy into the matching prepare in one run, reusing the VM deploy leaves running. The two commands stay separately invocable. The guest toolchain script is resolved and verified BEFORE any VM work, so a wrong working directory costs two seconds instead of failing the hand-off after a 45-minute deploy; --toolchain-script allows running from anywhere. - Corrected the final messages: deploy-rust told the user to run prepare-linux, and both deploy commands reported a snapshot name (PostInstall-Linux) that no command ever creates. The prepare hand-off hint passed a bare filename to --template and omitted --snapshot, so copy-pasting it cloned BaseClean, the Windows snapshot. - template backup --exclude, repeatable, for retired templates. An unknown name or --exclude without --all-templates is an error rather than a silent no-op. test_deploy_linux_full_happy_path declared the VM already running before Step 6, an impossible state that masked the new --force guard; its vmrun mock is now stateful.
3061 lines
108 KiB
Python
3061 lines
108 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_reboot_and_wait_boot_change(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls: list[str] = []
|
|
transport = MagicMock()
|
|
monkeypatch.setattr(tmpl_mod, "_tcp_open", lambda *a, **k: True)
|
|
# boot ticks: before reboot, then a newer value once it's back.
|
|
ticks = iter([100, 200])
|
|
monkeypatch.setattr(tmpl_mod, "_guest_boot_ticks", lambda _t: next(ticks))
|
|
monkeypatch.setattr(tmpl_mod, "_wait_winrm", lambda *a, **k: calls.append("winrm"))
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _s: None)
|
|
tmpl_mod._reboot_and_wait(transport, "10.0.0.5", 5986, 30)
|
|
assert "winrm" in calls
|
|
transport.close.assert_called_once()
|
|
|
|
|
|
def test_reboot_and_wait_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
transport = MagicMock()
|
|
monkeypatch.setattr(tmpl_mod, "_tcp_open", lambda *a, **k: False) # never comes back
|
|
monkeypatch.setattr(tmpl_mod, "_guest_boot_ticks", lambda _t: 100)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _s: None)
|
|
clock = iter([0.0, 1.0, 1e9]) # deadline calc, one loop check, then past deadline
|
|
monkeypatch.setattr(tmpl_mod.time, "monotonic", lambda: next(clock))
|
|
with pytest.raises(click.ClickException, match="did not reboot"):
|
|
tmpl_mod._reboot_and_wait(transport, "10.0.0.5", 5986, 1)
|
|
|
|
|
|
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 "PercentProcessorTime" in script: # pre-snapshot idle check
|
|
return WinRmResult(stdout='{"cpu":2.0,"ram":21.0,"top":"idle"}', stderr="", returncode=0)
|
|
if ").Shell" in script: # up-front shell check — already cmd, skip reboot
|
|
return WinRmResult(stdout="cmd.exe", stderr="", returncode=0)
|
|
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:
|
|
# _run_with_heartbeat prefers transport.run_streaming; route it through the
|
|
# test's transport.run side_effect and feed stdout to the on_line callback.
|
|
if not hasattr(transport.run_streaming, "side_effect") or transport.run_streaming.side_effect is None:
|
|
def _stream(script: str = "", on_line: object = None, **kwargs: object) -> WinRmResult:
|
|
res = transport.run(script, **kwargs)
|
|
if callable(on_line) and isinstance(res, WinRmResult):
|
|
on_line(res.stdout)
|
|
return res
|
|
transport.run_streaming.side_effect = _stream
|
|
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, "_reboot_and_wait", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(tmpl_mod, "_wait_winrm", lambda *_: None)
|
|
monkeypatch.setattr(tmpl_mod, "_check_guest_idle", lambda *_a, **_k: 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)
|
|
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, "_reboot_and_wait", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(tmpl_mod, "_check_guest_idle", lambda *_a, **_k: None)
|
|
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_recreate_snapshot_flag(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""--recreate-snapshot skips the interactive prompt and deletes+recreates."""
|
|
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]] = []
|
|
_patch_prepare(monkeypatch, backend, transport)
|
|
|
|
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",
|
|
"--recreate-snapshot",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert any("deleteSnapshot" in " ".join(c) for c in sp_calls)
|
|
|
|
|
|
def test_prepare_win_skip_flags(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""-SkipCleanup:$true always in loop args; --skip-cleanup suppresses post-loop cleanup call."""
|
|
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
|
|
|
|
|
|
def test_prepare_win_store_credential(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""--store-credential calls keyring.set_password with the two-entry scheme."""
|
|
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)
|
|
|
|
stored: list[tuple[str, str, str]] = []
|
|
|
|
fake_keyring = types.SimpleNamespace(
|
|
set_password=lambda svc, user, pw: stored.append((svc, user, pw))
|
|
)
|
|
monkeypatch.setattr(tmpl_mod, "keyring", fake_keyring, raising=False)
|
|
|
|
# Patch the import inside the function so it returns our fake module.
|
|
import builtins
|
|
real_import = builtins.__import__
|
|
|
|
def _fake_import(name: str, *args: object, **kwargs: object) -> object:
|
|
if name == "keyring":
|
|
return fake_keyring
|
|
return real_import(name, *args, **kwargs) # type: ignore[arg-type]
|
|
|
|
monkeypatch.setattr(builtins, "__import__", _fake_import)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
"--store-credential",
|
|
"--credential-target", "TestTarget",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert any(s[0] == "TestTarget:meta" for s in stored), "username meta-entry not stored"
|
|
assert any(s[0] == "TestTarget" for s in stored), "password entry not stored"
|
|
assert "Credentials stored" in result.output
|
|
|
|
|
|
def test_prepare_win_store_credential_default_target(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Default credential target is BuildVMGuest."""
|
|
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)
|
|
|
|
stored: list[tuple[str, str, str]] = []
|
|
|
|
import builtins
|
|
real_import = builtins.__import__
|
|
|
|
def _fake_import(name: str, *args: object, **kwargs: object) -> object:
|
|
if name == "keyring":
|
|
return types.SimpleNamespace(
|
|
set_password=lambda svc, user, pw: stored.append((svc, user, pw))
|
|
)
|
|
return real_import(name, *args, **kwargs) # type: ignore[arg-type]
|
|
|
|
monkeypatch.setattr(builtins, "__import__", _fake_import)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
"--store-credential",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert any(s[0] == "BuildVMGuest:meta" for s in stored)
|
|
assert any(s[0] == "BuildVMGuest" for s in stored)
|
|
|
|
|
|
def test_prepare_win_cleanup_runs_after_loop(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""CleanupOnly invocation fires after WU loop exits 0 (default, no --skip-cleanup)."""
|
|
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)
|
|
|
|
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 any("CleanupOnly:$true" in s for s in captured_scripts), (
|
|
"Expected a -CleanupOnly:$true script after the WU loop"
|
|
)
|
|
assert "Post-update cleanup complete" in result.output
|
|
|
|
|
|
def test_prepare_win_skip_cleanup_no_cleanup_call(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""--skip-cleanup suppresses the post-loop CleanupOnly invocation."""
|
|
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)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-win",
|
|
"--vmx", str(vmx),
|
|
"--ip", "192.168.1.100",
|
|
"--admin-password", "adminpass",
|
|
"--build-password", "buildpass",
|
|
"--skip-cleanup",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert not any("CleanupOnly:$true" in s for s in captured_scripts), (
|
|
"--skip-cleanup must suppress the -CleanupOnly:$true call"
|
|
)
|
|
|
|
|
|
def test_prepare_win_cleanup_only_fails(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Cleanup-only step returning nonzero exits the command with an error."""
|
|
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
backend = _make_backend([True])
|
|
|
|
def _run(script: str = "", **kwargs: object) -> WinRmResult:
|
|
if "CleanupOnly" in script:
|
|
return WinRmResult(stdout="CI_EXITCODE:1", stderr="cleanup error", returncode=0)
|
|
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
|
|
assert "Disk cleanup failed" in result.output
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# template deploy-linux — helper unit tests
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
from ci_orchestrator.config import BackendConfig, Config, Paths # noqa: E402
|
|
|
|
|
|
def _deploy_config(tmp_path: Path) -> Config:
|
|
return Config(
|
|
paths=Paths(
|
|
root=tmp_path,
|
|
templates=tmp_path / "templates",
|
|
build_vms=tmp_path / "build-vms",
|
|
artifacts=tmp_path / "artifacts",
|
|
keys=tmp_path / "keys",
|
|
),
|
|
backend=BackendConfig(),
|
|
vmrun_path="/usr/bin/vmrun",
|
|
ssh_key_path=tmp_path / "keys" / "ci_linux",
|
|
)
|
|
|
|
|
|
def _make_keys(keys_dir: Path) -> Path:
|
|
keys_dir.mkdir(parents=True, exist_ok=True)
|
|
key = keys_dir / "ci_linux"
|
|
key.write_text("FAKE PRIVATE KEY")
|
|
(keys_dir / "ci_linux.pub").write_text("ssh-rsa AAAA test@host")
|
|
return key
|
|
|
|
|
|
def _make_vmdk_cache(root: Path) -> Path:
|
|
iso_dir = root / "iso"
|
|
iso_dir.mkdir(exist_ok=True)
|
|
vmdk = iso_dir / "noble-server-cloudimg-amd64.vmdk"
|
|
vmdk.write_bytes(b"\x00" * 100)
|
|
return vmdk
|
|
|
|
|
|
@pytest.fixture()
|
|
def deploy_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
|
|
ssh_key = _make_keys(tmp_path / "keys")
|
|
vmdk_cache = _make_vmdk_cache(tmp_path)
|
|
vm_dir = tmp_path / "templates" / "LinuxBuild2404"
|
|
vm_dir.mkdir(parents=True)
|
|
config = _deploy_config(tmp_path)
|
|
monkeypatch.setattr(tmpl_mod, "load_config", lambda: config)
|
|
monkeypatch.setattr(tmpl_mod, "_find_vdisk_manager", lambda _: "/usr/bin/vmware-vdiskmanager")
|
|
monkeypatch.setattr(
|
|
tmpl_mod.shutil, "which",
|
|
lambda n: f"/usr/bin/{n}" if n in ("vmrun", "vmware-vdiskmanager", "genisoimage") else None,
|
|
)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
return {
|
|
"tmp_path": tmp_path,
|
|
"ssh_key": ssh_key,
|
|
"vmdk_cache": vmdk_cache,
|
|
"vm_dir": vm_dir,
|
|
"vmx": vm_dir / "LinuxBuild2404.vmx",
|
|
}
|
|
|
|
|
|
# ── _patch_vmx_key ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_patch_vmx_key_replaces_existing(tmp_path: Path) -> None:
|
|
vmx = tmp_path / "t.vmx"
|
|
vmx.write_text('ide1:0.present = "TRUE"\nother = "val"\n', encoding="ascii")
|
|
tmpl_mod._patch_vmx_key(vmx, "ide1:0.present", "FALSE")
|
|
content = vmx.read_text()
|
|
assert 'ide1:0.present = "FALSE"' in content
|
|
assert '"TRUE"' not in content
|
|
|
|
|
|
def test_patch_vmx_key_appends_when_absent(tmp_path: Path) -> None:
|
|
vmx = tmp_path / "t.vmx"
|
|
vmx.write_text('existing = "val"\n', encoding="ascii")
|
|
tmpl_mod._patch_vmx_key(vmx, "newkey", "nv")
|
|
content = vmx.read_text()
|
|
assert 'newkey = "nv"' in content
|
|
assert 'existing = "val"' in content
|
|
|
|
|
|
# ── _sha256_file ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_sha256_file_correct(tmp_path: Path) -> None:
|
|
import hashlib
|
|
data = b"sha256 test data"
|
|
f = tmp_path / "data.bin"
|
|
f.write_bytes(data)
|
|
assert tmpl_mod._sha256_file(f) == hashlib.sha256(data).hexdigest().upper()
|
|
|
|
|
|
# ── _find_vdisk_manager ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_find_vdisk_manager_uses_which(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(tmpl_mod.shutil, "which", lambda n: "/usr/bin/vmware-vdiskmanager")
|
|
assert tmpl_mod._find_vdisk_manager(None) == "/usr/bin/vmware-vdiskmanager"
|
|
|
|
|
|
def test_find_vdisk_manager_explicit_missing(tmp_path: Path) -> None:
|
|
with pytest.raises(click.ClickException):
|
|
tmpl_mod._find_vdisk_manager(str(tmp_path / "nonexistent"))
|
|
|
|
|
|
def test_find_vdisk_manager_not_in_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(tmpl_mod.shutil, "which", lambda n: None)
|
|
with pytest.raises(click.ClickException, match="PATH"):
|
|
tmpl_mod._find_vdisk_manager(None)
|
|
|
|
|
|
# ── _build_seed_iso ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_build_seed_iso_calls_genisoimage(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
staging = tmp_path / "staging"
|
|
staging.mkdir()
|
|
iso = tmp_path / "seed.iso"
|
|
calls: list[list[str]] = []
|
|
|
|
import subprocess as _sub
|
|
|
|
def fake_run(cmd: list[str], **kw: object) -> _sub.CompletedProcess[bytes]:
|
|
calls.append(list(cmd))
|
|
iso.write_bytes(b"\x00" * 8192)
|
|
return _sub.CompletedProcess(cmd, 0)
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(
|
|
tmpl_mod.shutil, "which",
|
|
lambda n: "/usr/bin/genisoimage" if n == "genisoimage" else None,
|
|
)
|
|
tmpl_mod._build_seed_iso(staging, iso)
|
|
assert calls and "genisoimage" in calls[0][0]
|
|
assert "cidata" in calls[0]
|
|
|
|
|
|
def test_build_seed_iso_no_tool_raises(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(tmpl_mod.shutil, "which", lambda n: None)
|
|
with pytest.raises(click.ClickException, match="genisoimage"):
|
|
tmpl_mod._build_seed_iso(tmp_path, tmp_path / "seed.iso")
|
|
|
|
|
|
# ── _read_guestinfo_ip ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_read_guestinfo_ip_valid(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import subprocess as _sub
|
|
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(cmd, 0, "10.0.0.5\n", ""),
|
|
)
|
|
assert tmpl_mod._read_guestinfo_ip("/vmrun", "/vm.vmx") == "10.0.0.5"
|
|
|
|
|
|
def test_read_guestinfo_ip_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import subprocess as _sub
|
|
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(cmd, 0, "", ""),
|
|
)
|
|
assert tmpl_mod._read_guestinfo_ip("/vmrun", "/vm.vmx") is None
|
|
|
|
|
|
def test_read_guestinfo_ip_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import subprocess as _sub
|
|
|
|
def raise_timeout(cmd: list[str], **kw: object) -> _sub.CompletedProcess[str]:
|
|
raise _sub.TimeoutExpired(cmd, 10)
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", raise_timeout)
|
|
assert tmpl_mod._read_guestinfo_ip("/vmrun", "/vm.vmx") is None
|
|
|
|
|
|
# ── _poll_guest_ip_deploy ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_poll_guest_ip_uses_guestinfo_first(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""If ci-report-ip has written guestinfo.ci-ip, return it without getGuestIPAddress."""
|
|
called: list[list[str]] = []
|
|
import subprocess as _sub
|
|
|
|
def fake_run(cmd: list[str], **kw: object) -> _sub.CompletedProcess[str]:
|
|
called.append(list(cmd))
|
|
if "readVariable" in cmd:
|
|
return _sub.CompletedProcess(cmd, 0, "10.0.1.50", "")
|
|
return _sub.CompletedProcess(cmd, 0, "", "")
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
result = tmpl_mod._poll_guest_ip_deploy("/vmrun", "/vm.vmx", 60.0)
|
|
assert result == "10.0.1.50"
|
|
assert not any("getGuestIPAddress" in " ".join(c) for c in called)
|
|
|
|
|
|
def test_poll_guest_ip_returns_valid(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import subprocess as _sub
|
|
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(cmd, 0, "192.168.1.100", ""),
|
|
)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
assert tmpl_mod._poll_guest_ip_deploy("/vmrun", "/vm.vmx", 60.0) == "192.168.1.100"
|
|
|
|
|
|
def test_poll_guest_ip_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import subprocess as _sub
|
|
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(cmd, 0, "Error: not ready", ""),
|
|
)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
assert tmpl_mod._poll_guest_ip_deploy("/vmrun", "/vm.vmx", 0.0) is None
|
|
|
|
|
|
# ── _wait_ssh_port_deploy ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_wait_ssh_port_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
ctx = MagicMock()
|
|
ctx.__enter__ = lambda s: s
|
|
ctx.__exit__ = lambda s, *a: False
|
|
monkeypatch.setattr(tmpl_mod.socket, "create_connection", lambda *a, **kw: ctx)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
assert tmpl_mod._wait_ssh_port_deploy("192.168.1.100", 22, 10.0, 1.0) is True
|
|
|
|
|
|
def test_wait_ssh_port_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _raise(*a: object, **kw: object) -> None:
|
|
raise OSError("refused")
|
|
|
|
monkeypatch.setattr(tmpl_mod.socket, "create_connection", _raise)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
assert tmpl_mod._wait_ssh_port_deploy("192.168.1.100", 22, 0.0, 1.0) is False
|
|
|
|
|
|
# ── _verify_cloud_init_done ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_verify_cloud_init_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
stdout_m = MagicMock()
|
|
stdout_m.read.return_value = b"OK"
|
|
client_m = MagicMock()
|
|
client_m.exec_command.return_value = (MagicMock(), stdout_m, MagicMock())
|
|
monkeypatch.setattr(tmpl_mod.paramiko, "SSHClient", lambda: client_m)
|
|
monkeypatch.setattr(tmpl_mod.paramiko, "AutoAddPolicy", MagicMock)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
assert tmpl_mod._verify_cloud_init_done("host", "user", "/key", 30.0) is True
|
|
|
|
|
|
def test_verify_cloud_init_exception_retries(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls = [0]
|
|
stdout_m = MagicMock()
|
|
stdout_m.read.return_value = b"OK"
|
|
client_m = MagicMock()
|
|
|
|
def _connect(*a: object, **kw: object) -> None:
|
|
calls[0] += 1
|
|
if calls[0] == 1:
|
|
raise OSError("not yet")
|
|
|
|
client_m.connect.side_effect = _connect
|
|
client_m.exec_command.return_value = (MagicMock(), stdout_m, MagicMock())
|
|
monkeypatch.setattr(tmpl_mod.paramiko, "SSHClient", lambda: client_m)
|
|
monkeypatch.setattr(tmpl_mod.paramiko, "AutoAddPolicy", MagicMock)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
# Give enough time budget for 2 tries
|
|
assert tmpl_mod._verify_cloud_init_done("host", "user", "/key", 60.0) is True
|
|
|
|
|
|
def test_verify_cloud_init_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
client_m = MagicMock()
|
|
client_m.connect.side_effect = OSError("refused")
|
|
monkeypatch.setattr(tmpl_mod.paramiko, "SSHClient", lambda: client_m)
|
|
monkeypatch.setattr(tmpl_mod.paramiko, "AutoAddPolicy", MagicMock)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
assert tmpl_mod._verify_cloud_init_done("host", "user", "/key", 0.0) is False
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# template deploy-linux — CLI tests
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def test_deploy_linux_help() -> None:
|
|
result = CliRunner().invoke(cli, ["template", "deploy-linux", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "Ubuntu" in result.output
|
|
|
|
|
|
def test_deploy_linux_preflight_missing_pub_key(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
(deploy_env["ssh_key"].parent / "ci_linux.pub").unlink()
|
|
result = CliRunner().invoke(
|
|
cli, ["template", "deploy-linux", "--vmx", str(deploy_env["vmx"])]
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "ci_linux.pub" in result.output or "public key" in result.output.lower()
|
|
|
|
|
|
def test_deploy_linux_vm_dir_exists_no_force(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
result = CliRunner().invoke(
|
|
cli, ["template", "deploy-linux", "--vmx", str(deploy_env["vmx"])]
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "force" in result.output.lower()
|
|
|
|
|
|
def test_deploy_linux_sha256_mismatch(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(deploy_env["vmx"]),
|
|
"--force",
|
|
"--vmdk-sha256", "DEADBEEF" + "0" * 56,
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "mismatch" in result.output.lower()
|
|
|
|
|
|
def test_deploy_linux_step3_iso_fail(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import subprocess as _sub
|
|
|
|
def fake_run(cmd: list[str], **kw: object) -> _sub.CompletedProcess[bytes]:
|
|
if "genisoimage" in str(cmd[0]):
|
|
raise _sub.CalledProcessError(1, cmd, b"", b"genisoimage error")
|
|
return _sub.CompletedProcess(cmd, 0)
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(deploy_env["vmx"]),
|
|
"--force",
|
|
"--start-from-step", "2",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "ISO creation failed" in result.output
|
|
|
|
|
|
def test_deploy_linux_step4_convert_fail(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import subprocess as _sub
|
|
|
|
def fake_run(cmd: list[str], **kw: object) -> _sub.CompletedProcess[bytes]:
|
|
if "vmware-vdiskmanager" in str(cmd[0]):
|
|
return _sub.CompletedProcess(cmd, 1, b"", b"convert error")
|
|
return _sub.CompletedProcess(cmd, 0)
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(deploy_env["vmx"]),
|
|
"--force",
|
|
"--start-from-step", "4",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "convert failed" in result.output
|
|
|
|
|
|
def test_deploy_linux_step5_vmx_content(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import subprocess as _sub
|
|
|
|
popen_m = MagicMock()
|
|
popen_m.pid = 1
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "Popen", lambda *a, **kw: popen_m)
|
|
|
|
# vmrun list returns empty so step 6 fails — we can still check VMX written in step 5
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "Total running VMs: 0\n", ""),
|
|
)
|
|
|
|
CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(deploy_env["vmx"]),
|
|
"--force",
|
|
"--start-from-step", "5",
|
|
],
|
|
)
|
|
# Step 6 should fail (VM not in list), but VMX should have been written in step 5
|
|
assert deploy_env["vmx"].exists()
|
|
content = deploy_env["vmx"].read_text()
|
|
assert 'guestOS = "ubuntu-64"' in content
|
|
assert 'firmware = "efi"' in content
|
|
assert 'ethernet0.virtualDev = "vmxnet3"' in content
|
|
|
|
|
|
def test_deploy_linux_step5_guestinfo_skipped_when_step2_skipped(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import subprocess as _sub
|
|
|
|
popen_m = MagicMock()
|
|
popen_m.pid = 1
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "Popen", lambda *a, **kw: popen_m)
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "Total running VMs: 0\n", ""),
|
|
)
|
|
|
|
CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(deploy_env["vmx"]),
|
|
"--force",
|
|
"--start-from-step", "5", # step 2 was skipped → no b64 strings
|
|
],
|
|
)
|
|
if deploy_env["vmx"].exists():
|
|
content = deploy_env["vmx"].read_text()
|
|
assert "guestinfo.userdata" not in content
|
|
|
|
|
|
def test_deploy_linux_step7_ip_timeout(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import subprocess as _sub
|
|
|
|
popen_m = MagicMock()
|
|
popen_m.pid = 1
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "Popen", lambda *a, **kw: popen_m)
|
|
|
|
vmx_str = str(deploy_env["vmx"])
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(
|
|
list(cmd), 0, f"Total running VMs: 1\n{vmx_str}\n", ""
|
|
),
|
|
)
|
|
# patch the poller so it returns None immediately (no 300-second spin loop)
|
|
monkeypatch.setattr(tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: None)
|
|
|
|
deploy_env["vmx"].write_text('dummy = "vmx"', encoding="ascii")
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(deploy_env["vmx"]),
|
|
"--force",
|
|
"--start-from-step", "6",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "guest IP" in result.output
|
|
|
|
|
|
def test_deploy_linux_step8_no_guest_ip(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
deploy_env["vmx"].write_text('dummy = "vmx"', encoding="ascii")
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(deploy_env["vmx"]),
|
|
"--force",
|
|
"--start-from-step", "8",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "Guest IP not available" in result.output
|
|
|
|
|
|
def test_deploy_linux_step10_patches_vmx(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
vmx = deploy_env["vmx"]
|
|
vmx.write_text(
|
|
'ide1:0.present = "TRUE"\nide1:0.startConnected = "TRUE"\n',
|
|
encoding="ascii",
|
|
)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(vmx),
|
|
"--force",
|
|
"--start-from-step", "10",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
content = vmx.read_text()
|
|
assert 'ide1:0.present = "FALSE"' in content
|
|
assert 'ide1:0.startConnected = "FALSE"' in content
|
|
|
|
|
|
def test_deploy_linux_full_happy_path(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import subprocess as _sub
|
|
|
|
vmx = deploy_env["vmx"]
|
|
|
|
# `vmrun list` must reflect reality: the VM is NOT running before Step 6
|
|
# powers it on. A mock that always reports it running hides the --force
|
|
# pre-flight guard (deploy must refuse to rewrite a live VM's disk).
|
|
started = {"value": False}
|
|
|
|
def fake_run(cmd: list[str], **kw: object) -> _sub.CompletedProcess[str]:
|
|
cmd_str = " ".join(str(c) for c in cmd)
|
|
if "genisoimage" in cmd_str:
|
|
for i, arg in enumerate(cmd):
|
|
if i > 0 and cmd[i - 1] == "-o":
|
|
Path(arg).write_bytes(b"\x00" * 8192)
|
|
return _sub.CompletedProcess(cmd, 0, "", "")
|
|
if "vmware-vdiskmanager" in cmd_str and "-r" in cmd:
|
|
dest = str(cmd[-1])
|
|
Path(dest).write_bytes(b"\x00" * 1024)
|
|
return _sub.CompletedProcess(cmd, 0, "", "")
|
|
if "vmware-vdiskmanager" in cmd_str:
|
|
return _sub.CompletedProcess(cmd, 0, "", "")
|
|
if "start" in cmd:
|
|
started["value"] = True
|
|
return _sub.CompletedProcess(cmd, 0, "", "")
|
|
if "list" in cmd:
|
|
body = (
|
|
f"Total running VMs: 1\n{vmx}\n"
|
|
if started["value"]
|
|
else "Total running VMs: 0\n"
|
|
)
|
|
return _sub.CompletedProcess(cmd, 0, body, "")
|
|
if "getGuestIPAddress" in cmd:
|
|
return _sub.CompletedProcess(cmd, 0, "192.168.79.200", "")
|
|
return _sub.CompletedProcess(cmd, 0, "", "")
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
|
|
|
|
popen_m = MagicMock()
|
|
popen_m.pid = 99
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "Popen", lambda *a, **kw: popen_m)
|
|
|
|
sock_m = MagicMock()
|
|
sock_m.__enter__ = lambda s: s
|
|
sock_m.__exit__ = lambda s, *a: False
|
|
monkeypatch.setattr(tmpl_mod.socket, "create_connection", lambda *a, **kw: sock_m)
|
|
|
|
stdout_m = MagicMock()
|
|
stdout_m.read.return_value = b"OK"
|
|
client_m = MagicMock()
|
|
client_m.exec_command.return_value = (MagicMock(), stdout_m, MagicMock())
|
|
monkeypatch.setattr(tmpl_mod.paramiko, "SSHClient", lambda: client_m)
|
|
monkeypatch.setattr(tmpl_mod.paramiko, "AutoAddPolicy", MagicMock)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "deploy-linux", "--vmx", str(vmx), "--force"],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
assert vmx.exists()
|
|
vmx_content = vmx.read_text()
|
|
assert 'guestOS = "ubuntu-64"' in vmx_content
|
|
assert 'ide1:0.present = "FALSE"' in vmx_content
|
|
assert 'ide1:0.startConnected = "FALSE"' in vmx_content
|
|
assert "guestinfo.userdata" in vmx_content # b64 from step 2
|
|
assert "192.168.79.200" in result.output # guest IP in summary
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# template prepare-linux — helper unit tests
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def test_pl_run_returns_stdout_and_rc(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
stdout_m = MagicMock()
|
|
stdout_m.read.return_value = b"hello\n"
|
|
stdout_m.channel.recv_exit_status.return_value = 0
|
|
stderr_m = MagicMock()
|
|
stderr_m.read.return_value = b""
|
|
client_m = MagicMock()
|
|
client_m.exec_command.return_value = (MagicMock(), stdout_m, stderr_m)
|
|
|
|
rc, out, err = tmpl_mod._pl_run(client_m, "echo hello")
|
|
assert rc == 0
|
|
assert "hello" in out
|
|
assert err == ""
|
|
|
|
|
|
def test_pl_run_nonzero_rc(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
stdout_m = MagicMock()
|
|
stdout_m.read.return_value = b""
|
|
stdout_m.channel.recv_exit_status.return_value = 1
|
|
stderr_m = MagicMock()
|
|
stderr_m.read.return_value = b"error"
|
|
client_m = MagicMock()
|
|
client_m.exec_command.return_value = (MagicMock(), stdout_m, stderr_m)
|
|
|
|
rc, _, err = tmpl_mod._pl_run(client_m, "false")
|
|
assert rc == 1
|
|
assert "error" in err
|
|
|
|
|
|
def test_pl_stream_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
channel_m = MagicMock()
|
|
# First call: stdout data; second: empty → exit_status_ready True on second loop
|
|
channel_m.recv_ready.side_effect = [True, False, False]
|
|
channel_m.recv.return_value = b"output line\n"
|
|
channel_m.recv_stderr_ready.return_value = False
|
|
channel_m.exit_status_ready.side_effect = [False, True]
|
|
channel_m.recv_exit_status.return_value = 0
|
|
|
|
transport_m = MagicMock()
|
|
transport_m.open_session.return_value = channel_m
|
|
client_m = MagicMock()
|
|
client_m.get_transport.return_value = transport_m
|
|
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
rc = tmpl_mod._pl_stream(client_m, "cat file")
|
|
assert rc == 0
|
|
|
|
|
|
def test_pl_upload_calls_sftp_put(tmp_path: Path) -> None:
|
|
local = tmp_path / "script.sh"
|
|
local.write_text("#!/bin/bash\n")
|
|
sftp_m = MagicMock()
|
|
client_m = MagicMock()
|
|
client_m.open_sftp.return_value = sftp_m
|
|
|
|
tmpl_mod._pl_upload(client_m, local, "/tmp/script.sh")
|
|
sftp_m.put.assert_called_once_with(str(local), "/tmp/script.sh")
|
|
sftp_m.close.assert_called_once()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# template prepare-linux — CLI tests
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
@pytest.fixture()
|
|
def prepare_env(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> dict:
|
|
"""Environment with SSH keys, VMX, and toolchain script."""
|
|
ssh_key = _make_keys(tmp_path / "keys")
|
|
vm_dir = tmp_path / "templates" / "LinuxBuild2404"
|
|
vm_dir.mkdir(parents=True)
|
|
vmx = vm_dir / "LinuxBuild2404.vmx"
|
|
vmx.write_text('guestOS = "ubuntu-64"\n', encoding="ascii")
|
|
|
|
# toolchain script in repo-relative location
|
|
tmpl_dir = tmp_path / "template"
|
|
tmpl_dir.mkdir()
|
|
toolchain = tmpl_dir / "Install-CIToolchain-Linux2404.sh"
|
|
toolchain.write_text("#!/bin/bash\necho done\n")
|
|
|
|
config = _deploy_config(tmp_path)
|
|
monkeypatch.setattr(tmpl_mod, "load_config", lambda: config)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
return {
|
|
"tmp_path": tmp_path,
|
|
"ssh_key": ssh_key,
|
|
"vmx": vmx,
|
|
"toolchain": toolchain,
|
|
}
|
|
|
|
|
|
def _stub_prepare(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
env: dict,
|
|
*,
|
|
toolchain_exit: int = 0,
|
|
val_output: str = (
|
|
"UserSudo:OK\nSudoNoPass:OK\nTool:gcc:OK\nTool:g++:OK\n"
|
|
"Tool:clang:OK\nTool:cmake:OK\nTool:python3:OK\nTool:git:OK\n"
|
|
"Tool:7z:OK\nDir:build:OK\nDir:output:OK\nDir:scripts:OK\n"
|
|
"Swap:off:OK\nSshd:active:OK\nSSHPwdAuth:disabled:OK\n"
|
|
"CIReportIP:enabled:OK\nCIReportIPScript:exists:OK\n"
|
|
),
|
|
machine_id_size: str = "0",
|
|
) -> MagicMock:
|
|
"""Stub all SSH / vmrun calls for prepare-linux happy-path testing."""
|
|
client_m = MagicMock()
|
|
raw = env["toolchain"].read_bytes()
|
|
if raw[:3] == b"\xef\xbb\xbf":
|
|
raw = raw[3:]
|
|
_toolchain_size = str(len(raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")))
|
|
|
|
def fake_pl_run(
|
|
client: object, cmd: str, timeout: float = 60.0
|
|
) -> tuple[int, str, str]:
|
|
if "stat -c %s /tmp/" in cmd:
|
|
return 0, _toolchain_size, ""
|
|
if "stat -c %s /etc/machine-id" in cmd:
|
|
return 0, machine_id_size, ""
|
|
if "truncate" in cmd:
|
|
return 0, "", ""
|
|
if "shutdown" in cmd:
|
|
return 0, "", ""
|
|
if "echo connected" in cmd:
|
|
return 0, "connected", ""
|
|
# Step 7d cloud-init seal verification.
|
|
if "cloud-init.disabled" in cmd and "echo DISABLED" in cmd:
|
|
return 0, "DISABLED\nNO50\n", ""
|
|
# validation script
|
|
return 0, val_output, ""
|
|
|
|
monkeypatch.setattr(tmpl_mod, "_pl_connect", lambda *a, **kw: client_m)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_run", fake_pl_run)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_stream", lambda *a, **kw: toolchain_exit)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_upload", lambda *a, **kw: None)
|
|
monkeypatch.setattr(tmpl_mod, "_wait_ssh_port_deploy", lambda *a, **kw: True)
|
|
return client_m
|
|
|
|
|
|
def test_prepare_linux_missing_vmx_and_ip(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Without --vmx/--ip AND no default template VMX on disk → error."""
|
|
prepare_env["vmx"].unlink() # default LinuxBuild2404.vmx absent
|
|
result = CliRunner().invoke(cli, ["template", "prepare-linux"])
|
|
assert result.exit_code != 0
|
|
assert "--vmx" in result.output or "--ip" in result.output
|
|
|
|
|
|
def test_prepare_linux_default_vmx_used(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Without --vmx/--ip, the default LinuxBuild2404 VMX is picked up."""
|
|
_stub_prepare(monkeypatch, prepare_env)
|
|
monkeypatch.setattr(tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: "192.168.1.200")
|
|
|
|
backend_m = MagicMock()
|
|
backend_m.is_running.side_effect = [False, False]
|
|
backend_m.list_snapshots.return_value = []
|
|
monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
|
|
|
|
import subprocess as _sub
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "", ""),
|
|
)
|
|
|
|
result = CliRunner().invoke(cli, ["template", "prepare-linux"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "LinuxBuild2404.vmx" in result.output
|
|
assert "192.168.1.200" in result.output
|
|
|
|
|
|
def test_prepare_linux_toolchain_not_found(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
prepare_env["toolchain"].unlink()
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--ip", "192.168.1.100"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "not found" in result.output.lower()
|
|
|
|
|
|
def test_prepare_linux_ssh_key_missing(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
prepare_env["ssh_key"].unlink()
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--ip", "192.168.1.100"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "not found" in result.output.lower()
|
|
|
|
|
|
def test_prepare_linux_ssh_connect_fail(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
tmpl_mod, "_wait_ssh_port_deploy", lambda *a, **kw: True
|
|
)
|
|
monkeypatch.setattr(
|
|
tmpl_mod, "_pl_connect",
|
|
lambda *a, **kw: (_ for _ in ()).throw(OSError("refused")),
|
|
)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--ip", "192.168.1.100"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "failed" in result.output.lower()
|
|
|
|
|
|
def test_prepare_linux_ssh_port_timeout(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(tmpl_mod, "_wait_ssh_port_deploy", lambda *a, **kw: False)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--ip", "192.168.1.100"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "not reachable" in result.output or "SSH" in result.output
|
|
|
|
|
|
def test_prepare_linux_toolchain_fail(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_stub_prepare(monkeypatch, prepare_env, toolchain_exit=1)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--ip", "192.168.1.100"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "exited 1" in result.output or "Toolchain" in result.output
|
|
|
|
|
|
def test_prepare_linux_validation_fail(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_stub_prepare(
|
|
monkeypatch, prepare_env,
|
|
val_output="UserSudo:OK\nTool:gcc:FAIL\nDir:build:OK\n",
|
|
)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--ip", "192.168.1.100"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "FAIL" in result.output
|
|
|
|
|
|
def test_prepare_linux_machine_id_not_cleared(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_stub_prepare(monkeypatch, prepare_env, machine_id_size="36")
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--ip", "192.168.1.100"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "machine-id" in result.output.lower()
|
|
|
|
|
|
def test_prepare_linux_happy_path_ip_only(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""No --vmx: provisions but skips auto power-on, IP detect, and snapshot."""
|
|
_stub_prepare(monkeypatch, prepare_env)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--ip", "192.168.1.100"],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Provisioning complete" in result.output
|
|
assert "not taken" in result.output # snapshot skipped
|
|
|
|
|
|
def test_prepare_linux_happy_path_with_vmx(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""--vmx: auto IP detect + shutdown + snapshot."""
|
|
_stub_prepare(monkeypatch, prepare_env)
|
|
monkeypatch.setattr(tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: "192.168.1.200")
|
|
|
|
backend_m = MagicMock()
|
|
backend_m.is_running.side_effect = [False, True, False] # start, check, powered off
|
|
backend_m.list_snapshots.return_value = []
|
|
monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
|
|
|
|
import subprocess as _sub
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "", ""),
|
|
)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--vmx", str(prepare_env["vmx"])],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Snapshot" in result.output
|
|
assert "192.168.1.200" in result.output
|
|
|
|
|
|
def test_prepare_linux_zombie_vm_restarts(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""VM process alive but guest powered off → quick probe fails → stop+restart."""
|
|
_stub_prepare(monkeypatch, prepare_env)
|
|
|
|
# First _poll call (quick probe, 30s) returns None; second (full 300s) returns IP.
|
|
poll_calls: list[float] = []
|
|
|
|
def fake_poll(vmrun: str, vmx: str, timeout: float) -> str | None:
|
|
poll_calls.append(timeout)
|
|
return None if timeout == 30.0 else "192.168.1.200"
|
|
|
|
monkeypatch.setattr(tmpl_mod, "_poll_guest_ip_deploy", fake_poll)
|
|
|
|
backend_m = MagicMock()
|
|
backend_m.is_running.side_effect = [True, True, False] # already running; post-stop checks
|
|
backend_m.list_snapshots.return_value = []
|
|
monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
|
|
|
|
import subprocess as _sub
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "", ""),
|
|
)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--vmx", str(prepare_env["vmx"])],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "restarting" in result.output
|
|
backend_m.stop.assert_called_once()
|
|
backend_m.start.assert_called()
|
|
assert 30.0 in poll_calls
|
|
|
|
|
|
def test_prepare_linux_happy_path_skip_flags(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""--skip-update, --install-dotnet, --no-mingw reach the toolchain command."""
|
|
streamed: list[str] = []
|
|
client_m = MagicMock()
|
|
|
|
_raw = prepare_env["toolchain"].read_bytes()
|
|
_tsize = str(len(_raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")))
|
|
|
|
def fake_pl_run(client: object, cmd: str, timeout: float = 60.0) -> tuple[int, str, str]:
|
|
if "stat -c %s /tmp/" in cmd:
|
|
return 0, _tsize, ""
|
|
if "stat -c %s /etc/machine-id" in cmd:
|
|
return 0, "0", ""
|
|
if "truncate" in cmd:
|
|
return 0, "", ""
|
|
if "echo connected" in cmd:
|
|
return 0, "connected", ""
|
|
if "cloud-init.disabled" in cmd and "echo DISABLED" in cmd:
|
|
return 0, "DISABLED\nNO50\n", ""
|
|
return 0, "UserSudo:OK\nSudoNoPass:OK\n", ""
|
|
|
|
def fake_pl_stream(client: object, cmd: str) -> int:
|
|
streamed.append(cmd)
|
|
return 0
|
|
|
|
monkeypatch.setattr(tmpl_mod, "_pl_connect", lambda *a, **kw: client_m)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_run", fake_pl_run)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_stream", fake_pl_stream)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_upload", lambda *a, **kw: None)
|
|
monkeypatch.setattr(tmpl_mod, "_wait_ssh_port_deploy", lambda *a, **kw: True)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-linux",
|
|
"--ip", "192.168.1.100",
|
|
"--skip-update", "--install-dotnet", "--no-mingw",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert streamed
|
|
assert "--skip-update" in streamed[0]
|
|
assert "--dotnet" in streamed[0]
|
|
assert "--no-mingw" in streamed[0]
|
|
|
|
|
|
def test_prepare_linux_existing_snapshot_deleted(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""If snapshot already exists, delete before recreating."""
|
|
_stub_prepare(monkeypatch, prepare_env)
|
|
monkeypatch.setattr(tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: "192.168.1.200")
|
|
|
|
backend_m = MagicMock()
|
|
backend_m.is_running.side_effect = [True, False]
|
|
backend_m.list_snapshots.return_value = ["BaseClean-Linux"]
|
|
monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
|
|
|
|
run_calls: list[list[str]] = []
|
|
import subprocess as _sub
|
|
|
|
def fake_run(cmd: list[str], **kw: object) -> _sub.CompletedProcess[str]:
|
|
run_calls.append(list(cmd))
|
|
return _sub.CompletedProcess(cmd, 0, "", "")
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "prepare-linux", "--vmx", str(prepare_env["vmx"])],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
cmds = [" ".join(c) for c in run_calls]
|
|
assert any("deleteSnapshot" in c for c in cmds)
|
|
assert any("snapshot" in c and "deleteSnapshot" not in c for c in cmds)
|
|
|
|
|
|
# ── _clean_force_artifacts (deploy-linux --force snapshot-chain cleanup) ────────
|
|
|
|
|
|
def test_clean_force_artifacts_removes_snapshot_chain(tmp_path: Path) -> None:
|
|
"""Snapshot delta/.vmsn/.vmsd/.lck are removed; base disk + vmx kept."""
|
|
(tmp_path / "LinuxBuild2404.vmdk").write_text("base")
|
|
(tmp_path / "LinuxBuild2404.vmx").write_text("vmx")
|
|
(tmp_path / "LinuxBuild2404-000001.vmdk").write_text("delta")
|
|
(tmp_path / "LinuxBuild2404-000002.vmdk").write_text("delta2")
|
|
(tmp_path / "LinuxBuild2404-Snapshot7.vmsn").write_text("snap")
|
|
(tmp_path / "LinuxBuild2404.vmsd").write_text("snapdb")
|
|
(tmp_path / "LinuxBuild2404.vmdk.lck").write_text("lock")
|
|
|
|
removed = tmpl_mod._clean_force_artifacts(tmp_path, "LinuxBuild2404")
|
|
|
|
assert set(removed) == {
|
|
"LinuxBuild2404-000001.vmdk",
|
|
"LinuxBuild2404-000002.vmdk",
|
|
"LinuxBuild2404-Snapshot7.vmsn",
|
|
"LinuxBuild2404.vmsd",
|
|
"LinuxBuild2404.vmdk.lck",
|
|
}
|
|
# Base disk and vmx survive (Step 4/5 overwrite them).
|
|
assert (tmp_path / "LinuxBuild2404.vmdk").is_file()
|
|
assert (tmp_path / "LinuxBuild2404.vmx").is_file()
|
|
# Chain artifacts gone.
|
|
assert not (tmp_path / "LinuxBuild2404-000001.vmdk").exists()
|
|
assert not (tmp_path / "LinuxBuild2404.vmsd").exists()
|
|
|
|
|
|
def test_clean_force_artifacts_removes_lock_directory(tmp_path: Path) -> None:
|
|
"""Lock entries may be directories (VMware writes .lck dirs)."""
|
|
lck = tmp_path / "LinuxBuild2404.vmx.lck"
|
|
lck.mkdir()
|
|
(lck / "M12345.lck").write_text("host")
|
|
|
|
removed = tmpl_mod._clean_force_artifacts(tmp_path, "LinuxBuild2404")
|
|
|
|
assert removed == ["LinuxBuild2404.vmx.lck"]
|
|
assert not lck.exists()
|
|
|
|
|
|
def test_clean_force_artifacts_noop_on_clean_dir(tmp_path: Path) -> None:
|
|
"""A clean directory yields no removals and leaves the base disk."""
|
|
(tmp_path / "LinuxBuild2404.vmdk").write_text("base")
|
|
|
|
removed = tmpl_mod._clean_force_artifacts(tmp_path, "LinuxBuild2404")
|
|
|
|
assert removed == []
|
|
assert (tmp_path / "LinuxBuild2404.vmdk").is_file()
|
|
|
|
|
|
def test_check_guest_idle_ok() -> None:
|
|
transport = MagicMock()
|
|
transport.run.return_value = WinRmResult(
|
|
stdout='{"cpu":3.0,"ram":22.5,"top":"idle"}', stderr="", returncode=0
|
|
)
|
|
tmpl_mod._check_guest_idle(transport, max_cpu=10.0, max_ram=50.0, settle_s=0, timeout_s=5)
|
|
|
|
|
|
def test_check_guest_idle_cpu_over() -> None:
|
|
transport = MagicMock()
|
|
transport.run.return_value = WinRmResult(
|
|
stdout='{"cpu":42.0,"ram":20.0,"top":"TiWorker"}', stderr="", returncode=0
|
|
)
|
|
with pytest.raises(click.ClickException, match=r"CPU 42\.0% > 10\.0%"):
|
|
tmpl_mod._check_guest_idle(transport, max_cpu=10.0, max_ram=50.0, settle_s=0, timeout_s=0)
|
|
|
|
|
|
def test_check_guest_idle_ram_over() -> None:
|
|
transport = MagicMock()
|
|
transport.run.return_value = WinRmResult(
|
|
stdout='{"cpu":1.0,"ram":80.0,"top":"x"}', stderr="", returncode=0
|
|
)
|
|
with pytest.raises(click.ClickException, match=r"RAM 80\.0% > 50\.0%"):
|
|
tmpl_mod._check_guest_idle(transport, max_cpu=10.0, max_ram=50.0, settle_s=0, timeout_s=0)
|
|
|
|
|
|
def test_check_guest_idle_unparseable() -> None:
|
|
transport = MagicMock()
|
|
transport.run.return_value = WinRmResult(stdout="not json", stderr="", returncode=0)
|
|
with pytest.raises(click.ClickException, match="could not parse"):
|
|
tmpl_mod._sample_guest_idle(transport)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# template deploy-rust / prepare-rust — CLI tests
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def test_deploy_rust_help() -> None:
|
|
result = CliRunner().invoke(cli, ["template", "deploy-rust", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "LinuxRust2404" in result.output
|
|
|
|
|
|
def test_deploy_rust_forwards_to_deploy_linux(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""deploy-rust calls the deploy-linux callback with the Rust defaults."""
|
|
captured: dict = {}
|
|
|
|
def fake_cb(**kwargs: object) -> None:
|
|
captured.update(kwargs)
|
|
|
|
monkeypatch.setattr(tmpl_mod.template_deploy_linux, "callback", fake_cb)
|
|
|
|
result = CliRunner().invoke(cli, ["template", "deploy-rust", "--force"])
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["vm_name"] == "LinuxRust2404"
|
|
assert captured["guest_hostname"] == "ci-rust-template"
|
|
assert captured["disk_size_gb"] == 60
|
|
assert captured["force"] is True
|
|
# Default VMX points at the LinuxRust2404 template dir.
|
|
vmx = str(captured["vmx_path_str"])
|
|
assert vmx.endswith("LinuxRust2404/LinuxRust2404.vmx")
|
|
|
|
|
|
def test_deploy_rust_vmx_override(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""An explicit --vmx is forwarded untouched."""
|
|
captured: dict = {}
|
|
monkeypatch.setattr(
|
|
tmpl_mod.template_deploy_linux, "callback",
|
|
lambda **kw: captured.update(kw),
|
|
)
|
|
result = CliRunner().invoke(
|
|
cli, ["template", "deploy-rust", "--vmx", "/tmp/custom/Custom.vmx"]
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["vmx_path_str"] == "/tmp/custom/Custom.vmx"
|
|
|
|
|
|
@pytest.fixture()
|
|
def prepare_rust_env(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> dict:
|
|
"""Environment with SSH keys, VMX, and the Rust toolchain script."""
|
|
ssh_key = _make_keys(tmp_path / "keys")
|
|
vm_dir = tmp_path / "templates" / "LinuxRust2404"
|
|
vm_dir.mkdir(parents=True)
|
|
vmx = vm_dir / "LinuxRust2404.vmx"
|
|
vmx.write_text('guestOS = "ubuntu-64"\n', encoding="ascii")
|
|
|
|
tmpl_dir = tmp_path / "template"
|
|
tmpl_dir.mkdir()
|
|
toolchain = tmpl_dir / "Install-CIToolchain-LinuxRust2404.sh"
|
|
toolchain.write_text("#!/bin/bash\necho done\n")
|
|
|
|
config = _deploy_config(tmp_path)
|
|
monkeypatch.setattr(tmpl_mod, "load_config", lambda: config)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
return {
|
|
"tmp_path": tmp_path,
|
|
"ssh_key": ssh_key,
|
|
"vmx": vmx,
|
|
"toolchain": toolchain,
|
|
}
|
|
|
|
|
|
def _stub_prepare_rust(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
env: dict,
|
|
*,
|
|
streamed: list[str] | None = None,
|
|
) -> MagicMock:
|
|
"""Stub SSH/vmrun for prepare-rust; records the streamed toolchain command."""
|
|
client_m = MagicMock()
|
|
raw = env["toolchain"].read_bytes()
|
|
if raw[:3] == b"\xef\xbb\xbf":
|
|
raw = raw[3:]
|
|
_toolchain_size = str(len(raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")))
|
|
|
|
def fake_pl_run(
|
|
client: object, cmd: str, timeout: float = 60.0
|
|
) -> tuple[int, str, str]:
|
|
if "stat -c %s /tmp/" in cmd:
|
|
return 0, _toolchain_size, ""
|
|
if "stat -c %s /etc/machine-id" in cmd:
|
|
return 0, "0", ""
|
|
if "truncate" in cmd:
|
|
return 0, "", ""
|
|
if "shutdown" in cmd:
|
|
return 0, "", ""
|
|
if "echo connected" in cmd:
|
|
return 0, "connected", ""
|
|
if "cloud-init.disabled" in cmd and "echo DISABLED" in cmd:
|
|
return 0, "DISABLED\nNO50\n", ""
|
|
return 0, "UserSudo:OK\nSudoNoPass:OK\n", ""
|
|
|
|
def fake_pl_stream(client: object, cmd: str) -> int:
|
|
if streamed is not None:
|
|
streamed.append(cmd)
|
|
return 0
|
|
|
|
monkeypatch.setattr(tmpl_mod, "_pl_connect", lambda *a, **kw: client_m)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_run", fake_pl_run)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_stream", fake_pl_stream)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_upload", lambda *a, **kw: None)
|
|
monkeypatch.setattr(tmpl_mod, "_wait_ssh_port_deploy", lambda *a, **kw: True)
|
|
return client_m
|
|
|
|
|
|
def test_prepare_rust_help() -> None:
|
|
result = CliRunner().invoke(cli, ["template", "prepare-rust", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "--rust-channel" in result.output
|
|
assert "--no-cargo-audit" in result.output
|
|
|
|
|
|
def test_prepare_rust_missing_vmx_and_ip(prepare_rust_env: dict) -> None:
|
|
"""Without --vmx/--ip AND no default template VMX on disk → error."""
|
|
prepare_rust_env["vmx"].unlink() # default LinuxRust2404.vmx absent
|
|
result = CliRunner().invoke(cli, ["template", "prepare-rust"])
|
|
assert result.exit_code != 0
|
|
assert "--vmx" in result.output or "--ip" in result.output
|
|
|
|
|
|
def test_prepare_rust_default_vmx_used(
|
|
prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Without --vmx/--ip, the default LinuxRust2404 VMX is picked up."""
|
|
_stub_prepare_rust(monkeypatch, prepare_rust_env)
|
|
monkeypatch.setattr(tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: "192.168.1.200")
|
|
|
|
backend_m = MagicMock()
|
|
backend_m.is_running.side_effect = [False, False]
|
|
backend_m.list_snapshots.return_value = []
|
|
monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
|
|
|
|
import subprocess as _sub
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "", ""),
|
|
)
|
|
|
|
result = CliRunner().invoke(cli, ["template", "prepare-rust"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "LinuxRust2404.vmx" in result.output
|
|
assert "BaseClean-LinuxRust" in result.output
|
|
|
|
|
|
def test_prepare_rust_toolchain_not_found(prepare_rust_env: dict) -> None:
|
|
prepare_rust_env["toolchain"].unlink()
|
|
result = CliRunner().invoke(
|
|
cli, ["template", "prepare-rust", "--ip", "192.168.1.100"]
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "not found" in result.output.lower()
|
|
assert "LinuxRust2404" in result.output
|
|
|
|
|
|
def test_prepare_rust_happy_path_defaults(
|
|
prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Default invocation: Rust toolchain script, BaseClean-LinuxRust, stable channel."""
|
|
streamed: list[str] = []
|
|
_stub_prepare_rust(monkeypatch, prepare_rust_env, streamed=streamed)
|
|
|
|
result = CliRunner().invoke(
|
|
cli, ["template", "prepare-rust", "--ip", "192.168.1.100"]
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "[prepare-rust]" in result.output
|
|
assert "BaseClean-LinuxRust" in result.output
|
|
assert streamed
|
|
assert "Install-CIToolchain-LinuxRust2404.sh" in streamed[0]
|
|
assert "--rust-channel stable" in streamed[0]
|
|
assert "--no-cargo-audit" not in streamed[0]
|
|
|
|
|
|
def test_prepare_rust_forwards_rust_flags(
|
|
prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""--rust-channel and --no-cargo-audit reach the toolchain command."""
|
|
streamed: list[str] = []
|
|
_stub_prepare_rust(monkeypatch, prepare_rust_env, streamed=streamed)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-rust",
|
|
"--ip", "192.168.1.100",
|
|
"--rust-channel", "1.97.1",
|
|
"--no-cargo-audit",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert streamed
|
|
assert "--rust-channel 1.97.1" in streamed[0]
|
|
assert "--no-cargo-audit" in streamed[0]
|
|
|
|
|
|
def test_prepare_rust_snapshot_override(
|
|
prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""--snapshot overrides the BaseClean-LinuxRust default."""
|
|
_stub_prepare_rust(monkeypatch, prepare_rust_env)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "prepare-rust",
|
|
"--ip", "192.168.1.100",
|
|
"--snapshot", "CustomSnap",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "CustomSnap" in result.output
|
|
|
|
|
|
def test_prepare_linux_unchanged_defaults(
|
|
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Regression: prepare-linux still uses the Linux toolchain + BaseClean-Linux
|
|
and never forwards rust flags."""
|
|
streamed: list[str] = []
|
|
client_m = MagicMock()
|
|
raw = prepare_env["toolchain"].read_bytes()
|
|
_tsize = str(len(raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")))
|
|
|
|
def fake_pl_run(client: object, cmd: str, timeout: float = 60.0) -> tuple[int, str, str]:
|
|
if "stat -c %s /tmp/" in cmd:
|
|
return 0, _tsize, ""
|
|
if "stat -c %s /etc/machine-id" in cmd:
|
|
return 0, "0", ""
|
|
if "truncate" in cmd or "shutdown" in cmd:
|
|
return 0, "", ""
|
|
if "echo connected" in cmd:
|
|
return 0, "connected", ""
|
|
if "cloud-init.disabled" in cmd and "echo DISABLED" in cmd:
|
|
return 0, "DISABLED\nNO50\n", ""
|
|
return 0, "UserSudo:OK\nSudoNoPass:OK\n", ""
|
|
|
|
monkeypatch.setattr(tmpl_mod, "_pl_connect", lambda *a, **kw: client_m)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_run", fake_pl_run)
|
|
monkeypatch.setattr(
|
|
tmpl_mod, "_pl_stream",
|
|
lambda c, cmd: streamed.append(cmd) or 0,
|
|
)
|
|
monkeypatch.setattr(tmpl_mod, "_pl_upload", lambda *a, **kw: None)
|
|
monkeypatch.setattr(tmpl_mod, "_wait_ssh_port_deploy", lambda *a, **kw: True)
|
|
|
|
result = CliRunner().invoke(
|
|
cli, ["template", "prepare-linux", "--ip", "192.168.1.100"]
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "[prepare-linux]" in result.output
|
|
assert "BaseClean-Linux" in result.output
|
|
assert streamed
|
|
assert "Install-CIToolchain-Linux2404.sh" in streamed[0]
|
|
assert "--rust-channel" not in streamed[0]
|
|
assert "--no-cargo-audit" not in streamed[0]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Final messages + deploy -> prepare hand-off
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _deploy_linux_stub_env(tmp_path: Path, name: str) -> dict:
|
|
"""Minimal on-disk state for a deploy-* run resumed at the last step."""
|
|
vm_dir = tmp_path / "templates" / name
|
|
vm_dir.mkdir(parents=True)
|
|
vmx = vm_dir / f"{name}.vmx"
|
|
vmx.write_text('ide1:0.present = "TRUE"\n', encoding="ascii")
|
|
key = tmp_path / "ci_linux"
|
|
key.write_text("PRIVATE KEY", encoding="ascii")
|
|
return {"vmx": vmx, "key": key}
|
|
|
|
|
|
def test_deploy_rust_summary_points_at_prepare_rust(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Regression: deploy-rust used to tell the user to run prepare-linux."""
|
|
env = _deploy_linux_stub_env(tmp_path, "LinuxRust2404")
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-rust",
|
|
"--vmx", str(env["vmx"]),
|
|
"--ssh-key", str(env["key"]),
|
|
"--start-from-step", "10",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "deploy-rust complete" in result.output
|
|
assert "prepare-rust" in result.output
|
|
assert "prepare-linux" not in result.output
|
|
# The snapshot named in the summary is the one prepare-rust creates.
|
|
assert "BaseClean-LinuxRust" in result.output
|
|
assert "PostInstall-Linux" not in result.output
|
|
|
|
|
|
def test_deploy_linux_summary_points_at_prepare_linux(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
env = _deploy_linux_stub_env(tmp_path, "LinuxBuild2404")
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(env["vmx"]),
|
|
"--ssh-key", str(env["key"]),
|
|
"--start-from-step", "10",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert "deploy-linux complete" in result.output
|
|
assert "template prepare-linux --vmx" in result.output
|
|
assert "BaseClean-Linux" in result.output
|
|
|
|
|
|
def test_deploy_rust_then_prepare_hands_off(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""--then-prepare chains into prepare-rust with the Rust defaults."""
|
|
env = _deploy_linux_stub_env(tmp_path, "LinuxRust2404")
|
|
captured: dict = {}
|
|
monkeypatch.setattr(
|
|
tmpl_mod, "_prepare_linux_impl", lambda **kw: captured.update(kw)
|
|
)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-rust",
|
|
"--vmx", str(env["vmx"]),
|
|
"--ssh-key", str(env["key"]),
|
|
"--start-from-step", "10",
|
|
"--then-prepare",
|
|
"--rust-channel", "1.97.1",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["prog_name"] == "prepare-rust"
|
|
assert captured["default_vmx_name"] == "LinuxRust2404"
|
|
assert captured["default_toolchain_name"] == "Install-CIToolchain-LinuxRust2404.sh"
|
|
assert captured["snapshot"] == "BaseClean-LinuxRust"
|
|
assert captured["rust_channel"] == "1.97.1"
|
|
assert captured["take_snapshot"] is True
|
|
assert captured["vmx_path_str"] == str(env["vmx"])
|
|
|
|
|
|
def test_deploy_linux_then_prepare_hands_off(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
env = _deploy_linux_stub_env(tmp_path, "LinuxBuild2404")
|
|
captured: dict = {}
|
|
monkeypatch.setattr(
|
|
tmpl_mod, "_prepare_linux_impl", lambda **kw: captured.update(kw)
|
|
)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(env["vmx"]),
|
|
"--ssh-key", str(env["key"]),
|
|
"--start-from-step", "10",
|
|
"--then-prepare",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["prog_name"] == "prepare-linux"
|
|
assert captured["default_toolchain_name"] == "Install-CIToolchain-Linux2404.sh"
|
|
assert captured["snapshot"] == "BaseClean-Linux"
|
|
assert captured["rust_channel"] is None
|
|
|
|
|
|
def test_deploy_without_then_prepare_does_not_chain(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The two commands stay separate: no hand-off unless asked for."""
|
|
env = _deploy_linux_stub_env(tmp_path, "LinuxBuild2404")
|
|
calls: list[dict] = []
|
|
monkeypatch.setattr(
|
|
tmpl_mod, "_prepare_linux_impl", lambda **kw: calls.append(kw)
|
|
)
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-linux",
|
|
"--vmx", str(env["vmx"]),
|
|
"--ssh-key", str(env["key"]),
|
|
"--start-from-step", "10",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert calls == []
|
|
|
|
|
|
def test_prepare_rust_next_hint_is_copy_pasteable(
|
|
prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The `vm new` hint needs the full VMX path and an explicit --snapshot.
|
|
|
|
`vm new` defaults --snapshot to "BaseClean" (the Windows snapshot), so a
|
|
hint that omits it clones the wrong snapshot.
|
|
"""
|
|
_stub_prepare_rust(monkeypatch, prepare_rust_env)
|
|
monkeypatch.setattr(
|
|
tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: "192.168.1.200"
|
|
)
|
|
backend_m = MagicMock()
|
|
backend_m.is_running.side_effect = [False, False]
|
|
backend_m.list_snapshots.return_value = []
|
|
monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
|
|
import subprocess as _sub
|
|
monkeypatch.setattr(
|
|
tmpl_mod.subprocess, "run",
|
|
lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "", ""),
|
|
)
|
|
|
|
result = CliRunner().invoke(cli, ["template", "prepare-rust"])
|
|
assert result.exit_code == 0, result.output
|
|
vmx = str(prepare_rust_env["vmx"])
|
|
assert f"--template {vmx}" in result.output
|
|
assert "--snapshot BaseClean-LinuxRust" in result.output
|
|
assert "--guest-os linux" in result.output
|
|
|
|
|
|
# ── --exclude ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _tmpl_root_with(tmp_path: Path, names: tuple[str, ...]) -> Path:
|
|
tmpl_root = tmp_path / "templates"
|
|
for name in names:
|
|
d = tmpl_root / name
|
|
d.mkdir(parents=True)
|
|
(d / f"{name}.vmx").write_text("vmx")
|
|
return tmpl_root
|
|
|
|
|
|
def test_exclude_skips_named_template(
|
|
tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
|
|
) -> None:
|
|
"""A retired template must not keep costing a backup generation."""
|
|
tmpl_root = _tmpl_root_with(
|
|
tmp_path, ("WinBuild2025", "WinBuildTest", "LinuxBuild2404")
|
|
)
|
|
monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "backup", "--all-templates",
|
|
"--exclude", "WinBuildTest",
|
|
"--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("LinuxBuild2404" in n for n in names)
|
|
assert not any("WinBuildTest" in n for n in names)
|
|
assert "Skipping 'WinBuildTest'" in result.output
|
|
|
|
|
|
def test_exclude_is_repeatable(
|
|
tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
|
|
) -> None:
|
|
tmpl_root = _tmpl_root_with(
|
|
tmp_path, ("WinBuild2025", "WinBuildTest", "LinuxBuildTest")
|
|
)
|
|
monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "backup", "--all-templates",
|
|
"--exclude", "WinBuildTest",
|
|
"--exclude", "LinuxBuildTest",
|
|
"--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 names and all("Test" not in n for n in names)
|
|
|
|
|
|
def test_exclude_unknown_name_raises(
|
|
tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
|
|
) -> None:
|
|
"""A typo must fail loudly, not silently back up everything."""
|
|
tmpl_root = _tmpl_root_with(tmp_path, ("WinBuild2025",))
|
|
monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "backup", "--all-templates",
|
|
"--exclude", "WinBuildTst",
|
|
"--backup-base-dir", str(backup_dir),
|
|
"--skip-runner-stop",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "no such template" in result.output
|
|
|
|
|
|
def test_exclude_everything_raises(
|
|
tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
|
|
) -> None:
|
|
tmpl_root = _tmpl_root_with(tmp_path, ("WinBuild2025",))
|
|
monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "backup", "--all-templates",
|
|
"--exclude", "WinBuild2025",
|
|
"--backup-base-dir", str(backup_dir),
|
|
"--skip-runner-stop",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "nothing to back up" in result.output
|
|
|
|
|
|
def test_exclude_without_all_templates_raises(
|
|
tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
|
|
) -> None:
|
|
"""--exclude alone would be a silent no-op; it must error instead."""
|
|
tmpl_root = _tmpl_root_with(tmp_path, ("WinBuild2025",))
|
|
monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "backup",
|
|
"--template-path", str(tmpl_root / "WinBuild2025"),
|
|
"--exclude", "WinBuildTest",
|
|
"--backup-base-dir", str(backup_dir),
|
|
"--skip-runner-stop",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "only applies to --all-templates" in result.output
|
|
|
|
|
|
def test_then_prepare_fails_before_deploy_when_toolchain_missing(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Regression: the toolchain was resolved only after the deploy finished.
|
|
|
|
Running deploy-rust --then-prepare from outside the repo root completed a
|
|
~45-minute deploy and only then died on the missing script. The check must
|
|
happen before any VM work starts.
|
|
"""
|
|
env = _deploy_linux_stub_env(tmp_path, "LinuxRust2404")
|
|
monkeypatch.chdir(tmp_path) # no template/ dir here
|
|
|
|
steps: list[int] = []
|
|
monkeypatch.setattr(
|
|
tmpl_mod, "_deploy_step", lambda *a, **kw: steps.append(1)
|
|
)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-rust",
|
|
"--vmx", str(env["vmx"]),
|
|
"--ssh-key", str(env["key"]),
|
|
"--then-prepare",
|
|
],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "Install-CIToolchain-LinuxRust2404.sh" in result.output
|
|
assert "--toolchain-script" in result.output
|
|
# Nothing was deployed: the failure landed before the first step ran.
|
|
assert steps == []
|
|
|
|
|
|
def test_then_prepare_accepts_explicit_toolchain_script(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""--toolchain-script makes the chain independent of the current directory."""
|
|
env = _deploy_linux_stub_env(tmp_path, "LinuxRust2404")
|
|
script = tmp_path / "custom-toolchain.sh"
|
|
script.write_text("#!/bin/sh\n", encoding="ascii")
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
captured: dict = {}
|
|
monkeypatch.setattr(
|
|
tmpl_mod, "_prepare_linux_impl", lambda **kw: captured.update(kw)
|
|
)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
[
|
|
"template", "deploy-rust",
|
|
"--vmx", str(env["vmx"]),
|
|
"--ssh-key", str(env["key"]),
|
|
"--start-from-step", "10",
|
|
"--then-prepare",
|
|
"--toolchain-script", str(script),
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["toolchain_script_str"] == str(script)
|
|
|
|
|
|
def test_resolve_toolchain_script_prefers_cwd(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
tdir = tmp_path / "template"
|
|
tdir.mkdir()
|
|
script = tdir / "Install-CIToolchain-Linux2404.sh"
|
|
script.write_text("#!/bin/sh\n", encoding="ascii")
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
got = tmpl_mod._resolve_toolchain_script("Install-CIToolchain-Linux2404.sh", None)
|
|
assert got == script
|
|
|
|
|
|
def test_resolve_toolchain_script_explicit_missing_raises(tmp_path: Path) -> None:
|
|
import click as _click
|
|
|
|
with pytest.raises(_click.ClickException, match="not found"):
|
|
tmpl_mod._resolve_toolchain_script("x.sh", str(tmp_path / "nope.sh"))
|
|
|
|
|
|
def test_poll_guest_ip_passes_wait_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Regression: without -wait vmrun reports "Tools are not running".
|
|
|
|
A guest that was booted, on the network and answering SSH still burned the
|
|
full 10-minute Step 7 budget because the poll omitted -wait, so vmrun only
|
|
answered when the Tools happened to have registered the IP at that instant.
|
|
"""
|
|
import subprocess as _sub
|
|
|
|
seen: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kw): # type: ignore[no-untyped-def]
|
|
seen.append(list(cmd))
|
|
return _sub.CompletedProcess(cmd, 0, "192.168.1.100", "")
|
|
|
|
monkeypatch.setattr(tmpl_mod, "_read_guestinfo_ip", lambda *a, **kw: None)
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
|
|
assert tmpl_mod._poll_guest_ip_deploy("/vmrun", "/vm.vmx", 60.0) == "192.168.1.100"
|
|
assert seen, "vmrun was never invoked"
|
|
assert "-wait" in seen[0], f"-wait missing from {seen[0]}"
|
|
|
|
|
|
def test_poll_guest_ip_ignores_error_line_on_stdout(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""vmrun can exit 0 with "Error: ..." on stdout (AGENTS.md #10)."""
|
|
import subprocess as _sub
|
|
|
|
outputs = [
|
|
"Error: The VMware Tools are not running in the virtual machine\n",
|
|
"192.168.1.101\n",
|
|
]
|
|
|
|
def fake_run(cmd, **kw): # type: ignore[no-untyped-def]
|
|
return _sub.CompletedProcess(cmd, 0, outputs.pop(0), "")
|
|
|
|
monkeypatch.setattr(tmpl_mod, "_read_guestinfo_ip", lambda *a, **kw: None)
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
|
|
|
|
# The Error line must not be mistaken for an address; the poll retries.
|
|
assert tmpl_mod._poll_guest_ip_deploy("/vmrun", "/vm.vmx", 60.0) == "192.168.1.101"
|
|
|
|
|
|
def test_deploy_force_refuses_when_vm_is_powered_on(
|
|
deploy_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Regression: --force against a running VM destroyed the live guest.
|
|
|
|
It deleted the live .vmdk.lck as "stale" and Step 4 rewrote the base VMDK
|
|
underneath it; the damage only surfaced ~10 minutes later as a Step 7 IP
|
|
timeout, because `vmrun list` still showed the OLD instance.
|
|
"""
|
|
import subprocess as _sub
|
|
|
|
vmx = deploy_env["vmx"]
|
|
|
|
def fake_run(cmd: list[str], **kw: object) -> _sub.CompletedProcess[str]:
|
|
if "list" in cmd:
|
|
return _sub.CompletedProcess(cmd, 0, f"Total running VMs: 1\n{vmx}\n", "")
|
|
return _sub.CompletedProcess(cmd, 0, "", "")
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["template", "deploy-linux", "--vmx", str(vmx), "--force"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "powered on" in result.output
|
|
assert "vmrun -T ws stop" in result.output
|
|
|
|
|
|
def test_prepare_detaches_seed_iso_before_snapshot(
|
|
prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Regression: the seed ISO ended up attached inside the snapshot.
|
|
|
|
deploy Step 10 sets ide1:0.present = FALSE while the VM is still powered
|
|
on; VMware rewrites the VMX from its in-memory copy at power-off and
|
|
restores TRUE. Clones then boot with cloud-init's seed still attached
|
|
(AGENTS.md #6), so prepare must detach it after power-off.
|
|
"""
|
|
vmx: Path = prepare_rust_env["vmx"]
|
|
vmx.write_text(
|
|
'displayName = "LinuxRust2404"\n'
|
|
'ide1:0.present = "TRUE"\n'
|
|
'ide1:0.startConnected = "TRUE"\n'
|
|
'ide1:0.fileName = "cloud-init-seed.iso"\n',
|
|
encoding="ascii",
|
|
)
|
|
|
|
_stub_prepare_rust(monkeypatch, prepare_rust_env)
|
|
monkeypatch.setattr(
|
|
tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: "192.168.1.200"
|
|
)
|
|
|
|
backend_m = MagicMock()
|
|
backend_m.is_running.side_effect = [False, False]
|
|
backend_m.list_snapshots.return_value = []
|
|
monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
|
|
|
|
snapshot_calls: list[list[str]] = []
|
|
|
|
import subprocess as _sub
|
|
|
|
def fake_run(cmd, **kw): # type: ignore[no-untyped-def]
|
|
if "snapshot" in cmd:
|
|
# The VMX must already be patched by the time the snapshot is taken.
|
|
snapshot_calls.append(vmx.read_text(encoding="ascii").splitlines())
|
|
return _sub.CompletedProcess(list(cmd), 0, "", "")
|
|
|
|
monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
|
|
|
|
result = CliRunner().invoke(cli, ["template", "prepare-rust"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
text = vmx.read_text(encoding="ascii")
|
|
assert 'ide1:0.present = "FALSE"' in text
|
|
assert 'ide1:0.startConnected = "FALSE"' in text
|
|
assert snapshot_calls, "snapshot was never taken"
|
|
assert any('ide1:0.present = "FALSE"' in ln for ln in snapshot_calls[0]), (
|
|
"snapshot captured the VMX with the seed ISO still attached"
|
|
)
|