From 4c80ff2528010b051b40a134a36603087525e418 Mon Sep 17 00:00:00 2001 From: Simone Date: Wed, 27 May 2026 23:34:37 +0200 Subject: [PATCH] feat: add guest IP retrieval functions and enhance VM preparation logic --- src/ci_orchestrator/commands/template.py | 45 +++++++++++- tests/python/test_commands_template.py | 94 +++++++++++++++++++++++- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/ci_orchestrator/commands/template.py b/src/ci_orchestrator/commands/template.py index a62f90a..a4cfa93 100644 --- a/src/ci_orchestrator/commands/template.py +++ b/src/ci_orchestrator/commands/template.py @@ -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) ...") diff --git a/tests/python/test_commands_template.py b/tests/python/test_commands_template.py index 49d5f87..d65dbe4 100644 --- a/tests/python/test_commands_template.py +++ b/tests/python/test_commands_template.py @@ -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: