Phase A1: bootstrap Python ci_orchestrator package

- pyproject.toml (hatchling) with ruff/mypy/pytest dev deps
- src/ci_orchestrator/: config, credentials, backends (Protocol + WorkstationVmrunBackend), transport (WinRM via pypsrp, SSH via paramiko)
- CLI entry point with PoC 'wait-ready' subcommand (Windows + Linux guests)
- tests/python/: 35 unit tests, 83% coverage, all backends/transport mocked
- gitea/workflows/lint.yml: new 'python' job (ruff + mypy --strict + pytest --cov-fail-under=70)
- config.example.toml + README setup section + .gitignore Python entries

Neutral VmBackend Protocol prepared for Phase C ESXi extension.
See plans/implementation-plan-A-B.md step A1.
This commit is contained in:
2026-05-13 11:25:07 +02:00
parent 4d957d34b2
commit bd31a3f2f3
26 changed files with 1920 additions and 2 deletions
+123
View File
@@ -0,0 +1,123 @@
"""CLI entry point.
Phase A1 ships the ``wait-ready`` PoC sub-command. Subsequent steps add
``vm``, ``build``, ``artifacts``, ``monitor``, ``report``, and ``job``.
"""
from __future__ import annotations
import sys
import 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
@click.group()
@click.version_option(__version__, prog_name="ci-orchestrator")
def cli() -> None:
"""Local CI/CD orchestrator (Phase A)."""
@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)
if __name__ == "__main__": # pragma: no cover
cli()