{ config, pkgs, lib, ... }: let inherit (lib) concatStringsSep mapAttrsToList mkDefault mkEnableOption mkIf mkOption optionalString types ; inherit (pkgs) writeShellApplication; # TODO: configure the name globally for everywhere it's used name = "panel"; cfg = config.services.${name}; package = pkgs.callPackage ./package.nix { }; environment = import ../env.nix { inherit lib pkgs; } // { DATABASE_URL = "sqlite:////var/lib/${name}/db.sqlite3"; USER_SETTINGS_FILE = pkgs.concatText "configuration.py" [ ((pkgs.formats.pythonVars { }).generate "settings.py" cfg.settings) (builtins.toFile "extra-settings.py" cfg.extra-settings) ]; REPO_DIR = import ../../launch/tf-env.nix { inherit lib pkgs; }; LOGGING_DIR = "/var/log/${name}"; }; python-environment = pkgs.python3.withPackages ( ps: with ps; [ package uvicorn ] ); manage-service = writeShellApplication { name = "manage"; text = ''exec ${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 \ --wait \ --collect \ --service-type=exec \ --unit "manage-${name}.service" \ --property "User=${name}" \ --property "Group=${name}" \ --property "WorkingDirectory=/var/lib/${name}" \ --property "Environment='' + (toString (lib.mapAttrsToList (name: value: "${name}=${value}") environment)) + "\" \\\n" + 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}`"; 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 { nixpkgs.overlays = [ (import ./overlay.nix) ]; 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} = { isNormalUser = true; }; 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) != ${package} ]]; then manage migrate --no-input manage collectstatic --no-input --clear manage compress --force echo ${package} > "$versionFile" fi ''; script = '' uvicorn ${name}.asgi:application --host ${cfg.host} --port ${toString cfg.port} ''; serviceConfig = { Restart = "always"; User = "root"; WorkingDirectory = "/var/lib/${name}"; StateDirectory = name; RuntimeDirectory = name; LogsDirectory = name; } // lib.optionalAttrs (credentials != [ ]) { LoadCredential = credentials; }; # TODO(@fricklerhandwerk): # Unify handling of runtime settings. # Right now we have four(!) places where we need to set environment variables, each in its own format: # - Django's `settings.py` declaring the setting # - the development environment # - the `manage` command # - here, the service configuration # Ideally we'd set them in two places (development environment and service configuration) but in the same format. # # For that we need to take into account # - the different types of settings # - secrets, which must not end up in the store # - other values, which can be world-readable # - ergonomics # - manipulation should be straightforward in both places; e.g. dumping secrets to a directory that is not git-tracked and adding values to an attrset otherwise # - error detection and correction; it should be clear where and why one messed up so it can be fixed immediately # We may also want to test the development environment in CI in order to make sure that we don't break it inadvertently, because misconfiguration due to multiplpe sources of truth wastes a lot of time. inherit environment; }; networking.firewall.allowedTCPPorts = [ 80 443 ]; }; }