From f2916b84d81482b0811d55b3f34418e338fb7e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tristan=20Dani=C3=ABl=20Maat?= <tm@tlater.net> Date: Sat, 15 May 2021 22:29:02 +0100 Subject: [PATCH 1/8] WIP: podman: Configure auto-updates --- configuration/services/gitea.nix | 7 +- modules/default.nix | 2 +- modules/virtualisation/oci-containers.nix | 350 ++++++++++++++++++++++ 3 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 modules/virtualisation/oci-containers.nix diff --git a/configuration/services/gitea.nix b/configuration/services/gitea.nix index 0abdb49..4ca6454 100644 --- a/configuration/services/gitea.nix +++ b/configuration/services/gitea.nix @@ -17,7 +17,7 @@ containers = { gitea = { - image = "gitea/gitea:latest"; + image = "docker.io/gitea/gitea:latest"; volumes = [ "gitea:/data:Z" "/etc/localtime:/etc/localtime:ro" ]; dependsOn = [ "postgres" ]; @@ -35,6 +35,11 @@ DOMAIN = "gitea.tlater.net"; SSH_PORT = "2221"; }; + + extraOptions = [ + "--replace" + "--label" "io.containers.autoupdate=image" + ]; }; postgres = { diff --git a/modules/default.nix b/modules/default.nix index 0bc1f1c..4b9cea4 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -1,5 +1,5 @@ { ... }: { - imports = [ ./virtualisation/pods.nix ]; + imports = [ ./virtualisation/pods.nix ./virtualisation/oci-containers.nix ]; } diff --git a/modules/virtualisation/oci-containers.nix b/modules/virtualisation/oci-containers.nix new file mode 100644 index 0000000..f37dcaf --- /dev/null +++ b/modules/virtualisation/oci-containers.nix @@ -0,0 +1,350 @@ +# Pulled from my own modified fork of nixpkgs, awaiting merge +# upstream. + +{ config, options, lib, pkgs, ... }: + +with lib; +let + cfg = config.virtualisation.oci-containers; + proxy_env = config.networking.proxy.envVars; + + defaultBackend = options.virtualisation.oci-containers.backend.default; + + containerOptions = + { ... }: { + + options = { + + image = mkOption { + type = with types; str; + description = "OCI image to run."; + example = "library/hello-world"; + }; + + imageFile = mkOption { + type = with types; nullOr package; + default = null; + description = '' + Path to an image file to load instead of pulling from a registry. + If defined, do not pull from registry. + + You still need to set the <literal>image</literal> attribute, as it + will be used as the image name for docker to start a container. + ''; + example = literalExample "pkgs.dockerTools.buildDockerImage {...};"; + }; + + cmd = mkOption { + type = with types; listOf str; + default = []; + description = "Commandline arguments to pass to the image's entrypoint."; + example = literalExample '' + ["--port=9000"] + ''; + }; + + entrypoint = mkOption { + type = with types; nullOr str; + description = "Override the default entrypoint of the image."; + default = null; + example = "/bin/my-app"; + }; + + environment = mkOption { + type = with types; attrsOf str; + default = {}; + description = "Environment variables to set for this container."; + example = literalExample '' + { + DATABASE_HOST = "db.example.com"; + DATABASE_PORT = "3306"; + } + ''; + }; + + environmentFiles = mkOption { + type = with types; listOf path; + default = []; + description = "Environment files for this container."; + example = literalExample '' + [ + /path/to/.env + /path/to/.env.secret + ] + ''; + }; + + log-driver = mkOption { + type = types.str; + default = "journald"; + description = '' + Logging driver for the container. The default of + <literal>"journald"</literal> means that the container's logs will be + handled as part of the systemd unit. + + For more details and a full list of logging drivers, refer to respective backends documentation. + + For Docker: + <link xlink:href="https://docs.docker.com/engine/reference/run/#logging-drivers---log-driver">Docker engine documentation</link> + + For Podman: + Refer to the docker-run(1) man page. + ''; + }; + + ports = mkOption { + type = with types; listOf str; + default = []; + description = '' + Network ports to publish from the container to the outer host. + + Valid formats: + + <itemizedlist> + <listitem> + <para> + <literal><ip>:<hostPort>:<containerPort></literal> + </para> + </listitem> + <listitem> + <para> + <literal><ip>::<containerPort></literal> + </para> + </listitem> + <listitem> + <para> + <literal><hostPort>:<containerPort></literal> + </para> + </listitem> + <listitem> + <para> + <literal><containerPort></literal> + </para> + </listitem> + </itemizedlist> + + Both <literal>hostPort</literal> and + <literal>containerPort</literal> can be specified as a range of + ports. When specifying ranges for both, the number of container + ports in the range must match the number of host ports in the + range. Example: <literal>1234-1236:1234-1236/tcp</literal> + + When specifying a range for <literal>hostPort</literal> only, the + <literal>containerPort</literal> must <emphasis>not</emphasis> be a + range. In this case, the container port is published somewhere + within the specified <literal>hostPort</literal> range. Example: + <literal>1234-1236:1234/tcp</literal> + + Refer to the + <link xlink:href="https://docs.docker.com/engine/reference/run/#expose-incoming-ports"> + Docker engine documentation</link> for full details. + ''; + example = literalExample '' + [ + "8080:9000" + ] + ''; + }; + + user = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Override the username or UID (and optionally groupname or GID) used + in the container. + ''; + example = "nobody:nogroup"; + }; + + volumes = mkOption { + type = with types; listOf str; + default = []; + description = '' + List of volumes to attach to this container. + + Note that this is a list of <literal>"src:dst"</literal> strings to + allow for <literal>src</literal> to refer to + <literal>/nix/store</literal> paths, which would be difficult with an + attribute set. There are also a variety of mount options available + as a third field; please refer to the + <link xlink:href="https://docs.docker.com/engine/reference/run/#volume-shared-filesystems"> + docker engine documentation</link> for details. + ''; + example = literalExample '' + [ + "volume_name:/path/inside/container" + "/path/on/host:/path/inside/container" + ] + ''; + }; + + workdir = mkOption { + type = with types; nullOr str; + default = null; + description = "Override the default working directory for the container."; + example = "/var/lib/hello_world"; + }; + + dependsOn = mkOption { + type = with types; listOf str; + default = []; + description = '' + Define which other containers this one depends on. They will be added to both After and Requires for the unit. + + Use the same name as the attribute under <literal>virtualisation.oci-containers.containers</literal>. + ''; + example = literalExample '' + virtualisation.oci-containers.containers = { + node1 = {}; + node2 = { + dependsOn = [ "node1" ]; + } + } + ''; + }; + + extraOptions = mkOption { + type = with types; listOf str; + default = []; + description = "Extra options for <command>${defaultBackend} run</command>."; + example = literalExample '' + ["--network=host"] + ''; + }; + + autoStart = mkOption { + type = types.bool; + default = true; + description = '' + When enabled, the container is automatically started on boot. + If this option is set to false, the container has to be started on-demand via its service. + ''; + }; + }; + }; + + mkService = name: container: let + dependsOn = map (x: "${cfg.backend}-${x}.service") container.dependsOn; + in { + wantedBy = [] ++ optional (container.autoStart) "multi-user.target"; + after = lib.optionals (cfg.backend == "docker") [ "docker.service" "docker.socket" ] ++ dependsOn; + requires = dependsOn; + environment = proxy_env; + + path = + if cfg.backend == "docker" then [ config.virtualisation.docker.package ] + else if cfg.backend == "podman" then [ config.virtualisation.podman.package ] + else throw "Unhandled backend: ${cfg.backend}"; + + preStart = '' + ${cfg.backend} rm -f ${name} || true + ${optionalString (container.imageFile != null) '' + ${cfg.backend} load -i ${container.imageFile} + ''} + ''; + + # Podman likes knowing what systemd unit launched its container, + # so that it can auto-update containers and restart them. + # + # Sadly, the NixOS `script` option doesn't expose the systemd + # syntax `%n` that would expose the unit location, so we pass it + # as $1 in a scriptArg. + scriptArgs = lib.optionals (cfg.backend == "podman") "%n"; + + script = concatStringsSep " \\\n " ([ + "exec ${cfg.backend} run" + "--rm" + "--name=${escapeShellArg name}" + "--log-driver=${container.log-driver}" + ] ++ optional (container.entrypoint != null) + "--entrypoint=${escapeShellArg container.entrypoint}" + ++ (mapAttrsToList (k: v: "-e ${escapeShellArg k}=${escapeShellArg v}") container.environment) + ++ optional (cfg.backend == "podman") ''-e 'PODMAN_SYSTEMD_UNIT'="$1"'' + ++ map (f: "--env-file ${escapeShellArg f}") container.environmentFiles + ++ map (p: "-p ${escapeShellArg p}") container.ports + ++ optional (container.user != null) "-u ${escapeShellArg container.user}" + ++ map (v: "-v ${escapeShellArg v}") container.volumes + ++ optional (container.workdir != null) "-w ${escapeShellArg container.workdir}" + ++ map escapeShellArg container.extraOptions + ++ [container.image] + ++ map escapeShellArg container.cmd + ); + + preStop = "[ $SERVICE_RESULT = success ] || ${cfg.backend} stop ${name}"; + postStop = "${cfg.backend} rm -f ${name} || true"; + + serviceConfig = { + StandardOutput = "null"; + StandardError = "null"; + + ### There is no generalized way of supporting `reload` for docker + ### containers. Some containers may respond well to SIGHUP sent to their + ### init process, but it is not guaranteed; some apps have other reload + ### mechanisms, some don't have a reload signal at all, and some docker + ### images just have broken signal handling. The best compromise in this + ### case is probably to leave ExecReload undefined, so `systemctl reload` + ### will at least result in an error instead of potentially undefined + ### behaviour. + ### + ### Advanced users can still override this part of the unit to implement + ### a custom reload handler, since the result of all this is a normal + ### systemd service from the perspective of the NixOS module system. + ### + # ExecReload = ...; + ### + + TimeoutStartSec = 0; + TimeoutStopSec = 120; + Restart = "always"; + }; + }; + +in { + imports = [ + ( + lib.mkChangedOptionModule + [ "docker-containers" ] + [ "virtualisation" "oci-containers" ] + (oldcfg: { + backend = "docker"; + containers = lib.mapAttrs (n: v: builtins.removeAttrs (v // { + extraOptions = v.extraDockerOptions or []; + }) [ "extraDockerOptions" ]) oldcfg.docker-containers; + }) + ) + ]; + disabledModules = [ "virtualisation/oci-containers.nix" ]; + + options.virtualisation.oci-containers = { + + backend = mkOption { + type = types.enum [ "podman" "docker" ]; + default = + # TODO: Once https://github.com/NixOS/nixpkgs/issues/77925 is resolved default to podman + # if versionAtLeast config.system.stateVersion "20.09" then "podman" + # else "docker"; + "docker"; + description = "The underlying Docker implementation to use."; + }; + + containers = mkOption { + default = {}; + type = types.attrsOf (types.submodule containerOptions); + description = "OCI (Docker) containers to run as systemd services."; + }; + + }; + + config = lib.mkIf (cfg.containers != {}) (lib.mkMerge [ + { + systemd.services = mapAttrs' (n: v: nameValuePair "${cfg.backend}-${n}" (mkService n v)) cfg.containers; + } + (lib.mkIf (cfg.backend == "podman") { + virtualisation.podman.enable = true; + }) + (lib.mkIf (cfg.backend == "docker") { + virtualisation.docker.enable = true; + }) + ]); + +} From 517f4f00802e867e5abb2059c7ce61995e000b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tristan=20Dani=C3=ABl=20Maat?= <tm@tlater.net> Date: Sun, 16 May 2021 00:40:09 +0100 Subject: [PATCH 2/8] postgres: Get rid of password authentication Podman pods make this obsolete; though we need to explicitly set slirp4netns, otherwise podman will not create private network namespaces for the pods. --- configuration/services/gitea.nix | 3 +-- configuration/services/nextcloud.nix | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/configuration/services/gitea.nix b/configuration/services/gitea.nix index 0abdb49..2258566 100644 --- a/configuration/services/gitea.nix +++ b/configuration/services/gitea.nix @@ -14,6 +14,7 @@ virtualisation.pods.gitea = { hostname = "gitea.tlater.net"; publish = [ "3000:3000" "2221:2221" ]; + network = "slirp4netns"; containers = { gitea = { @@ -26,7 +27,6 @@ DB_HOST = "gitea-postgres:5432"; DB_NAME = "gitea"; DB_USER = "gitea"; - DB_PASSWD = "/qNDDK9WCMuubfA7D8DFwfl9T+Gy2IMDvPhiNpcxZjY="; USER_UID = toString config.users.extraUsers.gitea.uid; USER_GID = toString config.users.extraGroups.gitea.gid; @@ -42,7 +42,6 @@ environment = { POSTGRES_DB = "gitea"; POSTGRES_USER = "gitea"; - POSTGRES_PASSWORD = "/qNDDK9WCMuubfA7D8DFwfl9T+Gy2IMDvPhiNpcxZjY="; }; volumes = [ "gitea-db-data:/var/lib/postgresql/data" ]; }; diff --git a/configuration/services/nextcloud.nix b/configuration/services/nextcloud.nix index ba1754b..4b74ac7 100644 --- a/configuration/services/nextcloud.nix +++ b/configuration/services/nextcloud.nix @@ -4,6 +4,7 @@ virtualisation.pods.nextcloud = { hostname = "nextcloud.tlater.net"; publish = [ "3001:80" ]; + network = "slirp4netns"; containers = { nextcloud = { @@ -18,7 +19,6 @@ POSTGRES_DB = "nextcloud"; POSTGRES_USER = "nextcloud"; POSTGRES_HOST = "nextcloud-postgres"; - POSTGRES_PASSWORD = "rI7t7Nek1yGA9ucrRc7Uhy0jcjwPjnXa8me4o8tJON8="; OVERWRITEPROTOCOL = "https"; }; }; @@ -43,7 +43,6 @@ environment = { POSTGRES_DB = "nextcloud"; POSTGRES_USER = "nextcloud"; - POSTGRES_PASSWORD = "rI7t7Nek1yGA9ucrRc7Uhy0jcjwPjnXa8me4o8tJON8="; }; volumes = [ "nextcloud-db-data:/var/lib/postgresql/data" ]; }; From 458f6c7f7b1df2a35dc7b78daf9dce5817aefcb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tristan=20Dani=C3=ABl=20Maat?= <tm@tlater.net> Date: Sun, 16 May 2021 01:34:03 +0100 Subject: [PATCH 3/8] nginx: Avoid connection issues caused by IPv6 resolution If localhost is specified in the proxyPass url, nginx will happily resolve IPv6 addresses, even if the upstream doesn't support them. This can result in connection issues, especially with containers that don't support IPv6. --- configuration/default.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configuration/default.nix b/configuration/default.nix index f6ff072..72c955d 100644 --- a/configuration/default.nix +++ b/configuration/default.nix @@ -60,7 +60,7 @@ { forceSSL = true; enableACME = true; - locations."/" = { proxyPass = "http://localhost:${toString port}"; }; + locations."/" = { proxyPass = "http://127.0.0.1:${toString port}"; }; } // extra; in { "tlater.net" = host 3002 { serverAliases = [ "www.tlater.net" ]; }; From b8bf3bd3a219300a9a8fd1868e28776806785c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tristan=20Dani=C3=ABl=20Maat?= <tm@tlater.net> Date: Sun, 16 May 2021 23:07:18 +0100 Subject: [PATCH 4/8] minecraft: Clean up use of pkgs.lib --- configuration/services/minecraft.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/configuration/services/minecraft.nix b/configuration/services/minecraft.nix index c3831aa..9b77c09 100644 --- a/configuration/services/minecraft.nix +++ b/configuration/services/minecraft.nix @@ -1,4 +1,4 @@ -{ config, pkgs, ... }: +{ config, pkgs, lib, ... }: let minecraft-server-args = [ @@ -52,7 +52,7 @@ let in { nixpkgs.config.allowUnfreePredicate = pkg: - builtins.elem (pkgs.lib.getName pkg) [ "forge-server" ]; + builtins.elem (lib.getName pkg) [ "forge-server" ]; virtualisation.oci-containers.containers.minecraft-voor-kia = let properties = ./configs/minecraft/voor-kia/server.properties; From 5f8899d542e43b63e2d3e59642f2691013ad237e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tristan=20Dani=C3=ABl=20Maat?= <tm@tlater.net> Date: Mon, 17 May 2021 00:00:34 +0100 Subject: [PATCH 5/8] nginx: Make VM testing easier by binding virtualHosts to localhost --- configuration/default.nix | 8 +++++--- flake.nix | 6 +++++- modules/default.nix | 9 ++++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/configuration/default.nix b/configuration/default.nix index 72c955d..49668dd 100644 --- a/configuration/default.nix +++ b/configuration/default.nix @@ -54,6 +54,7 @@ recommendedGzipSettings = true; recommendedProxySettings = true; clientMaxBodySize = "10G"; + domain = "tlater.net"; virtualHosts = let host = port: extra: @@ -62,10 +63,11 @@ enableACME = true; locations."/" = { proxyPass = "http://127.0.0.1:${toString port}"; }; } // extra; + domain = config.services.nginx.domain; in { - "tlater.net" = host 3002 { serverAliases = [ "www.tlater.net" ]; }; - "gitea.tlater.net" = host 3000 { }; - "nextcloud.tlater.net" = host 3001 { }; + "${domain}" = host 3002 { serverAliases = [ "www.${domain}" ]; }; + "gitea.${domain}" = host 3000 { }; + "nextcloud.${domain}" = host 3001 { }; }; }; diff --git a/flake.nix b/flake.nix index a9baf34..78a3cfb 100644 --- a/flake.nix +++ b/flake.nix @@ -68,12 +68,16 @@ (import ./modules) (import ./configuration) - ({ ... }: { + ({ lib, ... }: { users.users.tlater.password = "insecure"; # Disable graphical tty so -curses works boot.kernelParams = [ "nomodeset" ]; + # Sets the base domain for nginx to localhost so that we + # can easily test locally with the VM. + services.nginx.domain = lib.mkOverride 99 "localhost"; + # # Set up VM settings to match real VPS # virtualisation.memorySize = 3941; # virtualisation.cores = 2; diff --git a/modules/default.nix b/modules/default.nix index 0bc1f1c..1fd86fc 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -1,5 +1,12 @@ -{ ... }: +{ lib, ... }: + +with lib; { imports = [ ./virtualisation/pods.nix ]; + + options.services.nginx.domain = mkOption { + type = types.str; + description = "The base domain name to append to virtual domain names"; + }; } From 343c7fcc36b78badfa7f9910bfa101124c0fb990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tristan=20Dani=C3=ABl=20Maat?= <tm@tlater.net> Date: Mon, 17 May 2021 00:02:03 +0100 Subject: [PATCH 6/8] nginx: Don't override extra options in the host helper --- configuration/default.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/configuration/default.nix b/configuration/default.nix index 49668dd..a12aceb 100644 --- a/configuration/default.nix +++ b/configuration/default.nix @@ -1,4 +1,4 @@ -{ config, pkgs, ... }: +{ config, pkgs, lib, ... }: { imports = [ @@ -58,11 +58,11 @@ virtualHosts = let host = port: extra: - { + lib.recursiveUpdate { forceSSL = true; enableACME = true; locations."/" = { proxyPass = "http://127.0.0.1:${toString port}"; }; - } // extra; + } extra; domain = config.services.nginx.domain; in { "${domain}" = host 3002 { serverAliases = [ "www.${domain}" ]; }; From 4c9493249064abfd2d5d535d02939e0e001a5089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tristan=20Dani=C3=ABl=20Maat?= <tm@tlater.net> Date: Mon, 17 May 2021 00:18:51 +0100 Subject: [PATCH 7/8] webserver: Use SIGKILL instead of SIGTERM --- configuration/services/webserver.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/configuration/services/webserver.nix b/configuration/services/webserver.nix index e1c396d..c1966a5 100644 --- a/configuration/services/webserver.nix +++ b/configuration/services/webserver.nix @@ -34,6 +34,10 @@ ports = [ "3002:3002" ]; volumes = [ "tlaternet-mail:/srv/mail" ]; - extraOptions = [ "--hostname=tlater.net" ]; + extraOptions = [ + "--hostname=tlater.net" + # Rocket 0.4 doesn't support SIGTERM anyway, so SIGKILL is the cleanest exit possible. + "--stop-signal=SIGKILL" + ]; }; } From 7c65e7ad020b85a160baa258aec0cf808e91696d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tristan=20Dani=C3=ABl=20Maat?= <tm@tlater.net> Date: Sat, 15 May 2021 22:29:02 +0100 Subject: [PATCH 8/8] WIP: podman: Configure auto-updates --- configuration/services/gitea.nix | 7 +- modules/default.nix | 2 +- modules/virtualisation/oci-containers.nix | 350 ++++++++++++++++++++++ 3 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 modules/virtualisation/oci-containers.nix diff --git a/configuration/services/gitea.nix b/configuration/services/gitea.nix index 2258566..978760a 100644 --- a/configuration/services/gitea.nix +++ b/configuration/services/gitea.nix @@ -18,7 +18,7 @@ containers = { gitea = { - image = "gitea/gitea:latest"; + image = "docker.io/gitea/gitea:latest"; volumes = [ "gitea:/data:Z" "/etc/localtime:/etc/localtime:ro" ]; dependsOn = [ "postgres" ]; @@ -35,6 +35,11 @@ DOMAIN = "gitea.tlater.net"; SSH_PORT = "2221"; }; + + extraOptions = [ + "--replace" + "--label" "io.containers.autoupdate=image" + ]; }; postgres = { diff --git a/modules/default.nix b/modules/default.nix index 1fd86fc..1963d8e 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -3,7 +3,7 @@ with lib; { - imports = [ ./virtualisation/pods.nix ]; + imports = [ ./virtualisation/pods.nix ./virtualisation/oci-containers.nix ]; options.services.nginx.domain = mkOption { type = types.str; diff --git a/modules/virtualisation/oci-containers.nix b/modules/virtualisation/oci-containers.nix new file mode 100644 index 0000000..f37dcaf --- /dev/null +++ b/modules/virtualisation/oci-containers.nix @@ -0,0 +1,350 @@ +# Pulled from my own modified fork of nixpkgs, awaiting merge +# upstream. + +{ config, options, lib, pkgs, ... }: + +with lib; +let + cfg = config.virtualisation.oci-containers; + proxy_env = config.networking.proxy.envVars; + + defaultBackend = options.virtualisation.oci-containers.backend.default; + + containerOptions = + { ... }: { + + options = { + + image = mkOption { + type = with types; str; + description = "OCI image to run."; + example = "library/hello-world"; + }; + + imageFile = mkOption { + type = with types; nullOr package; + default = null; + description = '' + Path to an image file to load instead of pulling from a registry. + If defined, do not pull from registry. + + You still need to set the <literal>image</literal> attribute, as it + will be used as the image name for docker to start a container. + ''; + example = literalExample "pkgs.dockerTools.buildDockerImage {...};"; + }; + + cmd = mkOption { + type = with types; listOf str; + default = []; + description = "Commandline arguments to pass to the image's entrypoint."; + example = literalExample '' + ["--port=9000"] + ''; + }; + + entrypoint = mkOption { + type = with types; nullOr str; + description = "Override the default entrypoint of the image."; + default = null; + example = "/bin/my-app"; + }; + + environment = mkOption { + type = with types; attrsOf str; + default = {}; + description = "Environment variables to set for this container."; + example = literalExample '' + { + DATABASE_HOST = "db.example.com"; + DATABASE_PORT = "3306"; + } + ''; + }; + + environmentFiles = mkOption { + type = with types; listOf path; + default = []; + description = "Environment files for this container."; + example = literalExample '' + [ + /path/to/.env + /path/to/.env.secret + ] + ''; + }; + + log-driver = mkOption { + type = types.str; + default = "journald"; + description = '' + Logging driver for the container. The default of + <literal>"journald"</literal> means that the container's logs will be + handled as part of the systemd unit. + + For more details and a full list of logging drivers, refer to respective backends documentation. + + For Docker: + <link xlink:href="https://docs.docker.com/engine/reference/run/#logging-drivers---log-driver">Docker engine documentation</link> + + For Podman: + Refer to the docker-run(1) man page. + ''; + }; + + ports = mkOption { + type = with types; listOf str; + default = []; + description = '' + Network ports to publish from the container to the outer host. + + Valid formats: + + <itemizedlist> + <listitem> + <para> + <literal><ip>:<hostPort>:<containerPort></literal> + </para> + </listitem> + <listitem> + <para> + <literal><ip>::<containerPort></literal> + </para> + </listitem> + <listitem> + <para> + <literal><hostPort>:<containerPort></literal> + </para> + </listitem> + <listitem> + <para> + <literal><containerPort></literal> + </para> + </listitem> + </itemizedlist> + + Both <literal>hostPort</literal> and + <literal>containerPort</literal> can be specified as a range of + ports. When specifying ranges for both, the number of container + ports in the range must match the number of host ports in the + range. Example: <literal>1234-1236:1234-1236/tcp</literal> + + When specifying a range for <literal>hostPort</literal> only, the + <literal>containerPort</literal> must <emphasis>not</emphasis> be a + range. In this case, the container port is published somewhere + within the specified <literal>hostPort</literal> range. Example: + <literal>1234-1236:1234/tcp</literal> + + Refer to the + <link xlink:href="https://docs.docker.com/engine/reference/run/#expose-incoming-ports"> + Docker engine documentation</link> for full details. + ''; + example = literalExample '' + [ + "8080:9000" + ] + ''; + }; + + user = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Override the username or UID (and optionally groupname or GID) used + in the container. + ''; + example = "nobody:nogroup"; + }; + + volumes = mkOption { + type = with types; listOf str; + default = []; + description = '' + List of volumes to attach to this container. + + Note that this is a list of <literal>"src:dst"</literal> strings to + allow for <literal>src</literal> to refer to + <literal>/nix/store</literal> paths, which would be difficult with an + attribute set. There are also a variety of mount options available + as a third field; please refer to the + <link xlink:href="https://docs.docker.com/engine/reference/run/#volume-shared-filesystems"> + docker engine documentation</link> for details. + ''; + example = literalExample '' + [ + "volume_name:/path/inside/container" + "/path/on/host:/path/inside/container" + ] + ''; + }; + + workdir = mkOption { + type = with types; nullOr str; + default = null; + description = "Override the default working directory for the container."; + example = "/var/lib/hello_world"; + }; + + dependsOn = mkOption { + type = with types; listOf str; + default = []; + description = '' + Define which other containers this one depends on. They will be added to both After and Requires for the unit. + + Use the same name as the attribute under <literal>virtualisation.oci-containers.containers</literal>. + ''; + example = literalExample '' + virtualisation.oci-containers.containers = { + node1 = {}; + node2 = { + dependsOn = [ "node1" ]; + } + } + ''; + }; + + extraOptions = mkOption { + type = with types; listOf str; + default = []; + description = "Extra options for <command>${defaultBackend} run</command>."; + example = literalExample '' + ["--network=host"] + ''; + }; + + autoStart = mkOption { + type = types.bool; + default = true; + description = '' + When enabled, the container is automatically started on boot. + If this option is set to false, the container has to be started on-demand via its service. + ''; + }; + }; + }; + + mkService = name: container: let + dependsOn = map (x: "${cfg.backend}-${x}.service") container.dependsOn; + in { + wantedBy = [] ++ optional (container.autoStart) "multi-user.target"; + after = lib.optionals (cfg.backend == "docker") [ "docker.service" "docker.socket" ] ++ dependsOn; + requires = dependsOn; + environment = proxy_env; + + path = + if cfg.backend == "docker" then [ config.virtualisation.docker.package ] + else if cfg.backend == "podman" then [ config.virtualisation.podman.package ] + else throw "Unhandled backend: ${cfg.backend}"; + + preStart = '' + ${cfg.backend} rm -f ${name} || true + ${optionalString (container.imageFile != null) '' + ${cfg.backend} load -i ${container.imageFile} + ''} + ''; + + # Podman likes knowing what systemd unit launched its container, + # so that it can auto-update containers and restart them. + # + # Sadly, the NixOS `script` option doesn't expose the systemd + # syntax `%n` that would expose the unit location, so we pass it + # as $1 in a scriptArg. + scriptArgs = lib.optionals (cfg.backend == "podman") "%n"; + + script = concatStringsSep " \\\n " ([ + "exec ${cfg.backend} run" + "--rm" + "--name=${escapeShellArg name}" + "--log-driver=${container.log-driver}" + ] ++ optional (container.entrypoint != null) + "--entrypoint=${escapeShellArg container.entrypoint}" + ++ (mapAttrsToList (k: v: "-e ${escapeShellArg k}=${escapeShellArg v}") container.environment) + ++ optional (cfg.backend == "podman") ''-e 'PODMAN_SYSTEMD_UNIT'="$1"'' + ++ map (f: "--env-file ${escapeShellArg f}") container.environmentFiles + ++ map (p: "-p ${escapeShellArg p}") container.ports + ++ optional (container.user != null) "-u ${escapeShellArg container.user}" + ++ map (v: "-v ${escapeShellArg v}") container.volumes + ++ optional (container.workdir != null) "-w ${escapeShellArg container.workdir}" + ++ map escapeShellArg container.extraOptions + ++ [container.image] + ++ map escapeShellArg container.cmd + ); + + preStop = "[ $SERVICE_RESULT = success ] || ${cfg.backend} stop ${name}"; + postStop = "${cfg.backend} rm -f ${name} || true"; + + serviceConfig = { + StandardOutput = "null"; + StandardError = "null"; + + ### There is no generalized way of supporting `reload` for docker + ### containers. Some containers may respond well to SIGHUP sent to their + ### init process, but it is not guaranteed; some apps have other reload + ### mechanisms, some don't have a reload signal at all, and some docker + ### images just have broken signal handling. The best compromise in this + ### case is probably to leave ExecReload undefined, so `systemctl reload` + ### will at least result in an error instead of potentially undefined + ### behaviour. + ### + ### Advanced users can still override this part of the unit to implement + ### a custom reload handler, since the result of all this is a normal + ### systemd service from the perspective of the NixOS module system. + ### + # ExecReload = ...; + ### + + TimeoutStartSec = 0; + TimeoutStopSec = 120; + Restart = "always"; + }; + }; + +in { + imports = [ + ( + lib.mkChangedOptionModule + [ "docker-containers" ] + [ "virtualisation" "oci-containers" ] + (oldcfg: { + backend = "docker"; + containers = lib.mapAttrs (n: v: builtins.removeAttrs (v // { + extraOptions = v.extraDockerOptions or []; + }) [ "extraDockerOptions" ]) oldcfg.docker-containers; + }) + ) + ]; + disabledModules = [ "virtualisation/oci-containers.nix" ]; + + options.virtualisation.oci-containers = { + + backend = mkOption { + type = types.enum [ "podman" "docker" ]; + default = + # TODO: Once https://github.com/NixOS/nixpkgs/issues/77925 is resolved default to podman + # if versionAtLeast config.system.stateVersion "20.09" then "podman" + # else "docker"; + "docker"; + description = "The underlying Docker implementation to use."; + }; + + containers = mkOption { + default = {}; + type = types.attrsOf (types.submodule containerOptions); + description = "OCI (Docker) containers to run as systemd services."; + }; + + }; + + config = lib.mkIf (cfg.containers != {}) (lib.mkMerge [ + { + systemd.services = mapAttrs' (n: v: nameValuePair "${cfg.backend}-${n}" (mkService n v)) cfg.containers; + } + (lib.mkIf (cfg.backend == "podman") { + virtualisation.podman.enable = true; + }) + (lib.mkIf (cfg.backend == "docker") { + virtualisation.docker.enable = true; + }) + ]); + +}