feat(a3): port build pipeline (vm new, build run, artifacts collect)

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

- commands/vm.py: vm new (replaces New-BuildVM.ps1)

- commands/build.py: build run (replaces Invoke-RemoteBuild.ps1) with WinRM/SSH dispatch and stdout streaming for act_runner

- commands/artifacts.py: artifacts collect (replaces Get-BuildArtifacts.ps1) using transport.fetch()

- 3 PS scripts reduced to shims preserving \0

- Pester tests/{New-BuildVM,Wait-VMReady,Remove-BuildVM}.Tests.ps1 removed; equivalent cases covered in pytest

- Phase C hook: build/artifacts accept opaque VmHandle/vmx; no Windows path assumptions

Tests: 91 pytest, ruff clean, mypy --strict clean, coverage 78.27% (>=70 gate).

Master checklist + step A3 attivita + 'definizione di fatto' updated; A3-closeout.md added.
This commit is contained in:
2026-05-14 17:20:34 +02:00
parent 096ba7fe16
commit 816a15503e
15 changed files with 1900 additions and 1061 deletions
+212
View File
@@ -0,0 +1,212 @@
"""Tests for ``ci_orchestrator vm new``.
Migrated from Pester ``tests/New-BuildVM.Tests.ps1``. Preserves:
* parameter validation: missing template VMX rejected
* failure path: backend clone failure cleans up the partial clone dir
* clone naming: ``Clone_{JobId}_{yyyyMMdd_HHmmss}``
* output: stdout final line is the clone VMX path
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from click.testing import CliRunner
import ci_orchestrator.commands.vm as vm_module
from ci_orchestrator.__main__ import cli
from ci_orchestrator.backends.errors import BackendOperationFailed
from ci_orchestrator.backends.protocol import VmHandle
class _FakeBackend:
"""Records calls and creates the destination VMX on success."""
def __init__(self, *, fail: bool = False) -> None:
self.fail = fail
self.calls: list[dict[str, Any]] = []
def clone_linked(
self,
template: str,
snapshot: str,
name: str,
destination: str | None = None,
) -> VmHandle:
self.calls.append(
{
"template": template,
"snapshot": snapshot,
"name": name,
"destination": destination,
}
)
if self.fail:
raise BackendOperationFailed("clone", 1, "Error: source not found")
# Real vmrun creates dest dir + VMX file. Mirror that here so the
# post-clone existence check passes.
assert destination is not None
dst = Path(destination)
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text("config.version = \"8\"", encoding="utf-8")
return VmHandle(identifier=str(dst), name=name)
@pytest.fixture
def fake_template(tmp_path: Path) -> Path:
p = tmp_path / "WinBuild2025.vmx"
p.write_text("config.version = \"8\"", encoding="utf-8")
return p
def test_vm_new_rejects_missing_template(tmp_path: Path) -> None:
"""Pester migration: throws when TemplatePath does not exist."""
missing = tmp_path / "ghost.vmx"
base = tmp_path / "build-vms"
result = CliRunner().invoke(
cli,
[
"vm",
"new",
"--template",
str(missing),
"--clone-base-dir",
str(base),
"--job-id",
"test-1",
],
)
assert result.exit_code != 0
assert "Template VMX not found" in result.output
def test_vm_new_clone_failure_cleans_up_partial_dir(
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
) -> None:
"""Pester migration: vmrun failure removes the partially-created clone dir."""
base = tmp_path / "build-vms"
backend = _FakeBackend(fail=True)
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
result = CliRunner().invoke(
cli,
[
"vm",
"new",
"--template",
str(fake_template),
"--clone-base-dir",
str(base),
"--job-id",
"test-fail",
],
)
assert result.exit_code != 0
assert "clone failed" in result.output
leftovers = [p for p in base.iterdir() if p.is_dir() and "test-fail" in p.name]
assert leftovers == []
def test_vm_new_clone_name_format_and_stdout(
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
) -> None:
"""Pester migration: clone folder is ``Clone_{JobId}_{yyyyMMdd_HHmmss}``
and the final stdout line is the clone VMX path."""
base = tmp_path / "build-vms"
backend = _FakeBackend()
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
result = CliRunner().invoke(
cli,
[
"vm",
"new",
"--template",
str(fake_template),
"--clone-base-dir",
str(base),
"--job-id",
"run-42",
],
)
assert result.exit_code == 0, result.output
final = result.output.strip().splitlines()[-1]
assert final.endswith(".vmx")
assert "Clone_run-42_" in final
# Format check: timestamp is 8 digits + underscore + 6 digits.
import re
assert re.search(r"Clone_run-42_\d{8}_\d{6}\.vmx$", final)
def test_vm_new_passes_snapshot_default(
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
) -> None:
base = tmp_path / "build-vms"
backend = _FakeBackend()
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
result = CliRunner().invoke(
cli,
[
"vm",
"new",
"--template",
str(fake_template),
"--clone-base-dir",
str(base),
"--job-id",
"run-99",
],
)
assert result.exit_code == 0, result.output
assert backend.calls[0]["snapshot"] == "BaseClean"
def test_vm_new_pascal_case_aliases(
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
) -> None:
"""The PS-shim translation produces ``--template-path`` / ``--snapshot-name``."""
base = tmp_path / "build-vms"
backend = _FakeBackend()
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
result = CliRunner().invoke(
cli,
[
"vm",
"new",
"--template-path",
str(fake_template),
"--snapshot-name",
"Custom",
"--clone-base-dir",
str(base),
"--job-id",
"alias",
],
)
assert result.exit_code == 0, result.output
assert backend.calls[0]["snapshot"] == "Custom"
def test_vm_new_creates_base_dir(
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
) -> None:
base = tmp_path / "missing-base"
backend = _FakeBackend()
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
result = CliRunner().invoke(
cli,
[
"vm",
"new",
"--template",
str(fake_template),
"--clone-base-dir",
str(base),
"--job-id",
"bd",
],
)
assert result.exit_code == 0, result.output
assert base.is_dir()