feat(a2): port leaf PS scripts to ci_orchestrator CLI

Implements Phase A2 of plans/implementation-plan-A-B.md:

- commands/wait.py    -> wait-ready (replaces Wait-VMReady.ps1)

- commands/vm.py      -> vm remove + vm cleanup (replaces Remove-BuildVM.ps1, Cleanup-OrphanedBuildVMs.ps1); cleanup accepts injected VmBackend (Phase C ESXi hook preserved)

- commands/monitor.py -> monitor disk + monitor runner (replaces Watch-DiskSpace.ps1, Watch-RunnerHealth.ps1)

- commands/report.py  -> report job (replaces Get-CIJobSummary.ps1)

Each PS script reduced to a 3-line shim that delegates to the Python CLI and preserves \0.

Tests: 69 pytest cases across new test_commands_*.py modules; ruff clean, mypy --strict clean, coverage 74.5% (>=70 gate).
This commit is contained in:
2026-05-14 17:01:15 +02:00
parent 10da8f4e81
commit 80f6661ad5
17 changed files with 2074 additions and 881 deletions
+21 -102
View File
@@ -1,122 +1,41 @@
"""CLI entry point.
Phase A1 ships the ``wait-ready`` PoC sub-command. Subsequent steps add
``vm``, ``build``, ``artifacts``, ``monitor``, ``report``, and ``job``.
Phase A2 ships ``wait-ready``, ``vm`` (remove/cleanup), ``monitor``
(disk/runner) and ``report`` (job). Subsequent phases add ``vm new``,
``build``, ``artifacts``, ``job``.
"""
from __future__ import annotations
import sys
import time
import time as time
import click
from ci_orchestrator import __version__
from ci_orchestrator.backends import load_backend
from ci_orchestrator.backends.errors import BackendError
from ci_orchestrator.backends.protocol import VmHandle
from ci_orchestrator.config import load_config
from ci_orchestrator.credentials import KeyringCredentialStore
from ci_orchestrator.transport.errors import TransportError
from ci_orchestrator.transport.ssh import SshTransport
from ci_orchestrator.transport.winrm import WinRmTransport
from ci_orchestrator.commands.monitor import monitor
from ci_orchestrator.commands.report import report
from ci_orchestrator.commands.vm import vm
# Re-export so legacy A1 tests that patched ``cli_module.<name>`` keep working.
from ci_orchestrator.commands.wait import ( # noqa: F401 (intentional re-exports)
KeyringCredentialStore,
SshTransport,
WinRmTransport,
load_backend,
wait_ready,
)
@click.group()
@click.version_option(__version__, prog_name="ci-orchestrator")
def cli() -> None:
"""Local CI/CD orchestrator (Phase A)."""
"""Local CI/CD orchestrator."""
@cli.command("wait-ready")
@click.option("--vmx", required=True, help="Path to the guest VMX.")
@click.option(
"--guest-os",
type=click.Choice(["windows", "linux"]),
default="windows",
show_default=True,
)
@click.option("--timeout", type=float, default=300.0, show_default=True)
@click.option(
"--poll-interval", type=float, default=5.0, show_default=True, help="Seconds between probes."
)
@click.option(
"--credential-target",
default=None,
help="Override credential target name (defaults to config.guest_cred_target).",
)
@click.option("--ssh-user", default="ci_build", show_default=True)
def wait_ready(
vmx: str,
guest_os: str,
timeout: float,
poll_interval: float,
credential_target: str | None,
ssh_user: str,
) -> None:
"""Poll a guest until WinRM (Windows) or SSH (Linux) is responsive."""
config = load_config()
backend = load_backend(config)
handle = VmHandle(identifier=vmx)
deadline = time.monotonic() + timeout
# Step 1: wait until backend reports the VM as running.
click.echo(f"[wait-ready] waiting for backend to report VM running: {vmx}")
while time.monotonic() < deadline:
try:
if backend.is_running(handle):
break
except BackendError as exc:
click.echo(f"[wait-ready] backend probe error: {exc}", err=True)
time.sleep(poll_interval)
else:
click.echo("[wait-ready] timeout waiting for VM to be running.", err=True)
sys.exit(2)
# Step 2: wait for an IP.
ip: str | None = None
while time.monotonic() < deadline:
try:
ip = backend.get_ip(handle)
except BackendError:
ip = None
if ip:
break
time.sleep(poll_interval)
if not ip:
click.echo("[wait-ready] timeout waiting for guest IP.", err=True)
sys.exit(3)
click.echo(f"[wait-ready] guest IP: {ip}")
# Step 3: probe the transport layer.
if guest_os == "windows":
store = KeyringCredentialStore()
target = credential_target or config.guest_cred_target
cred = store.get(target)
while time.monotonic() < deadline:
try:
with WinRmTransport(ip, cred.username, cred.password) as t:
if t.is_ready():
click.echo("[wait-ready] WinRM ready.")
return
except TransportError as exc:
click.echo(f"[wait-ready] WinRM probe failed: {exc}", err=True)
time.sleep(poll_interval)
else:
key_path = config.ssh_key_path
while time.monotonic() < deadline:
try:
with SshTransport(ip, username=ssh_user, key_path=key_path) as t:
if t.is_ready():
click.echo("[wait-ready] SSH ready.")
return
except TransportError as exc:
click.echo(f"[wait-ready] SSH probe failed: {exc}", err=True)
time.sleep(poll_interval)
click.echo("[wait-ready] timeout waiting for transport to be ready.", err=True)
sys.exit(4)
cli.add_command(wait_ready)
cli.add_command(vm)
cli.add_command(monitor)
cli.add_command(report)
if __name__ == "__main__": # pragma: no cover