diff --git a/docs/manual/usage.md b/docs/manual/usage.md index 560d7cb8a..dd1283e50 100644 --- a/docs/manual/usage.md +++ b/docs/manual/usage.md @@ -60,6 +60,7 @@ usage/rollbacks.md usage/dotfiles.md usage/graphical.md usage/gpu-non-nixos.md +usage/modular-services.md usage/updating.md usage/upgrading.md ``` diff --git a/docs/manual/usage/modular-services.md b/docs/manual/usage/modular-services.md new file mode 100644 index 000000000..2990fa225 --- /dev/null +++ b/docs/manual/usage/modular-services.md @@ -0,0 +1,77 @@ +# Modular Services {#sec-usage-modular-services} + +Home Manager supports nixpkgs +[modular services](https://nixos.org/manual/nixos/unstable/#modular-services) +under [](#opt-home.services). This is the home-manager analog to the +NixOS `system.services` namespace: each entry is an abstract service +sourced from `` with the upstream portable +systemd module loaded into it, so service modules shipped with packages +(e.g. `pkgs..passthru.services.default`) drop in unchanged -- +the same module evaluates on NixOS and on Home Manager. + +A minimal example -- run a one-shot user service from a package's +modular service definition: + +```nix +{ pkgs, ... }: { + home.services.tunnel = { + imports = [ pkgs.ghostunnel.passthru.services.default ]; + ghostunnel = { + listen = "127.0.0.1:8443"; + target = "127.0.0.1:8080"; + cert = "/run/secrets/cert.pem"; + key = "/run/secrets/key.pem"; + allowAll = true; + }; + }; +} +``` + +This produces `~/.config/systemd/user/tunnel.service` with the expected +`ExecStart`, `LoadCredential`, and `WantedBy=default.target`. + +Each service exposes the upstream NixOS-style schema: [`process.argv`], +`systemd.lib`, `systemd.mainExecStart`, `systemd.service`, +`systemd.services`, `systemd.sockets`. Lifted units are translated from +NixOS-style attrs (`wantedBy`, `serviceConfig`, `unitConfig`, +`environment`, ...) into the section-based INI shape +(`{ Unit; Service; Install; }`) that home-manager's +[](#opt-systemd.user.services) consumes. Only common keys are mapped +explicitly; uncommon options remain reachable via `unitConfig`, +`serviceConfig`, or `socketConfig`. + +Sub-services (nested `services.` inside another service) and their +units are dashed under the parent service name. The empty unit key +`""` denotes the service's *primary* unit (lifted to a unit named +after the service itself); [`process.argv`] becomes the default +`ExecStart` for that unit, which defaults to `WantedBy=default.target`. + +## Configuration data {#sec-usage-modular-services-configdata} + +Each service can declare configuration files via `configData.`. +These are materialized at `$XDG_CONFIG_HOME/system-services//` +(mirroring how NixOS lifts `configData` to `environment.etc`), with the +absolute path injected back into `configData..path` so the service +can refer to its files at a stable location: + +```nix +{ config, ... }: +{ + home.services.demo = { + process.argv = [ "/bin/myapp" "--config" config.home.services.demo.configData."app.toml".path ]; + configData."app.toml".text = '' + port = 1234 + ''; + }; +} +``` + +## Scope notes {#sec-usage-modular-services-scope} + +Home Manager mirrors the surface of nixpkgs' portable systemd module: +services and sockets only. Other unit kinds Home Manager supports +natively under [](#opt-systemd.user.services) (timers, paths, mounts, ...) +are intentionally not modeled on `home.services` until upstream grows them, +to keep both surfaces aligned. + +[`process.argv`]: https://nixos.org/manual/nixos/unstable/#opt-system.services._name_.process.argv diff --git a/docs/release-notes/rl-2605.md b/docs/release-notes/rl-2605.md index 299725cd6..bdef834bb 100644 --- a/docs/release-notes/rl-2605.md +++ b/docs/release-notes/rl-2605.md @@ -19,6 +19,13 @@ This release has the following notable changes: range 1.0–2.0, previously it erroneously expected values in the range `0.0–1.0`. +- New [](#opt-home.services) namespace for nixpkgs + [modular services](https://nixos.org/manual/nixos/unstable/#modular-services). + Service modules shipped with packages (e.g. + `pkgs..passthru.services.default`) drop in unchanged and are + lifted to user systemd units. See + [Modular Services](#sec-usage-modular-services) for details. + ## State Version Changes {#sec-release-26.05-state-version-changes} The state version in this release includes the changes below. These diff --git a/modules/misc/news/2026/04/2026-04-29_15-39-41.nix b/modules/misc/news/2026/04/2026-04-29_15-39-41.nix new file mode 100644 index 000000000..b722282b4 --- /dev/null +++ b/modules/misc/news/2026/04/2026-04-29_15-39-41.nix @@ -0,0 +1,11 @@ +{ + time = "2026-04-29T15:39:41+00:00"; + condition = true; + message = '' + A new `home.services` namespace has been added for nixpkgs + modular services. Service modules shipped with packages (i.e. + `pkgs..passthru.services.default`) drop in unchanged and are + lifted to user systemd units. See the "Modular Services" chapter + in the manual for details. + ''; +} diff --git a/modules/modules.nix b/modules/modules.nix index a09db013c..e233252ed 100644 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -64,6 +64,7 @@ let ./misc/xdg-user-dirs.nix ./misc/xdg.nix ./misc/xfconf.nix + ./services-modular ./systemd.nix ./targets/darwin ./targets/generic-linux.nix diff --git a/modules/services-modular/config-data-path.nix b/modules/services-modular/config-data-path.nix new file mode 100644 index 000000000..dfce6ec15 --- /dev/null +++ b/modules/services-modular/config-data-path.nix @@ -0,0 +1,42 @@ +# Sets `path` on each modular service's `configData.` so that the +# service can refer to its config files at a stable absolute location +# inside the user's XDG config directory. Mirrors +# `nixos/modules/system/service/systemd/config-data-path.nix`. +let + setPathsModule = + prefix: + { + lib, + name, + xdgConfigHome, + ... + }: + let + inherit (lib) mkOption types; + servicePrefix = "${prefix}${name}"; + in + { + _class = "service"; + options = { + configData = mkOption { + type = types.lazyAttrsOf ( + types.submodule ( + { config, ... }: + { + config.path = lib.mkDefault "${xdgConfigHome}/system-services/${servicePrefix}/${config.name}"; + } + ) + ); + }; + services = mkOption { + type = types.attrsOf ( + types.submoduleWith { + modules = [ (setPathsModule "${servicePrefix}-") ]; + specialArgs = { inherit xdgConfigHome; }; + } + ); + }; + }; + }; +in +setPathsModule "" diff --git a/modules/services-modular/default.nix b/modules/services-modular/default.nix new file mode 100644 index 000000000..4813a373c --- /dev/null +++ b/modules/services-modular/default.nix @@ -0,0 +1,187 @@ +{ + lib, + config, + pkgs, + ... +}: +let + inherit (lib) concatMapAttrs mkOption types; + + portable-lib = import (pkgs.path + "/lib/services/lib.nix") { inherit lib; }; + + # Combine a service-name prefix with a unit name. The empty unit name + # `""` is the convention from nixpkgs' portable services for the + # service's *primary* unit (e.g. `services.foo.systemd.user.services.""` + # is the unit named `foo.service`). Sub-units use a hyphenated suffix. + dashed = + before: after: + if after == "" then + before + else if before == "" then + after + else + "${before}-${after}"; + + # Translate a NixOS-style systemd unit attrset (wantedBy, serviceConfig, + # unitConfig, environment, ...) into the section-based INI shape that + # home-manager's `systemd.user.` expects (Unit/Service/Install). + # Only the common keys are mapped; uncommon options can still be set + # explicitly via `unitConfig` / `serviceConfig` / `socketConfig`. + unitAttrKeys = [ + "description" + "documentation" + "requires" + "wants" + "upholds" + "after" + "before" + "bindsTo" + "partOf" + "conflicts" + "requisite" + "onFailure" + "onSuccess" + ]; + pickSection = + keys: src: + lib.listToAttrs ( + lib.concatMap ( + k: + lib.optional (src ? ${k} && src.${k} != null && src.${k} != [ ]) { + name = lib.toSentenceCase k; + value = src.${k}; + } + ) keys + ); + envToList = + env: lib.mapAttrsToList (k: v: "${k}=${toString v}") (lib.filterAttrs (_: v: v != null) env); + installSection = + u: + lib.filterAttrs (_: v: v != [ ]) { + WantedBy = u.wantedBy or [ ]; + RequiredBy = u.requiredBy or [ ]; + }; + toHmIni = unit: { + Unit = pickSection unitAttrKeys unit // (unit.unitConfig or { }); + Service = + (unit.serviceConfig or { }) + // lib.optionalAttrs (unit ? environment && unit.environment != { }) { + Environment = envToList unit.environment; + }; + Install = installSection unit; + }; + toHmIniSocket = sock: { + Unit = pickSection unitAttrKeys sock // (sock.unitConfig or { }); + Socket = + (sock.socketConfig or { }) + // lib.optionalAttrs (sock ? listenStreams && sock.listenStreams != [ ]) { + ListenStream = sock.listenStreams; + } + // lib.optionalAttrs (sock ? listenDatagrams && sock.listenDatagrams != [ ]) { + ListenDatagram = sock.listenDatagrams; + }; + Install = installSection sock; + }; + + # Evaluate a deferredModule into attrs, then translate. + evalDeferred = + translator: unitModule: + translator + (lib.evalModules { + modules = [ + (_: { + # Loose schema: NixOS-style unit attrs include nested sub-attrsets + # (e.g. `serviceConfig`) that need to merge across definitions. + freeformType = + with types; + attrsOf (oneOf [ + (attrsOf raw) + (listOf raw) + raw + ]); + }) + unitModule + ]; + }).config; + + makeUnits = + translator: unitType: prefix: service: + concatMapAttrs (unitName: unitModule: { + "${dashed prefix unitName}" = evalDeferred translator unitModule; + }) service.systemd.${unitType} + // concatMapAttrs ( + subName: subService: makeUnits translator unitType (dashed prefix subName) subService + ) service.services; + + # Lift each service's `configData` entries into `xdg.configFile` paths. + # Mirrors how `nixos/modules/system/service/systemd/system.nix` lifts + # `configData` to `environment.etc`. + makeConfigFiles = + prefix: service: + lib.mapAttrs' (_: cfg: { + name = "system-services/${prefix}/${cfg.name}"; + value = lib.filterAttrs (_: v: v != null) { + source = cfg.source or null; + text = cfg.text or null; + inherit (cfg) enable; + }; + }) (lib.filterAttrs (_: cfg: cfg.enable) (service.configData or { })) + // concatMapAttrs ( + subName: subService: makeConfigFiles (dashed prefix subName) subService + ) service.services; + + modularServiceConfiguration = portable-lib.configure { + serviceManagerPkgs = pkgs; + extraRootModules = [ + ./service.nix + ./config-data-path.nix + ]; + extraRootSpecialArgs = { + systemdPackage = pkgs.systemd; + nixpkgsPath = pkgs.path; + xdgConfigHome = config.xdg.configHome; + }; + }; +in +{ + meta.maintainers = [ lib.maintainers.kiara ]; + + options.home.services = mkOption { + description = '' + Home-Manager [modular services](https://nixos.org/manual/nixos/unstable/#modular-services). + + Each entry is an abstract service that may declare a {option}`process.argv` + and home-manager-style {option}`systemd.user.{services,sockets}` units + (INI section shape). Units are emitted under home-manager's + {option}`systemd.user.services` (and friends) with the service name + as a prefix. Mirrors {option}`system.services` in NixOS. + ''; + type = types.attrsOf modularServiceConfiguration.serviceSubmodule; + default = { }; + visible = "shallow"; + }; + + config = { + assertions = lib.concatLists ( + lib.mapAttrsToList ( + name: cfg: portable-lib.getAssertions [ "home" "services" name ] cfg + ) config.home.services + ); + + warnings = lib.concatLists ( + lib.mapAttrsToList ( + name: cfg: portable-lib.getWarnings [ "home" "services" name ] cfg + ) config.home.services + ); + + systemd.user.services = concatMapAttrs ( + name: svc: makeUnits toHmIni "services" name svc + ) config.home.services; + + systemd.user.sockets = concatMapAttrs ( + name: svc: makeUnits toHmIniSocket "sockets" name svc + ) config.home.services; + + xdg.configFile = concatMapAttrs (name: svc: makeConfigFiles name svc) config.home.services; + }; +} diff --git a/modules/services-modular/service.nix b/modules/services-modular/service.nix new file mode 100644 index 000000000..a7ba01b46 --- /dev/null +++ b/modules/services-modular/service.nix @@ -0,0 +1,22 @@ +# Per-service extension module loaded into every `home.services` entry. +# +# Re-exports the nixpkgs portable systemd service module so HM modular +# services accept the same NixOS-style schema (`systemd.lib`, +# `systemd.mainExecStart`, `systemd.service`, `systemd.services`, +# `systemd.sockets`) as their NixOS counterparts. This is what lets +# upstream service modules (e.g. `pkgs..passthru.services.default`) +# drop in unchanged. Then overrides the primary unit's `wantedBy` default +# to `default.target`, since user units typically attach to that instead +# of `multi-user.target`. +{ lib, nixpkgsPath, ... }: +{ + imports = [ + (nixpkgsPath + "/nixos/modules/system/service/systemd/service.nix") + ]; + + # The empty key `""` is the modular service's *primary* unit (see + # `dashed` in `default.nix`). + config.systemd.services."" = { + wantedBy = lib.mkOverride 950 [ "default.target" ]; + }; +} diff --git a/tests/default.nix b/tests/default.nix index 2ae6d7d76..837d99911 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -212,6 +212,7 @@ import nmtSrc { ./modules/misc/qt ./modules/misc/xdg/linux.nix ./modules/misc/xsession + ./modules/services-modular ./modules/systemd ./modules/targets-linux # keep-sorted end diff --git a/tests/modules/services-modular/basic.nix b/tests/modules/services-modular/basic.nix new file mode 100644 index 000000000..b79f7afc0 --- /dev/null +++ b/tests/modules/services-modular/basic.nix @@ -0,0 +1,15 @@ +{ pkgs, ... }: +{ + home.services.demo = { + process.argv = [ + "${pkgs.coreutils}/bin/echo" + "hello" + ]; + }; + + nmt.script = '' + assertFileExists home-files/.config/systemd/user/demo.service + assertFileContains home-files/.config/systemd/user/demo.service '/bin/echo' + assertFileContains home-files/.config/systemd/user/demo.service 'WantedBy=default.target' + ''; +} diff --git a/tests/modules/services-modular/configdata.nix b/tests/modules/services-modular/configdata.nix new file mode 100644 index 000000000..2bb588298 --- /dev/null +++ b/tests/modules/services-modular/configdata.nix @@ -0,0 +1,15 @@ +{ pkgs, ... }: +{ + home.services.demo = { + process.argv = [ "${pkgs.coreutils}/bin/true" ]; + configData."config.toml".text = '' + [server] + port = 1234 + ''; + }; + + nmt.script = '' + assertFileExists home-files/.config/system-services/demo/config.toml + assertFileContains home-files/.config/system-services/demo/config.toml 'port = 1234' + ''; +} diff --git a/tests/modules/services-modular/default.nix b/tests/modules/services-modular/default.nix new file mode 100644 index 000000000..4c6b0f548 --- /dev/null +++ b/tests/modules/services-modular/default.nix @@ -0,0 +1,5 @@ +{ + home-services-basic = ./basic.nix; + home-services-configdata = ./configdata.nix; + home-services-ghostunnel = ./ghostunnel.nix; +} diff --git a/tests/modules/services-modular/ghostunnel.nix b/tests/modules/services-modular/ghostunnel.nix new file mode 100644 index 000000000..2465f8e35 --- /dev/null +++ b/tests/modules/services-modular/ghostunnel.nix @@ -0,0 +1,20 @@ +{ pkgs, ... }: +{ + home.services.tunnel = { + imports = [ pkgs.ghostunnel.passthru.services.default ]; + ghostunnel = { + listen = "127.0.0.1:8443"; + target = "127.0.0.1:8080"; + cert = "/run/secrets/cert.pem"; + key = "/run/secrets/key.pem"; + allowAll = true; + }; + }; + + nmt.script = '' + assertFileExists home-files/.config/systemd/user/tunnel.service + assertFileContains home-files/.config/systemd/user/tunnel.service '/bin/ghostunnel' + assertFileContains home-files/.config/systemd/user/tunnel.service 'allow-all' + assertFileContains home-files/.config/systemd/user/tunnel.service 'LoadCredential=cert:/run/secrets/cert.pem' + ''; +}