staging-nixos merge for 2026-06-01 (#526774)

This commit is contained in:
zowoq
2026-06-01 22:39:39 +00:00
committed by GitHub
44 changed files with 1540 additions and 786 deletions

View File

@@ -19,6 +19,12 @@
- `uhttpmock` providing 0.0 ABI was removed. `uhttpmock_1_0` providing 1.0 ABI was renamed to `uhttpmock` and `uhttpmock_1_0` was kept as an alias.
- The ARMv5 Linux kernel build now uses a standard configuration and generates a standard compressed image instead of the deprecated legacy UBoot image format.
`lib.systems.{examples,platforms}.{sheevaplug,pogoplug4}` have been unified into `lib.systems.examples.armv5tel-multiplatform`.
Note that there is no official support for ARMv5 and it is not possible to build even a simple NixOS configuration out of the box.
- Support for the legacy UBoot image format has been removed from the Linux kernel builders, as it is deprecated upstream and no longer used by any platform in Nixpkgs.
- `requireFile` now sets `meta.license = lib.licenses.unfree` by default. Users of `requireFile`-based derivations that preserve this default will need to explicitly allow their evaluation as described in [](#sec-allow-unfree).
- `librest` providing 0.7 ABI was removed. `librest_1_0` providing 1.0 ABI was renamed to `librest` and `librest_1_0` was kept as an alias.

View File

@@ -40,10 +40,9 @@ rec {
rust.rustcTarget = "powerpc-unknown-linux-gnu";
};
sheevaplug = {
armv5tel-multiplatform = {
config = "armv5tel-unknown-linux-gnueabi";
}
// platforms.sheevaplug;
};
raspberryPi = {
config = "armv6l-unknown-linux-gnueabihf";
@@ -99,11 +98,6 @@ rec {
useLLVM = true;
};
pogoplug4 = {
config = "armv5tel-unknown-linux-gnueabi";
}
// platforms.pogoplug4;
ben-nanonote = {
config = "mipsel-unknown-linux-uclibc";
}

View File

@@ -46,138 +46,15 @@ rec {
## ARM
##
pogoplug4 = {
armv5tel-multiplatform = {
linux-kernel = {
name = "pogoplug4";
name = "armv5tel-multiplatform";
baseConfig = "multi_v5_defconfig";
autoModules = false;
extraConfig = ''
# Ubi for the mtd
MTD_UBI y
UBIFS_FS y
UBIFS_FS_XATTR y
UBIFS_FS_ADVANCED_COMPR y
UBIFS_FS_LZO y
UBIFS_FS_ZLIB y
UBIFS_FS_DEBUG n
'';
makeFlags = [ "LOADADDR=0x8000" ];
target = "uImage";
# TODO reenable once manual-config's config actually builds a .dtb and this is checked to be working
#DTB = true;
};
gcc = {
arch = "armv5te";
};
};
sheevaplug = {
linux-kernel = {
name = "sheevaplug";
baseConfig = "multi_v5_defconfig";
autoModules = false;
extraConfig = ''
BLK_DEV_RAM y
BLK_DEV_INITRD y
BLK_DEV_CRYPTOLOOP m
BLK_DEV_DM m
DM_CRYPT m
MD y
BTRFS_FS m
XFS_FS m
JFS_FS m
EXT4_FS m
USB_STORAGE_CYPRESS_ATACB m
# mv cesa requires this sw fallback, for mv-sha1
CRYPTO_SHA1 y
# Fast crypto
CRYPTO_TWOFISH y
CRYPTO_TWOFISH_COMMON y
CRYPTO_BLOWFISH y
CRYPTO_BLOWFISH_COMMON y
IP_PNP y
IP_PNP_DHCP y
NFS_FS y
ROOT_NFS y
TUN m
NFS_V4 y
NFS_V4_1 y
NFS_FSCACHE y
NFSD m
NFSD_V2_ACL y
NFSD_V3 y
NFSD_V3_ACL y
NFSD_V4 y
NETFILTER y
IP_NF_IPTABLES y
IP_NF_FILTER y
IP_NF_MATCH_ADDRTYPE y
IP_NF_TARGET_LOG y
IP_NF_MANGLE y
IPV6 m
VLAN_8021Q m
CIFS y
CIFS_XATTR y
CIFS_POSIX y
CIFS_FSCACHE y
CIFS_ACL y
WATCHDOG y
WATCHDOG_CORE y
ORION_WATCHDOG m
ZRAM m
NETCONSOLE m
# Disable OABI to have seccomp_filter (required for systemd)
# https://github.com/raspberrypi/firmware/issues/651
OABI_COMPAT n
# Fail to build
DRM n
SCSI_ADVANSYS n
USB_ISP1362_HCD n
SND_SOC n
SND_ALI5451 n
FB_SAVAGE n
SCSI_NSP32 n
ATA_SFF n
SUNGEM n
IRDA n
ATM_HE n
SCSI_ACARD n
BLK_DEV_CMD640_ENHANCED n
FUSE_FS m
# systemd uses cgroups
CGROUPS y
# Latencytop
LATENCYTOP y
# Ubi for the mtd
MTD_UBI y
UBIFS_FS y
UBIFS_FS_XATTR y
UBIFS_FS_ADVANCED_COMPR y
UBIFS_FS_LZO y
UBIFS_FS_ZLIB y
UBIFS_FS_DEBUG n
# Kdb, for kernel troubles
KGDB y
KGDB_SERIAL_CONSOLE y
KGDB_KDB y
'';
makeFlags = [ "LOADADDR=0x0200000" ];
target = "uImage";
DTB = true; # Beyond 3.10
DTB = true;
autoModules = true;
preferBuiltin = true;
target = "zImage";
};
gcc = {
arch = "armv5te";
@@ -192,11 +69,6 @@ rec {
DTB = true;
autoModules = true;
preferBuiltin = true;
extraConfig = ''
# Disable OABI to have seccomp_filter (required for systemd)
# https://github.com/raspberrypi/firmware/issues/651
OABI_COMPAT n
'';
target = "zImage";
};
gcc = {
@@ -217,15 +89,6 @@ rec {
};
zero-gravitas = {
linux-kernel = {
name = "zero-gravitas";
baseConfig = "zero-gravitas_defconfig";
# Target verified by checking /boot on reMarkable 1 device
target = "zImage";
autoModules = false;
DTB = true;
};
gcc = {
fpu = "neon";
cpu = "cortex-a9";
@@ -233,15 +96,6 @@ rec {
};
zero-sugar = {
linux-kernel = {
name = "zero-sugar";
baseConfig = "zero-sugar_defconfig";
DTB = true;
autoModules = false;
preferBuiltin = true;
target = "zImage";
};
gcc = {
cpu = "cortex-a7";
fpu = "neon-vfpv4";
@@ -249,49 +103,6 @@ rec {
};
};
utilite = {
linux-kernel = {
name = "utilite";
maseConfig = "multi_v7_defconfig";
autoModules = false;
extraConfig = ''
# Ubi for the mtd
MTD_UBI y
UBIFS_FS y
UBIFS_FS_XATTR y
UBIFS_FS_ADVANCED_COMPR y
UBIFS_FS_LZO y
UBIFS_FS_ZLIB y
UBIFS_FS_DEBUG n
'';
makeFlags = [ "LOADADDR=0x10800000" ];
target = "uImage";
DTB = true;
};
gcc = {
cpu = "cortex-a9";
fpu = "neon";
};
};
guruplug = lib.recursiveUpdate sheevaplug {
# Define `CONFIG_MACH_GURUPLUG' (see
# <http://kerneltrap.org/mailarchive/git-commits-head/2010/5/19/33618>)
# and other GuruPlug-specific things. Requires the `guruplug-defconfig'
# patch.
linux-kernel.baseConfig = "guruplug_defconfig";
};
beaglebone = lib.recursiveUpdate armv7l-hf-multiplatform {
linux-kernel = {
name = "beaglebone";
baseConfig = "bb.org_defconfig";
autoModules = false;
extraConfig = ""; # TBD kernel config
target = "zImage";
};
};
# https://developer.android.com/ndk/guides/abis#v7a
armv7a-android = {
linux-kernel.name = "armeabi-v7a";
@@ -305,32 +116,11 @@ rec {
armv7l-hf-multiplatform = {
linux-kernel = {
name = "armv7l-hf-multiplatform";
Major = "2.6"; # Using "2.6" enables 2.6 kernel syscalls in glibc.
baseConfig = "multi_v7_defconfig";
baseConfig = "defconfig";
DTB = true;
autoModules = true;
preferBuiltin = true;
target = "zImage";
extraConfig = ''
# Serial port for Raspberry Pi 3. Wasn't included in ARMv7 defconfig
# until 4.17.
SERIAL_8250_BCM2835AUX y
SERIAL_8250_EXTENDED y
SERIAL_8250_SHARE_IRQ y
# Hangs ODROID-XU4
ARM_BIG_LITTLE_CPUIDLE n
# Disable OABI to have seccomp_filter (required for systemd)
# https://github.com/raspberrypi/firmware/issues/651
OABI_COMPAT n
# >=5.12 fails with:
# drivers/net/ethernet/micrel/ks8851_common.o: in function `ks8851_probe_common':
# ks8851_common.c:(.text+0x179c): undefined reference to `__this_module'
# See: https://lore.kernel.org/netdev/20210116164828.40545-1-marex@denx.de/T/
KS8851_MLL y
'';
};
gcc = {
# Some table about fpu flags:
@@ -363,22 +153,6 @@ rec {
autoModules = true;
preferBuiltin = true;
extraConfig = ''
# Raspberry Pi 3 stuff. Not needed for s >= 4.10.
ARCH_BCM2835 y
BCM2835_MBOX y
BCM2835_WDT y
RASPBERRYPI_FIRMWARE y
RASPBERRYPI_POWER y
SERIAL_8250_BCM2835AUX y
SERIAL_8250_EXTENDED y
SERIAL_8250_SHARE_IRQ y
# Cavium ThunderX stuff.
PCI_HOST_THUNDER_ECAM y
# Nvidia Tegra stuff.
PCI_TEGRA y
# The default (=y) forces us to have the XHCI firmware available in initrd,
# which our initrd builder can't currently do easily.
USB_XHCI_TEGRA m
@@ -412,74 +186,6 @@ rec {
};
fuloong2f_n32 = {
linux-kernel = {
name = "fuloong2f_n32";
baseConfig = "lemote2f_defconfig";
autoModules = false;
extraConfig = ''
MIGRATION n
COMPACTION n
# nixos mounts some cgroup
CGROUPS y
BLK_DEV_RAM y
BLK_DEV_INITRD y
BLK_DEV_CRYPTOLOOP m
BLK_DEV_DM m
DM_CRYPT m
MD y
EXT4_FS m
USB_STORAGE_CYPRESS_ATACB m
IP_PNP y
IP_PNP_DHCP y
IP_PNP_BOOTP y
NFS_FS y
ROOT_NFS y
TUN m
NFS_V4 y
NFS_V4_1 y
NFS_FSCACHE y
NFSD m
NFSD_V2_ACL y
NFSD_V3 y
NFSD_V3_ACL y
NFSD_V4 y
# Fail to build
DRM n
SCSI_ADVANSYS n
USB_ISP1362_HCD n
SND_SOC n
SND_ALI5451 n
FB_SAVAGE n
SCSI_NSP32 n
ATA_SFF n
SUNGEM n
IRDA n
ATM_HE n
SCSI_ACARD n
BLK_DEV_CMD640_ENHANCED n
FUSE_FS m
# Needed for udev >= 150
SYSFS_DEPRECATED_V2 n
VGA_CONSOLE n
VT_HW_CONSOLE_BINDING y
SERIAL_8250_CONSOLE y
FRAMEBUFFER_CONSOLE y
EXT2_FS y
EXT3_FS y
MAGIC_SYSRQ y
# The kernel doesn't boot at all, with FTRACE
FTRACE n
'';
target = "vmlinux";
};
gcc = {
arch = "loongson2f";
float = "hard";
@@ -525,35 +231,6 @@ rec {
};
};
# based on:
# https://www.mail-archive.com/qemu-discuss@nongnu.org/msg05179.html
# https://gmplib.org/~tege/qemu.html#mips64-debian
mips64el-qemu-linux-gnuabi64 = {
linux-kernel = {
name = "mips64el";
baseConfig = "64r2el_defconfig";
target = "vmlinuz";
autoModules = false;
DTB = true;
# for qemu 9p passthrough filesystem
extraConfig = ''
MIPS_MALTA y
PAGE_SIZE_4KB y
CPU_LITTLE_ENDIAN y
CPU_MIPS64_R2 y
64BIT y
CPU_MIPS64_R2 y
NET_9P y
NET_9P_VIRTIO y
9P_FS y
9P_FS_POSIX_ACL y
PCI y
VIRTIO_PCI y
'';
};
};
##
## Other
##
@@ -607,7 +284,7 @@ rec {
if version == null then
pc
else if lib.versionOlder version "6" then
sheevaplug
armv5tel-multiplatform
else if lib.versionOlder version "7" then
raspberrypi
else

View File

@@ -18,10 +18,12 @@
- `boot.vesa` has been removed. It was deprecated in 2020 because Xorg now works better with kernel modesetting. If you still need the legacy VESA 800x600 fallback, set `boot.kernelParams = [ "vga=0x317" "nomodeset" ];` directly.
- Support for the legacy UBoot image format has been removed from the initrd generators, as it is deprecated upstream and no longer used by any platform in Nixpkgs.
- Python 2 has been removed from the top-level package set, as it is long past end-of-life. The `python2`, `python27`, `python2Full`, `python27Full`, `python2Packages`, and `python27Packages` attributes, along with the legacy `python`, `pythonFull`, and `pythonPackages` aliases, now throw an error directing you to `python3`. The `isPy2` and `isPy27` package flags have been removed accordingly. The only remaining Python 2 interpreter is vendored inside the `resholve` package for its `oil` dependency and is not exposed for general use.
## Other Notable Changes {#sec-release-26.11-notable-changes}
<!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->
- Create the first release note entry in this section!
- The `newuidmap` and `newgidmap` security wrappers are now installed with `cap_setuid`/`cap_setgid` file capabilities instead of the setuid-root bit, matching shadow's `--with-fcaps` install mode and other major distributions. Rootless containers (podman, docker-rootless, unprivileged user namespaces) are unaffected. The only behavioural change is that mapping host uid 0 via `/etc/subuid` (which NixOS never configures by default) additionally requires `cap_setfcap`; users who explicitly grant uid 0 in a subuid range can restore the previous behaviour with `security.wrappers.newuidmap.capabilities = lib.mkForce "cap_setuid,cap_setfcap+ep";`.

View File

@@ -1031,6 +1031,7 @@ class QemuMachine(BaseMachine):
As soon as we read some data from the socket here, we assume that
our root shell is operational.
"""
assert self.shell
(ready, _, _) = select.select([self.shell], [], [], timeout_secs)
return bool(ready)

View File

@@ -314,6 +314,27 @@ in
name = "nixos-rebuild";
package = config.system.build.nixos-rebuild;
})
(
{ config, ... }:
{
options.system.tools.nixos-rebuild.enableRun0Elevation = lib.mkEnableOption ''
support for being targeted by `nixos-rebuild --elevate=run0
--ask-elevate-password`.
This enables polkit and adds {command}`polkit-stdin-agent` to
{option}`environment.systemPackages` so that a deploying host
can find a target-architecture agent at
{file}`<toplevel>/sw/bin/polkit-stdin-agent` after copying the
closure (which is required for cross-architecture deploys and
mismatched nixpkgs revisions to work).
'';
config = lib.mkIf config.system.tools.nixos-rebuild.enableRun0Elevation {
security.polkit.enable = lib.mkDefault true;
environment.systemPackages = [ pkgs.polkit-stdin-agent ];
};
}
)
(mkToolModule {
name = "nixos-version";
package = nixos-version;

View File

@@ -267,13 +267,22 @@ in
group = "root";
inherit source;
};
mkCapRoot = capabilities: source: {
inherit capabilities source;
owner = "root";
group = "root";
};
in
{
su = mkSetuidRoot "${config.security.shadow.su.package}/bin/su";
sg = mkSetuidRoot "${cfg.package.out}/bin/sg";
newgrp = mkSetuidRoot "${cfg.package.out}/bin/newgrp";
newuidmap = mkSetuidRoot "${cfg.package.out}/bin/newuidmap";
newgidmap = mkSetuidRoot "${cfg.package.out}/bin/newgidmap";
# File capabilities instead of setuid root, mirroring shadow's
# own --with-fcaps install mode and what Arch/Fedora/Debian ship.
# The kernel only requires CAP_SETUID/CAP_SETGID over the parent
# userns to write a multi-line /proc/<pid>/[ug]id_map.
newuidmap = mkCapRoot "cap_setuid+ep" "${cfg.package.out}/bin/newuidmap";
newgidmap = mkCapRoot "cap_setgid+ep" "${cfg.package.out}/bin/newgidmap";
}
// lib.optionalAttrs config.users.mutableUsers {
chsh = mkSetuidRoot "${cfg.package.out}/bin/chsh";

View File

@@ -322,6 +322,9 @@ in
description = "Run user-specific NixOS activation";
script = config.system.userActivationScripts.script;
unitConfig.ConditionUser = "!@system";
# switch-to-configuration restarts this explicitly on every switch.
restartIfChanged = false;
serviceConfig.RemainAfterExit = true;
serviceConfig.Type = "oneshot";
wantedBy = [ "default.target" ];
};

View File

@@ -3,10 +3,10 @@
e2fsprogs,
iproute2,
lib,
mypy,
ruff,
setuptools,
systemd,
ty,
}:
buildPythonApplication {
@@ -35,13 +35,13 @@ buildPythonApplication {
doCheck = true;
nativeCheckInputs = [
mypy
ruff
ty
];
checkPhase = ''
echo -e "\x1b[32m## run mypy\x1b[0m"
mypy run_nspawn
echo -e "\x1b[32m## run ty\x1b[0m"
ty check --error-on-warning run_nspawn
echo -e "\x1b[32m## run ruff check\x1b[0m"
ruff check .
echo -e "\x1b[32m## run ruff format\x1b[0m"

View File

@@ -551,7 +551,7 @@ in
y = 768;
};
description = ''
The resolution of the virtual machine display.
The resolution of the virtual machine display (relevant only if virtualised machine uses grub bootloader).
'';
};
@@ -1379,7 +1379,6 @@ in
"-device usb-tablet,bus=usb-bus.0"
])
(mkIf pkgs.stdenv.hostPlatform.isAarch [
"-device virtio-gpu-pci"
"-device usb-ehci,id=usb0"
"-device usb-kbd"
"-device usb-tablet"

View File

@@ -22,6 +22,8 @@
};
system.includeBuildDependencies = true;
# Needed so the offline build of the target config succeeds.
system.extraDependencies = [ pkgs.polkit-stdin-agent ];
virtualisation = {
cores = 2;
@@ -49,6 +51,11 @@
users.users.alice.extraGroups = [ "wheel" ];
users.users.bob.extraGroups = [ "wheel" ];
# Needed for --elevate=run0. NixOS's default polkit admin rule is
# `unix-group:wheel`, so bob (in wheel) can authenticate with his
# own password via polkit-stdin-agent.
system.tools.nixos-rebuild.enableRun0Elevation = true;
# Disable sudo for root to ensure sudo isn't called without `--sudo`
security.sudo.extraRules = lib.mkForce [
{
@@ -142,6 +149,7 @@
deployer.copy_from_host("${configFile "config-1-deployed"}", "/root/configuration-1.nix")
deployer.copy_from_host("${configFile "config-2-deployed"}", "/root/configuration-2.nix")
deployer.copy_from_host("${configFile "config-3-deployed"}", "/root/configuration-3.nix")
deployer.copy_from_host("${configFile "config-4-deployed"}", "/root/configuration-4.nix")
deployer.copy_from_host("${targetNetworkJSON}", "/root/target-network.json")
deployer.copy_from_host("${targetConfigJSON}", "/root/target-configuration.json")
@@ -168,6 +176,20 @@
target_hostname = deployer.succeed("ssh alice@target cat /etc/hostname").rstrip()
assert target_hostname == "config-3-deployed", f"{target_hostname=}"
with subtest("Deploy to bob@target with run0 and password"):
# polkit-stdin-agent registers an agent for systemd-run on the
# target and answers the PAM conversation with the password we
# supply locally. The agent is resolved on the target from
# <toplevel>/sw/bin (see Run0Elevator._remote_agent_argv).
deployer.send_chars("nixos-rebuild switch -I nixos-config=/root/configuration-4.nix --target-host bob@target --elevate=run0 --ask-elevate-password\n")
deployer.wait_until_tty_matches("1", "\\[run0\\] password for bob@target")
deployer.send_chars("${nodes.target.users.users.bob.password}\n")
deployer.wait_until_tty_matches("1", "Done. The new configuration is /nix/store/.*config-4-deployed")
target_hostname = deployer.succeed("ssh alice@target cat /etc/hostname").rstrip()
assert target_hostname == "config-4-deployed", f"{target_hostname=}"
# The target-arch agent is reachable at the stable sw/bin path.
target.succeed("test -x /run/current-system/sw/bin/polkit-stdin-agent")
with subtest("Deploy works with very long TMPDIR"):
tmp_dir = "/var/folder/veryveryveryveryverylongpathnamethatdoesnotworkwithcontrolpath"
deployer.succeed(f"mkdir -p {tmp_dir}")

View File

@@ -739,6 +739,22 @@ in
'';
};
# As above, but with reloadIfChanged: pass 2 must reload, not
# restart.
userServiceMigratedToNixosReloadOnly.configuration = {
imports = [ userServiceMigratedToNixosNoStop.configuration ];
systemd.user.services.migrated = {
reloadIfChanged = true;
serviceConfig.ExecReload = "${pkgs.coreutils}/bin/true";
};
};
# As above, but with restartIfChanged = false: pass 2 must skip it.
userServiceMigratedToNixosNoRestart.configuration = {
imports = [ userServiceMigratedToNixosNoStop.configuration ];
systemd.user.services.migrated.restartIfChanged = false;
};
no_inhibitors.configuration.system.switch.inhibitors = lib.mkForce { };
inhibitors.configuration.system.switch.inhibitors = lib.mkForce {
@@ -810,6 +826,15 @@ in
RemainAfterExit=true
ExecStart=${pkgs.runtimeShell} -c 'echo home > %t/migrated-owner'
'';
# Unit file placed in ~/.local/share/systemd/user (lower priority than
# /etc) to simulate a package-shipped unit.
dataMigratedUnit = pkgs.writeText "migrated.service" ''
[Service]
Type=oneshot
RemainAfterExit=true
ExecStart=${pkgs.runtimeShell} -c 'echo data > %t/migrated-owner'
'';
in
# python
''
@@ -1729,9 +1754,10 @@ in
out = switch_to_specialisation("${machine}", "simpleUserService")
user_systemctl("is-active usertest.service")
# No-op switch does nothing
# No-op switch leaves the test unit alone.
out = switch_to_specialisation("${machine}", "simpleUserService")
assert_lacks(out, "user units:")
assert_lacks(out, "usertest.service")
assert_contains(out, "restarting the following user units: nixos-activation.service")
# Modifying the unit stop-starts it (default stopIfChanged=true)
out = switch_to_specialisation("${machine}", "simpleUserServiceModified")
@@ -1748,7 +1774,7 @@ in
# reloadIfChanged=true reloads instead
out = switch_to_specialisation("${machine}", "simpleUserServiceReload")
assert_lacks(out, "stopping the following user units:")
assert_lacks(out, "restarting the following user units:")
assert_lacks(out, "restarting the following user units: usertest.service")
assert_contains(out, "reloading the following user units: usertest.service")
user_systemctl("is-active usertest.service")
@@ -1818,6 +1844,59 @@ in
out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner")
assert_contains(out, "nixos")
# Pass 2 must honour reloadIfChanged.
switch_to_specialisation("${machine}", "")
machine.fail(f"sudo -u usertest {user_env} systemctl --user is-active migrated.service")
seed_home_unit()
out = switch_to_specialisation("${machine}", "userServiceMigratedToNixosReloadOnly")
assert_lacks(out, "restarting (post-activation) the following user units: migrated.service")
assert_contains(out, "reloading (post-activation) the following user units: migrated.service")
user_systemctl("is-active migrated.service")
# Reloaded only, so the home ExecStart never re-ran.
out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner")
assert_contains(out, "home")
# Pass 2 must honour restartIfChanged = false.
switch_to_specialisation("${machine}", "")
machine.fail(f"sudo -u usertest {user_env} systemctl --user is-active migrated.service")
seed_home_unit()
out = switch_to_specialisation("${machine}", "userServiceMigratedToNixosNoRestart")
assert_lacks(out, "\nrestarting (post-activation) the following user units: migrated.service")
assert_contains(out, "NOT restarting (post-activation) the following user units: migrated.service")
user_systemctl("is-active migrated.service")
out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner")
assert_contains(out, "home")
# Migration from a lower-priority search-path entry ($XDG_DATA_HOME
# here, standing in for ~/.nix-profile/share etc.). /etc outranks
# these, so pass 2 must restart onto the /etc definition.
switch_to_specialisation("${machine}", "")
machine.fail(f"sudo -u usertest {user_env} systemctl --user is-active migrated.service")
machine.succeed(
"sudo -u usertest mkdir -p ~usertest/.local/share/systemd/user",
"sudo -u usertest cp ${dataMigratedUnit} ~usertest/.local/share/systemd/user/migrated.service",
)
user_systemctl("daemon-reload")
user_systemctl("start migrated.service")
user_systemctl("is-active migrated.service")
out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner")
assert_contains(out, "data")
out = user_systemctl("show -p FragmentPath migrated.service")
assert_contains(out, "/.local/share/systemd/user/migrated.service")
out = switch_to_specialisation("${machine}", "userServiceMigratedShadowed")
assert_contains(out, "restarting (post-activation) the following user units: migrated.service")
user_systemctl("is-active migrated.service")
out = user_systemctl("show -p FragmentPath migrated.service")
assert_contains(out, "/etc/systemd/user/migrated.service")
out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner")
assert_contains(out, "nixos")
# Switching again must NOT touch it: /etc already had it, so it is
# not a candidate even though the lower-priority copy is still there.
out = switch_to_specialisation("${machine}", "userServiceMigratedShadowed")
assert_lacks(out, "migrated.service")
machine.succeed("sudo -u usertest rm -rf ~usertest/.local/share/systemd")
user_systemctl("daemon-reload")
# Units that remain shadowed by ~/.config must be left alone in both
# passes even though /etc now also defines them.
switch_to_specialisation("${machine}", "")

View File

@@ -13,12 +13,14 @@
isNormalUser = true;
};
systemd.user.tmpfiles.users.alice.rules = [ "r %h/file-to-remove" ];
specialisation.changed.configuration.system.userActivationScripts.bar = "true";
};
testScript = ''
def verify_user_activation_run_count(n):
machine.succeed(
'[[ "$(find /home/alice/ -name user-activation-ran.\\* | wc -l)" == %s ]]' % n
t.assertEqual(
n,
int(machine.succeed('find /home/alice/ -name user-activation-ran.\\* | wc -l').rstrip())
)
@@ -36,5 +38,12 @@
machine.succeed("/run/current-system/bin/switch-to-configuration test")
verify_user_activation_run_count(2)
machine.succeed("[[ ! -f /home/alice/file-to-remove ]] || false")
# Activation must not be killed while running.
machine.fail("journalctl -b _SYSTEMD_USER_UNIT=nixos-activation.service | grep -q 'code=killed'")
# Changed activation script: still exactly one run.
machine.succeed("/run/current-system/specialisation/changed/bin/switch-to-configuration test")
verify_user_activation_run_count(3)
machine.fail("journalctl -b _SYSTEMD_USER_UNIT=nixos-activation.service | grep -q 'code=killed'")
'';
}

View File

@@ -1,18 +1,15 @@
rec {
cat = {
executable = pkgs: "cat";
ubootName = "none";
extension = ".cpio";
};
gzip = {
executable = pkgs: "${pkgs.gzip}/bin/gzip";
defaultArgs = [ "-9n" ];
ubootName = "gzip";
extension = ".gz";
};
bzip2 = {
executable = pkgs: "${pkgs.bzip2}/bin/bzip2";
ubootName = "bzip2";
extension = ".bz2";
};
xz = {
@@ -29,24 +26,20 @@ rec {
"--check=crc32"
"--lzma1=dict=512KiB"
];
ubootName = "lzma";
extension = ".lzma";
};
lz4 = {
executable = pkgs: "${pkgs.lz4}/bin/lz4";
defaultArgs = [ "-l" ];
ubootName = "lz4";
extension = ".lz4";
};
lzop = {
executable = pkgs: "${pkgs.lzop}/bin/lzop";
ubootName = "lzo";
extension = ".lzo";
};
zstd = {
executable = pkgs: "${pkgs.zstd}/bin/zstd";
defaultArgs = [ "-10" ];
ubootName = "zstd";
extension = ".zst";
};
pigz = gzip // {

View File

@@ -4,14 +4,13 @@ let
# from it.
compressors = import ./initrd-compressor-meta.nix;
# Get the basename of the actual compression program from the whole
# compression command, for the purpose of guessing the u-boot
# compression command, for the purpose of guessing the
# compression type and filename extension.
compressorName = fullCommand: builtins.elemAt (builtins.match "([^ ]*/)?([^ ]+).*" fullCommand) 1;
in
{
stdenvNoCC,
cpio,
ubootTools,
lib,
pkgsBuildHost,
makeInitrdNGTool,
@@ -57,22 +56,13 @@ in
# symlinks to store paths.
prepend ? [ ],
# Whether to wrap the initramfs in a u-boot image.
makeUInitrd ? stdenvNoCC.hostPlatform.linux-kernel.target == "uImage",
# If generating a u-boot image, the architecture to use. The default
# guess may not align with u-boot's nomenclature correctly, so it can
# be overridden.
# See https://gitlab.denx.de/u-boot/u-boot/-/blob/9bfb567e5f1bfe7de8eb41f8c6d00f49d2b9a426/common/image.c#L81-106 for a list.
uInitrdArch ? stdenvNoCC.hostPlatform.ubootArch,
# The name of the compression, as recognised by u-boot.
# See https://gitlab.denx.de/u-boot/u-boot/-/blob/9bfb567e5f1bfe7de8eb41f8c6d00f49d2b9a426/common/image.c#L195-204 for a list.
# If this isn't guessed, you may want to complete the metadata above and send a PR :)
uInitrdCompression ?
_compressorMeta.ubootName
or (throw "Unrecognised compressor ${_compressorName}, please specify uInitrdCompression"),
# Deprecated; remove in 27.05.
makeUInitrd ? null,
uInitrdArch ? null,
uInitrdCompression ? null,
}:
assert lib.assertMsg (makeUInitrd == null && uInitrdArch == null && uInitrdCompression == null)
"makeInitrdNg: UBoot legacy image support has been removed as it is deprecated upstream and ARMv5 kernels no longer default to uImage";
stdenvNoCC.mkDerivation (finalAttrs: {
__structuredAttrs = true;
@@ -83,11 +73,8 @@ stdenvNoCC.mkDerivation (finalAttrs: {
inherit
name
extension
makeUInitrd
uInitrdArch
prepend
;
${if makeUInitrd then "uInitrdCompression" else null} = uInitrdCompression;
compress = "${_compressorExecutable} ${lib.escapeShellArgs _compressorArgsReal}";
contentsJSON = builtins.toJSON contents;
@@ -95,8 +82,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
nativeBuildInputs = [
makeInitrdNGTool
cpio
]
++ lib.optional makeUInitrd ubootTools;
];
buildCommand = ''
mkdir -p ./root/{run,tmp,var/empty}
@@ -109,13 +95,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
done
(cd root && find . -print0 | sort -z | cpio --quiet -o -H newc -R +0:+0 --reproducible --null | eval -- $compress >> "$out/initrd")
if [ -n "$makeUInitrd" ]; then
mkimage -A "$uInitrdArch" -O linux -T ramdisk -C "$uInitrdCompression" -d "$out/initrd" $out/initrd.img
# Compatibility symlink
ln -sf "initrd.img" "$out/initrd"
else
ln -s "initrd" "$out/initrd$extension"
fi
ln -s "initrd" "$out/initrd$extension"
'';
passthru = {

View File

@@ -10,18 +10,16 @@
# of algorithms.
let
# Some metadata on various compression programs, relevant to naming
# the initramfs file and, if applicable, generating a u-boot image
# from it.
# the initramfs file.
compressors = import ./initrd-compressor-meta.nix;
# Get the basename of the actual compression program from the whole
# compression command, for the purpose of guessing the u-boot
# compression command, for the purpose of guessing the
# compression type and filename extension.
compressorName = fullCommand: builtins.elemAt (builtins.match "([^ ]*/)?([^ ]+).*" fullCommand) 1;
in
{
stdenvNoCC,
cpio,
ubootTools,
lib,
pkgsBuildHost,
# Name of the derivation (not of the resulting file!)
@@ -65,22 +63,13 @@ in
# symlinks to store paths.
prepend ? [ ],
# Whether to wrap the initramfs in a u-boot image.
makeUInitrd ? stdenvNoCC.hostPlatform.linux-kernel.target or "dummy" == "uImage",
# If generating a u-boot image, the architecture to use. The default
# guess may not align with u-boot's nomenclature correctly, so it can
# be overridden.
# See https://gitlab.denx.de/u-boot/u-boot/-/blob/9bfb567e5f1bfe7de8eb41f8c6d00f49d2b9a426/common/image.c#L81-106 for a list.
uInitrdArch ? stdenvNoCC.hostPlatform.linuxArch,
# The name of the compression, as recognised by u-boot.
# See https://gitlab.denx.de/u-boot/u-boot/-/blob/9bfb567e5f1bfe7de8eb41f8c6d00f49d2b9a426/common/image.c#L195-204 for a list.
# If this isn't guessed, you may want to complete the metadata above and send a PR :)
uInitrdCompression ?
_compressorMeta.ubootName
or (throw "Unrecognised compressor ${_compressorName}, please specify uInitrdCompression"),
# Deprecated; remove in 27.05.
makeUInitrd ? null,
uInitrdArch ? null,
uInitrdCompression ? null,
}:
assert lib.assertMsg (makeUInitrd == null && uInitrdArch == null && uInitrdCompression == null)
"makeInitrd: UBoot legacy image support has been removed as it is deprecated upstream and ARMv5 kernels no longer default to uImage";
stdenvNoCC.mkDerivation (finalAttrs: {
__structuredAttrs = true;
@@ -91,18 +80,14 @@ stdenvNoCC.mkDerivation (finalAttrs: {
inherit
name
extension
makeUInitrd
uInitrdArch
prepend
;
${if makeUInitrd then "uInitrdCompression" else null} = uInitrdCompression;
builder = ./make-initrd.sh;
nativeBuildInputs = [
cpio
]
++ lib.optional makeUInitrd ubootTools;
];
compress = "${_compressorExecutable} ${lib.escapeShellArgs _compressorArgsReal}";

View File

@@ -36,10 +36,4 @@ done
(cd root && find * .[^.*] -exec touch -h -d '@1' '{}' +)
(cd root && find * .[^.*] -print0 | sort -z | cpio --quiet -o -H newc -R +0:+0 --reproducible --null | eval -- $compress >> "$out/initrd")
if [ -n "$makeUInitrd" ]; then
mkimage -A "$uInitrdArch" -O linux -T ramdisk -C "$uInitrdCompression" -d "$out/initrd" $out/initrd.img
# Compatibility symlink
ln -sf "initrd.img" "$out/initrd"
else
ln -s "initrd" "$out/initrd$extension"
fi
ln -s "initrd" "$out/initrd$extension"

View File

@@ -21,11 +21,11 @@
stdenv.mkDerivation (finalAttrs: {
pname = "btrfs-progs";
version = "6.19.1";
version = "7.0";
src = fetchurl {
url = "mirror://kernel/linux/kernel/people/kdave/btrfs-progs/btrfs-progs-v${finalAttrs.version}.tar.xz";
hash = "sha256-uyfh7FTnw8C3suWW+FOnPAej1y8hvJQEIHPCTb8EV5Y=";
hash = "sha256-wobWh2y81yMnoLQX5M/SgDU+wj43tUn9vNeACoMtmpk=";
};
nativeBuildInputs = [

View File

@@ -8,13 +8,13 @@
stdenv.mkDerivation (finalAttrs: {
pname = "capstone";
version = "5.0.7";
version = "5.0.9";
src = fetchFromGitHub {
owner = "capstone-engine";
repo = "capstone";
rev = finalAttrs.version;
hash = "sha256-+6QReHZK+iIXspizy6Kvk7cj016HOKgiaKSaP4h7mao=";
hash = "sha256-uAiiKWKGjEATPE0Xc3g+aOLCz5ffIlDmf+7jaGwaZ4I=";
};
cmakeFlags = [

View File

@@ -15,13 +15,13 @@
stdenv.mkDerivation (finalAttrs: {
pname = "dhcpcd";
version = "10.3.1";
version = "10.3.2";
src = fetchFromGitHub {
owner = "NetworkConfiguration";
repo = "dhcpcd";
rev = "v${finalAttrs.version}";
sha256 = "sha256-L2rR6/qMHWVth2GR3VAoBZmhA6lmCLddbi0VvEG5r70=";
sha256 = "sha256-tJV533j/nQT/PP5KVPJCgTo0Lu8NNMIGnJBvYUG8ufw=";
};
nativeBuildInputs = [ pkg-config ];

View File

@@ -22,7 +22,7 @@
stdenv.mkDerivation (finalAttrs: {
pname = "libgit2";
version = "1.9.3";
version = "1.9.4";
# also check the following packages for updates: python3Packages.pygit2 and libgit2-glib
outputs = [
@@ -35,7 +35,7 @@ stdenv.mkDerivation (finalAttrs: {
owner = "libgit2";
repo = "libgit2";
tag = "v${finalAttrs.version}";
hash = "sha256-nJrRdPs86oGNL4W2CJb16oSUgfzYr9A2i5sw9BAehME=";
hash = "sha256-ZKUiz3pdFE2SKxh53X2oyr7hs32Njj5YVA0OXDXz7h0=";
};
cmakeFlags = [

View File

@@ -21,7 +21,7 @@ nixos-rebuild - reconfigure a NixOS machine
_nixos-rebuild_ \[--verbose] [--quiet] [--max-jobs MAX_JOBS] [--cores CORES] [--log-format LOG_FORMAT] [--keep-going] [--keep-failed] [--fallback] [--repair] [--option OPTION OPTION] [--builders BUILDERS] [--include INCLUDE]++
\[--print-build-logs] [--show-trace] [--accept-flake-config] [--refresh] [--impure] [--offline] [--no-net] [--recreate-lock-file] [--no-update-lock-file] [--no-write-lock-file] [--no-registries] [--commit-lock-file]++
\[--update-input UPDATE_INPUT] [--override-input OVERRIDE_INPUT OVERRIDE_INPUT] [--no-build-output] [--use-substitutes] [--help] [--debug] [--file FILE] [--attr ATTR] [--flake [FLAKE]] [--no-flake] [--install-bootloader]++
\[--profile-name PROFILE_NAME] [--specialisation SPECIALISATION] [--rollback] [--store-path STORE_PATH] [--upgrade] [--upgrade-all] [--json] [--ask-sudo-password] [--sudo] [--no-reexec]++
\[--profile-name PROFILE_NAME] [--specialisation SPECIALISATION] [--rollback] [--store-path STORE_PATH] [--upgrade] [--upgrade-all] [--json] [--elevate {none,sudo,run0}] [--ask-elevate-password] [--no-reexec]++
\[--build-host BUILD_HOST] [--target-host TARGET_HOST] [--no-build-nix] [--image-variant IMAGE_VARIANT]++
\[{switch,boot,test,build,edit,repl,dry-build,dry-run,dry-activate,build-image,build-vm,build-vm-with-bootloader,list-generations}]
@@ -269,17 +269,41 @@ It must be one of the following:
target-host connection to cache.nixos.org is faster than the connection
between hosts.
*--elevate* {none,sudo,run0}
Privilege-elevation method used for activation commands. Setting this
option allows deploying as a non-root user.
_sudo_ prefixes commands with *sudo*. Additional sudo options can be
passed via the NIX_SUDOOPTS environment variable.
_run0_ uses systemd's polkit-based elevation. Locally this runs *run0*
directly so the user's normal polkit agent handles any prompts. With
*--target-host* the equivalent _systemd-run --uid=0 --pipe_ form is
used (no remote TTY is allocated). Unless *--ask-elevate-password* is
also passed, the deploying user must be granted the polkit action
_org.freedesktop.systemd1.manage-units_ on the target host without
authentication, e.g. via _security.polkit.extraConfig_.
*--ask-elevate-password*, *-S*
Prompt locally for a password and feed it to the elevation method.
Implies *--elevate=sudo* if *--elevate* is not given.
For _sudo_ this uses *sudo --stdin*. For _run0_ the command is wrapped
in *polkit-stdin-agent*, which registers a per-process polkit agent
and answers the PAM conversation from the supplied password. The
machine performing the elevation (the local host, or the target host
with *--target-host*) must set
_system.tools.nixos-rebuild.enableRun0Elevation = true_.
*--elevate=run0 --ask-elevate-password* is not usable otherwise.
*--sudo*
When set, *nixos-rebuild* prefixes activation commands with sudo.
Setting this option allows deploying as a non-root user.
Alias for *--elevate=sudo*.
You can set sudo options by defining the NIX_SUDOOPTS environment
variable.
*--ask-sudo-password*
Alias for *--elevate=sudo --ask-elevate-password*.
*--ask-sudo-password*, *-S*
When set, *nixos-rebuild* will ask for sudo password for remote
activation (i.e.: on *--target-host*) at the start of the build process.
Implies *--sudo*.
*--use-remote-sudo*
Deprecated, use *--elevate=sudo* instead.
*--file* _path_, *-f* _path_
Build the NixOS system from the specified file. The file must

View File

@@ -1,6 +1,5 @@
{
lib,
stdenv,
callPackage,
installShellFiles,
mkShell,

View File

@@ -6,6 +6,7 @@ from typing import Final, assert_never
from . import nix, services
from .constants import EXECUTABLE, WITH_SHELL_FILES
from .elevate import NO_ELEVATOR, ElevatorKind
from .models import Action, BuildAttr, Flake, GroupedNixArgs, Profile
from .process import Remote
from .utils import LogFormatter
@@ -163,18 +164,32 @@ def get_parser() -> tuple[argparse.ArgumentParser, dict[str, argparse.ArgumentPa
help="JSON output, only implemented for 'list-generations' right now",
)
main_parser.add_argument(
"--ask-sudo-password",
"-S",
action="store_true",
help="Asks for sudo password for remote activation, implies --sudo",
"--elevate",
choices=ElevatorKind.choices(),
default=None,
help="Privilege-elevation method for activation commands",
)
main_parser.add_argument(
"--sudo", action="store_true", help="Prefixes activation commands with sudo"
"--ask-elevate-password",
"-S",
action="store_true",
help="Prompt locally for the password to feed to the elevation "
"method, implies --elevate=sudo if --elevate is not given",
)
main_parser.add_argument(
"--sudo",
action="store_true",
help="Alias for '--elevate=sudo'",
)
main_parser.add_argument(
"--ask-sudo-password",
action="store_true",
help="Alias for '--elevate=sudo --ask-elevate-password'",
)
main_parser.add_argument(
"--use-remote-sudo",
action="store_true",
help="Deprecated, use '--sudo' instead",
help="Deprecated, use '--elevate=sudo' instead",
)
main_parser.add_argument("--no-ssh-tty", action="store_true", help="Deprecated")
main_parser.add_argument(
@@ -245,16 +260,32 @@ def parse_args(
args.action = Action.DRY_BUILD.value
if args.ask_sudo_password:
args.sudo = True
args.ask_elevate_password = True
if args.use_remote_sudo:
parser_warn("--use-remote-sudo is deprecated, use --elevate=sudo instead")
# Map the elevate flags onto an Elevator. The password itself is
# attached later via Elevator.with_prompted_password() once the
# target host (used in the prompt) is known.
if args.elevate is not None:
args.elevator = ElevatorKind.from_name(args.elevate)
elif args.sudo or args.use_remote_sudo or args.ask_sudo_password:
args.elevator = ElevatorKind.SUDO.make()
elif args.ask_elevate_password:
# -S historically implied --sudo. Keep that for muscle memory
# but be explicit now that there is more than one backend.
parser_warn(
"--ask-elevate-password without --elevate, falling back to --elevate=sudo"
)
args.elevator = ElevatorKind.SUDO.make()
else:
args.elevator = NO_ELEVATOR
if args.install_grub:
parser_warn("--install-grub is deprecated, use --install-bootloader instead")
args.install_bootloader = True
if args.use_remote_sudo:
parser_warn("--use-remote-sudo is deprecated, use --sudo instead")
args.sudo = True
if args.fast:
parser_warn("--fast is deprecated, use --no-reexec instead")
args.no_reexec = True
@@ -321,7 +352,7 @@ def execute(argv: list[str]) -> None:
args, grouped_nix_args = parse_args(argv)
if args.upgrade or args.upgrade_all:
nix.upgrade_channels(args.upgrade_all, args.sudo)
nix.upgrade_channels(args.upgrade_all, args.elevator)
action = Action(args.action)
# Only run shell scripts from the Nixpkgs tree if the action is
@@ -337,8 +368,12 @@ def execute(argv: list[str]) -> None:
services.reexec(argv, args, grouped_nix_args)
profile = Profile.from_arg(args.profile_name)
target_host = Remote.from_arg(args.target_host, args.ask_sudo_password)
build_host = Remote.from_arg(args.build_host, False, validate_opts=False)
target_host = Remote.from_arg(args.target_host)
build_host = Remote.from_arg(args.build_host, validate_opts=False)
args.elevator = args.elevator.with_prompted_password(
ask=args.ask_elevate_password,
host_label=target_host.host if target_host else "localhost",
)
build_attr = BuildAttr.from_arg(args.attr, args.file)
flake = Flake.from_arg(args.flake, target_host)

View File

@@ -0,0 +1,418 @@
"""Privilege-elevation backends for activation commands.
An :class:`Elevator` describes how to wrap a command so it runs as root,
both on the local machine and on a remote target host over SSH (where no
controlling terminal is available), and how to feed it a pre-supplied
password when the backend supports that. ``run_wrapper`` and its callers
carry a single ``elevate: Elevator`` value and let it produce the command
prefix and stdin.
The remote case has no controlling terminal and the elevated command's
environment depends on the backend (``sudo`` inherits the SSH login env,
while the run0 backend starts a transient unit with only systemd's
default ``PATH``), so each backend builds the full remote argv itself
via :meth:`Elevator.wrap_remote`.
"""
from __future__ import annotations
import getpass
import os
import shlex
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field, replace
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import ClassVar, Final, Literal, Self, override
# Kept here (rather than in process.py) so that elevators can build remote
# argvs without a circular import.
type Arg = str | bytes | os.PathLike[str] | os.PathLike[bytes]
type Args = Sequence[Arg]
class _Env(Enum):
PRESERVE_ENV = "PRESERVE"
@override
def __repr__(self) -> str:
return self.value
#: Sentinel meaning "copy this variable from the environment the wrapped
#: command would naturally see" (``os.environ`` locally, the SSH login
#: shell's environment remotely).
PRESERVE_ENV: Final = _Env.PRESERVE_ENV
type EnvValue = str | Literal[_Env.PRESERVE_ENV]
def _remote_env_shell_argv(
prefix: Sequence[str],
env: Mapping[str, EnvValue],
args: Args,
) -> list[Arg]:
"""Build ``<prefix> /bin/sh -c 'exec /usr/bin/env -i K=V… "$@"' sh <args>``.
The wrapper runs in the SSH login session, resolves ``PRESERVE_ENV``
variables against that session's environment, and re-execs the command
with exactly that set. ``/usr/bin/env`` is referenced by absolute path so
the wrapper does not depend on ``PATH`` itself (provided on NixOS via
``environment.usrbinenv``).
"""
assigns: list[str] = []
for k, v in env.items():
if v is PRESERVE_ENV:
assigns.append(f'{k}="${{{k}-}}"')
else:
assigns.append(f"{k}={shlex.quote(v)}")
script = f'exec /usr/bin/env -i {" ".join(assigns)} "$@"'
return [*prefix, "/bin/sh", "-c", script, "sh", *args]
@dataclass(frozen=True)
class Wrapped:
"""Result of wrapping a command for local elevation."""
#: Arguments to prepend to the command.
prefix: list[str]
#: Text to send on the wrapped command's stdin (typically a password
#: followed by a newline), or ``None`` to leave stdin alone.
stdin: str | None = None
@dataclass(frozen=True)
class RemoteWrapped:
"""Result of wrapping a command for remote elevation over SSH.
Unlike :class:`Wrapped` this carries the *full* remote argv: backends
differ in where the env-resolution shell wrapper must sit relative to
the elevator (inside ``sudo``, but *around* ``systemd-run``), so a
plain prefix is not expressive enough.
"""
argv: list[Arg]
stdin: str | None = None
class Elevator(ABC):
"""How to gain root for activation commands."""
#: CLI name, e.g. ``sudo`` or ``run0``.
name: str
@property
def elevates(self) -> bool:
"""Whether this elevator actually changes privileges.
``run_wrapper`` uses this to decide between passing ``env`` to
:func:`subprocess.run` directly (unprivileged local case) and
injecting it via ``env -i`` inside the wrapped command (where the
elevator may otherwise scrub the environment).
"""
return True
@abstractmethod
def wrap_local(self) -> Wrapped:
"""Wrap a command run on the local machine."""
@abstractmethod
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
"""Wrap a command run on a target host over SSH.
The remote side has no controlling terminal, so backends that need
interactive prompts must either accept a pre-supplied password
(see :meth:`with_password`) or rely on a passwordless policy on
the target.
*env* is the environment to establish for the elevated command.
:data:`PRESERVE_ENV` values are resolved against the SSH login
shell's environment, and the backend must do so before any step
that replaces it with a service-style one.
"""
@abstractmethod
def with_password(self, password: str) -> Self:
"""Return a copy that will feed *password* to the backend.
Backends that have no stdin path for credentials must raise
:class:`ElevateError` with a hint pointing at the alternative
(e.g. a polkit rule).
"""
def with_prompted_password(self, *, ask: bool, host_label: str) -> Self:
"""Prompt locally for a password and return a copy carrying it.
No-op when *ask* is false. May raise :class:`ElevateError` (e.g.
on :class:`NoElevator`).
"""
if not ask:
return self
password = getpass.getpass(f"[{self.name}] password for {host_label}: ")
return self.with_password(password)
def for_target_config(self, toplevel: PurePosixPath | Path) -> Self:
"""Return a copy bound to the toplevel being activated on the target.
Backends that need a helper binary on the remote
(:class:`Run0Elevator`'s ``polkit-stdin-agent``) use this to find
a target-architecture copy inside the just-copied closure. No-op
by default.
"""
del toplevel # unused in the base implementation
return self
def on_remote_failure(self) -> str | None:
"""Optional hint to print when a remote elevated command fails."""
return None
class ElevateError(Exception):
"""Raised for invalid elevator/flag combinations."""
@dataclass(frozen=True)
class NoElevator(Elevator):
name: str = "none"
@property
@override
def elevates(self) -> bool:
return False
@override
def wrap_local(self) -> Wrapped:
return Wrapped(prefix=[])
@override
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
return RemoteWrapped(argv=_remote_env_shell_argv([], env, args))
@override
def with_password(self, password: str) -> Self:
raise ElevateError(
"--ask-elevate-password requires --elevate to select an elevation method"
)
@dataclass(frozen=True)
class SudoElevator(Elevator):
"""Wrap with ``sudo``, optionally feeding the password on stdin.
Extra arguments come from ``NIX_SUDOOPTS`` for backwards
compatibility with the previous implementation in ``run_wrapper``.
"""
name: str = "sudo"
password: str | None = None
extra_opts: list[str] = field(
default_factory=lambda: shlex.split(os.getenv("NIX_SUDOOPTS", ""))
)
@override
def wrap_local(self) -> Wrapped:
# Local sudo can prompt on /dev/tty itself, so the password is
# only piped when one was supplied explicitly.
if self.password is not None:
return Wrapped(
prefix=["sudo", "--prompt=", "--stdin", *self.extra_opts],
stdin=self.password + "\n",
)
return Wrapped(prefix=["sudo", *self.extra_opts])
@override
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
# sudo runs inside the SSH login session, so the env wrapper can
# sit *inside* it and ${VAR-} resolves against the login env.
if self.password is not None:
prefix = ["sudo", "--prompt=", "--stdin", *self.extra_opts]
stdin = self.password + "\n"
else:
prefix = ["sudo", *self.extra_opts]
stdin = None
return RemoteWrapped(
argv=_remote_env_shell_argv(prefix, env, args),
stdin=stdin,
)
@override
def with_password(self, password: str) -> Self:
return replace(self, password=password)
@override
def on_remote_failure(self) -> str | None:
if self.password is None:
return (
"while running command with remote sudo, did you forget to "
"use --ask-elevate-password?"
)
return None
@dataclass(frozen=True)
class Run0Elevator(Elevator):
"""Wrap with systemd's polkit-based ``run0``.
Locally, ``run0`` is used directly and the user's polkit agent
(graphical or ``pkttyagent``) handles any prompts.
Remotely we spell out the explicit ``systemd-run --uid=0 --pipe``
form instead. ``run0`` would internally do the same thing when stdio
is not a TTY (see systemd ``src/run/run.c``), but going through
``systemd-run`` directly gives us ``--setenv K=V``, which we need to
forward the SSH login environment into the transient unit (whose own
``PATH`` on NixOS is just the systemd store path), and keeps the
argv independent of whether the SSH session happens to have a TTY.
Authorisation comes from either a polkit rule on the target granting
``org.freedesktop.systemd1.manage-units`` to the deploying user, or
from ``polkit-stdin-agent`` for ``--ask-elevate-password``.
"""
name: str = "run0"
password: str | None = None
#: ``${toplevel}/sw/bin/polkit-stdin-agent`` on the target, set via
#: :meth:`for_target_config`. ``None`` falls back to the target's ``PATH``.
remote_agent: str | None = None
#: Non-interactive equivalent of ``run0`` (see the class docstring).
REMOTE_BASE: ClassVar[tuple[str, ...]] = (
"systemd-run",
"--uid=0",
"--pipe",
"--quiet",
"--wait",
"--collect",
"--service-type=exec",
"--send-sighup",
)
@override
def wrap_local(self) -> Wrapped:
if self.password is not None:
# Resolved from PATH, same requirement as the remote case: the
# machine doing the elevation needs
# system.tools.nixos-rebuild.enableRun0Elevation.
return Wrapped(
prefix=["polkit-stdin-agent", "--password-fd=0", "--", "run0", "--"],
stdin=self.password + "\n",
)
return Wrapped(prefix=["run0", "--"])
@override
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
# /bin/sh wrapper resolves PRESERVE_ENV in the SSH login session
# and forwards the result into the unit via --setenv.
setenvs: list[str] = []
for k, v in env.items():
if v is PRESERVE_ENV:
setenvs.append(f'--setenv={k}="${{{k}-}}"')
else:
setenvs.append(f"--setenv={shlex.quote(f'{k}={v}')}")
script = f'exec {shlex.join(self.REMOTE_BASE)} {" ".join(setenvs)} -- "$@"'
argv: list[Arg] = ["/bin/sh", "-c", script, "sh", *args]
if self.password is not None:
# polkit has no `sudo --stdin` equivalent. polkit-stdin-agent
# registers a per-process agent for the wrapped command and
# answers the PAM conversation from its stdin.
argv = self._remote_agent_argv(argv)
return RemoteWrapped(argv=argv, stdin=self.password + "\n")
return RemoteWrapped(argv=argv)
#: POSIX sh fragment that picks the first runnable agent from the
#: positional parameters up to ``--`` and execs it with the remainder.
#: ``command -v`` covers both absolute paths and ``PATH`` lookups.
_AGENT_PICKER: ClassVar[str] = (
"agent=; "
"for a; do "
"shift; "
'[ "$a" = -- ] && break; '
'[ -z "$agent" ] && command -v "$a" >/dev/null 2>&1 && agent="$a"; '
"done; "
'[ -n "$agent" ] && exec "$agent" --password-fd=0 -- "$@"; '
'echo "nixos-rebuild: polkit-stdin-agent not found on target host '
'(set system.tools.nixos-rebuild.enableRun0Elevation = true)" >&2; '
"exit 127"
)
def _remote_agent_argv(self, inner: list[Arg]) -> list[Arg]:
"""Wrap *inner* in a target-side agent lookup.
The deployer's own agent may be the wrong arch/nixpkgs (cross-arch
deploys, Darwin deployers, ``--no-reexec``), so resolve on the
target instead: first ``${toplevel}/sw/bin/polkit-stdin-agent``
(present when ``system.tools.nixos-rebuild.enableRun0Elevation``
is set), then bare ``polkit-stdin-agent`` on the SSH login PATH.
:data:`_AGENT_PICKER` exits 127 with a hint if neither is found.
"""
candidates: list[str] = []
if self.remote_agent is not None:
candidates.append(self.remote_agent)
candidates.append("polkit-stdin-agent")
return ["/bin/sh", "-c", self._AGENT_PICKER, "sh", *candidates, "--", *inner]
@override
def with_password(self, password: str) -> Self:
return replace(self, password=password)
@override
def for_target_config(self, toplevel: PurePosixPath | Path) -> Self:
return replace(
self, remote_agent=str(toplevel / "sw" / "bin" / "polkit-stdin-agent")
)
@override
def on_remote_failure(self) -> str | None:
if self.password is None:
return (
"while running command with remote run0. Either pass "
"--ask-elevate-password, or grant the deploying user the "
"polkit action 'org.freedesktop.systemd1.manage-units' on "
"the target host (security.polkit.extraConfig)."
)
return (
"while running command with remote run0. If the error above "
"mentions polkit-stdin-agent or PolicyKit1, the target host "
"needs system.tools.nixos-rebuild.enableRun0Elevation = true."
)
class ElevatorKind(Enum):
"""CLI-selectable elevation backends.
The enum *value* is the :class:`Elevator` subclass to instantiate,
``str(member)`` is what ``--elevate`` accepts on the command line.
"""
NONE = NoElevator
SUDO = SudoElevator
RUN0 = Run0Elevator
@override
def __str__(self) -> str:
return self.name.lower()
def make(self) -> Elevator:
"""Instantiate a fresh, unparameterised elevator of this kind."""
cls: type[Elevator] = self.value
return cls()
@classmethod
def choices(cls) -> list[str]:
"""All ``--elevate`` values, for argparse ``choices=``."""
return [str(m) for m in cls]
@classmethod
def from_name(cls, name: str) -> Elevator:
try:
return cls[name.upper()].make()
except KeyError:
raise ElevateError(
f"unknown elevation method {name!r}, choose from: "
+ ", ".join(cls.choices())
) from None
#: Singleton used as the default ``elevate=`` argument throughout.
NO_ELEVATOR: Final[Elevator] = NoElevator()

View File

@@ -14,6 +14,7 @@ from textwrap import dedent
from typing import Final, Literal
from . import tmpdir
from .elevate import NO_ELEVATOR, PRESERVE_ENV, Elevator
from .models import (
Action,
BuildAttr,
@@ -25,7 +26,7 @@ from .models import (
Profile,
Remote,
)
from .process import PRESERVE_ENV, SSH_DEFAULT_OPTS, run_wrapper
from .process import SSH_DEFAULT_OPTS, run_wrapper
from .utils import Args, dict_to_flags
FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"]
@@ -453,7 +454,7 @@ def get_generations(profile: Profile) -> list[Generation]:
def get_generations_from_nix_env(
profile: Profile,
target_host: Remote | None = None,
sudo: bool = False,
elevate: Elevator = NO_ELEVATOR,
) -> list[Generation]:
"""Get all NixOS generations from profile with nix-env. Needs root.
@@ -468,7 +469,7 @@ def get_generations_from_nix_env(
["nix-env", "-p", profile.path, "--list-generations"],
stdout=PIPE,
remote=target_host,
sudo=sudo,
elevate=elevate,
)
def parse_line(line: str) -> Generation:
@@ -600,12 +601,12 @@ def repl_flake(flake: Flake, flake_flags: Args | None = None) -> None:
)
def rollback(profile: Profile, target_host: Remote | None, sudo: bool) -> Path:
def rollback(profile: Profile, target_host: Remote | None, elevate: Elevator) -> Path:
"Rollback Nix profile, like one created by `nixos-rebuild switch`."
run_wrapper(
["nix-env", "--rollback", "-p", profile.path],
remote=target_host,
sudo=sudo,
elevate=elevate,
)
# Rollback config PATH is the own profile
return profile.path
@@ -614,11 +615,11 @@ def rollback(profile: Profile, target_host: Remote | None, sudo: bool) -> Path:
def rollback_temporary_profile(
profile: Profile,
target_host: Remote | None,
sudo: bool,
elevate: Elevator,
) -> Path | None:
"Rollback a temporary Nix profile, like one created by `nixos-rebuild test`."
generations = get_generations_from_nix_env(
profile, target_host=target_host, sudo=sudo
profile, target_host=target_host, elevate=elevate
)
previous_gen_id = None
for generation in generations:
@@ -635,7 +636,7 @@ def set_profile(
profile: Profile,
path_to_config: Path,
target_host: Remote | None,
sudo: bool,
elevate: Elevator,
) -> None:
"Set a path as the current active Nix profile."
if not os.environ.get(
@@ -668,7 +669,7 @@ def set_profile(
run_wrapper(
["nix-env", "-p", profile.path, "--set", path_to_config],
remote=target_host,
sudo=sudo,
elevate=elevate,
)
@@ -676,7 +677,7 @@ def switch_to_configuration(
path_to_config: Path,
action: Literal[Action.SWITCH, Action.BOOT, Action.TEST, Action.DRY_ACTIVATE],
target_host: Remote | None,
sudo: bool,
elevate: Elevator,
install_bootloader: bool = False,
specialisation: str | None = None,
) -> None:
@@ -716,7 +717,7 @@ def switch_to_configuration(
"NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0",
},
remote=target_host,
sudo=sudo,
elevate=elevate,
# switch-to-configuration is not expected to produce meaningful
# stdout, but if it (or any of its children) does, it would leak
# into our stdout and break the "only the store path on stdout"
@@ -728,7 +729,7 @@ def switch_to_configuration(
def upgrade_channels(
all_channels: bool = False,
sudo: bool = False,
elevate: Elevator = NO_ELEVATOR,
channels_dir: Path = Path("/nix/var/nix/profiles/per-user/root/channels/"),
) -> None:
"""Upgrade channels for classic Nix.
@@ -736,10 +737,10 @@ def upgrade_channels(
It will either upgrade just the `nixos` channel (including any channel
that has a `.update-on-nixos-rebuild` file) or all.
"""
if not sudo and os.geteuid() != 0:
if not elevate.elevates and os.geteuid() != 0:
raise NixOSRebuildError(
"if you pass the '--upgrade' or '--upgrade-all' flag, you must "
"also pass '--sudo' or run the command as root (e.g., with sudo)"
"also pass '--elevate' or run the command as root"
)
channel_updated = False
@@ -752,7 +753,7 @@ def upgrade_channels(
run_wrapper(
["nix-channel", "--update", channel_path.name],
check=False,
sudo=sudo,
elevate=elevate,
)
channel_updated = True

View File

@@ -1,17 +1,23 @@
import atexit
import getpass
import logging
import os
import re
import shlex
import subprocess
from collections.abc import Mapping, Sequence
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
from ipaddress import AddressValueError, IPv6Address
from typing import Final, Literal, Self, TextIO, TypedDict, Unpack, override
from typing import Final, Self, TextIO, TypedDict, Unpack
from . import tmpdir
from .elevate import (
NO_ELEVATOR,
PRESERVE_ENV,
Arg,
Args,
Elevator,
EnvValue,
)
logger: Final = logging.getLogger(__name__)
@@ -19,40 +25,22 @@ SSH_DEFAULT_OPTS: Final = [
"-o",
"ControlMaster=auto",
"-o",
f"ControlPath={tmpdir.TMPDIR_PATH / 'ssh-%n'}",
f"ControlPath={tmpdir.TMPDIR_PATH / 'ssh-%C'}",
"-o",
"ControlPersist=60",
]
class _Env(Enum):
PRESERVE_ENV = "PRESERVE"
@override
def __repr__(self) -> str:
return self.value
PRESERVE_ENV: Final = _Env.PRESERVE_ENV
type Arg = str | bytes | os.PathLike[str] | os.PathLike[bytes]
type Args = Sequence[Arg]
type EnvValue = str | Literal[_Env.PRESERVE_ENV]
@dataclass(frozen=True)
class Remote:
host: str
opts: list[str]
sudo_password: str | None
store_type: str
@classmethod
def from_arg(
cls,
host: str | None,
ask_sudo_password: bool | None,
validate_opts: bool = True,
) -> Self | None:
if not host:
@@ -65,25 +53,21 @@ class Remote:
opts = shlex.split(os.getenv("NIX_SSHOPTS", ""))
if validate_opts:
cls._validate_opts(opts, ask_sudo_password)
sudo_password = None
if ask_sudo_password:
sudo_password = getpass.getpass(f"[sudo] password for {host}: ")
return cls(host, opts, sudo_password, store_type)
cls._validate_opts(opts)
return cls(host, opts, store_type)
@staticmethod
def _validate_opts(opts: list[str], ask_sudo_password: bool | None) -> None:
def _validate_opts(opts: list[str]) -> None:
for o in opts:
if o in ["-t", "-tt", "RequestTTY=yes", "RequestTTY=force"]:
logger.warning(
f"detected option '{o}' in NIX_SSHOPTS. SSH's TTY may "
"cause issues, it is recommended to remove this option"
)
if not ask_sudo_password:
logger.warning(
"if you want to prompt for sudo password use "
"'--ask-sudo-password' option instead"
)
logger.warning(
"if you want to prompt for a password for remote "
"elevation use '--ask-elevate-password' instead"
)
def ssh_host(self) -> str:
"""Fix up host string for SSH.
@@ -130,7 +114,7 @@ def run_wrapper(
env: Mapping[str, EnvValue] | None = None,
append_local_env: Mapping[str, str] | None = None,
remote: Remote | None = None,
sudo: bool = False,
elevate: Elevator = NO_ELEVATOR,
**kwargs: Unpack[RunKwargs],
) -> subprocess.CompletedProcess[str]:
"Wrapper around `subprocess.run` that supports extra functionality."
@@ -142,27 +126,9 @@ def run_wrapper(
resolved_env = _resolve_env_local(normalized_env)
if remote:
remote_run_args: list[Arg] = [
"/bin/sh",
"-c",
_remote_shell_script(normalized_env),
"sh",
*run_args,
]
if sudo:
sudo_args = shlex.split(os.getenv("NIX_SUDOOPTS", ""))
if remote.sudo_password:
remote_run_args = [
"sudo",
"--prompt=",
"--stdin",
*sudo_args,
*remote_run_args,
]
process_input = remote.sudo_password + "\n"
else:
remote_run_args = ["sudo", *sudo_args, *remote_run_args]
rwrapped = elevate.wrap_remote(normalized_env, run_args)
process_input = rwrapped.stdin
remote_run_args: list[Arg] = rwrapped.argv
ssh_args: list[Arg] = [
"ssh",
@@ -176,22 +142,19 @@ def run_wrapper(
popen_env = None # keep ssh's environment normal
else:
if sudo:
# subprocess.run(env=...) would affect sudo, but sudo may drop env
# for the target command.
# So we inject env via `sudo env ... cmd`.
wrapped = elevate.wrap_local()
process_input = wrapped.stdin
if elevate.elevates:
# subprocess.run(env=...) would affect the elevator process,
# which may then drop env for the target command. Inject env
# via `env -i ... cmd` instead so it survives.
if env is not None and resolved_env:
run_args = _prefix_env_cmd(run_args, resolved_env)
sudo_args = shlex.split(os.getenv("NIX_SUDOOPTS", ""))
final_args = ["sudo", *sudo_args, *run_args]
# No need to pass env to subprocess.run; keep sudo's own env
# default.
final_args = [*wrapped.prefix, *run_args]
popen_env = None
else:
# Non-sudo local: we can fully control the environment with
# subprocess.run(env=...)
# Unprivileged local: we can fully control the environment
# with subprocess.run(env=...)
final_args = run_args
popen_env = None if env is None else resolved_env
@@ -225,16 +188,14 @@ def run_wrapper(
return r
except KeyboardInterrupt:
# sudo commands are activation only and unlikely to be long running
if remote and not sudo:
# elevated commands are activation only and unlikely to be long
# running
if remote and not elevate.elevates:
_kill_long_running_ssh_process(args, remote)
raise
except subprocess.CalledProcessError:
if sudo and remote and remote.sudo_password is None:
logger.error(
"while running command with remote sudo, did you forget to use "
"--ask-sudo-password?"
)
if remote and (hint := elevate.on_remote_failure()):
logger.error(hint)
raise
@@ -268,7 +229,7 @@ def _resolve_env_local(env: dict[str, EnvValue]) -> dict[str, str]:
return result
def _prefix_env_cmd(cmd: Sequence[Arg], resolved_env: dict[str, str]) -> list[Arg]:
def _prefix_env_cmd(cmd: Args, resolved_env: dict[str, str]) -> list[Arg]:
"""
Prefix a command with `env -i K=V ... -- <cmd...>` to set vars for the
command.
@@ -280,24 +241,6 @@ def _prefix_env_cmd(cmd: Sequence[Arg], resolved_env: dict[str, str]) -> list[Ar
return ["env", "-i", *assigns, *cmd]
def _remote_shell_script(env: Mapping[str, EnvValue]) -> str:
"""
Build the POSIX shell wrapper used for remote execution over SSH.
SSH sends the remote command as a shell-interpreted command line, so we
need a wrapper to establish a clean environment before `exec`-ing the real
command. This wrapper is always run under `/bin/sh -c` so preserved
variables like `${PATH-}` do not depend on the remote user's login shell.
"""
shell_assigns: list[str] = []
for k, v in env.items():
if v is PRESERVE_ENV:
shell_assigns.append(f'{k}="${{{k}-}}"')
else:
shell_assigns.append(f"{k}={shlex.quote(v)}")
return f'exec env -i {" ".join(shell_assigns)} "$@"'
def _quote_remote_arg(arg: Arg) -> str:
return shlex.quote(str(arg))

View File

@@ -35,11 +35,7 @@ def reexec(
return
drv = None
# Parsing the args here but ignore ask_sudo_password since it is not
# needed and we would end up asking sudo password twice
if flake := Flake.from_arg(
args.flake, Remote.from_arg(args.target_host, ask_sudo_password=None)
):
if flake := Flake.from_arg(args.flake, Remote.from_arg(args.target_host)):
drv = nix.build_flake(
NIXOS_REBUILD_ATTR,
flake,
@@ -132,12 +128,12 @@ def _rollback_system(
) -> Path:
match action:
case Action.SWITCH | Action.BOOT:
path_to_config = nix.rollback(profile, target_host, sudo=args.sudo)
path_to_config = nix.rollback(profile, target_host, elevate=args.elevator)
case Action.TEST | Action.BUILD:
maybe_path_to_config = nix.rollback_temporary_profile(
profile,
target_host,
sudo=args.sudo,
elevate=args.elevator,
)
if maybe_path_to_config:
path_to_config = maybe_path_to_config
@@ -231,13 +227,13 @@ def _activate_system(
profile,
path_to_config,
target_host=target_host,
sudo=args.sudo,
elevate=args.elevator,
)
nix.switch_to_configuration(
path_to_config,
action,
target_host=target_host,
sudo=args.sudo,
elevate=args.elevator,
specialisation=args.specialisation,
install_bootloader=args.install_bootloader,
)
@@ -247,7 +243,7 @@ def _activate_system(
path_to_config,
action,
target_host=target_host,
sudo=args.sudo,
elevate=args.elevator,
specialisation=args.specialisation,
install_bootloader=args.install_bootloader,
)
@@ -302,6 +298,11 @@ def build_and_activate_system(
copy_flags=grouped_nix_args.copy_flags,
)
elif args.rollback:
if target_host is not None:
# The elevated `nix-env --rollback` runs before path_to_config
# is known, so point the elevator at the profile to find a
# target-arch helper in the *current* generation's sw/bin.
args.elevator = args.elevator.for_target_config(profile.path)
path_to_config = _rollback_system(
action=action,
args=args,
@@ -319,6 +320,11 @@ def build_and_activate_system(
grouped_nix_args=grouped_nix_args,
)
if target_host is not None and not args.rollback:
# Prefer the helper from the toplevel we just copied to the
# target (correct arch, independent of re-exec / nixpkgs pin).
args.elevator = args.elevator.for_target_config(path_to_config)
current_config = Path("/run/current-system")
if args.diff:
if current_config.exists():

View File

@@ -0,0 +1,155 @@
from pathlib import PurePosixPath
import pytest
from pytest import MonkeyPatch
import nixos_rebuild.elevate as e
def test_no_elevator() -> None:
n = e.NoElevator()
assert not n.elevates
assert n.wrap_local() == e.Wrapped(prefix=[])
rw = n.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
assert rw.stdin is None
assert rw.argv[:2] == ["/bin/sh", "-c"]
assert rw.argv[-1] == "cmd"
with pytest.raises(e.ElevateError):
n.with_password("x")
def test_sudo_elevator(monkeypatch: MonkeyPatch) -> None:
monkeypatch.delenv("NIX_SUDOOPTS", raising=False)
s = e.SudoElevator()
assert s.elevates
assert s.wrap_local() == e.Wrapped(prefix=["sudo"])
rw = s.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
assert rw.argv[0] == "sudo"
assert rw.argv[1:3] == ["/bin/sh", "-c"]
assert rw.stdin is None
assert s.on_remote_failure() is not None
sp = s.with_password("hunter2")
rw = sp.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
assert rw.argv[:3] == ["sudo", "--prompt=", "--stdin"]
assert rw.stdin == "hunter2\n"
assert sp.on_remote_failure() is None
# original unchanged
assert s.password is None
def test_sudo_elevator_extra_opts(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("NIX_SUDOOPTS", "--preserve-env=FOO -H")
s = e.SudoElevator()
assert s.wrap_local() == e.Wrapped(prefix=["sudo", "--preserve-env=FOO", "-H"])
rw = s.with_password("p").wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
assert rw.argv[:5] == ["sudo", "--prompt=", "--stdin", "--preserve-env=FOO", "-H"]
assert rw.stdin == "p\n"
def test_run0_elevator() -> None:
r = e.Run0Elevator()
assert r.elevates
assert r.wrap_local() == e.Wrapped(prefix=["run0", "--"])
assert r.on_remote_failure() is not None
rp = r.with_password("hunter2")
w = rp.wrap_local()
assert w.prefix[0] == "polkit-stdin-agent"
assert "run0" in w.prefix
assert w.stdin == "hunter2\n"
# With a password the failure hint points at the agent, not at -S.
hint = rp.on_remote_failure()
assert hint is not None and "polkit-stdin-agent" in hint
def test_run0_elevator_remote() -> None:
r = e.Run0Elevator()
# No password: /bin/sh wrapper around systemd-run, env passed via
# --setenv so it is resolved in the SSH login shell rather than
# inside the transient unit (which has a useless PATH on NixOS).
rw = r.wrap_remote(
{"PATH": e.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "1"},
["nix-env", "-p", "/profile"],
)
assert rw.stdin is None
assert rw.argv[:2] == ["/bin/sh", "-c"]
script = rw.argv[2]
assert isinstance(script, str)
assert script.startswith("exec systemd-run --uid=0 --pipe ")
assert '--setenv=PATH="${PATH-}"' in script
assert "--setenv=NIXOS_INSTALL_BOOTLOADER=1" in script
assert script.endswith(' -- "$@"')
assert rw.argv[3:] == ["sh", "nix-env", "-p", "/profile"]
# With password: an agent-picker /bin/sh wraps the inner /bin/sh, so
# the agent is registered for the inner shell (and the systemd-run it
# execs into). With no toplevel bound the only candidate is bare-name
# PATH lookup.
rw = r.with_password("pw").wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
assert rw.stdin == "pw\n"
assert rw.argv[:3] == ["/bin/sh", "-c", e.Run0Elevator._AGENT_PICKER]
sep = rw.argv.index("--")
assert rw.argv[3:sep] == ["sh", "polkit-stdin-agent"]
assert rw.argv[sep + 1 : sep + 3] == ["/bin/sh", "-c"]
# Explicit values containing spaces are shell-quoted inside the
# script (the whole thing is later shlex.quoted again for SSH).
rw = r.wrap_remote({"FOO": "a b"}, ["cmd"])
script = rw.argv[2]
assert isinstance(script, str)
assert "--setenv='FOO=a b'" in script
def test_run0_for_target_config() -> None:
toplevel = PurePosixPath("/nix/store/aaaa-nixos-system")
r = e.Run0Elevator().with_password("pw").for_target_config(toplevel)
assert r.remote_agent == f"{toplevel}/sw/bin/polkit-stdin-agent"
rw = r.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
sep = rw.argv.index("--")
# Order matters: target-arch toplevel first, then PATH.
assert rw.argv[4:sep] == [
f"{toplevel}/sw/bin/polkit-stdin-agent",
"polkit-stdin-agent",
]
# Inner argv is preserved verbatim after the separator.
assert rw.argv[sep + 1 : sep + 3] == ["/bin/sh", "-c"]
assert rw.argv[-1] == "cmd"
# Non-run0 elevators ignore the toplevel.
s = e.SudoElevator()
assert s.for_target_config(toplevel) is s
def test_elevator_kind() -> None:
assert isinstance(e.ElevatorKind.from_name("sudo"), e.SudoElevator)
assert isinstance(e.ElevatorKind.from_name("run0"), e.Run0Elevator)
assert isinstance(e.ElevatorKind.from_name("none"), e.NoElevator)
with pytest.raises(e.ElevateError):
e.ElevatorKind.from_name("doas")
assert set(e.ElevatorKind.choices()) == {"none", "sudo", "run0"}
def test_with_prompted_password(monkeypatch: MonkeyPatch) -> None:
prompts: list[str] = []
def fake_getpass(prompt: str) -> str:
prompts.append(prompt)
return "hunter2"
monkeypatch.setattr(e.getpass, "getpass", fake_getpass)
s = e.SudoElevator()
assert s.with_prompted_password(ask=False, host_label="x") is s
assert prompts == []
sp = s.with_prompted_password(ask=True, host_label="user@host")
assert isinstance(sp, e.SudoElevator)
assert sp.password == "hunter2"
assert prompts == ["[sudo] password for user@host: "]
with pytest.raises(e.ElevateError):
e.NoElevator().with_prompted_password(ask=True, host_label="localhost")

View File

@@ -21,6 +21,37 @@ DEFAULT_RUN_KWARGS = {
}
def test_parse_args_elevate() -> None:
r, _ = nr.parse_args(["nixos-rebuild", "switch"])
assert r.elevator is nr.elevate.NO_ELEVATOR
r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=sudo"])
assert isinstance(r.elevator, nr.elevate.SudoElevator)
r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=run0"])
assert isinstance(r.elevator, nr.elevate.Run0Elevator)
# back-compat aliases
for flag in ("--sudo", "--use-remote-sudo"):
r, _ = nr.parse_args(["nixos-rebuild", "switch", flag])
assert isinstance(r.elevator, nr.elevate.SudoElevator)
r, _ = nr.parse_args(["nixos-rebuild", "switch", "--ask-sudo-password"])
assert isinstance(r.elevator, nr.elevate.SudoElevator)
assert r.ask_elevate_password
# -S without --elevate implies sudo
r, _ = nr.parse_args(["nixos-rebuild", "switch", "-S"])
assert isinstance(r.elevator, nr.elevate.SudoElevator)
# explicit --elevate wins over the --sudo alias
r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=none", "--sudo"])
assert isinstance(r.elevator, nr.elevate.NoElevator)
with pytest.raises(SystemExit):
nr.parse_args(["nixos-rebuild", "switch", "--elevate=doas"])
def test_parse_args() -> None:
with pytest.raises(SystemExit) as e:
nr.parse_args(["nixos-rebuild", "unknown-action"])
@@ -663,7 +694,7 @@ def test_execute_nix_switch_build_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"mktemp",
"-d",
@@ -682,7 +713,7 @@ def test_execute_nix_switch_build_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix-store",
"--realise",
@@ -702,7 +733,7 @@ def test_execute_nix_switch_build_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"readlink",
"-f",
@@ -720,7 +751,7 @@ def test_execute_nix_switch_build_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"rm",
"-rf",
@@ -753,7 +784,7 @@ def test_execute_nix_switch_build_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix-env",
"-p",
@@ -772,7 +803,7 @@ def test_execute_nix_switch_build_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
"-d",
@@ -790,7 +821,7 @@ def test_execute_nix_switch_build_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
@@ -879,7 +910,7 @@ def test_execute_nix_switch_flake_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix-env",
"-p",
@@ -898,7 +929,7 @@ def test_execute_nix_switch_flake_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
"-d",
@@ -916,7 +947,7 @@ def test_execute_nix_switch_flake_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
@@ -1004,7 +1035,7 @@ def test_execute_nix_switch_flake_build_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix",
"--extra-experimental-features",
@@ -1243,7 +1274,7 @@ def test_execute_build_dry_run_build_and_target_remote(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix",
"--extra-experimental-features",
@@ -1498,7 +1529,7 @@ def test_execute_switch_store_path_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
"-f",
@@ -1516,7 +1547,7 @@ def test_execute_switch_store_path_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix-env",
"-p",
@@ -1535,7 +1566,7 @@ def test_execute_switch_store_path_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
"-d",
@@ -1553,7 +1584,7 @@ def test_execute_switch_store_path_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),

View File

@@ -81,7 +81,7 @@ def test_flake_parse(mock_node: Mock, tmpdir: Path, monkeypatch: MonkeyPatch) ->
autospec=True,
return_value=subprocess.CompletedProcess([], 0, stdout="remote\n"),
):
target_host = m.Remote("target@remote", [], None, "ssh")
target_host = m.Remote("target@remote", [], "ssh")
assert m.Flake.parse("/path/to/flake", target_host) == m.Flake(
"/path/to/flake", 'nixosConfigurations."remote"'
)
@@ -201,7 +201,7 @@ def test_flake_from_arg(
),
):
assert m.Flake.from_arg(
"/path/to", m.Remote("user@host", [], None, "ssh")
"/path/to", m.Remote("user@host", [], "ssh")
) == m.Flake("/path/to", 'nixosConfigurations."remote-hostname"')

View File

@@ -9,12 +9,15 @@ from unittest.mock import ANY, Mock, call, patch
import pytest
from pytest import MonkeyPatch
import nixos_rebuild.elevate as e
import nixos_rebuild.models as m
import nixos_rebuild.nix as n
import nixos_rebuild.process as p
from .helpers import get_qualified_name
SUDO = e.SudoElevator()
@patch(
get_qualified_name(n.run_wrapper, n),
@@ -83,7 +86,7 @@ def test_build_flake(mock_run: Mock, monkeypatch: MonkeyPatch, tmpdir: Path) ->
def test_build_remote(
mock_uuid4: Mock, mock_run: Mock, monkeypatch: MonkeyPatch
) -> None:
build_host = m.Remote("user@host", [], None, "ssh")
build_host = m.Remote("user@host", [], "ssh")
monkeypatch.setenv("NIX_SSHOPTS", "--ssh opts")
def run_wrapper_side_effect(
@@ -177,7 +180,7 @@ def test_build_remote_flake(
) -> None:
monkeypatch.chdir(tmpdir)
flake = m.Flake.parse("/flake.nix#hostname")
build_host = m.Remote("user@host", [], None, "ssh")
build_host = m.Remote("user@host", [], "ssh")
monkeypatch.setenv("NIX_SSHOPTS", "--ssh opts")
assert n.build_remote_flake(
@@ -237,9 +240,9 @@ def test_copy_closure(monkeypatch: MonkeyPatch) -> None:
n.copy_closure(closure, None)
mock_run.assert_not_called()
target_host = m.Remote("user@target.host", [], None, "ssh")
build_host = m.Remote("user@build.host", [], None, "ssh")
target_host_ng = m.Remote("user@target.host", [], None, "ssh-ng")
target_host = m.Remote("user@target.host", [], "ssh")
build_host = m.Remote("user@build.host", [], "ssh")
target_host_ng = m.Remote("user@target.host", [], "ssh-ng")
with patch(get_qualified_name(n.run_wrapper, n), autospec=True) as mock_run:
n.copy_closure(closure, target_host)
mock_run.assert_called_with(
@@ -531,15 +534,15 @@ def test_get_generations_from_nix_env(tmp_path: Path) -> None:
["nix-env", "-p", path, "--list-generations"],
stdout=PIPE,
remote=None,
sudo=False,
elevate=e.NO_ELEVATOR,
)
remote = m.Remote("user@host", [], "password", "ssh")
remote = m.Remote("user@host", [], "ssh")
with patch(
get_qualified_name(n.run_wrapper, n), autospec=True, return_value=return_value
) as mock_run:
assert n.get_generations_from_nix_env(
m.Profile("system", path), remote, True
m.Profile("system", path), remote, SUDO
) == [
m.Generation(id=2082, current=False, timestamp="2024-11-07 22:58:56"),
m.Generation(id=2083, current=False, timestamp="2024-11-07 22:59:41"),
@@ -549,7 +552,7 @@ def test_get_generations_from_nix_env(tmp_path: Path) -> None:
["nix-env", "-p", path, "--list-generations"],
stdout=PIPE,
remote=remote,
sudo=True,
elevate=SUDO,
)
@@ -640,19 +643,19 @@ def test_rollback(mock_run: Mock, tmp_path: Path) -> None:
profile = m.Profile("system", path)
assert n.rollback(profile, None, False) == profile.path
assert n.rollback(profile, None, e.NO_ELEVATOR) == profile.path
mock_run.assert_called_with(
["nix-env", "--rollback", "-p", path],
remote=None,
sudo=False,
elevate=e.NO_ELEVATOR,
)
target_host = m.Remote("user@localhost", [], None, "ssh")
assert n.rollback(profile, target_host, True) == profile.path
target_host = m.Remote("user@localhost", [], "ssh")
assert n.rollback(profile, target_host, SUDO) == profile.path
mock_run.assert_called_with(
["nix-env", "--rollback", "-p", path],
remote=target_host,
sudo=True,
elevate=SUDO,
)
@@ -672,7 +675,7 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None:
"""),
)
assert (
n.rollback_temporary_profile(m.Profile("system", path), None, False)
n.rollback_temporary_profile(m.Profile("system", path), None, e.NO_ELEVATOR)
== path.parent / "system-2083-link"
)
mock_run.assert_called_with(
@@ -684,12 +687,12 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None:
],
stdout=PIPE,
remote=None,
sudo=False,
elevate=e.NO_ELEVATOR,
)
target_host = m.Remote("user@localhost", [], None, "ssh")
target_host = m.Remote("user@localhost", [], "ssh")
assert (
n.rollback_temporary_profile(m.Profile("foo", path), target_host, True)
n.rollback_temporary_profile(m.Profile("foo", path), target_host, SUDO)
== path.parent / "foo-2083-link"
)
mock_run.assert_called_with(
@@ -701,12 +704,12 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None:
],
stdout=PIPE,
remote=target_host,
sudo=True,
elevate=SUDO,
)
with patch(get_qualified_name(n.run_wrapper, n), autospec=True) as mock_run:
mock_run.return_value = CompletedProcess([], 0, stdout="")
assert n.rollback_temporary_profile(profile, None, False) is None
assert n.rollback_temporary_profile(profile, None, e.NO_ELEVATOR) is None
@patch(get_qualified_name(n.run_wrapper, n), autospec=True)
@@ -719,25 +722,25 @@ def test_set_profile(mock_run: Mock) -> None:
m.Profile("system", profile_path),
config_path,
target_host=None,
sudo=False,
elevate=e.NO_ELEVATOR,
)
mock_run.assert_called_with(
["nix-env", "-p", profile_path, "--set", config_path],
remote=None,
sudo=False,
elevate=e.NO_ELEVATOR,
)
mock_run.return_value = CompletedProcess([], 1)
with pytest.raises(m.NixOSRebuildError) as e:
with pytest.raises(m.NixOSRebuildError) as exc:
n.set_profile(
m.Profile("system", profile_path),
config_path,
target_host=None,
sudo=False,
elevate=e.NO_ELEVATOR,
)
assert str(e.value).startswith(
assert str(exc.value).startswith(
"error: your NixOS configuration path seems to be missing essential files."
)
@@ -756,7 +759,7 @@ def test_switch_to_configuration_without_systemd_run(
n.switch_to_configuration(
profile_path,
m.Action.SWITCH,
sudo=False,
elevate=e.NO_ELEVATOR,
target_host=None,
specialisation=None,
install_bootloader=False,
@@ -764,29 +767,29 @@ def test_switch_to_configuration_without_systemd_run(
mock_run.assert_called_with(
[profile_path / "bin/switch-to-configuration", "switch"],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
},
sudo=False,
elevate=e.NO_ELEVATOR,
remote=None,
stdout=sys.stderr,
)
with pytest.raises(m.NixOSRebuildError) as e:
with pytest.raises(m.NixOSRebuildError) as exc:
n.switch_to_configuration(
config_path,
m.Action.BOOT,
sudo=False,
elevate=e.NO_ELEVATOR,
target_host=None,
specialisation="special",
)
assert (
str(e.value)
str(exc.value)
== "error: '--specialisation' can only be used with 'switch' and 'test'"
)
target_host = m.Remote("user@localhost", [], None, "ssh")
target_host = m.Remote("user@localhost", [], "ssh")
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "/path/to/locale")
mp.setenv("PATH", "/path/to/bin")
@@ -795,7 +798,7 @@ def test_switch_to_configuration_without_systemd_run(
n.switch_to_configuration(
Path("/path/to/config"),
m.Action.TEST,
sudo=True,
elevate=SUDO,
target_host=target_host,
install_bootloader=True,
specialisation="special",
@@ -806,11 +809,11 @@ def test_switch_to_configuration_without_systemd_run(
"test",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1",
},
sudo=True,
elevate=SUDO,
remote=target_host,
stdout=sys.stderr,
)
@@ -830,7 +833,7 @@ def test_switch_to_configuration_with_systemd_run(
n.switch_to_configuration(
profile_path,
m.Action.SWITCH,
sudo=False,
elevate=e.NO_ELEVATOR,
target_host=None,
specialisation=None,
install_bootloader=False,
@@ -842,16 +845,16 @@ def test_switch_to_configuration_with_systemd_run(
"switch",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
},
sudo=False,
elevate=e.NO_ELEVATOR,
remote=None,
stdout=sys.stderr,
)
target_host = m.Remote("user@localhost", [], None, "ssh")
target_host = m.Remote("user@localhost", [], "ssh")
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "/path/to/locale")
mp.setenv("PATH", "/path/to/bin")
@@ -860,7 +863,7 @@ def test_switch_to_configuration_with_systemd_run(
n.switch_to_configuration(
Path("/path/to/config"),
m.Action.TEST,
sudo=True,
elevate=SUDO,
target_host=target_host,
install_bootloader=True,
specialisation="special",
@@ -872,11 +875,11 @@ def test_switch_to_configuration_with_systemd_run(
"test",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1",
},
sudo=True,
elevate=SUDO,
remote=target_host,
stdout=sys.stderr,
)
@@ -887,11 +890,13 @@ def test_switch_to_configuration_with_systemd_run(
def test_upgrade_channels(mock_run: Mock, mock_geteuid: Mock, tmpdir: Path) -> None:
tmp_path = Path(tmpdir)
with pytest.raises(m.NixOSRebuildError) as e:
n.upgrade_channels(all_channels=False, sudo=False, channels_dir=tmp_path)
assert str(e.value) == (
with pytest.raises(m.NixOSRebuildError) as exc:
n.upgrade_channels(
all_channels=False, elevate=e.NO_ELEVATOR, channels_dir=tmp_path
)
assert str(exc.value) == (
"error: if you pass the '--upgrade' or '--upgrade-all' flag, you must "
"also pass '--sudo' or run the command as root (e.g., with sudo)"
"also pass '--elevate' or run the command as root"
)
(tmp_path / "nixos").mkdir()
@@ -899,19 +904,20 @@ def test_upgrade_channels(mock_run: Mock, mock_geteuid: Mock, tmpdir: Path) -> N
(tmp_path / "nixos-hardware" / ".update-on-nixos-rebuild").touch()
(tmp_path / "home-manager").mkdir()
# should work because we are passing sudo=True even with os.geteuid == 1000
n.upgrade_channels(all_channels=False, sudo=True, channels_dir=tmp_path)
# should work because we are passing an elevator even with os.geteuid == 1000
n.upgrade_channels(all_channels=False, elevate=SUDO, channels_dir=tmp_path)
# Path.glob order is filesystem-dependent, so don't assert call order.
mock_run.assert_has_calls(
[
call(
["nix-channel", "--update", "nixos-hardware"],
check=False,
sudo=True,
elevate=SUDO,
),
call(
["nix-channel", "--update", "nixos"],
check=False,
sudo=True,
elevate=SUDO,
),
],
any_order=True,
@@ -921,23 +927,23 @@ def test_upgrade_channels(mock_run: Mock, mock_geteuid: Mock, tmpdir: Path) -> N
# root check
mock_geteuid.return_value = 0
n.upgrade_channels(all_channels=True, sudo=False, channels_dir=tmp_path)
n.upgrade_channels(all_channels=True, elevate=e.NO_ELEVATOR, channels_dir=tmp_path)
mock_run.assert_has_calls(
[
call(
["nix-channel", "--update", "home-manager"],
check=False,
sudo=False,
elevate=e.NO_ELEVATOR,
),
call(
["nix-channel", "--update", "nixos-hardware"],
check=False,
sudo=False,
elevate=e.NO_ELEVATOR,
),
call(
["nix-channel", "--update", "nixos"],
check=False,
sudo=False,
elevate=e.NO_ELEVATOR,
),
],
any_order=True,

View File

@@ -3,28 +3,46 @@ from unittest.mock import patch
from pytest import MonkeyPatch
import nixos_rebuild.elevate as e
import nixos_rebuild.models as m
import nixos_rebuild.process as p
def test_remote_shell_script() -> None:
assert p._remote_shell_script({"PATH": p.PRESERVE_ENV}) == (
'''exec env -i PATH="${PATH-}" "$@"'''
)
assert p._remote_shell_script(
def test_remote_env_shell_argv() -> None:
assert e._remote_env_shell_argv([], {"PATH": e.PRESERVE_ENV}, ["cmd"]) == [
"/bin/sh",
"-c",
'''exec /usr/bin/env -i PATH="${PATH-}" "$@"''',
"sh",
"cmd",
]
assert e._remote_env_shell_argv(
["sudo"],
{
"PATH": p.PRESERVE_ENV,
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"PATH": e.PRESERVE_ENV,
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
}
) == (
"""exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" """
'''NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'''
)
assert p._remote_shell_script({"PATH": p.PRESERVE_ENV, "FOO": "some value"}) == (
'''exec env -i PATH="${PATH-}" FOO='some value' "$@"'''
)
},
["cmd", "arg"],
) == [
"sudo",
"/bin/sh",
"-c",
"""exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" """
'''NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"''',
"sh",
"cmd",
"arg",
]
assert e._remote_env_shell_argv(
[], {"PATH": e.PRESERVE_ENV, "FOO": "some value"}, []
) == [
"/bin/sh",
"-c",
'''exec /usr/bin/env -i PATH="${PATH-}" FOO='some value' "$@"''',
"sh",
]
@patch.dict(p.os.environ, {"PATH": "/path/to/bin"}, clear=True)
@@ -43,7 +61,7 @@ def test_run_wrapper(mock_run: Any) -> None:
p.run_wrapper(
["test", "--with", "flags"],
check=False,
sudo=True,
elevate=e.SudoElevator(),
env={"FOO": "bar"},
)
mock_run.assert_called_with(
@@ -67,7 +85,6 @@ def test_run_wrapper(mock_run: Any) -> None:
p.run_wrapper(
["test", "--with", "flags"],
check=False,
sudo=False,
append_local_env={"FOO": "bar"},
)
mock_run.assert_called_with(
@@ -89,7 +106,6 @@ def test_run_wrapper(mock_run: Any) -> None:
p.run_wrapper(
["test", "--with", "flags"],
check=False,
sudo=False,
env={"PATH": "/"},
append_local_env={"FOO": "bar"},
)
@@ -112,7 +128,7 @@ def test_run_wrapper(mock_run: Any) -> None:
p.run_wrapper(
["test", "--with", "some flags"],
check=True,
remote=m.Remote("user@localhost", ["--ssh", "opt"], "password", "ssh"),
remote=m.Remote("user@localhost", ["--ssh", "opt"], "ssh"),
)
mock_run.assert_called_with(
[
@@ -124,7 +140,7 @@ def test_run_wrapper(mock_run: Any) -> None:
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
"--with",
@@ -140,9 +156,9 @@ def test_run_wrapper(mock_run: Any) -> None:
p.run_wrapper(
["test", "--with", "flags"],
check=True,
sudo=True,
elevate=e.SudoElevator(password="password"),
env={"FOO": "bar"},
remote=m.Remote("user@localhost", ["--ssh", "opt"], "password", "ssh"),
remote=m.Remote("user@localhost", ["--ssh", "opt"], "ssh"),
)
mock_run.assert_called_with(
[
@@ -157,7 +173,7 @@ def test_run_wrapper(mock_run: Any) -> None:
"--stdin",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" FOO=bar "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" FOO=bar "$@"'""",
"sh",
"test",
"--with",
@@ -181,7 +197,7 @@ def test__kill_long_running_ssh_process(mock_run: Any) -> None:
"build",
"/nix/store/la0c8nmpr9xfclla0n4f3qq9iwgdrq4g-nixos-system-sankyuu-nixos-25.05.20250424.f771eb4.drv^*",
],
m.Remote("user@localhost", opts=[], sudo_password=None, store_type="ssh"),
m.Remote("user@localhost", opts=[], store_type="ssh"),
)
mock_run.assert_called_with(
[
@@ -204,21 +220,18 @@ def test__kill_long_running_ssh_process(mock_run: Any) -> None:
def test_remote_from_name(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("NIX_SSHOPTS", "")
assert m.Remote.from_arg("user@localhost", None, False) == m.Remote(
assert m.Remote.from_arg("user@localhost", validate_opts=False) == m.Remote(
"user@localhost",
opts=[],
sudo_password=None,
store_type="ssh",
)
with patch("getpass.getpass", autospec=True, return_value="password"):
monkeypatch.setenv("NIX_SSHOPTS", "-f foo -b bar -t")
assert m.Remote.from_arg("user@localhost", True, True) == m.Remote(
"user@localhost",
opts=["-f", "foo", "-b", "bar", "-t"],
sudo_password="password",
store_type="ssh",
)
monkeypatch.setenv("NIX_SSHOPTS", "-f foo -b bar -t")
assert m.Remote.from_arg("user@localhost") == m.Remote(
"user@localhost",
opts=["-f", "foo", "-b", "bar", "-t"],
store_type="ssh",
)
def test_ssh_host() -> None:
@@ -240,18 +253,94 @@ def test_ssh_host() -> None:
}
for host_input, expected in ssh_remotes.items():
remote = m.Remote.from_arg(host_input, None, False)
remote = m.Remote.from_arg(host_input, validate_opts=False)
assert remote is not None
assert remote.ssh_host() == expected
assert remote.store_type == "ssh"
for host_input, expected in ssh_ng_remotes.items():
remote = m.Remote.from_arg(host_input, None, False)
remote = m.Remote.from_arg(host_input, validate_opts=False)
assert remote is not None
assert remote.ssh_host() == expected
assert remote.store_type == "ssh-ng"
@patch.dict(p.os.environ, {"PATH": "/path/to/bin"}, clear=True)
@patch("subprocess.run", autospec=True)
def test_run_wrapper_run0(mock_run: Any) -> None:
p.run_wrapper(["cmd", "arg"], elevate=e.Run0Elevator())
mock_run.assert_called_with(
["run0", "--", "cmd", "arg"],
check=True,
text=True,
errors="surrogateescape",
env=None,
input=None,
)
run0_script = (
"exec systemd-run --uid=0 --pipe --quiet --wait --collect "
"--service-type=exec --send-sighup "
'--setenv=PATH="${PATH-}" -- "$@"'
)
p.run_wrapper(
["cmd", "arg"],
elevate=e.Run0Elevator(),
remote=m.Remote("user@host", [], "ssh"),
)
mock_run.assert_called_with(
[
"ssh",
*p.SSH_DEFAULT_OPTS,
"user@host",
"--",
"/bin/sh",
"-c",
p._quote_remote_arg(run0_script),
"sh",
"cmd",
"arg",
],
check=True,
text=True,
errors="surrogateescape",
env=None,
input=None,
)
p.run_wrapper(
["cmd"],
elevate=e.Run0Elevator().with_password("pw"),
remote=m.Remote("user@host", [], "ssh"),
)
mock_run.assert_called_with(
[
"ssh",
*p.SSH_DEFAULT_OPTS,
"user@host",
"--",
"/bin/sh",
"-c",
p._quote_remote_arg(e.Run0Elevator._AGENT_PICKER),
"sh",
# No toplevel bound, so the only candidate is PATH lookup.
"polkit-stdin-agent",
"--",
"/bin/sh",
"-c",
p._quote_remote_arg(run0_script),
"sh",
"cmd",
],
check=True,
text=True,
errors="surrogateescape",
env=None,
input="pw\n",
)
@patch("subprocess.run", autospec=True)
def test_custom_sudo_args(mock_run: Any) -> None:
with patch.dict(
@@ -262,7 +351,7 @@ def test_custom_sudo_args(mock_run: Any) -> None:
p.run_wrapper(
["test"],
check=False,
sudo=True,
elevate=e.SudoElevator(),
)
mock_run.assert_called_with(
[
@@ -287,8 +376,8 @@ def test_custom_sudo_args(mock_run: Any) -> None:
p.run_wrapper(
["test"],
check=False,
sudo=True,
remote=m.Remote("user@localhost", [], None, "ssh"),
elevate=e.SudoElevator(),
remote=m.Remote("user@localhost", [], "ssh"),
)
mock_run.assert_called_with(
[
@@ -302,7 +391,7 @@ def test_custom_sudo_args(mock_run: Any) -> None:
"--args",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
],

View File

@@ -0,0 +1,48 @@
{
lib,
rustPlatform,
fetchFromGitea,
nix-update-script,
nixosTests,
}:
rustPlatform.buildRustPackage (finalAttrs: {
pname = "polkit-stdin-agent";
version = "0.3.0";
src = fetchFromGitea {
domain = "codeberg.org";
owner = "r-vdp";
repo = "polkit-stdin-agent";
tag = "v${finalAttrs.version}";
hash = "sha256-Nl/+IBbUEsxSKSWLXwUB3mV4iAG0z9mv+Bl6CSeFzR4=";
};
cargoHash = "sha256-Eb/7ejVmtG5FNSh66gZO3337KCPNi+xtYVC5qyFKJzg=";
strictDeps = true;
__structuredAttrs = true;
passthru = {
updateScript = nix-update-script { };
tests = { inherit (nixosTests) nixos-rebuild-target-host; };
};
meta = {
description = "Non-interactive polkit authentication agent that answers PAM prompts from a file descriptor";
longDescription = ''
Registers a per-process polkit authentication agent for a wrapped
command and answers the PAM conversation from a file descriptor
instead of /dev/tty, giving run0 / systemd-run the same
"password on stdin" ergonomics as `sudo --stdin`.
Used by `nixos-rebuild --elevate=run0 --ask-elevate-password` to
authenticate on a target host over SSH without allocating a TTY.
'';
homepage = "https://codeberg.org/r-vdp/polkit-stdin-agent";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ rvdp ];
platforms = lib.platforms.linux;
mainProgram = "polkit-stdin-agent";
};
})

View File

@@ -140,11 +140,11 @@ stdenv.mkDerivation (finalAttrs: {
+ lib.optionalString nixosTestRunner "-for-vm-tests"
+ lib.optionalString toolsOnly "-utils"
+ lib.optionalString userOnly "-user";
version = "10.2.2";
version = "11.0.0";
src = fetchurl {
url = "https://download.qemu.org/qemu-${finalAttrs.version}.tar.xz";
hash = "sha256-eEspb/KcFBeqcjI6vLLS6pq5dxck9Xfc14XDsE8h4XY=";
hash = "sha256-wEyjYBJlPzLRHGdNNwz1KnEOfT8Ywti2PkkyBSpIVNY=";
};
depsBuildBuild = [
@@ -165,6 +165,8 @@ stdenv.mkDerivation (finalAttrs: {
# For python changes other than simple package additions, ping @dramforever for review.
# Don't change `python3Packages` to `python3.pkgs.*`, breaks cross-compilation.
python3Packages.distlib
python3Packages.setuptools
python3Packages.wheel
# Hooks from the python package are needed to add `$pythonPath` so
# `python/scripts/mkvenv.py` can detect `meson` otherwise the vendored meson without patches will be used.
python3Packages.python
@@ -374,7 +376,11 @@ stdenv.mkDerivation (finalAttrs: {
# tests can still timeout on slower systems
doCheck = false;
nativeCheckInputs = [ socat ];
nativeCheckInputs = [
python3Packages.pygdbmi
python3Packages.qemu-qmp
socat
];
preCheck = ''
# time limits are a little meagre for a build machine that's
# potentially under load.
@@ -444,7 +450,7 @@ stdenv.mkDerivation (finalAttrs: {
license = lib.licenses.gpl2Plus;
maintainers = with lib.maintainers; [ qyliss ];
teams = lib.optionals xenSupport xen.meta.teams;
platforms = lib.platforms.unix;
platforms = with lib.systems.inspect; patternLogicalAnd patterns.is64bit patterns.isUnix;
}
# toolsOnly: Does not have qemu-kvm and there's no main support tool
# userOnly: There's one qemu-<arch> for every architecture
@@ -453,7 +459,14 @@ stdenv.mkDerivation (finalAttrs: {
}
# userOnly: https://qemu.readthedocs.io/en/master/user/main.html#supported-operating-systems
// lib.optionalAttrs userOnly {
platforms = with lib.platforms; (linux ++ freebsd ++ openbsd ++ netbsd);
platforms =
with lib.systems.inspect;
patternLogicalAnd patterns.is64bit [
patterns.isLinux
patterns.isFreeBSD
patterns.isOpenBSD
patterns.isNetBSD
];
description = "QEMU User space emulator - launch executables compiled for one CPU on another CPU";
};
})

View File

@@ -16,7 +16,7 @@
rustPlatform.buildRustPackage (finalAttrs: {
pname = "ruff";
version = "0.15.14";
version = "0.15.15";
__structuredAttrs = true;
@@ -24,12 +24,12 @@ rustPlatform.buildRustPackage (finalAttrs: {
owner = "astral-sh";
repo = "ruff";
tag = finalAttrs.version;
hash = "sha256-Z8UhVS+YbYAxVWodU/I+p3Ns5/EpmzBTChcbkvJwe6Y=";
hash = "sha256-WpjOOCYLZ1d8XPUx3qNHD+fuK6t65u/1/ZezABWpBD0=";
};
cargoBuildFlags = [ "--package=ruff" ];
cargoHash = "sha256-GnRC5jXySAna7uAKPDtpPQUJe8AKqVSU+ynmGKZtfTs=";
cargoHash = "sha256-SfkoLl43Y1DNqIRW+HljVcEHWhedTS99SGhMvkQ4dmo=";
nativeBuildInputs = [ installShellFiles ];

View File

@@ -688,12 +688,7 @@ fn handle_modified_unit(
let reload_list = scope.reload_list_file();
let use_restart_as_stop_and_start = new_unit_info.is_none();
if matches!(
unit,
"sysinit.target" | "basic.target" | "multi-user.target" | "graphical.target"
) || unit.ends_with(".unit")
|| unit.ends_with(".slice")
{
if cannot_be_restarted_directly(unit) {
// Do nothing. These cannot be restarted directly.
// Slices and Paths don't have to be restarted since properties (resource limits and
@@ -940,6 +935,17 @@ fn parse_fstab(fstab: impl BufRead) -> (HashMap<String, Filesystem>, HashMap<Str
(filesystems, swaps)
}
/// Whether a unit cannot be (re)started directly. Special targets are pulled
/// in by their dependents; slices and paths get their properties applied on
/// daemon-reload.
fn cannot_be_restarted_directly(unit: &str) -> bool {
matches!(
unit,
"sysinit.target" | "basic.target" | "multi-user.target" | "graphical.target"
) || unit.ends_with(".path")
|| unit.ends_with(".slice")
}
// Returns a HashMap containing the same contents as the passed in `units`, minus the units in
// `units_to_filter`.
fn filter_units(
@@ -957,6 +963,50 @@ fn filter_units(
res
}
/// Action to take on a unit that migrated to NixOS ownership during the
/// post-activation pass. Honours the same X-* directives as
/// `handle_modified_unit`.
#[derive(Debug, PartialEq)]
enum MigrationAction {
Skip,
Reload,
Restart,
Start,
}
impl MigrationAction {
/// Action to take on a migrated unit that is still active.
fn for_active_unit(unit: &str, new_unit_info: &UnitInfo) -> Self {
if cannot_be_restarted_directly(unit) {
return Self::Skip;
}
if parse_systemd_bool(Some(new_unit_info), "Service", "X-ReloadIfChanged", false) {
return Self::Reload;
}
if !parse_systemd_bool(Some(new_unit_info), "Service", "X-RestartIfChanged", true)
|| parse_systemd_bool(Some(new_unit_info), "Unit", "RefuseManualStop", false)
|| parse_systemd_bool(Some(new_unit_info), "Unit", "X-OnlyManualStart", false)
{
return Self::Skip;
}
Self::Restart
}
/// Action to take on a migrated unit that the previous owner stopped.
fn for_stopped_unit(new_unit_info: &UnitInfo) -> Self {
if parse_systemd_bool(Some(new_unit_info), "Unit", "RefuseManualStart", false)
|| parse_systemd_bool(Some(new_unit_info), "Unit", "X-OnlyManualStart", false)
{
return Self::Skip;
}
Self::Start
}
}
fn unit_is_active(conn: &LocalConnection, unit: &str) -> Result<bool> {
let unit_object_path = conn
.with_proxy(
@@ -1345,26 +1395,79 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> {
let current_active_units = get_active_units(&systemd)?;
let old_unit_dir = old_toplevel.join(scope.etc_dir());
let new_unit_dir = toplevel.join(scope.etc_dir());
let fragment_prefix = scope
.current_dir()
.to_str()
.expect("scope dir is valid UTF-8");
let fragment_dir = scope.current_dir();
// Units that are currently running from a non-/etc location (typically
// ~/.config/systemd/user, i.e. home-manager) but that the new NixOS
// configuration also defines. Pass 1 will skip these because of the
// FragmentPath filter; if the per-user activation (sd-switch) later drops
// its copy, we need a second pass to bring the NixOS-owned definition up.
// Determine $XDG_CONFIG_HOME/systemd/user from the user manager's own
// environment (we are spawned with env_clear()).
let user_config_unit_dir: Option<PathBuf> = match systemd.environment() {
Err(err) => {
log::debug!("Failed to read user manager environment: {err}");
None
}
Ok(env) => {
let lookup = |key: &str| {
env.iter().find_map(|kv| {
kv.strip_prefix(key)
.and_then(|rest| rest.strip_prefix('='))
.filter(|v| Path::new(v).is_absolute())
.map(PathBuf::from)
})
};
let config_home =
lookup("XDG_CONFIG_HOME").or_else(|| lookup("HOME").map(|h| h.join(".config")));
if config_home.is_none() {
log::debug!(
"Neither $XDG_CONFIG_HOME nor $HOME is set in the user manager's environment"
);
}
config_home.map(|config_home| config_home.join("systemd/user"))
}
};
if user_config_unit_dir.is_none() {
log::debug!(
"Could not determine $XDG_CONFIG_HOME/systemd/user; \
units shadowed by ~/.config will not be considered for migration"
);
}
// Units active from a non-/etc location that the new generation defines
// in /etc/systemd/user. Pass 1 skips these (FragmentPath filter); pass 2
// brings the /etc definition into effect once /etc has won. Two cases:
// * ~/.config/systemd/user (home-manager): shadows /etc, so wait for
// the per-user activation (sd-switch) to remove its copy.
// * anywhere else outside /etc ($XDG_DATA_HOME, $XDG_DATA_DIRS, ...):
// /etc outranks these, so only act when /etc is gaining the unit;
// if the previous generation already had it, leave it alone.
// Pass 2's `now_etc` check verifies /etc actually won before acting.
let migration_candidates: Vec<String> = current_active_units
.iter()
.filter(|(unit, _)| new_unit_dir.join(unit).exists())
.filter(|(_, unit_state)| {
!unit_state
.filter(|(unit, unit_state)| {
let Ok(fragment_path) = unit_state
.proxy
.get("org.freedesktop.systemd1.Unit", "FragmentPath")
.map(|p: String| p.starts_with(fragment_prefix))
.unwrap_or(false)
.get::<String>("org.freedesktop.systemd1.Unit", "FragmentPath")
else {
return false;
};
let fragment_parent = Path::new(&fragment_path).parent();
// Already in /etc: handled by pass 1.
if fragment_parent == Some(fragment_dir) {
return false;
}
// Loaded from ~/.config/systemd/user, which shadows /etc.
if let Some(dir) = &user_config_unit_dir {
if fragment_parent == Some(dir.as_path()) {
return true;
}
}
// Elsewhere: only act if /etc is gaining the unit this switch.
!old_unit_dir.join(unit).exists()
})
.map(|(unit, _)| unit.clone())
.collect();
@@ -1372,7 +1475,7 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> {
collect_unit_changes(
&toplevel,
scope,
&old_toplevel.join(scope.etc_dir()),
&old_unit_dir,
&new_unit_dir,
&current_active_units,
&mut units_to_stop,
@@ -1383,6 +1486,9 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> {
&mut units_to_filter,
)?;
// Restarted unconditionally below; don't list it as skipped.
units_to_skip.remove("nixos-activation.service");
let print_units = |verb: &str, units: &HashMap<String, ()>| {
if units.is_empty() {
return;
@@ -1483,6 +1589,7 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> {
// Toplevels with system.activatable = false do not ship this unit; mirror
// the system scope's tolerance for a missing activate script.
if new_unit_dir.join("nixos-activation.service").exists() {
eprintln!("restarting the following user units: nixos-activation.service");
match systemd.restart_unit("nixos-activation.service", "replace") {
Ok(_) => {
log::debug!("waiting for nixos activation to finish");
@@ -1510,29 +1617,40 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> {
let active_after = get_active_units(&systemd)?;
let mut to_reload = HashMap::new();
let mut to_restart = HashMap::new();
let mut to_start = HashMap::new();
let mut to_skip = HashMap::new();
for unit in &migration_candidates {
match active_after.get(unit) {
// Honour X-* directives so reloadIfChanged/restartIfChanged hold.
let new_unit_file = new_unit_dir.join(unit);
let new_unit_info = parse_unit(&new_unit_file, &new_unit_file)?;
let action = match active_after.get(unit) {
Some(unit_state) => {
// Only act if /etc now wins (i.e. the higher-priority
// copy is gone). Read errors are treated as "leave alone".
let now_etc = unit_state
.proxy
.get("org.freedesktop.systemd1.Unit", "FragmentPath")
.map(|p: String| p.starts_with(fragment_prefix))
.map(|p: String| Path::new(&p).parent() == Some(fragment_dir))
.unwrap_or(false);
if now_etc {
// Still running with the previous manager's binary;
// restart so the /etc definition takes effect.
to_restart.insert(unit.clone(), ());
if !now_etc {
// Still shadowed (or read error); leave it alone.
continue;
}
// else: still shadowed by ~/.config, leave it alone.
MigrationAction::for_active_unit(unit, &new_unit_info)
}
None => {
// Stopped by the previous manager; start the /etc copy.
to_start.insert(unit.clone(), ());
}
}
// Stopped by the previous manager; start the /etc copy.
None => MigrationAction::for_stopped_unit(&new_unit_info),
};
match action {
MigrationAction::Skip => to_skip.insert(unit.clone(), ()),
MigrationAction::Reload => to_reload.insert(unit.clone(), ()),
MigrationAction::Restart => to_restart.insert(unit.clone(), ()),
MigrationAction::Start => to_start.insert(unit.clone(), ()),
};
}
// Re-start active targets so any other newly-unmasked dependencies are
@@ -1543,6 +1661,24 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> {
}
}
if !to_skip.is_empty() {
print_units("NOT restarting (post-activation)", &to_skip);
}
print_units("reloading (post-activation)", &to_reload);
for unit in to_reload.keys() {
match systemd.reload_unit(unit, "replace") {
Ok(job_path) => {
submitted_jobs.borrow_mut().insert(job_path, Job::Reload);
}
Err(err) => {
eprintln!("Failed to reload user unit {unit}: {err}");
exit_code = 4;
}
}
}
block_on_jobs(&dbus_conn, &submitted_jobs);
print_units("restarting (post-activation)", &to_restart);
for unit in to_restart.keys() {
match systemd.restart_unit(unit, "replace") {
@@ -2863,4 +2999,107 @@ After=dev-disk-by\x2dlabel-root.device
);
}
}
fn unit_info(
sections: &[(&str, &[(&str, &str)])],
) -> HashMap<String, HashMap<String, Vec<String>>> {
sections
.iter()
.map(|(section, kvs)| {
(
section.to_string(),
kvs.iter()
.map(|(k, v)| (k.to_string(), vec![v.to_string()]))
.collect(),
)
})
.collect()
}
#[test]
fn migration_action_for_active_unit() {
use super::MigrationAction;
// Plain service: restart.
assert_eq!(
MigrationAction::for_active_unit("foo.service", &unit_info(&[])),
MigrationAction::Restart
);
// reloadIfChanged must reload, not restart.
assert_eq!(
MigrationAction::for_active_unit(
"foo.service",
&unit_info(&[("Service", &[("X-ReloadIfChanged", "true")])])
),
MigrationAction::Reload
);
// X-RestartIfChanged=false (restartIfChanged = false) must skip.
assert_eq!(
MigrationAction::for_active_unit(
"foo.service",
&unit_info(&[("Service", &[("X-RestartIfChanged", "false")])])
),
MigrationAction::Skip
);
// RefuseManualStop must skip.
assert_eq!(
MigrationAction::for_active_unit(
"foo.service",
&unit_info(&[("Unit", &[("RefuseManualStop", "yes")])])
),
MigrationAction::Skip
);
// X-OnlyManualStart must skip.
assert_eq!(
MigrationAction::for_active_unit(
"foo.service",
&unit_info(&[("Unit", &[("X-OnlyManualStart", "yes")])])
),
MigrationAction::Skip
);
// Units that cannot be restarted directly must skip.
for unit in [
"sysinit.target",
"basic.target",
"multi-user.target",
"graphical.target",
"foo.path",
"bar.slice",
] {
assert_eq!(
MigrationAction::for_active_unit(unit, &unit_info(&[])),
MigrationAction::Skip,
"{unit}"
);
}
}
#[test]
fn migration_action_for_stopped_unit() {
use super::MigrationAction;
assert_eq!(
MigrationAction::for_stopped_unit(&unit_info(&[])),
MigrationAction::Start
);
assert_eq!(
MigrationAction::for_stopped_unit(&unit_info(&[(
"Unit",
&[("RefuseManualStart", "true")]
)])),
MigrationAction::Skip
);
assert_eq!(
MigrationAction::for_stopped_unit(&unit_info(&[(
"Unit",
&[("X-OnlyManualStart", "true")]
)])),
MigrationAction::Skip
);
}
}

View File

@@ -17,7 +17,7 @@
rustPlatform.buildRustPackage (finalAttrs: {
pname = "ty";
version = "0.0.38";
version = "0.0.40";
__structuredAttrs = true;
src = fetchFromGitHub {
@@ -25,7 +25,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
repo = "ty";
tag = finalAttrs.version;
fetchSubmodules = true;
hash = "sha256-70Y5i9m2h2+Jc44jLOf+gXX/PeDbURRJ80y+6h5SlRk=";
hash = "sha256-kdfPnyQXYtf3BDrYCFGfX0bMoPGjRpyH3aUeRZBiUKY=";
};
# For Darwin platforms, remove the integration test for file notifications,
@@ -39,7 +39,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
cargoBuildFlags = [ "--package=ty" ];
cargoHash = "sha256-+c2JfB55w9otmmgTFIDMwkpASJV7bIMEf0uqRXjk/QM=";
cargoHash = "sha256-yUbHTzUGNdpm3b1s/SkcpFGdp7WjN+xO+CVrPPwrh6A=";
nativeBuildInputs = [ installShellFiles ];
buildInputs = [ rust-jemalloc-sys ];
@@ -101,6 +101,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
mainProgram = "ty";
maintainers = with lib.maintainers; [
bengsparks
ddogfoodd
figsoda
GaetanLepage
];

View File

@@ -19,7 +19,6 @@
zlib,
pahole,
kmod,
ubootTools,
fetchpatch,
rustc-unwrapped,
rust-bindgen-unwrapped,
@@ -116,31 +115,6 @@ lib.makeOverridable (
;
};
# Folding in `ubootTools` in the default nativeBuildInputs is problematic, as
# it makes updating U-Boot cumbersome, since it will go above the current
# threshold of rebuilds
#
# To prevent these needless rounds of staging for U-Boot builds, we can
# limit the inclusion of ubootTools to target platforms where uImage *may*
# be produced.
#
# This command lists those (kernel-named) platforms:
# .../linux $ grep -l uImage ./arch/*/Makefile | cut -d'/' -f3 | sort
#
# This is still a guesstimation, but since none of our cached platforms
# coincide in that list, this gives us "perfect" decoupling here.
linuxPlatformsUsingUImage = [
"arc"
"arm"
"csky"
"mips"
"powerpc"
"sh"
"sparc"
"xtensa"
];
needsUbootTools = lib.elem stdenv.hostPlatform.linuxArch linuxPlatformsUsingUImage;
config =
let
attrName = attr: "CONFIG_" + attr;
@@ -267,7 +241,6 @@ lib.makeOverridable (
kmod
hexdump
]
++ optional needsUbootTools ubootTools
++ optionals (lib.versionAtLeast version "5.2") [
cpio
pahole
@@ -510,7 +483,7 @@ lib.makeOverridable (
preFixup = ''
if [ -z "''${dontStrip-}" -a -e $out/vmlinux ]; then
strip -v -S -p $out/vmlinux
$STRIP -v -S -p $out/vmlinux
fi
'';
@@ -536,12 +509,10 @@ lib.makeOverridable (
kernelAtLeast = lib.versionAtLeast baseVersion;
};
# Some image types need special install targets (e.g. uImage is installed with make uinstall on arm)
# Some image types need special install targets
installTargets = [
(stdenv.hostPlatform.linux-kernel.installTarget or (
if target == "uImage" && stdenv.hostPlatform.linuxArch == "arm" then
"uinstall"
else if
if
(target == "zImage" || target == "Image.gz" || target == "vmlinuz.efi")
&& builtins.elem stdenv.hostPlatform.linuxArch [
"arm"

View File

@@ -773,6 +773,9 @@ let
SQUASHFS_LZ4 = yes;
SQUASHFS_ZSTD = yes;
EROFS_FS_ZIP_DEFLATE = whenAtLeast "6.6" yes;
EROFS_FS_ZIP_ZSTD = whenAtLeast "6.10" yes;
# Native Language Support modules, needed by some filesystems
NLS = yes;
NLS_DEFAULT = freeform "utf8";

View File

@@ -1,42 +1,42 @@
{
"testing": {
"version": "7.1-rc4",
"hash": "sha256:06z73f1vprl4adbdj01h3407p3f8bl8jsz91zx454c62k2jg3w40",
"version": "7.1-rc6",
"hash": "sha256:1fmbsjhdrkzim6vzqc40raikv1szfw28q0lbvap8a1g77an0qi58",
"lts": false
},
"6.1": {
"version": "6.1.174",
"hash": "sha256:0vp07x4v82qnmc1pifv3ynp2ab5mvlbfnpqvs5893bi3yrnk927d",
"version": "6.1.175",
"hash": "sha256:11fapr04y96p9ja6mfzm7bcd3zb4dzyw6qrh7c11bss9wjlq9s9p",
"lts": true
},
"5.15": {
"version": "5.15.208",
"hash": "sha256:0wmi50q8vgblhbh77d1a4sw4snymr6srqd22bxcjg9i7wcv70gdm",
"version": "5.15.209",
"hash": "sha256:1d0yhbpqlkr1znahky15dfavr6dzb3wb8c15k9qqvkf2xb3pfv9l",
"lts": true
},
"5.10": {
"version": "5.10.257",
"hash": "sha256:1lghcrxc1fqarvym03jrcda2a3labc887ci9yjqgbmv3nphzvc88",
"version": "5.10.258",
"hash": "sha256:1rdldzb3g33v6zvcmxafqpkjgqpp4n5qlxwb77wfd5jpzhgcnz4y",
"lts": true
},
"6.6": {
"version": "6.6.141",
"hash": "sha256:1qbzxgqs7q9gyqfrf0j7p0pgjxnjj5mibamhm280mf9anqp6bhiv",
"version": "6.6.142",
"hash": "sha256:0w1bdzp9x1sqcr9xlk7dvylhs7kycghjabfgd3iv49ydfmx61xmj",
"lts": true
},
"6.12": {
"version": "6.12.91",
"hash": "sha256:0sbrb612b653w64g5jkpbf68y0fka2sgnwblam41k7wz2sgapwhg",
"version": "6.12.92",
"hash": "sha256:0gly5wld3x8l0f3zk9pspsw1q2d7zbjbx4c2ndb49b1wvfvpdqqg",
"lts": true
},
"6.18": {
"version": "6.18.33",
"hash": "sha256:10mp1ypsdz42jr26g1xxbw806mvpy0n35418fhsgxxlr4lqgy5kg",
"version": "6.18.34",
"hash": "sha256:0q6palsvwx0gnisjr658hlngfpvyzv0k5q4pvdk23122zcr4f334",
"lts": true
},
"7.0": {
"version": "7.0.10",
"hash": "sha256:1p1j9s0b4qv9m0pm6vj477rqgyd1b0lsk0gy74cks3n2cbmpfj89",
"version": "7.0.11",
"hash": "sha256:012307ni1v555a1rgzsxsg99pj8fplrghvhw0jk3c4d0vmb86v75",
"lts": false
}
}

View File

@@ -25,7 +25,7 @@ lib.mapAttrs (n: make) (
# on how to request an upload.
# Sort following the sorting in `./default.nix` `bootstrapFiles` argument.
armv5tel-unknown-linux-gnueabi = sheevaplug;
armv5tel-unknown-linux-gnueabi = armv5tel-multiplatform;
armv6l-unknown-linux-gnueabihf = raspberryPi;
armv7l-unknown-linux-gnueabihf = armv7l-hf-multiplatform;
aarch64-unknown-linux-gnu = aarch64-multiplatform;

View File

@@ -189,7 +189,7 @@ let
pkgs.pkgsLLVM.stdenv
pkgs.pkgsStatic.bash
pkgs.pkgsCross.arm-embedded.stdenv
pkgs.pkgsCross.sheevaplug.stdenv # for armv5tel
pkgs.pkgsCross.armv5tel-multiplatform.stdenv
pkgs.pkgsCross.raspberryPi.stdenv # for armv6l
pkgs.pkgsCross.armv7l-hf-multiplatform.stdenv
pkgs.pkgsCross.m68k.stdenv

View File

@@ -197,8 +197,8 @@ in
crossIphone32 = mapTestOnCross systems.examples.iphone32 darwinCommon;
# Test some cross builds to the Sheevaplug
crossSheevaplugLinux = mapTestOnCross systems.examples.sheevaplug (
# Test some cross builds to ARMv5
armv5tel = mapTestOnCross systems.examples.armv5tel-multiplatform (
linuxCommon
// {
ubootSheevaplug = nativePlatforms;
@@ -235,8 +235,6 @@ in
# Linux on armv7l-hf
armv7l-hf = mapTestOnCross systems.examples.armv7l-hf-multiplatform linuxCommon;
pogoplug4 = mapTestOnCross systems.examples.pogoplug4 linuxCommon;
# Linux on aarch64
aarch64 = mapTestOnCross systems.examples.aarch64-multiplatform linuxCommon;
aarch64-musl = mapTestOnCross systems.examples.aarch64-multiplatform-musl linuxCommon;