From e810747557a66c9ec3ff0fd6c4d194adda874179 Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 17 May 2026 00:14:04 +0200 Subject: [PATCH] docs,config: aggiorna AGENTS.md, config.example, HOST-SETUP.md, checklist faseA e fix vari orchestrator (workstation, build, job, wait, winrm, test) --- AGENTS.md | 2 +- config.example.toml | 12 ++- docs/HOST-SETUP.md | 19 +++++ plans/PhaseA-user-checklist.md | 81 +++++++++++++-------- src/ci_orchestrator/backends/workstation.py | 9 ++- src/ci_orchestrator/commands/build.py | 9 ++- src/ci_orchestrator/commands/job.py | 27 +++++-- src/ci_orchestrator/commands/wait.py | 7 +- src/ci_orchestrator/transport/winrm.py | 8 +- tests/python/test_workstation_backend.py | 13 ++++ 10 files changed, 135 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f37ff9f..b91d5a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,6 +223,6 @@ Configurazione in `PSScriptAnalyzerSettings.psd1`. Regole principali: 7. **`[ordered]@{}` non disponibile in PS 2** — non e' un problema qui (PS 5.1), ma ricordare che `[ordered]` e' PS 3+. 8. **`ForEach-Object -Parallel`** — disponibile solo in PS 7+; usare `foreach` classico o `Start-Job`. 9. **Snapshot template catturato con VM accesa** — produce file `*.vmem` / `*.vmsn` con memoria; `vmrun clone ... linked -snapshot=` rifiuta con `Error: The virtual machine should not be powered on. It is already running.` anche se `vmrun list` mostra 0 VM. Catturare snapshot SOLO da stato fully powered-off (verificare assenza di `.vmem` nella dir template). -10. **`vmrun getGuestIPAddress` per check "VM running"** — richiede VMware Tools attivi nel guest; in headless Tools puo' impiegare 30-60s a rispondere e il check appare bloccato. Usare `vmrun list` e cercare il path del VMX nell'output. +10. **`vmrun getGuestIPAddress` per check "VM running"** — richiede VMware Tools attivi nel guest; in headless Tools puo' impiegare 30-60s a rispondere e il check appare bloccato. Usare `vmrun list` e cercare il path del VMX nell'output per `is_running`. Per recuperare l'IP usare `getGuestIPAddress -wait` (blocca internamente finché Tools registrano l'IP) e filtrare le righe che iniziano con `"Error:"` perché vmrun in modalità headless può tornare exit code 0 con `"Error: The VMware Tools are not running..."` su stdout invece di usare un exit code non-zero. 11. **Machine-id identico tra clone Linux** — le cloud image Ubuntu hanno un `/etc/machine-id` fisso. VMware DHCP assegna l'IP tramite DHCP client-id derivato da machine-id: se tutti i clone hanno lo stesso machine-id ottengono lo stesso IP e si bloccano per "IP collision". Fix nel template (prima di prendere lo snapshot): `sudo truncate -s 0 /etc/machine-id && sudo rm -f /var/lib/dbus/machine-id && sudo shutdown -h now`, poi ricreare lo snapshot `BaseClean-Linux` da stato powered-off. 12. **`& nativecmd 2>$null` NON sopprime stderr in PS 5.1 con `$ErrorActionPreference='Stop'`** — la stderr del processo nativo viene convertita in ErrorRecord nel pipeline di PowerShell; sotto `'Stop'` questo diventa un errore terminante PRIMA che `2>$null` possa scartarlo. Esempio: `ssh-keygen -R -f ` stampa "Host X not found in file" su stderr → eccezione terminante. Fix: usare `$ErrorActionPreference = 'SilentlyContinue'` attorno alla chiamata oppure `2>&1 | Out-Null`. Per i job CI su VM ephemere locali, usare direttamente `StrictHostKeyChecking=no UserKnownHostsFile=NUL` (default in `_Transport.psm1` con `KnownHostsFile=''`) — nessun `ssh-keygen` necessario. diff --git a/config.example.toml b/config.example.toml index 51a1f6b..acd4f72 100644 --- a/config.example.toml +++ b/config.example.toml @@ -4,6 +4,14 @@ # Environment variables (CI_ROOT, CI_TEMPLATES, CI_BUILD_VMS, CI_ARTIFACTS, # CI_KEYS, CI_VMRUN_PATH, CI_SSH_KEY_PATH, CI_GUEST_CRED_TARGET) override # anything set here. +# +# IMPORTANT (TOML rule): top-level keys must appear BEFORE any [section] header. +# Keys placed after a [section] belong to that section, not to the root table. + +# Top-level orchestrator settings. +vmrun_path = "C:/Program Files (x86)/VMware/VMware Workstation/vmrun.exe" +ssh_key_path = "F:/CI/keys/ci_linux" +guest_cred_target = "BuildVMGuest" # --------------------------------------------------------------- Windows host [paths] @@ -24,7 +32,3 @@ keys = "F:/CI/keys" # Phase C hook: backend selector. Only "workstation" is implemented in Phase A. [backend] type = "workstation" - -vmrun_path = "C:/Program Files (x86)/VMware/VMware Workstation/vmrun.exe" -ssh_key_path = "F:/CI/keys/ci_linux" -guest_cred_target = "BuildVMGuest" diff --git a/docs/HOST-SETUP.md b/docs/HOST-SETUP.md index 1a73518..3ca06ee 100644 --- a/docs/HOST-SETUP.md +++ b/docs/HOST-SETUP.md @@ -38,6 +38,7 @@ F:\CI\ ├── keys\ │ ├── ci_linux # chiave privata SSH per Linux VMs (ed25519, no passphrase) │ └── ci_linux.pub # chiave pubblica corrispondente +├── config.toml # configurazione Python orchestrator (ssh_key_path, ecc.) ├── act_runner\ │ ├── act_runner.exe # binario runner Gitea │ ├── config.yaml # config runner (path, label, capacity) @@ -139,6 +140,22 @@ Allo `Setup-Host.ps1` exit 0: ``` Poi **provisiona il template VM Linux** — vedi [LINUX-TEMPLATE-SETUP.md](LINUX-TEMPLATE-SETUP.md) +6. **Crea `F:\CI\config.toml`** — necessario affinché il Python orchestrator trovi la chiave SSH e le altre impostazioni: + ```powershell + Copy-Item N:\Code\Workspace\Local-CI-CD-System\config.example.toml F:\CI\config.toml + ``` + Il file `config.example.toml` contiene già i path corretti per Windows (`F:\CI\...`). Modificare solo se si usa un `$CIRoot` diverso da `F:\CI`. + + Valori chiave configurati: + + | Chiave | Valore default | Scopo | + | -------------------- | ----------------------- | -------------------------------------------- | + | `ssh_key_path` | `F:/CI/keys/ci_linux` | Chiave privata per SSH verso Linux guest | + | `guest_cred_target` | `BuildVMGuest` | Target Windows Credential Manager (WinRM) | + | `backend.type` | `workstation` | Backend VMware (solo `workstation` in Phase A) | + + Le variabili d'ambiente `CI_SSH_KEY_PATH`, `CI_VMRUN_PATH`, `CI_GUEST_CRED_TARGET` sovrascrivono i valori del TOML se presenti. + 6. **Registra chiave SSH con Gitea**: - Gitea UI → User Settings → SSH Keys → Add Key - Chiave pubblica: `%USERPROFILE%\.ssh\id_rsa.pub` @@ -162,6 +179,7 @@ Allo `Setup-Host.ps1` exit 0: | Snapshot name (Windows) | `BaseClean` | | Snapshot name (Linux) | `BaseClean-Linux` | | SSH key (Linux VMs) | `F:\CI\keys\ci_linux` (privata), `F:\CI\keys\ci_linux.pub` | +| Orchestrator config TOML | `F:\CI\config.toml` (copia di `config.example.toml`) | | Clone base dir | `F:\CI\BuildVMs\` | | Artifact dir | `F:\CI\Artifacts\` | | Log dir | `F:\CI\Logs\` (retention: 30 giorni) | @@ -196,3 +214,4 @@ Task schedulati (vedi `scripts/Register-CIScheduledTasks.ps1` per il bootstrap): | Service `act_runner` non parte | Check `F:\CI\act_runner\logs\stderr.log` | | `New-StoredCredential` fail | Verifica modulo CredentialManager importato + scope corretto | | Job CI fallisce su `Test-WSMan` | TrustedHosts host non include subnet VM, AllowUnencrypted=$false | +| `wait-ready` SSH: `No authentication methods available` | `F:\CI\config.toml` mancante o `ssh_key_path` non configurato. Crea il file (vedi step 6) oppure passa `--ssh-key-path F:\CI\keys\ci_linux` | diff --git a/plans/PhaseA-user-checklist.md b/plans/PhaseA-user-checklist.md index a48ac90..ec154d9 100644 --- a/plans/PhaseA-user-checklist.md +++ b/plans/PhaseA-user-checklist.md @@ -16,8 +16,8 @@ copiabili. Stop a primo errore: ogni passo è un gate per il successivo. Il codice nuovo va installato nel venv che act_runner usa (`F:\CI\python\venv\`). -- [ ] Aprire PowerShell **come amministratore** sull'host CI. -- [ ] Posizionarsi nella copia git aggiornata del repo (pull del branch +- [x] Aprire PowerShell **come amministratore** sull'host CI. +- [x] Posizionarsi nella copia git aggiornata del repo (pull del branch `feature/python-rewrite-and-linux-migration`): ```powershell @@ -27,13 +27,13 @@ Il codice nuovo va installato nel venv che act_runner usa git pull ``` -- [ ] Installare il package in editable nel venv di produzione: +- [x] Installare il package in editable nel venv di produzione: ```powershell & 'F:\CI\python\venv\Scripts\python.exe' -m pip install -e . ``` -- [ ] Smoke check rapido che la CLI risponda: +- [x] Smoke check rapido che la CLI risponda: ```powershell & 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator --help @@ -50,19 +50,19 @@ L'act_runner gira come servizio SYSTEM e legge `runner/config.yaml` all'avvio. La modifica di A4 (`PYTHONIOENCODING=utf-8`) ha effetto solo dopo restart. -- [ ] Trovare il servizio: +- [x] Trovare il servizio: ```powershell Get-Service | Where-Object { $_.Name -like '*act*runner*' } ``` -- [ ] Restart (sostituisci `` con quello trovato): +- [x] Restart (sostituisci `` con quello trovato): ```powershell Restart-Service -Name '' ``` -- [ ] Verificare che sia partito: +- [x] Verificare che sia partito: ```powershell Get-Service -Name '' @@ -76,7 +76,7 @@ dopo restart. PoC pendente da A1. -- [ ] Clonare manualmente un template Windows: +- [x] Clonare manualmente un template Windows: ```powershell & 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' ` @@ -86,14 +86,14 @@ PoC pendente da A1. linked -snapshot=BaseClean -cloneName=smoke-win ``` -- [ ] Avviare la VM: +- [x] Avviare la VM: ```powershell & 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' ` -T ws start 'F:\CI\BuildVMs\smoke-win\smoke-win.vmx' nogui ``` -- [ ] Eseguire `wait-ready`: +- [x] Eseguire `wait-ready`: ```powershell & 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator wait-ready ` @@ -103,7 +103,7 @@ PoC pendente da A1. Atteso: exit code `0` entro 3 minuti. -- [ ] Cleanup VM di smoke: +- [x] Cleanup VM di smoke: ```powershell & 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator vm remove ` @@ -116,7 +116,7 @@ PoC pendente da A1. Stesso schema, template Linux. PoC pendente da A1. -- [ ] Clonare: +- [x] Clonare: ```powershell & 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' ` @@ -126,7 +126,7 @@ Stesso schema, template Linux. PoC pendente da A1. linked -snapshot=BaseClean-Linux -cloneName=smoke-linux ``` -- [ ] Start + wait-ready: +- [x] Start + wait-ready: ```powershell & 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' ` @@ -139,7 +139,11 @@ Stesso schema, template Linux. PoC pendente da A1. Atteso: exit code `0`. -- [ ] Cleanup: + > **Prerequisito scoperto**: `F:\CI\config.toml` deve esistere con `ssh_key_path` + > impostato — altrimenti SSH fallisce con `No authentication methods available`. + > Creato durante questa validazione; vedi HOST-SETUP.md step 6. + +- [x] Cleanup: ```powershell & 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator vm remove ` @@ -152,29 +156,44 @@ Stesso schema, template Linux. PoC pendente da A1. Valida A3+A4 insieme: clone → wait → build → collect → cleanup. -- [ ] Lanciare un job sul template Windows con uno script trivial: +- [x] Lanciare un job sul template Windows con uno script trivial: ```powershell & 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator job ` - --template 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' ` - --snapshot BaseClean ` - --name smoke-job-win ` + --template-path 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' ` + --snapshot-name BaseClean ` + --job-id smoke-job-win ` + --repo-url 'http://10.10.20.11:3100/Simone/your-repo.git' ` + --branch main ` --build-command 'echo hello > artifact.txt' ` - --artifact-source 'C:\ci\workspace\artifact.txt' ` - --artifact-dest 'F:\CI\Artifacts\smoke-job-win' + --guest-artifact-source 'C:\ci\workspace\artifact.txt' ` + --artifact-base-dir 'F:\CI\Artifacts\smoke-job-win' ``` Atteso: exit code `0`, file `F:\CI\Artifacts\smoke-job-win\artifact.txt` presente, VM smoke-job-win **non** più esistente in `F:\CI\BuildVMs\` (cleanup garantito). -- [ ] Ripetere su Linux (cambiare `--template`, `--snapshot`, - `--build-command 'echo hello > artifact.txt'`, - `--artifact-source '/opt/ci/build/artifact.txt'`). - -- [ ] Verificare assenza VM orfane: +- [x] Ripetere su Linux: ```powershell - & 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator vm cleanup --dry-run + & 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator job ` + --template-path 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx' ` + --snapshot-name BaseClean-Linux ` + --guest-os linux ` + --job-id smoke-job-linux ` + --repo-url 'http://10.10.20.11:3100/Simone/your-repo.git' ` + --branch main ` + --build-command 'echo hello > artifact.txt' ` + --guest-artifact-source 'artifact.txt' ` + --artifact-base-dir 'F:\CI\Artifacts\smoke-job-linux' + ``` + + Atteso: exit code `0`, `F:\CI\Artifacts\smoke-job-linux\smoke-job-linux\artifact.txt` presente. + +- [x] Verificare assenza VM orfane: + + ```powershell + & 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator vm cleanup --what-if ``` Atteso: nessun candidato. @@ -260,11 +279,11 @@ Solo dopo che i passi 3–7 sono tutti `[x]` PASS. | Passo | Descrizione | Stato | | ----- | ----------------------------------------- | ----- | -| 1 | Aggiorna venv di produzione | [ ] | -| 2 | Restart act_runner | [ ] | -| 3 | Smoke `wait-ready` Windows | [ ] | -| 4 | Smoke `wait-ready` Linux | [ ] | -| 5 | Smoke `job` end-to-end (Win + Linux) | [ ] | +| 1 | Aggiorna venv di produzione | [x] | +| 2 | Restart act_runner | [x] | +| 3 | Smoke `wait-ready` Windows | [x] | +| 4 | Smoke `wait-ready` Linux | [x] | +| 5 | Smoke `job` end-to-end (Win + Linux) | [x] | | 6 | Workflow Gitea (lint + self-test + build) | [ ] | | 7 | Burn-in 4 job × 10 round | [ ] | | 8 | Benchmark wall-clock | [ ] | diff --git a/src/ci_orchestrator/backends/workstation.py b/src/ci_orchestrator/backends/workstation.py index 652a846..726a8ef 100644 --- a/src/ci_orchestrator/backends/workstation.py +++ b/src/ci_orchestrator/backends/workstation.py @@ -121,11 +121,16 @@ class WorkstationVmrunBackend: args: list[str] = [handle.identifier] if timeout > 0: args.extend(["-wait"]) - result = self._run("getGuestIPAddress", *args, check=False, timeout=timeout or None) + try: + result = self._run("getGuestIPAddress", *args, check=False, timeout=timeout or None) + except subprocess.TimeoutExpired: + return None if result.returncode != 0: return None ip = (result.stdout or "").strip() - return ip or None + if not ip or ip.lower().startswith("error:"): + return None + return ip def list_snapshots(self, handle: VmHandle) -> list[str]: result = self._run("listSnapshots", handle.identifier) diff --git a/src/ci_orchestrator/commands/build.py b/src/ci_orchestrator/commands/build.py index 08c7962..a1d6dbb 100644 --- a/src/ci_orchestrator/commands/build.py +++ b/src/ci_orchestrator/commands/build.py @@ -144,6 +144,7 @@ def _linux_build( elif clone_url: click.echo(f"[build run] cloning {clone_url} into guest:{workdir}") cmd = ( + f"GIT_TERMINAL_PROMPT=0 " f"git clone --depth 1 --branch {_sh_quote(clone_branch)} " f"{'--recurse-submodules ' if clone_submodules else ''}" f"{_sh_quote(clone_url)} {_sh_quote(workdir)}" @@ -314,12 +315,14 @@ def _windows_build( f"$src = {src_expr}; " f"$zip = {_ps_quote(artifact_zip)}; " "if (-not (Test-Path $src)) { " - " Write-Host \"[build run] artifact source not found: $src\"; " - " exit 2 } ; " + " throw \"[build run] artifact source not found: $src\" }; " "$dir = Split-Path $zip -Parent; " "if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }; " "if (Test-Path $zip) { Remove-Item $zip -Force }; " - "Compress-Archive -Path (Join-Path $src '*') -DestinationPath $zip -Force" + "if (Test-Path $src -PathType Leaf) { " + " Compress-Archive -Path $src -DestinationPath $zip -Force " + "} else { " + " Compress-Archive -Path (Join-Path $src '*') -DestinationPath $zip -Force }" ) t.run(pkg_ps) click.echo(f"[build run] artifact written to {artifact_zip}") diff --git a/src/ci_orchestrator/commands/job.py b/src/ci_orchestrator/commands/job.py index ca4e909..9d4c824 100644 --- a/src/ci_orchestrator/commands/job.py +++ b/src/ci_orchestrator/commands/job.py @@ -135,14 +135,14 @@ def _wait_running( def _wait_for_ip( backend: VmBackend, handle: VmHandle, deadline: float, poll: float ) -> str | None: - while _now() < deadline: + while (now := _now()) < deadline: + call_timeout = min(poll, deadline - now) try: - ip = backend.get_ip(handle) + ip = backend.get_ip(handle, timeout=call_timeout) except BackendError: ip = None if ip: return ip - time.sleep(poll) return None @@ -328,7 +328,22 @@ def job( """ # Reserved for future host-side clone variant; the Python port always # provisions sources via in-guest ``git clone``. - del submodules, use_git_clone, gitea_credential_target + del submodules, use_git_clone + + # Inject Gitea credentials into HTTP(S) clone URL so git doesn't prompt. + authed_repo_url = repo_url + if repo_url and repo_url.startswith(("http://", "https://")): + from urllib.parse import urlparse, urlunparse + + try: + gitea_cred = KeyringCredentialStore().get(gitea_credential_target) + parsed = urlparse(repo_url) + host = parsed.hostname or "" + port_part = f":{parsed.port}" if parsed.port else "" + netloc = f"{gitea_cred.username}:{gitea_cred.password}@{host}{port_part}" + authed_repo_url = urlunparse(parsed._replace(netloc=netloc)) + except Exception: + pass # no credential stored — try unauthenticated (public repo) config: Config = load_config() if not template_path: @@ -463,7 +478,7 @@ def job( workdir="/opt/ci/build", output_dir="/opt/ci/output", host_source_dir=None, - clone_url=repo_url, + clone_url=authed_repo_url, clone_branch=branch, clone_commit=commit, clone_submodules=False, @@ -479,7 +494,7 @@ def job( workdir="C:\\CI\\build", artifact_zip="C:\\CI\\output\\artifacts.zip", host_source_dir=None, - clone_url=repo_url, + clone_url=authed_repo_url, clone_branch=branch, clone_commit=commit, clone_submodules=False, diff --git a/src/ci_orchestrator/commands/wait.py b/src/ci_orchestrator/commands/wait.py index 20d9514..4e41520 100644 --- a/src/ci_orchestrator/commands/wait.py +++ b/src/ci_orchestrator/commands/wait.py @@ -61,14 +61,14 @@ def _wait_running( def _wait_for_ip( backend: VmBackend, handle: VmHandle, deadline: float, poll: float ) -> str | None: - while time.monotonic() < deadline: + while (now := time.monotonic()) < deadline: + call_timeout = min(poll, deadline - now) try: - ip = backend.get_ip(handle) + ip = backend.get_ip(handle, timeout=call_timeout) except BackendError: ip = None if ip: return ip - time.sleep(poll) return None @@ -153,6 +153,7 @@ def wait_ready( sys.exit(_EXIT_TIMEOUT_RUNNING) if ip_address is None: + click.echo("[wait-ready] waiting for guest IP (VMware Tools must be running)...") ip = _wait_for_ip(backend, handle, deadline, poll_interval) if not ip: click.echo("[wait-ready] timeout waiting for guest IP.", err=True) diff --git a/src/ci_orchestrator/transport/winrm.py b/src/ci_orchestrator/transport/winrm.py index 93e7750..c5151f6 100644 --- a/src/ci_orchestrator/transport/winrm.py +++ b/src/ci_orchestrator/transport/winrm.py @@ -116,11 +116,15 @@ class WinRmTransport: stdout, stderr, returncode = client.execute_ps(script) except Exception as exc: raise TransportConnectError(f"WinRM execute_ps failed: {exc}") from exc - # pypsrp returns stderr as a list of PSRP error records in some versions; - # normalise to a string. + # pypsrp returns stderr as a PSDataStreams object (newer versions) or a + # list of error records (older versions); normalise to a plain string. stderr_any: Any = stderr if isinstance(stderr_any, str): stderr_str = stderr_any + elif hasattr(stderr_any, "error"): + # PSDataStreams — extract .error records. + error_records = getattr(stderr_any, "error", None) or [] + stderr_str = "\n".join(str(e) for e in error_records) else: try: stderr_str = "\n".join(str(s) for s in stderr_any) diff --git a/tests/python/test_workstation_backend.py b/tests/python/test_workstation_backend.py index 9842936..f4bc977 100644 --- a/tests/python/test_workstation_backend.py +++ b/tests/python/test_workstation_backend.py @@ -96,6 +96,19 @@ def test_get_ip_returns_none_on_failure(monkeypatch: pytest.MonkeyPatch, fake_vm assert backend.get_ip(VmHandle("x.vmx")) is None +def test_get_ip_returns_none_when_tools_not_running(monkeypatch: pytest.MonkeyPatch, fake_vmrun: str) -> None: + """vmrun exits 0 but writes 'Error: ...' to stdout — must return None, not the error string.""" + monkeypatch.setattr( + subprocess, + "run", + lambda *a, **kw: _make_completed( + 0, stdout="Error: The VMware Tools are not running in the virtual machine: x.vmx\n" + ), + ) + backend = WorkstationVmrunBackend(vmrun_path=fake_vmrun) + assert backend.get_ip(VmHandle("x.vmx")) is None + + def test_get_ip_returns_address(monkeypatch: pytest.MonkeyPatch, fake_vmrun: str) -> None: monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _make_completed(0, stdout="192.168.79.42\n")) backend = WorkstationVmrunBackend(vmrun_path=fake_vmrun)