diff --git a/docs/release-notes/rl-2505.md b/docs/release-notes/rl-2505.md index 588ce5b47..e73401e5c 100644 --- a/docs/release-notes/rl-2505.md +++ b/docs/release-notes/rl-2505.md @@ -22,6 +22,23 @@ This release has the following notable changes: tests are available through a Nix Flake file inside the `tests` directory. See [](#sec-tests) for example commands. +- The Home Manager NixOS module now supports + [home-manager.users](#nixos-opt-home-manager.users) entries that do not have + corresponding `users.users.` entries, making it easier to provide Home + Manager configurations for users managed through external identity management + systems. To take advantage of this, such users must have their + [](#opt-home.username) and [](#opt-home.homeDirectory) attributes set to + high-precedence values with `lib.mkForce` or similar, overriding the defaults + that pull from, respectively, `users.users..name` and + `users.users..home`. + +- The Home Manager NixOS module option + [home-manager.useUserPackages](#nixos-opt-home-manager.useUserPackages) now + sets the default value of the newly-introduced per-user + [](#opt-home.useUserPackages) option, making it possible to define a policy + on the use of `users.users..packages` for package installation, and to + override that policy on a user-specific basis. + ## State Version Changes {#sec-release-25.05-state-version-changes} The state version in this release includes the changes below. These diff --git a/modules/home-environment.nix b/modules/home-environment.nix index 9d792ca31..3a110c3e9 100644 --- a/modules/home-environment.nix +++ b/modules/home-environment.nix @@ -1,4 +1,4 @@ -{ config, lib, pkgs, ... }: +{ config, lib, pkgs, ... }@args: let inherit (lib) literalExpression mkOption types; @@ -500,6 +500,21 @@ in Whether to make programs use XDG directories whenever supported. ''; }; + + home.useUserPackages = mkOption { + type = types.bool; + default = args.nixosConfig.home-manager.useUserPackages or false; + defaultText = lib.literalExpression "home-manager.useUserPackages"; + description = '' + Enable installation of user packages through the + {option}`users.users..packages` option. + + ::: {.warning} + This option is specific to NixOS configurations and has no effect + elsewhere. + ::: + ''; + }; }; config = { @@ -521,6 +536,7 @@ in releaseMismatch = config.home.enableNixpkgsReleaseCheck && hmRelease != nixpkgsRelease; + wronglyUsingUserPackages = cfg.useUserPackages && (!(args ? nixosConfig)); in lib.optional releaseMismatch '' You are using @@ -537,7 +553,11 @@ in home.enableNixpkgsReleaseCheck = false; to your configuration. - ''; + '' + ++ lib.optional wronglyUsingUserPackages + "You have enabled `home.useUserPackages`, but this option has no effect outside of NixOS configurations."; + + home.username = lib.mkIf (lib.versionOlder config.home.stateVersion "20.09") diff --git a/nixos/common.nix b/nixos/common.nix index 05931ad60..fd0383cdc 100644 --- a/nixos/common.nix +++ b/nixos/common.nix @@ -10,6 +10,27 @@ let extendedLib = import ../modules/lib/stdlib-extended.nix lib; + usersUsingUserPackages = + lib.filterAttrs (_username: usercfg: usercfg.home.useUserPackages) + cfg.users; + + # `defaultOverridePriority` (and its legacy counterpart `defaultPriority`) + # represents the priority of option definitions that do not explicity specify + # a priority; that is, definitions of the form `{ foo = "bar"; }`. + # + # Setting an option with `mkUserDefault` permits the module system to resolve + # the so-defined option's priority without evaluating its value. We use this + # to define `home.username` and `home.homeDirectory` at the default priority, + # while also allowing consumers to override these values with `mkForce`, and, + # crucially, allowing consumers to define `home-manager.users` entries + # **without those users needing to have corresponding entries in + # `config.users.users`**. + # + # In other respects, `{ foo = mkUserDefault "bar"; }` quacks like + # `{ foo = "bar"; }`. + mkUserDefault = lib.mkOverride + (lib.modules.defaultOverridePriority or lib.modules.defaultPriority); + hmModule = types.submoduleWith { description = "Home Manager module"; class = "homeManager"; @@ -19,7 +40,7 @@ let modulesPath = builtins.toString ../modules; } // cfg.extraSpecialArgs; modules = [ - ({ name, ... }: { + ({ name, ... }@usercfg: { imports = import ../modules/modules.nix { inherit pkgs; lib = extendedLib; @@ -28,10 +49,11 @@ let config = { submoduleSupport.enable = true; - submoduleSupport.externalPackageInstall = cfg.useUserPackages; + submoduleSupport.externalPackageInstall = + usercfg.config.home.useUserPackages; - home.username = config.users.users.${name}.name; - home.homeDirectory = config.users.users.${name}.home; + home.username = mkUserDefault config.users.users.${name}.name; + home.homeDirectory = mkUserDefault config.users.users.${name}.home; # Forward `nix.enable` from the OS configuration. The # conditional is to check whether nix-darwin is new enough @@ -52,8 +74,8 @@ let in { options.home-manager = { useUserPackages = mkEnableOption '' - installation of user packages through the - {option}`users.users..packages` option''; + the per-user option {option}`home-manager.users..useUserPackages` + by default''; useGlobalPkgs = mkEnableOption '' using the system configuration's `pkgs` @@ -104,10 +126,10 @@ in { }; config = (lib.mkMerge [ - # Fix potential recursion when configuring home-manager users based on values in users.users #594 - (mkIf (cfg.useUserPackages && cfg.users != { }) { + (mkIf (usersUsingUserPackages != { }) { users.users = (lib.mapAttrs - (_username: usercfg: { packages = [ usercfg.home.path ]; }) cfg.users); + (_username: usercfg: { packages = [ usercfg.home.path ]; }) + usersUsingUserPackages); environment.pathsToLink = [ "/etc/profile.d" ]; }) (mkIf (cfg.users != { }) { diff --git a/nixos/default.nix b/nixos/default.nix index ee973146c..8f0c062d1 100644 --- a/nixos/default.nix +++ b/nixos/default.nix @@ -16,19 +16,22 @@ in { home-manager = { extraSpecialArgs.nixosConfig = config; - sharedModules = [{ - key = "home-manager#nixos-shared-module"; + sharedModules = [ + ({ ... }@usercfg: { + key = "home-manager#nixos-shared-module"; - config = { - # The per-user directory inside /etc/profiles is not known by - # fontconfig by default. - fonts.fontconfig.enable = lib.mkDefault - (cfg.useUserPackages && config.fonts.fontconfig.enable); + config = { + # The per-user directory inside /etc/profiles is not known by + # fontconfig by default. + fonts.fontconfig.enable = lib.mkDefault + (usercfg.config.home.useUserPackages != { } + && config.fonts.fontconfig.enable); - # Inherit glibcLocales setting from NixOS. - i18n.glibcLocales = lib.mkDefault config.i18n.glibcLocales; - }; - }]; + # Inherit glibcLocales setting from NixOS. + i18n.glibcLocales = lib.mkDefault config.i18n.glibcLocales; + }; + }) + ]; }; } (lib.mkIf (cfg.users != { }) { diff --git a/tests/integration/nixos/basics.nix b/tests/integration/nixos/basics.nix index 4b7cbcf8d..225339a54 100644 --- a/tests/integration/nixos/basics.nix +++ b/tests/integration/nixos/basics.nix @@ -1,94 +1,184 @@ -{ pkgs, ... }: +{ lib, pkgs, ... }: { name = "nixos-basics"; meta.maintainers = [ pkgs.lib.maintainers.rycee ]; - nodes.machine = { ... }: { - imports = [ ../../../nixos ]; # Import the HM NixOS module. + nodes.machine = { config, pkgs, ... }: + let inherit (config.home-manager.users) bob; + in { + imports = [ ../../../nixos ]; # Import the HM NixOS module. - virtualisation.memorySize = 2048; + security.pam.services = { + chpasswd = { }; + passwd = { }; + }; - users.users.alice = { - isNormalUser = true; - description = "Alice Foobar"; + system.activationScripts.addBobUser = lib.fullDepEntry '' + ${pkgs.shadow}/bin/useradd \ + --comment 'Manually-created user' \ + --create-home --no-user-group \ + --gid ${lib.escapeShellArg config.users.groups.users.name} \ + --home-dir ${lib.escapeShellArg bob.home.homeDirectory} \ + --no-user-group \ + --shell ${ + lib.escapeShellArg (lib.getExe config.users.defaultUserShell) + } \ + ${lib.escapeShellArg bob.home.username} + '' [ "groups" "users" ]; + + virtualisation.memorySize = 2048; + + # To be able to add the `bob` account with `useradd`. + users.mutableUsers = true; + + users.users.alice = { + isNormalUser = true; + description = "Alice Foobar"; + uid = 1000; + }; + + home-manager.useUserPackages = true; + + home-manager.users = let + common = { + home.stateVersion = "24.11"; + home.file.test.text = "testfile"; + home.packages = [ pkgs.hello ]; + # Enable a light-weight systemd service. + services.pueue.enable = true; + }; + in { + alice = common; + + # User without corresponding entry in `users.users`. + bob = { name, ... }: { + imports = [ common ]; + home.stateVersion = "24.11"; + home.username = lib.mkForce name; + home.homeDirectory = lib.mkForce "/var/tmp/hm/home/bob"; + home.useUserPackages = false; + }; + }; + }; + + testScript = { nodes, ... }: + let + inherit (nodes.machine.home-manager.users) alice bob; password = "foobar"; - uid = 1000; - }; + in '' + import pprint + from contextlib import contextmanager + from typing import Callable, Dict, Optional - home-manager.users.alice = { ... }: { - home.stateVersion = "24.11"; - home.file.test.text = "testfile"; - # Enable a light-weight systemd service. - services.pueue.enable = true; - }; - }; + def wait_for_unit_properties( + machine: Machine, unit: str, check: Callable[[Dict[str, str]], Dict[str, Optional[str]]], user: Optional[str] = None, timeout: int = 900, + ) -> None: + def unit_has_properties(_): + info = machine.get_unit_info(unit, user) + mismatches = check(info) + if mismatches == {}: + return True + else: + machine.log(f"unit {unit} has unexpected properties {pprint.pformat(mismatches)}") + return False - testScript = '' - def login_as_alice(): - machine.wait_until_tty_matches("1", "login: ") - machine.send_chars("alice\n") - machine.wait_until_tty_matches("1", "Password: ") - machine.send_chars("foobar\n") - machine.wait_until_tty_matches("1", "alice\\@machine") + with machine.nested(f"waiting for unit {unit} to satisfy expected properties"): + retry(unit_has_properties, timeout) - def logout_alice(): - machine.send_chars("exit\n") + def wait_for_oneshot_successful_completion( + machine: Machine, unit: str, user: Optional[str] = None, timeout: int = 900 + ) -> None: + def unit_is_successfully_completed_oneshot(info: Dict[str, str]) -> Dict[str, Optional[str]]: + assert "Type" in info, f"unit {unit}'s properties include 'Type'" + assert info["Type"] == "oneshot", f"expected unit {unit}'s 'Type' to be 'oneshot'; got {info['Type']}" - def alice_cmd(cmd): - return f"su -l alice --shell /bin/sh -c $'export XDG_RUNTIME_DIR=/run/user/$UID ; {cmd}'" + props = ["ActiveState", "Result", "SubState"] - def succeed_as_alice(cmd): - return machine.succeed(alice_cmd(cmd)) + if all([prop in info for prop in props]): + if "RemainAfterExit" in info and info["RemainAfterExit"] == "yes": + if info["ActiveState"] == "active" and info["Result"] == "success" and info["SubState"] == "exited": + return {} + elif info["ActiveState"] == "inactive" and info["Result"] == "success" and info["SubState"] == "dead": + return {} - def fail_as_alice(cmd): - return machine.fail(alice_cmd(cmd)) + return {prop: info.get(prop) for prop in props} - start_all() + return wait_for_unit_properties( + machine, unit, unit_is_successfully_completed_oneshot, user=user, timeout=timeout + ) - machine.wait_for_console_text("Finished Home Manager environment for alice.") + @contextmanager + def user_login(machine: Machine, user: str, password: str): + machine.wait_until_tty_matches("1", "login: ") + machine.send_chars(f"{user}\n") + machine.wait_until_tty_matches("1", "Password: ") + machine.send_chars(f"{password}\n") + machine.wait_until_tty_matches("1", f"{user}\\@{machine.name}") - with subtest("Home Manager file"): - # The file should be linked with the expected content. - path = "/home/alice/test" - machine.succeed(f"test -L {path}") - actual = machine.succeed(f"cat {path}") - expected = "testfile" - assert actual == expected, f"expected {path} to contain {expected}, but got {actual}" + try: + yield + finally: + machine.send_chars("exit\n") - with subtest("Pueue service"): - login_as_alice() + def cmd_for_user(user: str, cmd: str) -> str: + return f"su -l {user} --shell /bin/sh -c $'export XDG_RUNTIME_DIR=/run/user/$UID ; {cmd}'" - actual = succeed_as_alice("pueue status") - expected = "running" - assert expected in actual, f"expected pueue status to contain {expected}, but got {actual}" + def succeed_as_user(user: str, cmd: str) -> str: + return machine.succeed(cmd_for_user(user, cmd)) - # Shut down pueue, then run the activation again. Afterwards, the service - # should be running. - machine.succeed("systemctl --user -M alice@.host stop pueued.service") + def fail_as_user(user: str, cmd: str) -> str: + return machine.fail(cmd_for_user(user, cmd)) - fail_as_alice("pueue status") + start_all() - machine.systemctl("restart home-manager-alice.service") - machine.wait_for_console_text("Finished Home Manager environment for alice.") + for user in ["${alice.home.username}", "${bob.home.username}"]: + user_hm_unit = f"home-manager-{user}.service" + wait_for_oneshot_successful_completion(machine, user_hm_unit) - actual = succeed_as_alice("pueue status") - expected = "running" - assert expected in actual, f"expected pueue status to contain {expected}, but got {actual}" + machine.succeed(f"echo '{user}:${password}' | ${pkgs.shadow}/bin/chpasswd") - logout_alice() + with subtest(f"Home Manager file (user {user})"): + # The file should be linked with the expected content. + path = f"~{user}/test" + machine.succeed(f"test -L {path}") + actual = machine.succeed(f"cat {path}") + expected = "testfile" + assert actual == expected, f"expected {path} to contain {expected}, but got {actual}" - with subtest("GC root and profile"): - # There should be a GC root and Home Manager profile and they should point - # to the same path in the Nix store. - gcroot = "/home/alice/.local/state/home-manager/gcroots/current-home" - gcrootTarget = machine.succeed(f"readlink {gcroot}") + with subtest(f"Command from `home.packages` (user {user})"): + succeed_as_user(user, "hello") - profile = "/home/alice/.local/state/nix/profiles" - profileTarget = machine.succeed(f"readlink {profile}/home-manager") - profile1Target = machine.succeed(f"readlink {profile}/{profileTarget}") + with subtest(f"Pueue service (user {user})"): + with user_login(machine, user, "${password}"): + actual = succeed_as_user(user, "pueue status") + expected = "running" + assert expected in actual, f"expected pueue status to contain {expected}, but got {actual}" - assert gcrootTarget == profile1Target, \ - f"expected GC root and profile to point to same, but pointed to {gcrootTarget} and {profile1Target}" - ''; + # Shut down pueue, then run the activation again. Afterwards, the + # service should be running. + machine.succeed(f"systemctl --user -M {user}@.host stop pueued.service") + + fail_as_user(user, "pueue status") + + machine.systemctl(f"restart {user_hm_unit}") + wait_for_oneshot_successful_completion(machine, user_hm_unit) + + actual = succeed_as_user(user, "pueue status") + expected = "running" + assert expected in actual, f"expected pueue status to contain {expected}, but got {actual}" + + with subtest(f"GC root and profile (user {user})"): + # There should be a GC root and Home Manager profile and they should + # point to the same path in the Nix store. + gcroot = f"~{user}/.local/state/home-manager/gcroots/current-home" + gcrootTarget = machine.succeed(f"readlink {gcroot}") + + profile = f"~{user}/.local/state/nix/profiles" + profileTarget = machine.succeed(f"readlink {profile}/home-manager") + profile1Target = machine.succeed(f"readlink {profile}/{profileTarget}") + + assert gcrootTarget == profile1Target, \ + f"expected GC root and profile to point to same, but pointed to {gcrootTarget} and {profile1Target}" + ''; } diff --git a/tests/modules/home-environment/default.nix b/tests/modules/home-environment/default.nix index c38ad5ce5..bdc10ca82 100644 --- a/tests/modules/home-environment/default.nix +++ b/tests/modules/home-environment/default.nix @@ -2,4 +2,5 @@ home-session-path = ./session-path.nix; home-session-search-variables = ./session-search-variables.nix; home-session-variables = ./session-variables.nix; + home-use-user-packages = ./use-user-packages.nix; } diff --git a/tests/modules/home-environment/use-user-packages.nix b/tests/modules/home-environment/use-user-packages.nix new file mode 100644 index 000000000..747eb14de --- /dev/null +++ b/tests/modules/home-environment/use-user-packages.nix @@ -0,0 +1,7 @@ +{ + home.useUserPackages = true; + + test.asserts.warnings.expected = [ + "You have enabled `home.useUserPackages`, but this option has no effect outside of NixOS configurations." + ]; +}