refactor: share workstation setup and boot-staged updates
Use dev-owned checkouts and one update policy on both hosts. Keep only hardware and deployment identity in host modules, use the same SDDM/UWSM workstation module in the VM, and install a host-configured manual switch command with lock regression tests.
This commit is contained in:
+4
-4
@@ -11,12 +11,12 @@ Reviewed 2026-09-06 UTC on the physical laptop and against the pinned upstream s
|
||||
- Anyrun is the application/calculator launcher; Fuzzel provides consistent searchable action, clipboard, window and help pickers. Help is read from live described bindings and never executes a selected shortcut.
|
||||
- Flat NixOS modules, Home Manager files, native Hyprland Lua, standard systemd services and small packaged helpers. No copied dotfile framework, downloaded login scripts, dynamic theme generators or additional recovery framework.
|
||||
|
||||
The laptop is **Intel Lunar Lake / Arc 130V–140V**, using `xe`, with a **1920×1200 internal panel at 150%**. The CPU temperature source is configured on this host, not guessed for every machine. No NVIDIA configuration, driver replacement, DNS change or storage migration is part of this work.
|
||||
The laptop is **Intel Lunar Lake / Arc 130V–140V**, using `xe`, with a **1920×1200 internal panel now at 133⅓%** (1440×900 logical pixels; reduced from the original 150% audit at the user's request). The CPU temperature source is configured on this host, not guessed for every machine. No NVIDIA configuration, driver replacement, DNS change or storage migration is part of this work.
|
||||
|
||||
## What the audit repaired
|
||||
|
||||
1. **Session ownership:** the original SDDM selection started plain Hyprland without `graphical-session.target`; the configured bar, wallpaper, locker and polkit services consequently did not start. SDDM now offers only **Hyprland (uwsm-managed)**. Plasma and the `kbot` account are removed as requested; `/home/kbot` remains intact.
|
||||
2. **Physical updates:** the laptop previously excluded the updater, whose script selected the EC2 host. Host selection is now explicit. Daily physical updates stage checked generations for the next boot; they do not log out the user or reboot.
|
||||
2. **Physical updates:** the laptop previously excluded the updater, whose script selected the EC2 host. Host selection is now explicit. Both hosts now share dev-owned checkouts and the same updater, staging checked generations for the next boot without logging out the user or rebooting.
|
||||
3. **Shared styling:** native CSS and Lua are rendered from `colors.nix`, rather than keeping several unrelated palettes.
|
||||
4. **Launcher geometry:** upstream gives the row, boxes, image and labels the same `.match` class. Applying padding to that class multiplied row height. Padding now applies only to `row.match`; application descriptions are hidden, results are bounded, and calculator results remain readable. A session-owned Anyrun daemon supports calculator clipboard output.
|
||||
5. **Actual visual sizing:** screenshots led to a shorter/wider action picker, shorter help, smaller notification drawer, consistent borders, and removal of the thick upstream notification-focus background. Bar information was restored after an overly sparse iteration; information density is intentional.
|
||||
@@ -60,9 +60,9 @@ Native screenshots are kept locally under `~/.cache/desktop-audit/resume/`, **no
|
||||
|
||||
The native audit caught real issues that configuration evaluation did not: nested launcher padding, overly tall pickers, the notification focus slab and an uninitialized duplicate brightness control. The quick-settings screenshot confirms that the actual panel contains the audio/mic/brightness sliders, network/Bluetooth, idle inhibition, power profile, Actions and Health controls.
|
||||
|
||||
`desktop-test.py` exercises the disposable graphical VM: session ownership, fonts, real PipeWire nodes, launcher geometry at 100%/150%, clipboard picker, recording container and no-audio default, notification ownership/inhibition, described help, and real wrong/correct-password PAM locking. Test credentials never reach the host. `desktop-actions-test.py` covers cancellation, untrusted input, byte-preserving clipboard behavior, lock failure handling, display timeout restoration and recording-inhibitor cleanup.
|
||||
`desktop-test.py` exercises the disposable graphical VM using the same SDDM/UWSM workstation module as the laptop: session ownership, fonts, real PipeWire nodes, launcher geometry at 100%/150%, clipboard picker, recording container and no-audio default, notification ownership/inhibition, described help, and real wrong/correct-password PAM locking. Test credentials never reach the host. `desktop-actions-test.py` covers cancellation, untrusted input, byte-preserving clipboard behavior, lock failure handling, display timeout restoration and recording-inhibitor cleanup.
|
||||
|
||||
**Completed validation (2026-09-06):** flake evaluation, Nix formatting, generated Hyprland configuration, physical-host assertions, 124 CLI executable smoke checks, 14 desktop-action tests, both updater-policy regression suites, and the full graphical/PAM/clipboard/recording VM check all passed on the refreshed inputs. Native clipboard, recording and rootless Podman checks passed too. The 150% six-result launcher and help screenshots were inspected visually; the transparent launcher click-catcher's IPC dimensions are not mistaken for the visible palette bounds.
|
||||
**Completed validation (2026-09-06):** flake evaluation, Nix formatting, generated Hyprland configuration, physical/AWS/shared-policy assertions, 125 CLI executable smoke checks, 14 desktop-action tests, 23 updater regressions, 22 manual-switch regressions (including lock retention through sudo), both host system builds, and the full SDDM/UWSM graphical/PAM/clipboard/recording VM check all passed on the refreshed inputs. Native clipboard, recording and rootless Podman checks passed too. The 150% six-result launcher and help screenshots were inspected visually; the transparent launcher click-catcher's IPC dimensions are not mistaken for the visible palette bounds.
|
||||
|
||||
**Account/hardware boundaries:**
|
||||
|
||||
|
||||
@@ -9,19 +9,20 @@ Flat, explicit NixOS modules with locked inputs. Required setup belongs here—n
|
||||
| File | Owns |
|
||||
| --- | --- |
|
||||
| `flake.nix`, `flake.lock` | Host entry points, exact Nixpkgs/Home Manager/tool/dotfile revisions and hashes |
|
||||
| `common.nix` | Shared environment, platform, Nix features and compatibility version |
|
||||
| `physical.nix`, `hardware-configuration.nix` | Laptop boot/storage, SDDM with only managed Hyprland, networking, Bluetooth, power, firmware and host-specific updates |
|
||||
| `configuration.nix` | EC2-only boot/storage/SSM integration, updater and repo ownership |
|
||||
| `common.nix` | Shared environment, locale/timezone, Nix features, update policy and compatibility version |
|
||||
| `physical.nix`, `hardware-configuration.nix` | Laptop boot/storage, panel scale, CPU sensor and checkout/target identity |
|
||||
| `configuration.nix` | AWS boot/storage/network/recovery integration and checkout/target identity |
|
||||
| `users.nix` | `dev`, SSH authorization, sudo, Home Manager and workspace ownership |
|
||||
| `tools.nix`, `network.nix` | Development toolkit, terminal/shell, rootless Podman, VPN clients and network diagnostics |
|
||||
| `colors.nix`, `wallpaper.nix`, `wallpaper.svg` | Shared One Ring palette, hash-pinned wallpaper and original fallback artwork |
|
||||
| `desktop.nix`, `hyprland.lua`, `anyrun.css`, `swaync.css`, `desktop-help.py`, `desktop-actions.py` | Session/bar, launchers, described help, capture, clipboard, notifications, lock/idle and styling |
|
||||
| `apps.nix`, `element-nightly.nix` | Firefox ESR, KeePassXC, Thunderbird, Steam, pinned Element Nightly, Slack, file/media viewers and MIME defaults |
|
||||
| `neovim.nix`, `neovim-test.lua` | Unmodified upstream editor deployment and opt-in native runtime audit |
|
||||
| `updates.nix`, `update-system.sh`, `update-test.py` | Daily isolated-worktree updates and failure/concurrency regression tests |
|
||||
| `physical-test.nix`, `tools-test.nix` | Built host/session assertions and bounded offline tool/help smoke tests |
|
||||
| `updates.nix`, `update-system.sh`, `update-test.py` | Shared dev-owned checkouts, daily boot-staged updates and failure/concurrency regression tests |
|
||||
| `switch-system.sh`, `switch-test.py` | Same installed manual apply/preview command on both hosts, with host identity supplied by Nix |
|
||||
| `physical-test.nix`, `tools-test.nix` | Physical/AWS safety, shared-policy assertions and bounded offline tool/help smoke tests |
|
||||
| `desktop-test.nix`, `desktop-test.py`, `audit-desktop.sh` | Disposable graphical/PAM/audio/scaling audit |
|
||||
| `workstation.nix`, `nvidia.nix` | Optional ReGreet workstation and NVIDIA modules; not this Intel laptop's configuration |
|
||||
| `workstation.nix`, `nvidia.nix` | Shared local hardware/SDDM integration for laptop and VM; separate opt-in NVIDIA support |
|
||||
| [DESKTOP.md](DESKTOP.md) | Live audit, wallpaper provenance, JaKooLit comparison and explicit feature-completion plan |
|
||||
|
||||
## Account and session
|
||||
@@ -31,7 +32,8 @@ Flat, explicit NixOS modules with locked inputs. Required setup belongs here—n
|
||||
- The physical host uses the locally established `dev` password; mutable users preserve it. No plaintext password, private key or password hash is embedded in this repo/store.
|
||||
- At the user's request, **Plasma and the previously managed `kbot` account are removed**. This configuration does not delete `/home/kbot`; retained files and old generations are not erased. Home Manager only manages `dev`.
|
||||
- SDDM exposes **Hyprland (uwsm-managed)** only. Plain Hyprland is deliberately hidden because it does not start the session-bound bar, wallpaper, idle and polkit services. UWSM is the single session owner.
|
||||
- On EC2, SSM/root recovery and the host's integration remain intact; there is no graphical greeter or autologin. `/etc/nixos` is dev-owned there. On the laptop `/etc/nix` remains administrator-owned and the original `/etc/nixos` files are untouched.
|
||||
- Both checkouts are **dev-owned**, including Git metadata: `/etc/nix` on the laptop and `/etc/nixos` on EC2. Existing paths are retained to avoid moving deployed repositories. NixOS maintains ownership without following symlinks into the Nix store; the laptop's original `/etc/nixos` files stay untouched.
|
||||
- On EC2, AWS boot/storage/networking and SSM/SSH recovery remain intact; no physical greeter, Wi-Fi/Bluetooth or battery services are added. User settings, locale/timezone, packages and update policy come from the same modules. The graphical VM now uses the same SDDM/UWSM integration as the laptop.
|
||||
- The SSH key in `dev-authorized-keys` is public. Account/cloud/browser/VPN credentials and vaults remain user-controlled.
|
||||
|
||||
### Everyday controls
|
||||
@@ -90,17 +92,19 @@ These are system-owned executables from Nix, not unmanaged `npm -g`, `pip instal
|
||||
|
||||
## Build and apply
|
||||
|
||||
### One-command sync on this laptop
|
||||
### One-command sync on either host
|
||||
|
||||
Save your work, then run from any directory (it requests sudo when needed):
|
||||
Save your work, then run the installed command from any directory:
|
||||
|
||||
```sh
|
||||
/etc/nix/switch-system.sh # apply now and make it the boot default
|
||||
/etc/nix/switch-system.sh dry-activate # build and preview changes without applying
|
||||
/etc/nix/switch-system.sh boot # stage for the next boot instead
|
||||
switch-system # explicitly apply now and make it the boot default
|
||||
switch-system dry-activate # build and preview changes without applying
|
||||
switch-system boot # stage for the next boot instead
|
||||
```
|
||||
|
||||
This builds the current **`/etc/nix#nixos`** checkout and activates that exact output, including `dev`'s Home Manager configuration. It shares the automatic updater's lock, stops on build failure, keeps recovery generations and never reboots. It does **not** pull remote Git changes or update `flake.lock`: “latest” here means the files currently checked out in `/etc/nix`. New source files must be added to Git to be included. Open a new terminal afterward for shell environment changes; some desktop changes require a fresh login. `--help` lists the modes, including temporary `test` activation.
|
||||
Nix supplies the correct checkout/target to the **same script** on each host. Builds run as `dev`; only activation uses sudo. It applies the exact built output, including `dev`'s Home Manager configuration, shares the automatic updater's lock, stops on build failure, keeps recovery generations and never reboots. It does **not** pull Git or update `flake.lock`: “latest” means the current checkout. Add new source files to Git for flakes to include them. Open a new terminal afterward for shell environment changes; some desktop changes require a fresh login. `--help` lists the modes, including temporary `test` activation.
|
||||
|
||||
Direct `/etc/nix/switch-system.sh` execution still defaults to the laptop. When running the source script on EC2 before the packaged command is installed, explicitly set `NIXOS_CONFIG_REPO=/etc/nixos NIXOS_UPDATE_HOST=dev` and choose `boot`. Automatic updates, unlike an explicit manual switch, **always stage for the next boot on both hosts**.
|
||||
|
||||
### Detailed validation and activation
|
||||
|
||||
@@ -114,7 +118,7 @@ nix build .#checks.x86_64-linux.updates \
|
||||
.#checks.x86_64-linux.desktop-config \
|
||||
.#checks.x86_64-linux.physical-config \
|
||||
.#checks.x86_64-linux.tools .#checks.x86_64-linux.desktop-actions \
|
||||
--no-update-lock-file --no-link
|
||||
.#checks.x86_64-linux.switch-system --no-update-lock-file --no-link
|
||||
nix build .#nixosConfigurations.nixos.config.system.build.toplevel \
|
||||
--no-update-lock-file --out-link result-nixos
|
||||
built=$(readlink -f result-nixos)
|
||||
@@ -132,7 +136,7 @@ sudo "$built/sw/bin/nixos-rebuild" test --no-reexec --store-path "$built"
|
||||
sudo "$built/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$built"
|
||||
```
|
||||
|
||||
`test` is a real activation, not a dry run. For a non-disruptive deployment that takes effect on next boot, use `boot` instead of `test`/`switch`. The daily physical updater uses that policy.
|
||||
`test` is a real activation, not a dry run. For a non-disruptive deployment that takes effect on next boot, use `boot` instead of `test`/`switch`. Both hosts' daily updaters use that policy.
|
||||
|
||||
On **EC2 only**, use `/etc/nixos` and `.#nixosConfigurations.dev.config.system.build.toplevel`. Run the same evaluation/checks and activate the exact output with its own `nixos-rebuild --no-reexec --store-path`. Never select a target merely because it matches your username.
|
||||
|
||||
@@ -158,9 +162,8 @@ If boot fails, choose a known-good systemd-boot generation (hold Space at startu
|
||||
|
||||
- **Every day**, `nixos-update.timer` runs with up to one hour of jitter and catches missed runs. It advances `nixpkgs`, `home-manager`, and `nixpkgs-latest`; the Neovim source remains fixed.
|
||||
- The system/desktop use the stable `nixos-26.05` and matching Home Manager release branch. Most standalone CLI tools use the locked **Nixpkgs master** input to avoid stable-release feature lag. They are imported separately, **not** overlaid onto the desktop's GCC/Python/libraries. The Nix daemon and service-integrated packages remain from the system pin.
|
||||
- **Laptop:** administrator-owned `/etc/nix`, explicit `nixos` target, build/check/record and **stage for next boot**. No surprise logout, live compositor replacement or automatic reboot. Installed versions change when that generation is booted, or when explicitly switched by the user.
|
||||
- **EC2:** dev-owned `/etc/nixos`, explicit `dev` target and live `switch`, retaining the prior activation/rollback behavior.
|
||||
- Both policies skip dirty/detached repositories, serialize runs, use an isolated worktree, check for edits again after dry activation, commit only the tested lock, attempt rollback on failure and retain recovery generations. A failed rollback is reported as failure, not silently marked successful.
|
||||
- **Same policy on both hosts:** run as `dev`, build/check/record and **stage for next boot**. There is no automatic live switch, logout or reboot. Only checkout path and explicit flake target differ: laptop `/etc/nix#nixos`, EC2 `/etc/nixos#dev`. Installed versions change when that generation is booted, or explicitly switched by the user.
|
||||
- Both skip dirty/detached repositories, serialize runs with manual switching, use an isolated worktree, check for edits again after dry activation and commit only the tested lock. On staging failure, the updater restores the previously selected **boot generation**, including a generation that was already staged but not running. A failed rollback is reported as failure; recovery generations are retained.
|
||||
- **“Current” means newest successfully checked/built versions packaged in those branch heads**, not a guarantee of every upstream release immediately. Master can contain breakage and uncached builds; failure retains the previous working generation. Upstream Pi, Mason/plugin downloads, browser add-ons, firmware and project dependencies are separate update boundaries. No runtime self-updater is bolted on to override Nix-owned executables.
|
||||
- The updater does not fetch/merge repository code from origin. Configuration code is reviewed separately. A dirty working tree intentionally prevents automatic input changes until work is committed/stashed.
|
||||
|
||||
|
||||
+9
-1
@@ -1,4 +1,4 @@
|
||||
# Shared dev environment; boot, storage, recovery users and updates are host-specific.
|
||||
# Shared user environment and update policy; only machine integration differs.
|
||||
{ config, inputs, ... }:
|
||||
{
|
||||
imports = [
|
||||
@@ -8,8 +8,16 @@
|
||||
./desktop.nix
|
||||
./apps.nix
|
||||
./neovim.nix
|
||||
./updates.nix
|
||||
];
|
||||
|
||||
time.timeZone = "America/Chicago";
|
||||
i18n.defaultLocale = "en_US.UTF-8";
|
||||
services.xserver.xkb = {
|
||||
layout = "us";
|
||||
variant = "";
|
||||
};
|
||||
|
||||
nixpkgs.hostPlatform = "x86_64-linux";
|
||||
# tools.nix/network.nix select current standalone CLIs without overriding the
|
||||
# system package set (or mixing unstable libraries into the desktop stack).
|
||||
|
||||
+5
-3
@@ -5,9 +5,11 @@
|
||||
# Keep the image's EC2 boot, storage, metadata, SSH and SSM integration.
|
||||
"${modulesPath}/virtualisation/amazon-image.nix"
|
||||
./common.nix
|
||||
./updates.nix
|
||||
];
|
||||
|
||||
# Only the EC2 host keeps its dev-owned configuration checkout here.
|
||||
systemd.tmpfiles.rules = [ "Z /etc/nixos - dev users -" ];
|
||||
# Keep the existing deployment path; ownership/update policy is shared.
|
||||
systemd.services.nixos-update.environment = {
|
||||
NIXOS_CONFIG_REPO = "/etc/nixos";
|
||||
NIXOS_UPDATE_HOST = "dev";
|
||||
};
|
||||
}
|
||||
|
||||
+10
-9
@@ -15,15 +15,15 @@ pkgs.testers.runNixOSTest {
|
||||
nodes.machine = { pkgs, ... }: {
|
||||
imports = [
|
||||
inputs.home-manager.nixosModules.home-manager
|
||||
./users.nix
|
||||
./tools.nix
|
||||
./network.nix
|
||||
./neovim.nix
|
||||
./desktop.nix
|
||||
./apps.nix
|
||||
./common.nix
|
||||
./workstation.nix
|
||||
];
|
||||
system.stateVersion = "26.05";
|
||||
# Exercise the shared host policy, but never run automatic updates in a VM.
|
||||
systemd.services.nixos-update.environment = {
|
||||
NIXOS_CONFIG_REPO = "/etc/nix";
|
||||
NIXOS_UPDATE_HOST = "nixos";
|
||||
};
|
||||
systemd.timers.nixos-update.enable = false;
|
||||
boot.blacklistedKernelModules = [ "floppy" ];
|
||||
virtualisation = {
|
||||
memorySize = 6144;
|
||||
@@ -58,9 +58,10 @@ pkgs.testers.runNixOSTest {
|
||||
mkpasswd --method=sha-512 --salt=nixostest desktop-test > "$out"
|
||||
''
|
||||
);
|
||||
services.greetd.settings.initial_session = {
|
||||
# The same SDDM/UWSM session as the laptop; autologin is test-only.
|
||||
services.displayManager.autoLogin = {
|
||||
enable = true;
|
||||
user = "dev";
|
||||
command = "${pkgs.uwsm}/bin/uwsm start -e -D Hyprland hyprland.desktop";
|
||||
};
|
||||
};
|
||||
testScript = builtins.readFile ./desktop-test.py;
|
||||
|
||||
+3
-1
@@ -72,7 +72,9 @@ assert "0.42" in session("wpctl get-volume @DEFAULT_AUDIO_SINK@")
|
||||
session("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 1")
|
||||
assert "MUTED" in session("wpctl get-volume @DEFAULT_AUDIO_SOURCE@")
|
||||
session("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 0")
|
||||
machine.succeed("systemctl is-active systemd-resolved")
|
||||
machine.succeed("systemctl is-active NetworkManager display-manager")
|
||||
machine.succeed("test $(stat -c %U /etc/nix) = dev")
|
||||
machine.succeed("test $(stat -c %U /var/cache/nixos-update) = dev")
|
||||
|
||||
launch("terminal", "kitty --title 'Workspace ready' sh -c " + shlex.quote(
|
||||
"printf '\\n WORKSPACE READY\\n\\n'; "
|
||||
|
||||
@@ -22,22 +22,21 @@
|
||||
|
||||
outputs =
|
||||
inputs@{ nixpkgs, home-manager, ... }:
|
||||
let
|
||||
mkHost =
|
||||
module:
|
||||
nixpkgs.lib.nixosSystem {
|
||||
specialArgs = { inherit inputs; };
|
||||
modules = [
|
||||
module
|
||||
home-manager.nixosModules.home-manager
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
nixosConfigurations.dev = nixpkgs.lib.nixosSystem {
|
||||
specialArgs = { inherit inputs; };
|
||||
modules = [
|
||||
./configuration.nix
|
||||
home-manager.nixosModules.home-manager
|
||||
];
|
||||
};
|
||||
|
||||
# Physical laptop: dev + managed Hyprland; preserve its boot/storage.
|
||||
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
|
||||
specialArgs = { inherit inputs; };
|
||||
modules = [
|
||||
./physical.nix
|
||||
home-manager.nixosModules.home-manager
|
||||
];
|
||||
nixosConfigurations = {
|
||||
dev = mkHost ./configuration.nix; # EC2 integration
|
||||
nixos = mkHost ./physical.nix; # This laptop's boot/storage/hardware
|
||||
};
|
||||
|
||||
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt;
|
||||
@@ -66,7 +65,24 @@
|
||||
physical-config = import ./physical-test.nix {
|
||||
inherit pkgs;
|
||||
config = inputs.self.nixosConfigurations.nixos.config;
|
||||
ec2Config = inputs.self.nixosConfigurations.dev.config;
|
||||
};
|
||||
switch-system =
|
||||
pkgs.runCommand "switch-system-check"
|
||||
{
|
||||
nativeBuildInputs = with pkgs; [
|
||||
python3
|
||||
bash
|
||||
coreutils
|
||||
util-linux
|
||||
shellcheck
|
||||
];
|
||||
}
|
||||
''
|
||||
shellcheck ${./switch-system.sh}
|
||||
python ${./switch-test.py} ${./switch-system.sh}
|
||||
touch "$out"
|
||||
'';
|
||||
desktop-actions =
|
||||
pkgs.runCommand "desktop-actions-check" { nativeBuildInputs = [ pkgs.python3 ]; }
|
||||
''
|
||||
|
||||
+4
-4
@@ -1,4 +1,5 @@
|
||||
{
|
||||
config,
|
||||
inputs,
|
||||
lib,
|
||||
pkgs,
|
||||
@@ -11,11 +12,10 @@ let
|
||||
};
|
||||
in
|
||||
{
|
||||
# Keep each host's existing interface/DHCP owner. On EC2 this is dhcpcd;
|
||||
# NixOS wires its resolvconf hook to resolved's compatibility interface.
|
||||
# NetworkManager owns local DNS on workstations. With dhcpcd (EC2),
|
||||
# NixOS wires resolvconf to resolved. Preserve both hosts' existing behavior.
|
||||
services.resolved = {
|
||||
# Physical hosts can retain their existing DNS setup during migration.
|
||||
enable = lib.mkDefault true;
|
||||
enable = lib.mkDefault (!config.networking.networkmanager.enable);
|
||||
settings.Resolve = {
|
||||
LLMNR = false;
|
||||
MulticastDNS = false;
|
||||
|
||||
+51
-5
@@ -1,5 +1,9 @@
|
||||
# Host configuration and built-file checks: no activation, VM or VPN connections.
|
||||
{ config, pkgs }:
|
||||
# Physical safety, shared host policy and built files; no activation/connections.
|
||||
{
|
||||
config,
|
||||
ec2Config,
|
||||
pkgs,
|
||||
}:
|
||||
let
|
||||
inherit (pkgs) lib;
|
||||
dev = config.users.users.dev;
|
||||
@@ -90,12 +94,54 @@ let
|
||||
assertion =
|
||||
config.systemd.services.nixos-update.environment.NIXOS_UPDATE_HOST == "nixos"
|
||||
&& config.systemd.services.nixos-update.environment.NIXOS_CONFIG_REPO == "/etc/nix"
|
||||
&& config.systemd.services.nixos-update.environment.NIXOS_UPDATE_MODE == "boot"
|
||||
&& config.systemd.services.nixos-update.serviceConfig.User == "root"
|
||||
&& config.systemd.services.nixos-update.serviceConfig.User == "dev"
|
||||
&& builtins.elem "Z /etc/nix - dev users -" config.systemd.tmpfiles.rules
|
||||
&& config.systemd.timers.nixos-update.timerConfig.Persistent
|
||||
&& !(config.systemd.services ? amazon-ssm-agent)
|
||||
&& !(builtins.elem "Z /etc/nixos - dev users -" config.systemd.tmpfiles.rules);
|
||||
message = "Physical updates must stage the physical target, never EC2 or live-session restarts.";
|
||||
message = "The laptop must keep its own checkout/target, not EC2's deployment settings.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
lib.all
|
||||
(
|
||||
c:
|
||||
let
|
||||
update = c.systemd.services.nixos-update;
|
||||
in
|
||||
update.serviceConfig.User == "dev"
|
||||
&& update.serviceConfig.Group == "users"
|
||||
&& update.serviceConfig.ExecStart == config.systemd.services.nixos-update.serviceConfig.ExecStart
|
||||
&& c.systemd.timers.nixos-update.timerConfig == config.systemd.timers.nixos-update.timerConfig
|
||||
&& !(update.environment ? NIXOS_UPDATE_MODE)
|
||||
&& builtins.elem "Z ${update.environment.NIXOS_CONFIG_REPO} - dev users -" c.systemd.tmpfiles.rules
|
||||
&& builtins.elem "Z /var/cache/nixos-update - dev users -" c.systemd.tmpfiles.rules
|
||||
&& c.users.mutableUsers
|
||||
&& c.time.timeZone == "America/Chicago"
|
||||
&& c.i18n.defaultLocale == "en_US.UTF-8"
|
||||
)
|
||||
[
|
||||
config
|
||||
ec2Config
|
||||
];
|
||||
message = "Both hosts must share the dev-owned checkout/updater and user preferences; no per-host activation policy.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
ec2Config.systemd.services.nixos-update.environment.NIXOS_UPDATE_HOST == "dev"
|
||||
&& ec2Config.systemd.services.nixos-update.environment.NIXOS_CONFIG_REPO == "/etc/nixos"
|
||||
&& ec2Config.boot.loader.grub.enable
|
||||
&& ec2Config.boot.loader.grub.device == "/dev/xvda"
|
||||
&& !ec2Config.boot.loader.systemd-boot.enable
|
||||
&& ec2Config.fileSystems."/".device == "/dev/disk/by-label/nixos"
|
||||
&& ec2Config.fileSystems."/".fsType == "ext4"
|
||||
&& ec2Config.services.openssh.enable
|
||||
&& ec2Config.services.amazon-ssm-agent.enable
|
||||
&& ec2Config.networking.dhcpcd.enable
|
||||
&& ec2Config.services.resolved.enable
|
||||
&& !ec2Config.networking.networkmanager.enable
|
||||
&& !ec2Config.services.displayManager.sddm.enable;
|
||||
message = "EC2 must retain its AWS boot, disks, networking and remote recovery, not laptop hardware services.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
|
||||
+8
-102
@@ -1,122 +1,28 @@
|
||||
# This machine: dev + managed Hyprland only; preserve boot/storage and old home data.
|
||||
# Hardware settings were copied from /etc/nixos; those original files stay untouched.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
managedHyprlandSession =
|
||||
pkgs.runCommand "hyprland-managed-session"
|
||||
{
|
||||
passthru.providedSessions = [ "hyprland-uwsm" ];
|
||||
}
|
||||
''
|
||||
mkdir -p "$out/share/wayland-sessions"
|
||||
ln -s ${config.programs.hyprland.package}/share/wayland-sessions/hyprland-uwsm.desktop \
|
||||
"$out/share/wayland-sessions/hyprland-uwsm.desktop"
|
||||
'';
|
||||
in
|
||||
# Laptop-only boot/storage, hardware measurements and checkout identity.
|
||||
# The original /etc/nixos files and /home/kbot remain untouched.
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
./hardware-configuration.nix
|
||||
./common.nix
|
||||
./updates.nix
|
||||
./workstation.nix
|
||||
];
|
||||
|
||||
boot.loader = {
|
||||
systemd-boot.enable = true;
|
||||
efi.canTouchEfiVariables = true;
|
||||
};
|
||||
|
||||
networking.hostName = "nixos";
|
||||
networking.networkmanager = {
|
||||
enable = true;
|
||||
plugins = [ pkgs.networkmanager-openvpn ];
|
||||
};
|
||||
# Keep NetworkManager's installed DNS policy.
|
||||
services.resolved.enable = false;
|
||||
|
||||
time.timeZone = "America/Chicago";
|
||||
i18n.defaultLocale = "en_US.UTF-8";
|
||||
i18n.extraLocaleSettings = {
|
||||
LC_ADDRESS = "en_US.UTF-8";
|
||||
LC_IDENTIFICATION = "en_US.UTF-8";
|
||||
LC_MEASUREMENT = "en_US.UTF-8";
|
||||
LC_MONETARY = "en_US.UTF-8";
|
||||
LC_NAME = "en_US.UTF-8";
|
||||
LC_NUMERIC = "en_US.UTF-8";
|
||||
LC_PAPER = "en_US.UTF-8";
|
||||
LC_TELEPHONE = "en_US.UTF-8";
|
||||
LC_TIME = "en_US.UTF-8";
|
||||
};
|
||||
|
||||
# Keep the working SDDM greeter; only the managed Hyprland session is offered.
|
||||
# No autologin, second desktop environment, or competing session owner.
|
||||
services.xserver = {
|
||||
enable = true;
|
||||
xkb = {
|
||||
layout = "us";
|
||||
variant = "";
|
||||
};
|
||||
};
|
||||
services.displayManager = {
|
||||
sddm.enable = true;
|
||||
defaultSession = "hyprland-uwsm";
|
||||
# The upstream package also advertises a plain session that never starts
|
||||
# our bar/idle/polkit services. Hide that trap, not the managed startup.
|
||||
sessionPackages = lib.mkForce [ managedHyprlandSession ];
|
||||
};
|
||||
services.printing.enable = true;
|
||||
services.pulseaudio.enable = false;
|
||||
|
||||
# Preserve dev's locally established password. Removing the previously
|
||||
# managed kbot account does NOT request deletion of /home/kbot.
|
||||
users.mutableUsers = true;
|
||||
users.users.dev.extraGroups = [ "networkmanager" ];
|
||||
environment.systemPackages = [ pkgs.networkmanagerapplet ];
|
||||
services.udisks2.enable = true;
|
||||
services.gvfs.enable = true;
|
||||
services.fwupd.enable = true;
|
||||
home-manager.users.dev.services.udiskie.enable = true;
|
||||
# One clean step below the panel's automatic 150%: 1440x900 logical pixels.
|
||||
# Leave external displays on the shared configuration's automatic scale.
|
||||
# 1440x900 logical pixels; leave other displays on automatic scaling.
|
||||
home-manager.users.dev.wayland.windowManager.hyprland.extraConfig = lib.mkAfter ''
|
||||
hl.monitor({ output = "eDP-1", mode = "preferred", position = "auto", scale = 4 / 3 })
|
||||
'';
|
||||
hardware.bluetooth = {
|
||||
enable = true;
|
||||
powerOnBoot = false;
|
||||
};
|
||||
services.blueman.enable = true;
|
||||
services.upower.enable = true;
|
||||
services.power-profiles-daemon.enable = true;
|
||||
# sysinfo's component label for this Intel CPU, not another laptop's ACPI zone.
|
||||
home-manager.users.dev.programs.ashell.settings.system_info.temperature.sensor =
|
||||
"coretemp Package id 0";
|
||||
home-manager.users.dev.xdg.configFile."autostart/blueman.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Blueman
|
||||
Hidden=true
|
||||
'';
|
||||
|
||||
# Retain the installed host's package policy.
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
|
||||
# /etc/nix is administrator-owned. Stage tested updates for the next boot,
|
||||
# rather than restarting a compositor/display manager under an active user.
|
||||
systemd.services.nixos-update = {
|
||||
environment = {
|
||||
NIXOS_CONFIG_REPO = "/etc/nix";
|
||||
NIXOS_UPDATE_HOST = "nixos";
|
||||
NIXOS_UPDATE_MODE = "boot";
|
||||
};
|
||||
serviceConfig = {
|
||||
User = "root";
|
||||
Group = "root";
|
||||
};
|
||||
systemd.services.nixos-update.environment = {
|
||||
NIXOS_CONFIG_REPO = "/etc/nix";
|
||||
NIXOS_UPDATE_HOST = "nixos";
|
||||
};
|
||||
# Neither /etc/nixos nor /home/kbot is handed over to dev/Home Manager.
|
||||
}
|
||||
|
||||
+30
-13
@@ -1,11 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Apply this laptop's checkout, never the separate EC2 "dev" configuration.
|
||||
# The installed command gets its host/path from updates.nix on both machines.
|
||||
# Direct execution from this checkout defaults to the laptop, never the login name.
|
||||
set -euo pipefail
|
||||
repo=${NIXOS_CONFIG_REPO:-/etc/nix}
|
||||
host=${NIXOS_UPDATE_HOST:-nixos}
|
||||
|
||||
usage() {
|
||||
printf 'Usage: %s [switch|dry-activate|boot|test]\n' "$0"
|
||||
printf 'Build %s#%s using flake.lock.\n' "$repo" "$host"
|
||||
printf '%s\n' \
|
||||
'Build /etc/nix#nixos using flake.lock; default: switch now and save for boot.' \
|
||||
'Default: switch now and save for boot (an explicit manual action).' \
|
||||
'dry-activate previews changes; boot stages them; test applies temporarily.' \
|
||||
'Does not pull Git, update package pins, delete generations, or reboot.'
|
||||
}
|
||||
@@ -27,18 +31,31 @@ case "$action" in
|
||||
;;
|
||||
esac
|
||||
|
||||
if ((EUID != 0)); then
|
||||
exec sudo -- "$(readlink -f -- "${BASH_SOURCE[0]}")" "$@"
|
||||
fi
|
||||
case "$host" in
|
||||
nixos | dev) ;;
|
||||
*)
|
||||
printf 'Unsupported host: %s\n' "$host" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# Share the existing updater's lock so it cannot change the checkout/profile
|
||||
# underneath this build and activation.
|
||||
mkdir -p /var/cache/nixos-update
|
||||
exec 9>/var/cache/nixos-update/lock
|
||||
# Keep builds/Git access under the checkout owner, including when invoked via sudo.
|
||||
# Only activating the built system needs root.
|
||||
if ((EUID == 0)); then
|
||||
exec runuser -u dev -- env NIXOS_CONFIG_REPO="$repo" NIXOS_UPDATE_HOST="$host" \
|
||||
"$(readlink -f -- "${BASH_SOURCE[0]}")" "$@"
|
||||
fi
|
||||
sudo -v
|
||||
|
||||
# Share the automatic updater's lock. Directory ownership is managed by NixOS.
|
||||
state=${CACHE_DIRECTORY:-/var/cache/nixos-update}
|
||||
mkdir -p "$state"
|
||||
exec 9>"$state/lock"
|
||||
flock 9
|
||||
|
||||
cd /etc/nix
|
||||
printf 'Building the current /etc/nix checkout for nixos (%s).\n' "$action"
|
||||
built=$(nix build .#nixosConfigurations.nixos.config.system.build.toplevel \
|
||||
cd "$repo"
|
||||
printf 'Building %s#%s (%s).\n' "$repo" "$host" "$action"
|
||||
built=$(nix build ".#nixosConfigurations.$host.config.system.build.toplevel" \
|
||||
--no-update-lock-file --no-link --print-out-paths)
|
||||
exec "$built/sw/bin/nixos-rebuild" "$action" --no-reexec --store-path "$built"
|
||||
# Keep this shell alive holding the lock: sudo closes inherited descriptors.
|
||||
sudo "$built/sw/bin/nixos-rebuild" "$action" --no-reexec --store-path "$built"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Test manual switching for both hosts without Nix builds or host activation."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
script = Path(sys.argv[1]).resolve()
|
||||
if os.geteuid() == 0:
|
||||
raise SystemExit("Run this check as an unprivileged user (or through nix flake check).")
|
||||
|
||||
mock = r'''import fcntl, json, os, pathlib, sys
|
||||
name = pathlib.Path(sys.argv[0]).name
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["CALLS"], "a") as f:
|
||||
f.write(json.dumps([name, *args]) + "\n")
|
||||
if name == "nix" or (name == "sudo" and args != ["-v"]):
|
||||
# Model sudo's descriptor cleanup: the parent shell must retain the lock.
|
||||
try: os.close(9)
|
||||
except OSError: pass
|
||||
with open(pathlib.Path(os.environ["CACHE_DIRECTORY"]) / "lock", "a") as lock:
|
||||
try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError: pass
|
||||
else: raise AssertionError("Updater lock was released before activation completed")
|
||||
if name == "nix":
|
||||
assert pathlib.Path.cwd() == pathlib.Path(os.environ["NIXOS_CONFIG_REPO"])
|
||||
assert args == ["build", f'.#nixosConfigurations.{os.environ["NIXOS_UPDATE_HOST"]}.config.system.build.toplevel', "--no-update-lock-file", "--no-link", "--print-out-paths"]
|
||||
if os.environ["SCENARIO"] == "build-failure": sys.exit(42)
|
||||
print(os.environ["BUILT"])
|
||||
elif name == "sudo":
|
||||
if args == ["-v"]:
|
||||
if os.environ["SCENARIO"] == "sudo-failure": sys.exit(43)
|
||||
else:
|
||||
assert args[0] == os.environ["BUILT"] + "/sw/bin/nixos-rebuild"
|
||||
assert args[2:] == ["--no-reexec", "--store-path", os.environ["BUILT"]]
|
||||
if os.environ["SCENARIO"] == "activation-failure": sys.exit(44)
|
||||
else:
|
||||
raise AssertionError(name)
|
||||
'''
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="switch-test-") as directory:
|
||||
root = Path(directory)
|
||||
repo = root / "repo with spaces"
|
||||
binaries = root / "bin"
|
||||
calls = root / "calls"
|
||||
repo.mkdir()
|
||||
binaries.mkdir()
|
||||
for name in ["nix", "sudo"]:
|
||||
executable = binaries / name
|
||||
executable.write_text("#!" + sys.executable + "\n" + mock)
|
||||
executable.chmod(0o755)
|
||||
env = dict(os.environ, PATH=str(binaries) + os.pathsep + os.environ["PATH"],
|
||||
NIXOS_CONFIG_REPO=str(repo), CACHE_DIRECTORY=str(root / "cache"),
|
||||
CALLS=str(calls), BUILT=str(root / "built system"))
|
||||
cases = [([], "switch", "success", 0), (["dry-activate"], "dry-activate", "success", 0),
|
||||
(["boot"], "boot", "success", 0), (["test"], "test", "success", 0),
|
||||
(["--help"], None, "help", 0), (["invalid"], None, "invalid", 2),
|
||||
(["switch", "extra"], None, "invalid", 2),
|
||||
([], None, "build-failure", 42), ([], None, "sudo-failure", 43),
|
||||
([], "switch", "activation-failure", 44), ([], None, "invalid-host", 2)]
|
||||
for host in ["nixos", "dev"]:
|
||||
for args, action, scenario, expected_code in cases:
|
||||
calls.unlink(missing_ok=True)
|
||||
result = subprocess.run(
|
||||
["bash", str(script), *args], cwd=root,
|
||||
env=env | {"NIXOS_UPDATE_HOST": "unknown" if scenario == "invalid-host" else host,
|
||||
"SCENARIO": scenario}, text=True, capture_output=True,
|
||||
)
|
||||
assert result.returncode == expected_code, (host, scenario, result.stdout, result.stderr)
|
||||
log = [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else []
|
||||
if action:
|
||||
assert len(log) == 3, log
|
||||
assert log[0] == ["sudo", "-v"], log
|
||||
assert log[-1][2] == action, log
|
||||
elif scenario == "build-failure":
|
||||
assert [call[0] for call in log] == ["sudo", "nix"], log
|
||||
elif scenario == "sudo-failure":
|
||||
assert log == [["sudo", "-v"]], log
|
||||
else:
|
||||
assert not log, log
|
||||
print("PASS", host, args or ["default"], scenario)
|
||||
+3
-1
@@ -20,11 +20,13 @@ pkgs.runCommand "workstation-tools-check"
|
||||
valgrind heaptrack rr eu-readelf bpftrace java javac mvn gradle kotlin \
|
||||
dotnet ruby bundle php composer zig zls elixir erl protoc buf \
|
||||
dprint stylua taplo marksman markdownlint-cli2 sqlfluff hadolint ast-grep rga \
|
||||
hurl oha step mkcert cosign syft grype mosh sshfs asciinema vhs; do
|
||||
hurl oha step mkcert cosign syft grype mosh sshfs asciinema vhs switch-system; do
|
||||
command -v "$tool" >/dev/null
|
||||
done
|
||||
test -x ${config.home-manager.users.dev.programs.lazygit.package}/bin/lazygit
|
||||
test -x ${config.home-manager.users.dev.programs.tmux.package}/bin/tmux
|
||||
switch-system --help > "$TMPDIR/switch-help"
|
||||
grep -Fq '/etc/nix#nixos' "$TMPDIR/switch-help"
|
||||
pi --version
|
||||
uv --version
|
||||
ruff --version
|
||||
|
||||
+12
-17
@@ -1,11 +1,10 @@
|
||||
# Host and activation policy come from the host's systemd unit, never inference
|
||||
# from a login name. Physical builds are staged for next boot; EC2 switches live.
|
||||
# Both hosts stage for the next boot. Only the checkout and flake target differ;
|
||||
# target identity is explicit, never inferred from the login name.
|
||||
repo=${NIXOS_CONFIG_REPO:?Set NIXOS_CONFIG_REPO}
|
||||
host=${NIXOS_UPDATE_HOST:?Set NIXOS_UPDATE_HOST}
|
||||
mode=${NIXOS_UPDATE_MODE:?Set NIXOS_UPDATE_MODE}
|
||||
case "$host:$mode" in
|
||||
dev:switch|nixos:boot) ;;
|
||||
*) echo "Refusing unsupported update target/policy: $host:$mode" >&2; exit 2 ;;
|
||||
case "$host" in
|
||||
dev|nixos) ;;
|
||||
*) echo "Refusing unsupported update target: $host" >&2; exit 2 ;;
|
||||
esac
|
||||
state=${CACHE_DIRECTORY:-/var/cache/nixos-update}
|
||||
mkdir -p "$state"
|
||||
@@ -40,7 +39,7 @@ fi
|
||||
nix flake check --no-build --no-update-lock-file
|
||||
nix build .#checks.x86_64-linux.updates .#checks.x86_64-linux.desktop-config \
|
||||
.#checks.x86_64-linux.physical-config .#checks.x86_64-linux.tools \
|
||||
.#checks.x86_64-linux.desktop-actions \
|
||||
.#checks.x86_64-linux.desktop-actions .#checks.x86_64-linux.switch-system \
|
||||
--no-update-lock-file --no-link
|
||||
git add flake.lock
|
||||
git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \
|
||||
@@ -60,21 +59,17 @@ if ! unchanged; then
|
||||
echo 'Configuration changed during the build; leaving it untouched.'
|
||||
exit 0
|
||||
fi
|
||||
if [ "$mode" = boot ]; then
|
||||
# Preserve an already staged generation on failure, not just the running one.
|
||||
previous=$(readlink -f /nix/var/nix/profiles/system)
|
||||
else
|
||||
previous=$(readlink -f /run/current-system)
|
||||
fi
|
||||
# Preserve an already staged generation on failure, not just the running one.
|
||||
previous=$(readlink -f /nix/var/nix/profiles/system)
|
||||
sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built"
|
||||
if ! unchanged; then
|
||||
echo 'Configuration changed during dry activation; leaving it untouched.'
|
||||
exit 0
|
||||
fi
|
||||
git merge --ff-only "$candidate"
|
||||
if ! sudo "$built/sw/bin/nixos-rebuild" "$mode" --no-reexec --store-path "$built"; then
|
||||
echo 'Activation failed; restoring the previous system/profile. See the journal.' >&2
|
||||
sudo "$previous/sw/bin/nixos-rebuild" "$mode" --no-reexec --store-path "$previous"
|
||||
if ! sudo "$built/sw/bin/nixos-rebuild" boot --no-reexec --store-path "$built"; then
|
||||
echo 'Staging failed; restoring the previous boot generation. See the journal.' >&2
|
||||
sudo "$previous/sw/bin/nixos-rebuild" boot --no-reexec --store-path "$previous"
|
||||
if [ "$(git rev-parse HEAD)" = "$candidate" ] && [ -z "$(git status --porcelain)" ]; then
|
||||
git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \
|
||||
revert --no-edit "$candidate"
|
||||
@@ -82,5 +77,5 @@ if ! sudo "$built/sw/bin/nixos-rebuild" "$mode" --no-reexec --store-path "$built
|
||||
exit 1
|
||||
fi
|
||||
printf '%s %s %s\n' "$(date -Is)" "$candidate" "$built" > "$state/last-success"
|
||||
echo "Updated $host ($mode): $built"
|
||||
echo "Updated $host (staged for next boot): $built"
|
||||
# No forced reboot or garbage collection: recovery generations are retained.
|
||||
|
||||
+12
-12
@@ -37,18 +37,17 @@ elif name == "sudo":
|
||||
if scenario == "dry-activation-failure" and args[1] == "dry-activate": sys.exit(44)
|
||||
if scenario == "concurrent-dry-edit" and args[1] == "dry-activate":
|
||||
(pathlib.Path(os.environ["NIXOS_CONFIG_REPO"]) / "notes").write_text("user work\n")
|
||||
if scenario in ("activation-failure", "rollback-failure") and args[1] == os.environ["NIXOS_UPDATE_MODE"]:
|
||||
if scenario in ("activation-failure", "rollback-failure") and args[1] == "boot":
|
||||
if args[-1] == os.environ["BUILT"] or scenario == "rollback-failure": sys.exit(45)
|
||||
elif name == "readlink":
|
||||
previous = "/nix/var/nix/profiles/system" if os.environ["NIXOS_UPDATE_MODE"] == "boot" else "/run/current-system"
|
||||
assert args == ["-f", previous]
|
||||
assert args == ["-f", "/nix/var/nix/profiles/system"]
|
||||
print(os.environ["PREVIOUS"])
|
||||
else:
|
||||
raise AssertionError(name)
|
||||
'''
|
||||
|
||||
|
||||
def run_case(scenario, host="dev", mode="switch"):
|
||||
def run_case(scenario, host="dev"):
|
||||
with tempfile.TemporaryDirectory(prefix="update-test-") as directory:
|
||||
root = Path(directory)
|
||||
repo = root / "repo with spaces"
|
||||
@@ -59,7 +58,7 @@ def run_case(scenario, host="dev", mode="switch"):
|
||||
calls_path = root / "calls.jsonl"
|
||||
env = dict(os.environ, HOME=str(root / "home"), CACHE_DIRECTORY=str(cache),
|
||||
NIXOS_CONFIG_REPO=str(repo), NIXOS_UPDATE_HOST=host,
|
||||
NIXOS_UPDATE_MODE=mode, SCENARIO=scenario,
|
||||
SCENARIO=scenario,
|
||||
CALLS=str(calls_path), BUILT=str(root / "candidate-system"),
|
||||
PREVIOUS=str(root / "previous-system"),
|
||||
GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null")
|
||||
@@ -94,21 +93,22 @@ def run_case(scenario, host="dev", mode="switch"):
|
||||
sudo = [call for call in calls if call[0] == "sudo"]
|
||||
failed = scenario.endswith("failure") or scenario == "invalid-host"
|
||||
assert (result.returncode != 0) == failed, (scenario, result.stdout)
|
||||
assert all(call[2] in ("dry-activate", "boot") for call in sudo), calls
|
||||
if scenario == "success":
|
||||
assert json.loads((repo / "flake.lock").read_text())["revision"] == 2
|
||||
assert git("log", "-1", "--format=%an <%ae>") == "NixOS Updater <nixos-updater@localhost>"
|
||||
assert [call[2] for call in sudo] == ["dry-activate", mode]
|
||||
assert [call[2] for call in sudo] == ["dry-activate", "boot"]
|
||||
assert (cache / "last-success").is_file()
|
||||
assert git("status", "--porcelain") == ""
|
||||
elif scenario == "activation-failure":
|
||||
assert json.loads((repo / "flake.lock").read_text())["revision"] == 1
|
||||
assert [call[2] for call in sudo] == ["dry-activate", mode, mode]
|
||||
assert [call[2] for call in sudo] == ["dry-activate", "boot", "boot"]
|
||||
assert sudo[-1][-1] != env["BUILT"]
|
||||
assert git("log", "-1", "--format=%s").startswith("Revert")
|
||||
elif scenario == "rollback-failure":
|
||||
# Keep the candidate commit for recovery, never claim rollback worked.
|
||||
assert json.loads((repo / "flake.lock").read_text())["revision"] == 2
|
||||
assert [call[2] for call in sudo] == ["dry-activate", mode, mode]
|
||||
assert [call[2] for call in sudo] == ["dry-activate", "boot", "boot"]
|
||||
assert not (cache / "last-success").exists()
|
||||
else:
|
||||
assert git("rev-parse", "HEAD") == baseline, (scenario, result.stdout)
|
||||
@@ -120,12 +120,12 @@ def run_case(scenario, host="dev", mode="switch"):
|
||||
assert (repo / "notes").read_text() == "user work\n"
|
||||
if scenario in ("dirty", "detached", "invalid-host"):
|
||||
assert not calls
|
||||
print("PASS", host, mode, scenario)
|
||||
print("PASS", host, "boot", scenario)
|
||||
|
||||
|
||||
for host, mode in [("dev", "switch"), ("nixos", "boot")]:
|
||||
for host in ["dev", "nixos"]:
|
||||
for scenario in ["dirty", "detached", "unchanged", "evaluation-failure", "build-failure",
|
||||
"concurrent-edit", "concurrent-dry-edit", "dry-activation-failure",
|
||||
"activation-failure", "rollback-failure", "success"]:
|
||||
run_case(scenario, host, mode)
|
||||
run_case("invalid-host", "nixos", "switch")
|
||||
run_case(scenario, host)
|
||||
run_case("invalid-host", "not-a-host")
|
||||
|
||||
+35
-16
@@ -5,35 +5,54 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
env = config.systemd.services.nixos-update.environment;
|
||||
repo = env.NIXOS_CONFIG_REPO;
|
||||
runtimeInputs = with pkgs; [
|
||||
nix
|
||||
git
|
||||
coreutils
|
||||
util-linux
|
||||
];
|
||||
updater = pkgs.writeShellApplication {
|
||||
name = "update-system";
|
||||
runtimeInputs = with pkgs; [
|
||||
nix
|
||||
git
|
||||
coreutils
|
||||
util-linux
|
||||
];
|
||||
inherit runtimeInputs;
|
||||
text = builtins.readFile ./update-system.sh;
|
||||
};
|
||||
switcher = pkgs.writeShellApplication {
|
||||
name = "switch-system";
|
||||
inherit runtimeInputs;
|
||||
text = ''
|
||||
export NIXOS_CONFIG_REPO=${lib.escapeShellArg repo}
|
||||
export NIXOS_UPDATE_HOST=${lib.escapeShellArg env.NIXOS_UPDATE_HOST}
|
||||
${builtins.readFile ./switch-system.sh}
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [ updater ];
|
||||
environment.systemPackages = [
|
||||
updater
|
||||
switcher
|
||||
];
|
||||
# Z does not follow symlinks: in /etc/nix, Nix-owned configuration links and
|
||||
# result links never cause ownership changes in /etc/static or /nix/store.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${repo} 0755 dev users -"
|
||||
"Z ${repo} - dev users -"
|
||||
"d /var/cache/nixos-update 0700 dev users -"
|
||||
"Z /var/cache/nixos-update - dev users -"
|
||||
];
|
||||
systemd.services.nixos-update = {
|
||||
description = "Build and record host-specific NixOS/tool updates without disturbing local work";
|
||||
environment = {
|
||||
NIXOS_CONFIG_REPO = lib.mkDefault "/etc/nixos";
|
||||
NIXOS_UPDATE_HOST = lib.mkDefault "dev";
|
||||
NIXOS_UPDATE_MODE = lib.mkDefault "switch";
|
||||
};
|
||||
description = "Build and stage NixOS/tool updates for the next boot";
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
path = [ "/run/wrappers" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = lib.mkDefault "dev";
|
||||
Group = lib.mkDefault "users";
|
||||
WorkingDirectory = config.systemd.services.nixos-update.environment.NIXOS_CONFIG_REPO;
|
||||
User = "dev";
|
||||
Group = "users";
|
||||
WorkingDirectory = repo;
|
||||
CacheDirectory = "nixos-update";
|
||||
CacheDirectoryMode = "0700";
|
||||
UMask = "0077";
|
||||
Nice = 10;
|
||||
IOSchedulingClass = "idle";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
# Preserve locally provisioned passwords on every host; never invent one.
|
||||
users.mutableUsers = true;
|
||||
users.users.dev = {
|
||||
isNormalUser = true;
|
||||
uid = 1001;
|
||||
|
||||
+36
-26
@@ -1,47 +1,57 @@
|
||||
# Opt-in physical workstation integration. Never imported by the EC2 host.
|
||||
# Local hardware/session integration. Shared by the laptop and graphical VM,
|
||||
# not the headless EC2 host; all application/user settings live in common.nix.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
managedHyprlandSession =
|
||||
pkgs.runCommand "hyprland-managed-session"
|
||||
{
|
||||
passthru.providedSessions = [ "hyprland-uwsm" ];
|
||||
}
|
||||
''
|
||||
mkdir -p "$out/share/wayland-sessions"
|
||||
ln -s ${config.programs.hyprland.package}/share/wayland-sessions/hyprland-uwsm.desktop \
|
||||
"$out/share/wayland-sessions/hyprland-uwsm.desktop"
|
||||
'';
|
||||
in
|
||||
{
|
||||
networking.networkmanager.enable = true;
|
||||
networking.networkmanager = {
|
||||
enable = true;
|
||||
plugins = [ pkgs.networkmanager-openvpn ];
|
||||
};
|
||||
networking.dhcpcd.enable = false;
|
||||
users.users.dev.extraGroups = [ "networkmanager" ];
|
||||
|
||||
services.xserver.enable = true;
|
||||
services.displayManager = {
|
||||
sddm.enable = true;
|
||||
defaultSession = "hyprland-uwsm";
|
||||
# Plain Hyprland bypasses the UWSM-owned bar/idle/polkit services.
|
||||
sessionPackages = lib.mkForce [ managedHyprlandSession ];
|
||||
};
|
||||
services.printing.enable = true;
|
||||
services.udisks2.enable = true;
|
||||
services.gvfs.enable = true;
|
||||
services.fwupd.enable = true;
|
||||
home-manager.users.dev.services.udiskie.enable = true;
|
||||
environment.systemPackages = [ pkgs.networkmanagerapplet ];
|
||||
|
||||
hardware.bluetooth = {
|
||||
enable = true;
|
||||
powerOnBoot = false;
|
||||
};
|
||||
services.blueman.enable = true;
|
||||
# Keep Blueman's manager/mechanism, not a second tray applet next to ashell.
|
||||
services.upower.enable = true;
|
||||
services.power-profiles-daemon.enable = true;
|
||||
# Retain Blueman's manager without a second tray applet next to ashell.
|
||||
home-manager.users.dev.xdg.configFile."autostart/blueman.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Blueman
|
||||
Hidden=true
|
||||
'';
|
||||
services.upower.enable = true;
|
||||
services.power-profiles-daemon.enable = true;
|
||||
services.udisks2.enable = true;
|
||||
home-manager.users.dev.services.udiskie.enable = true;
|
||||
environment.systemPackages = [ pkgs.networkmanagerapplet ];
|
||||
|
||||
programs.regreet = {
|
||||
enable = true;
|
||||
theme.name = "Adwaita-dark";
|
||||
font = {
|
||||
package = pkgs.inter;
|
||||
name = "Inter";
|
||||
size = 13;
|
||||
};
|
||||
settings.GTK.application_prefer_dark_theme = true;
|
||||
};
|
||||
# No autologin. The physical host must supply a secure credential file.
|
||||
assertions = [
|
||||
{
|
||||
assertion = config.users.users.dev.hashedPasswordFile != null;
|
||||
message = "workstation.nix requires users.users.dev.hashedPasswordFile (provision outside the Nix store); no production password is invented.";
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user