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