fix(template): make deploy/prepare deterministic and safe to re-run
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.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1710,6 +1710,11 @@ def test_deploy_linux_full_happy_path(
|
||||
|
||||
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:
|
||||
@@ -1723,8 +1728,16 @@ def test_deploy_linux_full_happy_path(
|
||||
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:
|
||||
return _sub.CompletedProcess(cmd, 0, f"Total running VMs: 1\n{vmx}\n", "")
|
||||
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, "", "")
|
||||
@@ -1913,11 +1926,37 @@ def _stub_prepare(
|
||||
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:
|
||||
@@ -2251,3 +2290,771 @@ def test_check_guest_idle_unparseable() -> None:
|
||||
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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user