Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ ignore = [
]

[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["S101", "S105", "S108", "SLF001"] # assert + test data + private access OK in tests
"**/tests/**/*.py" = ["S101", "S105", "S106", "S107", "S108", "SLF001", "ARG001"] # assert + test data + credentials + private access + unused mock args OK in tests

[tool.ruff.lint.isort]
known-first-party = ["mac2nix"]
Expand Down
108 changes: 104 additions & 4 deletions src/mac2nix/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@
from rich.console import Console
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn

from mac2nix.models.system_state import SystemState
from mac2nix.orchestrator import run_scan
from mac2nix.scanners import get_all_scanners
from mac2nix.vm.discovery import DiscoveryRunner
from mac2nix.vm.manager import TartVMManager
from mac2nix.vm.validator import Validator


@click.group()
Expand Down Expand Up @@ -105,9 +109,60 @@ def generate() -> None:


@main.command()
def validate() -> None:
@click.option(
"--flake-path",
required=True,
type=click.Path(exists=True, file_okay=False, path_type=Path),
help="Path to the nix-darwin flake directory.",
)
@click.option(
"--scan-file",
required=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Source SystemState JSON produced by 'mac2nix scan'.",
)
@click.option("--base-vm", default="base-macos", show_default=True, help="Base Tart VM name.")
@click.option("--vm-user", default="admin", show_default=True, help="SSH username inside the VM.")
@click.option("--vm-password", default="admin", show_default=True, help="SSH password inside the VM.")
def validate(
flake_path: Path,
scan_file: Path,
base_vm: str,
vm_user: str,
vm_password: str,
) -> None:
"""Validate generated configuration in a Tart VM."""
click.echo("validate: not yet implemented")
if not TartVMManager.is_available():
raise click.ClickException("tart CLI not found — install tart to use 'validate'.")

try:
source_state = SystemState.from_json(scan_file)
except Exception as exc:
raise click.ClickException(f"Failed to load scan file: {exc}") from exc

async def _run() -> None:
async with TartVMManager(base_vm, vm_user, vm_password) as vm:
result = await Validator(vm).validate(flake_path, source_state)

if result.errors:
click.echo("Validation errors:", err=True)
for error in result.errors:
click.echo(f" {error}", err=True)

if result.fidelity:
click.echo(f"Overall fidelity: {result.fidelity.overall_score:.1%}")
for domain, ds in sorted(result.fidelity.domain_scores.items()):
click.echo(f" {domain}: {ds.score:.1%} ({ds.matching_fields}/{ds.total_fields})")

if not result.success:
raise click.ClickException("Validation failed.")

try:
asyncio.run(_run())
except click.ClickException:
raise
except Exception as exc:
raise click.ClickException(str(exc)) from exc


@main.command()
Expand All @@ -117,6 +172,51 @@ def diff() -> None:


@main.command()
def discover() -> None:
@click.option("--package", required=True, help="Package name to install and discover.")
@click.option(
"--type",
"package_type",
default="brew",
show_default=True,
type=click.Choice(["brew", "cask"]),
help="Package manager type.",
)
@click.option("--base-vm", default="base-macos", show_default=True, help="Base Tart VM name.")
@click.option("--vm-user", default="admin", show_default=True, help="SSH username inside the VM.")
@click.option("--vm-password", default="admin", show_default=True, help="SSH password inside the VM.")
@click.option(
"--output",
"-o",
type=click.Path(path_type=Path),
default=None,
metavar="FILE",
help="Write JSON result to FILE instead of stdout.",
)
def discover( # noqa: PLR0913
package: str,
package_type: str,
base_vm: str,
vm_user: str,
vm_password: str,
output: Path | None,
) -> None:
"""Discover app config paths by installing in a Tart VM."""
click.echo("discover: not yet implemented")
if not TartVMManager.is_available():
raise click.ClickException("tart CLI not found — install tart to use 'discover'.")

async def _run() -> str:
async with TartVMManager(base_vm, vm_user, vm_password) as vm:
result = await DiscoveryRunner(vm).discover(package, package_type)
return result.model_dump_json(indent=2)

try:
json_output = asyncio.run(_run())
except Exception as exc:
raise click.ClickException(str(exc)) from exc

if output is not None:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json_output)
click.echo(f"Discovery result written to {output}", err=True)
else:
click.echo(json_output)
30 changes: 30 additions & 0 deletions src/mac2nix/vm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""VM integration package for mac2nix."""

from mac2nix.vm._utils import VMConnectionError, VMError, VMTimeoutError
from mac2nix.vm.comparator import FileSystemComparator
from mac2nix.vm.discovery import DiscoveryResult, DiscoveryRunner
from mac2nix.vm.manager import TartVMManager
from mac2nix.vm.validator import (
DomainScore,
FidelityReport,
Mismatch,
ValidationResult,
Validator,
compute_fidelity,
)

__all__ = [
"DiscoveryResult",
"DiscoveryRunner",
"DomainScore",
"FidelityReport",
"FileSystemComparator",
"Mismatch",
"TartVMManager",
"VMConnectionError",
"VMError",
"VMTimeoutError",
"ValidationResult",
"Validator",
"compute_fidelity",
]
173 changes: 173 additions & 0 deletions src/mac2nix/vm/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""Async VM utilities — subprocess execution, SSH, and exception types."""

from __future__ import annotations

import asyncio
import contextlib
import logging
import os
import shutil

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Exception hierarchy
# ---------------------------------------------------------------------------


class VMError(Exception):
"""Base exception for VM integration errors."""


class VMConnectionError(VMError):
"""Raised when an SSH/network connection to a VM cannot be established."""


class VMTimeoutError(VMError):
"""Raised when a VM operation exceeds its timeout."""


# ---------------------------------------------------------------------------
# Tool availability
# ---------------------------------------------------------------------------


def is_tart_available() -> bool:
"""Return True if the tart CLI is on PATH."""
return shutil.which("tart") is not None


def is_sshpass_available() -> bool:
"""Return True if sshpass is on PATH."""
return shutil.which("sshpass") is not None


# ---------------------------------------------------------------------------
# Async subprocess helpers
# ---------------------------------------------------------------------------


async def async_run_command(
cmd: list[str],
*,
timeout: int = 30,
env: dict[str, str] | None = None,
) -> tuple[int, str, str]:
"""Run a subprocess command asynchronously.

Validates that the executable exists before running. Never uses shell=True.

Args:
cmd: Command as an argument list. cmd[0] must be the executable name.
timeout: Maximum seconds to wait for the process. Defaults to 30.
env: Extra environment variables merged into the current environment.

Returns:
Tuple of (returncode, stdout, stderr).

Raises:
VMError: If the executable is not found on PATH.
VMTimeoutError: If the process exceeds *timeout* seconds.
"""
executable = cmd[0]
if shutil.which(executable) is None:
logger.warning("Executable not found: %s", executable)
raise VMError(f"Executable not found: {executable}")

logger.debug("Running async command: %s", cmd)
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **env} if env else None,
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except TimeoutError:
proc.kill()
with contextlib.suppress(Exception): # Best-effort reap — process may already be gone
await proc.communicate()
logger.warning("Command timed out after %ds: %s", timeout, cmd)
raise VMTimeoutError(f"Command timed out after {timeout}s: {cmd}") from None

returncode = proc.returncode if proc.returncode is not None else -1
return returncode, stdout_bytes.decode(errors="replace"), stderr_bytes.decode(errors="replace")

except (VMError, VMTimeoutError):
raise
except FileNotFoundError:
logger.warning("Executable not found during execution: %s", executable)
raise VMError(f"Executable not found during execution: {executable}") from None
except OSError as exc:
logger.warning("OS error running command %s: %s", cmd, exc)
raise VMError(f"OS error running {cmd}: {exc}") from exc


async def async_ssh_exec(
ip: str,
user: str,
password: str,
cmd: list[str],
*,
timeout: int = 30,
) -> tuple[bool, str, str]:
"""Execute a command on a remote VM via SSH using sshpass.

Builds the argument list with sshpass + ssh options. For remote commands
involving pipes or redirects, wrap in ``['bash', '-c', 'pipeline']``.

Args:
ip: IP address or hostname of the VM.
user: SSH username.
password: SSH password (passed to sshpass, never via shell).
cmd: Remote command as an argument list.
timeout: Maximum seconds to wait. Defaults to 30.

Returns:
Tuple of (success, stdout, stderr). ``success`` is True when returncode == 0.

Raises:
VMConnectionError: If sshpass is not available.
VMTimeoutError: If the SSH command exceeds *timeout* seconds.
VMError: For other subprocess failures.
"""
if not is_sshpass_available():
raise VMConnectionError("sshpass is not available — cannot perform SSH exec")

# Use sshpass -e (reads password from SSHPASS env var) to avoid exposing
# the password in process listings (ps aux shows argv, not environment).
# StrictHostKeyChecking=no and UserKnownHostsFile=/dev/null: safe because
# target VMs are ephemeral Tart clones on localhost — no persistent host
# identity to verify, and the IP/key changes on every clone.
ssh_cmd = [
"sshpass",
"-e",
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"LogLevel=ERROR",
"-o",
f"ConnectTimeout={max(timeout // 2, 5)}",
f"{user}@{ip}",
"--",
*cmd,
]

logger.debug("Running SSH exec on %s@%s: %s", user, ip, cmd)
try:
returncode, stdout, stderr = await async_run_command(ssh_cmd, timeout=timeout, env={"SSHPASS": password})
except VMTimeoutError:
raise
except VMError as exc:
raise VMConnectionError(f"SSH connection to {user}@{ip} failed: {exc}") from exc

success = returncode == 0
if not success:
logger.debug("SSH exec on %s@%s returned %d: %s", user, ip, returncode, stderr.strip())

return success, stdout, stderr
Loading
Loading