diff --git a/src/ci_orchestrator/commands/template.py b/src/ci_orchestrator/commands/template.py
index 8047f2d..c3aac53 100644
--- a/src/ci_orchestrator/commands/template.py
+++ b/src/ci_orchestrator/commands/template.py
@@ -70,6 +70,7 @@ import threading
import time
import urllib.parse
import urllib.request
+from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
@@ -105,6 +106,17 @@ def template() -> None:
is_flag=True,
help="Back up every subdirectory under CI_TEMPLATES in a single pass.",
)
+@click.option(
+ "--exclude",
+ "excludes",
+ multiple=True,
+ metavar="NAME",
+ help=(
+ "Template directory name to skip with --all-templates. Repeatable. "
+ "Use for retired or disposable templates whose archives are not worth "
+ "the disk (a full Windows template costs ~25 GB per generation)."
+ ),
+)
@click.option(
"--backup-base-dir",
default="/var/lib/ci/backups",
@@ -130,6 +142,7 @@ def template() -> None:
def template_backup(
template_path: str | None,
all_templates: bool,
+ excludes: tuple[str, ...],
backup_base_dir: str,
keep_count: int,
skip_runner_stop: bool,
@@ -140,15 +153,37 @@ def template_backup(
backup_base = Path(backup_base_dir)
timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
+ if excludes and not all_templates:
+ raise click.ClickException(
+ "--exclude only applies to --all-templates; with a single "
+ "--template-path it would be a silent no-op."
+ )
+
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:
+ found = sorted(d for d in tmpl_root.iterdir() if d.is_dir())
+ if not found:
raise click.ClickException(
f"No template subdirectories found under {tmpl_root}"
)
+ excluded = {name.strip() for name in excludes if name.strip()}
+ # Report skips explicitly: a silently missing backup is worse than a
+ # noisy one, and a typo in --exclude must not look like a skip.
+ unknown = excluded - {d.name for d in found}
+ if unknown:
+ raise click.ClickException(
+ f"--exclude names no such template: {', '.join(sorted(unknown))}. "
+ f"Available: {', '.join(d.name for d in found)}"
+ )
+ to_backup = [d for d in found if d.name not in excluded]
+ for name in sorted(excluded):
+ click.echo(f"[Backup-CITemplate] Skipping '{name}' (--exclude).")
+ if not to_backup:
+ raise click.ClickException(
+ f"Every template under {tmpl_root} was excluded — nothing to back up."
+ )
else:
p = Path(template_path) if template_path else config.paths.templates / "WinBuild2025"
if not p.is_dir():
@@ -1029,6 +1064,15 @@ Register-ScheduledTask -TaskName 'CI-StaticIp' `
f"(target='{credential_target}', user='{build_username}')."
)
+ click.echo("")
+ click.echo("[prepare-win] Summary")
+ click.echo(f" VMX: {vmx_path}")
+ click.echo(f" Snapshot: {snapshot}")
+ click.echo(
+ " Next: ci-orchestrator vm new "
+ f"--template {vmx_path} --snapshot {snapshot} "
+ "--guest-os windows --clone-base-dir
--job-id "
+ )
click.echo("[prepare-win] Provisioning complete.")
@@ -1164,14 +1208,31 @@ def _poll_guest_ip_deploy(vmrun: str, vmx: str, timeout: float) -> str | None:
click.echo(f" ... guest IP from guestinfo.ci-ip: {ip}")
return ip
try:
+ # -wait is mandatory (AGENTS.md #10): without it vmrun answers
+ # "Error: The VMware Tools are not running in the virtual machine"
+ # unless the Tools happen to have registered the IP at that exact
+ # instant. That race is what made this poll intermittent — a guest
+ # that was up, on the network and answering SSH could still burn
+ # the whole 10-minute budget here. With -wait vmrun blocks until
+ # the Tools report, so the 30s subprocess cap (not the flag) is
+ # what keeps this loop responsive.
result = subprocess.run(
- [vmrun, "getGuestIPAddress", vmx],
+ [vmrun, "getGuestIPAddress", vmx, "-wait"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
- ip = result.stdout.strip()
+ # vmrun can exit 0 with "Error: ..." on stdout — skip those lines
+ # rather than trusting the exit code.
+ ip = next(
+ (
+ line.strip()
+ for line in (result.stdout or "").splitlines()
+ if line.strip() and not line.strip().lower().startswith("error:")
+ ),
+ "",
+ )
if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
return ip
except subprocess.TimeoutExpired:
@@ -1263,8 +1324,12 @@ def _patch_vmx_key(vmx_path: Path, key: str, value: str) -> None:
@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).",
+ "--snapshot-name", default="BaseClean-Linux", show_default=True,
+ help=(
+ "Snapshot that prepare-linux will create. deploy-linux takes no "
+ "snapshot itself — this only feeds the summary and the --then-prepare "
+ "hand-off, so it must match what prepare-linux is asked to create."
+ ),
)
@click.option(
"--ssh-key", "ssh_key_str", default=None, metavar="PATH",
@@ -1314,6 +1379,23 @@ def _patch_vmx_key(vmx_path: Path, key: str, value: str) -> None:
"5=VMX 6=power-on 7=guest-IP 8=SSH-port 9=cloud-init-verify 10=detach-ISO"
),
)
+@click.option(
+ "--then-prepare", is_flag=True,
+ help=(
+ "On success, hand off to the matching prepare-* command in the same "
+ "run (deploy-linux -> prepare-linux, deploy-rust -> prepare-rust). "
+ "The two commands stay independent and separately invocable; this "
+ "only chains them, reusing the VM that deploy left running."
+ ),
+)
+@click.option(
+ "--toolchain-script", "toolchain_script", default=None, metavar="PATH",
+ help=(
+ "Explicit path to the guest toolchain script for the chained "
+ "prepare-*. Only read with --then-prepare; without it the script is "
+ "looked up under /template/. Checked before the deploy starts."
+ ),
+)
def template_deploy_linux(
vmx_path_str: str | None,
vm_name: str,
@@ -1337,6 +1419,15 @@ def template_deploy_linux(
diag_password: str,
vdisk_manager_str: str | None,
start_from_step: int,
+ then_prepare: bool,
+ toolchain_script: str | None = None,
+ # Not click options: set by the deploy-rust wrapper so the log prefix, the
+ # summary and the --then-prepare hand-off name the command the user
+ # actually typed.
+ prog_name: str = "deploy-linux",
+ prepare_prog: str = "prepare-linux",
+ rust_channel: str | None = None,
+ no_cargo_audit: bool = False,
) -> None:
"""Deploy Ubuntu 24.04 LTS build VM template (Linux VMware Workstation host).
@@ -1346,6 +1437,22 @@ def template_deploy_linux(
"""
_ = timezone # accepted for parity with PS script; set timezone via prepare-linux
+ # Pre-flight the hand-off BEFORE any VM work. Resolving the toolchain script
+ # inside prepare means a ~45-minute deploy completes and only then fails on
+ # a missing file, leaving a half-built template. Resolve it now, and pass
+ # the absolute path down so the chained prepare no longer depends on CWD.
+ chained_toolchain: str | None = None
+ if then_prepare:
+ toolchain_name = (
+ "Install-CIToolchain-LinuxRust2404.sh"
+ if prepare_prog == "prepare-rust"
+ else "Install-CIToolchain-Linux2404.sh"
+ )
+ chained_toolchain = str(
+ _resolve_toolchain_script(toolchain_name, toolchain_script)
+ )
+ click.echo(f"[{prog_name}] --then-prepare pre-flight — toolchain: {chained_toolchain}")
+
config = load_config()
# ── Resolve paths ──────────────────────────────────────────────────────────
@@ -1416,6 +1523,22 @@ def template_deploy_linux(
f"[PreFlight] VM directory already exists: {vm_dir}\n"
" Use --force to overwrite."
)
+ # Refuse to touch a VM that is powered on. --force deletes the
+ # disk lock as "stale" and Step 4 rewrites the base VMDK: doing
+ # that under a live guest destroys it, and the damage only
+ # surfaces later as a Step 7 IP timeout on a VM that looks fine in
+ # `vmrun list` (it is the OLD instance being listed).
+ running = subprocess.run(
+ [vmrun, "-T", "ws", "list"], capture_output=True, text=True, check=False,
+ )
+ if str(vmx_path) in (running.stdout or ""):
+ raise click.ClickException(
+ f"[PreFlight] Refusing --force: the VM is currently powered on.\n"
+ f" {vmx_path}\n"
+ " Another deploy/prepare may still be using it. Stop it first:\n"
+ f" vmrun -T ws stop {vmx_path} hard"
+ )
+ click.echo(" [OK] Target VM is powered off")
click.echo(f" [WARN] --force set -- reusing existing VM directory: {vm_dir}")
cleaned = _clean_force_artifacts(vm_dir, vmx_path.stem)
if cleaned:
@@ -1620,9 +1743,21 @@ def template_deploy_linux(
_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)
+ # Presence in `vmrun list` is not proof that OUR power-on worked: if
+ # another instance of this VMX were already running, the list check
+ # would pass while our vmrun start died on a busy VMX. The pre-flight
+ # above rules that out, so here we additionally require the start
+ # command itself to succeed instead of only inspecting the list.
+ start_r = subprocess.run(
+ [vmrun, "-T", "ws", "start", str(vmx_path), gui_mode],
+ capture_output=True, text=True, check=False,
+ )
+ if start_r.returncode != 0:
+ detail = ((start_r.stdout or "") + (start_r.stderr or "")).strip()
+ raise click.ClickException(
+ f"[Deploy Step 6] vmrun start failed (exit={start_r.returncode}): {detail}"
+ )
+ click.echo(" [Deploy] VM power-on completed.")
list_r = subprocess.run(
[vmrun, "-T", "ws", "list"], capture_output=True, text=True, check=False,
)
@@ -1699,18 +1834,238 @@ def template_deploy_linux(
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("")
+ click.echo(f"\n=== {prog_name} complete ===\n")
+ click.echo(f"[{prog_name}] 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: none yet — {prepare_prog} creates '{snapshot_name}'")
if guest_ip:
- click.echo(f" SSH: ssh -i {ssh_key_path} {cloud_user}@{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.")
+ if not then_prepare:
+ click.echo(
+ f" Next: ci-orchestrator template {prepare_prog} "
+ f"--vmx {vmx_path}"
+ )
+ return
+
+ # ── Hand-off to prepare-* ─────────────────────────────────────────────────
+ # The VM is up and cloud-init has finished, so prepare reuses it instead of
+ # cold-booting: pass both the VMX (needed for the snapshot) and the IP we
+ # already resolved (skips a second detection round).
+ click.echo("")
+ click.echo(f"[{prog_name}] --then-prepare — handing off to {prepare_prog} ...")
+ if prepare_prog == "prepare-rust":
+ toolchain_name = "Install-CIToolchain-LinuxRust2404.sh"
+ template_dir_name = "LinuxRust2404"
+ else:
+ toolchain_name = "Install-CIToolchain-Linux2404.sh"
+ template_dir_name = "LinuxBuild2404"
+ _prepare_linux_impl(
+ prog_name=prepare_prog,
+ default_toolchain_name=toolchain_name,
+ default_vmx_name=template_dir_name,
+ vmx_path_str=str(vmx_path),
+ guest_ip=guest_ip or "",
+ cloud_user=cloud_user,
+ ssh_key_str=str(ssh_key_path),
+ snapshot=snapshot_name,
+ # Absolute path resolved during pre-flight: the chained prepare must
+ # not re-resolve against CWD, which may have changed meaning by now.
+ toolchain_script_str=chained_toolchain,
+ skip_update=False,
+ install_dotnet=False,
+ no_mingw=False,
+ ssh_timeout=ssh_timeout,
+ take_snapshot=True,
+ rust_channel=rust_channel,
+ no_cargo_audit=no_cargo_audit,
+ )
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# template deploy-rust command (thin wrapper over deploy-linux)
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+@template.command("deploy-rust")
+@click.option(
+ "--vmx", "vmx_path_str", default=None, metavar="PATH",
+ help="VMX file to create (default: /LinuxRust2404/LinuxRust2404.vmx).",
+)
+@click.option("--vm-name", default="LinuxRust2404", 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-rust-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=60, 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="BaseClean-LinuxRust", show_default=True,
+ help=(
+ "Snapshot that prepare-rust will create. deploy-rust takes no "
+ "snapshot itself — this only feeds the summary and the --then-prepare "
+ "hand-off, so it must match what prepare-rust is asked to create."
+ ),
+)
+@click.option(
+ "--ssh-key", "ssh_key_str", default=None, metavar="PATH",
+ help="SSH private key (default: from config ssh_key_path or /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: /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: /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"
+ ),
+)
+@click.option(
+ "--then-prepare", is_flag=True,
+ help=(
+ "On success, hand off to prepare-rust in the same run, reusing the VM "
+ "deploy-rust left running. Both commands stay separately invocable."
+ ),
+)
+@click.option(
+ "--rust-channel", default="stable", show_default=True,
+ help=(
+ "Rust toolchain baked by the chained prepare-rust. Only read with "
+ "--then-prepare. Pin an exact version (e.g. 1.97.1) for a "
+ "deterministic snapshot; 'stable' is a moving target."
+ ),
+)
+@click.option(
+ "--no-cargo-audit", is_flag=True,
+ help=(
+ "Skip baking the cargo-audit binary in the chained prepare-rust. "
+ "Only read with --then-prepare."
+ ),
+)
+@click.option(
+ "--toolchain-script", "toolchain_script", default=None, metavar="PATH",
+ help=(
+ "Explicit path to Install-CIToolchain-LinuxRust2404.sh for the chained "
+ "prepare-rust. Only read with --then-prepare; checked before the "
+ "deploy starts."
+ ),
+)
+def template_deploy_rust(
+ 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,
+ then_prepare: bool,
+ rust_channel: str,
+ no_cargo_audit: bool,
+ toolchain_script: str | None,
+) -> None:
+ """Deploy the Rust Linux template VM (LinuxRust2404, Ubuntu 24.04).
+
+ Identical to deploy-linux (same cloud image and VMX layout) but defaults
+ to the LinuxRust2404 directory/name and a larger disk (60 GB) to hold the
+ baked Rust toolchain. Run `template prepare-rust` next, or pass
+ --then-prepare to chain both in one run.
+ """
+ if vmx_path_str is None:
+ config = load_config()
+ vmx_path_str = str(
+ config.paths.templates / "LinuxRust2404" / "LinuxRust2404.vmx"
+ )
+ deploy_linux_cb = template_deploy_linux.callback
+ assert deploy_linux_cb is not None # click always sets callback for @command
+ deploy_linux_cb(
+ vmx_path_str=vmx_path_str,
+ vm_name=vm_name,
+ cloud_user=cloud_user,
+ guest_hostname=guest_hostname,
+ timezone=timezone,
+ disk_size_gb=disk_size_gb,
+ memory_mb=memory_mb,
+ vcpu_count=vcpu_count,
+ cores_per_socket=cores_per_socket,
+ snapshot_name=snapshot_name,
+ ssh_key_str=ssh_key_str,
+ vmdk_url=vmdk_url,
+ vmdk_cache_str=vmdk_cache_str,
+ vmdk_sha256=vmdk_sha256,
+ seed_iso_str=seed_iso_str,
+ ssh_timeout=ssh_timeout,
+ ssh_poll_interval=ssh_poll_interval,
+ show_gui=show_gui,
+ force=force,
+ diag_password=diag_password,
+ vdisk_manager_str=vdisk_manager_str,
+ start_from_step=start_from_step,
+ then_prepare=then_prepare,
+ toolchain_script=toolchain_script,
+ prog_name="deploy-rust",
+ prepare_prog="prepare-rust",
+ rust_channel=rust_channel,
+ no_cargo_audit=no_cargo_audit,
+ )
+
# ═══════════════════════════════════════════════════════════════════════════
# template prepare-linux helpers
@@ -1764,6 +2119,39 @@ def _pl_run(
return rc, out, err
+def _resolve_toolchain_script(default_name: str, explicit: str | None) -> Path:
+ """Locate the guest-side toolchain script, or fail listing what was tried.
+
+ Resolution order: an explicit ``--toolchain-script``, then ``template/``
+ under the current directory, then ``template/`` next to a source checkout of
+ this package (editable installs only — a non-editable venv has no repo to
+ walk back to, which is why the systemd units set ``WorkingDirectory``).
+
+ Callers that chain deploy -> prepare must call this **before** starting any
+ VM work: resolving it late means a 45-minute deploy succeeds and then the
+ hand-off dies on a missing file.
+ """
+ if explicit is not None:
+ p = Path(explicit).expanduser()
+ if not p.is_file():
+ raise click.ClickException(f"Toolchain script not found: {p}")
+ return p
+
+ # Deliberately only /template/. A fallback relative to this module
+ # would resolve in a source checkout and still fail in the production venv
+ # (installed non-editable, no repo to walk back to) — dev and prod would
+ # disagree, which is the failure mode this whole helper exists to remove.
+ candidate = Path.cwd() / "template" / default_name
+ if candidate.is_file():
+ return candidate
+
+ raise click.ClickException(
+ f"Toolchain script {default_name!r} not found at {candidate}.\n"
+ "Run from the repo root (cd /opt/ci/local-ci-cd-system), or pass "
+ "--toolchain-script with an explicit path."
+ )
+
+
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()
@@ -1807,46 +2195,11 @@ def _pl_upload(client: paramiko.SSHClient, local: Path, remote: str) -> None:
# ═══════════════════════════════════════════════════════════════════════════
-@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_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 /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(
+def _prepare_linux_impl(
+ *,
+ prog_name: str,
+ default_toolchain_name: str,
+ default_vmx_name: str,
vmx_path_str: str | None,
guest_ip: str,
cloud_user: str,
@@ -1858,11 +2211,19 @@ def template_prepare_linux(
no_mingw: bool,
ssh_timeout: int,
take_snapshot: bool,
+ rust_channel: str | None = None,
+ no_cargo_audit: bool = False,
) -> None:
- """Provision Linux template VM via SSH (ports Prepare-LinuxBuild2404.ps1).
+ """Shared implementation of ``prepare-linux`` / ``prepare-rust``.
- Requires either --vmx (auto power-on + IP detection + snapshot) or
- --ip (explicit IP, no auto VM lifecycle), or both.
+ ``prog_name`` is used for log prefixes (``[{prog_name}]`` /
+ ``[prepare-rust]``); ``default_toolchain_name`` is the toolchain script
+ auto-detected under ``/template/`` when ``--toolchain-script``
+ is not passed; ``default_vmx_name`` is the template directory name used to
+ resolve the default VMX (``//.vmx``) when neither
+ ``--vmx`` nor ``--ip`` is given. Rust-only flags (``rust_channel`` /
+ ``no_cargo_audit``) are forwarded to the toolchain script only when set
+ (i.e. by ``prepare-rust``).
"""
config = load_config()
@@ -1875,21 +2236,24 @@ def template_prepare_linux(
ssh_key_path = config.paths.keys / "ci_linux"
# ── Resolve VMX ───────────────────────────────────────────────────────────
- vmx_path = Path(vmx_path_str).resolve() if vmx_path_str else None
+ # Default: //.vmx, but
+ # only when it exists — otherwise fall through to the --vmx/--ip check
+ # below so the error message still makes sense on hosts without the VM.
+ vmx_path: Path | None
+ if vmx_path_str:
+ vmx_path = Path(vmx_path_str).resolve()
+ elif not guest_ip:
+ default_vmx = (
+ config.paths.templates / default_vmx_name / f"{default_vmx_name}.vmx"
+ )
+ vmx_path = default_vmx if default_vmx.is_file() else None
+ else:
+ vmx_path = 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}")
+ toolchain_path = _resolve_toolchain_script(
+ default_toolchain_name, toolchain_script_str
+ )
if vmx_path is None and not guest_ip:
raise click.ClickException(
@@ -1899,7 +2263,7 @@ def template_prepare_linux(
do_snapshot = take_snapshot or vmx_path is not None
# ── Step 1: Pre-flight ────────────────────────────────────────────────────
- click.echo("[prepare-linux] Step 1 — Pre-flight validation")
+ click.echo(f"[{prog_name}] 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}")
@@ -1919,36 +2283,36 @@ def template_prepare_linux(
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} ...")
+ click.echo(f"[{prog_name}] Starting VM: {vmx_path.name} ...")
backend.start(handle, headless=True)
else:
- click.echo(f"[prepare-linux] VM already running: {vmx_path.name}")
+ click.echo(f"[{prog_name}] 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) ...")
+ click.echo(f"[{prog_name}] 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 ..."
+ f"[{prog_name}] 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) ...")
+ click.echo(f"[{prog_name}] 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}")
+ click.echo(f"[{prog_name}] 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"[{prog_name}] 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):
@@ -1958,7 +2322,7 @@ def template_prepare_linux(
click.echo(f" [OK] TCP/22 reachable on {guest_ip}")
# ── Step 4: SSH connectivity test ─────────────────────────────────────────
- click.echo("[prepare-linux] Step 4 — Testing SSH connectivity ...")
+ click.echo(f"[{prog_name}] Step 4 — Testing SSH connectivity ...")
try:
client = _pl_connect(guest_ip, cloud_user, str(ssh_key_path))
except Exception as exc:
@@ -1974,14 +2338,14 @@ def template_prepare_linux(
try:
# ── Step 5: Upload toolchain script ───────────────────────────────────
click.echo(
- f"[prepare-linux] Step 5 — Uploading {toolchain_path.name} to guest /tmp/ ..."
+ f"[{prog_name}] 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"
+ remote_script = f"/tmp/{toolchain_path.name}"
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as tf:
tf.write(normalized)
@@ -2008,10 +2372,14 @@ def template_prepare_linux(
flags.append("--dotnet")
if no_mingw:
flags.append("--no-mingw")
+ if rust_channel is not None:
+ flags.extend(["--rust-channel", rust_channel])
+ if no_cargo_audit:
+ flags.append("--no-cargo-audit")
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"[{prog_name}] 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)
@@ -2020,12 +2388,12 @@ def template_prepare_linux(
raise click.ClickException(
f"Toolchain script exited {exit_code} — see output above."
)
- click.echo("[prepare-linux] Toolchain script completed (exit 0).")
+ click.echo(f"[{prog_name}] Toolchain script completed (exit 0).")
# ── Step 7: Post-setup validation ─────────────────────────────────────
- click.echo("[prepare-linux] Step 7 — Running post-setup validation ...")
+ click.echo(f"[{prog_name}] Step 7 — Running post-setup validation ...")
_, val_out, _ = _pl_run(client, _PREPARE_LINUX_VALIDATION, timeout=120.0)
- click.echo("[prepare-linux] Validation results:")
+ click.echo(f"[{prog_name}] Validation results:")
failures: list[str] = []
for line in val_out.splitlines():
line = line.strip()
@@ -2039,10 +2407,10 @@ def template_prepare_linux(
"Post-setup validation failed:\n"
+ "\n".join(f" {f}" for f in failures)
)
- click.echo("[prepare-linux] All post-setup checks passed.")
+ click.echo(f"[{prog_name}] All post-setup checks passed.")
# ── Step 7b: Clear machine-id (DHCP uniqueness for clones) ───────────
- click.echo("[prepare-linux] Step 7b — Clearing machine-id ...")
+ click.echo(f"[{prog_name}] 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",
@@ -2054,7 +2422,7 @@ def template_prepare_linux(
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.")
+ click.echo(f"[{prog_name}] 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
@@ -2062,7 +2430,7 @@ def template_prepare_linux(
# 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 ...")
+ click.echo(f"[{prog_name}] 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
@@ -2075,7 +2443,7 @@ def template_prepare_linux(
)
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.")
+ click.echo(f"[{prog_name}] 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
@@ -2089,7 +2457,7 @@ def template_prepare_linux(
# 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) ...")
+ click.echo(f"[{prog_name}] Step 7d — Sealing cloud-init (disable for clones) ...")
rc_seal, _, err_seal = _pl_run(
client,
"sudo rm -f /etc/netplan/50-cloud-init.yaml "
@@ -2112,17 +2480,17 @@ def template_prepare_linux(
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."
+ f"[{prog_name}] 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 ...")
+ click.echo(f"[{prog_name}] 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. "
+ f"[{prog_name}] --take-snapshot not set — VM left running. "
"Shut down and snapshot manually when ready."
)
@@ -2131,7 +2499,7 @@ def template_prepare_linux(
# ── 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) ...")
+ click.echo(f"[{prog_name}] Waiting for VM to power off (120s) ...")
off_deadline = time.monotonic() + 120
while time.monotonic() < off_deadline:
if not backend.is_running(handle):
@@ -2140,38 +2508,211 @@ def template_prepare_linux(
click.echo(" ... waiting for shutdown ...")
else:
raise click.ClickException("VM did not power off within 120s.")
- click.echo("[prepare-linux] VM powered off.")
+ click.echo(f"[{prog_name}] VM powered off.")
+
+ # Detach the cloud-init seed ISO now, with the VM off and before the
+ # snapshot. deploy's Step 10 already does this, but it runs while the
+ # VM is still powered on and VMware rewrites the VMX from its in-memory
+ # copy at power-off, silently restoring ide1:0.present = "TRUE" (the
+ # give-away is `cleanShutdown = "TRUE"` appearing in the file). Left
+ # attached, every clone boots with the seed still present — AGENTS.md
+ # error #6. Idempotent: harmless when already FALSE.
+ if any(
+ line.strip().lower().startswith("ide1:0.present")
+ and "true" in line.lower()
+ for line in vmx_path.read_text(encoding="ascii", errors="replace").splitlines()
+ ):
+ _patch_vmx_key(vmx_path, "ide1:0.present", "FALSE")
+ _patch_vmx_key(vmx_path, "ide1:0.startConnected", "FALSE")
+ click.echo(
+ f"[{prog_name}] Detached cloud-init seed ISO from VMX "
+ "(re-attached by VMware at power-off)."
+ )
existing = backend.list_snapshots(handle)
if snapshot in existing:
- click.echo(f"[prepare-linux] Deleting existing snapshot '{snapshot}' ...")
+ click.echo(f"[{prog_name}] Deleting existing snapshot '{snapshot}' ...")
subprocess.run(
[vmrun, "-T", "ws", "deleteSnapshot", str(vmx_path), snapshot],
check=True,
)
- click.echo(f"[prepare-linux] Taking snapshot '{snapshot}' ...")
+ click.echo(f"[{prog_name}] Taking snapshot '{snapshot}' ...")
subprocess.run(
[vmrun, "-T", "ws", "snapshot", str(vmx_path), snapshot],
check=True,
)
- click.echo(f"[prepare-linux] Snapshot '{snapshot}' created.")
+ click.echo(f"[{prog_name}] Snapshot '{snapshot}' created.")
# ── Summary ───────────────────────────────────────────────────────────────
click.echo("")
- click.echo("[prepare-linux] Summary")
+ click.echo(f"[{prog_name}] 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}")
+ # --template takes the full VMX path, and --snapshot must be passed
+ # explicitly: `vm new` defaults to "BaseClean", which is the Windows
+ # snapshot, not the one prepare-* just created.
click.echo(
- f" Snapshot: {snapshot} — ready for "
- f"`ci-orchestrator vm new --template {vmx_path.name}`"
+ " Next: ci-orchestrator vm new "
+ f"--template {vmx_path} --snapshot {snapshot} "
+ "--guest-os linux --clone-base-dir --job-id "
)
else:
click.echo(f" Snapshot: not taken — name it '{snapshot}' when done manually.")
- click.echo("[prepare-linux] Provisioning complete.")
+ click.echo(f"[{prog_name}] Provisioning complete.")
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# template prepare-linux / prepare-rust commands (thin wrappers over the impl)
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+def _prepare_linux_options(f: Callable[..., None]) -> Callable[..., None]:
+ """Shared click options for prepare-linux / prepare-rust."""
+ f = 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. Defaults to the "
+ "template VMX under when neither --vmx nor --ip "
+ "is given."
+ ),
+ )(f)
+ f = click.option(
+ "--ip", "guest_ip", default="",
+ help="Guest IP address (auto-detected via vmrun if --vmx is supplied).",
+ )(f)
+ f = click.option(
+ "--cloud-user", default="ci_build", show_default=True,
+ help="SSH username in guest.",
+ )(f)
+ f = click.option(
+ "--ssh-key", "ssh_key_str", default=None, metavar="PATH",
+ help="SSH private key (default: from config ssh_key_path or /ci_linux).",
+ )(f)
+ f = click.option(
+ "--snapshot", default=None,
+ help="Snapshot name (must match the name used by vm new).",
+ )(f)
+ f = click.option(
+ "--toolchain-script", "toolchain_script_str", default=None, metavar="PATH",
+ help="Path to the toolchain script (auto-detected from /template/ if omitted).",
+ )(f)
+ f = click.option(
+ "--skip-update", is_flag=True, help="Pass --skip-update to toolchain script."
+ )(f)
+ f = click.option(
+ "--install-dotnet", is_flag=True, help="Pass --dotnet to toolchain script."
+ )(f)
+ f = click.option(
+ "--no-mingw", is_flag=True, help="Pass --no-mingw to toolchain script."
+ )(f)
+ f = click.option(
+ "--ssh-timeout", default=180, show_default=True,
+ help="Seconds to wait for SSH port 22 to become reachable.",
+ )(f)
+ f = click.option(
+ "--take-snapshot", is_flag=True,
+ help="Shut down and take snapshot after provisioning (always implied when --vmx is set).",
+ )(f)
+ return f
+
+
+@template.command("prepare-linux")
+@_prepare_linux_options
+def template_prepare_linux(
+ vmx_path_str: str | None,
+ guest_ip: str,
+ cloud_user: str,
+ ssh_key_str: str | None,
+ snapshot: str | None,
+ 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).
+
+ Without --vmx/--ip, defaults to the LinuxBuild2404 template VMX under
+ . Pass --ip to provision a guest with no VM lifecycle.
+ """
+ _prepare_linux_impl(
+ prog_name="prepare-linux",
+ default_toolchain_name="Install-CIToolchain-Linux2404.sh",
+ default_vmx_name="LinuxBuild2404",
+ vmx_path_str=vmx_path_str,
+ guest_ip=guest_ip,
+ cloud_user=cloud_user,
+ ssh_key_str=ssh_key_str,
+ snapshot=snapshot or "BaseClean-Linux",
+ toolchain_script_str=toolchain_script_str,
+ skip_update=skip_update,
+ install_dotnet=install_dotnet,
+ no_mingw=no_mingw,
+ ssh_timeout=ssh_timeout,
+ take_snapshot=take_snapshot,
+ )
+
+
+@template.command("prepare-rust")
+@_prepare_linux_options
+@click.option(
+ "--rust-channel", default="stable", show_default=True,
+ help=(
+ "Rust toolchain channel baked into the snapshot. Pin an exact version "
+ "(e.g. 1.97.1) for a deterministic snapshot; 'stable' is a moving "
+ "target and rustup re-downloads at runtime when a new stable ships."
+ ),
+)
+@click.option(
+ "--no-cargo-audit", is_flag=True,
+ help="Skip baking the cargo-audit binary (the advisory DB is never baked).",
+)
+def template_prepare_rust(
+ vmx_path_str: str | None,
+ guest_ip: str,
+ cloud_user: str,
+ ssh_key_str: str | None,
+ snapshot: str | None,
+ toolchain_script_str: str | None,
+ skip_update: bool,
+ install_dotnet: bool,
+ no_mingw: bool,
+ ssh_timeout: int,
+ take_snapshot: bool,
+ rust_channel: str,
+ no_cargo_audit: bool,
+) -> None:
+ """Provision the Rust Linux template (LinuxRust2404) via SSH.
+
+ Same flow as prepare-linux but uses Install-CIToolchain-LinuxRust2404.sh
+ (adds Tauri/webview GTK libs + system-wide rustup toolchain) and defaults
+ to the BaseClean-LinuxRust snapshot.
+ """
+ _prepare_linux_impl(
+ prog_name="prepare-rust",
+ default_toolchain_name="Install-CIToolchain-LinuxRust2404.sh",
+ default_vmx_name="LinuxRust2404",
+ vmx_path_str=vmx_path_str,
+ guest_ip=guest_ip,
+ cloud_user=cloud_user,
+ ssh_key_str=ssh_key_str,
+ snapshot=snapshot or "BaseClean-LinuxRust",
+ toolchain_script_str=toolchain_script_str,
+ skip_update=skip_update,
+ install_dotnet=install_dotnet,
+ no_mingw=no_mingw,
+ ssh_timeout=ssh_timeout,
+ take_snapshot=take_snapshot,
+ rust_channel=rust_channel,
+ no_cargo_audit=no_cargo_audit,
+ )
# ═══════════════════════════════════════════════════════════════════════════
@@ -2617,8 +3158,8 @@ def template_deploy_win(
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}")
+ click.echo(f" Snapshot: {snapshot_name} (cold, taken by deploy-win itself)")
+ click.echo(f" Next: ci-orchestrator template prepare-win --vmx {vmx_path}")
# ═══════════════════════════════════════════════════════════════════════════
diff --git a/tests/python/test_commands_template.py b/tests/python/test_commands_template.py
index 9e3a627..7f10b68 100644
--- a/tests/python/test_commands_template.py
+++ b/tests/python/test_commands_template.py
@@ -1710,6 +1710,11 @@ def test_deploy_linux_full_happy_path(
vmx = deploy_env["vmx"]
+ # `vmrun list` must reflect reality: the VM is NOT running before Step 6
+ # powers it on. A mock that always reports it running hides the --force
+ # pre-flight guard (deploy must refuse to rewrite a live VM's disk).
+ started = {"value": False}
+
def fake_run(cmd: list[str], **kw: object) -> _sub.CompletedProcess[str]:
cmd_str = " ".join(str(c) for c in cmd)
if "genisoimage" in cmd_str:
@@ -1723,8 +1728,16 @@ def test_deploy_linux_full_happy_path(
return _sub.CompletedProcess(cmd, 0, "", "")
if "vmware-vdiskmanager" in cmd_str:
return _sub.CompletedProcess(cmd, 0, "", "")
+ if "start" in cmd:
+ started["value"] = True
+ return _sub.CompletedProcess(cmd, 0, "", "")
if "list" in cmd:
- return _sub.CompletedProcess(cmd, 0, f"Total running VMs: 1\n{vmx}\n", "")
+ body = (
+ f"Total running VMs: 1\n{vmx}\n"
+ if started["value"]
+ else "Total running VMs: 0\n"
+ )
+ return _sub.CompletedProcess(cmd, 0, body, "")
if "getGuestIPAddress" in cmd:
return _sub.CompletedProcess(cmd, 0, "192.168.79.200", "")
return _sub.CompletedProcess(cmd, 0, "", "")
@@ -1913,11 +1926,37 @@ def _stub_prepare(
def test_prepare_linux_missing_vmx_and_ip(
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
+ """Without --vmx/--ip AND no default template VMX on disk → error."""
+ prepare_env["vmx"].unlink() # default LinuxBuild2404.vmx absent
result = CliRunner().invoke(cli, ["template", "prepare-linux"])
assert result.exit_code != 0
assert "--vmx" in result.output or "--ip" in result.output
+def test_prepare_linux_default_vmx_used(
+ prepare_env: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Without --vmx/--ip, the default LinuxBuild2404 VMX is picked up."""
+ _stub_prepare(monkeypatch, prepare_env)
+ monkeypatch.setattr(tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: "192.168.1.200")
+
+ backend_m = MagicMock()
+ backend_m.is_running.side_effect = [False, False]
+ backend_m.list_snapshots.return_value = []
+ monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
+
+ import subprocess as _sub
+ monkeypatch.setattr(
+ tmpl_mod.subprocess, "run",
+ lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "", ""),
+ )
+
+ result = CliRunner().invoke(cli, ["template", "prepare-linux"])
+ assert result.exit_code == 0, result.output
+ assert "LinuxBuild2404.vmx" in result.output
+ assert "192.168.1.200" in result.output
+
+
def test_prepare_linux_toolchain_not_found(
prepare_env: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -2251,3 +2290,771 @@ def test_check_guest_idle_unparseable() -> None:
transport.run.return_value = WinRmResult(stdout="not json", stderr="", returncode=0)
with pytest.raises(click.ClickException, match="could not parse"):
tmpl_mod._sample_guest_idle(transport)
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# template deploy-rust / prepare-rust — CLI tests
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+def test_deploy_rust_help() -> None:
+ result = CliRunner().invoke(cli, ["template", "deploy-rust", "--help"])
+ assert result.exit_code == 0
+ assert "LinuxRust2404" in result.output
+
+
+def test_deploy_rust_forwards_to_deploy_linux(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """deploy-rust calls the deploy-linux callback with the Rust defaults."""
+ captured: dict = {}
+
+ def fake_cb(**kwargs: object) -> None:
+ captured.update(kwargs)
+
+ monkeypatch.setattr(tmpl_mod.template_deploy_linux, "callback", fake_cb)
+
+ result = CliRunner().invoke(cli, ["template", "deploy-rust", "--force"])
+ assert result.exit_code == 0, result.output
+ assert captured["vm_name"] == "LinuxRust2404"
+ assert captured["guest_hostname"] == "ci-rust-template"
+ assert captured["disk_size_gb"] == 60
+ assert captured["force"] is True
+ # Default VMX points at the LinuxRust2404 template dir.
+ vmx = str(captured["vmx_path_str"])
+ assert vmx.endswith("LinuxRust2404/LinuxRust2404.vmx")
+
+
+def test_deploy_rust_vmx_override(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """An explicit --vmx is forwarded untouched."""
+ captured: dict = {}
+ monkeypatch.setattr(
+ tmpl_mod.template_deploy_linux, "callback",
+ lambda **kw: captured.update(kw),
+ )
+ result = CliRunner().invoke(
+ cli, ["template", "deploy-rust", "--vmx", "/tmp/custom/Custom.vmx"]
+ )
+ assert result.exit_code == 0, result.output
+ assert captured["vmx_path_str"] == "/tmp/custom/Custom.vmx"
+
+
+@pytest.fixture()
+def prepare_rust_env(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> dict:
+ """Environment with SSH keys, VMX, and the Rust toolchain script."""
+ ssh_key = _make_keys(tmp_path / "keys")
+ vm_dir = tmp_path / "templates" / "LinuxRust2404"
+ vm_dir.mkdir(parents=True)
+ vmx = vm_dir / "LinuxRust2404.vmx"
+ vmx.write_text('guestOS = "ubuntu-64"\n', encoding="ascii")
+
+ tmpl_dir = tmp_path / "template"
+ tmpl_dir.mkdir()
+ toolchain = tmpl_dir / "Install-CIToolchain-LinuxRust2404.sh"
+ toolchain.write_text("#!/bin/bash\necho done\n")
+
+ config = _deploy_config(tmp_path)
+ monkeypatch.setattr(tmpl_mod, "load_config", lambda: config)
+ monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
+ monkeypatch.chdir(tmp_path)
+
+ return {
+ "tmp_path": tmp_path,
+ "ssh_key": ssh_key,
+ "vmx": vmx,
+ "toolchain": toolchain,
+ }
+
+
+def _stub_prepare_rust(
+ monkeypatch: pytest.MonkeyPatch,
+ env: dict,
+ *,
+ streamed: list[str] | None = None,
+) -> MagicMock:
+ """Stub SSH/vmrun for prepare-rust; records the streamed toolchain command."""
+ client_m = MagicMock()
+ raw = env["toolchain"].read_bytes()
+ if raw[:3] == b"\xef\xbb\xbf":
+ raw = raw[3:]
+ _toolchain_size = str(len(raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")))
+
+ def fake_pl_run(
+ client: object, cmd: str, timeout: float = 60.0
+ ) -> tuple[int, str, str]:
+ if "stat -c %s /tmp/" in cmd:
+ return 0, _toolchain_size, ""
+ if "stat -c %s /etc/machine-id" in cmd:
+ return 0, "0", ""
+ if "truncate" in cmd:
+ return 0, "", ""
+ if "shutdown" in cmd:
+ return 0, "", ""
+ if "echo connected" in cmd:
+ return 0, "connected", ""
+ if "cloud-init.disabled" in cmd and "echo DISABLED" in cmd:
+ return 0, "DISABLED\nNO50\n", ""
+ return 0, "UserSudo:OK\nSudoNoPass:OK\n", ""
+
+ def fake_pl_stream(client: object, cmd: str) -> int:
+ if streamed is not None:
+ streamed.append(cmd)
+ return 0
+
+ monkeypatch.setattr(tmpl_mod, "_pl_connect", lambda *a, **kw: client_m)
+ monkeypatch.setattr(tmpl_mod, "_pl_run", fake_pl_run)
+ monkeypatch.setattr(tmpl_mod, "_pl_stream", fake_pl_stream)
+ monkeypatch.setattr(tmpl_mod, "_pl_upload", lambda *a, **kw: None)
+ monkeypatch.setattr(tmpl_mod, "_wait_ssh_port_deploy", lambda *a, **kw: True)
+ return client_m
+
+
+def test_prepare_rust_help() -> None:
+ result = CliRunner().invoke(cli, ["template", "prepare-rust", "--help"])
+ assert result.exit_code == 0
+ assert "--rust-channel" in result.output
+ assert "--no-cargo-audit" in result.output
+
+
+def test_prepare_rust_missing_vmx_and_ip(prepare_rust_env: dict) -> None:
+ """Without --vmx/--ip AND no default template VMX on disk → error."""
+ prepare_rust_env["vmx"].unlink() # default LinuxRust2404.vmx absent
+ result = CliRunner().invoke(cli, ["template", "prepare-rust"])
+ assert result.exit_code != 0
+ assert "--vmx" in result.output or "--ip" in result.output
+
+
+def test_prepare_rust_default_vmx_used(
+ prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Without --vmx/--ip, the default LinuxRust2404 VMX is picked up."""
+ _stub_prepare_rust(monkeypatch, prepare_rust_env)
+ monkeypatch.setattr(tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: "192.168.1.200")
+
+ backend_m = MagicMock()
+ backend_m.is_running.side_effect = [False, False]
+ backend_m.list_snapshots.return_value = []
+ monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
+
+ import subprocess as _sub
+ monkeypatch.setattr(
+ tmpl_mod.subprocess, "run",
+ lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "", ""),
+ )
+
+ result = CliRunner().invoke(cli, ["template", "prepare-rust"])
+ assert result.exit_code == 0, result.output
+ assert "LinuxRust2404.vmx" in result.output
+ assert "BaseClean-LinuxRust" in result.output
+
+
+def test_prepare_rust_toolchain_not_found(prepare_rust_env: dict) -> None:
+ prepare_rust_env["toolchain"].unlink()
+ result = CliRunner().invoke(
+ cli, ["template", "prepare-rust", "--ip", "192.168.1.100"]
+ )
+ assert result.exit_code != 0
+ assert "not found" in result.output.lower()
+ assert "LinuxRust2404" in result.output
+
+
+def test_prepare_rust_happy_path_defaults(
+ prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Default invocation: Rust toolchain script, BaseClean-LinuxRust, stable channel."""
+ streamed: list[str] = []
+ _stub_prepare_rust(monkeypatch, prepare_rust_env, streamed=streamed)
+
+ result = CliRunner().invoke(
+ cli, ["template", "prepare-rust", "--ip", "192.168.1.100"]
+ )
+ assert result.exit_code == 0, result.output
+ assert "[prepare-rust]" in result.output
+ assert "BaseClean-LinuxRust" in result.output
+ assert streamed
+ assert "Install-CIToolchain-LinuxRust2404.sh" in streamed[0]
+ assert "--rust-channel stable" in streamed[0]
+ assert "--no-cargo-audit" not in streamed[0]
+
+
+def test_prepare_rust_forwards_rust_flags(
+ prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """--rust-channel and --no-cargo-audit reach the toolchain command."""
+ streamed: list[str] = []
+ _stub_prepare_rust(monkeypatch, prepare_rust_env, streamed=streamed)
+
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "prepare-rust",
+ "--ip", "192.168.1.100",
+ "--rust-channel", "1.97.1",
+ "--no-cargo-audit",
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ assert streamed
+ assert "--rust-channel 1.97.1" in streamed[0]
+ assert "--no-cargo-audit" in streamed[0]
+
+
+def test_prepare_rust_snapshot_override(
+ prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """--snapshot overrides the BaseClean-LinuxRust default."""
+ _stub_prepare_rust(monkeypatch, prepare_rust_env)
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "prepare-rust",
+ "--ip", "192.168.1.100",
+ "--snapshot", "CustomSnap",
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ assert "CustomSnap" in result.output
+
+
+def test_prepare_linux_unchanged_defaults(
+ prepare_env: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Regression: prepare-linux still uses the Linux toolchain + BaseClean-Linux
+ and never forwards rust flags."""
+ streamed: list[str] = []
+ client_m = MagicMock()
+ raw = prepare_env["toolchain"].read_bytes()
+ _tsize = str(len(raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")))
+
+ def fake_pl_run(client: object, cmd: str, timeout: float = 60.0) -> tuple[int, str, str]:
+ if "stat -c %s /tmp/" in cmd:
+ return 0, _tsize, ""
+ if "stat -c %s /etc/machine-id" in cmd:
+ return 0, "0", ""
+ if "truncate" in cmd or "shutdown" in cmd:
+ return 0, "", ""
+ if "echo connected" in cmd:
+ return 0, "connected", ""
+ if "cloud-init.disabled" in cmd and "echo DISABLED" in cmd:
+ return 0, "DISABLED\nNO50\n", ""
+ return 0, "UserSudo:OK\nSudoNoPass:OK\n", ""
+
+ monkeypatch.setattr(tmpl_mod, "_pl_connect", lambda *a, **kw: client_m)
+ monkeypatch.setattr(tmpl_mod, "_pl_run", fake_pl_run)
+ monkeypatch.setattr(
+ tmpl_mod, "_pl_stream",
+ lambda c, cmd: streamed.append(cmd) or 0,
+ )
+ monkeypatch.setattr(tmpl_mod, "_pl_upload", lambda *a, **kw: None)
+ monkeypatch.setattr(tmpl_mod, "_wait_ssh_port_deploy", lambda *a, **kw: True)
+
+ result = CliRunner().invoke(
+ cli, ["template", "prepare-linux", "--ip", "192.168.1.100"]
+ )
+ assert result.exit_code == 0, result.output
+ assert "[prepare-linux]" in result.output
+ assert "BaseClean-Linux" in result.output
+ assert streamed
+ assert "Install-CIToolchain-Linux2404.sh" in streamed[0]
+ assert "--rust-channel" not in streamed[0]
+ assert "--no-cargo-audit" not in streamed[0]
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Final messages + deploy -> prepare hand-off
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+def _deploy_linux_stub_env(tmp_path: Path, name: str) -> dict:
+ """Minimal on-disk state for a deploy-* run resumed at the last step."""
+ vm_dir = tmp_path / "templates" / name
+ vm_dir.mkdir(parents=True)
+ vmx = vm_dir / f"{name}.vmx"
+ vmx.write_text('ide1:0.present = "TRUE"\n', encoding="ascii")
+ key = tmp_path / "ci_linux"
+ key.write_text("PRIVATE KEY", encoding="ascii")
+ return {"vmx": vmx, "key": key}
+
+
+def test_deploy_rust_summary_points_at_prepare_rust(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Regression: deploy-rust used to tell the user to run prepare-linux."""
+ env = _deploy_linux_stub_env(tmp_path, "LinuxRust2404")
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "deploy-rust",
+ "--vmx", str(env["vmx"]),
+ "--ssh-key", str(env["key"]),
+ "--start-from-step", "10",
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ assert "deploy-rust complete" in result.output
+ assert "prepare-rust" in result.output
+ assert "prepare-linux" not in result.output
+ # The snapshot named in the summary is the one prepare-rust creates.
+ assert "BaseClean-LinuxRust" in result.output
+ assert "PostInstall-Linux" not in result.output
+
+
+def test_deploy_linux_summary_points_at_prepare_linux(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ env = _deploy_linux_stub_env(tmp_path, "LinuxBuild2404")
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "deploy-linux",
+ "--vmx", str(env["vmx"]),
+ "--ssh-key", str(env["key"]),
+ "--start-from-step", "10",
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ assert "deploy-linux complete" in result.output
+ assert "template prepare-linux --vmx" in result.output
+ assert "BaseClean-Linux" in result.output
+
+
+def test_deploy_rust_then_prepare_hands_off(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """--then-prepare chains into prepare-rust with the Rust defaults."""
+ env = _deploy_linux_stub_env(tmp_path, "LinuxRust2404")
+ captured: dict = {}
+ monkeypatch.setattr(
+ tmpl_mod, "_prepare_linux_impl", lambda **kw: captured.update(kw)
+ )
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "deploy-rust",
+ "--vmx", str(env["vmx"]),
+ "--ssh-key", str(env["key"]),
+ "--start-from-step", "10",
+ "--then-prepare",
+ "--rust-channel", "1.97.1",
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ assert captured["prog_name"] == "prepare-rust"
+ assert captured["default_vmx_name"] == "LinuxRust2404"
+ assert captured["default_toolchain_name"] == "Install-CIToolchain-LinuxRust2404.sh"
+ assert captured["snapshot"] == "BaseClean-LinuxRust"
+ assert captured["rust_channel"] == "1.97.1"
+ assert captured["take_snapshot"] is True
+ assert captured["vmx_path_str"] == str(env["vmx"])
+
+
+def test_deploy_linux_then_prepare_hands_off(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ env = _deploy_linux_stub_env(tmp_path, "LinuxBuild2404")
+ captured: dict = {}
+ monkeypatch.setattr(
+ tmpl_mod, "_prepare_linux_impl", lambda **kw: captured.update(kw)
+ )
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "deploy-linux",
+ "--vmx", str(env["vmx"]),
+ "--ssh-key", str(env["key"]),
+ "--start-from-step", "10",
+ "--then-prepare",
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ assert captured["prog_name"] == "prepare-linux"
+ assert captured["default_toolchain_name"] == "Install-CIToolchain-Linux2404.sh"
+ assert captured["snapshot"] == "BaseClean-Linux"
+ assert captured["rust_channel"] is None
+
+
+def test_deploy_without_then_prepare_does_not_chain(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """The two commands stay separate: no hand-off unless asked for."""
+ env = _deploy_linux_stub_env(tmp_path, "LinuxBuild2404")
+ calls: list[dict] = []
+ monkeypatch.setattr(
+ tmpl_mod, "_prepare_linux_impl", lambda **kw: calls.append(kw)
+ )
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "deploy-linux",
+ "--vmx", str(env["vmx"]),
+ "--ssh-key", str(env["key"]),
+ "--start-from-step", "10",
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ assert calls == []
+
+
+def test_prepare_rust_next_hint_is_copy_pasteable(
+ prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """The `vm new` hint needs the full VMX path and an explicit --snapshot.
+
+ `vm new` defaults --snapshot to "BaseClean" (the Windows snapshot), so a
+ hint that omits it clones the wrong snapshot.
+ """
+ _stub_prepare_rust(monkeypatch, prepare_rust_env)
+ monkeypatch.setattr(
+ tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: "192.168.1.200"
+ )
+ backend_m = MagicMock()
+ backend_m.is_running.side_effect = [False, False]
+ backend_m.list_snapshots.return_value = []
+ monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
+ import subprocess as _sub
+ monkeypatch.setattr(
+ tmpl_mod.subprocess, "run",
+ lambda cmd, **kw: _sub.CompletedProcess(list(cmd), 0, "", ""),
+ )
+
+ result = CliRunner().invoke(cli, ["template", "prepare-rust"])
+ assert result.exit_code == 0, result.output
+ vmx = str(prepare_rust_env["vmx"])
+ assert f"--template {vmx}" in result.output
+ assert "--snapshot BaseClean-LinuxRust" in result.output
+ assert "--guest-os linux" in result.output
+
+
+# ── --exclude ─────────────────────────────────────────────────────────────────
+
+
+def _tmpl_root_with(tmp_path: Path, names: tuple[str, ...]) -> Path:
+ tmpl_root = tmp_path / "templates"
+ for name in names:
+ d = tmpl_root / name
+ d.mkdir(parents=True)
+ (d / f"{name}.vmx").write_text("vmx")
+ return tmpl_root
+
+
+def test_exclude_skips_named_template(
+ tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
+) -> None:
+ """A retired template must not keep costing a backup generation."""
+ tmpl_root = _tmpl_root_with(
+ tmp_path, ("WinBuild2025", "WinBuildTest", "LinuxBuild2404")
+ )
+ monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
+
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "backup", "--all-templates",
+ "--exclude", "WinBuildTest",
+ "--backup-base-dir", str(backup_dir),
+ "--skip-runner-stop",
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ names = {f.name for f in backup_dir.iterdir() if f.suffix == ".7z"}
+ assert any("WinBuild2025" in n for n in names)
+ assert any("LinuxBuild2404" in n for n in names)
+ assert not any("WinBuildTest" in n for n in names)
+ assert "Skipping 'WinBuildTest'" in result.output
+
+
+def test_exclude_is_repeatable(
+ tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
+) -> None:
+ tmpl_root = _tmpl_root_with(
+ tmp_path, ("WinBuild2025", "WinBuildTest", "LinuxBuildTest")
+ )
+ monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
+
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "backup", "--all-templates",
+ "--exclude", "WinBuildTest",
+ "--exclude", "LinuxBuildTest",
+ "--backup-base-dir", str(backup_dir),
+ "--skip-runner-stop",
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ names = {f.name for f in backup_dir.iterdir() if f.suffix == ".7z"}
+ assert names and all("Test" not in n for n in names)
+
+
+def test_exclude_unknown_name_raises(
+ tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
+) -> None:
+ """A typo must fail loudly, not silently back up everything."""
+ tmpl_root = _tmpl_root_with(tmp_path, ("WinBuild2025",))
+ monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
+
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "backup", "--all-templates",
+ "--exclude", "WinBuildTst",
+ "--backup-base-dir", str(backup_dir),
+ "--skip-runner-stop",
+ ],
+ )
+ assert result.exit_code != 0
+ assert "no such template" in result.output
+
+
+def test_exclude_everything_raises(
+ tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
+) -> None:
+ tmpl_root = _tmpl_root_with(tmp_path, ("WinBuild2025",))
+ monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
+
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "backup", "--all-templates",
+ "--exclude", "WinBuild2025",
+ "--backup-base-dir", str(backup_dir),
+ "--skip-runner-stop",
+ ],
+ )
+ assert result.exit_code != 0
+ assert "nothing to back up" in result.output
+
+
+def test_exclude_without_all_templates_raises(
+ tmp_path: Path, backup_dir: Path, monkeypatch: pytest.MonkeyPatch, _no_systemctl: None
+) -> None:
+ """--exclude alone would be a silent no-op; it must error instead."""
+ tmpl_root = _tmpl_root_with(tmp_path, ("WinBuild2025",))
+ monkeypatch.setenv("CI_TEMPLATES", str(tmpl_root))
+
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "backup",
+ "--template-path", str(tmpl_root / "WinBuild2025"),
+ "--exclude", "WinBuildTest",
+ "--backup-base-dir", str(backup_dir),
+ "--skip-runner-stop",
+ ],
+ )
+ assert result.exit_code != 0
+ assert "only applies to --all-templates" in result.output
+
+
+def test_then_prepare_fails_before_deploy_when_toolchain_missing(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Regression: the toolchain was resolved only after the deploy finished.
+
+ Running deploy-rust --then-prepare from outside the repo root completed a
+ ~45-minute deploy and only then died on the missing script. The check must
+ happen before any VM work starts.
+ """
+ env = _deploy_linux_stub_env(tmp_path, "LinuxRust2404")
+ monkeypatch.chdir(tmp_path) # no template/ dir here
+
+ steps: list[int] = []
+ monkeypatch.setattr(
+ tmpl_mod, "_deploy_step", lambda *a, **kw: steps.append(1)
+ )
+
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "deploy-rust",
+ "--vmx", str(env["vmx"]),
+ "--ssh-key", str(env["key"]),
+ "--then-prepare",
+ ],
+ )
+ assert result.exit_code != 0
+ assert "Install-CIToolchain-LinuxRust2404.sh" in result.output
+ assert "--toolchain-script" in result.output
+ # Nothing was deployed: the failure landed before the first step ran.
+ assert steps == []
+
+
+def test_then_prepare_accepts_explicit_toolchain_script(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """--toolchain-script makes the chain independent of the current directory."""
+ env = _deploy_linux_stub_env(tmp_path, "LinuxRust2404")
+ script = tmp_path / "custom-toolchain.sh"
+ script.write_text("#!/bin/sh\n", encoding="ascii")
+ monkeypatch.chdir(tmp_path)
+
+ captured: dict = {}
+ monkeypatch.setattr(
+ tmpl_mod, "_prepare_linux_impl", lambda **kw: captured.update(kw)
+ )
+
+ result = CliRunner().invoke(
+ cli,
+ [
+ "template", "deploy-rust",
+ "--vmx", str(env["vmx"]),
+ "--ssh-key", str(env["key"]),
+ "--start-from-step", "10",
+ "--then-prepare",
+ "--toolchain-script", str(script),
+ ],
+ )
+ assert result.exit_code == 0, result.output
+ assert captured["toolchain_script_str"] == str(script)
+
+
+def test_resolve_toolchain_script_prefers_cwd(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ tdir = tmp_path / "template"
+ tdir.mkdir()
+ script = tdir / "Install-CIToolchain-Linux2404.sh"
+ script.write_text("#!/bin/sh\n", encoding="ascii")
+ monkeypatch.chdir(tmp_path)
+
+ got = tmpl_mod._resolve_toolchain_script("Install-CIToolchain-Linux2404.sh", None)
+ assert got == script
+
+
+def test_resolve_toolchain_script_explicit_missing_raises(tmp_path: Path) -> None:
+ import click as _click
+
+ with pytest.raises(_click.ClickException, match="not found"):
+ tmpl_mod._resolve_toolchain_script("x.sh", str(tmp_path / "nope.sh"))
+
+
+def test_poll_guest_ip_passes_wait_flag(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Regression: without -wait vmrun reports "Tools are not running".
+
+ A guest that was booted, on the network and answering SSH still burned the
+ full 10-minute Step 7 budget because the poll omitted -wait, so vmrun only
+ answered when the Tools happened to have registered the IP at that instant.
+ """
+ import subprocess as _sub
+
+ seen: list[list[str]] = []
+
+ def fake_run(cmd, **kw): # type: ignore[no-untyped-def]
+ seen.append(list(cmd))
+ return _sub.CompletedProcess(cmd, 0, "192.168.1.100", "")
+
+ monkeypatch.setattr(tmpl_mod, "_read_guestinfo_ip", lambda *a, **kw: None)
+ monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
+ monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
+
+ assert tmpl_mod._poll_guest_ip_deploy("/vmrun", "/vm.vmx", 60.0) == "192.168.1.100"
+ assert seen, "vmrun was never invoked"
+ assert "-wait" in seen[0], f"-wait missing from {seen[0]}"
+
+
+def test_poll_guest_ip_ignores_error_line_on_stdout(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """vmrun can exit 0 with "Error: ..." on stdout (AGENTS.md #10)."""
+ import subprocess as _sub
+
+ outputs = [
+ "Error: The VMware Tools are not running in the virtual machine\n",
+ "192.168.1.101\n",
+ ]
+
+ def fake_run(cmd, **kw): # type: ignore[no-untyped-def]
+ return _sub.CompletedProcess(cmd, 0, outputs.pop(0), "")
+
+ monkeypatch.setattr(tmpl_mod, "_read_guestinfo_ip", lambda *a, **kw: None)
+ monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
+ monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _: None)
+
+ # The Error line must not be mistaken for an address; the poll retries.
+ assert tmpl_mod._poll_guest_ip_deploy("/vmrun", "/vm.vmx", 60.0) == "192.168.1.101"
+
+
+def test_deploy_force_refuses_when_vm_is_powered_on(
+ deploy_env: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Regression: --force against a running VM destroyed the live guest.
+
+ It deleted the live .vmdk.lck as "stale" and Step 4 rewrote the base VMDK
+ underneath it; the damage only surfaced ~10 minutes later as a Step 7 IP
+ timeout, because `vmrun list` still showed the OLD instance.
+ """
+ import subprocess as _sub
+
+ vmx = deploy_env["vmx"]
+
+ def fake_run(cmd: list[str], **kw: object) -> _sub.CompletedProcess[str]:
+ if "list" in cmd:
+ return _sub.CompletedProcess(cmd, 0, f"Total running VMs: 1\n{vmx}\n", "")
+ return _sub.CompletedProcess(cmd, 0, "", "")
+
+ monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
+
+ result = CliRunner().invoke(
+ cli,
+ ["template", "deploy-linux", "--vmx", str(vmx), "--force"],
+ )
+ assert result.exit_code != 0
+ assert "powered on" in result.output
+ assert "vmrun -T ws stop" in result.output
+
+
+def test_prepare_detaches_seed_iso_before_snapshot(
+ prepare_rust_env: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Regression: the seed ISO ended up attached inside the snapshot.
+
+ deploy Step 10 sets ide1:0.present = FALSE while the VM is still powered
+ on; VMware rewrites the VMX from its in-memory copy at power-off and
+ restores TRUE. Clones then boot with cloud-init's seed still attached
+ (AGENTS.md #6), so prepare must detach it after power-off.
+ """
+ vmx: Path = prepare_rust_env["vmx"]
+ vmx.write_text(
+ 'displayName = "LinuxRust2404"\n'
+ 'ide1:0.present = "TRUE"\n'
+ 'ide1:0.startConnected = "TRUE"\n'
+ 'ide1:0.fileName = "cloud-init-seed.iso"\n',
+ encoding="ascii",
+ )
+
+ _stub_prepare_rust(monkeypatch, prepare_rust_env)
+ monkeypatch.setattr(
+ tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **kw: "192.168.1.200"
+ )
+
+ backend_m = MagicMock()
+ backend_m.is_running.side_effect = [False, False]
+ backend_m.list_snapshots.return_value = []
+ monkeypatch.setattr(tmpl_mod, "WorkstationVmrunBackend", lambda **kw: backend_m)
+
+ snapshot_calls: list[list[str]] = []
+
+ import subprocess as _sub
+
+ def fake_run(cmd, **kw): # type: ignore[no-untyped-def]
+ if "snapshot" in cmd:
+ # The VMX must already be patched by the time the snapshot is taken.
+ snapshot_calls.append(vmx.read_text(encoding="ascii").splitlines())
+ return _sub.CompletedProcess(list(cmd), 0, "", "")
+
+ monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run)
+
+ result = CliRunner().invoke(cli, ["template", "prepare-rust"])
+ assert result.exit_code == 0, result.output
+
+ text = vmx.read_text(encoding="ascii")
+ assert 'ide1:0.present = "FALSE"' in text
+ assert 'ide1:0.startConnected = "FALSE"' in text
+ assert snapshot_calls, "snapshot was never taken"
+ assert any('ide1:0.present = "FALSE"' in ln for ln in snapshot_calls[0]), (
+ "snapshot captured the VMX with the seed ISO still attached"
+ )