diff --git a/README.md b/README.md index c22104a2..801fea49 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ source by. | `flake-parts` | `flake-parts` | flake outputs | | `nixpkgs` | `nixpkgs` | package sets and overlays | | `nixos` | `nixpkgs` | NixOS configurations | -| `darwin` | `nix-darwin` | macOS machines, evaluated with nix-darwin and published as the `darwinConfigurations` that `darwin-rebuild` reads | +| `darwin` | `nix-darwin` | macOS machines, evaluated with nix-darwin and published as the `darwinConfigurations` that `darwin-rebuild` reads; they activate the homes and hjem configurations declared inside them | | `nixos-minimal` | `nixpkgs` | the `nixos` class under the minimal evaluator (no NixOS base modules); a second integration over the class `nixos` owns | | `home-manager` | `home-manager` | Home Manager configurations | | `home-manager-minimal` | `home-manager` | the `homeManager` class with Home Manager's necessary modules alone, the configuration importing what it uses from `modulesPath`; a second integration over the class `home-manager` owns | diff --git a/configs/flake/caisson/partitions/checks.nix b/configs/flake/caisson/partitions/checks.nix index 36cc9239..0b6ac347 100644 --- a/configs/flake/caisson/partitions/checks.nix +++ b/configs/flake/caisson/partitions/checks.nix @@ -290,11 +290,26 @@ # same machine on any host; building it needs a darwin builder, # so the darwin job of the workflow builds it on a macOS runner. flake.checks.aarch64-darwin.darwin-system = - (import (self.outPath + "/tests/darwin-system") { - inputs = inputs // { - caisson = self; + let + probe = import (self.outPath + "/tests/darwin-system") { + inputs = inputs // { + caisson = self; + }; }; - }).toplevel; + in + # The bare machine, and the machine with a home and an hjem + # configuration inside it, whose system holds the activation + # of the home and the launchd agents of hjem. + probe.pkgs.linkFarm "darwin-system" [ + { + name = "probe"; + path = probe.toplevel; + } + { + name = "family"; + path = probe.family.system; + } + ]; }; }; diff --git a/docs/development/testing-architecture.md b/docs/development/testing-architecture.md index e44f741e..4f4def03 100644 --- a/docs/development/testing-architecture.md +++ b/docs/development/testing-architecture.md @@ -313,6 +313,6 @@ The checks partition merges outputs from all test and example flakes into the | `debug-disabled` | `checks.nix` (inline) | `self.debug` is not exposed in production | | `pinned-world` | `checks.nix` + `tests/pinned-world/` | caisson composed with the pinned upstream world (nixpkgs, home-manager, colmena, disko, nix-darwin, hjem, terranix, system-manager) evaluates end to end; the `drift` workflow runs the same check against advanced pins | | `hjem-system` | `checks.nix` + `tests/hjem-system/` | The manifest file of an hjem configuration inside a NixOS machine builds, which checks it against hjem's schema with `cue vet`, and so do the units that activate it, which runs shellcheck on their scripts | -| `darwin-system` (aarch64-darwin) | `checks.nix` + `tests/darwin-system/` | The macOS machine of the pinned-world suite, declared through the darwin integration, builds; the `darwin-build` job of the check workflow builds it on a macOS runner | +| `darwin-system` (aarch64-darwin) | `checks.nix` + `tests/darwin-system/` | The two macOS machines of the pinned-world suite, declared through the darwin integration, build: the bare machine, and the machine with a home and an hjem configuration inside it; the `darwin-build` job of the check workflow builds them on a macOS runner | | `argument-errors` | `checks.nix` + `tests/argument-errors/` | The argument error of every entry point comes from Nix itself, raised at the call site with no frame of caisson | | `eval-weight` | `checks.nix` + `tests/eval-weight/` | Framework evaluation cost held to committed ceilings ([guide](https://nix-caisson.github.io/docs/eval-weight.html)) | diff --git a/docs/development/testing.md b/docs/development/testing.md index 7d416c7c..6b3b3a13 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -27,14 +27,15 @@ plumbing works. terranix, system-manager) and exercised end to end: a home configuration, a NixOS system with homes declared inside it, a colmena hive, disk layouts with the machines installed onto them, a - darwin machine evaluated at aarch64-darwin, hjem configurations on + darwin machine evaluated at aarch64-darwin with and without a home + and an hjem configuration inside it, hjem configurations on their own and inside a NixOS machine, a terranix and a system-manager configuration, and the composition guarantees of the real exported overlays. The `hjem-system` check builds the manifest file of the hjem configuration inside the machine, which checks it against hjem's schema, and the units that activate it, which runs shellcheck on - their scripts. The darwin machine of the suite is also the one check at - aarch64-darwin, `checks.aarch64-darwin.darwin-system`, which the + their scripts. The two darwin machines of the suite are also the one + check at aarch64-darwin, `checks.aarch64-darwin.darwin-system`, which the `darwin-build` job of the check workflow builds on a macOS runner. Every upstream expectation caisson relies on is a probe here. - **Location:** `tests/pinned-world/default.nix`; the pins in `tests/dependencies/flake.lock`. diff --git a/lib-overlays/hjem/default.nix b/lib-overlays/hjem/default.nix index 6e936afb..5adc3221 100644 --- a/lib-overlays/hjem/default.nix +++ b/lib-overlays/hjem/default.nix @@ -130,10 +130,13 @@ null; # The machine the configuration is declared inside, when there - # is one: the childless evaluation of the nearest NixOS - # configuration above it, which hjem's modules receive as - # `osConfig`, null when there is none. - machine = manifest.nearest.nixos or null; + # is one: the childless evaluation of the nearest NixOS or + # darwin configuration above it, which hjem's modules receive + # as `osConfig`, null when there is none. + machine = selection.nearestOf [ + "nixos" + "darwin" + ] manifest; osConfig = if machine != null then machine.value.config else null; # A user hjem's NixOS module embeds also imports hjem's @@ -143,7 +146,7 @@ # a NixOS machine that has `utils` gets the same; the minimal # NixOS evaluator gives none. machineArgs = if machine != null then (machine.value._module or { }).args or { } else { }; - nixosUser = machineArgs ? utils; + nixosUser = machine != null && machine.type == "nixos" && machineArgs ? utils; # What hjem's OS modules set on a user that caisson sets too: # the user's name from the name the configuration is declared @@ -169,9 +172,13 @@ hjem-lib = hjemLib; name = if name != null then name else "hjem"; } + # hjem's OS modules hand a user the options of the machine + # as `osOptions`, and the NixOS module also hands it `utils`. + // lib.optionalAttrs (machine != null) { + osOptions = machine.value.options; + } // lib.optionalAttrs nixosUser { inherit (machineArgs) utils; - osOptions = machine.value.options; } // (if (args.specialArgs or null) != null then args.specialArgs else { }); modules = [ diff --git a/lib-overlays/home-manager/default.nix b/lib-overlays/home-manager/default.nix index 8e06eee1..ee5f8191 100644 --- a/lib-overlays/home-manager/default.nix +++ b/lib-overlays/home-manager/default.nix @@ -234,15 +234,14 @@ home.username = lib.mkDefault name; }; - # The NixOS configuration the home is declared inside, when - # there is one. It is the nearest NixOS configuration above - # the home, so a structural configuration between the two - # changes nothing. The manifest holds the childless - # evaluation of that NixOS configuration, which is the - # evaluation that leaves out the configurations declared - # inside it. The home is one of those, so the full + # The machine the home is declared inside, when there is one: + # the nearest NixOS or darwin configuration above the home, so + # a structural configuration between the two changes nothing. + # The manifest holds the childless evaluation of that machine, + # which is the evaluation that leaves out the configurations + # declared inside it. The home is one of those, so the full # evaluation would depend on the home. - machine = manifest.nearest.nixos or null; + machine = selection.nearestOf machineTypes manifest; # home-manager hands a home the configuration of its machine # as the module argument `osConfig`, and null when the home @@ -287,6 +286,8 @@ # home-manager also hands a home inside a machine the class # of the machine, as `osClass`. A home with no machine gets # no such argument from home-manager, so none is added here. + # home-manager's nix-darwin module also hands a home the + # machine as `darwinConfig`. # # home-manager's legacy argument `nixosConfig` is left at # null, the value home-manager gives it in a home with no @@ -294,7 +295,8 @@ # `nixosConfig.home-manager`, an option that only # home-manager's NixOS module declares, and caisson does not # use that module. - // (if machine != null then { osClass = "nixos"; } else { }) + // (if machine != null then { osClass = machine.type; } else { }) + // (if machine != null && machine.type == "darwin" then { darwinConfig = osConfig; } else { }) // (if (args.specialArgs or null) != null then args.specialArgs else { }); # The module tree the evaluation reads: home-manager's # module list, its module files and the `modulesPath` special @@ -469,27 +471,37 @@ }; }; + # The integrations whose configurations are machines a home can + # be declared inside. + machineTypes = [ + "nixos" + "darwin" + ]; + # A home is published as `homeConfigurations.`, the # evaluated home, which is what the home-manager CLI reads. A - # home beneath a NixOS configuration is named `@`, - # the name it is passed up under and the name that NixOS - # configuration is declared under, which is where the CLI looks - # (`$USER@$(hostname)`); a home with no NixOS configuration above - # it keeps its name. + # home beneath a NixOS or darwin configuration is named + # `@`, the name it is passed up under and the name + # that machine is declared under, which is where the CLI looks + # (`$USER@$(hostname)`); a home with no machine above it keeps + # its name. exportsTo = { attrset = "homeConfigurations"; value = manifest: manifest.value; name = { name, manifest }: let - host = (manifest.nearest.nixos or { }).name or null; + machine = final.caisson.integrations.nearestOf machineTypes manifest; + host = if machine == null then null else machine.name or null; in if host == null then { value = name; } else { value = "${name}@${host}"; - description = "named ${name}@${host} from its name and the name of the NixOS configuration it is under"; + description = "named ${name}@${host} from its name and the name of the ${ + { nixos = "NixOS"; }.${machine.type} or machine.type + } configuration it is under"; }; }; diff --git a/lib-overlays/integrations/default.nix b/lib-overlays/integrations/default.nix index 15f40754..90a3c88c 100644 --- a/lib-overlays/integrations/default.nix +++ b/lib-overlays/integrations/default.nix @@ -565,6 +565,26 @@ genAttrs declaring (name: config.caisson.${name}.configurations) ); + # The nearest configuration above `manifest` whose integration is + # one of `types`, null when there is none. The manifest records + # the nearest ancestor of each integration; of those, the nearest + # is the one with the most ancestors itself. A home reads its + # machine this way, which is a NixOS or a darwin configuration. + nearestOf = + types: manifest: + builtins.foldl' ( + found: type: + let + candidate = (manifest.nearest or { }).${type} or null; + in + if candidate == null then + found + else if found == null || builtins.length candidate.ancestors > builtins.length found.ancestors then + candidate + else + found + ) null types; + isManifest = value: builtins.isAttrs value && (value._type or null) == "caisson-manifest"; # How a configuration is published: the `exportsTo` its @@ -811,6 +831,7 @@ entriesOf configurationsModuleFor integrationsIn + nearestOf mkEvaluation publish topValue diff --git a/modules/darwin/default/caisson/default.nix b/modules/darwin/default/caisson/default.nix new file mode 100644 index 00000000..9a7c0c1c --- /dev/null +++ b/modules/darwin/default/caisson/default.nix @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MIT +# +# The `caisson.*` part of the default darwin module. +{ closure-lib, ... }: +{ ... }: +{ + imports = [ + # `forChildren.nix` sets options under `caisson.forChildren`. The + # core module declares those options. + closure-lib.caisson.modules.darwin.core + (import ./forChildren.nix { inherit closure-lib; }) + ]; +} diff --git a/modules/darwin/default/caisson/forChildren.nix b/modules/darwin/default/caisson/forChildren.nix new file mode 100644 index 00000000..f70efda5 --- /dev/null +++ b/modules/darwin/default/caisson/forChildren.nix @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: MIT +# +# What a darwin configuration gives the homes and the hjem +# configurations declared inside it. +# +# A configuration gives things to the configurations declared inside +# it through the options under `caisson.forChildren`. This file adds +# the home-manager module `darwin-parent` to the modules that a home +# declared inside this darwin configuration imports by default, and +# the hjem module `darwin-parent` to those of an hjem configuration +# declared inside it. Each sets the options that come from the machine: +# the home directory from the account of the user, and for a home the +# user ID and the Nix of the machine. +# +# A darwin configuration that passes `moduleImports` selects its +# modules itself, and then nothing inside it gets these modules from +# here. A home or hjem configuration that passes `moduleImports` +# selects its modules itself, and gets the module only if it lists it. +# +# `closure-lib` is the library of caisson, where the modules are +# registered. +{ closure-lib }: +{ ... }: +{ + caisson.forChildren.extraDefaultModuleImports.homeManager = _lib: [ + closure-lib.caisson.modules.homeManager.darwin-parent + ]; + + caisson.forChildren.extraDefaultModuleImports.hjem = _lib: [ + closure-lib.caisson.modules.hjem.darwin-parent + ]; +} diff --git a/modules/darwin/default/default.nix b/modules/darwin/default/default.nix new file mode 100644 index 00000000..2982630a --- /dev/null +++ b/modules/darwin/default/default.nix @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MIT +# +# The `default` darwin module caisson registers. A darwin configuration +# imports every registered module named `default` unless it passes +# `moduleImports`, so this is what a darwin configuration gets from +# caisson when it does not select its modules itself. +# +# It holds what a macOS machine does for the homes and the hjem +# configurations declared inside it: +# +# - `./caisson/forChildren.nix` gives each home the home-manager module +# `darwin-parent`, and each hjem configuration the hjem module +# `darwin-parent`. Both set the options that come from the machine. +# - The registered module `home-manager-activation` activates the homes +# when the machine is activated. +# - The registered module `hjem-activation` writes the launchd agents +# that apply the hjem configurations. +# +# The parts read options of caisson's core module. The core module +# leaves its options out when the library of the evaluation is not a +# caisson composition, so the parts are left out in the same case. +{ closure-lib, mkModule, ... }: +{ lib, ... }: +{ + imports = + if lib ? caisson && lib ? caisson-core then + [ + (mkModule ./caisson) + closure-lib.caisson.modules.darwin.home-manager-activation + closure-lib.caisson.modules.darwin.hjem-activation + ] + else + [ ]; +} diff --git a/modules/darwin/hjem-activation/default.nix b/modules/darwin/hjem-activation/default.nix new file mode 100644 index 00000000..b9594e8c --- /dev/null +++ b/modules/darwin/hjem-activation/default.nix @@ -0,0 +1,248 @@ +# SPDX-License-Identifier: MIT +# +# A darwin module with which a macOS machine activates the hjem +# configurations declared inside it. +# +# Activating an hjem configuration means applying its manifest with +# hjem's CLI. hjem has a nix-darwin module that does this for the users +# it embeds, with a launchd agent, `org.hjem.activate`, that applies the +# manifest of the user it runs for and keeps the state in +# `~/Library/Application Support/Hjem`, and restarts the agent of each +# user from the machine's activation script, with `launchctl +# kickstart`. caisson does not use that nix-darwin module, and this +# module writes the same agent, script and restart. +# +# hjem's nix-darwin module declares the agent in `launchd.user.agents`, +# which nix-darwin installs for `system.primaryUser` alone. This module +# declares it in `launchd.agents`, which nix-darwin installs in +# `/Library/LaunchAgents`, where launchd loads it for every user who +# logs in, so each hjem user gets the agent, and the machine needs no +# primary user for it. The agent exits for a user no hjem configuration +# here names. +# +# The module also writes hjem's second agent, `org.nix.link-nix-apps`, +# which links the applications of the packages of the user into +# `~/Applications/Nix User Apps`, adds each user's `packages` to +# `users.users..packages`, and keeps each user's +# `extraDependencies` in the system, in `/etc/hjem/extra-dependencies`, +# as hjem's nix-darwin module does. +# +# Which configurations: the enabled hjem configurations this darwin +# configuration passes up, leaving out those declared inside a machine +# that is itself inside this one, and those evaluated for another +# system. caisson's `default` darwin module imports this module. +# +# The module reads the hjem configurations from the full evaluation of +# the darwin configuration; in the childless evaluation it defines +# nothing. +{ closure-lib, ... }: +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + + isActivatedHere = + entry: + entry.manifest.type == "hjem" + && !(builtins.any (segment: segment.type == "nixos" || segment.type == "darwin") ( + lib.lists.init entry.path + )) + && entry.manifest.system == evalManifest.system; + + users = + builtins.map + (entry: { + inherit (entry.manifest.value.config) user packages extraDependencies; + inherit (entry.manifest.outputs) manifestFile cli; + }) + ( + builtins.filter (entry: isActivatedHere entry && entry.manifest.value.config.enable) ( + config.caisson.exports.configurations + ) + ); + + newManifests = pkgs.linkFarm "hjem-manifests" ( + builtins.map (user: { + name = "manifest-${user.user}.json"; + path = user.manifestFile; + }) users + ); + + # The prefix hjem's CLI gives the backup of a file it replaces, the + # default of hjem's nix-darwin module. + prefix = ".backup-"; + + # The agent sets `hjem` to the CLI of the configuration of the user it + # runs for, and exits for any other user. + selectUser = '' + case "$USER" in + ${lib.concatMapStrings (user: '' + ${lib.escapeShellArg user.user}) hjem=${lib.getExe user.cli} ;; + '') users} + *) exit 0 ;; + esac + ''; + + # The start of the applications agent, which runs no hjem CLI: it + # exits for a user no hjem configuration here names. + requireUser = '' + case "$USER" in + ${lib.concatMapStringsSep " | " (user: lib.escapeShellArg user.user) users}) ;; + *) exit 0 ;; + esac + ''; + + activateLabel = "org.hjem.activate"; + appsLabel = "org.nix.link-nix-apps"; + + # The activation step that restarts an agent of each user who has a + # session, as hjem's nix-darwin module does. + kick = + label: + lib.concatMapStringsSep "\n" (user: '' + if uid="$(${lib.getExe' pkgs.coreutils-full "id"} -u ${lib.escapeShellArg user.user} 2>/dev/null)"; then + /bin/launchctl kickstart -k "gui/''${uid}/${label}" 2>/dev/null || true + fi + '') users; + + names = builtins.map (user: user.user) users; + repeated = lib.lists.unique ( + builtins.filter (name: lib.lists.count (other: other == name) names > 1) names + ); +in +{ + # The module reads `config.caisson.exports.configurations`. The core + # module declares that option. + imports = [ closure-lib.caisson.modules.darwin.core ]; + + config = lib.mkIf (!evalManifest.childless && users != [ ]) { + assertions = [ + { + assertion = repeated == [ ]; + message = "caisson: this darwin configuration activates more than one hjem configuration for ${lib.concatStringsSep ", " repeated}."; + } + ]; + + users.users = builtins.listToAttrs ( + builtins.map (user: { + name = user.user; + value.packages = user.packages; + }) users + ); + + # nix-darwin has no `system.extraDependencies`; hjem's nix-darwin + # module keeps the dependencies in the system this way. + environment.etc."hjem/extra-dependencies".text = lib.concatLines ( + builtins.concatMap (user: builtins.map (path: "${path}") user.extraDependencies) users + ); + + launchd.agents = { + hjem-activate.serviceConfig = { + Program = lib.getExe ( + pkgs.writeShellApplication { + name = "hjem-activate"; + runtimeInputs = [ pkgs.coreutils-full ]; + text = '' + set -euo pipefail + + USER="$(id -un)" + ${selectUser} + NEW="${newManifests}/manifest-''${USER}.json" + + if [ ! -f "$NEW" ]; then + exit 0 + fi + + STATE_DIR="$HOME/Library/Application Support/Hjem" + mkdir -p "$STATE_DIR" + CUR="$STATE_DIR/manifest.json" + + "$hjem" internal activate \ + --manifest "$NEW" \ + --state "$CUR" \ + --actions-file "$STATE_DIR/actions.json" \ + --prefix "${prefix}" + ''; + } + ); + Label = activateLabel; + RunAtLoad = true; + StandardOutPath = "/var/tmp/hjem-activate.out"; + StandardErrorPath = "/var/tmp/hjem-activate.err"; + }; + + link-nix-apps.serviceConfig = { + Program = lib.getExe ( + pkgs.writeShellApplication { + name = "link-nix-apps"; + runtimeInputs = with pkgs; [ + coreutils-full + findutils + gnugrep + nix + ]; + text = '' + set -euo pipefail + + USER="$(id -un)" + ${requireUser} + GROUP="$(id -gn)" + DEST="$HOME/Applications/Nix User Apps" + PROFILE="/etc/profiles/per-user/$USER" + + install -d -m 0755 -o "$USER" -g "$GROUP" "$DEST" + + desired="$(mktemp -t desired-apps.XXXXXX)" + trap 'rm -f "$desired"' EXIT + + nix-store -qR "$PROFILE" | while IFS= read -r p; do + apps="$p/Applications" + if [ -d "$apps" ]; then + find "$apps" -maxdepth 1 -type d -name "*.app" -print0 \ + | while IFS= read -r -d "" app; do + bname="$(basename "$app")" + echo "$bname" >> "$desired" + ln -sfn "$app" "$DEST/$bname" + done + fi + done + + sort -u "$desired" -o "$desired" + + find "$DEST" -maxdepth 1 -type l -name "*.app" -print0 \ + | while IFS= read -r -d "" link; do + name="$(basename "$link")" + if ! grep -Fxq "$name" "$desired"; then + rm -f "$link" + fi + done + + # Remove broken links + find "$DEST" -maxdepth 1 -type l -name "*.app" -print0 \ + | xargs -0 -I {} bash -c '[[ -e "{}" ]] || rm -f "{}"' + ''; + } + ); + Label = appsLabel; + RunAtLoad = true; + StandardOutPath = "/var/tmp/link-nix-apps.out"; + StandardErrorPath = "/var/tmp/link-nix-apps.err"; + }; + }; + + # The restart runs in `postActivation`, after nix-darwin's `launchd` + # step installs the agents of the new generation. nix-darwin runs a + # fixed list of activation steps; hjem's nix-darwin module restarts + # the activation agent from a step of another name, which nix-darwin + # does not run, and the applications agent from `applications`, + # which runs before the agents are installed. + system.activationScripts.postActivation.text = lib.mkAfter '' + ${kick activateLabel} + ${kick appsLabel} + ''; + }; +} diff --git a/modules/darwin/home-manager-activation/default.nix b/modules/darwin/home-manager-activation/default.nix new file mode 100644 index 00000000..ef0f7fc0 --- /dev/null +++ b/modules/darwin/home-manager-activation/default.nix @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: MIT +# +# A darwin module with which a macOS machine activates the homes +# declared inside it. +# +# Activating a home means running the `activate` script of its +# activation package as the user of the home. home-manager has a +# nix-darwin module that does this for the homes it embeds: a step of +# the machine's `postActivation` script runs `activate` for each home, +# as its user, through `launchctl asuser` and `sudo`. caisson does not +# use that nix-darwin module. A home declared inside a darwin +# configuration is a caisson configuration, and this module adds the +# same step, so that `darwin-rebuild switch` also applies the homes. +# +# caisson's `default` darwin module imports this module. A darwin +# configuration that passes `moduleImports` selects its modules +# itself, and then its homes are activated only if it lists this +# module. +# +# The module activates the home-manager configurations that this +# darwin configuration passes up, leaving out those declared inside a +# machine that is itself inside this one, and those evaluated for +# another system. It expects the machine to declare the account of the +# user of each home in `users.users`, as home-manager's nix-darwin +# module does, and refuses a home whose user has none. +# +# The step runs `activate` with no driver version, as caisson's NixOS +# activation does: the script also updates the home-manager profile of +# the user, so the user can run `home-manager switch` on the same +# profile. home-manager's nix-darwin module passes `--driver-version 1`, +# which leaves the profile alone. +# +# The module reads the homes from the full evaluation of the darwin +# configuration, so in the childless evaluation it defines nothing. +{ closure-lib, ... }: +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + + # True for an entry this machine activates. `entry.path` is the path + # from this darwin configuration to the configuration of the entry, a + # list of `{ type, name }` segments. + isActivatedHere = + entry: + let + above = lib.lists.init entry.path; + in + entry.manifest.type == "home-manager" + && !(builtins.any (segment: segment.type == "nixos" || segment.type == "darwin") above) + && entry.manifest.system == evalManifest.system; + + homes = builtins.map (entry: { + # The name the home is published under, for messages. + name = entry.manifest.name; + inherit (entry.value.config.home) username; + inherit (entry.value) activationPackage; + }) (builtins.filter isActivatedHere config.caisson.exports.configurations); + + withoutAccount = builtins.filter (home: !(config.users.users ? ${home.username})) homes; + + usernames = builtins.map (home: home.username) homes; + repeated = lib.lists.unique ( + builtins.filter (username: lib.lists.count (other: other == username) usernames > 1) usernames + ); + + # The step for one home: the step home-manager's nix-darwin module + # writes, with `DRY_RUN` passed on when the machine is activated dry. + stepOf = home: '' + echo Activating home-manager configuration for ${home.username} >&2 + hmDryRunArgs=() + hmParentArgs="$(ps -p "$PPID" -ww -o args= || true)" + if [[ -v DRY_RUN || "$hmParentArgs" == *" --dry-run"* ]]; then + hmDryRunArgs=(env DRY_RUN=1) + fi + launchctl asuser "$(id -u ${home.username})" sudo -u ${home.username} --set-home "''${hmDryRunArgs[@]}" ${pkgs.writeShellScript "activation-${home.username}" '' + exec ${home.activationPackage}/activate >&2 + ''} + ''; +in +{ + # The module reads `config.caisson.exports.configurations`. The core + # module declares that option. + imports = [ closure-lib.caisson.modules.darwin.core ]; + + config = lib.mkIf (!evalManifest.childless && homes != [ ]) { + assertions = [ + { + assertion = withoutAccount == [ ]; + message = '' + caisson: this darwin configuration has a home whose user has no account in `users.users`. + ${lib.concatMapStringsSep "\n" ( + home: " the home `${home.name}` is for the user `${home.username}`" + ) withoutAccount} + Declare the account in `users.users`, or check `home.username` of the home. + ''; + } + { + assertion = repeated == [ ]; + message = "caisson: this darwin configuration activates more than one home for ${lib.concatStringsSep ", " repeated}."; + } + ]; + + system.activationScripts.postActivation.text = lib.concatMapStringsSep "\n" stepOf homes; + }; +} diff --git a/modules/hjem/darwin-parent/default.nix b/modules/hjem/darwin-parent/default.nix new file mode 100644 index 00000000..f4306004 --- /dev/null +++ b/modules/hjem/darwin-parent/default.nix @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: MIT +# +# An hjem module for an hjem configuration declared inside a darwin +# configuration. It sets the home directory of the user from the +# account the machine declares, as hjem's nix-darwin module does for +# the users it embeds, and refuses a user the machine has no account +# for. +# +# caisson does not use hjem's nix-darwin module. A darwin configuration +# adds this module to the modules that the hjem configurations declared +# inside it import by default; caisson's `default` darwin module does +# that, through `caisson.forChildren`. An hjem configuration that passes +# `moduleImports` selects its modules itself, and gets this module only +# if it lists it. +# +# The module reads the machine through the manifest of the hjem +# configuration, so it can only be imported by an hjem configuration +# that has a darwin configuration above it. +{ ... }: +# `lib` is the library of the hjem configuration, and `config` is its +# configuration. +{ lib, config, ... }: +let + machine = lib.caisson.evalManifest.nearest.darwin.value.config; + account = machine.users.users.${config.user} or null; +in +{ + config = lib.mkMerge [ + { + assertions = [ + { + assertion = config.enable -> account != null; + message = "The hjem user `${config.user}` has no account in `users.users` of the darwin configuration it is declared inside."; + } + ]; + } + (lib.mkIf (account != null) { + directory = lib.mkDefault account.home; + }) + ]; +} diff --git a/modules/homeManager/darwin-parent/default.nix b/modules/homeManager/darwin-parent/default.nix new file mode 100644 index 00000000..64112a12 --- /dev/null +++ b/modules/homeManager/darwin-parent/default.nix @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: MIT +# +# A home-manager module for a home that is declared inside a darwin +# configuration. It sets the options of the home that come from the +# machine, as home-manager's nix-darwin module does for the homes it +# embeds: the home directory and the user ID from the account the +# machine declares, and the Nix the machine runs. caisson does not use +# that nix-darwin module. +# +# A darwin configuration adds this module to the modules that the +# homes declared inside it import by default. caisson's `default` +# darwin module does that, through `caisson.forChildren`. A home that +# passes `moduleImports` selects its modules itself, and gets this +# module only if it lists it. +# +# The module reads the machine through the manifest of the home, so it +# can only be imported by a home that has a darwin configuration above +# it. +{ ... }: +# `lib` is the library of the home, and `config` is the configuration +# of the home. +{ lib, config, ... }: +let + # The configuration of the darwin configuration the home is declared + # inside, in its childless evaluation, which leaves out the + # configurations declared inside it. + machine = lib.caisson.evalManifest.nearest.darwin.value.config; + + # The account of the user of the home, when the machine declares one. + account = machine.users.users.${config.home.username} or null; + + # nix-darwin can declare `uid` without a value, so it is read with + # `tryEval`, as home-manager's nix-darwin module reads it. + uid = if account == null then null else builtins.tryEval account.uid; +in +{ + config = lib.mkMerge [ + { + # home-manager reads this option to tell that the home is + # activated by a machine. `externalPackageInstall` stays false, + # so the activation of the home installs the packages of the home. + submoduleSupport.enable = true; + + nix.enable = machine.nix.enable; + nix.package = lib.mkIf machine.nix.enable machine.nix.package; + } + + (lib.mkIf (account != null) { + home.homeDirectory = account.home; + home.uid = lib.mkIf (uid.success && uid.value != null) uid.value; + }) + + # home-manager leaves its command-line program out of a home that + # has `submoduleSupport.enable` set. The program is how the user + # activates the home by hand, with `home-manager switch`, so it is + # put back. + (lib.mkIf config.programs.home-manager.enable { + home.packages = [ config.programs.home-manager.package ]; + }) + ]; +} diff --git a/tests/darwin-system/default.nix b/tests/darwin-system/default.nix index 88bc32ac..56372d7a 100644 --- a/tests/darwin-system/default.nix +++ b/tests/darwin-system/default.nix @@ -6,9 +6,11 @@ # tree itself. # # The composition is at aarch64-darwin, with a package config whose set -# there is nixpkgs at aarch64-darwin, and with nixpkgs and nix-darwin -# declared as its ecosystems. A structural top declares the machine -# `probe` from `machine`. Evaluating it needs no Mac: the package set +# there is nixpkgs at aarch64-darwin, and with nixpkgs, nix-darwin, +# home-manager and hjem declared as its ecosystems. A structural top declares the machine +# `probe` from `machine`, and the machine `family`, which is `machine` +# with a home and an hjem configuration for the user `probe` declared +# inside it. Evaluating them needs no Mac: the package set # and the derivations are evaluated for aarch64-darwin wherever the # evaluation runs. Building the system needs a darwin builder. { inputs }: @@ -24,7 +26,12 @@ let systems = [ system ]; projects.caisson = inputs.caisson; defaultEcosystemSrc = { - inherit (inputs) nixpkgs nix-darwin; + inherit (inputs) + nixpkgs + nix-darwin + home-manager + hjem + ; }; pkgSets = _lib: { default = @@ -62,6 +69,16 @@ let }; }; + # A home and an hjem configuration for the user `probe`. + home = { + home.stateVersion = "25.05"; + home.file.".probe".text = "home"; + }; + hjemUser = { + files.".config/probe/greeting".text = "hjem"; + packages = [ pkgs.hello ]; + }; + top = lib.caisson.structural.mkTopConfiguration { moduleImports = _modules: [ ]; configModule = @@ -70,11 +87,28 @@ let caisson.darwin.configurations.probe = lib.caisson.darwin.mkConfiguration { configModule = machine; }; + # The same machine, with the home and the hjem configuration of + # `probe` declared inside it. + caisson.darwin.configurations.family = lib.caisson.darwin.mkConfiguration { + configModule = + { lib, ... }: + { + imports = [ machine ]; + caisson.home-manager.configurations.probe = lib.caisson.home-manager.mkConfiguration { + configModule = home; + }; + caisson.hjem.configurations.probe = lib.caisson.hjem.mkConfiguration { + configModule = hjemUser; + }; + }; + }; }; }; in { inherit + hjemUser + home lib machine pkgs @@ -84,4 +118,8 @@ in # The machine as darwin-rebuild reads it, and the system it builds. configuration = top.darwinConfigurations.probe; toplevel = top.darwinConfigurations.probe.system; + # The machine with the home and the hjem configuration, and the + # manifests of the two inside it. + family = top.darwinConfigurations.family; + familyManifest = top.caisson.manifest.children.system.${system}.children.darwin.family; } diff --git a/tests/pinned-world/default.nix b/tests/pinned-world/default.nix index d0dab88f..b1d6d6b9 100644 --- a/tests/pinned-world/default.nix +++ b/tests/pinned-world/default.nix @@ -1403,11 +1403,60 @@ let ]; }; in - builtins.attrNames probe.top.darwinConfigurations == [ "probe" ] + builtins.attrNames probe.top.darwinConfigurations == [ + "family" + "probe" + ] && probe.toplevel.drvPath == upstream.system.drvPath && probe.configuration.config.nixpkgs.hostPlatform.system == "aarch64-darwin" && probe.configuration.pkgs.hello.drvPath == probe.pkgs.hello.drvPath; + # A home and an hjem configuration inside a darwin machine take the + # home directory and the user ID from the account the machine + # declares, and the machine activates both. Its `postActivation` + # step runs the activation of the home as the user, as + # home-manager's nix-darwin module does, and it installs hjem's + # launchd agent in `/Library/LaunchAgents`, which applies a manifest + # equal to the one hjem's nix-darwin module makes from the same user + # module. The home is published as `probe@family`. + darwinActivatesItsHomes = + let + probe = import ../darwin-system { inherit inputs; }; + config = probe.family.config; + homeManifest = probe.familyManifest.children.system.${probe.system}.children.home-manager.probe; + hjemManifest = probe.familyManifest.children.system.${probe.system}.children.hjem.probe; + upstream = inputs.nix-darwin.lib.darwinSystem { + inherit (probe) pkgs; + modules = [ + { nixpkgs.hostPlatform = probe.system; } + inputs.hjem.darwinModules.default + probe.machine + { hjem.users.probe = probe.hjemUser; } + ]; + }; + hjemLib = import "${inputs.hjem}/lib.nix" { + inherit (probe.pkgs) lib; + inherit (probe) pkgs; + }; + upstreamManifest = probe.lib.caisson.hjem.manifestOf hjemLib upstream.config.hjem.users.probe; + postActivation = config.system.activationScripts.postActivation.text; + hasInfix = probe.pkgs.lib.strings.hasInfix; + in + homeManifest.value.config.home.homeDirectory == "/Users/probe" + && homeManifest.value.config.home.uid == 501 + && hasInfix "-activation-probe" postActivation + && hasInfix "launchctl asuser" postActivation + && hjemManifest.value.config.directory == "/Users/probe" + && builtins.toJSON hjemManifest.outputs.manifest == builtins.toJSON upstreamManifest + && config.launchd.agents.hjem-activate.serviceConfig.Label == "org.hjem.activate" + && config.launchd.user.agents == { } + && hasInfix "gui/\${uid}/org.hjem.activate" postActivation + && builtins.elem "hello" ( + builtins.map (package: package.pname or "") config.users.users.probe.packages + ) + && builtins.attrNames probe.top.homeConfigurations == [ "probe@family" ] + && builtins.attrNames probe.top.hjemConfigurations == [ "probe" ]; + # An hjem configuration that is a top gives what hjem's standalone # CLI reads from a file: the version 3 manifest of the user's files, # with targets in the user's home directory, and the packages. The diff --git a/tests/unit/lib-overlays.nix b/tests/unit/lib-overlays.nix index f5eef058..f54bf934 100644 --- a/tests/unit/lib-overlays.nix +++ b/tests/unit/lib-overlays.nix @@ -761,6 +761,54 @@ in declared = [ "nixos" ]; }; }; + + # The machine of a home is the nearest NixOS or darwin configuration + # above it: of the nearest ancestor of each integration, the one with + # the most ancestors. + "test: nearestOf finds the nearest ancestor of the given integrations" = { + expr = + let + outer = { + name = "outer"; + ancestors = [ "lib" ]; + }; + inner = { + name = "inner"; + ancestors = [ + "lib" + "outer" + ]; + }; + nearestOf = lib.caisson.integrations.nearestOf [ + "nixos" + "darwin" + ]; + in + { + nearer = + (nearestOf { + nearest = { + nixos = outer; + darwin = inner; + }; + }).name; + nearerFirst = + (nearestOf { + nearest = { + nixos = inner; + darwin = outer; + }; + }).name; + one = (nearestOf { nearest.darwin = outer; }).name; + other = nearestOf { nearest.structural = inner; }; + }; + expected = { + nearer = "inner"; + nearerFirst = "inner"; + one = "outer"; + other = null; + }; + }; }; mkLib = {