Fediversity/panel/nix/configuration.nix
Valentin Gagarin 7c33e8aaf3 scaffold Django web service
This setup is greatly inspired by the one used for [0], although with
notable modifications, such as:
- a SASS preprocessor and CSS compressor
- more streamlined NixOS integration tests
- cleaned up service configuration
- a few notes on how to do things better in the future

[0]: https://github.com/Nix-Security-WG/nix-security-tracker/

Apart from cloning the Nix setup, there were additional steps:
- Create an empty `src` directory, since the package requires it
- In the development shell, run `django-admin startproject panel src`

Note that while you can already do

```bash
manage migrate
manage runserver
```

the NixOS integration tests will fail, since `settings.py` needs
careful massaging to expose knobs that can be turned from our systemd
wrapper. The required changes are introduced in the next commit to make
them observable.

Noteworthy related work:

- https://github.com/sephii/django.nix

  Rather mature setup with a clean interface, uses Caddy as reverse proxy.

- https://git.dgnum.eu/mdebray/djangonix

  A work-in-progress attempt to capture more moving parts through the
  module system, in particular secrets.

- https://github.com/DavHau/django-nixos

  Out of date and somewhat simplistic, but serves as a reasonable
  example for what can be done

I chose the variant I'm intimately familiar with in order to be able to
pass on knowledge or help with maintenance. But for the future
I strongly recommend picking the good bits from the other
implementations that control complexity in static configuration parts
through Nix expressions.
2025-02-13 00:26:28 +01:00

199 lines
5.3 KiB
Nix

{
config,
pkgs,
lib,
...
}:
let
inherit (lib)
concatStringsSep
mapAttrsToList
mkDefault
mkEnableOption
mkIf
mkOption
mkPackageOption
optionalString
types
;
inherit (pkgs) writeShellApplication;
# TODO: configure the name globally for everywhere it's used
name = "panel";
cfg = config.services.${name};
database-url = "sqlite:////var/lib/${name}/db.sqlite3";
python-environment = pkgs.python3.withPackages (
ps: with ps; [
cfg.package
uvicorn
]
);
configFile = pkgs.concatText "configuration.py" [
((pkgs.formats.pythonVars { }).generate "settings.py" cfg.settings)
(builtins.toFile "extra-settings.py" cfg.extra-settings)
];
manage-service = writeShellApplication {
name = "manage";
text = ''exec ${cfg.package}/bin/manage.py "$@"'';
};
manage-admin = writeShellApplication {
# This allows running the `manage` command in the system environment, e.g. to initialise an admin user
# Executing
name = "manage";
text =
''
systemd-run --pty \
--same-dir \
--wait \
--collect \
--service-type=exec \
--unit "manage-${name}.service" \
--property "User=${name}" \
--property "Group=${name}" \
--property "Environment=DATABASE_URL=${database-url} USER_SETTINGS_FILE=${configFile}" \
''
+ optionalString (credentials != [ ]) (
(concatStringsSep " \\\n" (map (cred: "--property 'LoadCredential=${cred}'") credentials)) + " \\\n"
)
+ ''
${lib.getExe manage-service} "$@"
'';
};
credentials = mapAttrsToList (name: secretPath: "${name}:${secretPath}") cfg.secrets;
in
# TODO: for a more clever and generic way of running Django services:
# https://git.dgnum.eu/mdebray/djangonix/
# unlicensed at the time of writing, but surely worth taking some inspiration from...
{
options.services.${name} = {
enable = mkEnableOption "Service configuration for `${name}`";
# NOTE: this requires that the package is present in `pkgs`
package = mkPackageOption pkgs name { };
production = mkOption {
type = types.bool;
default = true;
};
restart = mkOption {
description = "systemd restart behavior";
type = types.enum [
"no"
"on-success"
"on-failure"
"on-abnormal"
"on-abort"
"always"
];
default = "always";
};
domain = mkOption { type = types.str; };
host = mkOption {
type = types.str;
default = "127.0.0.1";
};
port = mkOption {
type = types.port;
default = 8000;
};
settings = mkOption {
type = types.attrsOf types.anything;
default = {
STATIC_ROOT = mkDefault "/var/lib/${name}/static";
DEBUG = mkDefault false;
ALLOWED_HOSTS = mkDefault [
cfg.domain
cfg.host
"localhost"
"[::1]"
];
CSRF_TRUSTED_ORIGINS = mkDefault [ "https://${cfg.domain}" ];
COMPRESS_OFFLINE = true;
LIBSASS_OUTPUT_STYLE = "compressed";
};
description = ''
Django configuration as an attribute set.
Name-value pairs will be converted to Python variable assignments.
'';
};
extra-settings = mkOption {
type = types.lines;
default = "";
description = ''
Django configuration written in Python verbatim.
Contents will be appended to the definitions in `settings`.
'';
};
secrets = mkOption {
type = types.attrsOf types.path;
default = { };
};
};
config = mkIf cfg.enable {
environment.systemPackages = [ manage-admin ];
services = {
nginx.enable = true;
nginx.virtualHosts = {
${cfg.domain} =
{
locations = {
"/".proxyPass = "http://localhost:${toString cfg.port}";
"/static/".alias = "/var/lib/${name}/static/";
};
}
// lib.optionalAttrs cfg.production {
enableACME = true;
forceSSL = true;
};
};
};
users.users.${name} = {
isSystemUser = true;
group = name;
};
users.groups.${name} = { };
systemd.services.${name} = {
description = "${name} ASGI server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
path = [
python-environment
manage-service
];
preStart = ''
# Auto-migrate on first run or if the package has changed
versionFile="/var/lib/${name}/package-version"
if [[ $(cat "$versionFile" 2>/dev/null) != ${cfg.package} ]]; then
manage migrate --no-input
manage collectstatic --no-input --clear
manage compress --force
echo ${cfg.package} > "$versionFile"
fi
'';
script = ''
uvicorn ${name}.asgi:application --host ${cfg.host} --port ${toString cfg.port}
'';
serviceConfig = {
Restart = "always";
User = name;
WorkingDirectory = "/var/lib/${name}";
StateDirectory = name;
RuntimeDirectory = name;
LogsDirectory = name;
} // lib.optionalAttrs (credentials != [ ]) { LoadCredential = credentials; };
environment = {
USER_SETTINGS_FILE = "${configFile}";
DATABASE_URL = database-url;
};
};
};
}