Automating home manager setup

I would like to automate my home-manager setup process.

I would like to keep my home manager and my system config repo separate, which is why I am not using home manager as a module. Instead I would like to automatically clone the home manager repository and rebuild the home config when the system configuration is first activated.

Should I just use system.userActivationScripts or is there a nicer way to do this?

1 Like

I personally originally kept mine separated and included it via git submodules. At the time, I simply declared home-manager as an import with a fetchGit: https://github.com/houstdav000/nixos-configuration/blob/9fbbdae1bb35d54e4e5d61f89fcb2160b86d9217/users/david/david.nix. Since then though, I’m using Nix flakes, which would let you build them entirely separated, with the home manager flake and home-manger as inputs to the system flake.

activationScripts are discouraged for good reason - you’ll end up reimplementing dependency management. I’d say the way to go for stateful parts is to use a systemd service. For my setup with the exact same goal in mind, I have a little module that lets me configure for a given user where to fetchGit the home-manager environment and which executable to run for bootstrapping. It’s implemented as a service that depends on existence of relevant files.

That way the user environment is pretty much opaque to the system and (with some modularity applied) portable between NixOS, other Linux distributions and Darwin.

If you’re interested I’ll dig out the relevant code.

Even if your home-manager repo is separate, I’m fairly certain you can still just use the home-manager nixos module. Since the contents in home-manager.users.* is the same as what goes in a home.nix you can just import the home-manager repo into your system repo. If you use flakes, that can be done by adding your home-manager repo as an input for your system repo. Or if not, then you can just use a fetchTarball call or something similar to pull your hm repo and put it into your users home-manager config. Git submodules is also another option as was suggested. But either way, the HM nixos module would do exactly this for you and doesn’t require the HM code to be in the same place as the system code.

1 Like

@fricklerhandwerk I’d be interested in that. So you basically have a systemd service that checks for the existence of i.e. home.nix and then clones a git repo and runs home-manager when it is not available, right?

My motivation behind this is that I eventually wanna move to an ldap authentication system which would clone the relevant repository from an ldap field when the user first logs in.

2 Likes

This is exactly how it is in use on my machines now. It has accumulated quite a bit of debugging by now, so excuse the amount of code.

That setup does not do exactly what you describe, though. Here the user environment is set up before first login, because in my case otherwise you’d log into basically nothing. Therefore the first boot may get lengthy (depending on what you need to get installed) and will have no feedback. One could split this up into stuff necessary for login and all the other stuff to run within the user environment after login (in a similar fashion, but managed entirely by home-manager), but this would obviously make it even more intricate.

# home-config.nix
{ config, lib, pkgs, utils,  ... }:
with pkgs;
let
  users = config.users.users;
  # submodule for per-user settings for remote repository
  home-config = { lib, ... }: {
    options.config = with lib; mkOption {
      description = "user's home configuration repository";
      default = null;
      type = with types; nullOr (submodule ({config, ...}: {
        options = {
          fetch = mkOption {
            type = str;
            description = "fetch URL for git repository with user configuration";
          };
          push = mkOption {
            type = str;
            default = config.fetch;
            description = "push URL for git repository, if it differs";
          };
          branch = mkOption {
            type = str;
            default = "master";
            description = "branch in repository to clone";
          };
          path = mkOption {
            type = str;
            default = ".config";
            description = "clone path for configuration repository, relative to user's $HOME";
          };
          install = mkOption {
            type = str;
            default = "./install";
            description = "installation command";
          };
        };
      }));
    };
  };
in
{
  # extend NixOS user configuration module
  options = with lib; with types; {
    users.users = mkOption {
      type = attrsOf (submodule home-config);
    };
  };
  config = with builtins; with lib;
    let
      check = user: "home-config-check-${utils.escapeSystemdPath user.name}";
      initialise = user: "home-config-initialise-${utils.escapeSystemdPath user.name}";
      service = unit: "${unit}.service";
    in {
    # set up user configuration *before* first login
    systemd.services = mkMerge (map (user: mkIf (user.isNormalUser && user.config != null) {
      # skip initialisation early on boot, before waiting for the network, if
      # git repository appears to be in place.
      "${check user}" = {
        description = "check home configuration for ${user.name}";
        wantedBy = [ "multi-user.target" ];
        unitConfig = {
          # path must be absolute!
          # <https://www.freedesktop.org/software/systemd/man/systemd.unit.html#ConditionArchitecture=>
          ConditionPathExists = "!${user.home}/${user.config.path}/.git";
        };
        serviceConfig = {
          User = user.name;
          SyslogIdentifier = check user;
          Type = "oneshot";
          RemainAfterExit = true;
          ExecStart = "${coreutils}/bin/true";
        };
      };
      "${initialise user}" = {
        description = "initialise home-manager configuration for ${user.name}";
        # do not allow login before setup is finished. after first boot the
        # process takes a long time, and the user would log into a broken
        # environment.
        # let display manager wait in graphical setups.
        wantedBy = [ "multi-user.target" ];
        before = [ "systemd-user-sessions.service" ] ++ optional config.services.xserver.enable "display-manager.service";
        # `nix-daemon` and `network-online` are required under the assumption
        # that installation performs `nix` operations and those usually need to
        # fetch remote data
        after = [ (service (check user)) "nix-daemon.socket" "network-online.target" ];
        bindsTo = [ (service (check user)) "nix-daemon.socket" "network-online.target" ];
        path = [ git nix ];
        environment = {
          NIX_PATH = builtins.concatStringsSep ":" config.nix.nixPath;
        };
        serviceConfig = {
          User = user.name;
          Type = "oneshot";
          SyslogIdentifier = initialise user;
          ExecStart = let
            script = writeShellScriptBin (initialise user) ''
              set -e
              mkdir -p ${user.home}/${user.config.path}
              cd ${user.home}/${user.config.path}
              git init
              git remote add origin ${user.config.fetch}
              git remote set-url origin --push ${user.config.push}
              git fetch
              git checkout ${user.config.branch} --force
              ${user.config.install}
            ''; in "${script}/bin/${(initialise user)}";
          };
        };
      }) (attrValues config.users.users));
  };
}

This is how it’s used:

# configuration.nix
{ config, ... }:
{
  imports = [
    ../modules/home-config.nix
  ];
# ...
  users.users.fricklerhandwerk = {
    isNormalUser = true;
    shell = pkgs.fish;
    config = {
      fetch = https://github.com/fricklerhandwerk/.config;
      push = "git@github.com:fricklerhandwerk/.config";
      install = "./install ${config.networking.hostName}";
    };
  };
}

Installation amounts to this (stripped the command line handling fluff for brevity here):

# ~/.config/install
nix-shell -E \
"with import <nixpkgs> {}; mkShell { buildInputs = [ (callPackage ./modules/home-manager.nix {}) ]; }" \
--run "home-manager -f $1 switch"

The trick is that the same home-manager is part of the instantiated environment. There may be a more elegant trick to wire this up, but this is what I came up with back then, and it works:

# ~/.config/modules/machine.nix
# machine-specific wrapper to `home-manager`, such that `home-manager switch`
# automatically uses the correct configuration files
{ config, pkgs, lib, ... }:
with pkgs;
with lib;
let
  home-manager = writeShellScriptBin "home-manager" ''
    exec ${callPackage ../home-manager.nix {}}/bin/home-manager -f ${toString config.machine} $@
  '';
in
{
  options = {
    machine = mkOption {
      type = types.path;
      description = "machine to use with `home-manager` wrapper";
    };
  };
  config = {
    home.packages = [ home-manager ];
  };
}
# ~/.config/modules/home-manager.nix
{ pkgs ? import <nixpkgs> {} }:
with pkgs;
let
  version = "20.09";
  src = builtins.fetchGit {
    name = "home-manager-${version}";
    url = https://github.com/rycee/home-manager;
    ref = "release-${version}";
  };
in
callPackage "${src}/home-manager" { path = "${src}"; }

Here is a sample from the environment:

# ~/.config/machines/mymachine/default.nix
{ pkgs, ... }:
{
  imports = [
    ./machine.nix
    # ...
  ];

  # pick up location of this machine's configuration
  machine = ./.;

  home.packages = with pkgs; [
    # ...
  ];
}

I’d love to just point you to a GitHub repository for more details on the NixOS side, but I now have the same problem as the people where I originally found conceptual inspiration: there is so much private stuff in there that I never find time to separate out the educationally relevant components for public display.

5 Likes

Thank you so much for posting this! Its really great and almost everything I was looking for.
I didn’t really get around to really looking at it until now since I was busy with university stuff.

There is just one thing I don’t quite get:
Why fetch the git repo using a systemd service that runs at startup instead of using system.userActivationScripts?
It feels like its perfectly suited towards that purpose. Instead of running the home setup when the system is started it would run when the system is being rebuilt, which is also the only time any information inside the users.user.<name>.config field could ever change, right?
The check for the repo being already cloned could still be used there, but it would not run on every startup.

Also, the users variable in line 5 of the first snippet is unused, right?

Btw, I found an sssd module which runs a script every time a user logs in. So if I ever get around to doing the ldap stuff I would probably use that to clone the git repo and install the homedir in addition to running it at startup for hard-coded users.

I can’t remember, it was 2 years ago, but I tried that first and for some reason it did not work or was not suitable for what I originally had in mind. Also I was so new to NixOS that I most probably was too confused to fix all issues I encountered and was cargo-culting a lot of what I randomly picked up reading… one of that was to avoid activationScripts.

Looking at it today, what you say totally makes sense. Once I get to repair a broken machine I will try this out.

Not sure which one, but possibly some editing artifact.

You mean for imperatively created users? Sure, sounds legit. I never got back to even create users at runtime any more, but my use case may be restricted.

Turns out activationScripts still runs at startup and since nixos-rebuild restarts the service anyways using a systemd service is still the better idea since it gives more control.

I am trying to adapt the system to work in an OS we use internally in our computer club, which uses LDAP to manage user accounts. We now have a field in LDAP which allows users to add a git url via our account management system and it clones that directory and runs install when the user loggs in for the first time.


There is one last thing that doesn’t quite work and that is installing home-manager itself. Whenever I install it using programs.home-manager.enable or adding the package to home.nix it works after running the installer script, until I reboot, after which the package does not exist anymore.

error: file 'home-manager/home-manager/home-manager.nix' was not found in the Nix search path (add it using $NIX_PATH or -I)
error: file 'home-manager/home-manager/home-manager.nix' was not found in the Nix search path (add it using $NIX_PATH or -I)
/home/mbust/.nix-profile/bin/home-manager: line 155: /tmp/home-manager-build.sQFFpliAnP/news-info.sh: No such file or directory

Is there any special step you take to install home-manager from the installscript?
Or do you perhaps install it system wide?

Huh, weird. No, the installer script works as shown. The error message hints at that the version installed (there is one installed!) points to some wrong path, one of them in /tmp.

tell me if I am wrong but git fetch here is giving a json[^1] object for the contents of nix files in clone?
[^1]: git - Retrieve a single file from a repository - Stack Overflow

Sorry, I don’t understand the question, could you rephrase it please?

Tl;dr

  1. what’s the data type of input (from git fetch) to the corresponding config ?
  2. how d runcommand or pkgs.writeShellScriptBin / runcommand or just a /bin/sh on top of the install file be worse or better than nix-shell -E?
    Long
    In my limited exposure, I ve seen, list of nix config files usually imported (with path) at any level or read with builtins.readfile/readdir etcpath or something , equality usually comes for value of a variable or extension of a pkgs/service/program with options/types defined in modules , which can be bool,str or (list of options,types?) etc …
    Here I see type of fetch is str (for git url/ path of remote data), which is being fetched with a git command , so what’s the data type of input to config?

I’m still not sure I really understand the first question.

I can follow so far:

This fetch is a module option that is declared to have the type str, and is supposed to hold a URL for use by Git in the corresponding service definition:

But I can’t follow which “corresponding config” or which “input” you mean here.

But this value is not used for import. What may be confusing in this setup is that the Home Manager configuration is decoupled from the NixOS configuration, by design, and their connection is essentially by that custom systemd service fetching the repository. To be clear, I made this stuff years ago when my own understanding of the bigger picture was still limited, and while I think the general idea is about right, I’d probably do it very differently today.

The second question is therefore hard to answer for me, I’d have to look into the code very closely. I suppose the idea of the nix-shell -E was to have a standalone script. With a runCommand you’d have to build the NixOS configuration to have it available. But I may be misremembering, and probably it doesn’t matter.

TBF I should have just mentioned the whole line, as there s no linum here.

fetch = https://github.com/fricklerhandwerk/.config (1); as input of users.users.fricklerhandwerk .config (2)
I guess , I find it less intuitive to see atm while (1) need be toplevel , (2) seems to be systemd.level ( service which activated the HM here), so if I see it right git fetch gets me a json string of data (with types?) and there is some in build json2nix like thing, when its read as config ?
As, while I ve not dug nix2json deep , but the very reason it should be there , is because the difference between the two matters to the system.
In another long shot, it’s hard for me to realize that git fetch command might actually be giving data in “nix” and not json or equivalent.
yes niv gives source.nix & sources.json , but they are different files and niv shouldn’t be same git fetch

git fetch literally puts a Git repo into the configured location, that’s the purpose of that systemd service. No JSON or reading anything into Nix is involved here.

Ah, yes, I guess , I was embarrassingly lost to see , the regular text processing here. My bad , I guess .