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