diff --git a/lib-overlays/darwin/default.nix b/lib-overlays/darwin/default.nix index bcaff53f..173985be 100644 --- a/lib-overlays/darwin/default.nix +++ b/lib-overlays/darwin/default.nix @@ -164,6 +164,10 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # A darwin configuration is activated on a machine; the + # configurations with an activation declared inside it check + # its marker. + activation = final.caisson.integrations.machineActivation; evaluate = evaluate args; }; diff --git a/lib-overlays/hjem/default.nix b/lib-overlays/hjem/default.nix index 14ab0b94..d094644f 100644 --- a/lib-overlays/hjem/default.nix +++ b/lib-overlays/hjem/default.nix @@ -292,9 +292,25 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # A hjem configuration is activated for its user. Its + # identity is its manifest file; its marker is a file in the + # directory of the user, which the hjem module + # `activation-marker` adds to the files of the configuration. + # The configurations with an activation declared inside it + # check the marker. + activation = { + identity = manifest: manifest.outputs.manifestFile.outPath; + marker = + manifest: + "${manifest.value.config.directory}/${markerDirectory}/${final.caisson.integrations.markerFileName manifest}"; + }; evaluate = evaluate args; }; + # The directory, relative to the directory of the user, that holds + # the marker of a hjem configuration. + markerDirectory = ".local/state/caisson/markers"; + integration = final.caisson.integrations.mkIntegration { name = "hjem"; class = "hjem"; diff --git a/lib-overlays/home-manager/default.nix b/lib-overlays/home-manager/default.nix index a8f62a9e..b1387f3d 100644 --- a/lib-overlays/home-manager/default.nix +++ b/lib-overlays/home-manager/default.nix @@ -516,6 +516,17 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # A home is activated for its user. Its identity is its + # activation package; its marker is a file in the state + # directory of the user, which the home-manager module + # `activation-marker` writes. The configurations with an + # activation declared inside the home check it. + activation = { + identity = manifest: manifest.outputs.activationPackage.outPath; + marker = + manifest: + "\${XDG_STATE_HOME:-$HOME/.local/state}/caisson/markers/${selection.markerFileName manifest}"; + }; evaluate = evaluation variant args; }; diff --git a/lib-overlays/integrations/default.nix b/lib-overlays/integrations/default.nix index ad8543ff..7c2b50c8 100644 --- a/lib-overlays/integrations/default.nix +++ b/lib-overlays/integrations/default.nix @@ -265,19 +265,37 @@ # one output. It is recorded on the manifest, and applied where # the configuration is passed up (`entriesOf`). Null for a # configuration that is not published under a name. + # + # `activation` is given by an integration whose configurations + # are activated on a machine or for a user: NixOS, darwin and + # system-manager machines, homes, hjem configurations. It holds + # two functions of a manifest of the configuration. `identity` + # returns the store path that names what the activation applies, + # computed from the childless evaluation, or null where there is + # none. `standsAlone`, when true, says the configuration is + # applied on its own, as a machine is on its own host, so that it + # checks no configuration above it. `marker` returns the path of the file the activation + # writes that identity into, as shell text, which may name + # variables such as `$HOME`. It is recorded on the manifest. A + # configuration with an activation declared beneath one checks + # the marker of the nearest such configuration above it when it + # is activated (`driftCheckOf`). Null for a configuration that is + # not activated. mkModuleConfiguration = { type, perSystem ? false, defaultPkgs ? null, exportsTo ? null, + activation ? null, evaluate, }: final.caisson-core.mkConfiguration { inherit type perSystem; record = (if defaultPkgs == null then { } else { inherit defaultPkgs; }) - // (if exportsTo == null then { } else { inherit exportsTo; }); + // (if exportsTo == null then { } else { inherit exportsTo; }) + // (if activation == null then { } else { inherit activation; }); evaluate = view: let @@ -597,6 +615,144 @@ found ) null types; + # The nearest configuration above `manifest` that has an + # activation (`mkModuleConfiguration`'s `activation`), as its + # childless manifest, or null when there is none. A home inside a + # NixOS machine finds the machine; an nvf configuration inside + # that home finds the home. + activatingAncestorOf = + manifest: + let + found = builtins.filter (ancestor: (ancestor.activation or null) != null) ( + manifest.ancestors or [ ] + ); + in + if found == [ ] then null else last found; + + # The `activation` of a machine: a NixOS, darwin or system-manager + # configuration. Its identity is its system, `toplevel`, null for + # a configuration that builds none, as the nixos-minimal + # integration can produce. Its marker is a file under `/etc`, + # which the module `activation-marker` of its class writes. + # + # A machine is activated on its own host. A machine declared + # inside another, such as an image, is applied on a different host + # than the machine above, and the marker under `/etc` that it + # finds there is its own, so it checks nothing above it: + # `standsAlone`. + machineActivation = { + standsAlone = true; + identity = + manifest: if (manifest.outputs or { }) ? toplevel then manifest.outputs.toplevel.outPath else null; + marker = _manifest: "/etc/caisson/marker.json"; + }; + + # The file name of the marker of a configuration activated for a + # user: the integration and the name the configuration is declared + # under, with the characters the shell would read inside double + # quotes, and the path separator, replaced. + markerFileName = + manifest: + "${manifest.type}-${ + builtins.replaceStrings [ "/" "\"" "$" "`" "\\" ] [ "_" "_" "_" "_" "_" ] ( + manifest.name or "default" + ) + }.json"; + + # The digest a marker holds for an identity: the SHA-256 of its + # store path. Computing the store path evaluates the childless + # evaluation and builds nothing, and the digest refers to no store + # path, so a marker that holds it depends on nothing. The digest + # is compared, never read as a path. + digestOf = identity: builtins.hashString "sha256" identity; + + # What the activation of the configuration of `manifest` writes + # into its marker: the digest of the identity of its childless + # evaluation (`digestOf`). `path` is the shell text of the path of + # the marker. Null when the configuration has no activation or no + # identity. + markerOf = + manifest: + let + activation = manifest.activation or null; + childless = manifest.childlessManifest or manifest; + identity = if activation == null then null else activation.identity childless; + in + if identity == null then + null + else + { + path = activation.marker childless; + identity = digestOf identity; + text = builtins.toJSON { identity = digestOf identity; }; + }; + + # True when `entry`, an entry of `caisson.exports.configurations` + # of the configuration of `manifest`, checks the marker of that + # configuration when it is activated: the entry has an activation, + # and the configuration is the nearest one above it that has an + # activation. + checksMarkerOf = + manifest: entry: + let + ancestor = activatingAncestorOf entry.manifest; + in + (entry.manifest.activation or null) != null + && !(entry.manifest.activation.standsAlone or false) + && ancestor != null + && builtins.length (ancestor.ancestors or [ ]) == builtins.length (manifest.ancestors or [ ]); + + # The shell text with which the activation of the configuration of + # `manifest` checks for drift: it compares the marker of the + # nearest configuration above with an activation against the + # identity of that configuration as this configuration was built + # for it, and prints a warning to standard error when the marker is + # missing or names another identity. It does not stop the + # activation. When that configuration activates this one, its + # marker already belongs to the generation being activated, and + # the check prints nothing. Empty when there is no such + # configuration, or it has no identity. `jq` is the path of a jq + # executable, and `optionText` names the option that turns the + # warning off, for the message. + driftCheckOf = + { jq, optionText }: + manifest: + let + ancestor = + if (manifest.activation.standsAlone or false) then null else activatingAncestorOf manifest; + expected = if ancestor == null then null else ancestor.activation.identity ancestor; + marker = ancestor.activation.marker ancestor; + above = "the ${ancestor.type} configuration `${ancestor.name or "(unnamed)"}`"; + self = "the ${manifest.type} configuration `${manifest.name or "(unnamed)"}`"; + # The digest of the identity, quoted for the shell. A digest is + # hexadecimal, so quoting it whole is enough. + escaped = "'${digestOf expected}'"; + in + if expected == null then + "" + else + '' + _caissonMarker="${marker}" + if [ ! -f "$_caissonMarker" ]; then + echo "WARNING: $_caissonMarker does not exist." >&2 + echo " ${self} is declared inside ${above}," >&2 + echo " and the activation of ${above} has not recorded what it applied." >&2 + echo " Activate ${above} as this configuration was built with it," >&2 + echo " or set ${optionText} to false in ${self}." >&2 + else + _caissonApplied="$(${jq} -r '.identity // empty' < "$_caissonMarker")" + if [ "$_caissonApplied" != ${escaped} ]; then + echo "WARNING: ${self} was built for a version of ${above} that differs from the version that is applied." >&2 + echo " digest of the version applied (configurations declared inside it left out):" >&2 + echo " ''${_caissonApplied:-unknown}" >&2 + echo " digest of the version built for (configurations declared inside it left out):" >&2 + echo " "${escaped} >&2 + echo " Activate ${above} as this configuration was built with it," >&2 + echo " or set ${optionText} to false in ${self}." >&2 + fi + fi + ''; + isManifest = value: builtins.isAttrs value && (value._type or null) == "caisson-manifest"; # How a configuration is published: the exports its manifest @@ -895,6 +1051,12 @@ integrationsIn integrationsDeclaringIn nearestOf + activatingAncestorOf + machineActivation + markerFileName + markerOf + checksMarkerOf + driftCheckOf mkEvaluation publish topValue diff --git a/lib-overlays/nixos/default.nix b/lib-overlays/nixos/default.nix index 42e02d6d..591fc813 100644 --- a/lib-overlays/nixos/default.nix +++ b/lib-overlays/nixos/default.nix @@ -114,6 +114,10 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # A NixOS configuration is activated on a machine; the + # configurations with an activation declared inside it check + # its marker. + activation = final.caisson.integrations.machineActivation; evaluate = evaluate variant args; }; diff --git a/lib-overlays/system-manager/default.nix b/lib-overlays/system-manager/default.nix index 493ad60a..2a6f1d93 100644 --- a/lib-overlays/system-manager/default.nix +++ b/lib-overlays/system-manager/default.nix @@ -299,6 +299,10 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # A system-manager configuration is activated on a machine; + # the configurations with an activation declared inside it + # check its marker. + activation = final.caisson.integrations.machineActivation; evaluate = evaluate args; }; diff --git a/modules/darwin/activation-marker b/modules/darwin/activation-marker new file mode 120000 index 00000000..d4a211a0 --- /dev/null +++ b/modules/darwin/activation-marker @@ -0,0 +1 @@ +../nixos/activation-marker \ No newline at end of file diff --git a/modules/darwin/default/default.nix b/modules/darwin/default/default.nix index 2982630a..e7baecdc 100644 --- a/modules/darwin/default/default.nix +++ b/modules/darwin/default/default.nix @@ -28,6 +28,7 @@ (mkModule ./caisson) closure-lib.caisson.modules.darwin.home-manager-activation closure-lib.caisson.modules.darwin.hjem-activation + closure-lib.caisson.modules.darwin.activation-marker ] else [ ]; diff --git a/modules/hjem/activation-marker/default.nix b/modules/hjem/activation-marker/default.nix new file mode 100644 index 00000000..e263cfaf --- /dev/null +++ b/modules/hjem/activation-marker/default.nix @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: MIT +# +# A hjem module with which a hjem configuration records what it +# applied, so that the configurations declared inside it can detect +# drift. It is the counterpart, for a hjem configuration, of the +# machine module `activation-marker`: see there for why. +# +# The module adds a file to the files of the configuration, +# `.local/state/caisson/markers/hjem-.json` in the directory of +# the user, holding the identity of the childless evaluation of the +# configuration: its manifest file without the configurations declared +# inside it. hjem links the file into place when it applies the +# configuration. +# +# caisson's `default` hjem module imports this module. The file is +# added only when the configuration has a configuration declared inside +# it that checks it, and not in the childless evaluation. +{ ... }: +{ + config, + lib, + ... +}: +let + inherit (lib.caisson) evalManifest; + selection = lib.caisson.integrations; + + checked = builtins.any (selection.checksMarkerOf evalManifest) config.caisson.exports.configurations; + + marker = selection.markerOf evalManifest; +in +{ + config = lib.mkIf (!evalManifest.childless && checked && marker != null) { + files.".local/state/caisson/markers/${selection.markerFileName evalManifest}".text = marker.text; + }; +} diff --git a/modules/hjem/default/default.nix b/modules/hjem/default/default.nix new file mode 100644 index 00000000..f97a6379 --- /dev/null +++ b/modules/hjem/default/default.nix @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: MIT +# +# The `default` hjem module caisson registers. A hjem configuration +# imports every registered module named `default` unless it passes +# `moduleImports`, so this is what it gets from caisson when it does not +# select its modules itself: `activation-marker`, which records what the +# hjem configuration applied, for the configurations declared inside it +# to check. +{ closure-lib, ... }: +{ ... }: +{ + imports = [ + closure-lib.caisson.modules.hjem.activation-marker + ]; +} diff --git a/modules/homeManager/activation-drift/default.nix b/modules/homeManager/activation-drift/default.nix new file mode 100644 index 00000000..dc0ee1a4 --- /dev/null +++ b/modules/homeManager/activation-drift/default.nix @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MIT +# +# A home-manager module with which a home declared inside another +# configuration that has an activation, such as a NixOS, darwin or +# system-manager machine, detects drift when it is activated. +# +# The user can activate such a home by hand, with `home-manager switch`, +# built from the source the user has checked out. That source can +# describe a different version of the configuration above than the one +# that is applied. The configuration above records what it applied in a +# marker (the module `activation-marker` of its class). A step of the +# activation of the home compares that marker with the identity of the +# configuration above as the home was built for it, and prints a +# warning when they differ or when the marker is missing. The step does +# not stop the activation. When the configuration above activates the +# home, its marker belongs to the generation being activated, and the +# step prints nothing. +# +# The check is `lib.caisson.integrations.driftCheckOf`, the same check +# every configuration with an activation runs against the nearest +# configuration above it that has one. A home with no such +# configuration above it has nothing to check, and the module then +# defines no step. +# +# caisson's `default` home-manager module imports this module. +{ ... }: +{ + config, + lib, + pkgs, + ... +}: +let + check = lib.caisson.integrations.driftCheckOf { + jq = lib.getExe' pkgs.jq "jq"; + optionText = "`caisson.drift.warn`"; + } lib.caisson.evalManifest; +in +{ + options.caisson.drift.warn = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether the activation of this home prints a warning when the home + was built for a version of the configuration it is declared inside + that differs from the version that is applied. + + The configuration above, such as the machine, records what it + applied in a marker file. The activation of this home compares the + marker with the configuration above as it is in the source this + home was built from. The comparison leaves out the configurations + declared inside the configuration above, so a change to a home + does not cause a warning. + ''; + }; + + config = lib.mkIf (config.caisson.drift.warn && check != "") { + home.activation.caissonDrift = lib.hm.dag.entryBefore [ "writeBoundary" ] check; + }; +} diff --git a/modules/homeManager/activation-marker/default.nix b/modules/homeManager/activation-marker/default.nix new file mode 100644 index 00000000..8654916e --- /dev/null +++ b/modules/homeManager/activation-marker/default.nix @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: MIT +# +# A home-manager module with which a home records what it applied, so +# that the configurations declared inside it can detect drift. It is the +# counterpart, for a home, of the machine module `activation-marker`: +# see there for why. +# +# A step of the activation of the home writes the identity of the +# childless evaluation of the home, its activation package without the +# configurations declared inside it, into a file in the state directory +# of the user, `$XDG_STATE_HOME/caisson/markers/home-manager-.json`. +# A configuration declared inside the home, such as an nvf +# configuration, compares that file with the identity it was built for +# when it is activated by hand. The step runs after the files of the +# home are linked, so a configuration the home activates after it reads +# the identity of the generation being activated. +# +# caisson's `default` home-manager module imports this module. The file +# is written only when the home has a configuration declared inside it +# that checks it, and not in the childless evaluation. +{ ... }: +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + selection = lib.caisson.integrations; + + checked = builtins.any (selection.checksMarkerOf evalManifest) config.caisson.exports.configurations; + + marker = selection.markerOf evalManifest; +in +{ + config = lib.mkIf (!evalManifest.childless && checked && marker != null) { + home.activation.caissonActivationMarker = lib.hm.dag.entryAfter [ "linkGeneration" ] '' + run ${lib.getExe' pkgs.coreutils "install"} -D -m 0644 ${pkgs.writeText "caisson-marker.json" marker.text} "${marker.path}" + ''; + }; +} diff --git a/modules/homeManager/default/default.nix b/modules/homeManager/default/default.nix new file mode 100644 index 00000000..02a02e7e --- /dev/null +++ b/modules/homeManager/default/default.nix @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MIT +# +# The `default` home-manager module caisson registers. A home imports +# every registered module named `default` unless it passes +# `moduleImports`, so this is what a home gets from caisson when it does +# not select its modules itself: +# +# - `activation-drift`, with which the activation of the home warns when +# the home was built for a different version of the configuration it +# is declared inside, such as its machine; +# - `activation-marker`, with which the activation of the home records +# what it applied, for the configurations declared inside the home to +# check in turn. +{ closure-lib, ... }: +{ ... }: +{ + imports = [ + closure-lib.caisson.modules.homeManager.activation-drift + closure-lib.caisson.modules.homeManager.activation-marker + ]; +} diff --git a/modules/homeManager/nixos-source-marker/default.nix b/modules/homeManager/nixos-source-marker/default.nix deleted file mode 100644 index ffcd3370..00000000 --- a/modules/homeManager/nixos-source-marker/default.nix +++ /dev/null @@ -1,135 +0,0 @@ -# SPDX-License-Identifier: MIT -# -# A home-manager module for a home that is declared inside a NixOS -# configuration. The module adds a step to the activation of the home. -# The step prints a warning if the home was built for a NixOS -# configuration that differs from the machine that is running. -# -# Why -# -# The machine activates the homes declared inside its NixOS -# configuration. The user can also activate such a home by hand, with -# `home-manager switch`. `home-manager switch` builds the home from -# the source the user has checked out. -# -# That source can describe a different machine than the machine that -# is running. This happens when the user changed the NixOS -# configuration and has not switched the machine yet. It also happens -# when the checkout is older than the machine. -# -# A home reads settings of its machine. A home built from such a -# source has read the settings of a machine that is not the machine -# the home is activated on. -# -# How -# -# The machine has a file, `/etc/caisson-home-manager/source.json`, -# that names the NixOS configuration the machine is running. The NixOS -# module `home-manager-source-marker` writes the file. The field -# `baseSystemOutPath` of the file is a store path. The store path is -# the system that the childless evaluation of the NixOS configuration -# builds. The childless evaluation is the evaluation that leaves out -# the homes. -# -# This module computes the same store path from the source the home is -# built from. The module reads the childless evaluation of the NixOS -# configuration through the manifest of the home. -# -# The step in the activation compares the store path this module -# computed with the store path in the file. The step prints a warning -# when the two store paths differ. The step also prints a warning when -# the file does not exist. The step does not stop the activation. -# -# When the machine activates the home, the file on the machine already -# belongs to the generation that is being activated. The two store -# paths are then equal, and the step prints nothing. -# -# Cost -# -# Building the activation package of the home evaluates the system of -# the childless evaluation of the NixOS configuration. -# -# Which homes import this module -# -# caisson's `default` NixOS module gives this module to the homes -# declared inside the NixOS configuration, through -# `caisson.forChildren`. Such a home imports this module by default. -# A home that passes `moduleImports` selects its modules itself. That -# home imports this module only if the home lists it. -# -# The module reads the NixOS configuration through the manifest of the -# home. A home with no NixOS configuration above it cannot import this -# module. -{ ... }: -# `lib` is the library of the home, and `config` is the configuration -# of the home. -{ - lib, - config, - pkgs, - ... -}: -let - # The manifest of the childless evaluation of the NixOS configuration - # the home is declared inside. - machine = lib.caisson.evalManifest.nearest.nixos; - - # The store path of the system that the childless evaluation builds. - # - # The store path is kept as a plain string. A string that refers to - # the system would make the home depend on the system. Building the - # home would then build the system. - # - # The value is null for a NixOS configuration that builds no system. - # The nixos-minimal integration can produce such a NixOS - # configuration. - expected = - if machine.outputs ? toplevel then - builtins.unsafeDiscardStringContext machine.outputs.toplevel.outPath - else - null; - - marker = "/etc/caisson-home-manager/source.json"; - jq = lib.getExe' pkgs.jq "jq"; -in -{ - options.caisson.home-manager.machineDrift.warn = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Whether the activation of this home prints a warning when the - home was built for a NixOS configuration that differs from the - machine that is running. - - The file `/etc/caisson-home-manager/source.json` on the machine - names the NixOS configuration the machine is running. The - activation of this home compares the file with the NixOS - configuration in the source this home was built from. - - The comparison leaves the homes out. A change to a home does not - cause a warning. - ''; - }; - - config = lib.mkIf (config.caisson.home-manager.machineDrift.warn && expected != null) { - home.activation.caissonMachineDrift = lib.hm.dag.entryBefore [ "writeBoundary" ] '' - if [ ! -f ${marker} ]; then - echo "WARNING: ${marker} does not exist." >&2 - echo " This home is declared inside the NixOS configuration \`${machine.name}\`," >&2 - echo " and the machine that is running does not record which NixOS configuration it runs." >&2 - echo " Switch the machine to the NixOS configuration this home was built with." >&2 - else - _caissonRunning="$(${jq} -r '.baseSystemOutPath // empty' < ${marker})" - if [ "$_caissonRunning" != ${lib.escapeShellArg expected} ]; then - echo "WARNING: this home was built for a NixOS configuration that differs from the machine that is running." >&2 - echo " the machine runs (homes left out):" >&2 - echo " ''${_caissonRunning:-unknown}" >&2 - echo " this home was built for (homes left out):" >&2 - echo " ${expected}" >&2 - echo " Switch the machine to the NixOS configuration this home was built with," >&2 - echo " or set \`caisson.home-manager.machineDrift.warn = false\` in the home." >&2 - fi - fi - ''; - }; -} diff --git a/modules/nixos/activation-marker/default.nix b/modules/nixos/activation-marker/default.nix new file mode 100644 index 00000000..133962a8 --- /dev/null +++ b/modules/nixos/activation-marker/default.nix @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: MIT +# +# A module with which a machine records what it applied, so that the +# configurations declared inside it can detect drift. +# +# Why +# +# A machine activates the configurations declared inside it: homes, +# hjem configurations, and the others that have an activation. A user +# can also activate such a configuration by hand, built from the source +# the user has checked out, for example with `home-manager switch`. +# That source can describe a different machine than the one that is +# applied: the user changed the machine and has not switched it yet, or +# the checkout is older than the machine. A configuration declared +# inside a machine reads settings of the machine, so it was then built +# for a machine that is not the one it is activated on. +# +# What the file holds +# +# The module writes `/etc/caisson/marker.json`, with one field, +# `identity`: the SHA-256 digest of the store path of the system that +# the childless evaluation of this machine builds, which is the +# evaluation without the configurations declared inside it. Computing +# the store path evaluates that system and builds nothing, and the +# digest names no store path, so the file depends on nothing. Changing +# one of the configurations declared inside leaves the identity the +# same; changing anything else about the machine changes it. +# +# A configuration declared inside the machine reads the same childless +# evaluation through its own manifest, so it computes the same identity +# from the same source. Its activation compares the two +# (`lib.caisson.integrations.driftCheckOf`), and warns when they differ. +# When the machine activates it, the file already belongs to the +# generation being activated, and the two are equal. +# +# The module is registered for the `nixos`, `darwin` and +# `systemManager` classes, under the same name, and the `default` +# module of each imports it. The file is written only when the machine +# has a configuration declared inside it that checks it, because +# computing the identity evaluates the machine a second time. It is not +# written in the childless evaluation, and not in an evaluation without +# `environment.etc`, which the nixos-minimal integration can produce. +{ closure-lib, ... }: +{ + config, + lib, + options, + ... +}: +let + inherit (lib.caisson) evalManifest; + selection = lib.caisson.integrations; + + hasEtc = options ? environment && options.environment ? etc; + + # The core module of the class of this evaluation, which declares + # `caisson.exports`. + coreModule = + { + darwin = closure-lib.caisson.modules.darwin.core; + system-manager = closure-lib.caisson.modules.systemManager.core; + } + .${evalManifest.type or "nixos"} or closure-lib.caisson.modules.nixos.core; + + checked = builtins.any (selection.checksMarkerOf evalManifest) config.caisson.exports.configurations; + + marker = selection.markerOf evalManifest; +in +{ + imports = [ coreModule ]; + + # `optionalAttrs` and not `mkIf` decides whether the option is defined + # at all: a definition under `mkIf false` is still a definition, and + # NixOS refuses a definition of an option that no module declares. + config = lib.optionalAttrs hasEtc ( + lib.mkIf (!evalManifest.childless && checked && marker != null) { + environment.etc."caisson/marker.json".text = marker.text; + } + ); +} diff --git a/modules/nixos/default/caisson/forChildren.nix b/modules/nixos/default/caisson/forChildren.nix index 2b5e74b1..8d745d1a 100644 --- a/modules/nixos/default/caisson/forChildren.nix +++ b/modules/nixos/default/caisson/forChildren.nix @@ -4,16 +4,10 @@ # # A configuration gives things to the configurations declared inside # it through the options under `caisson.forChildren`. This file adds -# two home-manager modules, `nixos-parent` and `nixos-source-marker`, -# to the modules that a home declared inside this NixOS configuration -# imports by default, and the hjem module `nixos-parent` to those of a -# hjem configuration declared inside it. -# -# `nixos-source-marker` is the module caisson registers as -# `modules/homeManager/nixos-source-marker`. It adds a step to the -# activation of the home. The step prints a warning if the home was -# built for a NixOS configuration that differs from the machine that -# is running. +# the home-manager module `nixos-parent` to the modules that a home +# declared inside this NixOS configuration imports by default, and the +# hjem module `nixos-parent` to those of a hjem configuration declared +# inside it. # # `nixos-parent` is the module caisson registers as # `modules/homeManager/nixos-parent`. It sets the options of a home @@ -35,7 +29,6 @@ { caisson.forChildren.extraDefaultModuleImports.homeManager = _lib: [ closure-lib.caisson.modules.homeManager.nixos-parent - closure-lib.caisson.modules.homeManager.nixos-source-marker ]; # The hjem module `nixos-parent` sets the home directory of a hjem diff --git a/modules/nixos/default/default.nix b/modules/nixos/default/default.nix index ed0fa4c7..da2aab6f 100644 --- a/modules/nixos/default/default.nix +++ b/modules/nixos/default/default.nix @@ -15,19 +15,19 @@ # machine's account. # # It also holds what a NixOS configuration does for the homes declared -# inside it, in three parts: +# inside it, in two parts: # -# - `./caisson/forChildren.nix` gives each of those homes two -# home-manager modules. The first module sets the options of the -# home that come from the machine. The second module prints a -# warning when the home is activated on a machine that differs from -# the machine the home was built for. +# - `./caisson/forChildren.nix` gives each of those homes the +# home-manager module `nixos-parent`, which sets the options of the +# home that come from the machine. # - The registered module `home-manager-activation` writes the # systemd units that activate those homes on the machine. -# - The registered module `home-manager-source-marker` writes a file -# on the machine. The file names the NixOS configuration the -# machine is running. The warning of the second module compares -# against that file. +# +# The registered module `activation-marker` writes a file on the +# machine that names what the machine applied. Every configuration with +# an activation declared inside the machine, a home among them, compares +# against that file when it is activated by hand, and warns when it was +# built for a different machine. # # 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 @@ -42,7 +42,7 @@ (mkModule ./caisson) closure-lib.caisson.modules.nixos.home-manager-activation closure-lib.caisson.modules.nixos.hjem-activation - closure-lib.caisson.modules.nixos.home-manager-source-marker + closure-lib.caisson.modules.nixos.activation-marker ] else [ ]; diff --git a/modules/nixos/home-manager-source-marker/default.nix b/modules/nixos/home-manager-source-marker/default.nix deleted file mode 100644 index a6f01252..00000000 --- a/modules/nixos/home-manager-source-marker/default.nix +++ /dev/null @@ -1,110 +0,0 @@ -# SPDX-License-Identifier: MIT -# -# A NixOS module for a NixOS configuration that has homes declared -# inside it. The module writes a file on the machine. The file names -# the NixOS configuration that the machine is running. A home reads -# the file when the home is activated. The home prints a warning if -# the home was built for a different NixOS configuration. -# -# Why a home needs this -# -# The machine activates the homes declared inside its NixOS -# configuration. The user can also activate such a home by hand, with -# `home-manager switch`. `home-manager switch` builds the home from -# the source the user has checked out. -# -# That source can describe a different machine than the machine that -# is running. This happens when the user changed the NixOS -# configuration and has not switched the machine yet. It also happens -# when the checkout is older than the machine. -# -# A home reads settings of its machine. A home built from such a -# source has read the settings of a machine that is not the machine -# the home is activated on. -# -# What the file holds -# -# The file is `/etc/caisson-home-manager/source.json`. It has one -# field, `baseSystemOutPath`, which is a store path. The store path is -# the system that the childless evaluation of this NixOS configuration -# builds. -# -# The childless evaluation is the evaluation of the NixOS -# configuration that leaves out the configurations declared inside the -# NixOS configuration. The homes are among those configurations. So -# the store path does not depend on the homes. Changing a home leaves -# the store path the same. Changing anything else about the machine -# changes the store path. -# -# A home declared inside the NixOS configuration can read the same -# childless evaluation through the manifest of the home. The home -# therefore computes the same store path from the same source. The -# home-manager module `nixos-source-marker` compares the store path -# the home computed with the store path in the file. -# -# Cost -# -# To write the file, the module evaluates the system of the childless -# evaluation. That is a second evaluation of the whole NixOS -# configuration. The module writes the file only when at least one -# home is declared inside the NixOS configuration. A machine with no -# homes does not pay for the second evaluation. -# -# Where the module defines nothing -# -# The module defines nothing in the childless evaluation, because the -# childless evaluation has no homes. -# -# The module also defines nothing in a NixOS evaluation that has no -# `environment.etc` option. The nixos-minimal integration can produce -# such an evaluation, and such an evaluation has nowhere to put the -# file. -{ closure-lib, ... }: -{ - config, - lib, - options, - ... -}: -let - inherit (lib.caisson) evalManifest; - - hasEtc = options ? environment && options.environment ? etc; - - # True for a home that belongs to this machine. - # - # `entry.path` is a list of `{ type, name }` segments. The list is - # the path from this NixOS configuration to the configuration of the - # entry. A NixOS configuration on that path is a machine inside this - # machine. A home below such a machine belongs to that machine and - # not to this machine. - isHomeOfThisMachine = - entry: - entry.manifest.type == "home-manager" - && !(builtins.any (segment: segment.type == "nixos") (lib.lists.init entry.path)); - - hasHomes = builtins.any isHomeOfThisMachine config.caisson.exports.configurations; - - # The store path is written as a plain string. A string that refers - # to the system would make the file depend on the system. The - # machine would then have to build the system of the childless - # evaluation, only to write the name of that system into the file. - baseSystemOutPath = builtins.unsafeDiscardStringContext evalManifest.childlessManifest.outputs.toplevel.outPath; -in -{ - # The module reads `config.caisson.exports.configurations`. The core - # module declares that option. - imports = [ closure-lib.caisson.modules.nixos.core ]; - - # `optionalAttrs` and not `mkIf` decides whether the option is - # defined at all. A definition under `mkIf false` is still a - # definition, and NixOS refuses a definition of an option that no - # module declares. - config = lib.optionalAttrs hasEtc ( - lib.mkIf (!evalManifest.childless && hasHomes) { - environment.etc."caisson-home-manager/source.json".text = builtins.toJSON { - inherit baseSystemOutPath; - }; - } - ); -} diff --git a/modules/systemManager/activation-marker b/modules/systemManager/activation-marker new file mode 120000 index 00000000..d4a211a0 --- /dev/null +++ b/modules/systemManager/activation-marker @@ -0,0 +1 @@ +../nixos/activation-marker \ No newline at end of file diff --git a/modules/systemManager/default/default.nix b/modules/systemManager/default/default.nix index 39c8fbaa..4b893502 100644 --- a/modules/systemManager/default/default.nix +++ b/modules/systemManager/default/default.nix @@ -30,6 +30,7 @@ (mkModule ./caisson) closure-lib.caisson.modules.systemManager.home-manager-activation closure-lib.caisson.modules.systemManager.hjem-activation + closure-lib.caisson.modules.systemManager.activation-marker ] else [ ]; diff --git a/tests/pinned-world/default.nix b/tests/pinned-world/default.nix index 20f75abf..d1b3a6b3 100644 --- a/tests/pinned-world/default.nix +++ b/tests/pinned-world/default.nix @@ -464,7 +464,7 @@ let builtins.all evaluates [ "default" "home-manager-activation" - "home-manager-source-marker" + "activation-marker" ]; composesTheCaissonLibrary = @@ -719,36 +719,41 @@ let undeclared = machineWithHomes.homeConfigurations."undeclared@probe"; systemUnit = machine.systemd.services.home-manager-declared; userUnit = machine.systemd.user.services.home-manager-undeclared; - # The comparison is made on plain strings: a string that - # refers to a store path cannot be searched for. - plain = builtins.unsafeDiscardStringContext; + # The script of the unit runs `activate` of the activation + # package of the home: the string context of the script records + # that it refers to the activation package, and the script runs + # an `activate`. runs = unit: home: - pkgs.lib.hasInfix (plain "${home.activationPackage}/activate") ( - plain unit.serviceConfig.ExecStart.text - ); + let + script = unit.serviceConfig.ExecStart.text; + in + builtins.elem home.activationPackage.drvPath (builtins.attrNames (builtins.getContext script)) + && pkgs.lib.hasInfix "/activate" script; in systemUnit.serviceConfig.User == "declared" && systemUnit.unitConfig.RequiresMountsFor == "/home/declared" && runs systemUnit declared + && !(runs systemUnit undeclared) && userUnit.unitConfig.ConditionUser == "undeclared" && runs userUnit undeclared + && !(runs userUnit declared) && !(machine.systemd.services ? home-manager-undeclared) && !(machine.systemd.user.services ? home-manager-declared) && builtins.isString machine.system.build.toplevel.drvPath; - # The same machine writes the file - # `/etc/caisson-home-manager/source.json`. The file holds a store - # path. The store path is the system of the machine with the homes - # left out. + # The same machine writes the file `/etc/caisson/marker.json`. The + # file holds the digest of the system of the machine with the + # configurations declared inside it left out. # # The system that the machine builds has the units of the homes in - # it. So the store path in the file must differ from the system - # that the machine builds. + # it. So the digest in the file is the digest of the childless + # system, and not of the system that the machine builds. # - # Each home computes the same store path. The activation of each - # home has a step that compares that store path with the file. The - # script of the step must therefore contain the store path. + # Each home computes the same digest. The activation of each home + # has a step that compares that digest with the file. The script of + # the step must therefore contain the digest and the path of the + # file. aMachineRecordsWhatItsHomesCompareAgainst = let machine = machineWithHomes.nixosConfigurations.probe.config; @@ -756,20 +761,20 @@ let machineWithHomes.homeConfigurations."declared@probe" machineWithHomes.homeConfigurations."undeclared@probe" ]; - # fromJSON refuses a string that refers to a store path. - record = builtins.fromJSON ( - builtins.unsafeDiscardStringContext machine.environment.etc."caisson-home-manager/source.json".text - ); - base = record.baseSystemOutPath; + record = builtins.fromJSON machine.environment.etc."caisson/marker.json".text; + digest = record.identity; + childless = + machineWithHomes.caisson.manifest.children.system.x86_64-linux.children.nixos.probe.childlessManifest.outputs.toplevel; comparesAgainstIt = home: - pkgs.lib.hasInfix base ( - builtins.unsafeDiscardStringContext home.config.home.activation.caissonMachineDrift.data - ); + let + step = home.config.home.activation.caissonDrift.data; + in + pkgs.lib.hasInfix digest step && pkgs.lib.hasInfix "/etc/caisson/marker.json" step; in - builtins.attrNames record == [ "baseSystemOutPath" ] - && pkgs.lib.hasPrefix builtins.storeDir base - && base != builtins.unsafeDiscardStringContext machine.system.build.toplevel.outPath + builtins.attrNames record == [ "identity" ] + && digest == builtins.hashString "sha256" childless.outPath + && digest != builtins.hashString "sha256" machine.system.build.toplevel.outPath && builtins.all comparesAgainstIt homes; # A NixOS configuration can be evaluated alone, with @@ -2001,6 +2006,10 @@ let names = packages: builtins.map (package: package.name or "") packages; in home.home.homeDirectory == "/home/probe" + # The machine records what it applied, and the home compares + # against it when activated by hand, as inside a NixOS machine. + && box.environment.etc ? "caisson/marker.json" + && pkgs.lib.hasInfix "/etc/caisson/marker.json" home.home.activation.caissonDrift.data && hjem.directory == "/home/probe" && box.systemd.services ? home-manager-probe && box.systemd.services ? "hjem-activate@" diff --git a/tests/unit/lib-overlays.nix b/tests/unit/lib-overlays.nix index bb595812..6aa2bd48 100644 --- a/tests/unit/lib-overlays.nix +++ b/tests/unit/lib-overlays.nix @@ -4008,21 +4008,22 @@ in }; # A NixOS configuration with a home declared inside it writes the - # file `caisson-home-manager/source.json` under `/etc`. The file - # holds a store path. The store path is the system of the - # machine with the homes left out. + # file `caisson/marker.json` under `/etc`. The file holds the digest + # of the store path of the system of the machine with the + # configurations declared inside it left out. # # The stub of NixOS puts the number of system units into the # store path of a system. The machine `laptop` has one home, and # `laptop` declares the account of that home. The system of - # `laptop` therefore has one unit. The store path in the file - # must be the system with no units. + # `laptop` therefore has one unit. The digest in the file must be + # that of the system with no units. # # The machine `bare` has no home, and writes no file. # # The machine `outer` has a machine `image` declared inside it, # and `image` has a home. That home belongs to `image`. So - # `image` writes the file, and `outer` does not. + # `image` writes the file, and `outer` does not: `image` is + # applied on its own host, so it checks no marker of `outer`. "test: a NixOS configuration with homes records the system it runs without them" = { expr = let @@ -4056,7 +4057,7 @@ in }; }; }; - file = "caisson-home-manager/source.json"; + file = "caisson/marker.json"; recordOf = name: let @@ -4073,13 +4074,13 @@ in }; expected = { laptop = { - baseSystemOutPath = "/nix/store/stub-system-with-0-units"; + identity = builtins.hashString "sha256" "/nix/store/stub-system-with-0-units"; }; laptopBuilds = "/nix/store/stub-system-with-1-units"; bare = null; outer = null; image = { - baseSystemOutPath = "/nix/store/stub-system-with-0-units"; + identity = builtins.hashString "sha256" "/nix/store/stub-system-with-0-units"; }; }; };