Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bc55cf4
feat(cli): adds init/add-host scaffold with sops-nix safety gates
wgordon17 Jul 31, 2026
45d04df
fix(tests): skips TestGenerateAgeKey when age-keygen is unavailable
wgordon17 Aug 2, 2026
40cd962
fix(ci): trusts cirruslabs/cli tap before installing tart
wgordon17 Aug 2, 2026
271d3b4
refactor(ci): merges integration job into vm-integration
wgordon17 Aug 2, 2026
e85dbc7
fix(ci): applies nix-darwin switch natively, drops Tart from CI
wgordon17 Aug 3, 2026
9fa58e0
fix(tests): runs nix-darwin switch under sudo -n with absolute nix path
wgordon17 Aug 3, 2026
53cdba6
fix(ci): moves pre-existing nix.custom.conf before nix-darwin switch
wgordon17 Aug 3, 2026
061f026
fix(ci): removes pre-existing Homebrew Taps before autoMigrate
wgordon17 Aug 3, 2026
afa11b3
fix(ci): wipes Homebrew before switch, installs sops/age via nix
wgordon17 Aug 3, 2026
6758698
fix(ci): authenticates nix's github flake-input fetches
wgordon17 Aug 3, 2026
28c9fc9
fix(ci): propagates nix github token through sudo and wrapper scripts
wgordon17 Aug 3, 2026
d39054e
feat(cli): reworks add-host confirmation flow per UAT feedback
wgordon17 Aug 5, 2026
81a4e13
fix(vm): points guest DNS at a public resolver, bypassing tart gateway
wgordon17 Aug 6, 2026
8f01df3
fix(vm): forces password-only ssh auth to avoid MaxAuthTries exhaustion
wgordon17 Aug 6, 2026
be9c36a
fix(vm): sudos the real switch and retries transient DNS-setup failures
wgordon17 Aug 6, 2026
0c6e8db
fix(vm): moves aside nix.custom.conf before the real vm switch test
wgordon17 Aug 6, 2026
bbc7ee0
fix(vm): wipes pre-existing homebrew before the real vm switch test
wgordon17 Aug 6, 2026
4b241ff
fix(vm): requires 2 consecutive ssh successes before vm is ready
wgordon17 Aug 6, 2026
c14c513
fix(cli): stops pre-gating op backup on op whoami
wgordon17 Aug 6, 2026
cf392d0
fix(cli): tightens add-host prompts per UAT review
wgordon17 Aug 7, 2026
7fcddff
fix(cli): reads op document create's real "uuid" field, not "id"
wgordon17 Aug 7, 2026
cd54e13
fix(cli): reports 1password item by title, not raw uuid
wgordon17 Aug 7, 2026
a2d0037
docs(ci): trims comment bloat and removes stale merge notes
wgordon17 Aug 7, 2026
9d58159
docs: trims remaining comment bloat found in a full audit
wgordon17 Aug 7, 2026
8008c90
refactor(generators): passes primaryUser instead of inferring it
wgordon17 Aug 7, 2026
f256727
fix(cli): distinguishes real interrupt from eof in confirm prompts
wgordon17 Aug 8, 2026
6708091
fix(generators): hardens age key directory permissions to 0700
wgordon17 Aug 8, 2026
43b08d2
refactor(cli): privatizes unused onepassword sign-in check
wgordon17 Aug 8, 2026
abf1452
style(templates): standardizes nix module signatures across scaffold
wgordon17 Aug 8, 2026
2ead2c6
fix(vm): cleans up orphaned clone when prewarm fails
wgordon17 Aug 8, 2026
742a3c3
test(vm): covers validator's local source copy behavior
wgordon17 Aug 8, 2026
31a1782
chore(deps): scopes pytest collection and adds op-cli test tier
wgordon17 Aug 8, 2026
72c0df2
fix(cli): mocks crypto in confirm-or-default eof test
wgordon17 Aug 8, 2026
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
106 changes: 94 additions & 12 deletions .github/workflows/pr-checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,75 @@ jobs:
- name: Test
run: make test

integration:
needs: lint-and-test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Unconditional and ungated (unlike nix-darwin-switch below) — every PR must
# build before merge. Read-only default GITHUB_TOKEN is safe even against
# fork PRs since this only runs `nix build`, no elevated permissions needed.
nix-integration:
if: github.event_name == 'pull_request'
runs-on: macos-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
python-version: "3.13"

- name: Install dependencies
run: make install

- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22

- name: Install sops and age
run: brew install sops age

# Authenticates nix's github: flake-input fetches against the shared,
# rate-limited runner IP pool (see _nix_extra_access_tokens_args()).
- name: Nix build integration tests
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: make test-nix

# Job-level scoping, not workflow-level `on.pull_request.paths` — a
# workflow-level filter leaves required checks stuck "Waiting for status"
# on PRs that don't touch these paths; a job-level skip reports success.
detect-changes:
if: github.event_name == 'pull_request'
runs-on: macos-latest
outputs:
vm-relevant: ${{ steps.check.outputs.vm-relevant }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

- name: Detect VM-relevant changes
id: check
run: |
git fetch origin "${{ github.base_ref }}" --depth=1
changed=$(git diff --name-only "origin/${{ github.base_ref }}" HEAD)
echo "$changed"
if echo "$changed" | grep -qE '^(src/mac2nix/generators/|src/mac2nix/templates/|src/mac2nix/vm/|tests/generators/|tests/vm/|tests/vm_fixtures\.py)'; then
echo "vm-relevant=true" >> "$GITHUB_OUTPUT"
else
echo "vm-relevant=false" >> "$GITHUB_OUTPUT"
fi

# Invasive (applies a real nix-darwin switch to the runner) and gated:
# only runs when detect-changes flags VM/generator-relevant paths, and
# even then needs a maintainer's approval via the vm-validated environment.
#
# Runs natively, not via Tart, because nested macOS virtualization is
# unsupported on GitHub-hosted runners (see
# test_scaffold_switch_native.py's own docstring for the full rationale
# and safety gating) — safe here only because the runner itself is
# disposable. test_integration.py's VM-control-layer tests still can't
# run here regardless (they need an actual VM boot) and remain local-only
# via `make test-integration`.
nix-darwin-switch:
needs: detect-changes
if: needs.detect-changes.outputs.vm-relevant == 'true'
runs-on: macos-latest
environment: vm-validated
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
Expand All @@ -44,15 +107,34 @@ jobs:
- name: Install dependencies
run: make install

- name: Install tart
run: brew install cirruslabs/cli/tart
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22

- name: Install sshpass
# Via Nix, not Homebrew, so these survive the Homebrew removal below —
# add_host() needs sops/age-keygen on PATH regardless of Homebrew's state.
- name: Install sops and age via nix
run: nix profile install nixpkgs#sops nixpkgs#age

# nix-darwin refuses to overwrite /etc files it doesn't manage with
# unrecognized content — the nix-installer-action writes its own
# nix.custom.conf, which nix-darwin then wants to own. A real user
# migrating an existing Nix install would hit this identically.
- name: Move pre-existing /etc/nix/nix.custom.conf out of nix-darwin's way
run: |
brew tap hudochenkov/sshpass
brew install sshpass
if [ -f /etc/nix/nix.custom.conf ]; then
sudo mv /etc/nix/nix.custom.conf /etc/nix/nix.custom.conf.before-nix-darwin
fi

# nix-homebrew's autoMigrate can't cleanly adopt this runner's heavily
# provisioned Homebrew (real content across many non-default taps) —
# migration cascaded through conflict after conflict. Wiping it first
# lets nix-homebrew do a normal fresh install instead. autoMigrate
# stays true in the template — real migration targets need it.
- name: Remove pre-existing Homebrew installation
run: sudo rm -rf /opt/homebrew

- name: Integration tests
# Same GITHUB_TOKEN reasoning as nix-integration — sudo's env-stripping
# means this is threaded through as an explicit nix CLI arg, not NIX_CONFIG.
- name: Real nix-darwin switch (applied to this disposable runner)
env:
MAC2NIX_BASE_VM: macos-tahoe-base
run: make test-integration
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: make test-nix-darwin-switch
39 changes: 36 additions & 3 deletions 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-integration test-quick clean all prek-install prek
.PHONY: install lint format typecheck test test-integration test-vm test-nix test-nix-darwin-switch test-op-cli prewarm-vm pull-base-vm test-quick clean all prek-install prek

install:
uv sync
Expand All @@ -18,10 +18,43 @@ typecheck:
test:
uv run pytest

test-integration:
tart list | grep -q "$${MAC2NIX_BASE_VM:-macos-tahoe-base}" || tart pull ghcr.io/cirruslabs/macos-tahoe-base@sha256:a8e1c8305758643f513fdccdd829c2243687c60791083dea42f73f0b7aeb435c # latest
# Pull-if-missing, shared by test-integration/test-vm rather than each duplicating
# the pinned digest. Uses mac2nix.vm.manager.pull_base_image_if_missing() (exact-name
# match + `tart clone <ref> <name>`) rather than a plain-text `tart list | grep`, which
# false-positives: an OCI pull caches under its full registry/repo@digest string, which
# contains "macos-tahoe-base" as a substring without actually being named that.
# Always operates on the one canonical pinned name/digest pair (its own defaults) —
# deliberately ignores MAC2NIX_BASE_VM, since passing only `name=` without a matching
# `image_ref=` would silently clone the unrelated pinned macos-tahoe-base image and
# tag it under whatever name MAC2NIX_BASE_VM names (e.g. mac2nix-nix-base), producing
# a VM that looks prewarmed by name but has no Nix installed. A prewarmed image is
# only ever created for real by `make prewarm-vm`; this target's only job is to
# guarantee the fallback pinned image exists.
pull-base-vm:
uv run python -c "import asyncio; from mac2nix.vm.manager import pull_base_image_if_missing; asyncio.run(pull_base_image_if_missing())"

test-integration: pull-base-vm
uv run pytest -m integration --tb=long

test-vm: pull-base-vm
uv run pytest -m nix_vm --tb=long

test-nix:
uv run pytest -m nix_build --tb=long

# CI-only — skips unless GITHUB_ACTIONS=true (see the test module's own docstring).
# Never invoke this on a real machine; it applies a genuine nix-darwin switch.
test-nix-darwin-switch:
uv run pytest -m nix_darwin_switch --tb=long

# Requires a real, signed-in `op` CLI and MAC2NIX_TEST_OP_VAULT set to a disposable
# test vault — skips otherwise (see tests/test_onepassword.py's op_test_vault fixture).
test-op-cli:
uv run pytest -m op_cli --tb=long

prewarm-vm:
uv run python scripts/prewarm_vm.py

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

Expand Down
10 changes: 8 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,23 @@ include = ["src"]
exclude = ["**/__pycache__"]

[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
pythonpath = ["src", "scripts"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
addopts = [
"--strict-markers",
"--tb=short",
"-m", "not integration",
"-m", "not integration and not nix_vm and not nix_build and not nix_darwin_switch and not op_cli",
]
markers = [
"integration: real VM tests — require tart + sshpass + base VM image",
"nix: nix-instantiate syntax validation — require Nix on PATH",
"nix_vm: real VM-based apply-and-verify tests — require tart",
"nix_build: real nix flake lock/build tests — never skipped, require nix + age + sops + network",
"nix_darwin_switch: real nix-darwin switch applied to the running machine — CI-only, skips unless GITHUB_ACTIONS=true",
"op_cli: real 1Password CLI integration — requires op on PATH, signed in, and MAC2NIX_TEST_OP_VAULT set",
]
cache_dir = ".cache/pytest"

Expand Down
107 changes: 107 additions & 0 deletions scripts/prewarm_vm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Pre-warm a Nix-enabled Tart base image for fast local VM-based validation.

Every ``Validator.validate()`` run installs Nix from scratch inside a fresh
VM clone — correct, but slow when repeated often during local development.
This script bakes a customized, persistent base image once: pull the pinned
``macos-tahoe-base`` image if missing, clone it into ``mac2nix-nix-base``,
install Nix inside it, then stop (without deleting). Any later clone of
``mac2nix-nix-base`` already has Nix installed, and
``Validator._bootstrap_nix_darwin()``'s own idempotency check skips
reinstalling it.

Rerun this whenever you want to refresh the baked-in Nix version.

Usage: ``uv run python scripts/prewarm_vm.py`` (or ``make prewarm-vm``).
"""

from __future__ import annotations

import asyncio
import logging
import sys

from mac2nix.vm._utils import VMError
from mac2nix.vm.manager import BASE_IMAGE_NAME, BASE_IMAGE_REF, TartVMManager, pull_base_image_if_missing
from mac2nix.vm.validator import NIX_INSTALLER_URL

logger = logging.getLogger(__name__)

PREWARMED_VM_NAME = "mac2nix-nix-base"


async def _install_nix(vm: TartVMManager) -> None:
"""Run the same Nix-install steps as Validator._bootstrap_nix_darwin().

Reuses NIX_INSTALLER_URL from validator.py rather than a second copy, but
calls exec_command() directly (this is a one-shot bake, not the
per-validate-run path Validator itself owns).
"""
installer_path = "/tmp/nix-installer.sh" # noqa: S108

logger.info("Downloading Nix installer...")
# Two things collide here, verified empirically against a real VM: (1)
# curl on this VM's own build rejects the combined "--proto=https" form
# outright ("option --proto=https: is unknown"); (2) the separate-argv
# form ["--proto", "=https"] hits the VM's remote login shell (zsh),
# which treats a bare leading-"=" word as its own command-path-expansion
# syntax and fails with "https not found" before curl ever runs.
# Wrapping in `bash -c` with `=https` single-quoted sidesteps both: bash
# has no equals-expansion, and curl accepts the quoted, space-separated
# two-word form.
download_cmd = f"curl --proto '=https' --tlsv1.2 -sSf -L {NIX_INSTALLER_URL} -o {installer_path}"
ok, _out, err = await vm.exec_command(["bash", "-c", download_cmd], timeout=60)
if not ok:
raise VMError(f"Failed to download Nix installer: {err.strip()}")

ok, _out, err = await vm.exec_command(["chmod", "+x", installer_path])
if not ok:
raise VMError(f"chmod +x nix-installer.sh failed: {err.strip()}")

logger.info("Installing Nix (this can take a few minutes)...")
ok, _out, err = await vm.exec_command([installer_path, "install", "--no-confirm"], timeout=300)
if not ok:
raise VMError(f"Nix installation failed: {err.strip()}")

logger.info("Nix installed successfully")


async def _prewarm() -> None:
if not TartVMManager.is_available():
raise VMError("tart CLI is not available — install tart to prewarm a VM")

await pull_base_image_if_missing(name=BASE_IMAGE_NAME, image_ref=BASE_IMAGE_REF)

logger.info("Cloning %r -> %r", BASE_IMAGE_NAME, PREWARMED_VM_NAME)
vm = TartVMManager(BASE_IMAGE_NAME)
try:
await vm.clone(PREWARMED_VM_NAME)
await vm.start()
except VMError:
await vm.cleanup()
raise
try:
await _install_nix(vm)
finally:
# Deliberately not deleted — the whole point is a persistent, reusable
# disk other clones can be made from.
await vm.stop()

logger.info("%r is ready. Pass --base-vm %s to skip the Nix install wait.", PREWARMED_VM_NAME, PREWARMED_VM_NAME)


def main() -> int:
# Configured here, not at module scope — this module is imported by
# tests/test_prewarm_vm.py (via pythonpath), and a module-scope
# basicConfig() would mutate the root logger for the whole pytest
# process the moment that test module is collected.
logging.basicConfig(level=logging.INFO, format="%(message)s")
try:
asyncio.run(_prewarm())
except VMError as exc:
logger.error("prewarm failed: %s", exc)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading