nixpkgs/nixos/modules/services/web-apps/weblate.nix
Wolfgang Walther 41c5662cbe
nixos/postgresql: move postStart into separate unit
This avoids restarting the postgresql server, when only ensureDatabases
or ensureUsers have been changed. It will also allow to properly wait
for recovery to finish later.

To wait for "postgresql is ready" in other services, we now provide a
postgresql.target.

Resolves #400018

Co-authored-by: Marcel <me@m4rc3l.de>
2025-06-24 15:26:47 +02:00

430 lines
13 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.weblate;
dataDir = "/var/lib/weblate";
settingsDir = "${dataDir}/settings";
finalPackage = cfg.package.overridePythonAttrs (old: {
# We only support the PostgreSQL backend in this module
dependencies = old.dependencies ++ cfg.package.optional-dependencies.postgres;
# Use a settings module in dataDir, to avoid having to rebuild the package
# when user changes settings.
makeWrapperArgs = (old.makeWrapperArgs or [ ]) ++ [
"--set PYTHONPATH \"${settingsDir}\""
"--set DJANGO_SETTINGS_MODULE \"settings\""
];
});
inherit (finalPackage) python;
pythonEnv = python.buildEnv.override {
extraLibs = with python.pkgs; [
(toPythonModule finalPackage)
celery
];
};
# This extends and overrides the weblate/settings_example.py code found in upstream.
weblateConfig =
''
# This was autogenerated by the NixOS module.
SITE_TITLE = "Weblate"
SITE_DOMAIN = "${cfg.localDomain}"
# TLS terminates at the reverse proxy, but this setting controls how links to weblate are generated.
ENABLE_HTTPS = True
SESSION_COOKIE_SECURE = ENABLE_HTTPS
DATA_DIR = "${dataDir}"
CACHE_DIR = f"{DATA_DIR}/cache"
STATIC_ROOT = "${finalPackage.static}"
MEDIA_ROOT = "/var/lib/weblate/media"
COMPRESS_ROOT = "${finalPackage.static}"
COMPRESS_OFFLINE = True
DEBUG = False
with open("${cfg.djangoSecretKeyFile}") as f:
SECRET_KEY = f.read().rstrip("\n")
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "unix://${config.services.redis.servers.weblate.unixSocket}",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PASSWORD": None,
"CONNECTION_POOL_KWARGS": {},
},
"KEY_PREFIX": "weblate",
"TIMEOUT": 3600,
},
"avatar": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
"LOCATION": "/var/lib/weblate/avatar-cache",
"TIMEOUT": 86400,
"OPTIONS": {"MAX_ENTRIES": 1000},
}
}
CELERY_TASK_ALWAYS_EAGER = False
CELERY_BROKER_URL = "redis+socket://${config.services.redis.servers.weblate.unixSocket}"
CELERY_RESULT_BACKEND = CELERY_BROKER_URL
VCS_BACKENDS = ("weblate.vcs.git.GitRepository",)
SITE_URL = "https://{}".format(SITE_DOMAIN)
# WebAuthn
OTP_WEBAUTHN_RP_NAME = SITE_TITLE
OTP_WEBAUTHN_RP_ID = SITE_DOMAIN.split(":")[0]
OTP_WEBAUTHN_ALLOWED_ORIGINS = [SITE_URL]
''
+ lib.optionalString cfg.configurePostgresql ''
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": "/run/postgresql",
"NAME": "weblate",
"USER": "weblate",
}
}
''
+ lib.optionalString cfg.smtp.enable ''
EMAIL_HOST = "${cfg.smtp.host}"
EMAIL_USE_TLS = True
EMAIL_PORT = ${builtins.toString cfg.smtp.port}
SERVER_EMAIL = "${cfg.smtp.from}"
DEFAULT_FROM_EMAIL = "${cfg.smtp.from}"
''
+ lib.optionalString (cfg.smtp.enable && cfg.smtp.user != null) ''
ADMINS = (("Weblate Admin", "${cfg.smtp.user}"),)
EMAIL_HOST_USER = "${cfg.smtp.user}"
''
+ lib.optionalString (cfg.smtp.enable && cfg.smtp.passwordFile != null) ''
with open("${cfg.smtp.passwordFile}") as f:
EMAIL_HOST_PASSWORD = f.read().rstrip("\n")
''
+ cfg.extraConfig;
settings_py =
pkgs.runCommand "weblate_settings.py"
{
inherit weblateConfig;
passAsFile = [ "weblateConfig" ];
}
''
mkdir -p $out
cat \
${finalPackage}/${python.sitePackages}/weblate/settings_example.py \
$weblateConfigPath \
> $out/settings.py
'';
environment = {
PYTHONPATH = "${settingsDir}:${pythonEnv}/${python.sitePackages}/";
DJANGO_SETTINGS_MODULE = "settings";
# We run Weblate through gunicorn, so we can't utilise the env var set in the wrapper.
inherit (finalPackage) GI_TYPELIB_PATH;
};
weblatePath = with pkgs; [
gitSVN
borgbackup
#optional
git-review
tesseract
licensee
mercurial
openssh
];
in
{
options = {
services.weblate = {
enable = lib.mkEnableOption "Weblate service";
package = lib.mkPackageOption pkgs "weblate" { };
localDomain = lib.mkOption {
description = "The domain name serving your Weblate instance.";
example = "weblate.example.org";
type = lib.types.str;
};
djangoSecretKeyFile = lib.mkOption {
description = ''
Location of the Django secret key.
This should be a path pointing to a file with secure permissions (not /nix/store).
Can be generated with `weblate-generate-secret-key` which is available as the `weblate` user.
'';
type = lib.types.path;
};
configurePostgresql = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to enable and configure a local PostgreSQL server by creating a user and database for weblate.
The default `settings` reference this database, if you disable this option you must provide a database URL in `extraConfig`.
'';
};
extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = ''
Text to append to `settings.py` Weblate configuration file.
'';
};
smtp = {
enable = lib.mkEnableOption "Weblate SMTP support";
from = lib.mkOption {
description = "The from address being used in sent emails.";
example = "weblate@example.com";
default = config.services.weblate.smtp.user;
defaultText = "config.services.weblate.smtp.user";
type = lib.types.str;
};
user = lib.mkOption {
description = "SMTP login name.";
example = "weblate@example.org";
type = lib.types.nullOr lib.types.str;
default = null;
};
host = lib.mkOption {
description = "SMTP host used when sending emails to users.";
type = lib.types.str;
example = "127.0.0.1";
};
port = lib.mkOption {
description = "SMTP port used when sending emails to users.";
type = lib.types.port;
default = 587;
example = 25;
};
passwordFile = lib.mkOption {
description = ''
Location of a file containing the SMTP password.
This should be a path pointing to a file with secure permissions (not /nix/store).
'';
type = lib.types.nullOr lib.types.path;
default = null;
};
};
};
};
config = lib.mkIf cfg.enable {
systemd.tmpfiles.rules = [ "L+ ${settingsDir} - - - - ${settings_py}" ];
services.nginx = {
enable = true;
virtualHosts."${cfg.localDomain}" = {
forceSSL = true;
enableACME = true;
locations = {
"= /favicon.ico".alias = "${finalPackage}/${python.sitePackages}/weblate/static/favicon.ico";
"/static/".alias = "${finalPackage.static}/";
"/media/".alias = "/var/lib/weblate/media/";
"/".proxyPass = "http://unix:///run/weblate.socket";
};
};
};
systemd.services.weblate-postgresql-setup = {
description = "Weblate PostgreSQL setup";
after = [ "postgresql.target" ];
serviceConfig = {
Type = "oneshot";
User = "postgres";
Group = "postgres";
ExecStart = ''
${config.services.postgresql.package}/bin/psql weblate -c "CREATE EXTENSION IF NOT EXISTS pg_trgm"
'';
};
};
systemd.services.weblate-migrate = {
description = "Weblate migration";
after = [
"weblate-postgresql-setup.service"
"redis-weblate.service"
];
requires = [
"weblate-postgresql-setup.service"
"redis-weblate.service"
];
# We want this to be active on boot, not just on socket activation
wantedBy = [ "multi-user.target" ];
inherit environment;
path = weblatePath;
serviceConfig = {
Type = "oneshot";
StateDirectory = "weblate";
User = "weblate";
Group = "weblate";
ExecStart = "${finalPackage}/bin/weblate migrate --noinput";
};
};
systemd.services.weblate-celery = {
description = "Weblate Celery";
after = [
"network.target"
"redis-weblate.service"
"postgresql.target"
];
# We want this to be active on boot, not just on socket activation
wantedBy = [ "multi-user.target" ];
environment = environment // {
CELERY_WORKER_RUNNING = "1";
};
path = weblatePath;
# Recommendations from:
# https://github.com/WeblateOrg/weblate/blob/main/weblate/examples/celery-weblate.service
serviceConfig =
let
# We have to push %n through systemd's replacement, therefore %%n.
pidFile = "/run/celery/weblate-%%n.pid";
nodes = "celery notify memory backup translate";
cmd = verb: ''
${pythonEnv}/bin/celery multi ${verb} \
${nodes} \
-A "weblate.utils" \
--pidfile=${pidFile} \
--logfile=/var/log/celery/weblate-%%n%%I.log \
--loglevel=DEBUG \
--beat:celery \
--queues:celery=celery \
--prefetch-multiplier:celery=4 \
--queues:notify=notify \
--prefetch-multiplier:notify=10 \
--queues:memory=memory \
--prefetch-multiplier:memory=10 \
--queues:translate=translate \
--prefetch-multiplier:translate=4 \
--concurrency:backup=1 \
--queues:backup=backup \
--prefetch-multiplier:backup=2
'';
in
{
Type = "forking";
User = "weblate";
Group = "weblate";
WorkingDirectory = "${finalPackage}/${python.sitePackages}/weblate/";
RuntimeDirectory = "celery";
RuntimeDirectoryPreserve = "restart";
LogsDirectory = "celery";
ExecStart = cmd "start";
ExecReload = cmd "restart";
ExecStop = ''
${pythonEnv}/bin/celery multi stopwait \
${nodes} \
--pidfile=${pidFile}
'';
Restart = "always";
};
};
systemd.services.weblate = {
description = "Weblate Gunicorn app";
after = [
"network.target"
"weblate-migrate.service"
"weblate-celery.service"
];
requires = [
"weblate-migrate.service"
"weblate-celery.service"
"weblate.socket"
];
inherit environment;
path = weblatePath;
serviceConfig = {
Type = "notify";
NotifyAccess = "all";
ExecStart =
let
gunicorn = python.pkgs.gunicorn.overridePythonAttrs (old: {
# Allows Gunicorn to set a meaningful process name
dependencies = (old.dependencies or [ ]) ++ old.optional-dependencies.setproctitle;
});
in
''
${gunicorn}/bin/gunicorn \
--name=weblate \
--bind='unix:///run/weblate.socket' \
weblate.wsgi
'';
ExecReload = "kill -s HUP $MAINPID";
KillMode = "mixed";
PrivateTmp = true;
WorkingDirectory = dataDir;
StateDirectory = "weblate";
RuntimeDirectory = "weblate";
User = "weblate";
Group = "weblate";
};
};
systemd.sockets.weblate = {
before = [ "nginx.service" ];
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "/run/weblate.socket";
SocketUser = "weblate";
SocketGroup = "weblate";
SocketMode = "770";
};
};
services.redis.servers.weblate = {
enable = true;
user = "weblate";
unixSocket = "/run/redis-weblate/redis.sock";
unixSocketPerm = 770;
};
services.postgresql = lib.mkIf cfg.configurePostgresql {
enable = true;
ensureUsers = [
{
name = "weblate";
ensureDBOwnership = true;
}
];
ensureDatabases = [ "weblate" ];
};
users.users.weblate = {
isSystemUser = true;
group = "weblate";
packages = [ finalPackage ] ++ weblatePath;
};
users.groups.weblate.members = [ config.services.nginx.user ];
};
meta.maintainers = with lib.maintainers; [ erictapen ];
}