Skip to content

feat(cli): adds init/add-host scaffold with sops-nix safety gates - #24

Merged
wgordon17 merged 33 commits into
gordon-code:mainfrom
wgordon17:feat/migration-mvp-pr1
Aug 8, 2026
Merged

feat(cli): adds init/add-host scaffold with sops-nix safety gates#24
wgordon17 merged 33 commits into
gordon-code:mainfrom
wgordon17:feat/migration-mvp-pr1

Conversation

@wgordon17

@wgordon17 wgordon17 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds 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 flake.nix/.sops.yaml regeneration)
  • Adds a shared Nix value-rendering module and real nix build/VM-based nix-darwin switch integration tests proving the scaffold actually evaluates, builds, and activates
  • Fixes a latent idempotency/networking bug in the pre-existing VM Validator and adds matching CI jobs (nix-integration, vm-integration)

@wgordon17

Copy link
Copy Markdown
Member Author

UAT — implementer-executed, per plan Task 4

Two deliberate deviations from the plan's literal UAT script, both necessary:

  1. Age key path redirected to a scratch directory via monkeypatch for this run only. add_host() has no CLI-level override for the key path, and the plan's placeholder --username uat-user has no real corresponding macOS account (/Users is root:admin 755, so a fake username can't create its own home directory). This is explicitly the plan's manual UAT runbook (distinct from the automated nix_build/nix_vm test suite already in this PR, which covers repeatable CI verification) — so it doesn't need to write permanent, real cryptographic key material to a real developer machine for a throwaway host. Still fully real end-to-end: real age-keygen, real sops, real nix build. A real end user's default key path is /Users/<username>/.config/sops/age/keys.txt, unaffected by this redirect.
  2. Second host uses --username uat-user-2, not the plan's literal uat-user. Reusing a username for a second host hits the documented-by-design age-key-collision abort (the key is scoped to username alone, verified during PR1's own component review) — the plan's own UAT script text reuses uat-user for both hosts, which is inconsistent with that already-confirmed design. Using a distinct username here instead.

A real bug was found and fixed during this UAT run: a relative output_dir (e.g. hack/tmp/mac2nix-pr1-uat) broke add_host()'s sops subprocess call (a doubled relative path). Fixed with output_dir = output_dir.resolve() as the first statement in add_host(). No existing test caught this because every test uses pytest's tmp_path fixture, which is always absolute already.

Step 1: mac2nix init

$ uv run mac2nix init hack/tmp/mac2nix-pr1-uat
Framework scaffolded at hack/tmp/mac2nix-pr1-uat
Next: mac2nix add-host --hostname <name> [--username <name>] to register a machine.

Step 2: mac2nix add-host --hostname uat-host --username uat-user

Public key fingerprint: age12us325x4kp3c9kluymkzypd07zwdgvewugjchjnhx0uf3cl5nvzsuwxqxw
Type CONFIRMED once the private key has been backed up to a password manager []: CONFIRMED
Host 'uat-host' registered (age key fingerprint: age12us325x4kp3c9kluymkzypd07zwdgvewugjchjnhx0uf3cl5nvzsuwxqxw).
Age key stored at /Users/uat-user/.config/sops/age/keys.txt — make sure it's backed up somewhere safe.
Next: run `nix flake lock` inside hack/tmp/mac2nix-pr1-uat before the first build for this host.
Reminder: push this repo as a PRIVATE GitHub repo — scan-derived configuration isn't vetted for public-repo exposure the way sops-encrypted secrets are.

Step 3: nix flake lock

$ nix flake lock
warning: Git tree '.../mac2nix/hack/tmp/mac2nix-pr1-uat' has uncommitted changes
unpacking 'https://flakehub.com/f/DeterminateSystems/determinate/3' into the Git cache...
unpacking 'github:nix-community/home-manager/e8827fbbb12015a8dd9f66285aec79d655bcb9f6' into the Git cache...
unpacking 'github:homebrew/homebrew-cask/0ff4aa3861b12664bdccb38c29f76340ee2a32ef' into the Git cache...
unpacking 'github:homebrew/homebrew-core/67aa86ae1b1f58b69a42f56ba4fcf64b5d1a767f' into the Git cache...
unpacking 'github:NixOS/nixpkgs/1559d3daa3ecc813a650b79375ea61b6741b8746' into the Git cache...
warning: creating lock file ".../mac2nix-pr1-uat/flake.lock":
• Added input 'darwin':
    'github:nix-darwin/nix-darwin/15abb8c98f336cd8bd840d71059adebabe60bf04?narHash=sha256-0tLW8Ff5yt8AH97jw4ZpFJ0OCJ122zIlgWGDmOfU/VU%3D' (2026-07-30)
• Added input 'darwin/nixpkgs': follows 'nixpkgs'
• Added input 'determinate':
    'https://api.flakehub.com/f/pinned/DeterminateSystems/determinate/3.21.9/019fb411-e801-74a0-8c53-b41643853e87/source.tar.gz?narHash=sha256-IrqJV%2B9NcFevwyBBqEkxkbqhX3rtJI7sSzHz5wDVaLo%3D' (2026-07-30)
• Added input 'home-manager':
    'github:nix-community/home-manager/e8827fbbb12015a8dd9f66285aec79d655bcb9f6?narHash=sha256-EASdusMYLuPhOCrE3LmsAGOvGhX3vnKBLxFvj9lI8tU%3D' (2026-07-31)
• Added input 'home-manager/nixpkgs': follows 'nixpkgs'
• Added input 'homebrew-cask':
    'github:homebrew/homebrew-cask/0ff4aa3861b12664bdccb38c29f76340ee2a32ef?narHash=sha256-Sh2Nzof51YH4LO5ZRGq6PIo/A0QbPsmo0p6/lZkacvg%3D' (2026-07-31)
• Added input 'homebrew-core':
    'github:homebrew/homebrew-core/67aa86ae1b1f58b69a42f56ba4fcf64b5d1a767f?narHash=sha256-mjVbC4AjzpOZPhNElWv4AQBSJEWlKbLQVyrBRdQykrY%3D' (2026-07-31)
• Added input 'mac-app-util':
    'github:hraban/mac-app-util/039f33deef21782d4db97087f426504951239887?narHash=sha256-7IteXipJZfFepi5zakxe0jQ5i3vRBQguk0%2BGtte1Fu4%3D' (2026-07-04)
• Added input 'nix-homebrew':
    'github:zhaofengli/nix-homebrew/60623ec512406261f553d24033c8a0c53fd0b7f2?narHash=sha256-2v0H8%2BErt%2Bxbm0oliHblXTPPczuKiibBUBvRax59kxg%3D' (2026-07-24)
• Added input 'nixpkgs':
    'github:NixOS/nixpkgs/1559d3daa3ecc813a650b79375ea61b6741b8746?narHash=sha256-LQy14TZp77TwbQf40gg1V3jo8FwJG0jGDkAH%2BzRHqg8%3D' (2026-07-30)
• Added input 'sops-nix':
    'github:Mic92/sops-nix/f1406619a3884cd5c47992a70b8b35c9c0fcb4c9?narHash=sha256-aCWC8ngycU7OdJrU2%2BJe3qf%2B1a2ykuBvpPhZT/9tXMc%3D' (2026-07-04)
• Added input 'sops-nix/nixpkgs': follows 'nixpkgs'
(plus transitive inputs — mac-app-util/cl-nix-lite/*, determinate/nix/*, etc. — full set resolved cleanly)

Step 4: nix flake check

$ nix flake check
warning: Git tree '.../mac2nix-pr1-uat' has uncommitted changes
evaluating flake...
checking flake output 'darwinConfigurations'...

(exit 0, no errors)

Step 5: nix build .#darwinConfigurations.uat-host.system --no-link

$ nix build .#darwinConfigurations.uat-host.system --no-link --print-out-paths
warning: Git tree '.../mac2nix-pr1-uat' has uncommitted changes
copying path '/nix/store/lj0fr41wwbrx2mwq2378jg8qrns7b93d-source' from 'https://cache.nixos.org'...
these 29 derivations will be built: [home-manager files/fonts/applications, activation-script, org.nixos.activate-system.plist, launchd, setup-homebrew, darwin-system-26.11.15abb8c, etc.]
building '/nix/store/djanyh381hqbqx00alglfs2r03605vqi-darwin-version.json.drv'...
[... all 29 build successfully, no errors ...]
/nix/store/5qhy7x2iqld809dazv58ah8pc3kazbqp-darwin-system-26.11.15abb8c

(exit 0; --print-out-paths added for an unambiguous success signal beyond "no error printed")

Step 6: cat flake.nix and cat .sops.yaml

flake.nix:

{
  description = "mac2nix-managed nix-darwin configuration";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

    darwin = {
      url = "github:nix-darwin/nix-darwin";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    nix-homebrew.url = "github:zhaofengli/nix-homebrew";

    homebrew-core = {
      url = "github:homebrew/homebrew-core";
      flake = false;
    };

    homebrew-cask = {
      url = "github:homebrew/homebrew-cask";
      flake = false;
    };

    mac-app-util.url = "github:hraban/mac-app-util";

    determinate.url = "https://flakehub.com/f/DeterminateSystems/determinate/3";

    sops-nix = {
      url = "github:Mic92/sops-nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = inputs:
    let
      mkDarwinSystem = import ./lib/helpers.nix { inherit inputs; };
    in
    {
      darwinConfigurations = {
        # MAC2NIX:HOSTS:BEGIN -- generated by `mac2nix add-host`; do not edit by hand
    "uat-host" = mkDarwinSystem { hostname = "uat-host"; system = "aarch64-darwin"; users = [ "uat-user" ]; };
        # MAC2NIX:HOSTS:END
      };
    };
}

.sops.yaml:

creation_rules:
- path_regex: secrets/uat\-host\.yaml$
  key_groups:
  - age:
    - age12us325x4kp3c9kluymkzypd07zwdgvewugjchjnhx0uf3cl5nvzsuwxqxw

Step 7: Register a second host, re-verify both

$ uv run mac2nix add-host hack/tmp/mac2nix-pr1-uat --hostname uat-host-2 --username uat-user-2
Public key fingerprint: age1qsmsglug2tyzr7vrjht0zx235qwu988wlv0lcr8vmhacd7wat40q9jza5e
Type CONFIRMED once the private key has been backed up to a password manager []: CONFIRMED
Host 'uat-host-2' registered (age key fingerprint: age1qsmsglug2tyzr7vrjht0zx235qwu988wlv0lcr8vmhacd7wat40q9jza5e).
Age key stored at /Users/uat-user-2/.config/sops/age/keys.txt — make sure it's backed up somewhere safe.
Next: run `nix flake lock` inside hack/tmp/mac2nix-pr1-uat before the first build for this host.
Reminder: push this repo as a PRIVATE GitHub repo — scan-derived configuration isn't vetted for public-repo exposure the way sops-encrypted secrets are.

$ nix flake lock
warning: Git tree '.../mac2nix-pr1-uat' has uncommitted changes
(no new inputs added — lock file already current, only darwinConfigurations output changed)

$ nix flake check
warning: Git tree '.../mac2nix-pr1-uat' has uncommitted changes
evaluating flake...
checking flake output 'darwinConfigurations'...
(exit 0)

$ nix build .#darwinConfigurations.uat-host-2.system --no-link --print-out-paths
(18 derivations built cleanly)
/nix/store/0l95qijf8pw7148jg3sjxhaqrklhl33q-darwin-system-26.11.15abb8c

$ nix build .#darwinConfigurations.uat-host.system --no-link --print-out-paths
(re-verify uat-host — identical output store path to Step 5, confirming zero disturbance)
/nix/store/5qhy7x2iqld809dazv58ah8pc3kazbqp-darwin-system-26.11.15abb8c

The identical content-addressed store path for uat-host before and after registering uat-host-2 is a strong, direct proof the second registration didn't disturb the first at all.

Step 8: make test-vm

$ make prewarm-vm
Cloning 'macos-tahoe-base' -> 'mac2nix-nix-base'
Downloading Nix installer...
SSH disconnect detected for 'mac2nix-nix-base' — retrying once (timeout=120s)
Retry also failed for 'mac2nix-nix-base': curl: (6) Could not resolve host: install.determinate.systems
prewarm failed: Failed to download Nix installer: curl: (6) Could not resolve host: install.determinate.systems

$ make test-vm
collected 1643 items / 1640 deselected / 3 selected
tests/vm/test_scaffold_vm.py F                                           [ 33%]
tests/vm/test_vm_fixtures.py ..                                          [100%]
=================================== FAILURES ===================================
test_scaffold_switches_for_real:
  ...
  E   mac2nix.vm._utils.VMError: Failed to download Nix installer: curl: (6) Could not resolve host: install.determinate.systems
1 failed, 2 passed, 1640 deselected in 93.26s

This is a known, root-caused host-level limitation on the specific dev machine this PR was implemented on — a local homebrew dnsmasq process conflicting with Tart's VM-to-host DNS forwarding gateway (192.168.64.1:53 not resolving external hosts from inside the VM), unrelated to mac2nix's own code. Diagnosed via: raw-IP ping succeeds inside the VM, dig via the gateway hangs, dig @1.1.1.1 (bypassing the gateway) succeeds instantly. tests/vm/test_vm_fixtures.py's 2 tests (no real network needed) passed cleanly, and the fixture's teardown correctly cleaned up both VM clones despite the failure (confirmed via tart list afterward). The real nix-integration CI job (unconditional on every PR) and vm-integration CI job (gated, on approval) will validate this for real on unrestricted GitHub-hosted runners.

Step 9: Cleanup

$ rm -rf hack/tmp/mac2nix-pr1-uat hack/tmp/uat-age-keys
$ git status --short

Confirmed clean — only the expected PR1 file set remains, no UAT residue.


Note for whoever merges this PR (per the plan's own call-outs): the repository's branch-protection required-status-checks list needs the new nix-integration job added manually via GitHub repo settings, and a vm-validated GitHub Environment with a required reviewer needs to be created once for the vm-integration job's approval gate — both are one-time repo-settings changes outside this PR's file scope.

@wgordon17
wgordon17 marked this pull request as ready for review August 2, 2026 17:46
@wgordon17
wgordon17 requested a review from a team August 2, 2026 17:46
@wgordon17

wgordon17 commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

UAT — full end-to-end, reworked add-host flow + real VM switch

Age key path redirected to a scratch directory for this run only (add_host() has no CLI-level override, and the plan's placeholder username has no real corresponding macOS account) — otherwise fully real end-to-end: real age-keygen, real sops, real op, real nix, real Tart VM.

Step 1: mac2nix init

$ uv run mac2nix init <dir>
Framework scaffolded at <dir>
Next: mac2nix add-host --hostname <name> [--username <name>] to register a machine.

Step 2: mac2nix add-host --hostname uat-host --username uat-user --op-vault Private

$ uv run mac2nix add-host <dir> --hostname uat-host --username uat-user --op-vault Private
1Password backup failed (op document create failed: [ERROR] authorization timeout) — falling back to manual confirmation.
Have you backed up the private key? [y/N]: y
Host 'uat-host' registered (age key fingerprint: age1q5rau9znhqk3yzrjwa52lclk36c52c2zn5tu88jkswqulccupphs3dzv23).
Age key stored at /Users/uat-user/.config/sops/age/keys.txt — make sure it's backed up somewhere safe.
Register another host now? [y/N]: n
Run `nix flake lock` now? [Y/n]: y

Notes on this run:

  • A successful --op-vault backup skips the manual confirmation entirely (verified in tests/test_onepassword.py); this transcript shows the fallback path since no attended 1Password authorization was available in this run.
  • Caught and fixed during this exact UAT: op document create --format json's real response uses a uuid field, not id — a mock-based test could only ever assert its own guess at that schema, so this needed a real op call to surface. The success message also now reports the item's human-readable title (e.g. 'mac2nix age key — uat-host/uat-user') instead of that raw uuid, since a uuid isn't something you can actually search for in the 1Password app.
  • The backup prompt now reprompts on "n" instead of aborting and discarding the just-generated key, no longer mentions "password manager" specifically, and the fingerprint is reported once (in the final "registered" line) instead of twice.
  • nix flake lock now defaults to yes ([Y/n]) — it's non-destructive and the natural next step after registering a host.

Step 3: nix flake lock (triggered above)

warning: creating lock file "<dir>/flake.lock":
• Added input 'darwin': 'github:nix-darwin/nix-darwin/...'
• Added input 'determinate': 'https://api.flakehub.com/f/pinned/DeterminateSystems/determinate/3.21.9/...'
• Added input 'home-manager': 'github:nix-community/home-manager/...'
• Added input 'homebrew-cask': 'github:homebrew/homebrew-cask/...'
• Added input 'homebrew-core': 'github:homebrew/homebrew-core/...'
• Added input 'mac-app-util': 'github:hraban/mac-app-util/...'
• Added input 'nix-homebrew': 'github:zhaofengli/nix-homebrew/...'
• Added input 'nixpkgs': 'github:NixOS/nixpkgs/...'
• Added input 'sops-nix': 'github:Mic92/sops-nix/...'
(plus transitive inputs — resolved cleanly)

Step 4: nix flake check

$ nix flake check
evaluating flake...
checking flake output 'darwinConfigurations'...

(exit 0, no errors)

Step 5: nix build .#darwinConfigurations.uat-host.system --no-link

$ nix build .#darwinConfigurations.uat-host.system --no-link --print-out-paths
(29 derivations built cleanly)
/nix/store/ibp3acf38fwrkdz1dpxf9q8wkbmhilwx-darwin-system-26.11.15abb8c

Step 6: Register a second host, re-verify both

Register another host now? [y/N]: y
Hostname: uat-host-2
Username [uat-user]: uat-user-2
System (aarch64-darwin, x86_64-darwin) [aarch64-darwin]: aarch64-darwin
1Password backup failed (op document create failed: [ERROR] authorization timeout) — falling back to manual confirmation.
Have you backed up the private key? [y/N]: y
Host 'uat-host-2' registered (age key fingerprint: age1chr9vzqvq7g6dc830hjm7j78s0jer2cm64pqw4rwrt9pmlq29ums9d6rg0).
Register another host now? [y/N]: n
$ nix build .#darwinConfigurations.uat-host-2.system --no-link --print-out-paths
(18 derivations built cleanly)
/nix/store/zh77jl62z9h4nm6zfdpd97b9jhsnjn54-darwin-system-26.11.15abb8c

$ nix build .#darwinConfigurations.uat-host.system --no-link --print-out-paths
(re-verify uat-host — identical output store path to Step 5)
/nix/store/ibp3acf38fwrkdz1dpxf9q8wkbmhilwx-darwin-system-26.11.15abb8c

Identical content-addressed store path for uat-host before and after registering uat-host-2 — direct proof the second registration didn't disturb the first.

Step 7: make test-vm

$ make test-vm
tests/vm/test_scaffold_vm.py .                                           [ 33%]
tests/vm/test_vm_fixtures.py ..                                          [100%]
================ 3 passed, 1664 deselected in 547.27s (0:09:07) ================

All 3 pass, including a real nix run nix-darwin -- switch --flake .#uat-host end-to-end inside a fresh Tart VM — full activation (networking, firewall, power, fonts, nvram, Homebrew, launchd services) with no manual intervention.

Getting there required a few real fixes, landed as their own commits:

  • Guest DNS resolution inside the VM was unreliable — the guest now gets pointed at a public resolver directly during boot.
  • SSH authentication could exhaust the remote's retry limit before ever trying the actual password — auth is now pinned to password-only.
  • The real nix-darwin switch needs root and a couple of one-time environment preconditions (an existing nix.custom.conf, a pre-existing Homebrew install) handled the same way the CI nix-darwin-switch job already does.
  • VM readiness now requires two consecutive successful SSH checks, not just one, before handing control to the caller.

CI status

All 4 checks green on the latest push (lint-and-test, nix-integration, detect-changes, nix-darwin-switch) — including a real, passing nix-darwin-switch run on a GitHub-hosted runner, confirming the fixes above hold outside this local environment too.

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().
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.
@wgordon17
wgordon17 deployed to vm-validated August 8, 2026 15:15 — with GitHub Actions Active
@wgordon17
wgordon17 merged commit d002fdc into gordon-code:main Aug 8, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant