infrastructure/modules/nixos/django-apps/default.nix

715 lines
22 KiB
Nix
Raw Normal View History

# SPDX-FileCopyrightText: 2024 Tom Hubrecht <tom.hubrecht@dgnum.eu>
#
# SPDX-License-Identifier: EUPL-1.2
2024-11-25 23:16:33 +01:00
{
config,
lib,
options,
pkgs,
utils,
...
}:
let
inherit (lib)
attrNames
concatLists
concatMapAttrs
filterAttrs
getExe
getExe'
literalExpression
mapAttrs
mapAttrs'
mapAttrsToList
mkEnableOption
mkIf
mkMerge
mkOption
mkPackageOption
nameValuePair
optional
optionals
recursiveUpdate
toUpper
;
inherit (lib.types)
attrs
attrsOf
enum
functionTo
ints
listOf
nullOr
package
path
str
submodule
;
inherit (utils) escapeSystemdExecArgs;
cfg = config.services.django-apps;
# Alias the global config to allow its use when the identifier is shadowed
config' = config;
systemctl = getExe' config.systemd.package "systemctl";
in
{
options.services.django-apps = {
enable = mkEnableOption "automatic django apps management";
webhook = {
domain = mkOption {
type = str;
description = ''
The domain where the webhook service will listen.
'';
};
nginx = mkOption {
type = nullOr options.services.nginx.virtualHosts.type.nestedTypes.elemType;
default = null;
description = ''
With this option, you can customize the nginx virtualHost settings.
'';
example = literalExpression ''
{
# To enable encryption and let Let's Encrypt take care of certificate
forceSSL = true;
enableACME = true;
}
'';
};
};
sites = mkOption {
type = attrsOf (
submodule (
{ name, config, ... }:
{
options = {
source = mkOption {
type = str;
description = ''
The URI where the source of the app can be publicly fetched via git.
'';
};
branch = mkOption {
type = str;
default = "production";
description = ''
Branch to follow for updates to the source.
'';
};
domain = mkOption {
type = str;
description = ''
The domain where the web app will be served.
'';
};
nginx = mkOption {
type = nullOr options.services.nginx.virtualHosts.type.nestedTypes.elemType;
default = null;
description = ''
With this option, you can customize the nginx virtualHost settings.
'';
example = literalExpression ''
{
# To enable encryption and let Let's Encrypt take care of certificate
forceSSL = true;
enableACME = true;
}
'';
};
env_prefix = mkOption {
type = str;
default = toUpper name;
description = ''
The prefix to use for environment settings declaration.
'';
};
application = {
type = mkOption {
type = enum [
"asgi"
"wsgi"
"daphne"
];
default = "wsgi";
description = ''
Specification for the django application.
'';
};
module = mkOption {
type = str;
default = "app";
description = ''
Name of the module containing the application interface.
'';
};
settingsModule = mkOption {
type = str;
default = "${config.application.module}.settings";
description = ''
The django settings module, will be passed as an environment variable to the app.
'';
};
workers = mkOption {
type = ints.positive;
default = 4;
description = ''
Number of workers processes to use.
'';
};
channelLayer = mkOption {
type = str;
default = "channel_layer";
description = ''
Channel layer to use when running the application with daphne.
'';
};
};
python = mkPackageOption pkgs "python3" { };
django = mkOption {
type = functionTo package;
default = ps: ps.django;
defaultText = literalExpression "ps: ps.django";
description = ''
The django version to use to run the app.
'';
};
djangoEnv = mkOption {
type = package;
default = config.python.withPackages (
ps:
[ (config.django ps) ]
++ (optional (config.application.type != "daphne") ps.gunicorn)
++ (optional (config.application.type == "asgi") ps.uvicorn)
++ (optional (config.dbType == "postgresql") ps.psycopg)
++ (config.dependencies ps)
);
description = ''
The python version used to run the app, with the correct dependencies.
'';
};
dependencies = mkOption {
type = functionTo (listOf package);
default = _: [ ];
example = literalExpression "ps: [ ps.requests ]";
description = ''
Python dependencies of the app.
'';
};
extraPackages = mkOption {
type = listOf package;
default = [ ];
description = ''
Packages that will be added to the path of the app.
'';
};
credentials = mkOption {
type = attrsOf path;
default = { };
description = ''
The files containing credentials to pass through `LoadCredential` to the application.
'';
};
environment = mkOption {
type = attrsOf (pkgs.formats.json { }).type;
default = { };
description = ''
Environment variables to pass to the app.
'';
};
managePath = mkOption {
type = str;
default = "manage.py";
description = ''
Path to the manage.py file inside the source
'';
};
extraServices = mkOption {
type = attrs;
default = { };
description = ''
Extra services to run in parallel of the application.
May be used to run background tasks and/or workers.
'';
};
manageScript = mkOption {
type = package;
default = pkgs.writeShellApplication {
name = "${name}-manage";
runtimeInputs = [
pkgs.util-linux
config'.systemd.package
config.djangoEnv
] ++ config.extraPackages;
text = ''
MainPID=$(systemctl show -p MainPID --value dj-${name}.service)
nsenter -e -a -t "$MainPID" -G follow -S follow python /var/lib/django-apps/${name}/source/${config.managePath} "$@"
'';
};
description = ''
Script to run manage.py related tasks.
'';
};
updateScript = mkOption {
type = package;
default = pkgs.writeShellApplication {
name = "dj-${name}-update-source";
runtimeInputs = [
config.djangoEnv
pkgs.git
];
text = ''
git pull
python3 ${config.managePath} migrate
python3 ${config.managePath} collectstatic --no-input
'';
};
description = ''
Script to run when updating the app source.
'';
};
webHookSecret = mkOption {
type = path;
description = ''
Path to the webhook secret.
'';
};
dbType = mkOption {
type = enum [
"manual"
"postgresql"
"sqlite"
];
default = "postgresql";
description = ''
Which database backend to use, set to `manual` for custom declaration.
'';
};
baseDirectory = mkOption {
type = str;
readOnly = true;
default = "/var/lib/django-apps/${name}";
};
sourceDirectory = mkOption {
type = str;
readOnly = true;
default = "${config.baseDirectory}/source";
};
staticDirectory = mkOption {
type = str;
default = "static";
description = ''
Path to the staticfiles directory.
This is relative to the base directory, e.g. the parent of the source directory.
'';
};
mediaDirectory = mkOption {
type = str;
default = "media";
description = ''
Path to the media files directory.
This is relative to the base directory, e.g. the parent of the source directory.
'';
};
};
}
)
);
};
};
config = mkIf cfg.enable {
security.sudo.extraRules = [
{
users = [ "webhook" ];
commands = builtins.map (name: {
command = "${systemctl} start dj-${name}-update.service";
options = [ "NOPASSWD" ];
}) (attrNames cfg.sites);
}
];
environment.systemPackages = mapAttrsToList (_: { manageScript, ... }: manageScript) cfg.sites;
services = {
webhook = {
enable = true;
package = pkgs.webhook.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [ ./01-webhook.patch ];
});
# extraArgs = [ "-debug" ];
# Only listen on localhost
ip = "127.0.0.1";
hooksTemplated = mapAttrs' (
name:
{ branch, ... }:
nameValuePair "dj-${name}" (
# Avoid issues when quoting "dj-name" through builtins.toJSON
builtins.replaceStrings [ "\\" ] [ "" ] (
builtins.toJSON {
id = "dj-${name}";
execute-command = "/run/wrappers/bin/sudo";
pass-arguments-to-command =
builtins.map
(name: {
inherit name;
source = "string";
})
[
systemctl
"start"
"dj-${name}-update.service"
];
# command-working-directory = "/var/lib/django-apps/${name}";
trigger-rule = {
and = [
{
or = [
{
match = {
type = "payload-hmac-sha256";
secret = ''{{ credential "dj-${name}" | js }}'';
parameter = {
source = "header";
name = "X-Hub-Signature-256";
};
};
}
{
match = {
type = "value";
value = ''{{ credential "dj-${name}" | js }}'';
parameter = {
source = "header";
name = "X-Gitlab-Token";
};
};
}
];
}
{
match = {
type = "value";
value = "refs/heads/${branch}";
parameter = {
source = "payload";
name = "ref";
};
};
}
];
};
}
)
)
) cfg.sites;
};
nginx = mkMerge [
(mkIf (cfg.webhook.nginx != null) {
enable = true;
virtualHosts = {
${cfg.webhook.domain} = mkMerge [
{ locations."/".proxyPass = "http://127.0.0.1:${builtins.toString config.services.webhook.port}"; }
cfg.webhook.nginx
];
};
})
{
virtualHosts = mapAttrs' (
name:
{ domain, nginx, ... }:
nameValuePair domain (
recursiveUpdate {
locations = {
"/".proxyPass = "http://unix:/run/django-apps/${name}.sock";
"/static/".root = "/run/django-apps/${name}";
"/media/".root = "/run/django-apps/${name}";
};
} nginx
)
) cfg.sites;
}
];
postgresql =
let
apps = builtins.map (name: "dj-${name}") (
attrNames (filterAttrs (_: { dbType, ... }: dbType == "postgresql") cfg.sites)
);
in
mkIf (apps != [ ]) {
enable = true;
ensureDatabases = apps;
ensureUsers = builtins.map (name: {
inherit name;
ensureDBOwnership = true;
}) apps;
};
};
users = {
users.nginx.extraGroups = [ "django-apps" ];
groups.django-apps = { };
};
systemd = {
sockets = mapAttrs' (
name: _:
nameValuePair "dj-${name}" {
description = "Socket for the ${name} Django Application";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "/run/django-apps/${name}.sock";
SocketMode = "600";
SocketUser = config'.services.nginx.user;
};
}
) cfg.sites;
mounts = concatLists (
mapAttrsToList (
name:
{ mediaDirectory, staticDirectory, ... }:
[
{
where = "/run/django-apps/${name}/static";
what = "/var/lib/django-apps/${name}/${staticDirectory}";
options = "bind";
after = [ "dj-${name}.service" ];
partOf = [ "dj-${name}.service" ];
upheldBy = [ "dj-${name}.service" ];
}
{
where = "/run/django-apps/${name}/media";
what = "/var/lib/django-apps/${name}/${mediaDirectory}";
options = "bind";
after = [ "dj-${name}.service" ];
partOf = [ "dj-${name}.service" ];
upheldBy = [ "dj-${name}.service" ];
}
]
) cfg.sites
);
services =
{
webhook.serviceConfig.LoadCredential = mapAttrsToList (
name: { webHookSecret, ... }: "dj-${name}:${webHookSecret}"
) cfg.sites;
}
// (concatMapAttrs (
name: config:
let
mkDatabase =
name: type:
if type == "postgresql" then
{
ENGINE = "django.db.backends.postgresql";
NAME = "dj-${name}";
}
else if type == "sqlite" then
{
ENGINE = "django.db.backends.sqlite3";
NAME = "/var/lib/django-apps/${name}/db.sqlite3";
}
else
throw "Invalid database type !";
# Systemd Service Configuration
Group = "django-apps";
LoadCredential = mapAttrsToList (credential: path: "${credential}:${path}") config.credentials;
RuntimeDirectory = "django-apps/${name}";
StateDirectory = "django-apps/${name}";
UMask = "0027";
User = "dj-${name}";
WorkingDirectory = "/var/lib/django-apps/${name}";
environment =
let
mkValue = v: if builtins.isString v then v else builtins.toJSON v;
in
(mapAttrs' (key: value: nameValuePair "${config.env_prefix}_${key}" (mkValue value)) {
DATABASES =
if (config.dbType != "manual") then { default = mkDatabase name config.dbType; } else null;
STATIC_ROOT = "/var/lib/django-apps/${name}/${config.staticDirectory}";
MEDIA_ROOT = "/var/lib/django-apps/${name}/${config.mediaDirectory}";
ALLOWED_HOSTS = [ config.domain ];
})
// {
DJANGO_SETTINGS_MODULE = config.application.settingsModule;
}
// (mapAttrs (_: mkValue) config.environment);
path = config.extraPackages ++ [ config.djangoEnv ];
after = [ "network.target" ] ++ (optional (config.dbType == "postgresql") "postgresql.service");
in
{
"dj-${name}" = {
inherit after environment path;
preStart = ''
if [ ! -f .initialized ]; then
# The previous initialization might have failed, so restart from the beginning
rm -rf source
# We need to download the application source and run the migrations first
${lib.getExe pkgs.git} clone --single-branch --branch ${config.branch} ${config.source} source
(cd source && python ${config.managePath} migrate --no-input && python ${config.managePath} collectstatic --no-input)
touch .initialized
fi
# Create the necessary directory with the correct user/group
mkdir -p ${config.mediaDirectory} ${config.staticDirectory}
'';
requires = [ "dj-${name}.socket" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
inherit
Group
LoadCredential
RuntimeDirectory
StateDirectory
User
UMask
WorkingDirectory
;
DynamicUser = true;
ExecStart = escapeSystemdExecArgs (
if (config.application.type == "daphne") then
[
(getExe' config.djangoEnv "daphne")
"-u"
"/run/django-apps/${name}.sock"
"${config.application.module}.asgi:${config.application.channelLayer}"
]
else
(
[
(getExe' config.djangoEnv "gunicorn")
"--workers"
config.application.workers
"--bind"
"unix:/run/django-apps/${name}.sock"
"--pythonpath"
"source"
]
++ (optionals (config.application.type == "asgi") [
"--worker-class"
"uvicorn.workers.UvicornWorker"
])
++ [ "${config.application.module}.${config.application.type}" ]
)
);
ExecReload = "${getExe' pkgs.coreutils "kill"} -s HUP $MAINPID";
KillMode = "mixed";
Type = mkIf (config.application.type != "daphne") "notify";
};
};
"dj-${name}-update" = {
inherit environment path;
serviceConfig = {
inherit
Group
LoadCredential
StateDirectory
UMask
User
;
DynamicUser = true;
ExecStart = "${getExe config.updateScript}";
Type = "oneshot";
WorkingDirectory = "/var/lib/django-apps/${name}/source";
};
unitConfig = {
After = "dj-${name}.service";
Conflicts = "dj-${name}.service";
};
};
}
// (mapAttrs' (
serviceName: serviceContent:
nameValuePair "dj-${name}_${serviceName}" (
recursiveUpdate {
inherit after environment path;
partOf = [ "dj-${name}.service" ];
wantedBy = [ "multi-user.target" ];
upheldBy = [ "dj-${name}.service" ];
serviceConfig = {
inherit
Group
LoadCredential
RuntimeDirectory
StateDirectory
UMask
User
;
DynamicUser = true;
};
} serviceContent
)
) config.extraServices)
) cfg.sites);
};
};
}