A NixOS Module for Timed Jobs

I often want to run some script on my machine on a schedule. There are a few different tools commonly used to achieve this, cron being a popular choice. With systemd, this is conventionally achieved using a timer and service unit. The units do however require a wee-bit of boilerplate. With NixOS, we can define a module to avoid that boilerplate.

Without the module, the configuration for an example task looks like this:

systemd.services.chaos = {
  serviceConfig.Type = "oneshot";
  serviceConfig.User = "root";
  script = ''
    kill -9 $(ps -ef | awk '{print $2}' | shuf -n 1)
  '';
  path = [ ];
};

systemd.services.timers.chaos = {
  wantedBy = [ "timers.target" ];
  partOf = [ "chaos.service" ];
  timerConfig.OnCalendar = "*-*-* *:00:00";
};

At a minimum, I want to specify only two things:

And let’s add options for these commonly needed configurations:

The specification of a nixos module for tasks with the above configuration options is as follows:

let
  taskSpec = types.submodule {
    options = {
      script = mkOption {
        type = types.lines;
        description = "The script to execute.";
      };
      onCalendar = mkOption {
        type = types.str;
        description = "The systemd time specification.";
      };
      user = mkOption {
        type = types.str;
        description = "The user to run the service as.";
        default = "root";
      };
      path = mkOption {
        type = types.listOf types.package;
        default = [ ];
        description = "List of pkgs in the path for the service script.";
      };
    };
  };
in {
  options.tasks = mkOption {
    type = types.attrsOf taskSpec;
    description = { };
    default = { };
  };
}

To specify the systemd service and timer units, we’ll use the following utility function:

mkService = name:
  { script, path, user, ... }: {
    serviceConfig.Type = "oneshot";
    serviceConfig.User = user;
    script = script;
    path = path;
  };

mkTimer = name:
  { onCalendar, ... }: {
    wantedBy = [ "timers.target" ];
    partOf = [ "${name}.service" ];
    timerConfig.OnCalendar = onCalendar;
  };

With these utility functions, we can map over the configured tasks to set timer and service units:

let
  cfg = config.tasks;
in {

  config.systemd.services = let
    units = mapAttrs' (name: info: {
      name = "${name}";
      value = (mkService name info);
    }) cfg;
  in units;

  config.systemd.timers = let
    timers = mapAttrs' (name: info: {
      name = "${name}";
      value = (mkTimer name info);
    }) cfg;
  in timers;
}

The full module looks like this:

{ lib, config, pkgs, ... }:
with lib;
let
  cfg = config.tasks;

  taskSpec = types.submodule {
    options = {
      script = mkOption {
        type = types.lines;
        description = "The script to execute.";
      };
      onCalendar = mkOption {
        type = types.str;
        description = "The systemd time specification.";
      };
      user = mkOption {
        type = types.str;
        description = "The user to run the service as.";
        default = "root";
      };
      path = mkOption {
        type = types.listOf types.package;
        default = [ ];
        description = "List of pkgs in the path for the service script.";
      };
    };
  };

  mkService = name:
    { script, path, user, ... }: {
      serviceConfig.Type = "oneshot";
      serviceConfig.User = user;
      script = script;
      path = path;
    };

  mkTimer = name:
    { onCalendar, ... }: {
      wantedBy = [ "timers.target" ];
      partOf = [ "${name}.service" ];
      timerConfig.OnCalendar = onCalendar;
    };

in {
  options.tasks = mkOption {
    type = types.attrsOf taskSpec;
    description = { };
    default = { };
  };

  config.systemd.services = let
    units = mapAttrs' (name: info: {
      name = "${name}";
      value = (mkService name info);
    }) cfg;
  in units;

  config.systemd.timers = let
    timers = mapAttrs' (name: info: {
      name = "${name}";
      value = (mkTimer name info);
    }) cfg;
  in timers;
}

Now we can specify a task to run every hour as follows:

tasks.chaos = {
  onCalendar = "*-*-* *:00:00";
  script = ''
    kill -9 $(ps -ef | awk '{print $2}' | shuf -n 1)
  '';
};

If we want to further configure the systemd units, beyond what is configured by our module, we can do so by configuring the unit directly.

systemd.timers.chaos.timerConfig.Persistent = true;
systemd.services.chaos.serviceConfig.Nice = -20;