Files
local-ci-cd-system/src/ci_orchestrator/commands/template.py
T
Simone a0497ffa4e feat(deploy-win): cache the patched ISO, skip repack when inputs unchanged
The ISO repack (extract + xorriso, ~minutes) ran on every deploy. Cache it
per VM dir: store an inputs key (base ISO size+mtime + rendered
autounattend.xml + post-install.ps1) in a `.iso.inputs` sidecar next to
install-noprompt.iso. Step 4 skips the repack when the patched ISO and a
matching sidecar already exist, and re-patches automatically when the base
ISO or either answer file changes.
2026-06-08 23:53:18 +02:00

2338 lines
103 KiB
Python

"""``template`` sub-commands — CI template VM management.
Commands
--------
template backup
Ports ``scripts/Backup-CITemplate.ps1`` to Python. Creates timestamped
7z archives of VMware template directories and prunes old backups beyond
``--keep-count``. Stops ``act-runner.service`` before copying and
restarts it afterwards; pass ``--skip-runner-stop`` to bypass this.
template prepare-win
Provisions a Windows template VM end-to-end via pypsrp (replaces the
``Prepare-WinBuild*.ps1`` PowerShell scripts that required PSWSMan /
New-PSSession — unreliable on Linux hosts).
Steps performed by ``template prepare-win``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1. Resolves ``Install-CIToolchain-<vmx-stem>.ps1`` from
``<repo-root>/template/`` (or ``--toolchain-script``).
2. Optionally locates ``template/guest-setup/windows/ci-static-ip.ps1``
for the ip_pool feature.
3. Starts the VM via vmrun (skipped if already running).
4. Auto-detects guest IP via VMware Tools (skipped if ``--ip``
supplied).
5. Waits for TCP/5986 then probes WinRM session readiness.
6. Uploads and executes the toolchain script inside the guest. Handles
the Windows Update reboot-required exit code (3010) in a loop
(max 10 iterations).
7. Installs the ``CI-StaticIp`` scheduled task (``SYSTEM``, At Startup)
so each linked clone applies a pre-assigned static IP from
``guestinfo.ip-assignment`` before WinRM starts.
8. Runs post-setup remote validation (11 guest-state checks via a
single ``ConvertTo-Json`` call).
9. Final reboot → re-affirms autologin registry keys → graceful
``Stop-Computer``.
10. Waits for the VM to power off (120 s timeout).
11. Creates the ``--snapshot`` (default ``BaseClean``); optionally
deletes and recreates if a snapshot with that name already exists.
12. If ``--store-credential`` is set, writes build-user credentials into
the system keyring (Windows → Credential Manager; Linux →
SecretService / file backend) using the two-entry scheme read by
:class:`~ci_orchestrator.credentials.KeyringCredentialStore`.
Run from the repository root so toolchain and guest-setup scripts are
auto-discovered under ``template/``.
Usage example::
python -m ci_orchestrator template prepare-win \\
--vmx /var/lib/ci/templates/WinBuild2025/WinBuild2025.vmx \\
--admin-password AdminPass1! \\
--build-password CIBuildPass1! \\
--store-credential
"""
from __future__ import annotations
import base64
import contextlib
import fnmatch
import hashlib
import json
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
import click
import paramiko
from ci_orchestrator.backends.protocol import VmHandle
from ci_orchestrator.backends.workstation import WorkstationVmrunBackend
from ci_orchestrator.config import load_config
from ci_orchestrator.transport.errors import TransportError
from ci_orchestrator.transport.winrm import WinRmResult, WinRmTransport
from ci_orchestrator.winimage import autounattend as win_autounattend
from ci_orchestrator.winimage import iso_patch as win_iso_patch
from ci_orchestrator.winimage import uup as win_uup
@click.group()
def template() -> None:
"""VMware template management."""
@template.command("backup")
@click.option(
"--template-path",
default=None,
help=(
"Template directory to back up "
"(default: <CI_TEMPLATES>/WinBuild2025)."
),
)
@click.option(
"--all-templates",
is_flag=True,
help="Back up every subdirectory under CI_TEMPLATES in a single pass.",
)
@click.option(
"--backup-base-dir",
default="/var/lib/ci/backups",
show_default=True,
help="Parent directory for timestamped backup folders.",
)
@click.option(
"--keep-count",
default=3,
show_default=True,
help="Number of most-recent backups to retain per template.",
)
@click.option(
"--skip-runner-stop",
is_flag=True,
help="Skip stopping/restarting act-runner.service.",
)
@click.option(
"--what-if",
is_flag=True,
help="Dry run — log what would happen without copying or deleting.",
)
def template_backup(
template_path: str | None,
all_templates: bool,
backup_base_dir: str,
keep_count: int,
skip_runner_stop: bool,
what_if: bool,
) -> None:
"""Create timestamped backup of CI template VM directories."""
config = load_config()
backup_base = Path(backup_base_dir)
timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
if all_templates:
tmpl_root = config.paths.templates
if not tmpl_root.exists():
raise click.ClickException(f"Templates directory not found: {tmpl_root}")
to_backup = sorted(d for d in tmpl_root.iterdir() if d.is_dir())
if not to_backup:
raise click.ClickException(
f"No template subdirectories found under {tmpl_root}"
)
else:
p = Path(template_path) if template_path else config.paths.templates / "WinBuild2025"
if not p.is_dir():
raise click.ClickException(f"Template directory not found: {p}")
to_backup = [p]
runner_was_stopped = False
try:
if not skip_runner_stop:
runner_was_stopped = _stop_runner(what_if=what_if)
if not what_if:
backup_base.mkdir(parents=True, exist_ok=True)
for tmpl_dir in to_backup:
name_tag = f"{tmpl_dir.name}_{timestamp}" if all_templates else timestamp
archive = backup_base / f"Template_{name_tag}.7z"
if what_if:
click.echo(
f"[Backup-CITemplate] WhatIf: would compress {tmpl_dir} -> {archive}"
)
else:
click.echo(f"[Backup-CITemplate] Compressing {tmpl_dir} -> {archive} ...")
_compress_7z(tmpl_dir, archive)
size_mb = archive.stat().st_size // (1024 * 1024)
click.echo(
f"[Backup-CITemplate] Backup complete: {archive} ({size_mb} MB)"
)
template_label = tmpl_dir.name if all_templates else None
_prune_backups(backup_base, template_label, keep_count, what_if=what_if)
finally:
if runner_was_stopped:
_start_runner(what_if=what_if)
# ──────────────────────────────────────────────────────────────── helpers
def _stop_runner(*, what_if: bool) -> bool:
"""Stop act-runner.service. Returns True if it was running."""
result = subprocess.run(
["systemctl", "is-active", "act-runner.service"],
capture_output=True,
text=True,
)
if result.stdout.strip() != "active":
return False
if what_if:
click.echo("[Backup-CITemplate] WhatIf: would stop act-runner.service")
return True
click.echo("[Backup-CITemplate] Stopping act-runner.service ...")
subprocess.run(["systemctl", "stop", "act-runner.service"], check=True)
click.echo("[Backup-CITemplate] act-runner.service stopped.")
return True
def _start_runner(*, what_if: bool) -> None:
if what_if:
click.echo("[Backup-CITemplate] WhatIf: would start act-runner.service")
return
click.echo("[Backup-CITemplate] Restarting act-runner.service ...")
subprocess.run(["systemctl", "start", "act-runner.service"], check=True)
result = subprocess.run(
["systemctl", "is-active", "act-runner.service"],
capture_output=True,
text=True,
)
click.echo(
f"[Backup-CITemplate] act-runner.service status: {result.stdout.strip()}"
)
def _compress_7z(source: Path, archive: Path) -> None:
"""Create a 7z archive of ``source`` at ``archive`` using compression level 1."""
subprocess.run(
["7z", "a", "-mx=1", "-mmt=on", str(archive), str(source)],
check=True,
stdout=subprocess.DEVNULL,
)
def _prune_backups(
backup_base: Path,
template_name: str | None,
keep_count: int,
*,
what_if: bool,
) -> None:
if template_name:
pattern = f"Template_{template_name}_????????_??????.7z"
else:
pattern = "Template_????????_??????.7z"
existing = sorted(
[
f
for f in backup_base.iterdir()
if f.is_file() and fnmatch.fnmatch(f.name, pattern)
],
key=lambda f: f.stat().st_mtime,
reverse=True,
)
to_remove = existing[keep_count:]
for old in to_remove:
if what_if:
click.echo(
f"[Backup-CITemplate] WhatIf: would prune old backup: {old.name}"
)
else:
click.echo(f"[Backup-CITemplate] Pruning old backup: {old.name}")
old.unlink()
if to_remove:
label = template_name or "template"
click.echo(f"[Backup-CITemplate] Retained {keep_count} backup(s) for {label}.")
# ═══════════════════════════════════════════════════════════════════════════
# template prepare-win helpers
# ═══════════════════════════════════════════════════════════════════════════
def _ps_escape(s: str) -> str:
"""Escape *s* for use inside a PowerShell single-quoted string."""
return s.replace("'", "''")
def _wait_tcp(host: str, port: int, timeout: float) -> bool:
"""Return True once TCP *port* on *host* accepts a connection within *timeout* s."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=5.0):
return True
except OSError:
time.sleep(5)
return False
def _parse_exit_marker(stdout: str) -> int:
"""Extract ``CI_EXITCODE:N`` written by the invoke wrapper; default 0."""
for line in reversed(stdout.splitlines()):
stripped = line.strip()
if stripped.startswith("CI_EXITCODE:"):
try:
return int(stripped.split(":", 1)[1])
except (IndexError, ValueError):
pass
return 0
def _strip_exit_marker(stdout: str) -> str:
"""Remove ``CI_EXITCODE:N`` lines from output before display."""
return "\n".join(
line for line in stdout.splitlines() if not line.strip().startswith("CI_EXITCODE:")
)
def _wait_winrm(transport: WinRmTransport, timeout: int) -> None:
"""Poll *transport*.is_ready() until True or *timeout* seconds elapse."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if transport.is_ready():
return
time.sleep(10)
click.echo(" ... waiting for WinRM ...")
raise click.ClickException(f"WinRM not ready within {timeout}s.")
def _run_with_heartbeat(
transport: WinRmTransport,
script: str,
*,
check: bool = True,
heartbeat: int = 30,
) -> WinRmResult:
"""Run *script* via *transport* and print a heartbeat line every *heartbeat* s.
pypsrp's execute_ps is fully blocking — no streaming. The heartbeat
thread reassures the operator that the command is still running during
long-running steps (e.g. Windows Update, which can take 20-40 min).
"""
result_holder: list[WinRmResult] = []
exc_holder: list[BaseException] = []
stop_event = threading.Event()
def _worker() -> None:
try:
result_holder.append(transport.run(script, check=check))
except BaseException as exc:
exc_holder.append(exc)
finally:
stop_event.set()
worker = threading.Thread(target=_worker, daemon=True)
worker.start()
elapsed = 0
while not stop_event.wait(timeout=float(heartbeat)):
elapsed += heartbeat
click.echo(f" ... still running ({elapsed}s elapsed) ...")
worker.join()
if exc_holder:
raise exc_holder[0]
return result_holder[0]
# ═══════════════════════════════════════════════════════════════════════════
# template prepare-win command
# ═══════════════════════════════════════════════════════════════════════════
@template.command("prepare-win")
@click.option("--vmx", required=True, metavar="PATH", help="Template VM .vmx file.")
@click.option("--ip", "guest_ip", default="", help="Guest IP (auto-detect via VMware Tools if omitted).")
@click.option("--admin-username", default="Administrator", show_default=True)
@click.option("--admin-password", default="", help="Administrator password (prompted if omitted).")
@click.option("--build-username", default="ci_build", show_default=True)
@click.option("--build-password", default="", help="CI build user password (prompted if omitted).")
@click.option("--snapshot", default="BaseClean", show_default=True, help="Snapshot name.")
@click.option("--toolchain-script", "toolchain_script", default=None, metavar="PATH",
help="Path to Install-CIToolchain-*.ps1 (auto-detected from VMX stem if omitted).")
@click.option("--skip-windows-update", is_flag=True, help="Pass -SkipWindowsUpdate to toolchain script.")
@click.option("--skip-tier2", is_flag=True, help="Pass -SkipTier2 to toolchain script.")
@click.option("--skip-cleanup", is_flag=True, help="Pass -SkipCleanup to toolchain script.")
@click.option("--winrm-timeout", default=300, show_default=True, help="Seconds to wait for WinRM.")
@click.option("--auth", default="basic", show_default=True,
type=click.Choice(["basic", "ntlm", "negotiate"]),
help="WinRM authentication protocol.")
@click.option("--store-credential", is_flag=True,
help="Store build-user credentials in the system keyring after provisioning.")
@click.option("--credential-target", default="BuildVMGuest", show_default=True,
help="Keyring target name for the stored credential.")
@click.option("--recreate-snapshot", is_flag=True,
help="Delete and recreate the snapshot if it already exists (no prompt).")
def template_prepare_win(
vmx: str,
guest_ip: str,
admin_username: str,
admin_password: str,
build_username: str,
build_password: str,
snapshot: str,
toolchain_script: str | None,
skip_windows_update: bool,
skip_tier2: bool,
skip_cleanup: bool,
winrm_timeout: int,
auth: str,
store_credential: bool,
credential_target: str,
recreate_snapshot: bool,
) -> None:
"""Provision a Windows template VM via pypsrp (Linux-host compatible).
Replaces Prepare-WinBuild*.ps1 for hosts where PSWSMan/New-PSSession is
unavailable or unreliable. Run from the repo root so toolchain and
guest-setup scripts are auto-discovered under template/.
"""
vmx_path = Path(vmx).resolve()
if not vmx_path.is_file():
raise click.ClickException(f"VMX not found: {vmx_path}")
# --- Resolve toolchain script ---
if toolchain_script is None:
candidate = Path.cwd() / "template" / f"Install-CIToolchain-{vmx_path.stem}.ps1"
if not candidate.is_file():
raise click.ClickException(
f"Toolchain script not found at {candidate}. "
"Run from the repo root or pass --toolchain-script."
)
toolchain_path = candidate
else:
toolchain_path = Path(toolchain_script)
if not toolchain_path.is_file():
raise click.ClickException(f"Toolchain script not found: {toolchain_path}")
# --- Locate CI-StaticIp guest script ---
_sip_candidate = Path.cwd() / "template" / "guest-setup" / "windows" / "ci-static-ip.ps1"
static_ip_path: Path | None
if _sip_candidate.is_file():
static_ip_path = _sip_candidate
else:
click.echo(
f"[prepare-win] WARNING: {_sip_candidate} not found — "
"ip_pool static-IP feature will not work on this template.",
err=True,
)
static_ip_path = None
# --- Prompt for passwords ---
if not admin_password:
admin_password = click.prompt(
f"Template VM {admin_username} password", hide_input=True
)
if not build_password:
build_password = click.prompt(f"New {build_username} password", hide_input=True)
# Save tty state now (terminal clean) so we can restore it before any
# interactive prompt at the end — pypsrp alters tty settings during
# WinRM sessions, causing readline() to receive \r instead of \n.
_tty_fd = sys.stdin.fileno() if sys.stdin.isatty() else -1
_saved_tty_attrs: list[object] = []
if _tty_fd >= 0: # pragma: no cover
try:
import termios
_saved_tty_attrs.append(termios.tcgetattr(_tty_fd))
except Exception:
pass
config = load_config()
backend = WorkstationVmrunBackend(vmrun_path=config.vmrun_path)
handle = VmHandle(identifier=str(vmx_path), name=vmx_path.stem)
if not backend.is_running(handle):
click.echo(f"[prepare-win] Starting VM: {vmx_path.name} ...")
backend.start(handle, headless=True)
else:
click.echo(f"[prepare-win] VM already running: {vmx_path.name}")
if not guest_ip:
click.echo("[prepare-win] Waiting for guest IP (vmrun getGuestIPAddress -wait) ...")
detected = backend.get_ip(handle, timeout=300.0)
if not detected:
raise click.ClickException(
"Could not get guest IP. Ensure VMware Tools is installed."
)
guest_ip = detected
click.echo(f"[prepare-win] Guest IP: {guest_ip}")
# --- Wait for WinRM TCP then probe session ---
click.echo(f"[prepare-win] Waiting for TCP/5986 on {guest_ip} ({winrm_timeout}s) ...")
if not _wait_tcp(guest_ip, 5986, float(winrm_timeout)):
raise click.ClickException(f"TCP/5986 not reachable on {guest_ip}.")
transport = WinRmTransport(
host=guest_ip,
username=admin_username,
password=admin_password,
port=5986,
ssl=True,
cert_validation=False,
auth=auth,
connection_timeout=30,
)
click.echo("[prepare-win] Probing WinRM session ...")
_wait_winrm(transport, winrm_timeout)
click.echo("[prepare-win] WinRM ready.")
try:
# Ensure C:\CI exists
transport.run(
"if (-not (Test-Path 'C:\\CI')) "
"{ New-Item -ItemType Directory 'C:\\CI' -Force | Out-Null }"
)
# Upload toolchain script
guest_script = f"C:\\CI\\{toolchain_path.name}"
click.echo(f"[prepare-win] Uploading {toolchain_path.name} -> {guest_script} ...")
transport.copy(toolchain_path, guest_script)
click.echo("[prepare-win] Upload complete.")
# Build args string
e = _ps_escape
args_parts = [
f"-BuildPassword '{e(build_password)}'",
f"-BuildUsername '{e(build_username)}'",
f"-AdminPassword '{e(admin_password)}'",
]
if skip_windows_update:
args_parts.append("-SkipWindowsUpdate:$true")
if skip_tier2:
args_parts.append("-SkipTier2:$true")
args_parts.append("-SkipCleanup:$true") # always skip during WU loop; cleanup runs separately after
args_str = " ".join(args_parts)
# Run toolchain script — loop handles Windows Update reboot (exit 3010)
_MAX_ITER = 10
exit_code = 0
for iteration in range(1, _MAX_ITER + 1):
click.echo(
f"[prepare-win] Running {toolchain_path.name} "
f"(iteration {iteration}/{_MAX_ITER}) ..."
)
invoke = (
"Set-ExecutionPolicy Bypass -Scope Process -Force\n"
"$ec = 0\n"
"try {\n"
f" & '{e(guest_script)}' {args_str}\n"
" $ec = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }\n"
"} catch {\n"
" Write-Error $_\n"
" $ec = 1\n"
"}\n"
'"CI_EXITCODE:$ec"\n'
)
try:
result = _run_with_heartbeat(transport, invoke, check=False)
exit_code = _parse_exit_marker(result.stdout)
display = _strip_exit_marker(result.stdout).strip()
if display:
click.echo(display)
if result.stderr.strip():
click.echo(result.stderr.strip(), err=True)
except TransportError:
click.echo(
"[prepare-win] Transport error — VM likely rebooted mid-update "
"(treating as 3010)."
)
exit_code = 3010
click.echo(f"[prepare-win] Toolchain exit code: {exit_code}")
if exit_code == 0:
break
if exit_code != 3010:
raise click.ClickException(
f"Toolchain script failed (exit {exit_code})."
)
if iteration == _MAX_ITER:
raise click.ClickException(
f"Windows Update did not converge after {_MAX_ITER} iterations."
)
click.echo("[prepare-win] Windows Update requires reboot — rebooting ...")
with contextlib.suppress(TransportError):
transport.run("Restart-Computer -Force", check=False)
transport.close()
time.sleep(30)
click.echo(f"[prepare-win] Waiting for WinRM after reboot ({winrm_timeout}s) ...")
_wait_winrm(transport, winrm_timeout)
click.echo("[prepare-win] WinRM ready after reboot.")
# Install CI-StaticIp scheduled task
if static_ip_path is not None:
click.echo("[prepare-win] Installing CI-StaticIp scheduled task ...")
transport.copy(static_ip_path, "C:\\CI\\ci-static-ip.ps1")
transport.run(r"""
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\CI\ci-static-ip.ps1'
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName 'CI-StaticIp' `
-Action $action -Trigger $trigger -Principal $principal -Force | Out-Null
""")
click.echo("[prepare-win] CI-StaticIp task registered.")
# Post-setup validation
click.echo("[prepare-win] Running post-setup validation ...")
esc_u = e(build_username)
val_result = transport.run(
"$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','Machine')"
" + ';' + [System.Environment]::GetEnvironmentVariable('PATH','User')\n"
"$msbuild = 'C:\\Program Files (x86)\\Microsoft Visual Studio\\2026"
"\\BuildTools\\MSBuild\\Current\\Bin\\MSBuild.exe'\n"
"@{\n"
" WinRMRunning = (Get-Service WinRM -EA SilentlyContinue).Status -eq 'Running'\n"
f" UserExists = [bool](Get-LocalUser -Name '{esc_u}' -EA SilentlyContinue)\n"
f" UserIsAdmin = [bool](net localgroup Administrators 2>&1 | Select-String -SimpleMatch '{esc_u}')\n"
" UACDisabled = (Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows"
"\\CurrentVersion\\Policies\\System' -Name EnableLUA -EA SilentlyContinue).EnableLUA -eq 0\n"
" FirewallOff = -not (Get-NetFirewallProfile | Where-Object Enabled -eq $true)\n"
" DotNetExists = [bool](Get-Command dotnet -EA SilentlyContinue)\n"
" PythonExists = Test-Path 'C:\\Python\\python.exe' -PathType Leaf\n"
" MSBuildExists = Test-Path $msbuild -PathType Leaf\n"
" CIDirsPresent = (Test-Path 'C:\\CI\\build') -and (Test-Path 'C:\\CI\\output')"
" -and (Test-Path 'C:\\CI\\scripts')\n"
" StaticIpTask = [bool](Get-ScheduledTask -TaskName 'CI-StaticIp' -EA SilentlyContinue)\n"
" StaticIpScript = Test-Path 'C:\\CI\\ci-static-ip.ps1' -PathType Leaf\n"
"} | ConvertTo-Json -Compress\n",
check=False,
)
if val_result.ok and val_result.stdout.strip():
try:
state: dict[str, object] = json.loads(val_result.stdout.strip())
failed = [k for k, v in state.items() if not v]
for k, v in state.items():
mark = "OK " if v else "FAIL"
click.echo(f" [{mark}] {k}")
if failed:
click.echo(
f"[prepare-win] WARNING: {len(failed)} check(s) failed: "
f"{', '.join(failed)}",
err=True,
)
else:
click.echo("[prepare-win] All post-setup checks passed.")
except (json.JSONDecodeError, AttributeError):
click.echo(val_result.stdout)
else:
click.echo(
f"[prepare-win] Validation script error:\n{val_result.stderr}", err=True
)
# Final reboot to flush installation state
click.echo("[prepare-win] Final reboot to flush installation state ...")
with contextlib.suppress(TransportError):
transport.run("Restart-Computer -Force", check=False)
transport.close()
time.sleep(30)
click.echo("[prepare-win] Waiting for WinRM after final reboot ...")
_wait_winrm(transport, winrm_timeout)
# Disk cleanup — runs after final reboot on fully-installed system
if not skip_cleanup:
click.echo("[prepare-win] Running post-update disk cleanup (-CleanupOnly) ...")
cleanup_invoke = (
"Set-ExecutionPolicy Bypass -Scope Process -Force\n"
"$ec = 0\n"
"try {\n"
f" & '{e(guest_script)}' -BuildPassword '{e(build_password)}'"
f" -BuildUsername '{e(build_username)}'"
f" -AdminPassword '{e(admin_password)}' -CleanupOnly:$true\n"
" $ec = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }\n"
"} catch {\n"
" Write-Error $_\n"
" $ec = 1\n"
"}\n"
'"CI_EXITCODE:$ec"\n'
)
cleanup_result = _run_with_heartbeat(transport, cleanup_invoke, check=False)
cleanup_ec = _parse_exit_marker(cleanup_result.stdout)
display = _strip_exit_marker(cleanup_result.stdout).strip()
if display:
click.echo(display)
if cleanup_result.stderr.strip():
click.echo(cleanup_result.stderr.strip(), err=True)
if cleanup_ec != 0:
raise click.ClickException(f"Disk cleanup failed (exit {cleanup_ec}).")
click.echo("[prepare-win] Post-update cleanup complete.")
# Re-affirm autologin
click.echo("[prepare-win] Re-affirming autologin registry settings ...")
transport.run(
"$wl = 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'\n"
"Set-ItemProperty $wl AutoAdminLogon '1' -Type String -Force\n"
f"Set-ItemProperty $wl DefaultUserName '{e(admin_username)}' -Type String -Force\n"
"Set-ItemProperty $wl DefaultDomainName '.' -Type String -Force\n"
f"if ('{e(admin_password)}' -ne '') {{\n"
f" Set-ItemProperty $wl DefaultPassword '{e(admin_password)}' -Type String -Force\n"
"}\n"
)
# Graceful shutdown
click.echo("[prepare-win] Sending graceful shutdown ...")
with contextlib.suppress(TransportError):
transport.run("Stop-Computer -Force", check=False)
finally:
transport.close()
# Wait for VM to power off
click.echo("[prepare-win] Waiting for VM to power off (120s) ...")
off_deadline = time.monotonic() + 120
while time.monotonic() < off_deadline:
if not backend.is_running(handle):
break
time.sleep(5)
click.echo(" ... waiting for shutdown ...")
else:
raise click.ClickException("VM did not power off within 120s.")
click.echo("[prepare-win] VM powered off.")
# Restore tty state before interactive prompt — pypsrp alters terminal
# settings and readline() would receive \r instead of \n on Enter.
if _saved_tty_attrs and _tty_fd >= 0: # pragma: no cover
try:
import termios
termios.tcsetattr(_tty_fd, termios.TCSADRAIN, _saved_tty_attrs[0]) # type: ignore[arg-type]
except Exception:
pass
# Create snapshot
vmrun_exe = backend.vmrun_path
existing = backend.list_snapshots(handle)
if snapshot in existing:
click.echo(f"[prepare-win] Snapshot '{snapshot}' already exists.")
if recreate_snapshot:
do_recreate = True
else:
sys.stdout.write(f"[prepare-win] Delete and recreate '{snapshot}'? [y/N] ")
sys.stdout.flush()
raw = sys.stdin.readline()
do_recreate = raw.strip().lower() in ("y", "yes")
if not do_recreate:
click.echo("[prepare-win] Keeping existing snapshot. Provisioning complete.")
return
click.echo(f"[prepare-win] Deleting snapshot '{snapshot}' ...")
subprocess.run(
[vmrun_exe, "-T", "ws", "deleteSnapshot", str(vmx_path), snapshot],
check=True,
)
click.echo(f"[prepare-win] Creating snapshot '{snapshot}' ...")
subprocess.run(
[vmrun_exe, "-T", "ws", "snapshot", str(vmx_path), snapshot],
check=True,
)
click.echo(f"[prepare-win] Snapshot '{snapshot}' created.")
# Store credentials in system keyring (Windows Credential Manager on Windows,
# SecretService / file backend on Linux) using the two-entry scheme so
# KeyringCredentialStore.get() can retrieve them without knowing the username.
if store_credential:
try:
import keyring
except ImportError as exc:
raise click.ClickException(
"keyring is not installed; run `pip install keyring`."
) from exc
keyring.set_password(f"{credential_target}:meta", "username", build_username)
keyring.set_password(credential_target, build_username, build_password)
click.echo(
f"[prepare-win] Credentials stored in keyring "
f"(target='{credential_target}', user='{build_username}')."
)
click.echo("[prepare-win] Provisioning complete.")
# ═══════════════════════════════════════════════════════════════════════════
# template deploy-linux helpers
# ═══════════════════════════════════════════════════════════════════════════
def _deploy_step(msg: str, step: int, start_from: int) -> None:
if start_from > step:
click.echo(f"\n=== {msg} === [SKIP]")
else:
click.echo(f"\n=== {msg} ===")
def _find_vdisk_manager(explicit: str | None) -> str:
if explicit:
p = Path(explicit)
if not p.is_file():
raise click.ClickException(f"vmware-vdiskmanager not found: {p}")
return str(p)
found = shutil.which("vmware-vdiskmanager")
if found:
return found
raise click.ClickException(
"vmware-vdiskmanager not in PATH. Pass --vdisk-manager or install VMware Workstation."
)
def _clean_force_artifacts(vm_dir: Path, disk_stem: str) -> list[str]:
"""Remove snapshot-chain and lock artifacts left in a reused VM directory.
``deploy-linux --force`` reuses the existing VM directory. If a previous
run (or a prepare-linux snapshot) left a snapshot chain behind, the disk
in the .vmx is "part of a snapshot chain" and ``vmware-vdiskmanager``
refuses to resize it at Step 4. We delete the snapshot delta disks, the
snapshot metadata (.vmsn), the snapshot database (.vmsd) and stale lock
files (.lck) so Step 4 sees a plain, chain-free disk.
The base disk itself is left untouched — Step 4 overwrites it from the
cached cloud image. Returns the sorted list of removed file names (for
logging and tests).
"""
removed: list[str] = []
patterns = (
f"{disk_stem}-[0-9]*.vmdk", # snapshot delta/redo disks
"*.vmsn", # snapshot memory/state
"*.vmsd", # snapshot database
"*.lck", # stale lock files/dirs
)
for pattern in patterns:
for path in vm_dir.glob(pattern):
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
path.unlink(missing_ok=True)
removed.append(path.name)
return sorted(removed)
def _download_vmdk(url: str, dest: Path) -> None:
click.echo(f" Downloading {url} ...")
tmp = dest.with_suffix(dest.suffix + ".part")
try:
urllib.request.urlretrieve(url, str(tmp))
tmp.rename(dest)
except Exception:
if tmp.exists():
tmp.unlink()
raise
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.hexdigest().upper()
def _patched_iso_inputs_key(
base_iso: Path, autounattend_xml: str, post_install_ps1: str
) -> str:
"""Cache key for a patched ISO — changes if the base ISO or answer files do."""
st = base_iso.stat()
h = hashlib.sha256()
h.update(f"{base_iso.name}|{st.st_size}|{int(st.st_mtime)}\n".encode())
h.update(autounattend_xml.encode("utf-8"))
h.update(b"\x00")
h.update(post_install_ps1.encode("utf-8"))
return h.hexdigest()
def _build_seed_iso(staging_dir: Path, iso_path: Path) -> None:
tool = shutil.which("genisoimage") or shutil.which("mkisofs")
if not tool:
raise click.ClickException("genisoimage/mkisofs not found. Install genisoimage.")
if iso_path.exists():
iso_path.unlink()
subprocess.run(
[tool, "-o", str(iso_path), "-V", "cidata", "-J", "-quiet", str(staging_dir)],
check=True,
capture_output=True,
)
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],
capture_output=True,
text=True,
timeout=30,
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
click.echo(" ... waiting for guest IP (open-vm-tools) ...")
time.sleep(15)
return None
def _wait_ssh_port_deploy(
host: str, port: int, timeout: float, interval: float
) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=5.0):
return True
except OSError:
click.echo(f" ... waiting for SSH port {port} on {host} ...")
time.sleep(interval)
return False
def _verify_cloud_init_done(
host: str,
cloud_user: str,
ssh_key: str,
timeout: float,
interval: float = 15.0,
) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
host,
username=cloud_user,
key_filename=ssh_key,
timeout=5.0,
auth_timeout=5.0,
banner_timeout=10.0,
look_for_keys=False,
allow_agent=False,
)
_, stdout_fh, _ = client.exec_command(
"test -f /var/lib/cloud/instance/boot-finished && echo OK",
timeout=10.0,
)
if stdout_fh.read().decode().strip() == "OK":
return True
except Exception as exc:
click.echo(
f" ... not ready yet ({str(exc)[:80]}) -- retrying in {interval:.0f}s ..."
)
finally:
client.close()
time.sleep(interval)
return False
def _patch_vmx_key(vmx_path: Path, key: str, value: str) -> None:
text = vmx_path.read_text(encoding="ascii", errors="replace")
pattern = re.compile(rf"^\s*{re.escape(key)}\s*=.*$", re.MULTILINE)
line = f'{key} = "{value}"'
text = pattern.sub(line, text) if pattern.search(text) else text.rstrip("\n") + f"\n{line}\n"
vmx_path.write_text(text, encoding="ascii")
# ═══════════════════════════════════════════════════════════════════════════
# template deploy-linux command
# ═══════════════════════════════════════════════════════════════════════════
@template.command("deploy-linux")
@click.option(
"--vmx", "vmx_path_str", default=None, metavar="PATH",
help="VMX file to create (default: <CI_TEMPLATES>/LinuxBuild2404/LinuxBuild2404.vmx).",
)
@click.option("--vm-name", default="LinuxBuild2404", show_default=True, help="VM display name.")
@click.option("--cloud-user", default="ci_build", show_default=True, help="cloud-init username.")
@click.option(
"--hostname", "guest_hostname", default="ci-linux-template", show_default=True,
help="Guest hostname.",
)
@click.option("--timezone", default="Europe/Rome", show_default=True, help="IANA timezone (reserved).")
@click.option("--disk-size-gb", default=40, show_default=True, help="Disk size after resize (GB).")
@click.option("--memory-mb", default=4096, show_default=True, help="Guest RAM (MiB).")
@click.option("--vcpu-count", default=4, show_default=True, help="vCPU count.")
@click.option("--cores-per-socket", default=2, show_default=True, help="Cores per socket.")
@click.option(
"--snapshot-name", default="PostInstall-Linux", show_default=True,
help="Snapshot name recorded in summary (taken by prepare-linux).",
)
@click.option(
"--ssh-key", "ssh_key_str", default=None, metavar="PATH",
help="SSH private key (default: from config ssh_key_path or <CI_KEYS>/ci_linux).",
)
@click.option(
"--vmdk-url",
default="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.vmdk",
show_default=True,
help="Ubuntu cloud VMDK download URL.",
)
@click.option(
"--vmdk-cache", "vmdk_cache_str", default=None, metavar="PATH",
help="Local VMDK cache path (default: <CI_ROOT>/iso/noble-server-cloudimg-amd64.vmdk).",
)
@click.option(
"--vmdk-sha256", "vmdk_sha256", default="",
help="Expected SHA256 of cached VMDK (empty = skip verification).",
)
@click.option(
"--seed-iso", "seed_iso_str", default=None, metavar="PATH",
help="Seed ISO path (default: <VM dir>/cloud-init-seed.iso).",
)
@click.option(
"--ssh-timeout", default=300, show_default=True, help="Seconds to wait for SSH port 22.",
)
@click.option(
"--ssh-poll-interval", default=10, show_default=True,
help="Seconds between SSH port poll attempts.",
)
@click.option("--show-gui", is_flag=True, help="Power on with VMware Workstation GUI visible.")
@click.option("--force", is_flag=True, help="Allow overwriting an existing VM directory.")
@click.option(
"--diag-password", "diag_password", default="",
help="Set plaintext password on ubuntu account for console login (diagnostic only).",
)
@click.option(
"--vdisk-manager", "vdisk_manager_str", default=None, metavar="PATH",
help="Path to vmware-vdiskmanager (auto-detected if omitted).",
)
@click.option(
"--start-from-step", "start_from_step", default=1,
type=click.IntRange(1, 10), show_default=True,
help=(
"Skip to this step (artifacts from skipped steps must exist). "
"Steps: 1=prereqs+vmdk 2=cloud-init 3=seed-ISO 4=vmdk-copy+resize "
"5=VMX 6=power-on 7=guest-IP 8=SSH-port 9=cloud-init-verify 10=detach-ISO"
),
)
def template_deploy_linux(
vmx_path_str: str | None,
vm_name: str,
cloud_user: str,
guest_hostname: str,
timezone: str,
disk_size_gb: int,
memory_mb: int,
vcpu_count: int,
cores_per_socket: int,
snapshot_name: str,
ssh_key_str: str | None,
vmdk_url: str,
vmdk_cache_str: str | None,
vmdk_sha256: str,
seed_iso_str: str | None,
ssh_timeout: int,
ssh_poll_interval: int,
show_gui: bool,
force: bool,
diag_password: str,
vdisk_manager_str: str | None,
start_from_step: int,
) -> None:
"""Deploy Ubuntu 24.04 LTS build VM template (Linux VMware Workstation host).
Ports Deploy-LinuxBuild2404.ps1 for Linux hosts. Steps 1-10 mirror
the PowerShell original; ISO creation uses genisoimage instead of IMAPI2FS,
cloud-init verification uses paramiko instead of ssh.exe.
"""
_ = timezone # accepted for parity with PS script; set timezone via prepare-linux
config = load_config()
# ── Resolve paths ──────────────────────────────────────────────────────────
vmx_path = (
Path(vmx_path_str).resolve()
if vmx_path_str
else config.paths.templates / "LinuxBuild2404" / "LinuxBuild2404.vmx"
)
vm_dir = vmx_path.parent
ssh_key_path: Path
if ssh_key_str:
ssh_key_path = Path(ssh_key_str)
elif config.ssh_key_path:
ssh_key_path = config.ssh_key_path
else:
ssh_key_path = config.paths.keys / "ci_linux"
ssh_pub_key_path = ssh_key_path.with_suffix(ssh_key_path.suffix + ".pub")
vmdk_cache = (
Path(vmdk_cache_str)
if vmdk_cache_str
else config.paths.root / "iso" / "noble-server-cloudimg-amd64.vmdk"
)
seed_iso_path = Path(seed_iso_str) if seed_iso_str else vm_dir / "cloud-init-seed.iso"
staging_dir = vm_dir / "_cloud_init_staging"
dest_vmdk = vm_dir / "LinuxBuild2404.vmdk"
# ── Resolve tools ──────────────────────────────────────────────────────────
vmrun = config.vmrun_path or shutil.which("vmrun") or "vmrun"
vdisk_mgr = _find_vdisk_manager(vdisk_manager_str)
# guestinfo base64 payloads — populated in step 2, consumed in step 5;
# empty when step 2 is skipped (guestinfo then omitted from VMX).
guest_user_data_b64 = ""
guest_meta_data_b64 = ""
guest_ip: str | None = None
if start_from_step > 1:
click.echo(
f" [SKIP] Steps 1..{start_from_step - 1} -- starting from Step {start_from_step}"
)
# ──────────────────────────────────────────────────────────────────────────
# Step 1: validate prerequisites + download cloud VMDK
# ──────────────────────────────────────────────────────────────────────────
_deploy_step("Step 1: validate prerequisites + download cloud VMDK", 1, start_from_step)
if start_from_step <= 1:
checks: list[tuple[str, bool]] = [
(f"vmrun found: {vmrun}", bool(shutil.which(vmrun) or Path(vmrun).is_file())),
(f"vmware-vdiskmanager found: {vdisk_mgr}", Path(vdisk_mgr).is_file()),
(
"genisoimage/mkisofs in PATH",
bool(shutil.which("genisoimage") or shutil.which("mkisofs")),
),
(f"SSH private key: {ssh_key_path}", ssh_key_path.is_file()),
(f"SSH public key: {ssh_pub_key_path}", ssh_pub_key_path.is_file()),
]
for label, ok in checks:
if not ok:
raise click.ClickException(f"[PreFlight] {label}")
click.echo(f" [OK] {label}")
if vm_dir.exists():
if not force:
raise click.ClickException(
f"[PreFlight] VM directory already exists: {vm_dir}\n"
" Use --force to overwrite."
)
click.echo(f" [WARN] --force set -- reusing existing VM directory: {vm_dir}")
cleaned = _clean_force_artifacts(vm_dir, vmx_path.stem)
if cleaned:
click.echo(
" [OK] Cleared stale snapshot/lock artifacts (avoids resize "
f"snapshot-chain error): {', '.join(cleaned)}"
)
else:
vm_dir.mkdir(parents=True, exist_ok=True)
click.echo(f" [OK] VM directory ready: {vm_dir}")
if not vmdk_cache.is_file():
vmdk_cache.parent.mkdir(parents=True, exist_ok=True)
try:
_download_vmdk(vmdk_url, vmdk_cache)
click.echo(f" [OK] Download complete: {vmdk_cache}")
except Exception as exc:
raise click.ClickException(f"[Deploy] VMDK download failed: {exc}") from exc
else:
click.echo(f" [OK] VMDK found in cache: {vmdk_cache}")
if vmdk_sha256:
click.echo(" Verifying VMDK SHA256 ...")
actual = _sha256_file(vmdk_cache)
if actual != vmdk_sha256.upper():
raise click.ClickException(
f"[Deploy] VMDK SHA256 mismatch!\n"
f" Expected: {vmdk_sha256.upper()}\n"
f" Actual: {actual}"
)
click.echo(f" [OK] VMDK SHA256: {actual}")
# ──────────────────────────────────────────────────────────────────────────
# Step 2: generate cloud-init user-data + meta-data
# ──────────────────────────────────────────────────────────────────────────
_deploy_step("Step 2: generate cloud-init user-data + meta-data", 2, start_from_step)
if start_from_step <= 2:
pub_key = ssh_pub_key_path.read_text(encoding="utf-8").strip()
if staging_dir.exists():
shutil.rmtree(staging_dir)
staging_dir.mkdir(parents=True)
meta_data = f"instance-id: linuxbuild-001\nlocal-hostname: {guest_hostname}\n"
user_data = (
"#cloud-config\n"
f"hostname: {guest_hostname}\n"
# Suppress cloud-init's first-boot apt update/upgrade. It would hold
# the dpkg lock for minutes and delay open-vm-tools startup past the
# step-7 IP-detection poll. The real toolchain apt work runs later
# in prepare-linux where it is managed.
"package_update: false\n"
"package_upgrade: false\n"
"users:\n"
f" - name: {cloud_user}\n"
" shell: /bin/bash\n"
" sudo: 'ALL=(ALL) NOPASSWD:ALL'\n"
" ssh_authorized_keys:\n"
f" - {pub_key}\n"
)
if diag_password:
click.echo(
" [WARN] --diag-password set: console login enabled for ubuntu "
"(diagnostic mode)"
)
user_data += (
f"chpasswd:\n list: |\n ubuntu:{diag_password}\n expire: false\n"
)
(staging_dir / "meta-data").write_text(meta_data, encoding="utf-8")
(staging_dir / "user-data").write_text(user_data, encoding="utf-8")
click.echo(f" [OK] meta-data written: {staging_dir / 'meta-data'}")
click.echo(f" [OK] user-data written: {staging_dir / 'user-data'}")
guest_meta_data_b64 = base64.b64encode(meta_data.encode()).decode()
guest_user_data_b64 = base64.b64encode(user_data.encode()).decode()
# ──────────────────────────────────────────────────────────────────────────
# Step 3: build cloud-init seed ISO (nocloud, label: cidata)
# ──────────────────────────────────────────────────────────────────────────
_deploy_step(
"Step 3: build cloud-init seed ISO (nocloud, label: cidata)", 3, start_from_step
)
if start_from_step <= 3:
try:
_build_seed_iso(staging_dir, seed_iso_path)
except subprocess.CalledProcessError as exc:
raise click.ClickException(
f"[Deploy Step 3] ISO creation failed:\n{exc.stderr}"
) from exc
if not seed_iso_path.is_file() or seed_iso_path.stat().st_size <= 4096:
raise click.ClickException("[Deploy Step 3] Seed ISO missing or too small.")
click.echo(
f" [OK] Seed ISO: {seed_iso_path} "
f"({seed_iso_path.stat().st_size // 1024} KB)"
)
# ──────────────────────────────────────────────────────────────────────────
# Step 4: copy + resize cloud VMDK
# ──────────────────────────────────────────────────────────────────────────
_deploy_step(f"Step 4: copy + resize cloud VMDK to {disk_size_gb} GB", 4, start_from_step)
if start_from_step <= 4:
if dest_vmdk.exists() and force:
dest_vmdk.unlink()
click.echo(
f" Converting {vmdk_cache} -> {dest_vmdk} "
"(streamOptimized -> monolithicSparse) ..."
)
r = subprocess.run(
[vdisk_mgr, "-r", str(vmdk_cache), "-t", "0", str(dest_vmdk)],
check=False, capture_output=True, text=True,
)
if r.returncode != 0:
raise click.ClickException(
f"[Deploy Step 4] vmware-vdiskmanager convert failed "
f"(exit {r.returncode}):\n{r.stderr}"
)
click.echo(f" [OK] VMDK converted: {dest_vmdk}")
click.echo(f" Resizing VMDK to {disk_size_gb} GB ...")
r = subprocess.run(
[vdisk_mgr, "-x", f"{disk_size_gb}GB", str(dest_vmdk)],
check=False, capture_output=True, text=True,
)
if r.returncode != 0:
raise click.ClickException(
f"[Deploy Step 4] vmware-vdiskmanager resize failed "
f"(exit {r.returncode}):\n{r.stderr}"
)
click.echo(f" [OK] VMDK resized: {dest_vmdk}")
# ──────────────────────────────────────────────────────────────────────────
# Step 5: generate VMX
# ──────────────────────────────────────────────────────────────────────────
_deploy_step("Step 5: generate VMX", 5, start_from_step)
if start_from_step <= 5:
vmx_lines = [
'.encoding = "UTF-8"',
'config.version = "8"',
'virtualHW.version = "19"',
f'displayName = "{vm_name}"',
'guestOS = "ubuntu-64"',
'firmware = "efi"',
f'memsize = "{memory_mb}"',
f'numvcpus = "{vcpu_count}"',
f'cpuid.coresPerSocket = "{cores_per_socket}"',
'scsi0.present = "TRUE"',
'scsi0.virtualDev = "pvscsi"',
'scsi0:0.present = "TRUE"',
'scsi0:0.fileName = "LinuxBuild2404.vmdk"',
'scsi0:0.deviceType = "scsi-hardDisk"',
'ethernet0.present = "TRUE"',
'ethernet0.connectionType = "nat"',
'ethernet0.virtualDev = "vmxnet3"',
'ethernet0.wakeOnPcktRcv = "FALSE"',
'ethernet0.addressType = "generated"',
f'ide1:0.fileName = "{seed_iso_path}"',
'ide1:0.present = "TRUE"',
'ide1:0.deviceType = "cdrom-image"',
'ide1:0.startConnected = "TRUE"',
'pciBridge0.present = "TRUE"',
'pciBridge4.present = "TRUE"',
'pciBridge4.virtualDev = "pcieRootPort"',
'pciBridge4.functions = "8"',
'pciBridge5.present = "TRUE"',
'pciBridge5.virtualDev = "pcieRootPort"',
'pciBridge5.functions = "8"',
'pciBridge6.present = "TRUE"',
'pciBridge6.virtualDev = "pcieRootPort"',
'pciBridge6.functions = "8"',
'pciBridge7.present = "TRUE"',
'pciBridge7.virtualDev = "pcieRootPort"',
'pciBridge7.functions = "8"',
'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 += [
f'guestinfo.userdata = "{guest_user_data_b64}"',
'guestinfo.userdata.encoding = "base64"',
f'guestinfo.metadata = "{guest_meta_data_b64}"',
'guestinfo.metadata.encoding = "base64"',
]
click.echo(
" [INFO] guestinfo cloud-init properties embedded in VMX (backup datasource)."
)
vmx_path.write_text("\n".join(vmx_lines) + "\n", encoding="ascii")
click.echo(f" [OK] VMX written: {vmx_path}")
# ──────────────────────────────────────────────────────────────────────────
# Step 6: power on VM
# ──────────────────────────────────────────────────────────────────────────
_deploy_step("Step 6: power on VM", 6, start_from_step)
if start_from_step <= 6:
gui_mode = "gui" if show_gui else "nogui"
proc = subprocess.Popen([vmrun, "-T", "ws", "start", str(vmx_path), gui_mode])
click.echo(f" [Deploy] VM power-on issued (pid={proc.pid}).")
time.sleep(10)
list_r = subprocess.run(
[vmrun, "-T", "ws", "list"], capture_output=True, text=True, check=False,
)
if str(vmx_path) not in (list_r.stdout or ""):
raise click.ClickException(
"[Deploy Step 6] VM not found in vmrun list after power-on."
)
click.echo(" [OK] VM appears in vmrun list.")
# ──────────────────────────────────────────────────────────────────────────
# Step 7: detect guest IP via vmrun getGuestIPAddress (max 5 min)
# ──────────────────────────────────────────────────────────────────────────
_deploy_step(
"Step 7: detect guest IP via vmrun getGuestIPAddress (max 10 min)", 7, start_from_step
)
if start_from_step <= 7:
guest_ip = _poll_guest_ip_deploy(vmrun, str(vmx_path), timeout=600.0)
if not guest_ip:
raise click.ClickException("[Deploy Step 7] Timed out waiting for guest IP.")
click.echo(f" [OK] Guest IP: {guest_ip}")
# ──────────────────────────────────────────────────────────────────────────
# Step 8: poll SSH port 22 until ready
# ──────────────────────────────────────────────────────────────────────────
_deploy_step(f"Step 8: poll SSH port 22 (max {ssh_timeout}s)", 8, start_from_step)
if start_from_step <= 8:
if not guest_ip:
raise click.ClickException(
"[Deploy Step 8] Guest IP not available. Run from Step 7 or earlier."
)
if not _wait_ssh_port_deploy(
guest_ip, 22, float(ssh_timeout), float(ssh_poll_interval)
):
raise click.ClickException(
f"[Deploy Step 8] SSH port 22 not reachable on {guest_ip} "
f"within {ssh_timeout}s."
)
click.echo(f" [OK] SSH port 22 open on {guest_ip}")
# ──────────────────────────────────────────────────────────────────────────
# Step 9: verify cloud-init completed (boot-finished flag)
# ──────────────────────────────────────────────────────────────────────────
_deploy_step(
"Step 9: verify cloud-init completed (boot-finished flag)", 9, start_from_step
)
if start_from_step <= 9:
if not guest_ip:
raise click.ClickException(
"[Deploy Step 9] Guest IP not available. Run from Step 7 or earlier."
)
click.echo(" Polling cloud-init completion (max 600s) ...")
if not _verify_cloud_init_done(
guest_ip, cloud_user, str(ssh_key_path), timeout=600.0
):
raise click.ClickException(
"[Deploy Step 9] Timed out (600s) waiting for cloud-init to complete."
)
click.echo(" [OK] cloud-init completed.")
# ──────────────────────────────────────────────────────────────────────────
# Step 10: remove seed ISO from VMX (prevent re-run on clone)
# ──────────────────────────────────────────────────────────────────────────
_deploy_step(
"Step 10: remove seed ISO from VMX (prevent re-run on clone)", 10, start_from_step
)
if start_from_step <= 10:
_patch_vmx_key(vmx_path, "ide1:0.present", "FALSE")
_patch_vmx_key(vmx_path, "ide1:0.startConnected", "FALSE")
click.echo(" [OK] ide1:0.present = FALSE")
click.echo(" [OK] ide1:0.startConnected = FALSE")
# Cleanup staging dir
if staging_dir.exists():
shutil.rmtree(staging_dir, ignore_errors=True)
# Summary
click.echo("\n=== Done ===\n")
click.echo("[Deploy] Summary")
click.echo(f" VMX: {vmx_path}")
click.echo(f" Guest IP: {guest_ip or '(not obtained)'}")
click.echo(f" SSH key: {ssh_key_path}")
click.echo(f" Snapshot name: {snapshot_name} (run template prepare-linux next)")
click.echo("")
if guest_ip:
click.echo(f" SSH: ssh -i {ssh_key_path} {cloud_user}@{guest_ip}")
click.echo("")
click.echo(" VM is powered on. cloud-init provisioning complete.")
# ═══════════════════════════════════════════════════════════════════════════
# template prepare-linux helpers
# ═══════════════════════════════════════════════════════════════════════════
_PREPARE_LINUX_VALIDATION = (
'( id ci_build | grep -q sudo || sudo -l -U ci_build 2>/dev/null | grep -q NOPASSWD )'
' && echo "UserSudo:OK" || echo "UserSudo:FAIL"\n'
'sudo -n true 2>/dev/null && echo "SudoNoPass:OK" || echo "SudoNoPass:FAIL"\n'
'for t in gcc g++ clang cmake python3 git 7z; do'
' command -v "$t" >/dev/null && echo "Tool:$t:OK" || echo "Tool:$t:FAIL"; done\n'
'test -d /opt/ci/build && echo "Dir:build:OK" || echo "Dir:build:FAIL"\n'
'test -d /opt/ci/output && echo "Dir:output:OK" || echo "Dir:output:FAIL"\n'
'test -d /opt/ci/scripts && echo "Dir:scripts:OK" || echo "Dir:scripts:FAIL"\n'
'swapon --show | grep -q . && echo "Swap:ACTIVE:FAIL" || echo "Swap:off:OK"\n'
'systemctl is-active ssh >/dev/null && echo "SSHD:active:OK" || echo "SSHD:inactive:FAIL"\n'
'grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config'
' && echo "SSHPwdAuth:disabled:OK" || echo "SSHPwdAuth:enabled:FAIL"\n'
'systemctl is-enabled ci-report-ip.service >/dev/null 2>&1'
' && echo "CIReportIP:enabled:OK" || echo "CIReportIP:enabled:FAIL"\n'
'test -x /usr/local/bin/ci-report-ip.sh'
' && echo "CIReportIPScript:exists:OK" || echo "CIReportIPScript:exists:FAIL"\n'
)
def _pl_connect(
host: str, cloud_user: str, ssh_key: str, timeout: float = 10.0
) -> paramiko.SSHClient:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
host,
username=cloud_user,
key_filename=ssh_key,
timeout=timeout,
auth_timeout=timeout,
banner_timeout=timeout * 2,
look_for_keys=False,
allow_agent=False,
)
return client
def _pl_run(
client: paramiko.SSHClient, cmd: str, timeout: float = 60.0
) -> tuple[int, str, str]:
_, stdout_fh, stderr_fh = client.exec_command(cmd, timeout=timeout)
out = stdout_fh.read().decode(errors="replace")
err = stderr_fh.read().decode(errors="replace")
rc = stdout_fh.channel.recv_exit_status()
return rc, out, err
def _pl_stream(client: paramiko.SSHClient, cmd: str) -> int:
"""Run cmd and stream stdout/stderr to console in real time. Returns exit code."""
transport = client.get_transport()
assert transport is not None, "SSH transport closed"
channel = transport.open_session()
channel.set_combine_stderr(False)
channel.exec_command(cmd)
while True:
if channel.recv_ready():
data = channel.recv(65536)
if data:
click.echo(data.decode(errors="replace"), nl=False)
if channel.recv_stderr_ready():
data = channel.recv_stderr(65536)
if data:
click.echo(data.decode(errors="replace"), nl=False, err=True)
if channel.exit_status_ready():
while channel.recv_ready():
data = channel.recv(65536)
if data:
click.echo(data.decode(errors="replace"), nl=False)
while channel.recv_stderr_ready():
data = channel.recv_stderr(65536)
if data:
click.echo(data.decode(errors="replace"), nl=False, err=True)
break
time.sleep(0.1)
return channel.recv_exit_status()
def _pl_upload(client: paramiko.SSHClient, local: Path, remote: str) -> None:
sftp = client.open_sftp()
try:
sftp.put(str(local), remote)
finally:
sftp.close()
# ═══════════════════════════════════════════════════════════════════════════
# template prepare-linux command
# ═══════════════════════════════════════════════════════════════════════════
@template.command("prepare-linux")
@click.option(
"--vmx", "vmx_path_str", default=None, metavar="PATH",
help=(
"Template VM .vmx file. Enables auto power-on, IP detection, "
"and automatic shutdown + snapshot at the end."
),
)
@click.option(
"--ip", "guest_ip", default="",
help="Guest IP address (auto-detected via vmrun if --vmx is supplied).",
)
@click.option("--cloud-user", default="ci_build", show_default=True, help="SSH username in guest.")
@click.option(
"--ssh-key", "ssh_key_str", default=None, metavar="PATH",
help="SSH private key (default: from config ssh_key_path or <CI_KEYS>/ci_linux).",
)
@click.option(
"--snapshot", default="BaseClean-Linux", show_default=True,
help="Snapshot name (must match the name used by vm new).",
)
@click.option(
"--toolchain-script", "toolchain_script_str", default=None, metavar="PATH",
help=(
"Path to Install-CIToolchain-Linux2404.sh "
"(auto-detected from <repo-root>/template/ if omitted)."
),
)
@click.option("--skip-update", is_flag=True, help="Pass --skip-update to toolchain script.")
@click.option("--install-dotnet", is_flag=True, help="Pass --dotnet to toolchain script.")
@click.option("--no-mingw", is_flag=True, help="Pass --no-mingw to toolchain script.")
@click.option(
"--ssh-timeout", default=180, show_default=True,
help="Seconds to wait for SSH port 22 to become reachable.",
)
@click.option(
"--take-snapshot", is_flag=True,
help="Shut down and take snapshot after provisioning (always implied when --vmx is set).",
)
def template_prepare_linux(
vmx_path_str: str | None,
guest_ip: str,
cloud_user: str,
ssh_key_str: str | None,
snapshot: str,
toolchain_script_str: str | None,
skip_update: bool,
install_dotnet: bool,
no_mingw: bool,
ssh_timeout: int,
take_snapshot: bool,
) -> None:
"""Provision Linux template VM via SSH (ports Prepare-LinuxBuild2404.ps1).
Requires either --vmx (auto power-on + IP detection + snapshot) or
--ip (explicit IP, no auto VM lifecycle), or both.
"""
config = load_config()
# ── Resolve SSH key ────────────────────────────────────────────────────────
if ssh_key_str:
ssh_key_path = Path(ssh_key_str)
elif config.ssh_key_path:
ssh_key_path = config.ssh_key_path
else:
ssh_key_path = config.paths.keys / "ci_linux"
# ── Resolve VMX ───────────────────────────────────────────────────────────
vmx_path = Path(vmx_path_str).resolve() if vmx_path_str else None
# ── Resolve toolchain script ──────────────────────────────────────────────
if toolchain_script_str is None:
candidate = Path.cwd() / "template" / "Install-CIToolchain-Linux2404.sh"
if not candidate.is_file():
raise click.ClickException(
f"Toolchain script not found at {candidate}. "
"Run from the repo root or pass --toolchain-script."
)
toolchain_path = candidate
else:
toolchain_path = Path(toolchain_script_str)
if not toolchain_path.is_file():
raise click.ClickException(f"Toolchain script not found: {toolchain_path}")
if vmx_path is None and not guest_ip:
raise click.ClickException(
"Provide --vmx (auto power-on + IP detection) or --ip (explicit IP), or both."
)
do_snapshot = take_snapshot or vmx_path is not None
# ── Step 1: Pre-flight ────────────────────────────────────────────────────
click.echo("[prepare-linux] Step 1 — Pre-flight validation")
if not ssh_key_path.is_file():
raise click.ClickException(f"SSH private key not found: {ssh_key_path}")
click.echo(f" [OK] SSH key: {ssh_key_path}")
if vmx_path is not None and not vmx_path.is_file():
raise click.ClickException(f"VMX not found: {vmx_path}")
if vmx_path is not None:
click.echo(f" [OK] VMX: {vmx_path}")
click.echo(f" [OK] Toolchain script: {toolchain_path}")
# ── Step 2: Auto power-on + IP detection ──────────────────────────────────
vmrun = config.vmrun_path or shutil.which("vmrun") or "vmrun"
backend: WorkstationVmrunBackend | None = None
handle: VmHandle | None = None
if vmx_path is not None:
backend = WorkstationVmrunBackend(vmrun_path=config.vmrun_path)
handle = VmHandle(identifier=str(vmx_path), name=vmx_path.stem)
if not backend.is_running(handle):
click.echo(f"[prepare-linux] Starting VM: {vmx_path.name} ...")
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) ...")
detected = _poll_guest_ip_deploy(vmrun, str(vmx_path), timeout=300.0)
if not detected:
raise click.ClickException(
"Could not get guest IP. Ensure open-vm-tools is running in the guest."
)
guest_ip = detected
click.echo(f"[prepare-linux] Guest IP: {guest_ip}")
# ── Step 3: Wait for SSH port 22 ──────────────────────────────────────────
click.echo(
f"[prepare-linux] Step 3 — Waiting for SSH port 22 on {guest_ip} "
f"(timeout {ssh_timeout}s) ..."
)
if not _wait_ssh_port_deploy(guest_ip, 22, float(ssh_timeout), 10.0):
raise click.ClickException(
f"SSH port 22 not reachable on {guest_ip} within {ssh_timeout}s."
)
click.echo(f" [OK] TCP/22 reachable on {guest_ip}")
# ── Step 4: SSH connectivity test ─────────────────────────────────────────
click.echo("[prepare-linux] Step 4 — Testing SSH connectivity ...")
try:
client = _pl_connect(guest_ip, cloud_user, str(ssh_key_path))
except Exception as exc:
raise click.ClickException(
f"SSH connection to {cloud_user}@{guest_ip} failed: {exc}"
) from exc
rc, out, _ = _pl_run(client, "echo connected")
if rc != 0 or "connected" not in out:
client.close()
raise click.ClickException("SSH connectivity test failed.")
click.echo(" [OK] SSH connectivity confirmed.")
try:
# ── Step 5: Upload toolchain script ───────────────────────────────────
click.echo(
f"[prepare-linux] Step 5 — Uploading {toolchain_path.name} to guest /tmp/ ..."
)
raw = toolchain_path.read_bytes()
if raw[:3] == b"\xef\xbb\xbf":
click.echo(" [WARN] BOM detected — stripping")
raw = raw[3:]
normalized = raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
remote_script = "/tmp/Install-CIToolchain-Linux2404.sh"
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as tf:
tf.write(normalized)
tmp_local = Path(tf.name)
try:
_pl_upload(client, tmp_local, remote_script)
finally:
tmp_local.unlink(missing_ok=True)
rc, size_out, _ = _pl_run(client, f"stat -c %s {remote_script}")
remote_size = int(size_out.strip()) if size_out.strip().isdigit() else -1
local_size = len(normalized)
if abs(remote_size - local_size) > 2:
raise click.ClickException(
f"Upload size mismatch: local={local_size}, remote={remote_size}."
)
click.echo(f" [OK] {toolchain_path.name} uploaded ({local_size} bytes)")
# ── Step 6: Execute toolchain script ──────────────────────────────────
flags: list[str] = []
if skip_update:
flags.append("--skip-update")
if install_dotnet:
flags.append("--dotnet")
if no_mingw:
flags.append("--no-mingw")
flags_str = " ".join(flags)
remote_cmd = f"chmod +x {remote_script} && sudo {remote_script} {flags_str}".strip()
click.echo("[prepare-linux] Step 6 — Running toolchain script (may take several minutes)")
click.echo(f" Command: {remote_cmd}")
click.echo("-" * 60)
exit_code = _pl_stream(client, remote_cmd)
click.echo("-" * 60)
if exit_code != 0:
raise click.ClickException(
f"Toolchain script exited {exit_code} — see output above."
)
click.echo("[prepare-linux] Toolchain script completed (exit 0).")
# ── Step 7: Post-setup validation ─────────────────────────────────────
click.echo("[prepare-linux] Step 7 — Running post-setup validation ...")
_, val_out, _ = _pl_run(client, _PREPARE_LINUX_VALIDATION, timeout=120.0)
click.echo("[prepare-linux] Validation results:")
failures: list[str] = []
for line in val_out.splitlines():
line = line.strip()
if not line:
continue
click.echo(f" {line}")
if ":FAIL" in line:
failures.append(line)
if failures:
raise click.ClickException(
"Post-setup validation failed:\n"
+ "\n".join(f" {f}" for f in failures)
)
click.echo("[prepare-linux] All post-setup checks passed.")
# ── Step 7b: Clear machine-id (DHCP uniqueness for clones) ───────────
click.echo("[prepare-linux] Step 7b — Clearing machine-id ...")
rc2, _, err2 = _pl_run(
client,
"sudo truncate -s 0 /etc/machine-id && sudo rm -f /var/lib/dbus/machine-id",
)
if rc2 != 0:
raise click.ClickException(f"machine-id clear failed (exit {rc2}):\n{err2}")
_, size_out2, _ = _pl_run(client, "stat -c %s /etc/machine-id")
if size_out2.strip() != "0":
raise click.ClickException(
f"/etc/machine-id not empty after truncate (size={size_out2.strip()!r})."
)
click.echo("[prepare-linux] machine-id cleared — clones get unique DHCP ID.")
# ── Step 7c: Enable NTP time sync (clock-skew guard for clones) ──────
# Bake persistent NTP into the snapshot so every clone boots and
# corrects its clock automatically. Without this, a clone whose RTC
# drifted behind the host produces source mtimes "in the future" and
# make emits "Clock skew detected". This is the clean fix; the build
# path also force-syncs + touches sources as a runtime safety net.
click.echo("[prepare-linux] Step 7c — Enabling NTP time sync ...")
# set-local-rtc 0 forces the RTC to be interpreted as UTC. Without it
# a clone can boot hours ahead (RTC in local time read as UTC); NTP
# then steps the clock *backward* at runtime, which is what produces
# the "Clock skew detected" / future-mtime warnings in make.
rc_ntp, _, err_ntp = _pl_run(
client,
"sudo timedatectl set-local-rtc 0 2>/dev/null; "
"sudo systemctl enable --now systemd-timesyncd 2>/dev/null; "
"sudo timedatectl set-ntp true",
)
if rc_ntp != 0:
raise click.ClickException(f"enabling NTP failed (exit {rc_ntp}):\n{err_ntp}")
click.echo("[prepare-linux] NTP time sync enabled.")
# ── Step 7d: Seal cloud-init (runs once at deploy, never in clones) ───
# cloud-init provisioned the image at deploy time. It must not run
# again on the template or on linked clones: the VMX carries the
# guestinfo seed (userdata/metadata), which every linked clone
# inherits, so cloud-init would re-run on each boot and rewrite
# /etc/netplan/50-cloud-init.yaml pinning the deploy-time MAC. That
# stale MAC match never matches a clone's NIC (nor a re-MAC'd
# template), which wedges systemd-networkd's DHCPv4 — the interface
# links up and gets an offer on the wire but no address is ever
# applied. We seal the image: drop the stale cloud-init netplan,
# leaving only the MAC-agnostic 99-ci-dhcp-allnics.yaml, regenerate
# networkd config, then disable cloud-init for all future boots.
click.echo("[prepare-linux] Step 7d — Sealing cloud-init (disable for clones) ...")
rc_seal, _, err_seal = _pl_run(
client,
"sudo rm -f /etc/netplan/50-cloud-init.yaml "
"/etc/netplan/50-cloud-init.yaml.bak && sudo netplan generate",
)
if rc_seal != 0:
raise click.ClickException(f"cloud-init seal (netplan) failed (exit {rc_seal}):\n{err_seal}")
# Disable cloud-init for every subsequent boot, then clean its state
# (best-effort: clean may exit non-zero if already clean).
_pl_run(client, "sudo touch /etc/cloud/cloud-init.disabled")
_pl_run(client, "sudo cloud-init clean --logs 2>/dev/null || true")
# Verify the stale netplan is gone and cloud-init is disabled.
_, seal_out, _ = _pl_run(
client,
"test -f /etc/cloud/cloud-init.disabled && echo DISABLED; "
"test ! -f /etc/netplan/50-cloud-init.yaml && echo NO50",
)
if "DISABLED" not in seal_out or "NO50" not in seal_out:
raise click.ClickException(
f"cloud-init seal verification failed (expected DISABLED+NO50):\n{seal_out}"
)
click.echo(
"[prepare-linux] cloud-init sealed — clones boot with static netplan only."
)
# ── Step 8: Graceful shutdown ─────────────────────────────────────────
if do_snapshot:
click.echo("[prepare-linux] Step 8 — Sending graceful shutdown ...")
with contextlib.suppress(Exception):
_pl_run(client, "sudo shutdown -h now", timeout=10.0)
else:
click.echo(
"[prepare-linux] --take-snapshot not set — VM left running. "
"Shut down and snapshot manually when ready."
)
finally:
client.close()
# ── Post-SSH: wait for power-off + snapshot ───────────────────────────────
if do_snapshot and vmx_path is not None and backend is not None and handle is not None:
click.echo("[prepare-linux] Waiting for VM to power off (120s) ...")
off_deadline = time.monotonic() + 120
while time.monotonic() < off_deadline:
if not backend.is_running(handle):
break
time.sleep(5)
click.echo(" ... waiting for shutdown ...")
else:
raise click.ClickException("VM did not power off within 120s.")
click.echo("[prepare-linux] VM powered off.")
existing = backend.list_snapshots(handle)
if snapshot in existing:
click.echo(f"[prepare-linux] Deleting existing snapshot '{snapshot}' ...")
subprocess.run(
[vmrun, "-T", "ws", "deleteSnapshot", str(vmx_path), snapshot],
check=True,
)
click.echo(f"[prepare-linux] Taking snapshot '{snapshot}' ...")
subprocess.run(
[vmrun, "-T", "ws", "snapshot", str(vmx_path), snapshot],
check=True,
)
click.echo(f"[prepare-linux] Snapshot '{snapshot}' created.")
# ── Summary ───────────────────────────────────────────────────────────────
click.echo("")
click.echo("[prepare-linux] Summary")
click.echo(f" Guest IP: {guest_ip}")
click.echo(f" SSH key: {ssh_key_path}")
if vmx_path:
click.echo(f" VMX: {vmx_path}")
if do_snapshot and vmx_path is not None:
click.echo(
f" Snapshot: {snapshot} — ready for "
f"`ci-orchestrator vm new --template {vmx_path.name}`"
)
else:
click.echo(f" Snapshot: not taken — name it '{snapshot}' when done manually.")
click.echo("[prepare-linux] Provisioning complete.")
# ═══════════════════════════════════════════════════════════════════════════
# template deploy-win command (Windows Server 2025, Linux host)
# ═══════════════════════════════════════════════════════════════════════════
#: Forward-compatible Workstation guestOS id for Windows Server 2025.
_WIN2025_GUEST_OS = "windows2019srvnext-64"
def _remove_vmx_lines(vmx_path: Path, prefix: str) -> int:
"""Remove every VMX line whose key starts with ``prefix``. Returns count."""
text = vmx_path.read_text(encoding="ascii", errors="replace")
kept: list[str] = []
removed = 0
pattern = re.compile(rf"^\s*{re.escape(prefix)}", re.IGNORECASE)
for line in text.splitlines():
if pattern.match(line):
removed += 1
else:
kept.append(line)
vmx_path.write_text("\n".join(kept) + "\n", encoding="ascii")
return removed
def _winbuild_vmx_lines(
*,
vm_name: str,
mem_mb: int,
vcpu_count: int,
cores_per_socket: int,
vmdk_name: str,
install_iso: str,
tools_iso: str,
) -> list[str]:
"""Build the WinBuild2025 VMX (ports Deploy-WinBuild2025.ps1 Step 5).
Boot NIC is e1000e; sata0:0 = patched install ISO, sata0:1 = VMware Tools
ISO. ``deploy-win`` removes the SATA/USB controllers and switches the NIC to
vmxnet3 after the unattended install drops install_complete.flag.
"""
return [
'.encoding = "windows-1252"',
'config.version = "8"',
'virtualHW.version = "21"',
f'displayName = "{vm_name}"',
f'guestOS = "{_WIN2025_GUEST_OS}"',
'firmware = "efi"',
'uefi.secureBoot.enabled = "FALSE"',
f'memSize = "{mem_mb}"',
f'numvcpus = "{vcpu_count}"',
f'cpuid.coresPerSocket = "{cores_per_socket}"',
'tools.syncTime = "TRUE"',
'tools.upgrade.policy = "manual"',
'powerType.powerOff = "soft"',
'powerType.powerOn = "soft"',
'powerType.suspend = "soft"',
'powerType.reset = "soft"',
'uuid.action = "create"',
'pciBridge0.present = "TRUE"',
'pciBridge4.present = "TRUE"',
'pciBridge4.virtualDev = "pcieRootPort"',
'pciBridge4.functions = "8"',
'pciBridge5.present = "TRUE"',
'pciBridge5.virtualDev = "pcieRootPort"',
'pciBridge5.functions = "8"',
'pciBridge6.present = "TRUE"',
'pciBridge6.virtualDev = "pcieRootPort"',
'pciBridge6.functions = "8"',
'pciBridge7.present = "TRUE"',
'pciBridge7.virtualDev = "pcieRootPort"',
'pciBridge7.functions = "8"',
'nvme0.present = "TRUE"',
'nvme0.pciSlotNumber = "224"',
'nvme0:0.present = "TRUE"',
f'nvme0:0.fileName = "{vmdk_name}"',
'nvme0:0.deviceType = "disk"',
'sata0.present = "TRUE"',
'sata0.pciSlotNumber = "32"',
'sata0:0.present = "TRUE"',
'sata0:0.deviceType = "cdrom-image"',
f'sata0:0.fileName = "{install_iso}"',
'sata0:0.startConnected = "TRUE"',
'sata0:1.present = "TRUE"',
'sata0:1.deviceType = "cdrom-image"',
f'sata0:1.fileName = "{tools_iso}"',
'sata0:1.startConnected = "TRUE"',
'ethernet0.present = "TRUE"',
'ethernet0.virtualDev = "e1000e"',
'ethernet0.connectionType = "nat"',
'ethernet0.addressType = "generated"',
'ethernet0.startConnected = "TRUE"',
'ethernet0.pciSlotNumber = "192"',
# No USB controllers: a headless CI build VM has no USB devices; input is
# the VMware PS/2 keyboard/mouse + SVGA console. Omitting usb/ehci/xhci
# trims device enumeration at boot. (Step 9 still strips any usb* line.)
'usb.present = "FALSE"',
'svga.present = "TRUE"',
'svga.graphicsMemoryKB = "262144"',
'floppy0.present = "FALSE"',
'sound.present = "FALSE"',
'vmci0.present = "TRUE"',
'vmci0.pciSlotNumber = "36"',
'hpet0.present = "TRUE"',
'mks.enable3d = "FALSE"',
'snapshot.action = "keep"',
]
def _vmrun_tools_running(vmrun: str, vmx: str) -> bool:
"""True when VMware Tools report 'running' inside the guest."""
proc = subprocess.run(
[vmrun, "-T", "ws", "checkToolsState", str(vmx)],
capture_output=True, text=True, timeout=30, check=False,
)
return proc.stdout.strip().lower() == "running"
def _wait_install_flag(
vmrun: str,
vmx: str,
*,
admin_user: str,
admin_password: str,
flag_path: str,
timeout_s: float,
poll_s: float,
) -> bool:
"""Poll until the guest drops ``flag_path`` (Tools must be running first)."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if _vmrun_tools_running(vmrun, vmx):
proc = subprocess.run(
[vmrun, "-gu", admin_user, "-gp", admin_password,
"fileExistsInGuest", str(vmx), flag_path],
capture_output=True, text=True, timeout=60, check=False,
)
if "the file exists" in proc.stdout.strip().lower():
return True
click.echo(" ... Tools up; waiting for install_complete.flag ...")
else:
click.echo(" ... waiting for VMware Tools to report running ...")
time.sleep(poll_s)
return False
def _vmrun_soft_stop(vmrun: str, vmx: str, *, timeout_s: float = 300.0) -> None:
"""Soft-stop the VM, falling back to hard stop after ``timeout_s``."""
subprocess.run(
[vmrun, "-T", "ws", "stop", str(vmx), "soft"],
capture_output=True, text=True, check=False,
)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
listing = subprocess.run(
[vmrun, "-T", "ws", "list"], capture_output=True, text=True, check=False,
)
if str(vmx) not in listing.stdout:
return
time.sleep(5)
subprocess.run(
[vmrun, "-T", "ws", "stop", str(vmx), "hard"],
capture_output=True, text=True, check=False,
)
@template.command("deploy-win")
@click.option("--vmx", "vmx_path_str", default=None, metavar="PATH",
help="VMX to create (default: <CI_TEMPLATES>/WinBuild2025/WinBuild2025.vmx).")
@click.option("--vm-name", default="WinBuild2025", show_default=True, help="VM display name.")
@click.option("--build", "uup_build", default=None,
help="Pin a UUP build number (e.g. 26100.32860). Default: newest WS2025.")
@click.option("--win-iso", "win_iso_str", default=None, metavar="PATH",
help="Use this Windows ISO instead of downloading from UUP dump.")
@click.option("--edition", default="SERVERSTANDARD", show_default=True, help="UUP edition.")
@click.option("--lang", default="en-us", show_default=True, help="UUP language pack.")
@click.option("--image-index", default=2, show_default=True, help="install.wim image index.")
@click.option("--product-key", default="TVRH6-WHNXV-R9WG3-9XRFY-MY832", show_default=True)
@click.option("--admin-user", default="Administrator", show_default=True)
@click.option("--admin-password", default="WinBuild!", show_default=True)
@click.option("--computer-name", default="WINBUILD-2025", show_default=True)
@click.option("--locale-os", default="en-US", show_default=True)
@click.option("--locale-user", default="it-IT", show_default=True)
@click.option("--keyboard", default="0410:00000410", show_default=True)
@click.option("--timezone", default="W. Europe Standard Time", show_default=True)
@click.option("--disk-size-gb", default=80, show_default=True)
@click.option("--memory-mb", default=6144, show_default=True)
@click.option("--vcpu-count", default=4, show_default=True)
@click.option("--cores-per-socket", default=2, show_default=True)
@click.option("--tools-iso", "tools_iso_str", default=None, metavar="PATH",
help="VMware Tools ISO (default: <CI_ROOT>/iso/VMware-tools-windows.iso).")
@click.option("--snapshot-name", default="PostInstall", show_default=True)
@click.option("--gui/--nogui", "gui", default=False, show_default=True,
help="Start the VM with the Workstation window (needs an X display).")
@click.option("--flag-timeout", default=5400, show_default=True,
help="Seconds to wait for install_complete.flag.")
@click.option("--flag-poll", default=30, show_default=True, help="Flag poll interval (s).")
@click.option("--vdisk-manager", "vdisk_manager_str", default=None)
@click.option("--force", is_flag=True, default=False, help="Overwrite an existing VM directory.")
@click.option("--start-from-step", default=1, type=click.IntRange(1, 10), show_default=True)
def template_deploy_win(
vmx_path_str: str | None,
vm_name: str,
uup_build: str | None,
win_iso_str: str | None,
edition: str,
lang: str,
image_index: int,
product_key: str,
admin_user: str,
admin_password: str,
computer_name: str,
locale_os: str,
locale_user: str,
keyboard: str,
timezone: str,
disk_size_gb: int,
memory_mb: int,
vcpu_count: int,
cores_per_socket: int,
tools_iso_str: str | None,
snapshot_name: str,
gui: bool,
flag_timeout: int,
flag_poll: int,
vdisk_manager_str: str | None,
force: bool,
start_from_step: int,
) -> None:
"""Deploy the Windows Server 2025 build VM template (Linux VMware host).
Downloads the latest WS2025 ISO from UUP dump (deterministic, build-pinned),
patches it (inject autounattend.xml + post-install.ps1 + no-prompt boot),
runs the unattended install, swaps the NIC to vmxnet3 and snapshots the
template. Mirrors the legacy Deploy-WinBuild2025.ps1 ten-step flow.
"""
config = load_config()
vmx_path = (
Path(vmx_path_str).resolve()
if vmx_path_str
else config.paths.templates / "WinBuild2025" / "WinBuild2025.vmx"
)
vm_dir = vmx_path.parent
iso_dir = config.paths.root / "iso"
tools_iso = Path(tools_iso_str) if tools_iso_str else iso_dir / "VMware-tools-windows.iso"
vmdk_name = vmx_path.stem + ".vmdk"
vmdk_path = vm_dir / vmdk_name
patched_iso = vm_dir / "install-noprompt.iso"
flag_path = r"C:\Windows\Temp\install_complete.flag"
vmrun = config.vmrun_path or shutil.which("vmrun") or "vmrun"
vdisk_mgr = _find_vdisk_manager(vdisk_manager_str)
params = win_autounattend.WinUnattendParams(
admin_user=admin_user, admin_password=admin_password,
computer_name=computer_name, product_key=product_key,
image_index=image_index, locale_os=locale_os, locale_user=locale_user,
keyboard=keyboard, timezone=timezone,
)
if start_from_step > 1:
click.echo(f" [SKIP] Steps 1..{start_from_step - 1} -- starting at Step {start_from_step}")
# ── Step 1: preflight ──────────────────────────────────────────────────────
_deploy_step("Step 1: preflight", 1, start_from_step)
if start_from_step <= 1:
try:
win_autounattend.validate_computer_name(computer_name)
except ValueError as exc:
raise click.ClickException(f"[PreFlight] {exc}") from exc
checks: list[tuple[str, bool]] = [
(f"vmrun found: {vmrun}", bool(shutil.which(vmrun) or Path(vmrun).is_file())),
(f"vmware-vdiskmanager found: {vdisk_mgr}", Path(vdisk_mgr).is_file()),
("xorriso in PATH", shutil.which("xorriso") is not None),
("7z in PATH", any(shutil.which(n) for n in ("7z", "7za", "7zr"))),
(f"VMware Tools ISO: {tools_iso}", tools_iso.is_file()),
]
for label, ok in checks:
if not ok:
raise click.ClickException(f"[PreFlight] {label}")
click.echo(f" [OK] {label}")
if win_iso_str is None:
missing = win_uup.check_tools()
if missing:
raise click.ClickException(
"[PreFlight] missing UUP tools: " + ", ".join(missing)
)
click.echo(" [OK] UUP conversion tools present")
if vm_dir.exists():
if not force:
raise click.ClickException(
f"[PreFlight] VM directory exists: {vm_dir}\n Use --force to overwrite."
)
click.echo(f" [WARN] --force -- reusing VM directory: {vm_dir}")
_clean_force_artifacts(vm_dir, vmx_path.stem)
else:
vm_dir.mkdir(parents=True, exist_ok=True)
click.echo(f" [OK] VM directory ready: {vm_dir}")
# ── Step 2: render answer files ────────────────────────────────────────────
_deploy_step("Step 2: render autounattend.xml + post-install.ps1", 2, start_from_step)
autounattend_xml = win_autounattend.render_autounattend(params)
post_install_ps1 = win_autounattend.render_post_install(params)
if start_from_step <= 2:
click.echo(" [OK] answer files rendered")
# ── Step 3: acquire base ISO (UUP dump) ────────────────────────────────────
_deploy_step("Step 3: acquire Windows Server 2025 ISO", 3, start_from_step)
if win_iso_str is not None:
base_iso = Path(win_iso_str)
if start_from_step <= 3 and not base_iso.is_file():
raise click.ClickException(f"--win-iso not found: {base_iso}")
click.echo(f" [OK] using supplied ISO: {base_iso}")
else:
build = win_uup.resolve_build(build=uup_build)
click.echo(f" resolved build: {build.build} ({build.uuid})")
base_iso = win_uup.build_iso(build, dest_dir=iso_dir, lang=lang, edition=edition)
# ── Step 4: patch ISO (inject answer files + no-prompt boot) ───────────────
_deploy_step("Step 4: patch ISO (inject autounattend + no-prompt boot)", 4, start_from_step)
if start_from_step <= 4:
key = _patched_iso_inputs_key(base_iso, autounattend_xml, post_install_ps1)
sidecar = patched_iso.with_suffix(patched_iso.suffix + ".inputs")
cached = (
patched_iso.is_file()
and sidecar.is_file()
and sidecar.read_text(encoding="utf-8").strip() == key
)
if cached:
click.echo(f" [OK] patched ISO up to date — skipping repack: {patched_iso}")
else:
win_iso_patch.patch_iso(
base_iso,
autounattend_xml=autounattend_xml,
post_install_ps1=post_install_ps1,
out_iso=patched_iso,
work_dir=vm_dir,
)
sidecar.write_text(key, encoding="utf-8")
# ── Step 5: create VMDK ────────────────────────────────────────────────────
_deploy_step("Step 5: create VMDK", 5, start_from_step)
if start_from_step <= 5:
if vmdk_path.exists():
vmdk_path.unlink()
subprocess.run(
[vdisk_mgr, "-c", "-s", f"{disk_size_gb}GB", "-a", "lsilogic", "-t", "0",
str(vmdk_path)],
check=True,
)
click.echo(f" [OK] VMDK created: {vmdk_path}")
# ── Step 6: write VMX ──────────────────────────────────────────────────────
_deploy_step("Step 6: write VMX", 6, start_from_step)
if start_from_step <= 6:
lines = _winbuild_vmx_lines(
vm_name=vm_name, mem_mb=memory_mb, vcpu_count=vcpu_count,
cores_per_socket=cores_per_socket, vmdk_name=vmdk_name,
install_iso=str(patched_iso), tools_iso=str(tools_iso),
)
vmx_path.write_text("\n".join(lines) + "\n", encoding="ascii")
click.echo(f" [OK] VMX written: {vmx_path}")
# ── Step 7: power on for unattended install ────────────────────────────────
_deploy_step("Step 7: power on (unattended install)", 7, start_from_step)
if start_from_step <= 7:
gui_mode = "gui" if gui else "nogui"
subprocess.Popen(
[vmrun, "-T", "ws", "start", str(vmx_path), gui_mode]
)
time.sleep(10)
click.echo(" [OK] VM powered on")
# ── Step 8: wait for install_complete.flag ─────────────────────────────────
_deploy_step("Step 8: wait for install_complete.flag", 8, start_from_step)
if start_from_step <= 8:
ok = _wait_install_flag(
vmrun, str(vmx_path), admin_user=admin_user, admin_password=admin_password,
flag_path=flag_path, timeout_s=float(flag_timeout), poll_s=float(flag_poll),
)
if not ok:
raise click.ClickException(
f"[Deploy Step 8] install_complete.flag not seen within {flag_timeout}s."
)
click.echo(" [OK] install_complete.flag detected")
# ── Step 9: stop, strip install media, swap NIC to vmxnet3 ─────────────────
_deploy_step("Step 9: strip install media + swap NIC to vmxnet3", 9, start_from_step)
if start_from_step <= 9:
_vmrun_soft_stop(vmrun, str(vmx_path))
for prefix in ("sata0", "usb", "ehci", "usb_xhci"):
_remove_vmx_lines(vmx_path, prefix)
_patch_vmx_key(vmx_path, "ethernet0.virtualDev", "vmxnet3")
click.echo(" [OK] SATA/USB removed, NIC -> vmxnet3")
# ── Step 10: verify vmxnet3 IP, then cold snapshot ─────────────────────────
_deploy_step("Step 10: verify network + snapshot", 10, start_from_step)
if start_from_step <= 10:
gui_mode = "gui" if gui else "nogui"
subprocess.Popen(
[vmrun, "-T", "ws", "start", str(vmx_path), gui_mode]
)
guest_ip = _poll_guest_ip_deploy(vmrun, str(vmx_path), timeout=600.0)
if not guest_ip:
raise click.ClickException("[Deploy Step 10] no guest IP after vmxnet3 swap.")
click.echo(f" [OK] vmxnet3 guest IP: {guest_ip}")
_vmrun_soft_stop(vmrun, str(vmx_path))
subprocess.run(
[vmrun, "-T", "ws", "snapshot", str(vmx_path), snapshot_name], check=True,
)
listing = subprocess.run(
[vmrun, "-T", "ws", "listSnapshots", str(vmx_path)],
capture_output=True, text=True, check=False,
)
if snapshot_name not in listing.stdout:
raise click.ClickException(
f"[Deploy Step 10] snapshot '{snapshot_name}' not found after creation."
)
click.echo(f" [OK] snapshot '{snapshot_name}' created (cold)")
click.echo("\n=== deploy-win complete ===")
click.echo(f" VMX: {vmx_path}")
click.echo(f" Snapshot: {snapshot_name}")
click.echo(f" Next: ci template prepare-win --vmx {vmx_path}")