diff --git a/README.md b/README.md index e9da8d3d..c22104a2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ drift

-

The drift badge reports the daily run of the pinned-world suite against the current upstreams (nixpkgs, home-manager, colmena, terranix, system-manager); red means an upstream moved and broke an expectation caisson holds.

+

The drift badge reports the daily run of the pinned-world suite against the current upstreams (nixpkgs, home-manager, colmena, disko, nix-darwin, hjem, terranix, system-manager); red means an upstream moved and broke an expectation caisson holds.

--- @@ -124,6 +124,7 @@ source by. | `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 | +| `hjem` | `hjem` | the files and packages of a user, as hjem manifests; activated by the NixOS configuration they are declared inside, or applied with `hjem standalone` | | `terranix` | `terranix` | Terranix and Terraform configurations | | `caisson-colmena` | `colmena` | colmena configurations whose machines are NixOS configurations declared inside them, published as the hive colmena's command reads; the colmena integration we recommend for new configurations | | `colmena` | `colmena` | a hive written for colmena, taken as it is, with each node a NixOS configuration; for porting an existing hive and the modules written for it | diff --git a/configs/flake/caisson/partitions/checks.nix b/configs/flake/caisson/partitions/checks.nix index 6299fa11..36cc9239 100644 --- a/configs/flake/caisson/partitions/checks.nix +++ b/configs/flake/caisson/partitions/checks.nix @@ -178,6 +178,43 @@ }; in builtins.seq suite.summary (pkgs.runCommand "pinned-world" { } "touch $out"); + # The manifest file of an hjem configuration inside a NixOS + # machine and the units that activate it, built. Building + # the manifest file checks it against hjem's own schema + # with `cue vet`, so the file the machine activates is one + # hjem's CLI accepts. Building the units runs shellcheck + # on their scripts. + hjem-system = + let + probe = import (self.outPath + "/tests/hjem-system") { + inputs = inputs // { + caisson = self; + }; + }; + units = probe.top.nixosConfigurations.probe-machine.config.systemd.units; + in + pkgs.linkFarm "hjem-system" ( + [ + { + name = "manifest-probe.json"; + path = probe.inner.outputs.manifestFile; + } + ] + ++ + builtins.map + (name: { + inherit name; + path = units.${name}.unit; + }) + [ + "hjem.target" + "hjem-prepare.service" + "hjem-activate@.service" + "hjem-reload@.service" + "hjem-update-state@.service" + "hjem-cleanup.service" + ] + ); # The argument error of every entry point comes from Nix # itself, raised at the call site with no frame of caisson # above it: the real evaluator runs each wrong call and the diff --git a/configs/structural/impl/default.nix b/configs/structural/impl/default.nix index 985d7ae9..9f7d8b4e 100644 --- a/configs/structural/impl/default.nix +++ b/configs/structural/impl/default.nix @@ -24,6 +24,7 @@ nixos-minimal home-manager home-manager-minimal + hjem caisson-colmena colmena caisson-disko diff --git a/docs/development/repository-structure.md b/docs/development/repository-structure.md index 5d0d4d8d..59d72bf5 100644 --- a/docs/development/repository-structure.md +++ b/docs/development/repository-structure.md @@ -26,7 +26,7 @@ without nixpkgs can depend on it directly. The layer users reach for: the integrations (`structural`, `flake-parts`, `nixpkgs`, `nixos`, `nixos-minimal`, `home-manager`, -`home-manager-minimal`, `darwin`, `caisson-colmena`, `colmena`, +`home-manager-minimal`, `darwin`, `hjem`, `caisson-colmena`, `colmena`, `caisson-disko`, `disko`, `terranix`, `system-manager`), each a library overlay contributing its `lib.caisson` namespace, registering its module @@ -50,7 +50,7 @@ inject the same names beside `self`. ## The pinned world The upstream world caisson is tested against (nixpkgs, home-manager, -colmena, disko, nix-darwin, terranix, system-manager, and the test +colmena, disko, nix-darwin, hjem, terranix, system-manager, and the test tooling) is pinned in `tests/dependencies/flake.lock`, a lock only the checks and formatter partitions read, so caisson's `flake.nix` carries no churning pin. The diff --git a/docs/development/testing-architecture.md b/docs/development/testing-architecture.md index 7b34980c..e44f741e 100644 --- a/docs/development/testing-architecture.md +++ b/docs/development/testing-architecture.md @@ -311,7 +311,8 @@ The checks partition merges outputs from all test and example flakes into the | `literate-flake-default` | `examples/literate-flake/` | Example default package builds | | `literate-flake-greeting` | `examples/literate-flake/` | Example greeting package builds | | `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, terranix, system-manager) evaluates end to end; the `drift` workflow runs the same check against advanced pins | +| `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 | | `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 b3e571a0..7d416c7c 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -23,13 +23,17 @@ plumbing works. ## Pinned-World Suite - **Scope:** caisson composed with concrete pinned versions of the upstream - world (nixpkgs, home-manager, colmena, disko, nix-darwin, 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, a terranix and a system-manager + world (nixpkgs, home-manager, colmena, disko, nix-darwin, hjem, + 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 + their own and inside a NixOS machine, a terranix and a system-manager configuration, and the composition guarantees of the real exported - overlays. The darwin machine of the suite is also the one check at + 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 `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 diff --git a/lib-overlays/hjem/default.nix b/lib-overlays/hjem/default.nix new file mode 100644 index 00000000..6e936afb --- /dev/null +++ b/lib-overlays/hjem/default.nix @@ -0,0 +1,341 @@ +# SPDX-License-Identifier: MIT +# +# The hjem integration: it owns the `hjem` class, hjem's class for the +# files and packages of a user. An hjem configuration is the home of a +# user, evaluated at every system in force where it is declared. +# +# hjem itself evaluates a user only inside its NixOS and nix-darwin +# modules, as a submodule of `hjem.users`; its standalone CLI evaluates +# no modules and reads a manifest. caisson evaluates the user with its +# own `evalModules` over hjem's user module, `modules/common/user.nix`, +# with the arguments hjem's OS modules give it: hjem's library as +# `hjem-lib`, the user's name as `name`, the package set as `pkgs`, and +# the machine above it, if any, as `osConfig`. The user's name is the +# name the configuration is declared under, unless a module of the +# configuration sets `user`. +# +# The outputs are what hjem's tools read: `manifest`, the version 3 +# manifest of the user's files as hjem's OS modules write it, +# `manifestFile`, that manifest as a JSON file checked against hjem's +# schema, and `packages`. The configuration is published as +# `hjemConfigurations.` with `manifest` and `packages`, which +# `hjem standalone switch --flake` reads. A failed assertion of the +# user fails every output. +# +# A NixOS configuration activates the hjem configurations declared +# inside it with caisson's `hjem-activation` NixOS module, and gives +# them the home directory of the user's account with the hjem module +# `nixos-parent`. +{ + mkLibOverlay, + ... +}: +{ + + imports = [ + # The library of nixpkgs, which this overlay calls through the + # composed library, imported by key so it is composed wherever + # this is. + ((mkLibOverlay ../nixpkgs-lib) // { key = "nixpkgs-lib"; }) + # What an integration is written from, imported by key so it is + # composed wherever this integration is. + ((mkLibOverlay ../integrations) // { key = "integrations"; }) + ]; + + overlay = + final: prev: + let + context = "lib.caisson.hjem.mkConfiguration"; + + resolveEcosystemSrc = final.caisson.integrations.resolveEcosystemSrc { + name = "hjem"; + context = "caisson.hjem"; + }; + + # An hjem source: the hjem flake or source tree, which holds the + # user module and hjem's library. + assertHjemSrc = + ecosystemSrc: + let + path = if builtins.isAttrs ecosystemSrc then ecosystemSrc.outPath or null else ecosystemSrc; + in + if + path != null + && builtins.pathExists "${path}/modules/common/user.nix" + && builtins.pathExists "${path}/lib.nix" + then + ecosystemSrc + else + throw "${context} requires `ecosystemSrc` to be an hjem flake or source tree (holding modules/common/user.nix and lib.nix)."; + + # The manifest of an evaluated user, as hjem's OS modules write + # it: version 3, with an entry for every enabled file of the + # user's file sets. + manifestOf = + hjemLib: config: + let + fileSets = [ + config.files + config.xdg.cache.files + config.xdg.config.files + config.xdg.data.files + config.xdg.state.files + ]; + in + { + version = 3; + files = builtins.concatMap ( + files: + builtins.map hjemLib.fileToJson (builtins.filter (file: file.enable) (builtins.attrValues files)) + ) fileSets; + }; + + evaluate = + args: + { lib, manifest }: + let + selection = lib.caisson.integrations; + src = assertHjemSrc (resolveEcosystemSrc { + explicit = args.ecosystemSrc or null; + manifest = lib.caisson-core.libManifest or { }; + }); + + name = manifest.name or null; + inherit (manifest) system; + what = + if name != null then + "the hjem configuration `${name}` at ${system}" + else + "this hjem configuration at ${system}"; + + pkgSets = selection.pkgSetsAt { inherit context what; } manifest system; + pkgs = selection.pkgSetOf { inherit context what; } manifest pkgSets; + + hjemLib = import "${src}/lib.nix" { inherit lib pkgs; }; + + registry = lib.caisson.modules.hjem or { }; + frameworkModules = selection.frameworkModules "hjem" registry; + moduleImports = selection.moduleImportsOf "hjem" { inherit lib manifest; } args; + + # The configuration's module: the module passed, else the + # configuration registered under the configuration's name + # (`configs/hjem/`), else none. + registered = lib.caisson.configs.hjem or { }; + configModule = + if (args.configModule or null) != null then + args.configModule + else if name != null then + registered.${name} or null + else + 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; + osConfig = if machine != null then machine.value.config else null; + + # A user hjem's NixOS module embeds also imports hjem's + # `modules/nixos/systemd.nix`, which declares the user's + # systemd units and links them into the user's files, with the + # `utils` and the options of the machine. A configuration inside + # 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; + + # What hjem's OS modules set on a user that caisson sets too: + # the user's name from the name the configuration is declared + # under, and hjem's default of not clobbering existing files. + userModule = + { lib, ... }: + { + _file = "caisson-hjem:user"; + user = lib.mkIf (name != null) (lib.mkDefault name); + clobberFiles = lib.mkDefault false; + }; + + value = lib.evalModules ( + { + class = "hjem"; + specialArgs = { + inherit + lib + pkgs + pkgSets + osConfig + ; + hjem-lib = hjemLib; + name = if name != null then name else "hjem"; + } + // lib.optionalAttrs nixosUser { + inherit (machineArgs) utils; + osOptions = machine.value.options; + } + // (if (args.specialArgs or null) != null then args.specialArgs else { }); + modules = [ + "${src}/modules/common/user.nix" + userModule + ] + ++ lib.optional nixosUser "${src}/modules/nixos/systemd.nix" + ++ frameworkModules + ++ moduleImports registry + ++ (if configModule == null then [ ] else [ configModule ]); + } + // (if (args.ecosystemArgs or null) != null then args.ecosystemArgs else { }) + ); + inherit (value) config; + + # Every output fails with the messages of the user's failed + # assertions, and carries its warnings. + failed = builtins.map (assertion: assertion.message) ( + builtins.filter (assertion: !assertion.assertion) config.assertions + ); + checked = + output: + if failed != [ ] then + throw '' + ${what}: + ${lib.concatMapStringsSep "\n" (message: "- ${message}") failed} + '' + else + lib.foldr (warning: lib.warn "${what}: ${warning}") output config.warnings; + + manifestValue = checked (manifestOf hjemLib config); + in + { + inherit value; + outputs = { + manifest = manifestValue; + manifestFile = pkgs.writeTextFile { + name = "manifest-${config.user}.json"; + text = builtins.toJSON manifestValue; + checkPhase = '' + set -e + export CUE_CACHE_DIR=$(pwd)/.cache + export CUE_CONFIG_DIR=$(pwd)/.config + ${lib.getExe pkgs.cue} vet -c ${src + "/manifest/v3.cue"} $target + ''; + }; + packages = checked config.packages; + # hjem's CLI, which applies a manifest, built from the hjem + # source the configuration was evaluated with. + cli = pkgs.callPackage "${src}/cli/package.nix" { }; + }; + }; + + # An hjem configuration is published as + # `hjemConfigurations.`, with `manifest` and `packages`, + # which is what `hjem standalone switch --flake` reads. + exportsTo = { + attrset = "hjemConfigurations"; + value = manifest: { + inherit (manifest.outputs) manifest packages; + }; + }; + + # What a reader of a file gets from an hjem configuration that is + # a top: `manifest` and `packages`, which `hjem standalone switch + # --config` reads, and the manifest of the evaluation under + # `caisson.manifest`. hjem's CLI serializes the whole value with + # `nix eval --json`, and an attribute set with `outPath` is + # serialized as that string, so the live manifest stays out of + # what the CLI reads. + projection = manifest: { + inherit (manifest.outputs) manifest packages; + caisson = { + outPath = "caisson manifest: read caisson.manifest"; + inherit manifest; + }; + }; + + # A top evaluated at a single system is the projection of that + # evaluation; at several, the projections by system. + top = + evaluations: + let + systems = builtins.attrNames evaluations; + in + if builtins.length systems == 1 then + projection evaluations.${builtins.head systems} + else + builtins.mapAttrs (_: projection) evaluations; + + configuration = + args: + final.caisson.integrations.mkModuleConfiguration { + type = "hjem"; + # An hjem configuration is evaluated at a system: it has an + # evaluation for every system in force where it is declared. + perSystem = true; + defaultPkgs = args.defaultPkgs or null; + inherit exportsTo; + evaluate = evaluate args; + }; + + integration = final.caisson.integrations.mkIntegration { + name = "hjem"; + class = "hjem"; + inherit top; + # What these return is a configuration, a function of + # `{ name, parent }`: a parent that declares it under + # `caisson.hjem.configurations.` finalizes it, and + # `mkTopConfiguration` finalizes it at a top. + mkConfiguration = + { + # The configuration's module. When absent, the configuration + # registered under the configuration's name + # (`lib.caisson.configs.hjem.`), if any. Further + # modules of the class are selected with `moduleImports`, + # from the registry. + configModule ? null, + # The hjem flake or source tree; resolved from the + # composition's declarations when absent. + ecosystemSrc ? null, + # The package set the configuration runs on: a function + # that receives the package sets available where it is + # declared, by package config name, each at the system of + # the evaluation, and returns the set to run on. When + # absent, the selection of the nearest configuration above, + # and the set named `default` where none above selects. + defaultPkgs ? null, + # The selection over the hjem class of the registry. It + # replaces the default of the class, which is every entry + # named `default` followed by what the configurations + # above added. + moduleImports ? null, + # A selection added to that selection, whichever it is. + extraModuleImports ? null, + # Extra module arguments, merged over those the framework + # supplies. + specialArgs ? null, + }@args: + configuration args; + # The same arguments and `ecosystemArgs`, the evaluator's + # arguments merged over the composed call last. + mkConfigurationWithEcosystemArgs = + { + configModule ? null, + ecosystemSrc ? null, + defaultPkgs ? null, + moduleImports ? null, + extraModuleImports ? null, + specialArgs ? null, + ecosystemArgs ? null, + }@args: + configuration args; + extra = { + # The manifest of an evaluated user, from hjem's library and + # the user's `config`. + inherit manifestOf; + }; + }; + in + { + caisson = (prev.caisson or { }) // { + hjem = integration.namespace; + }; + }; + +} diff --git a/lib-overlays/integrations/default.nix b/lib-overlays/integrations/default.nix index 77843291..15f40754 100644 --- a/lib-overlays/integrations/default.nix +++ b/lib-overlays/integrations/default.nix @@ -410,23 +410,41 @@ # The module that declares the options `configurationsModuleOf` # declares, for several integrations at once. The core module of # caisson imports it once, for all the integrations of caisson. + # The types of the options are built once for all of them. configurationsModuleFor = names: { key = "caisson-configurations:${builtins.concatStringsSep "," names}"; imports = [ ( { lib, options, ... }: + let + types = configurationsTypesOf lib; + in { - options.caisson = genAttrs names (name: configurationsOptionsOf { inherit lib options; } name); + options.caisson = genAttrs names ( + name: configurationsOptionsOf { inherit lib options types; } name + ); } ) ]; }; + # The types of `caisson..configurations` and + # `caisson..exported`, the same for every integration. + configurationsTypesOf = lib: { + configurations = lib.types.lazyAttrsOf lib.types.raw; + exported = lib.types.functionTo (lib.types.lazyAttrsOf lib.types.raw); + }; + # The options `caisson..configurations` and # `caisson..exported` of the integration `name`, in the - # evaluation whose `lib` and `options` are given. + # evaluation whose `lib` and `options` are given, with `types` from + # `configurationsTypesOf`. configurationsOptionsOf = - { lib, options }: + { + lib, + options, + types ? configurationsTypesOf lib, + }: name: let manifest = lib.caisson-core.evalManifest; @@ -483,7 +501,7 @@ in { exported = lib.mkOption { - type = lib.types.functionTo (lib.types.lazyAttrsOf lib.types.raw); + type = types.exported; default = configurations: configurations; defaultText = lib.literalMD "every configuration declared"; description = '' @@ -495,7 +513,7 @@ ''; }; configurations = lib.mkOption { - type = lib.types.lazyAttrsOf lib.types.raw; + type = types.configurations; default = { }; apply = builtins.mapAttrs finalize; description = '' @@ -531,11 +549,20 @@ # integration's `evaluate` returns: the values of its # `caisson..configurations` options, which finalize # each entry when read. An integration with none declared is left - # out. + # out. An option with no definition but its default, whose highest + # priority is the priority of an option default, is not read at + # all, which spares merging it in every evaluation for every + # integration the evaluation could hold. childrenOf = { options, config }: + let + optionDefaultPriority = 1500; + declaring = builtins.filter ( + name: options.caisson.${name}.configurations.highestPrio < optionDefaultPriority + ) (integrationsIn options); + in filterAttrs (_: declared: declared != { }) ( - genAttrs (integrationsIn options) (name: config.caisson.${name}.configurations) + genAttrs declaring (name: config.caisson.${name}.configurations) ); isManifest = value: builtins.isAttrs value && (value._type or null) == "caisson-manifest"; diff --git a/modules/generic/core/default.nix b/modules/generic/core/default.nix index 428fa24e..ffc7bf5c 100644 --- a/modules/generic/core/default.nix +++ b/modules/generic/core/default.nix @@ -34,6 +34,7 @@ "darwin" "disko" "flake-parts" + "hjem" "home-manager" "nixos" "nixpkgs" diff --git a/modules/hjem/core b/modules/hjem/core new file mode 120000 index 00000000..3715354a --- /dev/null +++ b/modules/hjem/core @@ -0,0 +1 @@ +../generic/core \ No newline at end of file diff --git a/modules/hjem/nixos-parent/default.nix b/modules/hjem/nixos-parent/default.nix new file mode 100644 index 00000000..18235e8a --- /dev/null +++ b/modules/hjem/nixos-parent/default.nix @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MIT +# +# An hjem module for an hjem configuration declared inside a NixOS +# configuration. It sets the home directory of the user from the +# account the machine declares, as hjem's NixOS module does for the +# users it embeds, and refuses a user the machine has no account for. +# +# caisson does not use hjem's NixOS module. A NixOS configuration adds +# this module to the modules that the hjem configurations declared +# inside it import by default; caisson's `default` NixOS 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 NixOS configuration above it. +{ ... }: +# `lib` is the library of the hjem configuration, and `config` is its +# configuration. +{ lib, config, ... }: +let + # The configuration of the NixOS configuration the hjem configuration + # is declared inside, in its childless evaluation, which leaves out + # the configurations declared inside it. + machine = lib.caisson.evalManifest.nearest.nixos.value.config; + + # The account of the user, when the machine declares one. + account = (machine.users.users or { }).${config.user} or null; +in +{ + config = lib.mkMerge [ + { + assertions = [ + { + assertion = config.enable -> account != null && (account.enable or true); + message = "The hjem user `${config.user}` has no enabled account in `users.users` of the NixOS configuration it is declared inside."; + } + ]; + } + (lib.mkIf (account != null) { + directory = lib.mkDefault account.home; + }) + ]; +} diff --git a/modules/nixos/default/caisson/forChildren.nix b/modules/nixos/default/caisson/forChildren.nix index 9b282789..3e50c996 100644 --- a/modules/nixos/default/caisson/forChildren.nix +++ b/modules/nixos/default/caisson/forChildren.nix @@ -3,10 +3,11 @@ # What a NixOS configuration gives the homes declared inside it. # # A configuration gives things to the configurations declared inside -# it through the options under `caisson.forChildren`. This file sets -# one of them: it 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. +# 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 an +# 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 @@ -36,4 +37,11 @@ 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 an hjem + # configuration declared inside this machine from the account of its + # user, and refuses a user the machine has no account for. + caisson.forChildren.extraDefaultModuleImports.hjem = _lib: [ + closure-lib.caisson.modules.hjem.nixos-parent + ]; } diff --git a/modules/nixos/default/default.nix b/modules/nixos/default/default.nix index 329b0dfb..ed0fa4c7 100644 --- a/modules/nixos/default/default.nix +++ b/modules/nixos/default/default.nix @@ -8,6 +8,12 @@ # `./networking` defaults the host name of the NixOS configuration to # the name it is declared under, at `mkDefault` priority. # +# The registered module `hjem-activation` activates the hjem +# configurations declared inside the machine, and +# `./caisson/forChildren.nix` gives each of them the hjem module +# `nixos-parent`, which sets the user's home directory from the +# machine's account. +# # It also holds what a NixOS configuration does for the homes declared # inside it, in three parts: # @@ -35,6 +41,7 @@ (mkModule ./networking) (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 ] else diff --git a/modules/nixos/hjem-activation/default.nix b/modules/nixos/hjem-activation/default.nix new file mode 100644 index 00000000..0eaea2f1 --- /dev/null +++ b/modules/nixos/hjem-activation/default.nix @@ -0,0 +1,265 @@ +# SPDX-License-Identifier: MIT +# +# A NixOS module with which a machine activates the hjem configurations +# declared inside it. +# +# Activating an hjem configuration means applying its manifest with +# hjem's CLI: `hjem internal activate` links the user's files and +# replaces those of the previous generation, whose manifest is kept in +# `/var/lib/hjem/manifest-.json`. hjem has a NixOS module that +# does this for the users it embeds. caisson does not use that NixOS +# module. An hjem configuration declared inside a NixOS configuration +# is a caisson configuration, and this module writes the units hjem's +# NixOS module writes, under the same names. Each template unit has an +# instance for each user: +# +# - `hjem-activate@` runs as the user and links the files of the +# new manifest, given the stored manifest of the previous one; +# - `hjem-reload@` runs as the user after it and reloads the +# user's systemd units the change touched; +# - `hjem-update-state@` stores the new manifest as the state; +# +# and `hjem-cleanup` removes the stored manifests of users that are no +# longer activated. The target `hjem` requires them all, and starts at +# boot and when the machine switches to a new generation. The units +# link with hjem's own CLI and the backup prefix of hjem's NixOS +# module; hjem's options for another linker are not offered here. +# +# The module also installs each user's `packages` for that user and +# keeps each user's `extraDependencies` in the closure of the system, +# as hjem's NixOS module does. +# +# Which configurations: the hjem configurations this NixOS +# configuration passes up, leaving out those declared inside a NixOS +# configuration that is itself inside this one, and those evaluated for +# another system. caisson's `default` NixOS module imports this module. +# A NixOS configuration that passes `moduleImports` activates its hjem +# configurations only if it lists it. +# +# The module reads the hjem configurations from the full evaluation of +# the NixOS configuration; in the childless evaluation, and in an +# evaluation without systemd options, it defines nothing. +{ closure-lib, ... }: +{ + config, + lib, + options, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + + # True when this evaluation declares the options the module defines. + hasUnits = + options ? systemd + && options.systemd ? services + && options.systemd ? targets + && options ? users + && options ? system + && options.system ? extraDependencies; + + isActivatedHere = + entry: + entry.manifest.type == "hjem" + && !(builtins.any (segment: segment.type == "nixos") (lib.lists.init entry.path)) + && entry.manifest.system == evalManifest.system; + + # What the units need of each enabled hjem configuration. + users = + builtins.map + (entry: { + inherit (entry.manifest.value.config) + user + directory + packages + extraDependencies + ; + inherit (entry.manifest.outputs) manifestFile cli; + }) + ( + builtins.filter (entry: isActivatedHere entry && entry.manifest.value.config.enable) ( + config.caisson.exports.configurations + ) + ); + + # The manifests of the previous generations, which the units read and + # write, and the manifests of this generation, one file per user. + oldManifests = "/var/lib/hjem"; + newManifests = pkgs.linkFarm "hjem-manifests" ( + builtins.map (user: { + name = "manifest-${user.user}.json"; + path = user.manifestFile; + }) users + ); + homeDirectories = builtins.map (user: user.directory) users; + + # The prefix hjem's CLI gives the backup of a file it replaces, the + # default of hjem's NixOS module. + prefix = ".backup-"; + + # The start of each template unit: it stops for a user no hjem + # configuration here names, and sets `hjem` to the CLI of the + # configuration of the user. Each hjem configuration builds the CLI + # from the hjem source it is evaluated with. + selectUser = '' + case "$1" in + ${lib.concatMapStrings (user: '' + ${lib.escapeShellArg user.user}) hjem=${lib.getExe user.cli} ;; + '') users} + *) echo "User '$1' is not configured for hjem" >&2; exit 0 ;; + esac + ''; + + # The users that more than one hjem configuration names, which would + # both write the files of that user. + 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.nixos.core ]; + + # `optionalAttrs` and not `mkIf` decides whether the options are + # defined at all: NixOS refuses a definition, even under `mkIf false`, + # of an option that no module declares. + config = lib.optionalAttrs hasUnits ( + lib.mkIf (!evalManifest.childless && users != [ ]) { + assertions = [ + { + assertion = repeated == [ ]; + message = "caisson: this NixOS 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 + ); + + system.extraDependencies = builtins.concatMap (user: user.extraDependencies) users; + + systemd.targets.hjem = { + description = "hjem file management"; + unitConfig.X-StopOnReconfiguration = true; + after = [ "local-fs.target" ]; + wantedBy = [ + "sysinit-reactivation.target" + "multi-user.target" + ]; + before = [ "sysinit-reactivation.target" ]; + requires = + builtins.concatMap (user: [ + "hjem-activate@${user.user}.service" + "hjem-reload@${user.user}.service" + "hjem-update-state@${user.user}.service" + ]) users + ++ [ "hjem-cleanup.service" ]; + }; + + systemd.services = { + hjem-prepare = { + description = "Prepare Hjem manifests directory"; + enableStrictShellChecks = true; + script = "mkdir -p ${oldManifests}"; + serviceConfig.Type = "oneshot"; + unitConfig.RefuseManualStart = true; + }; + + "hjem-activate@" = { + description = "Link files for %i from their manifest"; + enableStrictShellChecks = true; + unitConfig.RequiresMountsFor = homeDirectories; + serviceConfig = { + User = "%i"; + Type = "oneshot"; + }; + requires = [ "hjem-prepare.service" ]; + after = [ "hjem-prepare.service" ]; + scriptArgs = "%i"; + script = '' + ${selectUser} + "$hjem" internal activate \ + --manifest "${newManifests}/manifest-$1.json" \ + --state "${oldManifests}/manifest-$1.json" \ + --skip-state-update \ + --prefix "${prefix}" \ + --json + ''; + }; + + "hjem-reload@" = { + description = "Reload systemd user units for %i after Hjem file activation"; + enableStrictShellChecks = true; + serviceConfig = { + User = "%i"; + Type = "oneshot"; + }; + unitConfig.RequiresMountsFor = homeDirectories; + requires = [ "hjem-activate@%i.service" ]; + after = [ "hjem-activate@%i.service" ]; + path = [ + config.systemd.package + pkgs.coreutils-full + ]; + scriptArgs = "%i"; + script = '' + ${selectUser} + XDG_RUNTIME_DIR=$(loginctl show-user "$1" --property=RuntimePath --value 2>/dev/null || true) + if [ -z "$XDG_RUNTIME_DIR" ]; then + echo "Could not determine XDG runtime directory for $1. Skipping." + exit 0 + fi + export XDG_RUNTIME_DIR + + systemd_status=$(systemctl --user is-system-running 2>&1 || true) + if [ "$systemd_status" != "running" ] && [ "$systemd_status" != "degraded" ]; then + echo "User systemd for $1 is not running (status: $systemd_status). Skipping." + exit 0 + fi + + "$hjem" internal reload-actions \ + --old-manifest "${oldManifests}/manifest-$1.json" \ + --new-manifest "${newManifests}/manifest-$1.json" \ + --user "$1" \ + --require-running-systemd + ''; + }; + + "hjem-update-state@" = { + description = "Update Hjem state manifest for %i"; + enableStrictShellChecks = true; + serviceConfig.Type = "oneshot"; + requires = [ "hjem-reload@%i.service" ]; + unitConfig.RequiresMountsFor = homeDirectories; + after = [ "hjem-reload@%i.service" ]; + scriptArgs = "%i"; + script = '' + ${selectUser} + "$hjem" internal update-state \ + --manifest "${newManifests}/manifest-$1.json" \ + --state "${oldManifests}/manifest-$1.json" + ''; + }; + + hjem-cleanup = { + description = "Cleanup disabled users' manifests"; + enableStrictShellChecks = true; + serviceConfig.Type = "oneshot"; + after = [ "hjem.target" ]; + unitConfig.RefuseManualStart = false; + script = '' + ${lib.getExe (builtins.head users).cli} internal cleanup-state \ + --state-dir ${oldManifests} \ + ${lib.concatMapStringsSep " " (user: "--enabled-user ${lib.escapeShellArg user.user}") users} + ''; + }; + }; + } + ); +} diff --git a/tests/argument-errors/check.sh b/tests/argument-errors/check.sh index 3b99d8e1..1a0be246 100755 --- a/tests/argument-errors/check.sh +++ b/tests/argument-errors/check.sh @@ -21,17 +21,18 @@ shift flags=("$@") # Every entry point takes `configModule`. The structural, caisson-colmena, -# caisson-disko, disko, flake-parts, nixos, nixos-minimal, darwin, +# caisson-disko, disko, flake-parts, nixos, nixos-minimal, darwin, hjem, # home-manager and home-manager-minimal entry points require nothing: # each finds the configuration registered under the name of the # configuration. The others require `configModule`, and take `pkgSets`, -# which the nixos, darwin and home-manager entry points do not: a NixOS -# configuration, a darwin configuration and a home take their package -# sets from the composition. +# which the nixos, darwin, hjem and home-manager entry points do not: a +# NixOS configuration, a darwin configuration, an hjem configuration and +# a home take their package sets from the composition. namespaces=( nixos nixos-minimal darwin + hjem home-manager home-manager-minimal flake-parts @@ -91,13 +92,13 @@ missing="function '[^']*%s' called without required argument '(configModule|pkgS # The arguments a call passes beside the wrong argument. given_for() { case "$1" in - nixos | nixos-minimal | darwin | home-manager | home-manager-minimal) printf 'configModule = { };' ;; + nixos | nixos-minimal | darwin | hjem | home-manager | home-manager-minimal) printf 'configModule = { };' ;; *) printf 'configModule = { }; pkgSets.pkgs = { };' ;; esac } requires_nothing() { case "$1" in - structural | caisson-colmena | caisson-disko | disko | flake-parts | nixos | nixos-minimal | darwin | home-manager | home-manager-minimal) return 0 ;; + structural | caisson-colmena | caisson-disko | disko | flake-parts | nixos | nixos-minimal | darwin | hjem | home-manager | home-manager-minimal) return 0 ;; *) return 1 ;; esac } diff --git a/tests/dependencies/flake.lock b/tests/dependencies/flake.lock index 36213121..c64eaf79 100644 --- a/tests/dependencies/flake.lock +++ b/tests/dependencies/flake.lock @@ -195,6 +195,26 @@ "type": "github" } }, + "hjem": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791807571, + "narHash": "sha256-Q5hGcKBaqz1L0neSRDfQgQPBKmSCkV1N5YBRwp+RtUc=", + "owner": "feel-co", + "repo": "hjem", + "rev": "6b2cc57463bbbac3d7c61206ab365a2c928861a4", + "type": "github" + }, + "original": { + "owner": "feel-co", + "repo": "hjem", + "type": "github" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -379,6 +399,7 @@ "colmena": "colmena", "disko": "disko", "flake-parts": "flake-parts", + "hjem": "hjem", "home-manager": "home-manager", "nix-darwin": "nix-darwin", "nix-unit": "nix-unit", diff --git a/tests/dependencies/flake.nix b/tests/dependencies/flake.nix index b2df2947..b09d16b2 100644 --- a/tests/dependencies/flake.nix +++ b/tests/dependencies/flake.nix @@ -21,6 +21,8 @@ inputs.system-manager.inputs.nixpkgs.follows = "nixpkgs"; inputs.disko.url = "github:nix-community/disko"; inputs.disko.inputs.nixpkgs.follows = "nixpkgs"; + inputs.hjem.url = "github:feel-co/hjem"; + inputs.hjem.inputs.nixpkgs.follows = "nixpkgs"; inputs.nix-darwin.url = "github:nix-darwin/nix-darwin"; inputs.nix-darwin.inputs.nixpkgs.follows = "nixpkgs"; outputs = { ... }: { }; diff --git a/tests/hjem-system/default.nix b/tests/hjem-system/default.nix new file mode 100644 index 00000000..1194fa41 --- /dev/null +++ b/tests/hjem-system/default.nix @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: MIT +# +# hjem configurations declared through caisson's hjem integration, for +# the pinned-world suite and the hjem manifest check. `inputs` is the +# dependency pool of the checks partition with `caisson` set to the +# tree itself. +# +# The composition is at x86_64-linux, with a package config whose set +# there is nixpkgs, and with nixpkgs and hjem declared as its +# ecosystems. `user` is an hjem module with a file and a package. +# `standalone` is an hjem configuration that is a top, for the user +# `probe` with the home directory it sets itself. `top` is a structural +# top holding a NixOS machine `probe-machine`, which declares the +# account of `probe` and an hjem configuration `probe` from +# `machineUser`, which is `user` with a systemd user unit. +{ inputs }: +let + core = inputs.caisson.lib.caisson-core; + + system = "x86_64-linux"; + + pkgs = import inputs.nixpkgs { inherit system; }; + + lib = core.mkLib { + sources = { }; + systems = [ system ]; + projects.caisson = inputs.caisson; + defaultEcosystemSrc = { + inherit (inputs) nixpkgs hjem; + }; + pkgSets = _lib: { + default = + { name, parent }: + { + _type = "caisson-manifest"; + type = "nixpkgs"; + inherit name parent; + children.nixpkgs.${system} = { + _type = "caisson-manifest"; + type = "nixpkgs"; + name = system; + value = pkgs; + }; + }; + }; + }; + + user = { + files.".config/probe/greeting".text = "hello from caisson"; + xdg.config.files."probe/settings".source = pkgs.writeText "settings" "probe"; + packages = [ pkgs.hello ]; + }; + + # The user inside the machine also declares a systemd user unit, which + # only a user inside a NixOS machine can declare. + machineUser = { + imports = [ user ]; + systemd.services.probe = { + description = "probe"; + script = "true"; + wantedBy = [ "default.target" ]; + }; + }; + + standalone = lib.caisson.hjem.mkTopConfiguration { + configModule = { + imports = [ user ]; + user = "probe"; + directory = "/home/probe"; + }; + }; + + machine = { + boot.loader.grub.enable = false; + fileSystems."/" = { + device = "none"; + fsType = "tmpfs"; + }; + system.stateVersion = "25.05"; + users.users.probe = { + isNormalUser = true; + home = "/home/probe"; + }; + }; + + top = lib.caisson.structural.mkTopConfiguration { + moduleImports = _modules: [ ]; + configModule = + { lib, ... }: + { + caisson.nixos.configurations.probe-machine = lib.caisson.nixos.mkConfiguration { + configModule = + { lib, ... }: + { + imports = [ machine ]; + caisson.hjem.configurations.probe = lib.caisson.hjem.mkConfiguration { + configModule = machineUser; + }; + }; + }; + }; + }; +in +{ + inherit + lib + machine + machineUser + pkgs + standalone + system + top + user + ; + # The machine and the hjem configuration `probe` inside it, as their + # manifests. A configuration evaluated at a system sits under that + # system in the manifest of its parent. + machineManifest = top.caisson.manifest.children.system.${system}.children.nixos.probe-machine; + inner = + top.caisson.manifest.children.system.${system}.children.nixos.probe-machine.children.system.${system}.children.hjem.probe; +} diff --git a/tests/pinned-world/default.nix b/tests/pinned-world/default.nix index 843f9575..d0dab88f 100644 --- a/tests/pinned-world/default.nix +++ b/tests/pinned-world/default.nix @@ -61,6 +61,7 @@ let registered."caisson/nixos" registered."caisson/nixos-minimal" registered."caisson/home-manager" + registered."caisson/hjem" registered."caisson/caisson-colmena" registered."caisson/colmena" registered."caisson/caisson-disko" @@ -93,6 +94,7 @@ let "evalManifest" "finalizeTop" "flake-parts" + "hjem" "home-manager" "importApply" "integrations" @@ -464,6 +466,7 @@ let "caisson/nixos" "caisson/nixos-minimal" "caisson/home-manager" + "caisson/hjem" "caisson/caisson-colmena" "caisson/colmena" "caisson/caisson-disko" @@ -560,6 +563,7 @@ let "nixos" "nixos-minimal" "home-manager" + "hjem" "caisson-colmena" "colmena" "caisson-disko" @@ -884,6 +888,7 @@ let hiveLib.caisson.nixos ? mkTopConfiguration && hiveLib.caisson.darwin ? mkTopConfiguration && hiveLib.caisson.home-manager ? mkTopConfiguration + && hiveLib.caisson.hjem ? mkTopConfiguration && hiveLib.caisson.structural ? mkTopConfiguration && hiveLib.caisson.flake-parts ? mkTopConfiguration && !(hiveLib.caisson.caisson-colmena ? mkTopConfiguration) @@ -1403,6 +1408,75 @@ let && probe.configuration.config.nixpkgs.hostPlatform.system == "aarch64-darwin" && probe.configuration.pkgs.hello.drvPath == probe.pkgs.hello.drvPath; + # 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 + # manifest of the evaluation sits under `caisson.manifest`, behind an + # `outPath`, so the JSON the CLI reads holds a string in its place. + hjemTopIsWhatHjemsCliReads = + let + probe = import ../hjem-system { inherit inputs; }; + top = probe.standalone; + targets = builtins.map (file: file.target) top.manifest.files; + in + top.manifest.version == 3 + && + builtins.sort builtins.lessThan targets == [ + "/home/probe/.config/probe/greeting" + "/home/probe/.config/probe/settings" + ] + && builtins.all (file: file.type == "symlink") top.manifest.files + && builtins.map (package: package.pname) top.packages == [ "hello" ] + && builtins.toJSON top.caisson == builtins.toJSON "caisson manifest: read caisson.manifest" + && top.caisson.manifest.type == "hjem"; + + # An hjem configuration inside a NixOS machine takes the home + # directory of the account the machine declares, and its manifest is + # the one hjem's own NixOS module makes from the same user module, + # the systemd user unit of the user included. The machine activates + # it with the template units hjem's NixOS module writes, which read + # the manifest file from the directory of this generation, installs + # the user's packages, and the top publishes it as + # `hjemConfigurations.probe`. + hjemInsideNixosIsActivated = + let + probe = import ../hjem-system { inherit inputs; }; + inner = probe.inner; + machine = probe.top.nixosConfigurations.probe-machine; + services = machine.config.systemd.services; + upstream = import "${inputs.nixpkgs}/nixos/lib/eval-config.nix" { + inherit (probe) system; + modules = [ + inputs.hjem.nixosModules.default + probe.machine + { hjem.users.probe = probe.machineUser; } + ]; + }; + 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; + targets = builtins.map (file: file.target) inner.outputs.manifest.files; + target = machine.config.systemd.targets.hjem; + in + inner.value.config.directory == "/home/probe" + && builtins.toJSON inner.outputs.manifest == builtins.toJSON upstreamManifest + && builtins.elem "/home/probe/.config/systemd/user/probe.service" targets + && builtins.all (name: services ? ${name}) [ + "hjem-prepare" + "hjem-activate@" + "hjem-reload@" + "hjem-update-state@" + "hjem-cleanup" + ] + && builtins.elem "hjem-activate@probe.service" target.requires + && probe.pkgs.lib.strings.hasInfix "hjem-manifests" services."hjem-activate@".script + && builtins.elem "hello" ( + builtins.map (package: package.pname or "") machine.config.users.users.probe.packages + ) + && builtins.attrNames probe.top.hjemConfigurations == [ "probe" ]; + terranixConfigurationEvaluatesEndToEnd = let terraform = composed.lib.caisson.terranix.mkConfiguration { diff --git a/tests/unit/lib-overlays.nix b/tests/unit/lib-overlays.nix index 35aa58d1..f5eef058 100644 --- a/tests/unit/lib-overlays.nix +++ b/tests/unit/lib-overlays.nix @@ -114,6 +114,7 @@ let "nixos" "nixos-minimal" "darwin" + "hjem" "home-manager" "home-manager-minimal" "flake-parts" @@ -5264,6 +5265,7 @@ in "nixos" "nixos-minimal" "darwin" + "hjem" "home-manager" "home-manager-minimal" ]; @@ -5271,6 +5273,7 @@ in "nixos" "nixos-minimal" "darwin" + "hjem" "home-manager" "home-manager-minimal" ];