feat: add guest IP retrieval functions and enhance VM preparation logic
This commit is contained in:
@@ -827,9 +827,34 @@ def _build_seed_iso(staging_dir: Path, iso_path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _read_guestinfo_ip(vmrun: str, vmx: str) -> str | None:
|
||||
"""Read guestinfo.ci-ip written by ci-report-ip service inside the guest."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[vmrun, "readVariable", vmx, "runtimeConfig", "guestinfo.ci-ip"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
ip = result.stdout.strip()
|
||||
if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
|
||||
return ip
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _poll_guest_ip_deploy(vmrun: str, vmx: str, timeout: float) -> str | None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
# Prefer guestinfo.ci-ip written by ci-report-ip service (faster, no
|
||||
# VMware Tools DHCP heuristic); fall back to getGuestIPAddress for
|
||||
# first-boot VMs that don't have ci-report-ip installed yet.
|
||||
ip = _read_guestinfo_ip(vmrun, vmx)
|
||||
if ip:
|
||||
click.echo(f" ... guest IP from guestinfo.ci-ip: {ip}")
|
||||
return ip
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[vmrun, "getGuestIPAddress", vmx],
|
||||
@@ -1218,7 +1243,7 @@ def template_deploy_linux(
|
||||
f'numvcpus = "{vcpu_count}"',
|
||||
f'cpuid.coresPerSocket = "{cores_per_socket}"',
|
||||
'scsi0.present = "TRUE"',
|
||||
'scsi0.virtualDev = "lsilogic"',
|
||||
'scsi0.virtualDev = "pvscsi"',
|
||||
'scsi0:0.present = "TRUE"',
|
||||
'scsi0:0.fileName = "LinuxBuild2404.vmdk"',
|
||||
'scsi0:0.deviceType = "scsi-hardDisk"',
|
||||
@@ -1247,8 +1272,14 @@ def template_deploy_linux(
|
||||
'floppy0.present = "FALSE"',
|
||||
'usb.present = "FALSE"',
|
||||
'sound.present = "FALSE"',
|
||||
'mks.enable3d = "FALSE"',
|
||||
'svga.vramSize = "4194304"',
|
||||
'tools.syncTime = "TRUE"',
|
||||
'uuid.action = "create"',
|
||||
'sched.mem.pshare.enable = "FALSE"',
|
||||
'MemAllowAutoScaleDown = "FALSE"',
|
||||
'mainMem.useNamedFile = "FALSE"',
|
||||
'isolation.tools.hgfs.disable = "TRUE"',
|
||||
]
|
||||
if guest_user_data_b64 and guest_meta_data_b64:
|
||||
vmx_lines += [
|
||||
@@ -1572,6 +1603,18 @@ def template_prepare_linux(
|
||||
backend.start(handle, headless=True)
|
||||
else:
|
||||
click.echo(f"[prepare-linux] VM already running: {vmx_path.name}")
|
||||
# VM process alive but guest may be powered off (e.g. after a
|
||||
# previous prepare run that shut down the guest). Do a quick IP
|
||||
# probe; if nothing comes back, stop and cold-boot the VM.
|
||||
if not guest_ip:
|
||||
click.echo("[prepare-linux] Quick IP probe (30s) ...")
|
||||
quick_ip = _poll_guest_ip_deploy(vmrun, str(vmx_path), timeout=30.0)
|
||||
if not quick_ip:
|
||||
click.echo(
|
||||
"[prepare-linux] Guest not responding — stopping and restarting VM ..."
|
||||
)
|
||||
backend.stop(handle)
|
||||
backend.start(handle, headless=True)
|
||||
|
||||
if not guest_ip:
|
||||
click.echo("[prepare-linux] Detecting guest IP (vmrun getGuestIPAddress) ...")
|
||||
|
||||
@@ -1148,7 +1148,7 @@ def test_prepare_win_cleanup_only_fails(
|
||||
# template deploy-linux — helper unit tests
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
from ci_orchestrator.config import BackendConfig, Config, IpPoolConfig, Paths # noqa: E402
|
||||
from ci_orchestrator.config import BackendConfig, Config, Paths # noqa: E402
|
||||
|
||||
|
||||
def _deploy_config(tmp_path: Path) -> Config:
|
||||
@@ -1292,9 +1292,60 @@ def test_build_seed_iso_no_tool_raises(
|
||||
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
|
||||
|
||||
@@ -1492,7 +1543,7 @@ def test_deploy_linux_step5_vmx_content(
|
||||
lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "Total running VMs: 0\n", ""),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"template", "deploy-linux",
|
||||
@@ -1522,7 +1573,7 @@ def test_deploy_linux_step5_guestinfo_skipped_when_step2_skipped(
|
||||
lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "Total running VMs: 0\n", ""),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"template", "deploy-linux",
|
||||
@@ -1956,6 +2007,43 @@ def test_prepare_linux_happy_path_with_vmx(
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user