How to include extra files in a custom service?

Hi,

I’ve created a custom service:

{
  config,
  lib,
  pkgs,
  utils,
  ...
}:

with lib;

let
  cfg = config.services.gosk;

  # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
  escapeUnitName =
    name:
    lib.concatMapStrings (s: if lib.isList s then "-" else s) (
      builtins.split "[^a-zA-Z0-9_.\\-]+" name
    );
in
{
  options.services.gosk = {
    enable = mkEnableOption "Go Signal K service";

    package = lib.mkPackageOption pkgs "gosk" { };

    processors = mkOption {
      type = types.attrsOf (
        types.submodule (
          { name, ... }:
          {
            options = {
              enable = mkOption {
                type = types.bool;
                default = true;
                description = "Whether or not to enable this command.";
              };
              command = mkOption {
                type = types.separatedString " ";
                default = "";
                example = "write database mapped";
                description = "Command to add to this gosk command.";
              };
              subscribeURLs = mkOption {
                type = types.listOf types.str;
                default = [ ];
                example = [ "tcp://127.0.0.1:6000" ];
                description = "URLs to subscribe to.";
              };
              publishURL = mkOption {
                type = types.nullOr types.str;
                default = null;
                example = "tcp://127.0.0.1:6000";
                description = "URL to publish to.";
              };
              configFile = mkOption {
                type = types.nullOr types.path;
                default = null;
                example = ./gosk.yaml;
                description = "Configuration file in YAML format for this gosk command.";
              };
              after = mkOption {
                type = types.listOf utils.systemdUtils.lib.unitNameType;
                default = [ ];
                example = [ "postgresql" ];
                description = "Systemd services to wait for";
              };
              extraArgs = mkOption {
                type = types.listOf types.str;
                default = [ ];
                example = ''
                  [ "--pmport" "127.0.0.1:9203" ]
                '';
                description = "Extra arguments for the gosk processor.";
              };
              extraFiles = mkOption {
                type = types.listOf types.path;
                default = [ ];
                example = [ ./can.dbc ];
                description = "Extra files that are needed for this gosk processor.";
              };
            };
          }
        )
      );
      default = { };
      example = literalExpression ''
        {
          "map ais" = {
            command = "map";
            subscribeURLs = [ "tcp://127.0.0.1:6001" ];
            publishURL = "tcp://127.0.0.1:6101";
            configFile = ./gosk.yaml;
          };
        }
      '';
      description = "gosk commands to run.";
    };
  };

  config = mkIf cfg.enable {
    systemd.services = mapAttrs' (
      name: c:
      nameValuePair "gosk-${escapeUnitName name}" (mkMerge [
        {
          enable = c.enable;
          description = "gosk ${name}";
          wantedBy = [ "multi-user.target" ];
          after = c.after;
          serviceConfig = {
            ExecStart = toString (
              [ "${pkgs.gosk}/bin/gosk" ]
              ++ lib.optional (c.command != "") c.command
              ++ [ (concatMapStringsSep " " (url: "-s ${url}") c.subscribeURLs) ]
              ++ lib.optional (isString c.publishURL) "-p ${c.publishURL}"
              ++ lib.optional (c.configFile != null) "--config \${CREDENTIALS_DIRECTORY}/gosk.yaml"
              ++ c.extraArgs
            );

            LoadCredential = if c.configFile != null then "gosk.yaml:${c.configFile}" else "";
            DynamicUser = "yes";
          };
        }
      ])
    ) cfg.processors;
  };

  meta.maintainers = with maintainers; [ munnik ];
}

For each processor a sytemd.services.<name> is created. The service uses a YAML config file that I can specify with the option configFile. This config file can contain references to other (binary) files that are needed for the configuration. How can I include these other files in the service so that refer to them (maybe relative path) from the config file? I’ve created the option extraFiles but I don’t know how to include them.

I’m also fairly new to Nix, so please feel free to also make general comments on how to improve services.gosk.

Assuming gosk supports multiple --config parameters you could include the extraFiles in the ExecStart line:

++ lib.lists.flatten (map (extraFile: [ "--config" extraFile]) c.extraFiles)

Then when using the module you can include the extra files into the store and then set the extraFiles option.

config.services.gosk.extraFiles = let
  extraFile1 = builtins.path { path = ./extraFile1; name=extraFile1 };
  extraFile2 = builtins.path { path = ./extraFile1; name=extraFile2 };
in [ extraFile1 extraFile2 ];

Edit:
Upon re-reading your post I now understand that the extraFiles are referenced inside the configFile and should be available to at runtime. In that case you’d have to either replace the files referenced in the config with paths of the files in the nix store or, if local file references are possible, putting them into a shared directory in the store, e.g. using lib.symlinkJoin or lib.writeTextDir.

Upon re-reading your post I now understand that the extraFiles are referenced inside the configFile and should be available to at runtime. In that case you’d have to either replace the files referenced in the config with paths of the files in the nix store or, if local file references are possible, putting them into a shared directory in the store, e.g. using lib.symlinkJoin or lib.writeTextDir.

I’m not sure if I understand how to do that. My YAML config file looks like this:

---
protocol: "canbus"
dbcFile: "config/mapper/shaft.dbc"
mappings:
  - name: "RPM"
    origin: "PS_Shaft"
    expression: "value / 60"
    path: "propulsion.mainEnginePort.drive.revolutions"

In this example, the config file is referring to an external file config/mapper/shaft.dbc. Currently, the config file is generated with a Nix expression because sometimes it also needs to include sops secrets. Therefore, I have the option to use a placeholder/variable for the actual path of the config file.
So what I’m aiming for is that I can pass a list of files to extraFiles. This list points to files that are in the same directory or a subdirectory of the nix file that creates the services.gosk.processors. If I can predict the path of the file in the Nix store I can include that in the YAML config file.

I tried adding

  confinement.packages = [
    pkgs.stdenv.mkDerivation
    {
      name = "gosk-${escapeUnitName name}-extra-files";
      srcs = c.extraFiles;
      phases = [ "installPhase" ];
      installPhase = ''
        mkdir -p $out
        for srcFile in $srcs; do      
          cp $srcFile $out
        done
      '';
    }
  ];

to the systemd.services but it doesn’t seem to have any effect.

So config yaml potentially contains secrets provided by sops and from your module it looks like you store the config using systemd-creds, so it does not live in the nix store.
The extra file config/mapper/shaft.dbc does not contain any secrets should be referenced by the main config.

Because the decrypted yaml config lies in the CREDENTIALS_DIRECTORY local references to extraFiles are not easily possible. This means we have to link the path of the extraFiles into the configuration file. In my view this leaves templating the store location of the extraFiles into the config file.

How exactly do you create the systemd-credential for the yaml config? If you generate it with a nix expression before you store it you can’t really couple extraFiles to your service as they need to be templated into the config before it gets encrypted. Have you thought about only storing the actual secrets as systemd-credentials and referencing these in the config using something like BindReadOnlyPaths=%d/some/secretspath if it is possible.

So config yaml potentially contains secrets provided by sops and from your module it looks like you store the config using systemd-creds, so it does not live in the nix store.
The extra file config/mapper/shaft.dbc does not contain any secrets should be referenced by the main config.

Exactly

Because the decrypted yaml config lies in the CREDENTIALS_DIRECTORY local references to extraFiles are not easily possible. This means we have to link the path of the extraFiles into the configuration file. In my view this leaves templating the store location of the extraFiles into the config file.

Agree

How exactly do you create the systemd-credential for the yaml config? If you generate it with a nix expression before you store it you can’t really couple extraFiles to your service as they need to be templated into the config before it gets encrypted. Have you thought about only storing the actual secrets as systemd-credentials and referencing these in the config using something like BindReadOnlyPaths=%d/some/secretspath if it is possible.

I’m trying something like this, so the configFile is a template.

  services.gosk.processors."read mqtt" = {
    command = "read mqtt";
    publishURL = "tcp://127.0.0.1:6300";
    configFile = config.sops.templates."read_mqtt".path;
    extraFiles = [
      ./extraFiles/TelMA_1_shaft.dbc
      ./extraFiles/test.bin
    ];
  };
  sops.templates."read_mqtt" = {
    content = ''
      ---
      username: "${mosquittoUsername}"
      password: "${config.sops.placeholder.mosquittoPassword}"
      url: "mqtt://10.24.0.1:1883"
      interval: "10s"
      buffer_size: 1000
    '';
  };

I don’t have any references in the configFile (template) to the extraFiles yet, but then a configFile would look like the CAN bus example in the previous post.

Currently the service definition looks like this:

{
  config,
  lib,
  pkgs,
  utils,
  ...
}:

with lib;

let
  cfg = config.services.gosk;

  # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
  escapeUnitName =
    name:
    lib.concatMapStrings (s: if lib.isList s then "-" else s) (
      builtins.split "[^a-zA-Z0-9_.\\-]+" name
    );
in
{
  options.services.gosk = {
    enable = mkEnableOption "Go Signal K service";

    package = lib.mkPackageOption pkgs "gosk" { };

    processors = mkOption {
      type = types.attrsOf (
        types.submodule (
          { name, ... }:
          {
            options = {
              enable = mkOption {
                type = types.bool;
                default = true;
                description = "Whether or not to enable this command.";
              };
              command = mkOption {
                type = types.separatedString " ";
                default = "";
                example = "write database mapped";
                description = "Command to add to this gosk command.";
              };
              subscribeURLs = mkOption {
                type = types.listOf types.str;
                default = [ ];
                example = [ "tcp://127.0.0.1:6000" ];
                description = "URLs to subscribe to.";
              };
              publishURL = mkOption {
                type = types.nullOr types.str;
                default = null;
                example = "tcp://127.0.0.1:6000";
                description = "URL to publish to.";
              };
              configFile = mkOption {
                type = types.nullOr types.path;
                default = null;
                example = ./gosk.yaml;
                description = "Configuration file in YAML format for this gosk command.";
              };
              after = mkOption {
                type = types.listOf utils.systemdUtils.lib.unitNameType;
                default = [ ];
                example = [ "postgresql" ];
                description = "Systemd services to wait for";
              };
              extraArgs = mkOption {
                type = types.listOf types.str;
                default = [ ];
                example = ''
                  [ "--pmport" "127.0.0.1:9203" ]
                '';
                description = "Extra arguments for the gosk processor.";
              };
              extraFiles = mkOption {
                type = types.listOf types.path;
                default = [ ];
                example = [ ./can.dbc ];
                description = "Extra files that are needed for this gosk processor.";
              };
            };
          }
        )
      );
      default = { };
      example = literalExpression ''
        {
          "map ais" = {
            command = "map";
            subscribeURLs = [ "tcp://127.0.0.1:6001" ];
            publishURL = "tcp://127.0.0.1:6101";
            configFile = ./gosk.yaml;
          };
        }
      '';
      description = "gosk commands to run.";
    };
  };

  config = mkIf cfg.enable {
    environment.systemPackages = mapAttrsToList (
      name: c:
      pkgs.stdenv.mkDerivation {
        name = "gosk-${escapeUnitName name}-extra-files";
        srcs = c.extraFiles;
        phases = [ "installPhase" ];
        installPhase = ''
          mkdir -p $out
          for srcFile in $srcs; do      
            cp $srcFile $out
          done
        '';
      }
    ) cfg.processors;

    systemd.services = mapAttrs' (
      name: c:
      nameValuePair "gosk-${escapeUnitName name}" (mkMerge [
        {
          enable = c.enable;
          description = "gosk ${name}";
          wantedBy = [ "multi-user.target" ];
          after = c.after;
          # confinement.packages = [
          #   pkgs.stdenv.mkDerivation
          #   {
          #     name = "gosk-${escapeUnitName name}-extra-files";
          #     srcs = c.extraFiles;
          #     phases = [ "installPhase" ];
          #     installPhase = ''
          #       mkdir -p $out
          #       for srcFile in $srcs; do      
          #         cp $srcFile $out
          #       done
          #     '';
          #   }
          # ];
          serviceConfig = {
            ExecStart = toString (
              [ "${pkgs.gosk}/bin/gosk" ]
              ++ lib.optional (c.command != "") c.command
              ++ [ (concatMapStringsSep " " (url: "-s ${url}") c.subscribeURLs) ]
              ++ lib.optional (isString c.publishURL) "-p ${c.publishURL}"
              ++ lib.optional (c.configFile != null) "--config \${CREDENTIALS_DIRECTORY}/gosk.yaml"
              ++ c.extraArgs
            );

            LoadCredential = if c.configFile != null then "gosk.yaml:${c.configFile}" else "";
            DynamicUser = "yes";
          };
        }
      ])
    ) cfg.processors;
  };

  meta.maintainers = with maintainers; [ munnik ];
}

So I included the environment.systemPackages option. On my local computer I can find the generated files in the nix store under

$ eza -l /nix/store/pr6ml85fnfrpp0gnv2dxx6zmb2vq830m-gosk-read-mqtt-extra-files
Permissions Size User Group Date Modified    Name
.r--r--r--     0 root root  1970-01-01 02:00  f3q8jxqi4q7vjabzl1n52gdjmn43dk5g-test.bin
.r--r--r--  2.1k root root  1970-01-01 02:00  gjr9imycxla664hlkg1q2h4m5b8k4r6d-TelMA_1_shaft.dbc

but when I deploy this to a remote computer using deploy-rs that path is not in the nix store on the remote computer.

So now 3 questions remain:

  1. Is using the environment.systemPackages option the correct way to get the extraFiles in the store?
  2. How can I predict the path to a file in extraFiles, so I can include it in the configFile template?
  3. Why doesn’t deploy-rs deploy these file to a remote computer?