diff --git a/AGENTS.md b/AGENTS.md index cda9e979..5ec9a1bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,10 +112,23 @@ nixup # NixOS: sudo nixos-rebuild switch --flake ~/repos/dots There is also `nixboot` on NixOS hosts to stage a change for the next reboot without switching immediately. -### Build here, deploy to a remote NixOS host +### Deploy to a remote host -`nixos-rebuild` is available natively on NixOS hosts but not on home-manager-only -or nix-darwin hosts. When deploying from one of those, bring it in via `nix shell`: +Preferred: [deploy-rs](https://github.com/serokell/deploy-rs), which handles +build/copy/activate and automatically rolls back if the new generation breaks +SSH connectivity. See the [Deploying](README.md#deploying) section of +`README.md` for full details, usage, and scope. + +```bash +nix run .#deploy-rs -- .# # build, copy, activate, confirm +nix run .#deploy-rs -- .# --dry-activate # preview without switching +nix run .#deploy-rs -- .# --skip-checks # required when running from mightymac (see README) +``` + +For hosts/scenarios outside deploy-rs's scope, or as a manual fallback, +`nixos-rebuild` can also be invoked directly - available natively on NixOS +hosts but not on home-manager-only or nix-darwin hosts, where it needs to be +brought in via `nix shell`: ```bash nix shell nixpkgs#nixos-rebuild -c nixos-rebuild switch \ diff --git a/README.md b/README.md index d90db1fc..7f13bdf7 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ This repo is a Nix flake that manages most of my setup on macOS and fully manages machines I have that run NixOS as their operating system. It also contains as much configruation as I can make work on other Linux distros such as Ubuntu. - [Flake structure](#flake-structure) +- [Deploying](#deploying) - [Formatting and CI](#formatting-and-ci) - [Historical bits](#historical-bits) - [Host Bootstrapping](#host-bootstrapping) @@ -38,6 +39,40 @@ This repo is a Nix flake that manages most of my setup on macOS and fully manage - `modules/hosts/darwin/` - macOS host configs - `modules/hosts/home-manager-only/` - Home Manager-only configs +## Deploying + +Local changes are applied with the `nixup`/`nixdiff` aliases (see `AGENTS.md` +for the exact commands they wrap). For deploying to a remote host without +SSHing in and running `nixup` by hand, use +[deploy-rs](https://github.com/serokell/deploy-rs): + +```bash +nix run .#deploy-rs -- .# # build, copy, activate, confirm +nix run .#deploy-rs -- .# --dry-activate # preview without switching +``` + +Every node in `deploy.nodes` (`flake.nix`) builds itself (`remoteBuild = +true`), so this can be run from any machine that can reach the target over +SSH - not just `mightymac`. Hostnames are resolved via Tailscale MagicDNS, so +no domain needs to be configured anywhere in this repo. + +deploy-rs's automatic rollback (magic-rollback) is the main reason to use it +over a manual `nixos-rebuild switch --target-host`: if the new generation +breaks SSH connectivity, it rolls back on its own - important for +`kiosk-entryway` and `kiosk-gene-desk`, which have no keyboard normally +attached and would otherwise need a physical visit to recover. + +`--skip-checks` is required when deploying from `mightymac` specifically: +deploy-rs's own pre-deploy `nix flake check` tries to build every system's +derivations locally regardless of any node's `remoteBuild` setting, and +mightymac can't build `x86_64-linux` at all (no local builder for it). Run +`nix flake check` separately beforehand instead - it validates the same +things without attempting to build anything locally that can't be. + +Scope: all NixOS hosts plus `mightymac`. `AirPuppet`/`Blue-Rock` and +home-manager-only hosts stay on their existing `darwin-rebuild`/`home-manager +switch` workflows. + ## Formatting and CI This repo uses the following tools for code quality: diff --git a/flake.lock b/flake.lock index 48a99948..165e6669 100644 --- a/flake.lock +++ b/flake.lock @@ -128,6 +128,28 @@ "type": "github" } }, + "deploy-rs": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "nixpkgs" + ], + "utils": "utils_2" + }, + "locked": { + "lastModified": 1781627888, + "narHash": "sha256-5yHuAh9k7rT7rtf3uRaXkiUyYvQE9oaCgzhprLm2mr8=", + "owner": "serokell", + "repo": "deploy-rs", + "rev": "6d3087eedff75a715b40c0e124ba15d2dd7bec28", + "type": "github" + }, + "original": { + "owner": "serokell", + "repo": "deploy-rs", + "type": "github" + } + }, "disko": { "inputs": { "nixpkgs": [ @@ -215,6 +237,22 @@ } }, "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-compat_2": { "flake": false, "locked": { "lastModified": 1696426674, @@ -230,7 +268,7 @@ "type": "github" } }, - "flake-compat_2": { + "flake-compat_3": { "flake": false, "locked": { "lastModified": 1746162366, @@ -246,7 +284,7 @@ "type": "github" } }, - "flake-compat_3": { + "flake-compat_4": { "locked": { "lastModified": 1767039857, "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", @@ -261,7 +299,7 @@ "type": "github" } }, - "flake-compat_4": { + "flake-compat_5": { "flake": false, "locked": { "lastModified": 1767039857, @@ -330,7 +368,7 @@ }, "flake-utils_3": { "inputs": { - "systems": "systems_3" + "systems": "systems_4" }, "locked": { "lastModified": 1731533236, @@ -724,7 +762,7 @@ }, "nixos-cosmic": { "inputs": { - "flake-compat": "flake-compat_2", + "flake-compat": "flake-compat_3", "nixpkgs": [ "nixpkgs" ], @@ -820,7 +858,7 @@ "nixos-raspberrypi": { "inputs": { "argononed": "argononed", - "flake-compat": "flake-compat_3", + "flake-compat": "flake-compat_4", "nixos-images": "nixos-images_2", "nixpkgs": "nixpkgs_4" }, @@ -1009,7 +1047,7 @@ }, "pre-commit-hooks": { "inputs": { - "flake-compat": "flake-compat", + "flake-compat": "flake-compat_2", "gitignore": "gitignore", "nixpkgs": [ "flox", @@ -1061,6 +1099,7 @@ "compose2nix": "compose2nix", "cup-collector": "cup-collector", "deadnix": "deadnix", + "deploy-rs": "deploy-rs", "disko": "disko", "flox": "flox", "genebean-neovim": "genebean-neovim", @@ -1145,7 +1184,7 @@ "simple-nixos-mailserver": { "inputs": { "blobs": "blobs", - "flake-compat": "flake-compat_4", + "flake-compat": "flake-compat_5", "git-hooks": "git-hooks", "nixpkgs": [ "nixpkgs" @@ -1252,6 +1291,21 @@ "type": "github" } }, + "systems_4": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "treefmt-nix": { "inputs": { "nixpkgs": "nixpkgs_2" @@ -1309,6 +1363,24 @@ "type": "github" } }, + "utils_2": { + "inputs": { + "systems": "systems_3" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "viscosity-cli": { "inputs": { "flake-utils": "flake-utils_3", diff --git a/flake.nix b/flake.nix index 90111197..a40cdd37 100644 --- a/flake.nix +++ b/flake.nix @@ -40,6 +40,12 @@ inputs.nixpkgs.follows = "nixpkgs"; }; + # Codified remote deploys (build/copy/activate + automatic rollback) + deploy-rs = { + url = "github:serokell/deploy-rs"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + flox = { url = "github:flox/flox/v1.4.4"; }; @@ -267,6 +273,62 @@ #}; }; # end nixosConfigurations + # Codified remote deploys (`nix run .#deploy-rs -- .#`) - see + # sshUser/profiles.system wiring in lib/mkDeployNode.nix. Every node + # builds itself (remoteBuild = true) so the deploy can be invoked + # from any machine that can reach the target over SSH, not just + # mightymac. `hostname` is used directly as the SSH target, resolved + # via Tailscale MagicDNS - deliberately not a hardcoded FQDN (e.g. + # private-flake's own config.private-flake.tailnetDomain), since a + # baked-in default read at flake-eval time wouldn't reflect a + # per-host override of that setting anyway. + deploy.nodes = { + bigboy = localLib.mkDeployNode { + hostname = "bigboy"; + system = "x86_64-linux"; + fastConnection = true; + remoteBuild = true; + }; + hetznix01 = localLib.mkDeployNode { + hostname = "hetznix01"; + system = "x86_64-linux"; + fastConnection = false; + remoteBuild = true; + }; + hetznix02 = localLib.mkDeployNode { + hostname = "hetznix02"; + system = "aarch64-linux"; + fastConnection = false; + remoteBuild = true; + }; + kiosk-entryway = localLib.mkDeployNode { + hostname = "kiosk-entryway"; + system = "x86_64-linux"; + fastConnection = true; + remoteBuild = true; + }; + kiosk-gene-desk = localLib.mkDeployNode { + hostname = "kiosk-gene-desk"; + system = "aarch64-linux"; + fastConnection = true; + remoteBuild = true; + }; + mightymac = localLib.mkDeployNode { + hostname = "mightymac"; + system = "aarch64-darwin"; + fastConnection = true; + remoteBuild = true; + darwin = true; + sshUser = "gene.liverman"; # matches mkDarwinHost's username override for this host below + }; + nixnuc = localLib.mkDeployNode { + hostname = "nixnuc"; + system = "x86_64-linux"; + fastConnection = true; + remoteBuild = true; + }; + }; # end deploy.nodes + # Home Manager (only) users homeConfigurations = { gene-x86_64-linux = localLib.mkHomeConfig { @@ -288,6 +350,15 @@ pkgs = nixpkgs.legacyPackages.${system}; in import ./pkgs { inherit inputs pkgs; } + // { + deploy-rs = inputs.deploy-rs.packages.${system}.deploy-rs; + } ); + + # Validates every deploy.nodes entry builds (nix flake check) - + # pure evaluation, zero deploy risk. + checks = builtins.mapAttrs ( + system: deployLib: deployLib.deployChecks self.deploy + ) inputs.deploy-rs.lib; }; } diff --git a/lib/default.nix b/lib/default.nix index dc8c6353..62ec7115 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -1,6 +1,7 @@ { inputs, ... }: let mkDarwinHost = import ./mkDarwinHost.nix { inherit inputs; }; + mkDeployNode = import ./mkDeployNode.nix { inherit inputs; }; mkHomeConfig = import ./mkHomeConfig.nix { inherit inputs; }; mkNixosHost = import ./mkNixosHost.nix { inherit inputs; }; genebeanLib = { @@ -11,6 +12,7 @@ let in { inherit (mkDarwinHost) mkDarwinHost; + inherit (mkDeployNode) mkDeployNode; inherit (mkHomeConfig) mkHomeConfig; inherit (mkNixosHost) mkNixosHost; inherit genebeanLib; diff --git a/lib/mkDeployNode.nix b/lib/mkDeployNode.nix new file mode 100644 index 00000000..f3dceffc --- /dev/null +++ b/lib/mkDeployNode.nix @@ -0,0 +1,24 @@ +{ inputs, ... }: +{ + mkDeployNode = + { + hostname, # matches nixosConfigurations. (or darwinConfigurations. when darwin = true); also used as-is for SSH, resolved via Tailscale MagicDNS + system, + fastConnection, + remoteBuild, + darwin ? false, + sshUser ? "gene", # mkDarwinHost defaults to "gene" too, but some darwin hosts (mightymac, Blue-Rock) override username to "gene.liverman" - pass that through explicitly for those nodes + }: + { + inherit hostname sshUser; + inherit fastConnection remoteBuild; + profiles.system = { + user = "root"; + path = + if darwin then + inputs.deploy-rs.lib.${system}.activate.darwin inputs.self.darwinConfigurations.${hostname} + else + inputs.deploy-rs.lib.${system}.activate.nixos inputs.self.nixosConfigurations.${hostname}; + }; + }; +} diff --git a/modules/hosts/darwin/mightymac/default.nix b/modules/hosts/darwin/mightymac/default.nix index e36d1c17..aa131671 100644 --- a/modules/hosts/darwin/mightymac/default.nix +++ b/modules/hosts/darwin/mightymac/default.nix @@ -123,5 +123,37 @@ }; }; - security.pam.services.sudo_local.enable = false; + security = { + pam.services.sudo_local.enable = false; + # nix-darwin has no wheelNeedsPassword-style toggle like NixOS + # (security.sudo only exposes extraConfig/keepTerminfo), so this is + # a raw sudoers rule - needed for deploy-rs's non-interactive sudo + # activation over SSH, which would otherwise hang on a password + # prompt with no way to answer it. Scoped to deploy-rs's own two + # sudo command patterns rather than blanket NOPASSWD: ALL (confirmed + # by reading deploy-rs's src/deploy.rs and its activate.custom + # builder in flake.nix): + # 1. `sudo -u root /activate-rs ...` - the actual + # activation/wait/revoke entrypoint. That single already-root + # process is what runs nix-darwin's own activation - including + # its Homebrew steps - so this one rule covers the whole flake + # switch, not just the non-Homebrew parts. + # 2. `sudo -u root rm /tmp/deploy-rs-canary-` - a *separate* + # command deploy-rs's magic-rollback confirmation step runs + # after a successful activation, to tell the target "keep this + # generation, don't roll back". Missing this one still lets + # activation succeed but makes confirmation hang on a password + # prompt, which deploy-rs correctly treats as a failure and + # rolls back - confirmed on hardware. deploy-rs passes bare + # `rm`, not a full path, and sudo resolves that via the + # invoking user's own PATH (no secure_path override in macOS's + # default sudoers) - which for gene.liverman is the Nix-managed + # coreutils at /run/current-system/sw/bin/rm, not /bin/rm. + # That symlink's path is stable across generations (only its + # target changes), so it's safe to hardcode here. + sudo.extraConfig = '' + gene.liverman ALL=(root) NOPASSWD: /nix/store/*/activate-rs * + gene.liverman ALL=(root) NOPASSWD: /run/current-system/sw/bin/rm /tmp/deploy-rs-canary-* + ''; + }; } diff --git a/modules/hosts/nixos/default.nix b/modules/hosts/nixos/default.nix index 5c31d7ad..5fa05152 100644 --- a/modules/hosts/nixos/default.nix +++ b/modules/hosts/nixos/default.nix @@ -1,5 +1,6 @@ { hostname, + lib, pkgs, username, ... @@ -87,6 +88,31 @@ sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ]; + # dbus-broker's unit is Type=notify-reload, but it doesn't send the + # RELOADING=1/READY=1 handshake systemd expects for that type - every + # `nixos-rebuild switch`/deploy that touches its config hangs for the + # full TimeoutStartSec (90s) then fails the whole activation. This is + # an upstream systemd/dbus-broker bug (systemd/systemd#37515), not + # anything host-specific - confirmed reproduced on nixnuc, but every + # host is equally exposed once something changes dbus-broker's + # config. + # + # Restarting instead of reloading isn't safe either - also confirmed + # on nixnuc: switch-to-configuration's own remaining steps (starting + # other changed units) talk to systemd over D-Bus, so stopping + # dbus-broker mid-switch stranded the activation script with "Failed + # to process dbus messages... disconnected from D-Bus?" and left the + # bus down until a manual `systemctl start dbus.socket` - a real + # outage on a host running "most services" (see AGENTS.md). Since + # this is such a foundational service, the safe answer is to leave it + # untouched by every switch entirely (neither reload nor restart) and + # let it only actually pick up a new dbus-broker on the next real + # reboot, same as how systemd itself is typically treated. + systemd.services.dbus-broker = { + reloadIfChanged = lib.mkForce false; + restartIfChanged = lib.mkForce false; + }; + time.timeZone = "America/New_York"; users.defaultUserShell = pkgs.zsh;