feat(cli): adds init/add-host scaffold with sops-nix safety gates - #24
Conversation
UAT — implementer-executed, per plan Task 4Two deliberate deviations from the plan's literal UAT script, both necessary:
A real bug was found and fixed during this UAT run: a relative Step 1:
|
UAT — full end-to-end, reworked
|
Implements the foundational scaffold PR of the migration-mvp plan: a shared Nix value-rendering module (python_to_nix/nix_string/mkdefault, custom-delimiter Jinja2 environment) plus mac2nix init (host-less nix-darwin+home-manager+sops-nix framework scaffold) and mac2nix add-host (per-host registration with its own sops-nix age key, mandatory backup confirmation, and wholesale regeneration of flake.nix and .sops.yaml from on-disk metadata). Adds real nix build and VM-based nix-darwin switch integration tests, a shared Tart VM pytest fixture, an idempotent Nix bootstrap in the pre-existing Validator, and matching CI jobs.
lint-and-test's CI job never installed age (only nix-integration and vm-integration do), so these tests failed there with "age-keygen is not available" — harmless before branch protection existed, but a real merge blocker now that lint-and-test is a required status check. Gates the class the same way TestAddHostSops already gates on sops/age together, via a narrower age-keygen-only check.
integration ran post-merge-only with no gate and no path-scoping — a holdover from before this PR's pre-merge validation tiers existed, not a deliberate design choice. Its tests (raw TartVMManager lifecycle, FileSystemComparator diffing) are already covered by detect-changes's path filter (src/mac2nix/vm/, tests/vm/), so folding them into vm-integration gets them the same pre-merge timing and approval gate as the new scaffold tests, without paying for a second job's tart/sshpass/sops/age setup. Each suite still manages its own VM independently — this merges the job, not the fixtures.
Nested macOS virtualization is categorically unsupported on any GitHub-hosted runner — confirmed via GitHub's own docs, an Apple Virtualization Framework limitation, not a Tart-specific one. `tart run` exits immediately with no boot at all on macos-latest, so the vm-integration job (and the old integration job's tests, which it absorbed) could never have passed there. Renames the job to nix-darwin-switch and applies the generated config directly to the runner itself instead of inside a VM — safe only because the runner is already fully disposable. Adds nix_darwin_switch, a new pytest marker gated to skip unless GITHUB_ACTIONS=true, so it can never run against a real developer machine by accident. test_integration.py's TartVMManager-lifecycle/FileSystemComparator tests test the VM-control layer itself and so can never run on any GitHub-hosted runner regardless of this fix — they stay a local-only check (make test-integration). Also sets nix-homebrew.autoMigrate = true in the scaffold template: without it, nix-homebrew expects to own an empty Homebrew prefix and fails against the runner's already-populated one — but this is also the objectively correct setting independent of CI, since mac2nix's whole premise is migrating a Mac that's already running Homebrew.
nix run nix-darwin -- switch failed with "API rate limit exceeded" resolving github:nix-darwin/nix-darwin — Nix's own flake-input fetching hits GitHub's REST API unauthenticated by default, capped at 60/hr per egress IP, shared across every unrelated customer on the same NAT'd GitHub Actions IP pool, not scoped to our own traffic. Adds a shared _nix_extra_access_tokens_args() helper that threads GITHUB_TOKEN through as an explicit --extra-access-tokens CLI argument, used by both nix-integration and nix-darwin-switch's real nix invocations. Passed as a literal argument rather than via NIX_CONFIG: sudo strips the calling environment by default, and multi-user/daemon Nix installs don't reliably forward client-side NIX_CONFIG to the daemon's own fetches — a CLI flag applies to the invoked process regardless of either concern. Fails open (empty arg list) if GITHUB_TOKEN isn't set, matching this project's other real-network tests.
Two more real bugs surfaced only once the DNS and SSH-auth fixes let a real VM run get far enough to hit them. test_scaffold_vm.py's plain `nix run nix-darwin -- switch` failed with "system activation must now be run as root" — sudos it (matching the same pattern already used in CI's native-switch test), resolving nix's absolute path via `command -v` in the profile-sourced shell first since sudo's own secure_path won't include wherever the nix-daemon profile put it on PATH. TartVMManager._ensure_dns_resolves() also got "Permission denied" once, moments after wait_ready()'s own SSH check had just succeeded with the same credentials on a freshly-booted VM. wait_ready() already tolerates exactly this class of boot-timing flakiness via retries; a single-shot DNS-setup call right after it was throwing that resilience away for one more mandatory SSH round-trip. Now retries the same way.
The real switch got much further this time (building the full 126-drv darwin system, not just darwin-rebuild itself) but hit the same /etc/nix/nix.custom.conf ownership conflict already fixed in CI's nix-darwin-switch job: nix-darwin refuses to overwrite an /etc file it doesn't manage and finds with unrecognized content, and the Determinate Nix installer run by _bootstrap_nix_darwin() writes exactly that file. test_scaffold_vm.py never had the CI job's equivalent workaround since it's a separate test predating that fix. Same move-aside, same reason.
Got past the nix.custom.conf conflict and much further this time (networking/firewall/power/fonts/nvram all configured) but hit the exact same cascading Homebrew conflict already root-caused and fixed in CI's nix-darwin-switch job: macos-tahoe-base ships with a real, multi-tap Homebrew install that nix-homebrew's autoMigrate can't cleanly adopt (Library/Taps already exists, then a formula whose originating tap was just removed). Wiping it first lets nix-homebrew do a normal fresh install instead — nothing in this test needs Homebrew itself, Nix comes from _bootstrap_nix_darwin() independently.
`op whoami` has a known failure mode where it reports "not signed in" while the local vault is still genuinely readable/writable (confirmed against a real machine this session — session-state can desync from what `op` actually has access to). Pre-checking is_signed_in() before ever attempting the real `op document create` meant a stale whoami result could force an unnecessary manual-confirmation fallback even though the backup would have succeeded. Attempts the write directly now and surfaces its own error message instead — a more reliable signal than a separate pre-flight check that can itself be wrong. is_signed_in() stays available as its own function, just no longer used to gate store_age_key().
d61edef to
c14c513
Compare
Drops "to a password manager" from the backup-confirmation prompt — unnecessarily presumptuous about where the key ends up. Removes the early "Public key fingerprint" echo since the final "Host registered (age key fingerprint: ...)" line already reports it once; printing it twice in the same short run was noise, not confirmation. Defaults the "Run `nix flake lock` now?" prompt to yes — locking isn't destructive and is the natural next step after registering a host, so a bare Enter should do it rather than silently skip it.
Caught by an actual `op document create --format json` invocation: the
real response is `{"uuid": ..., "createdAt": ..., "updatedAt": ...,
"vaultUuid": ...}` — no "id" field at all, despite "id" being what
`op item ...` commands use elsewhere. store_age_key() was parsing the
wrong key, so every real backup attempt that got past authentication
would have failed at this step. A mock-based test can only ever verify
its own guess at a schema; this needed a real op call to surface.
The success message printed a raw item uuid ("item 3xem4ns7v..."),
which isn't something you can search for in the 1Password app — the
title is. Report the human-readable title (already constructed for
the op document create call itself) instead.
pr-checks.yaml had grown to ~40% comments, much of it restating detail already covered in the referenced test files' own docstrings. Condenses every block to its essential non-obvious fact and drops the two "NOTE FOR WHOEVER MERGES THIS PR" comments outright — both described manual setup (required-status-checks, the vm-validated environment) that's already declared and applied via khepri/tofu (gordon-code/mac2nix.tf), confirmed live by this session's own repeated successful use of the vm-validated environment's approval gate.
helpers.nix derived system.primaryUser via `builtins.elemAt users 0`, silently trusting list order even though `users` is really scoped to home-manager account generation and could grow past one entry via a hand-edit. Python already knows the single canonical username per host from .mac2nix-meta.json -- the same value used for `users` itself and for configuration.nix's __USERNAME__ substitution -- so add-host now emits it as its own explicit primaryUser field instead of making Nix re-derive it positionally.
Also adds an opt-in real-op-CLI integration test tier (op_cli-marked, gated on MAC2NIX_TEST_OP_VAULT) and switches remaining ad-hoc mock objects to MagicMock, matching the rest of the test suite.
Also moves logging.basicConfig() out of module scope, since the new tests/test_prewarm_vm.py imports this module and a module-scope call would mutate the root logger for the whole pytest process.
Restricts collection to tests/ (previously unset, so pytest scanned the whole repo tree including gitignored scratch scripts) and adds scripts/ to pythonpath so tests/test_prewarm_vm.py can import it directly. Also registers the new op_cli marker and excludes it from the default run.
test_real_eof_at_trailing_prompts_defaults_and_completes used _redirect_age_keys, which still shells out to the real age-keygen binary — not installed on the lint-and-test CI runner (only nix-integration/nix-darwin-switch install it), so the test failed there while passing locally. This test verifies CLI prompt/exception handling, not crypto behavior, so it should never have needed a real binary in the first place; switches to the same generate_age_key/ _create_host_secrets_file mocking the rest of the unguarded suite uses.
Summary
mac2nix init(host-less nix-darwin+home-manager+sops-nix framework scaffold) andmac2nix add-host(per-host registration with its own sops-nix age key, mandatory backup confirmation, and wholesale flake.nix/.sops.yaml regeneration)nix build/VM-basednix-darwin switchintegration tests proving the scaffold actually evaluates, builds, and activatesValidatorand adds matching CI jobs (nix-integration,vm-integration)