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
+268
View File
@@ -0,0 +1,268 @@
"""``artifacts`` sub-commands.
Phase A3 ports the artifact-collection script:
* ``artifacts collect`` — replaces ``scripts/Get-BuildArtifacts.ps1``
Uses the abstract :meth:`~ci_orchestrator.transport.winrm.WinRmTransport.fetch`
and the SSH transport (via a tar+fetch helper) so no platform-specific
``Copy-Item`` / ``scp.exe`` invocations leak into command code.
"""
from __future__ import annotations
import hashlib
import json
import shlex
import tarfile
import tempfile
from datetime import UTC, datetime
from pathlib import Path
import click
from ci_orchestrator.config import load_config
from ci_orchestrator.credentials import KeyringCredentialStore
from ci_orchestrator.transport.errors import TransportCommandError, TransportError
from ci_orchestrator.transport.ssh import SshTransport
from ci_orchestrator.transport.winrm import WinRmTransport
# ---------------------------------------------------------------- group
@click.group("artifacts")
def artifacts() -> None:
"""Artifact transfer commands."""
# ---------------------------------------------------------------- helpers
def _sh_quote(value: str) -> str:
return shlex.quote(value)
def _ps_quote(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _sha256_hex(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(64 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def _write_manifest(host_dir: Path, job_id: str, commit: str) -> int:
"""Write ``manifest.json`` summarising every collected file. Returns count."""
files = [
p
for p in host_dir.rglob("*")
if p.is_file() and p.name != "manifest.json"
]
entries = []
for f in files:
rel = f.relative_to(host_dir).as_posix()
entries.append(
{
"name": rel,
"size": f.stat().st_size,
"sha256": _sha256_hex(f),
}
)
payload = {
"ts": datetime.now(tz=UTC).isoformat(),
"jobId": job_id,
"commit": commit,
"files": entries,
}
out = host_dir / "manifest.json"
out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return len(entries)
# ---------------------------------------------------------------- Linux flow
def _linux_collect(
*,
ip_address: str,
ssh_user: str,
key_path: str | None,
known_hosts: str | None,
guest_path: str,
host_dir: Path,
) -> None:
"""Tar the guest directory, fetch the single archive, extract locally."""
click.echo(f"[artifacts collect] tarring guest:{guest_path}")
archive_remote = f"/tmp/ci-artifacts-{ip_address.replace('.', '-')}.tar.gz"
with SshTransport(
ip_address,
username=ssh_user,
key_path=key_path,
known_hosts=known_hosts,
) as t:
# Build a tarball whose contents are *inside* guest_path. Strip the
# parent component so files land directly under host_dir.
t.run(
f"tar -czf {_sh_quote(archive_remote)} "
f"-C {_sh_quote(guest_path)} ."
)
try:
with tempfile.TemporaryDirectory() as td:
local_tar = Path(td) / "artifacts.tar.gz"
t.fetch(archive_remote, str(local_tar))
with tarfile.open(local_tar, "r:gz") as tf:
# ``data`` filter (Python 3.12+) protects against path
# traversal in malicious tarballs.
tf.extractall(host_dir, filter="data")
finally:
t.run(f"rm -f {_sh_quote(archive_remote)}", check=False)
# ---------------------------------------------------------------- Windows flow
def _windows_collect(
*,
ip_address: str,
credential_target: str,
guest_path: str,
host_dir: Path,
include_logs: bool,
) -> None:
cred = KeyringCredentialStore().get(credential_target)
with WinRmTransport(ip_address, cred.username, cred.password) as t:
# Verify artifact exists.
probe = t.run(
f"if (Test-Path {_ps_quote(guest_path)}) {{ 'OK' }} else {{ 'MISSING' }}",
check=False,
)
if "MISSING" in probe.stdout:
raise click.ClickException(f"artifact not found in guest: {guest_path}")
artifact_name = Path(guest_path).name
host_dest = host_dir / artifact_name
click.echo(f"[artifacts collect] fetching {artifact_name}")
t.fetch(guest_path, str(host_dest))
if include_logs:
# List log files in the build dir and fetch each one.
list_ps = (
"Get-ChildItem -Path 'C:\\CI\\build' -Filter '*.log' -Recurse "
"-ErrorAction SilentlyContinue | "
"ForEach-Object { $_.FullName }"
)
listing = t.run(list_ps, check=False)
for line in (listing.stdout or "").splitlines():
line = line.strip()
if not line:
continue
dest = host_dir / Path(line).name
click.echo(f"[artifacts collect] fetching log: {Path(line).name}")
try:
t.fetch(line, str(dest))
except TransportError as exc:
click.echo(
f"[artifacts collect] log fetch failed ({line}): {exc}",
err=True,
)
if not host_dest.is_file() or host_dest.stat().st_size == 0:
raise click.ClickException(
f"artifact missing or empty after fetch: {host_dest}"
)
# ---------------------------------------------------------------- CLI
@artifacts.command("collect")
@click.option("--ip-address", "ip_address", required=True)
@click.option(
"--guest-os",
type=click.Choice(["windows", "linux"], case_sensitive=False),
default="windows",
show_default=True,
)
@click.option(
"--guest-artifact-path",
"guest_artifact_path",
required=True,
help="Guest path: file (Windows) or directory (Linux).",
)
@click.option(
"--host-artifact-dir",
"host_artifact_dir",
required=True,
help="Local directory to deposit artifacts (created if missing).",
)
@click.option("--include-logs", "include_logs", is_flag=True, default=False)
@click.option("--credential-target", "credential_target", default=None)
@click.option("--ssh-user", "ssh_user", default="ci_build", show_default=True)
@click.option("--ssh-key-path", "ssh_key_path", default=None)
@click.option("--ssh-known-hosts-file", "ssh_known_hosts_file", default=None)
@click.option("--job-id", "job_id", default="")
@click.option("--commit", "commit", default="")
def artifacts_collect(
ip_address: str,
guest_os: str,
guest_artifact_path: str,
host_artifact_dir: str,
include_logs: bool,
credential_target: str | None,
ssh_user: str,
ssh_key_path: str | None,
ssh_known_hosts_file: str | None,
job_id: str,
commit: str,
) -> None:
"""Copy build artifacts from a guest VM to a local directory.
Mirrors ``scripts/Get-BuildArtifacts.ps1``. Writes ``manifest.json``
when ``--job-id`` or ``--commit`` is provided.
"""
guest_os = guest_os.lower()
host_dir = Path(host_artifact_dir)
host_dir.mkdir(parents=True, exist_ok=True)
config = load_config()
try:
if guest_os == "linux":
key_path: str | None = ssh_key_path
if key_path is None and config.ssh_key_path is not None:
key_path = str(config.ssh_key_path)
_linux_collect(
ip_address=ip_address,
ssh_user=ssh_user,
key_path=key_path,
known_hosts=ssh_known_hosts_file,
guest_path=guest_artifact_path,
host_dir=host_dir,
)
else:
target = credential_target or config.guest_cred_target
_windows_collect(
ip_address=ip_address,
credential_target=target,
guest_path=guest_artifact_path,
host_dir=host_dir,
include_logs=include_logs,
)
except (TransportError, TransportCommandError) as exc:
raise click.ClickException(str(exc)) from exc
transferred = [p for p in host_dir.rglob("*") if p.is_file()]
if not transferred:
raise click.ClickException(
f"no files were transferred into {host_dir}"
)
click.echo(f"[artifacts collect] {len(transferred)} file(s) collected.")
if job_id or commit:
count = _write_manifest(host_dir, job_id, commit)
click.echo(f"[artifacts collect] manifest.json written ({count} file(s)).")
__all__ = ["artifacts", "artifacts_collect"]