init
This commit is contained in:
commit
5b5c57bb32
10 changed files with 509 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.nixos-test-history
|
||||||
|
result
|
||||||
|
result-*
|
211
module.nix
Normal file
211
module.nix
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
mkManagePy = pkgs.callPackage ./utils/mkManagePy.nix { };
|
||||||
|
mkStaticAssets =
|
||||||
|
{ app, managePy }:
|
||||||
|
pkgs.runCommand "django-${app}-static" {} ''
|
||||||
|
mkdir -p "$out/static"
|
||||||
|
STATIC_ROOT="\"$out/static\"" \
|
||||||
|
DJANGO_SETTINGS_MODULE="${app}_settings.mock" \
|
||||||
|
${lib.getExe managePy} collectstatic --noinput
|
||||||
|
'';
|
||||||
|
|
||||||
|
mkSettingsModule =
|
||||||
|
{
|
||||||
|
runtimeSettings,
|
||||||
|
settings,
|
||||||
|
secrets,
|
||||||
|
mainModule,
|
||||||
|
extraConfig,
|
||||||
|
p,
|
||||||
|
}:
|
||||||
|
p.callPackage ./utils/mkSettingsModule.nix {
|
||||||
|
inherit
|
||||||
|
runtimeSettings
|
||||||
|
settings
|
||||||
|
secrets
|
||||||
|
mainModule
|
||||||
|
extraConfig
|
||||||
|
;
|
||||||
|
};
|
||||||
|
|
||||||
|
djangoAppModule = lib.types.submodule (
|
||||||
|
{ config, name, ... }:
|
||||||
|
{
|
||||||
|
options = {
|
||||||
|
enable = lib.mkEnableOption (lib.mdDoc "Enable django application") // {
|
||||||
|
default = true;
|
||||||
|
};
|
||||||
|
src = lib.mkOption { type = lib.types.path; };
|
||||||
|
sourceRoot = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
example = "myproject/";
|
||||||
|
description = "Directory in the source directory to find the django app (must point to basedir of manage.py)";
|
||||||
|
};
|
||||||
|
mainModule = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Name of the django application";
|
||||||
|
default = name;
|
||||||
|
defaultText = lib.literalMD "Defaults to attribute name in djangoAppModule.";
|
||||||
|
};
|
||||||
|
settings = lib.mkOption {
|
||||||
|
type = lib.types.submodule {
|
||||||
|
freeformType = with lib.types; attrsOf anything;
|
||||||
|
options = { };
|
||||||
|
};
|
||||||
|
description = ''
|
||||||
|
Settings to pass to django.
|
||||||
|
'';
|
||||||
|
example = {
|
||||||
|
DATABASES = {
|
||||||
|
"default" = {
|
||||||
|
"ENGINE" = "django.db.backends.sqlite3";
|
||||||
|
"NAME" = "/var/lib/django-myproject/db.sqlite3";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
runtimeSettings = lib.mkOption {
|
||||||
|
type = with lib.types; attrsOf str;
|
||||||
|
default= {};
|
||||||
|
description = ''
|
||||||
|
Settings to pass to only at runtime.
|
||||||
|
|
||||||
|
Useful for settings that depends on the python package.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
secrets = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.path;
|
||||||
|
default = { };
|
||||||
|
example = {
|
||||||
|
SECRET_KEY = "/etc/django-secret.json";
|
||||||
|
DATABASES = "/etc/django-db-config.json";
|
||||||
|
};
|
||||||
|
description = ''
|
||||||
|
Secrets to pass to django through a file. The content of the file will be loaded as json
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
extraConfig = lib.mkOption {
|
||||||
|
type = lib.types.lines;
|
||||||
|
default = "";
|
||||||
|
description = "Extra python code to append at the end of the production settings module.";
|
||||||
|
};
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 51666;
|
||||||
|
description = "Port for the gunicorn process";
|
||||||
|
};
|
||||||
|
processes = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 2;
|
||||||
|
};
|
||||||
|
threads = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 2;
|
||||||
|
};
|
||||||
|
staticAssets = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = mkStaticAssets {
|
||||||
|
inherit (config) managePy;
|
||||||
|
app = name;
|
||||||
|
};
|
||||||
|
description = "Satic assets to be served directly by nginx. The default value should be good enough in most cases.";
|
||||||
|
};
|
||||||
|
manageFilePath = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "${config.sourceRoot}manage.py";
|
||||||
|
description = "Path relative to src pointing to manage.py file";
|
||||||
|
};
|
||||||
|
pythonPackage = lib.mkOption {
|
||||||
|
internal = true;
|
||||||
|
type = lib.types.path;
|
||||||
|
description = "Final python environment containing everything including the settings module";
|
||||||
|
default = pkgs.python3.withPackages (
|
||||||
|
p:
|
||||||
|
[
|
||||||
|
p.django
|
||||||
|
p.gunicorn
|
||||||
|
(mkSettingsModule {
|
||||||
|
inherit (config)
|
||||||
|
secrets
|
||||||
|
settings
|
||||||
|
mainModule
|
||||||
|
extraConfig
|
||||||
|
;
|
||||||
|
runtimeSettings = builtins.attrNames config.runtimeSettings;
|
||||||
|
inherit p;
|
||||||
|
})
|
||||||
|
]
|
||||||
|
++ config.extraPackages p
|
||||||
|
);
|
||||||
|
};
|
||||||
|
extraPackages = lib.mkOption {
|
||||||
|
type = lib.types.functionTo (lib.types.listOf lib.types.package);
|
||||||
|
default = p: [ ];
|
||||||
|
defaultText = lib.literalExpression "p: []";
|
||||||
|
description = ''
|
||||||
|
Extra Python packages available to django app. The
|
||||||
|
value must be a function which receives the attrset defined
|
||||||
|
in {var}`python3Packages` as the sole argument.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
managePy = lib.mkOption {
|
||||||
|
internal = true;
|
||||||
|
type = lib.types.package;
|
||||||
|
default = mkManagePy {
|
||||||
|
inherit (config) pythonPackage manageFilePath src;
|
||||||
|
app = name;
|
||||||
|
};
|
||||||
|
description = "Manage py script";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
config = {
|
||||||
|
runtimeSettings.STATIC_ROOT = "";#config.staticAssets;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options = {
|
||||||
|
services.django = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf djangoAppModule;
|
||||||
|
description = "Attribute set of djanfo app modules";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
config.systemd.services = lib.mapAttrs' (
|
||||||
|
app: cfg:
|
||||||
|
lib.nameValuePair "django-${app}" (
|
||||||
|
lib.mkIf cfg.enable {
|
||||||
|
description = "${app} django service";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wants = [ "network.target" ];
|
||||||
|
serviceConfig = rec {
|
||||||
|
Type = "notify";
|
||||||
|
#NotifyAllow = "exec";
|
||||||
|
DynamicUser = true;
|
||||||
|
|
||||||
|
LoadCredential = lib.mapAttrsToList (k: v: "${k}:${v}") cfg.secrets;
|
||||||
|
StateDirectory = "django-${app}";
|
||||||
|
};
|
||||||
|
environment = {
|
||||||
|
DJANGO_SETTINGS_MODULE = "${cfg.mainModule}_settings.prod";
|
||||||
|
} // (lib.mapAttrs (_: v: builtins.toJSON v) cfg.runtimeSettings);
|
||||||
|
script = ''
|
||||||
|
${lib.getExe cfg.managePy} migrate
|
||||||
|
exec ${cfg.pythonPackage}/bin/gunicorn ${cfg.mainModule}.wsgi \
|
||||||
|
--pythonpath ${cfg.src}/${cfg.sourceRoot} \
|
||||||
|
-b 127.0.0.1:${builtins.toString cfg.port} \
|
||||||
|
--workers=${builtins.toString cfg.processes} \
|
||||||
|
--threads=${builtins.toString cfg.threads}
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) config.services.django;
|
||||||
|
}
|
80
npins/default.nix
Normal file
80
npins/default.nix
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
# Generated by npins. Do not modify; will be overwritten regularly
|
||||||
|
let
|
||||||
|
data = builtins.fromJSON (builtins.readFile ./sources.json);
|
||||||
|
version = data.version;
|
||||||
|
|
||||||
|
mkSource =
|
||||||
|
spec:
|
||||||
|
assert spec ? type;
|
||||||
|
let
|
||||||
|
path =
|
||||||
|
if spec.type == "Git" then
|
||||||
|
mkGitSource spec
|
||||||
|
else if spec.type == "GitRelease" then
|
||||||
|
mkGitSource spec
|
||||||
|
else if spec.type == "PyPi" then
|
||||||
|
mkPyPiSource spec
|
||||||
|
else if spec.type == "Channel" then
|
||||||
|
mkChannelSource spec
|
||||||
|
else
|
||||||
|
builtins.throw "Unknown source type ${spec.type}";
|
||||||
|
in
|
||||||
|
spec // { outPath = path; };
|
||||||
|
|
||||||
|
mkGitSource =
|
||||||
|
{
|
||||||
|
repository,
|
||||||
|
revision,
|
||||||
|
url ? null,
|
||||||
|
hash,
|
||||||
|
branch ? null,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
assert repository ? type;
|
||||||
|
# At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
|
||||||
|
# In the latter case, there we will always be an url to the tarball
|
||||||
|
if url != null then
|
||||||
|
(builtins.fetchTarball {
|
||||||
|
inherit url;
|
||||||
|
sha256 = hash; # FIXME: check nix version & use SRI hashes
|
||||||
|
})
|
||||||
|
else
|
||||||
|
assert repository.type == "Git";
|
||||||
|
let
|
||||||
|
urlToName =
|
||||||
|
url: rev:
|
||||||
|
let
|
||||||
|
matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url;
|
||||||
|
|
||||||
|
short = builtins.substring 0 7 rev;
|
||||||
|
|
||||||
|
appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else "";
|
||||||
|
in
|
||||||
|
"${if matched == null then "source" else builtins.head matched}${appendShort}";
|
||||||
|
name = urlToName repository.url revision;
|
||||||
|
in
|
||||||
|
builtins.fetchGit {
|
||||||
|
url = repository.url;
|
||||||
|
rev = revision;
|
||||||
|
inherit name;
|
||||||
|
# hash = hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
mkPyPiSource =
|
||||||
|
{ url, hash, ... }:
|
||||||
|
builtins.fetchurl {
|
||||||
|
inherit url;
|
||||||
|
sha256 = hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
mkChannelSource =
|
||||||
|
{ url, hash, ... }:
|
||||||
|
builtins.fetchTarball {
|
||||||
|
inherit url;
|
||||||
|
sha256 = hash;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
if version == 3 then
|
||||||
|
builtins.mapAttrs (_: mkSource) data.pins
|
||||||
|
else
|
||||||
|
throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"
|
11
npins/sources.json
Normal file
11
npins/sources.json
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"pins": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"type": "Channel",
|
||||||
|
"name": "nixpkgs-unstable",
|
||||||
|
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre635739.31f409910124/nixexprs.tar.xz",
|
||||||
|
"hash": "03k8b8yawqcbz75n0fv8bfa7capm3shnd72z06plw5cp8wn9ymrm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 3
|
||||||
|
}
|
86
test.nix
Normal file
86
test.nix
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
let
|
||||||
|
sources = import ./npins;
|
||||||
|
inherit (sources) nixpkgs;
|
||||||
|
pkgs = import nixpkgs { };
|
||||||
|
|
||||||
|
django = pkgs.python3.withPackages (p: [ p.django ]);
|
||||||
|
settings = {
|
||||||
|
SMOKE_TEST = "Hello settings";
|
||||||
|
JSON_SMOKE_TEST = {
|
||||||
|
"hello" = "json";
|
||||||
|
};
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default" = {
|
||||||
|
"ENGINE" = "django.db.backends.sqlite3";
|
||||||
|
"NAME" = "/var/lib/django-smoketest/db.sqlite3";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
secretFile = "${./test_assets/secret}";
|
||||||
|
|
||||||
|
secrets = {
|
||||||
|
SECRETS_SMOKE_TEST = secretFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
project = pkgs.runCommandNoCC "smoketest" { } ''
|
||||||
|
mkdir -p $out
|
||||||
|
${django}/bin/django-admin startproject smoketest $out
|
||||||
|
cp ${./test_assets/views.py} $out/smoketest/views.py
|
||||||
|
cp ${./test_assets/urls.py} $out/smoketest/urls.py
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
pkgs.testers.runNixOSTest (
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
name = "djangonix smoke test";
|
||||||
|
nodes.machine =
|
||||||
|
{ pkgs, config, ... }:
|
||||||
|
{
|
||||||
|
imports = [ ./module.nix ];
|
||||||
|
|
||||||
|
services.django.smoketest = {
|
||||||
|
src = project;
|
||||||
|
inherit settings secrets;
|
||||||
|
port = 8000;
|
||||||
|
};
|
||||||
|
services.nginx = {
|
||||||
|
enable = true;
|
||||||
|
recommendedProxySettings = true;
|
||||||
|
virtualHosts.netbox = {
|
||||||
|
default = true;
|
||||||
|
locations."/".proxyPass = "http://localhost:${toString config.services.django.smoketest.port}";
|
||||||
|
locations."/static/".alias = config.services.django.smoketest.staticAssets;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
testScript = ''
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
start_all()
|
||||||
|
machine.wait_for_unit("django-smoketest.service")
|
||||||
|
machine.wait_for_unit("nginx.service")
|
||||||
|
time.sleep(1)
|
||||||
|
with subtest("Test settings"):
|
||||||
|
status, out = machine.execute("curl http://127.0.0.1/test_settings/")
|
||||||
|
print(status, repr(out))
|
||||||
|
if status != 0 or out != "${settings.SMOKE_TEST}":
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with subtest("Test secrets"):
|
||||||
|
status, out = machine.execute("curl http://127.0.0.1/test_secrets/")
|
||||||
|
print(status, repr(out))
|
||||||
|
if status != 0 or out != json.load(open("${secretFile}")):
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with subtest("Test json"):
|
||||||
|
status, out = machine.execute("curl http://127.0.0.1:8000/test_json/")
|
||||||
|
print(status, repr(out))
|
||||||
|
if status != 0 or json.loads(out) != json.loads('${builtins.toJSON settings.JSON_SMOKE_TEST}'):
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
)
|
1
test_assets/secret
Normal file
1
test_assets/secret
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"secret"
|
9
test_assets/urls.py
Normal file
9
test_assets/urls.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import test_json, test_secrets, test_settings
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("test_settings/", test_settings),
|
||||||
|
path("test_secrets/", test_secrets),
|
||||||
|
path("test_json/", test_json),
|
||||||
|
]
|
16
test_assets/views.py
Normal file
16
test_assets/views.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings(request):
|
||||||
|
return HttpResponse(settings.SMOKE_TEST, content_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
def test_secrets(request):
|
||||||
|
return HttpResponse(settings.SECRETS_SMOKE_TEST, content_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
def test_json(request):
|
||||||
|
return HttpResponse(json.dumps(settings.JSON_SMOKE_TEST), content_type="application/json")
|
20
utils/mkManagePy.nix
Normal file
20
utils/mkManagePy.nix
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
writeShellApplication,
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
app,
|
||||||
|
pythonPackage,
|
||||||
|
manageFilePath,
|
||||||
|
src,
|
||||||
|
}:
|
||||||
|
writeShellApplication {
|
||||||
|
name = "manage-${app}";
|
||||||
|
runtimeInputs = [ pythonPackage ];
|
||||||
|
text = ''
|
||||||
|
PYTHON_PATH=${src} \
|
||||||
|
DJANGO_SETTINGS_MODULE=''${DJANGO_SETTINGS_MODULE:=${app}_settings.prod} \
|
||||||
|
python ${src}/${manageFilePath} "$@"
|
||||||
|
'';
|
||||||
|
}
|
70
utils/mkSettingsModule.nix
Normal file
70
utils/mkSettingsModule.nix
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
buildPythonPackage,
|
||||||
|
hatchling,
|
||||||
|
loadcredential,
|
||||||
|
runtimeSettings,
|
||||||
|
settings,
|
||||||
|
secrets,
|
||||||
|
mainModule,
|
||||||
|
extraConfig,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
secretsImport = lib.mapAttrsToList (k: _: "${k} = credentials.get_json(\"${k}\")") secrets;
|
||||||
|
runtimeImport = builtins.map (k: "${k} = credentials.get_json(\"${k}\")") runtimeSettings;
|
||||||
|
settingsImport = lib.mapAttrsToList (k: v: "${k} = json.loads('${builtins.toJSON v}')") settings;
|
||||||
|
prod = ''
|
||||||
|
from loadcredential import Credentials
|
||||||
|
from .mock import *
|
||||||
|
|
||||||
|
credentials = Credentials()
|
||||||
|
|
||||||
|
${lib.concatLines secretsImport}
|
||||||
|
|
||||||
|
${extraConfig}
|
||||||
|
'';
|
||||||
|
mock = ''
|
||||||
|
import json
|
||||||
|
from ${mainModule}.settings import *
|
||||||
|
from loadcredential import Credentials
|
||||||
|
|
||||||
|
credentials = Credentials()
|
||||||
|
|
||||||
|
${lib.concatLines settingsImport}
|
||||||
|
|
||||||
|
${lib.concatLines runtimeImport}
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
buildPythonPackage {
|
||||||
|
name = "${mainModule}-settings";
|
||||||
|
unpackPhase = ''
|
||||||
|
cat > pyproject.toml << EOF
|
||||||
|
[project]
|
||||||
|
name = "${mainModule}-settings"
|
||||||
|
version="0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"loadcredential"
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
requires = ["hatchling"]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mkdir -p src/${mainModule}_settings
|
||||||
|
|
||||||
|
touch src/${mainModule}_settings/__init__.py
|
||||||
|
|
||||||
|
cat > src/${mainModule}_settings/mock.py << EOF
|
||||||
|
${mock}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > src/${mainModule}_settings/prod.py << EOF
|
||||||
|
${prod}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
'';
|
||||||
|
pyproject = true;
|
||||||
|
dependencies = [ loadcredential ];
|
||||||
|
build-system = [ hatchling ];
|
||||||
|
}
|
Loading…
Reference in a new issue