1{
2 config,
3 options,
4 lib,
5 pkgs,
6 ...
7}:
8
9with lib;
10let
11 cfg = config.virtualisation.oci-containers;
12 proxy_env = config.networking.proxy.envVars;
13
14 defaultBackend = options.virtualisation.oci-containers.backend.default;
15
16 containerOptions =
17 { name, ... }:
18 {
19
20 config = {
21 podman = mkIf (cfg.backend == "podman") { };
22 };
23
24 options = {
25
26 image = mkOption {
27 type = with types; str;
28 description = "OCI image to run.";
29 example = "library/hello-world";
30 };
31
32 imageFile = mkOption {
33 type = with types; nullOr package;
34 default = null;
35 description = ''
36 Path to an image file to load before running the image. This can
37 be used to bypass pulling the image from the registry.
38
39 The `image` attribute must match the name and
40 tag of the image contained in this file, as they will be used to
41 run the container with that image. If they do not match, the
42 image will be pulled from the registry as usual.
43 '';
44 example = literalExpression "pkgs.dockerTools.buildImage {...};";
45 };
46
47 imageStream = mkOption {
48 type = with types; nullOr package;
49 default = null;
50 description = ''
51 Path to a script that streams the desired image on standard output.
52
53 This option is mainly intended for use with
54 `pkgs.dockerTools.streamLayeredImage` so that the intermediate
55 image archive does not need to be stored in the Nix store. For
56 larger images this optimization can significantly reduce Nix store
57 churn compared to using the `imageFile` option, because you don't
58 have to store a new copy of the image archive in the Nix store
59 every time you change the image. Instead, if you stream the image
60 then you only need to build and store the layers that differ from
61 the previous image.
62 '';
63 example = literalExpression "pkgs.dockerTools.streamLayeredImage {...};";
64 };
65
66 serviceName = mkOption {
67 type = types.str;
68 default = "${cfg.backend}-${name}";
69 defaultText = "<backend>-<name>";
70 description = "Systemd service name that manages the container";
71 };
72
73 login = {
74
75 username = mkOption {
76 type = with types; nullOr str;
77 default = null;
78 description = "Username for login.";
79 };
80
81 passwordFile = mkOption {
82 type = with types; nullOr str;
83 default = null;
84 description = "Path to file containing password.";
85 example = "/etc/nixos/dockerhub-password.txt";
86 };
87
88 registry = mkOption {
89 type = with types; nullOr str;
90 default = null;
91 description = "Registry where to login to.";
92 example = "https://docker.pkg.github.com";
93 };
94
95 };
96
97 cmd = mkOption {
98 type = with types; listOf str;
99 default = [ ];
100 description = "Commandline arguments to pass to the image's entrypoint.";
101 example = literalExpression ''
102 ["--port=9000"]
103 '';
104 };
105
106 labels = mkOption {
107 type = with types; attrsOf str;
108 default = { };
109 description = "Labels to attach to the container at runtime.";
110 example = literalExpression ''
111 {
112 "traefik.https.routers.example.rule" = "Host(`example.container`)";
113 }
114 '';
115 };
116
117 entrypoint = mkOption {
118 type = with types; nullOr str;
119 description = "Override the default entrypoint of the image.";
120 default = null;
121 example = "/bin/my-app";
122 };
123
124 environment = mkOption {
125 type = with types; attrsOf str;
126 default = { };
127 description = "Environment variables to set for this container.";
128 example = literalExpression ''
129 {
130 DATABASE_HOST = "db.example.com";
131 DATABASE_PORT = "3306";
132 }
133 '';
134 };
135
136 environmentFiles = mkOption {
137 type = with types; listOf path;
138 default = [ ];
139 description = "Environment files for this container.";
140 example = literalExpression ''
141 [
142 /path/to/.env
143 /path/to/.env.secret
144 ]
145 '';
146 };
147
148 log-driver = mkOption {
149 type = types.str;
150 default = "journald";
151 description = ''
152 Logging driver for the container. The default of
153 `"journald"` means that the container's logs will be
154 handled as part of the systemd unit.
155
156 For more details and a full list of logging drivers, refer to respective backends documentation.
157
158 For Docker:
159 [Docker engine documentation](https://docs.docker.com/engine/logging/configure/)
160
161 For Podman:
162 Refer to the {manpage}`docker-run(1)` man page.
163 '';
164 };
165
166 ports = mkOption {
167 type = with types; listOf str;
168 default = [ ];
169 description = ''
170 Network ports to publish from the container to the outer host.
171
172 Valid formats:
173 - `<ip>:<hostPort>:<containerPort>`
174 - `<ip>::<containerPort>`
175 - `<hostPort>:<containerPort>`
176 - `<containerPort>`
177
178 Both `hostPort` and `containerPort` can be specified as a range of
179 ports. When specifying ranges for both, the number of container
180 ports in the range must match the number of host ports in the
181 range. Example: `1234-1236:1234-1236/tcp`
182
183 When specifying a range for `hostPort` only, the `containerPort`
184 must *not* be a range. In this case, the container port is published
185 somewhere within the specified `hostPort` range.
186 Example: `1234-1236:1234/tcp`
187
188 Publishing a port bypasses the NixOS firewall. If the port is not
189 supposed to be shared on the network, make sure to publish the
190 port to localhost.
191 Example: `127.0.0.1:1234:1234`
192
193 Refer to the
194 [Docker engine documentation](https://docs.docker.com/engine/network/#published-ports) for full details.
195 '';
196 example = literalExpression ''
197 [
198 "127.0.0.1:8080:9000"
199 ]
200 '';
201 };
202
203 user = mkOption {
204 type = with types; nullOr str;
205 default = null;
206 description = ''
207 Override the username or UID (and optionally groupname or GID) used
208 in the container.
209 '';
210 example = "nobody:nogroup";
211 };
212
213 volumes = mkOption {
214 type = with types; listOf str;
215 default = [ ];
216 description = ''
217 List of volumes to attach to this container.
218
219 Note that this is a list of `"src:dst"` strings to
220 allow for `src` to refer to `/nix/store` paths, which
221 would be difficult with an attribute set. There are
222 also a variety of mount options available as a third
223 field; please refer to the
224 [docker engine documentation](https://docs.docker.com/engine/storage/volumes/) for details.
225 '';
226 example = literalExpression ''
227 [
228 "volume_name:/path/inside/container"
229 "/path/on/host:/path/inside/container"
230 ]
231 '';
232 };
233
234 workdir = mkOption {
235 type = with types; nullOr str;
236 default = null;
237 description = "Override the default working directory for the container.";
238 example = "/var/lib/hello_world";
239 };
240
241 dependsOn = mkOption {
242 type = with types; listOf str;
243 default = [ ];
244 description = ''
245 Define which other containers this one depends on. They will be added to both After and Requires for the unit.
246
247 Use the same name as the attribute under `virtualisation.oci-containers.containers`.
248 '';
249 example = literalExpression ''
250 virtualisation.oci-containers.containers = {
251 node1 = {};
252 node2 = {
253 dependsOn = [ "node1" ];
254 }
255 }
256 '';
257 };
258
259 hostname = mkOption {
260 type = with types; nullOr str;
261 default = null;
262 description = "The hostname of the container.";
263 example = "hello-world";
264 };
265
266 preRunExtraOptions = mkOption {
267 type = with types; listOf str;
268 default = [ ];
269 description = "Extra options for {command}`${defaultBackend}` that go before the `run` argument.";
270 example = [
271 "--runtime"
272 "runsc"
273 ];
274 };
275
276 extraOptions = mkOption {
277 type = with types; listOf str;
278 default = [ ];
279 description = "Extra options for {command}`${defaultBackend} run`.";
280 example = literalExpression ''
281 ["--network=host"]
282 '';
283 };
284
285 autoStart = mkOption {
286 type = with types; bool;
287 default = true;
288 description = ''
289 When enabled, the container is automatically started on boot.
290 If this option is set to false, the container has to be started on-demand via its service.
291 '';
292 };
293
294 podman = mkOption {
295 type = types.nullOr (
296 types.submodule {
297 options = {
298 sdnotify = mkOption {
299 default = "conmon";
300 type = types.enum [
301 "conmon"
302 "healthy"
303 "container"
304 ];
305 description = ''
306 Determines how `podman` should notify systemd that the unit is ready. There are
307 [three options](https://docs.podman.io/en/latest/markdown/podman-run.1.html#sdnotify-container-conmon-healthy-ignore):
308
309 * `conmon`: marks the unit as ready when the container has started.
310 * `healthy`: marks the unit as ready when the [container's healthcheck](https://docs.podman.io/en/stable/markdown/podman-healthcheck-run.1.html) passes.
311 * `container`: `NOTIFY_SOCKET` is passed into the container and the process inside the container needs to indicate on its own that it's ready.
312 '';
313 };
314 user = mkOption {
315 default = "root";
316 type = types.str;
317 description = ''
318 The user under which the container should run.
319 '';
320 };
321 };
322 }
323 );
324 default = null;
325 description = ''
326 Podman-specific settings in OCI containers. These must be null when using
327 the `docker` backend.
328 '';
329 };
330
331 pull = mkOption {
332 type =
333 with types;
334 enum [
335 "always"
336 "missing"
337 "never"
338 "newer"
339 ];
340 default = "missing";
341 description = ''
342 Image pull policy for the container. Must be one of: always, missing, never, newer
343 '';
344 };
345
346 capabilities = mkOption {
347 type = with types; lazyAttrsOf (nullOr bool);
348 default = { };
349 description = ''
350 Capabilities to configure for the container.
351 When set to true, capability is added to the container.
352 When set to false, capability is dropped from the container.
353 When null, default runtime settings apply.
354 '';
355 example = literalExpression ''
356 {
357 SYS_ADMIN = true;
358 SYS_WRITE = false;
359 {
360 '';
361 };
362
363 devices = mkOption {
364 type = with types; listOf str;
365 default = [ ];
366 description = ''
367 List of devices to attach to this container.
368 '';
369 example = literalExpression ''
370 [
371 "/dev/dri:/dev/dri"
372 ]
373 '';
374 };
375
376 privileged = mkOption {
377 type = with types; bool;
378 default = false;
379 description = ''
380 Give extended privileges to the container
381 '';
382 };
383
384 autoRemoveOnStop = mkOption {
385 type = types.bool;
386 default = true;
387 description = ''
388 Automatically remove the container when it is stopped or killed
389 '';
390 };
391
392 networks = mkOption {
393 type = with types; listOf str;
394 default = [ ];
395 description = ''
396 Networks to attach the container to
397 '';
398 };
399 };
400 };
401
402 isValidLogin =
403 login: login.username != null && login.passwordFile != null && login.registry != null;
404
405 mkService =
406 name: container:
407 let
408 dependsOn = map (x: "${cfg.backend}-${x}.service") container.dependsOn;
409 escapedName = escapeShellArg name;
410 preStartScript = pkgs.writeShellApplication {
411 name = "pre-start";
412 runtimeInputs = [ ];
413 text = ''
414 ${cfg.backend} rm -f ${name} || true
415 ${optionalString (isValidLogin container.login) ''
416 # try logging in, if it fails, check if image exists locally
417 ${cfg.backend} login \
418 ${container.login.registry} \
419 --username ${escapeShellArg container.login.username} \
420 --password-stdin < ${container.login.passwordFile} \
421 || ${cfg.backend} image inspect ${container.image} >/dev/null \
422 || { echo "image doesn't exist locally and login failed" >&2 ; exit 1; }
423 ''}
424 ${optionalString (container.imageFile != null) ''
425 ${cfg.backend} load -i ${container.imageFile}
426 ''}
427 ${optionalString (container.imageStream != null) ''
428 ${container.imageStream} | ${cfg.backend} load
429 ''}
430 ${optionalString (cfg.backend == "podman") ''
431 rm -f /run/${escapedName}/ctr-id
432 ''}
433 '';
434 };
435
436 effectiveUser = container.podman.user or "root";
437 inherit (config.users.users.${effectiveUser}) uid;
438 dependOnLingerService =
439 cfg.backend == "podman" && effectiveUser != "root" && config.users.users.${effectiveUser}.linger;
440 in
441 {
442 wantedBy = [ ] ++ optional (container.autoStart) "multi-user.target";
443 wants =
444 lib.optional (container.imageFile == null && container.imageStream == null) "network-online.target"
445 ++ lib.optionals dependOnLingerService [ "linger-users.service" ];
446 after =
447 lib.optionals (cfg.backend == "docker") [
448 "docker.service"
449 "docker.socket"
450 ]
451 # if imageFile or imageStream is not set, the service needs the network to download the image from the registry
452 ++ lib.optionals (container.imageFile == null && container.imageStream == null) [
453 "network-online.target"
454 ]
455 ++ dependsOn
456 ++ lib.optionals dependOnLingerService [ "linger-users.service" ]
457 ++ lib.optionals (effectiveUser != "root" && container.podman.sdnotify == "healthy") [
458 "user@${toString uid}.service"
459 ];
460 requires =
461 dependsOn
462 ++ lib.optionals (effectiveUser != "root" && container.podman.sdnotify == "healthy") [
463 "user@${toString uid}.service"
464 ];
465 environment = lib.mkMerge [
466 proxy_env
467 (mkIf (cfg.backend == "podman" && container.podman.user != "root") {
468 HOME = config.users.users.${container.podman.user}.home;
469 })
470 ];
471
472 path =
473 if cfg.backend == "docker" then
474 [ config.virtualisation.docker.package ]
475 else if cfg.backend == "podman" then
476 [ config.virtualisation.podman.package ]
477 else
478 throw "Unhandled backend: ${cfg.backend}";
479
480 script = concatStringsSep " \\\n " (
481 [
482 "exec ${cfg.backend} "
483 ]
484 ++ map escapeShellArg container.preRunExtraOptions
485 ++ [
486 "run"
487 "--name=${escapedName}"
488 "--log-driver=${container.log-driver}"
489 ]
490 ++ optional (container.entrypoint != null) "--entrypoint=${escapeShellArg container.entrypoint}"
491 ++ optional (container.hostname != null) "--hostname=${escapeShellArg container.hostname}"
492 ++ lib.optionals (cfg.backend == "podman") [
493 "--cidfile=/run/${escapedName}/ctr-id"
494 "--cgroups=enabled"
495 "--sdnotify=${container.podman.sdnotify}"
496 "-d"
497 "--replace"
498 ]
499 ++ (mapAttrsToList (k: v: "-e ${escapeShellArg k}=${escapeShellArg v}") container.environment)
500 ++ map (f: "--env-file ${escapeShellArg f}") container.environmentFiles
501 ++ map (p: "-p ${escapeShellArg p}") container.ports
502 ++ optional (container.user != null) "-u ${escapeShellArg container.user}"
503 ++ map (v: "-v ${escapeShellArg v}") container.volumes
504 ++ (mapAttrsToList (k: v: "-l ${escapeShellArg k}=${escapeShellArg v}") container.labels)
505 ++ optional (container.workdir != null) "-w ${escapeShellArg container.workdir}"
506 ++ optional (container.privileged) "--privileged"
507 ++ optional (container.autoRemoveOnStop) "--rm"
508 ++ mapAttrsToList (k: _: "--cap-add=${escapeShellArg k}") (
509 filterAttrs (_: v: v == true) container.capabilities
510 )
511 ++ mapAttrsToList (k: _: "--cap-drop=${escapeShellArg k}") (
512 filterAttrs (_: v: v == false) container.capabilities
513 )
514 ++ map (d: "--device=${escapeShellArg d}") container.devices
515 ++ map (n: "--network=${escapeShellArg n}") container.networks
516 ++ [ "--pull ${escapeShellArg container.pull}" ]
517 ++ map escapeShellArg container.extraOptions
518 ++ [ container.image ]
519 ++ map escapeShellArg container.cmd
520 );
521
522 preStop =
523 if cfg.backend == "podman" then
524 "podman stop --ignore --cidfile=/run/${escapedName}/ctr-id"
525 else
526 "${cfg.backend} stop ${name} || true";
527
528 postStop =
529 if cfg.backend == "podman" then
530 "podman rm -f --ignore --cidfile=/run/${escapedName}/ctr-id"
531 else
532 "${cfg.backend} rm -f ${name} || true";
533
534 unitConfig = mkIf (effectiveUser != "root") {
535 RequiresMountsFor = "/run/user/${toString uid}/containers";
536 };
537
538 serviceConfig =
539 {
540 ### There is no generalized way of supporting `reload` for docker
541 ### containers. Some containers may respond well to SIGHUP sent to their
542 ### init process, but it is not guaranteed; some apps have other reload
543 ### mechanisms, some don't have a reload signal at all, and some docker
544 ### images just have broken signal handling. The best compromise in this
545 ### case is probably to leave ExecReload undefined, so `systemctl reload`
546 ### will at least result in an error instead of potentially undefined
547 ### behaviour.
548 ###
549 ### Advanced users can still override this part of the unit to implement
550 ### a custom reload handler, since the result of all this is a normal
551 ### systemd service from the perspective of the NixOS module system.
552 ###
553 # ExecReload = ...;
554 ###
555 ExecStartPre = [ "${preStartScript}/bin/pre-start" ];
556 TimeoutStartSec = 0;
557 TimeoutStopSec = 120;
558 Restart = "always";
559 }
560 // optionalAttrs (cfg.backend == "podman") {
561 Environment = "PODMAN_SYSTEMD_UNIT=podman-${name}.service";
562 Type = "notify";
563 NotifyAccess = "all";
564 Delegate = mkIf (container.podman.sdnotify == "healthy") true;
565 User = effectiveUser;
566 RuntimeDirectory = escapedName;
567 };
568 };
569
570in
571{
572 imports = [
573 (lib.mkChangedOptionModule [ "docker-containers" ] [ "virtualisation" "oci-containers" ] (oldcfg: {
574 backend = "docker";
575 containers = lib.mapAttrs (
576 n: v:
577 builtins.removeAttrs (
578 v
579 // {
580 extraOptions = v.extraDockerOptions or [ ];
581 }
582 ) [ "extraDockerOptions" ]
583 ) oldcfg.docker-containers;
584 }))
585 ];
586
587 options.virtualisation.oci-containers = {
588
589 backend = mkOption {
590 type = types.enum [
591 "podman"
592 "docker"
593 ];
594 default = if versionAtLeast config.system.stateVersion "22.05" then "podman" else "docker";
595 description = "The underlying Docker implementation to use.";
596 };
597
598 containers = mkOption {
599 default = { };
600 type = types.attrsOf (types.submodule containerOptions);
601 description = "OCI (Docker) containers to run as systemd services.";
602 };
603
604 };
605
606 config = lib.mkIf (cfg.containers != { }) (
607 lib.mkMerge [
608 {
609 systemd.services = mapAttrs' (n: v: nameValuePair v.serviceName (mkService n v)) cfg.containers;
610
611 assertions =
612 let
613 toAssertions =
614 name:
615 {
616 imageFile,
617 imageStream,
618 podman,
619 ...
620 }:
621 [
622 {
623 assertion = imageFile == null || imageStream == null;
624
625 message = "virtualisation.oci-containers.containers.${name}: You can only define one of imageFile and imageStream";
626 }
627 {
628 assertion = cfg.backend == "docker" -> podman == null;
629 message = "virtualisation.oci-containers.containers.${name}: Cannot set `podman` option if backend is `docker`.";
630 }
631 {
632 assertion =
633 cfg.backend == "podman" && podman.sdnotify == "healthy" && podman.user != "root"
634 -> config.users.users.${podman.user}.uid != null;
635 message = ''
636 Rootless container ${name} (with podman and sdnotify=healthy)
637 requires that its running user ${podman.user} has a statically specified uid.
638 '';
639 }
640 ];
641 in
642 concatMap (name: toAssertions name cfg.containers.${name}) (lib.attrNames cfg.containers);
643
644 warnings = mkIf (cfg.backend == "podman") (
645 lib.foldlAttrs (
646 warnings: name:
647 { podman, ... }:
648 let
649 inherit (config.users.users.${podman.user}) linger;
650 in
651 warnings
652 ++ lib.optional (podman.user != "root" && linger && podman.sdnotify == "conmon") ''
653 Podman container ${name} is configured as rootless (user ${podman.user})
654 with `--sdnotify=conmon`, but lingering for this user is turned on.
655 ''
656 ++ lib.optional (podman.user != "root" && !linger && podman.sdnotify == "healthy") ''
657 Podman container ${name} is configured as rootless (user ${podman.user})
658 with `--sdnotify=healthy`, but lingering for this user is turned off.
659 ''
660 ) [ ] cfg.containers
661 );
662 }
663 (lib.mkIf (cfg.backend == "podman") {
664 virtualisation.podman.enable = true;
665 })
666 (lib.mkIf (cfg.backend == "docker") {
667 virtualisation.docker.enable = true;
668 })
669 ]
670 );
671}