1{ config, lib, options, pkgs, buildEnv, ... }:
2
3with lib;
4
5let
6 defaultUser = "healthchecks";
7 cfg = config.services.healthchecks;
8 opt = options.services.healthchecks;
9 pkg = cfg.package;
10 boolToPython = b: if b then "True" else "False";
11 environment = {
12 PYTHONPATH = pkg.pythonPath;
13 STATIC_ROOT = cfg.dataDir + "/static";
14 } // cfg.settings;
15
16 environmentFile = pkgs.writeText "healthchecks-environment" (lib.generators.toKeyValue { } environment);
17
18 healthchecksManageScript = pkgs.writeShellScriptBin "healthchecks-manage" ''
19 sudo=exec
20 if [[ "$USER" != "${cfg.user}" ]]; then
21 sudo='exec /run/wrappers/bin/sudo -u ${cfg.user} --preserve-env --preserve-env=PYTHONPATH'
22 fi
23 export $(cat ${environmentFile} | xargs)
24 $sudo ${pkg}/opt/healthchecks/manage.py "$@"
25 '';
26in
27{
28 options.services.healthchecks = {
29 enable = mkEnableOption "healthchecks" // {
30 description = ''
31 Enable healthchecks.
32 It is expected to be run behind a HTTP reverse proxy.
33 '';
34 };
35
36 package = mkPackageOption pkgs "healthchecks" { };
37
38 user = mkOption {
39 default = defaultUser;
40 type = types.str;
41 description = ''
42 User account under which healthchecks runs.
43
44 ::: {.note}
45 If left as the default value this user will automatically be created
46 on system activation, otherwise you are responsible for
47 ensuring the user exists before the healthchecks service starts.
48 :::
49 '';
50 };
51
52 group = mkOption {
53 default = defaultUser;
54 type = types.str;
55 description = ''
56 Group account under which healthchecks runs.
57
58 ::: {.note}
59 If left as the default value this group will automatically be created
60 on system activation, otherwise you are responsible for
61 ensuring the group exists before the healthchecks service starts.
62 :::
63 '';
64 };
65
66 listenAddress = mkOption {
67 type = types.str;
68 default = "localhost";
69 description = "Address the server will listen on.";
70 };
71
72 port = mkOption {
73 type = types.port;
74 default = 8000;
75 description = "Port the server will listen on.";
76 };
77
78 dataDir = mkOption {
79 type = types.str;
80 default = "/var/lib/healthchecks";
81 description = ''
82 The directory used to store all data for healthchecks.
83
84 ::: {.note}
85 If left as the default value this directory will automatically be created before
86 the healthchecks server starts, otherwise you are responsible for ensuring the
87 directory exists with appropriate ownership and permissions.
88 :::
89 '';
90 };
91
92 settings = lib.mkOption {
93 description = ''
94 Environment variables which are read by healthchecks `(local)_settings.py`.
95
96 Settings which are explicitly covered in options below, are type-checked and/or transformed
97 before added to the environment, everything else is passed as a string.
98
99 See <https://healthchecks.io/docs/self_hosted_configuration/>
100 for a full documentation of settings.
101
102 We add additional variables to this list inside the packages `local_settings.py.`
103 - `STATIC_ROOT` to set a state directory for dynamically generated static files.
104 - `SECRET_KEY_FILE` to read `SECRET_KEY` from a file at runtime and keep it out of
105 /nix/store.
106 - `_FILE` variants for several values that hold sensitive information in
107 [Healthchecks configuration](https://healthchecks.io/docs/self_hosted_configuration/) so
108 that they also can be read from a file and kept out of /nix/store. To see which values
109 have support for a `_FILE` variant, run:
110 - `nix-instantiate --eval --expr '(import <nixpkgs> {}).healthchecks.secrets'`
111 - or `nix eval 'nixpkgs#healthchecks.secrets'` if the flake support has been enabled.
112 '';
113 type = types.submodule (settings: {
114 freeformType = types.attrsOf types.str;
115 options = {
116 ALLOWED_HOSTS = lib.mkOption {
117 type = types.listOf types.str;
118 default = [ "*" ];
119 description = "The host/domain names that this site can serve.";
120 apply = lib.concatStringsSep ",";
121 };
122
123 SECRET_KEY_FILE = mkOption {
124 type = types.path;
125 description = "Path to a file containing the secret key.";
126 };
127
128 DEBUG = mkOption {
129 type = types.bool;
130 default = false;
131 description = "Enable debug mode.";
132 apply = boolToPython;
133 };
134
135 REGISTRATION_OPEN = mkOption {
136 type = types.bool;
137 default = false;
138 description = ''
139 A boolean that controls whether site visitors can create new accounts.
140 Set it to false if you are setting up a private Healthchecks instance,
141 but it needs to be publicly accessible (so, for example, your cloud
142 services can send pings to it).
143 If you close new user registration, you can still selectively invite
144 users to your team account.
145 '';
146 apply = boolToPython;
147 };
148
149 DB = mkOption {
150 type = types.enum [ "sqlite" "postgres" "mysql" ];
151 default = "sqlite";
152 description = "Database engine to use.";
153 };
154
155 DB_NAME = mkOption {
156 type = types.str;
157 default =
158 if settings.config.DB == "sqlite"
159 then "${cfg.dataDir}/healthchecks.sqlite"
160 else "hc";
161 defaultText = lib.literalExpression ''
162 if config.${settings.options.DB} == "sqlite"
163 then "''${config.${opt.dataDir}}/healthchecks.sqlite"
164 else "hc"
165 '';
166 description = "Database name.";
167 };
168 };
169 });
170 };
171 };
172
173 config = mkIf cfg.enable {
174 environment.systemPackages = [ healthchecksManageScript ];
175
176 systemd.targets.healthchecks = {
177 description = "Target for all Healthchecks services";
178 wantedBy = [ "multi-user.target" ];
179 wants = [ "network-online.target" ];
180 after = [ "network.target" "network-online.target" ];
181 };
182
183 systemd.services =
184 let
185 commonConfig = {
186 WorkingDirectory = cfg.dataDir;
187 User = cfg.user;
188 Group = cfg.group;
189 EnvironmentFile = [ environmentFile ];
190 StateDirectory = mkIf (cfg.dataDir == "/var/lib/healthchecks") "healthchecks";
191 StateDirectoryMode = mkIf (cfg.dataDir == "/var/lib/healthchecks") "0750";
192 };
193 in
194 {
195 healthchecks-migration = {
196 description = "Healthchecks migrations";
197 wantedBy = [ "healthchecks.target" ];
198
199 serviceConfig = commonConfig // {
200 Restart = "on-failure";
201 Type = "oneshot";
202 ExecStart = ''
203 ${pkg}/opt/healthchecks/manage.py migrate
204 '';
205 };
206 };
207
208 healthchecks = {
209 description = "Healthchecks WSGI Service";
210 wantedBy = [ "healthchecks.target" ];
211 after = [ "healthchecks-migration.service" ];
212
213 preStart = ''
214 ${pkg}/opt/healthchecks/manage.py collectstatic --no-input
215 ${pkg}/opt/healthchecks/manage.py remove_stale_contenttypes --no-input
216 '' + lib.optionalString (cfg.settings.DEBUG != "True") "${pkg}/opt/healthchecks/manage.py compress";
217
218 serviceConfig = commonConfig // {
219 Restart = "always";
220 ExecStart = ''
221 ${pkgs.python3Packages.gunicorn}/bin/gunicorn hc.wsgi \
222 --bind ${cfg.listenAddress}:${toString cfg.port} \
223 --pythonpath ${pkg}/opt/healthchecks
224 '';
225 };
226 };
227
228 healthchecks-sendalerts = {
229 description = "Healthchecks Alert Service";
230 wantedBy = [ "healthchecks.target" ];
231 after = [ "healthchecks.service" ];
232
233 serviceConfig = commonConfig // {
234 Restart = "always";
235 ExecStart = ''
236 ${pkg}/opt/healthchecks/manage.py sendalerts
237 '';
238 };
239 };
240
241 healthchecks-sendreports = {
242 description = "Healthchecks Reporting Service";
243 wantedBy = [ "healthchecks.target" ];
244 after = [ "healthchecks.service" ];
245
246 serviceConfig = commonConfig // {
247 Restart = "always";
248 ExecStart = ''
249 ${pkg}/opt/healthchecks/manage.py sendreports --loop
250 '';
251 };
252 };
253 };
254
255 users.users = optionalAttrs (cfg.user == defaultUser) {
256 ${defaultUser} =
257 {
258 description = "healthchecks service owner";
259 isSystemUser = true;
260 group = defaultUser;
261 };
262 };
263
264 users.groups = optionalAttrs (cfg.user == defaultUser) {
265 ${defaultUser} =
266 {
267 members = [ defaultUser ];
268 };
269 };
270 };
271}