"""``smoke`` sub-commands. Phase C4 ports the manual smoke / E2E build tooling to Python so the Linux host no longer needs ``pwsh``: * ``smoke run`` — end-to-end no-op (or real build) job, replacing ``Test-Smoke.ps1`` and ``Test-*Build-Linux.ps1``. The command runs **one** full end-to-end job through the in-process ``job`` orchestrator (reusing ``commands/job.py`` — no re-spawn of ``python -m ci_orchestrator``) and then asserts the three smoke invariants of ``Test-Smoke.ps1``: 1. the pipeline exited 0, 2. the per-job artifact directory was created, and 3. a ``job``/``success`` event is present in the ``invoke-ci.jsonl`` event log (the same JSONL format ``report job`` consumes). Because the in-process ``job`` command does not itself emit the JSONL event log (that was the job of the retired ``Invoke-CIJob.ps1``), this command writes the ``job``/``start`` and ``job``/``success`` events around the orchestration, exactly as the PowerShell wrapper did. Design rule: import ``backends.load_backend`` and the existing transports only — never ``WorkstationVmrunBackend`` directly (keeps the Phase D ESXi path open). """ from __future__ import annotations import json import time from datetime import UTC, datetime from pathlib import Path import click from ci_orchestrator.commands.job import job as job_command from ci_orchestrator.config import Config, load_config # ─────────────────────────────────────────────────────── OS presets # Per-guest defaults mirroring ``Test-Smoke.ps1``: a trivial build that # writes a marker file into the standard output directory, plus the # matching template / snapshot / artifact-source for the OS family. _LINUX_TEMPLATE = "/var/lib/ci/templates/LinuxBuild2404/LinuxBuild2404.vmx" _WINDOWS_TEMPLATE = r"F:\CI\Templates\WinBuild2025\WinBuild2025.vmx" _NOOP_BUILD_LINUX = "mkdir -p /opt/ci/output && echo smoke-ok > /opt/ci/output/smoke.txt" _NOOP_BUILD_WINDOWS = ( "New-Item -ItemType Directory -Path C:\\CI\\output -Force | Out-Null; " 'Set-Content C:\\CI\\output\\smoke.txt "smoke-ok"' ) # Real Linux build E2E presets (ports of ``Test-*Build-Linux.ps1``). # Exposed via ``--preset`` so the no-op default is never overridden by a # hard-coded ``F:\`` Windows path. Each preset supplies a build command # and the in-guest artifact source directory. _PRESETS: dict[str, tuple[str, str]] = { "ns7zip": ( "python3 build_plugin.py --host linux --7zip-version 26.01 " "--configs x64-unicode --verbose", "plugins", ), "nsinnounp": ( "python3 build_plugin.py --final --dist-dir dist", "dist", ), } def _default_template(guest_os: str) -> str: return _LINUX_TEMPLATE if guest_os == "linux" else _WINDOWS_TEMPLATE def _default_snapshot(guest_os: str) -> str: return "BaseClean-Linux" if guest_os == "linux" else "BaseClean" def _noop_build_command(guest_os: str) -> tuple[str, str]: """Return ``(build_command, guest_artifact_source)`` for the marker job.""" if guest_os == "linux": return _NOOP_BUILD_LINUX, "/opt/ci/output" return _NOOP_BUILD_WINDOWS, "C:\\CI\\output" def _utcnow_iso() -> str: return datetime.now(tz=UTC).isoformat() def _write_event( jsonl_path: Path, *, job_id: str, phase: str, status: str, elapsed_sec: float | None = None, error: str | None = None, ) -> None: """Append one event to ``invoke-ci.jsonl`` in ``report job`` format.""" data: dict[str, object] = {} if elapsed_sec is not None: data["elapsedSec"] = int(elapsed_sec) if error is not None: data["error"] = error record: dict[str, object] = { "jobId": job_id, "phase": phase, "status": status, "ts": _utcnow_iso(), "data": data, } jsonl_path.parent.mkdir(parents=True, exist_ok=True) with jsonl_path.open("a", encoding="utf-8") as fh: fh.write(json.dumps(record) + "\n") def _has_job_success(jsonl_path: Path) -> bool: """True iff the JSONL log contains a ``job``/``success`` event.""" try: text = jsonl_path.read_text(encoding="utf-8") except OSError: return False for line in text.splitlines(): line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: continue if not isinstance(obj, dict): continue if obj.get("phase") == "job" and obj.get("status") == "success": return True return False # ────────────────────────────────────────────────────────── command @click.group("smoke") def smoke() -> None: """End-to-end smoke and build validation.""" @smoke.command("run") @click.option( "--guest-os", type=click.Choice(["windows", "linux"], case_sensitive=False), default="linux", show_default=True, help="Guest OS family to smoke-test.", ) @click.option( "--build-command", "build_command", default="", help="Build command to run (default: a marker no-op job).", ) @click.option( "--preset", type=click.Choice(sorted(_PRESETS), case_sensitive=False), default=None, help="Real Linux build E2E preset (ns7zip / nsinnounp). Linux only.", ) @click.option( "--repo-url", "repo_url", default="https://example.invalid/smoke-test.git", show_default=True, help="Repository to build (default: a placeholder; the no-op marker " "job never depends on the clone).", ) @click.option("--branch", default="main", show_default=True) @click.option( "--job-id", "job_id", default=None, help="Job id (default: smoke-).", ) @click.option( "--template-path", "template_path", default=None, help="Template VMX path (default: per-guest-os standard template).", ) @click.option( "--snapshot-name", "snapshot_name", default=None, help="Snapshot to clone from (default: per-guest-os BaseClean[-Linux]).", ) @click.option( "--ssh-key-path", "ssh_key_path", default=None, help="SSH private key for the guest (Linux).", ) @click.option( "--log-dir", "log_dir", default=None, help="Base directory for invoke-ci.jsonl logs (default: CI_ARTIFACTS).", ) def smoke_run( guest_os: str, build_command: str, preset: str | None, repo_url: str, branch: str, job_id: str | None, template_path: str | None, snapshot_name: str | None, ssh_key_path: str | None, log_dir: str | None, ) -> None: """Run one end-to-end job and assert success + artifact + event.""" guest_os = guest_os.lower() if preset is not None: preset = preset.lower() if guest_os != "linux": raise click.UsageError("--preset is only supported with --guest-os linux.") if build_command: raise click.UsageError("--preset and --build-command are mutually exclusive.") preset_cmd, artifact_source = _PRESETS[preset] effective_build_command = preset_cmd elif build_command: effective_build_command = build_command # A custom command writes to the standard output dir for the OS. _, artifact_source = _noop_build_command(guest_os) else: effective_build_command, artifact_source = _noop_build_command(guest_os) config: Config = load_config() resolved_job_id = job_id or f"smoke-{datetime.now(tz=UTC).strftime('%Y%m%d-%H%M%S')}" resolved_template = template_path or _default_template(guest_os) resolved_snapshot = snapshot_name or _default_snapshot(guest_os) artifact_base: Path = config.paths.artifacts # Logs live alongside (not inside) the artifacts tree so that writing the # event log never accidentally creates the per-job artifact directory and # masks a build that produced no artifacts (mirrors F:\CI\Logs vs # F:\CI\Artifacts in Test-Smoke.ps1). log_base = Path(log_dir) if log_dir else config.paths.root / "logs" job_artifact_dir = artifact_base / resolved_job_id jsonl_path = log_base / resolved_job_id / "invoke-ci.jsonl" click.echo("=" * 60) click.echo(f"[smoke] Guest OS : {guest_os}") click.echo(f"[smoke] Template : {resolved_template}") click.echo(f"[smoke] Snapshot : {resolved_snapshot}") click.echo(f"[smoke] Job id : {resolved_job_id}") if preset is not None: click.echo(f"[smoke] Preset : {preset}") click.echo(f"[smoke] Artifacts : {job_artifact_dir}") click.echo("=" * 60) _write_event( jsonl_path, job_id=resolved_job_id, phase="job", status="start" ) start = time.monotonic() failed = False try: job_command.callback( # type: ignore[misc] job_id=resolved_job_id, repo_url=repo_url, branch=branch, commit="", configuration="Release", build_command=effective_build_command, guest_artifact_source=artifact_source, submodules=True, use_git_clone=True, use_shared_cache=False, skip_artifact=False, use_xvfb=False, gitea_credential_target="GiteaPAT", template_path=resolved_template, snapshot_name=resolved_snapshot, clone_base_dir=None, artifact_base_dir=str(artifact_base), guest_os=guest_os, guest_credential_target=None, ssh_key_path=ssh_key_path, ssh_user="ci_build", ssh_known_hosts_file=None, extra_env_json="", guest_cpu=0, guest_memory_mb=0, ready_timeout=600.0, poll_interval=5.0, vmrun_path=None, ) except click.ClickException as exc: failed = True _write_event( jsonl_path, job_id=resolved_job_id, phase="job", status="failure", elapsed_sec=time.monotonic() - start, error=exc.format_message(), ) click.echo(f"[smoke] FAIL: pipeline raised: {exc.format_message()}", err=True) if not failed: _write_event( jsonl_path, job_id=resolved_job_id, phase="job", status="success", elapsed_sec=time.monotonic() - start, ) # ── Assert the three smoke invariants ─────────────────────────────── click.echo("\n[smoke] Verifying results...") checks_ok = True if failed: checks_ok = False else: click.echo("[smoke] OK: pipeline exit 0") if job_artifact_dir.is_dir(): click.echo(f"[smoke] OK: artifact dir exists: {job_artifact_dir}") else: click.echo( f"[smoke] FAIL: artifact dir not found: {job_artifact_dir}", err=True ) checks_ok = False if _has_job_success(jsonl_path): click.echo("[smoke] OK: JSONL contains job/success event") else: click.echo( f"[smoke] FAIL: JSONL missing job/success event in: {jsonl_path}", err=True, ) checks_ok = False click.echo("") if checks_ok: click.echo(f"[smoke] PASSED — {guest_os} smoke test completed successfully.") return raise click.ClickException("smoke test FAILED — one or more checks did not pass.") __all__ = ["smoke"]