"""``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 import zipfile 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) remote_zip = "C:\\CI\\ci-artifacts-transfer.zip" with WinRmTransport(ip_address, cred.username, cred.password) as t: # guest_path is the collect directory (raw build outputs). # Verify it exists and is non-empty. probe = t.run( f"if ((Test-Path {_ps_quote(guest_path)}) -and " f"(Get-ChildItem -Force -- {_ps_quote(guest_path)})) " f"{{ 'OK' }} else {{ 'MISSING' }}", check=False, ) if "MISSING" in probe.stdout: raise click.ClickException( f"artifact not found or empty in guest: {guest_path}" ) # Zip the directory contents as a single transport file, fetch, # extract into host_dir. Mirrors the Linux tar flow so both OSes # deposit raw files; the final single archive is produced by # actions/upload-artifact (no zip-in-zip). t.run( f"if (Test-Path {_ps_quote(remote_zip)}) " f"{{ Remove-Item {_ps_quote(remote_zip)} -Force }}; " f"Compress-Archive -Path (Join-Path {_ps_quote(guest_path)} '*') " f"-DestinationPath {_ps_quote(remote_zip)} -Force" ) click.echo(f"[artifacts collect] fetching artifacts from {guest_path}") try: with tempfile.TemporaryDirectory() as td: local_zip = Path(td) / "artifacts.zip" t.fetch(remote_zip, str(local_zip)) with zipfile.ZipFile(local_zip) as zf: zf.extractall(host_dir) finally: t.run( f"Remove-Item {_ps_quote(remote_zip)} -Force " f"-ErrorAction SilentlyContinue", check=False, ) 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 any(host_dir.iterdir()): raise click.ClickException( f"no artifacts after collect from guest: {guest_path}" ) # ---------------------------------------------------------------- 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 collect directory (Windows and 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"]