1{ config, options, lib, pkgs, ... }:
2
3with lib;
4let
5 cfg = config.virtualisation.oci-containers;
6 proxy_env = config.networking.proxy.envVars;
7
8 defaultBackend = options.virtualisation.oci-containers.backend.default;
9
10 containerOptions =
11 { ... }: {
12
13 options = {
14
15 image = mkOption {
16 type = with types; str;
17 description = "OCI image to run.";
18 example = "library/hello-world";
19 };
20
21 imageFile = mkOption {
22 type = with types; nullOr package;
23 default = null;
24 description = ''
25 Path to an image file to load before running the image. This can
26 be used to bypass pulling the image from the registry.
27
28 The `image` attribute must match the name and
29 tag of the image contained in this file, as they will be used to
30 run the container with that image. If they do not match, the
31 image will be pulled from the registry as usual.
32 '';
33 example = literalExpression "pkgs.dockerTools.buildImage {...};";
34 };
35
36 login = {
37
38 username = mkOption {
39 type = with types; nullOr str;
40 default = null;
41 description = "Username for login.";
42 };
43
44 passwordFile = mkOption {
45 type = with types; nullOr str;
46 default = null;
47 description = "Path to file containing password.";
48 example = "/etc/nixos/dockerhub-password.txt";
49 };
50
51 registry = mkOption {
52 type = with types; nullOr str;
53 default = null;
54 description = "Registry where to login to.";
55 example = "https://docker.pkg.github.com";
56 };
57
58 };
59
60 cmd = mkOption {
61 type = with types; listOf str;
62 default = [];
63 description = "Commandline arguments to pass to the image's entrypoint.";
64 example = literalExpression ''
65 ["--port=9000"]
66 '';
67 };
68
69 labels = mkOption {
70 type = with types; attrsOf str;
71 default = {};
72 description = "Labels to attach to the container at runtime.";
73 example = literalExpression ''
74 {
75 "traefik.https.routers.example.rule" = "Host(`example.container`)";
76 }
77 '';
78 };
79
80 entrypoint = mkOption {
81 type = with types; nullOr str;
82 description = "Override the default entrypoint of the image.";
83 default = null;
84 example = "/bin/my-app";
85 };
86
87 environment = mkOption {
88 type = with types; attrsOf str;
89 default = {};
90 description = "Environment variables to set for this container.";
91 example = literalExpression ''
92 {
93 DATABASE_HOST = "db.example.com";
94 DATABASE_PORT = "3306";
95 }
96 '';
97 };
98
99 environmentFiles = mkOption {
100 type = with types; listOf path;
101 default = [];
102 description = "Environment files for this container.";
103 example = literalExpression ''
104 [
105 /path/to/.env
106 /path/to/.env.secret
107 ]
108 '';
109 };
110
111 log-driver = mkOption {
112 type = types.str;
113 default = "journald";
114 description = ''
115 Logging driver for the container. The default of
116 `"journald"` means that the container's logs will be
117 handled as part of the systemd unit.
118
119 For more details and a full list of logging drivers, refer to respective backends documentation.
120
121 For Docker:
122 [Docker engine documentation](https://docs.docker.com/engine/reference/run/#logging-drivers---log-driver)
123
124 For Podman:
125 Refer to the docker-run(1) man page.
126 '';
127 };
128
129 ports = mkOption {
130 type = with types; listOf str;
131 default = [];
132 description = ''
133 Network ports to publish from the container to the outer host.
134
135 Valid formats:
136 - `<ip>:<hostPort>:<containerPort>`
137 - `<ip>::<containerPort>`
138 - `<hostPort>:<containerPort>`
139 - `<containerPort>`
140
141 Both `hostPort` and `containerPort` can be specified as a range of
142 ports. When specifying ranges for both, the number of container
143 ports in the range must match the number of host ports in the
144 range. Example: `1234-1236:1234-1236/tcp`
145
146 When specifying a range for `hostPort` only, the `containerPort`
147 must *not* be a range. In this case, the container port is published
148 somewhere within the specified `hostPort` range.
149 Example: `1234-1236:1234/tcp`
150
151 Refer to the
152 [Docker engine documentation](https://docs.docker.com/engine/reference/run/#expose-incoming-ports) for full details.
153 '';
154 example = literalExpression ''
155 [
156 "8080:9000"
157 ]
158 '';
159 };
160
161 user = mkOption {
162 type = with types; nullOr str;
163 default = null;
164 description = ''
165 Override the username or UID (and optionally groupname or GID) used
166 in the container.
167 '';
168 example = "nobody:nogroup";
169 };
170
171 volumes = mkOption {
172 type = with types; listOf str;
173 default = [];
174 description = ''
175 List of volumes to attach to this container.
176
177 Note that this is a list of `"src:dst"` strings to
178 allow for `src` to refer to `/nix/store` paths, which
179 would be difficult with an attribute set. There are
180 also a variety of mount options available as a third
181 field; please refer to the
182 [docker engine documentation](https://docs.docker.com/engine/reference/run/#volume-shared-filesystems) for details.
183 '';
184 example = literalExpression ''
185 [
186 "volume_name:/path/inside/container"
187 "/path/on/host:/path/inside/container"
188 ]
189 '';
190 };
191
192 workdir = mkOption {
193 type = with types; nullOr str;
194 default = null;
195 description = "Override the default working directory for the container.";
196 example = "/var/lib/hello_world";
197 };
198
199 dependsOn = mkOption {
200 type = with types; listOf str;
201 default = [];
202 description = ''
203 Define which other containers this one depends on. They will be added to both After and Requires for the unit.
204
205 Use the same name as the attribute under `virtualisation.oci-containers.containers`.
206 '';
207 example = literalExpression ''
208 virtualisation.oci-containers.containers = {
209 node1 = {};
210 node2 = {
211 dependsOn = [ "node1" ];
212 }
213 }
214 '';
215 };
216
217 hostname = mkOption {
218 type = with types; nullOr str;
219 default = null;
220 description = "The hostname of the container.";
221 example = "hello-world";
222 };
223
224 extraOptions = mkOption {
225 type = with types; listOf str;
226 default = [];
227 description = "Extra options for {command}`${defaultBackend} run`.";
228 example = literalExpression ''
229 ["--network=host"]
230 '';
231 };
232
233 autoStart = mkOption {
234 type = types.bool;
235 default = true;
236 description = ''
237 When enabled, the container is automatically started on boot.
238 If this option is set to false, the container has to be started on-demand via its service.
239 '';
240 };
241 };
242 };
243
244 isValidLogin = login: login.username != null && login.passwordFile != null && login.registry != null;
245
246 mkService = name: container: let
247 dependsOn = map (x: "${cfg.backend}-${x}.service") container.dependsOn;
248 escapedName = escapeShellArg name;
249 preStartScript = pkgs.writeShellApplication {
250 name = "pre-start";
251 runtimeInputs = [ ];
252 text = ''
253 ${cfg.backend} rm -f ${name} || true
254 ${optionalString (isValidLogin container.login) ''
255 # try logging in, if it fails, check if image exists locally
256 ${cfg.backend} login \
257 ${container.login.registry} \
258 --username ${container.login.username} \
259 --password-stdin < ${container.login.passwordFile} \
260 || ${cfg.backend} image inspect ${container.image} >/dev/null \
261 || { echo "image doesn't exist locally and login failed" >&2 ; exit 1; }
262 ''}
263 ${optionalString (container.imageFile != null) ''
264 ${cfg.backend} load -i ${container.imageFile}
265 ''}
266 ${optionalString (cfg.backend == "podman") ''
267 rm -f /run/podman-${escapedName}.ctr-id
268 ''}
269 '';
270 };
271 in {
272 wantedBy = [] ++ optional (container.autoStart) "multi-user.target";
273 wants = lib.optional (container.imageFile == null) "network-online.target";
274 after = lib.optionals (cfg.backend == "docker") [ "docker.service" "docker.socket" ]
275 # if imageFile is not set, the service needs the network to download the image from the registry
276 ++ lib.optionals (container.imageFile == null) [ "network-online.target" ]
277 ++ dependsOn;
278 requires = dependsOn;
279 environment = proxy_env;
280
281 path =
282 if cfg.backend == "docker" then [ config.virtualisation.docker.package ]
283 else if cfg.backend == "podman" then [ config.virtualisation.podman.package ]
284 else throw "Unhandled backend: ${cfg.backend}";
285
286 script = concatStringsSep " \\\n " ([
287 "exec ${cfg.backend} run"
288 "--rm"
289 "--name=${escapedName}"
290 "--log-driver=${container.log-driver}"
291 ] ++ optional (container.entrypoint != null)
292 "--entrypoint=${escapeShellArg container.entrypoint}"
293 ++ optional (container.hostname != null)
294 "--hostname=${escapeShellArg container.hostname}"
295 ++ lib.optionals (cfg.backend == "podman") [
296 "--cidfile=/run/podman-${escapedName}.ctr-id"
297 "--cgroups=no-conmon"
298 "--sdnotify=conmon"
299 "-d"
300 "--replace"
301 ] ++ (mapAttrsToList (k: v: "-e ${escapeShellArg k}=${escapeShellArg v}") container.environment)
302 ++ map (f: "--env-file ${escapeShellArg f}") container.environmentFiles
303 ++ map (p: "-p ${escapeShellArg p}") container.ports
304 ++ optional (container.user != null) "-u ${escapeShellArg container.user}"
305 ++ map (v: "-v ${escapeShellArg v}") container.volumes
306 ++ (mapAttrsToList (k: v: "-l ${escapeShellArg k}=${escapeShellArg v}") container.labels)
307 ++ optional (container.workdir != null) "-w ${escapeShellArg container.workdir}"
308 ++ map escapeShellArg container.extraOptions
309 ++ [container.image]
310 ++ map escapeShellArg container.cmd
311 );
312
313 preStop = if cfg.backend == "podman"
314 then "podman stop --ignore --cidfile=/run/podman-${escapedName}.ctr-id"
315 else "${cfg.backend} stop ${name} || true";
316
317 postStop = if cfg.backend == "podman"
318 then "podman rm -f --ignore --cidfile=/run/podman-${escapedName}.ctr-id"
319 else "${cfg.backend} rm -f ${name} || true";
320
321 serviceConfig = {
322 ### There is no generalized way of supporting `reload` for docker
323 ### containers. Some containers may respond well to SIGHUP sent to their
324 ### init process, but it is not guaranteed; some apps have other reload
325 ### mechanisms, some don't have a reload signal at all, and some docker
326 ### images just have broken signal handling. The best compromise in this
327 ### case is probably to leave ExecReload undefined, so `systemctl reload`
328 ### will at least result in an error instead of potentially undefined
329 ### behaviour.
330 ###
331 ### Advanced users can still override this part of the unit to implement
332 ### a custom reload handler, since the result of all this is a normal
333 ### systemd service from the perspective of the NixOS module system.
334 ###
335 # ExecReload = ...;
336 ###
337 ExecStartPre = [ "${preStartScript}/bin/pre-start" ];
338 TimeoutStartSec = 0;
339 TimeoutStopSec = 120;
340 Restart = "always";
341 } // optionalAttrs (cfg.backend == "podman") {
342 Environment="PODMAN_SYSTEMD_UNIT=podman-${name}.service";
343 Type="notify";
344 NotifyAccess="all";
345 };
346 };
347
348in {
349 imports = [
350 (
351 lib.mkChangedOptionModule
352 [ "docker-containers" ]
353 [ "virtualisation" "oci-containers" ]
354 (oldcfg: {
355 backend = "docker";
356 containers = lib.mapAttrs (n: v: builtins.removeAttrs (v // {
357 extraOptions = v.extraDockerOptions or [];
358 }) [ "extraDockerOptions" ]) oldcfg.docker-containers;
359 })
360 )
361 ];
362
363 options.virtualisation.oci-containers = {
364
365 backend = mkOption {
366 type = types.enum [ "podman" "docker" ];
367 default = if versionAtLeast config.system.stateVersion "22.05" then "podman" else "docker";
368 description = "The underlying Docker implementation to use.";
369 };
370
371 containers = mkOption {
372 default = {};
373 type = types.attrsOf (types.submodule containerOptions);
374 description = "OCI (Docker) containers to run as systemd services.";
375 };
376
377 };
378
379 config = lib.mkIf (cfg.containers != {}) (lib.mkMerge [
380 {
381 systemd.services = mapAttrs' (n: v: nameValuePair "${cfg.backend}-${n}" (mkService n v)) cfg.containers;
382 }
383 (lib.mkIf (cfg.backend == "podman") {
384 virtualisation.podman.enable = true;
385 })
386 (lib.mkIf (cfg.backend == "docker") {
387 virtualisation.docker.enable = true;
388 })
389 ]);
390
391}