Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .github/workflows/pr-checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,33 @@ jobs:

- name: Test
run: make test

integration:
needs: lint-and-test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: macos-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5
with:
python-version: "3.13"

- name: Install dependencies
run: make install

- name: Install tart
run: brew install cirruslabs/cli/tart

- name: Install sshpass
run: |
brew tap hudochenkov/sshpass
brew install sshpass

- name: Pull base VM image
run: tart pull ghcr.io/cirruslabs/macos-sequoia-base@sha256:6d2fcc3b4f669e5fec2c567bd991cfb14b527532a288177ce29fc7eb1ee575c2 # latest

- name: Integration tests
env:
MAC2NIX_BASE_VM: macos-sequoia-base
run: make test-integration
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.DEFAULT_GOAL := all
.PHONY: install lint format typecheck test test-quick clean all prek-install prek
.PHONY: install lint format typecheck test test-integration test-quick clean all prek-install prek

install:
uv sync
Expand All @@ -18,6 +18,9 @@ typecheck:
test:
uv run pytest

test-integration:
uv run pytest -m integration --tb=long

test-quick:
uv run pytest -x --no-header -q

Expand Down
6 changes: 5 additions & 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 All @@ -82,6 +82,10 @@ python_functions = "test_*"
addopts = [
"--strict-markers",
"--tb=short",
"-m", "not integration",
]
markers = [
"integration: real VM tests — require tart + sshpass + base VM image",
]
cache_dir = ".cache/pytest"

Expand Down
118 changes: 114 additions & 4 deletions src/mac2nix/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@

import asyncio
import time
import uuid
from pathlib import Path

import click
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 @@ -98,16 +103,78 @@ def progress_callback(name: str) -> None:
click.echo(json_output)


def _vm_options(f: click.decorators.FC) -> click.decorators.FC:
"""Shared CLI options for Tart VM commands (--base-vm, --vm-user, --vm-password)."""
# Applied in reverse order — Click decorators are bottom-up.
return 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=False, help="SSH password inside the VM.")(f)
)
)


@main.command()
def generate() -> None:
"""Generate nix-darwin configuration from a scan snapshot."""
click.echo("generate: not yet implemented")


@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'.",
)
@_vm_options
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:
clone_name = f"mac2nix-validate-{uuid.uuid4().hex[:8]}"
await vm.clone(clone_name)
await vm.start()
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 +184,49 @@ 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.",
)
@_vm_options
@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",
]
Loading
Loading