Files
Niklas Korz 9ea977e104 nixos/weblate: ensure ssh wrappers are up to date
Weblate generates an SSH wrapper with some preset options
that uses the absolute path of the ssh binary internally.
As the wrapper is only regenerated when the generator itself
is changed, this absolute nix store path becomes unusable once
ssh is updated and the path is garbage collected.
As generating the wrappers is a quick operation, simply deleting the wrapper
directory before service start ensures they are up to date.
2026-06-03 09:09:33 +02:00

447 lines
13 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.weblate;
dataDir = "/var/lib/weblate";
cacheDir = "${dataDir}/cache";
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 = ${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;
};
# Packages needed at runtime
weblatePath = with pkgs; [
gitSVN
subversion
gettext
fontconfig
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}/";
"/".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;
# Weblate generates SSH wrappers with some preset options that use the
# absolute paths of the ssh and scp binaries internally.
# As the wrapper is only regenerated when the generator itself is changed,
# this absolute nix store path becomes unusable once ssh is updated and
# the path is garbage collected.
# As generating the wrappers is a quick operation, simply deleting the
# wrapper directory before service start ensures they are up to date.
preStart = ''
if [ -d "${cacheDir}/ssh" ]; then
rm -r "${cacheDir}/ssh"
fi
'';
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
''
${lib.getExe gunicorn} \
--name=weblate \
--bind='unix:///run/weblate.socket' \
--preload \
weblate.wsgi
'';
ExecReload = "${lib.getExe' pkgs.coreutils "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 ];
}