diff --git a/maintainers/maintainer-list.nix b/maintainers/maintainer-list.nix index 58932ecdc51..8cc8ea9d9b6 100644 --- a/maintainers/maintainer-list.nix +++ b/maintainers/maintainer-list.nix @@ -9821,6 +9821,17 @@ githubId = 645664; name = "Philippe Hürlimann"; }; + phaer = { + name = "Paul Haerle"; + email = "nix@phaer.org"; + + matrix = "@phaer:matrix.org"; + github = "phaer"; + githubId = 101753; + keys = [{ + fingerprint = "5D69 CF04 B7BC 2BC1 A567 9267 00BC F29B 3208 0700"; + }]; + }; philandstuff = { email = "philip.g.potter@gmail.com"; github = "philandstuff"; diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 759c31ef28b..03442d50503 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1057,6 +1057,7 @@ ./services/web-apps/gerrit.nix ./services/web-apps/gotify-server.nix ./services/web-apps/grocy.nix + ./services/web-apps/healthchecks.nix ./services/web-apps/hedgedoc.nix ./services/web-apps/hledger-web.nix ./services/web-apps/icingaweb2/icingaweb2.nix diff --git a/nixos/modules/services/web-apps/healthchecks.nix b/nixos/modules/services/web-apps/healthchecks.nix new file mode 100644 index 00000000000..be025e7dd55 --- /dev/null +++ b/nixos/modules/services/web-apps/healthchecks.nix @@ -0,0 +1,249 @@ +{ config, lib, pkgs, buildEnv, ... }: + +with lib; + +let + defaultUser = "healthchecks"; + cfg = config.services.healthchecks; + pkg = cfg.package; + boolToPython = b: if b then "True" else "False"; + environment = { + PYTHONPATH = pkg.pythonPath; + STATIC_ROOT = cfg.dataDir + "/static"; + DB_NAME = "${cfg.dataDir}/healthchecks.sqlite"; + } // cfg.settings; + + environmentFile = pkgs.writeText "healthchecks-environment" (lib.generators.toKeyValue { } environment); + + healthchecksManageScript = with pkgs; (writeShellScriptBin "healthchecks-manage" '' + if [[ "$USER" != "${cfg.user}" ]]; then + echo "please run as user 'healtchecks'." >/dev/stderr + exit 1 + fi + export $(cat ${environmentFile} | xargs); + exec ${pkg}/opt/healthchecks/manage.py "$@" + ''); +in +{ + options.services.healthchecks = { + enable = mkEnableOption "healthchecks" // { + description = '' + Enable healthchecks. + It is expected to be run behind a HTTP reverse proxy. + ''; + }; + + package = mkOption { + default = pkgs.healthchecks; + defaultText = literalExpression "pkgs.healthchecks"; + type = types.package; + description = "healthchecks package to use."; + }; + + user = mkOption { + default = defaultUser; + type = types.str; + description = '' + User account under which healthchecks runs. + + + If left as the default value this user will automatically be created + on system activation, otherwise you are responsible for + ensuring the user exists before the healthchecks service starts. + + ''; + }; + + group = mkOption { + default = defaultUser; + type = types.str; + description = '' + Group account under which healthchecks runs. + + + If left as the default value this group will automatically be created + on system activation, otherwise you are responsible for + ensuring the group exists before the healthchecks service starts. + + ''; + }; + + listenAddress = mkOption { + type = types.str; + default = "localhost"; + description = "Address the server will listen on."; + }; + + port = mkOption { + type = types.port; + default = 8000; + description = "Port the server will listen on."; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/healthchecks"; + description = '' + The directory used to store all data for healthchecks. + + + If left as the default value this directory will automatically be created before + the healthchecks server starts, otherwise you are responsible for ensuring the + directory exists with appropriate ownership and permissions. + + ''; + }; + + settings = lib.mkOption { + description = '' + Environment variables which are read by healthchecks (local)_settings.py. + + Settings which are explictly covered in options bewlow, are type-checked and/or transformed + before added to the environment, everything else is passed as a string. + + See https://healthchecks.io/docs/self_hosted_configuration/ + for a full documentation of settings. + + We add two variables to this list inside the packages local_settings.py. + - STATIC_ROOT to set a state directory for dynamically generated static files. + - SECRET_KEY_FILE to read SECRET_KEY from a file at runtime and keep it out of /nix/store. + ''; + type = types.submodule { + freeformType = types.attrsOf types.str; + options = { + ALLOWED_HOSTS = lib.mkOption { + type = types.listOf types.str; + default = [ "*" ]; + description = "The host/domain names that this site can serve."; + apply = lib.concatStringsSep ","; + }; + + SECRET_KEY_FILE = mkOption { + type = types.path; + description = "Path to a file containing the secret key."; + }; + + DEBUG = mkOption { + type = types.bool; + default = false; + description = "Enable debug mode."; + apply = boolToPython; + }; + + REGISTRATION_OPEN = mkOption { + type = types.bool; + default = false; + description = '' + A boolean that controls whether site visitors can create new accounts. + Set it to false if you are setting up a private Healthchecks instance, + but it needs to be publicly accessible (so, for example, your cloud + services can send pings to it). + If you close new user registration, you can still selectively invite + users to your team account. + ''; + apply = boolToPython; + }; + }; + }; + }; + }; + + config = mkIf cfg.enable { + environment.systemPackages = [ healthchecksManageScript ]; + + systemd.targets.healthchecks = { + description = "Target for all Healthchecks services"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "network-online.target" ]; + }; + + systemd.services = + let + commonConfig = { + WorkingDirectory = cfg.dataDir; + User = cfg.user; + Group = cfg.group; + EnvironmentFile = environmentFile; + StateDirectory = mkIf (cfg.dataDir == "/var/lib/healthchecks") "healthchecks"; + StateDirectoryMode = mkIf (cfg.dataDir == "/var/lib/healthchecks") "0750"; + }; + in + { + healthchecks-migration = { + description = "Healthchecks migrations"; + wantedBy = [ "healthchecks.target" ]; + + serviceConfig = commonConfig // { + Restart = "on-failure"; + Type = "oneshot"; + ExecStart = '' + ${pkg}/opt/healthchecks/manage.py migrate + ''; + }; + }; + + healthchecks = { + description = "Healthchecks WSGI Service"; + wantedBy = [ "healthchecks.target" ]; + after = [ "healthchecks-migration.service" ]; + + preStart = '' + ${pkg}/opt/healthchecks/manage.py collectstatic --no-input + ${pkg}/opt/healthchecks/manage.py remove_stale_contenttypes --no-input + ${pkg}/opt/healthchecks/manage.py compress + ''; + + serviceConfig = commonConfig // { + Restart = "always"; + ExecStart = '' + ${pkgs.python3Packages.gunicorn}/bin/gunicorn hc.wsgi \ + --bind ${cfg.listenAddress}:${toString cfg.port} \ + --pythonpath ${pkg}/opt/healthchecks + ''; + }; + }; + + healthchecks-sendalerts = { + description = "Healthchecks Alert Service"; + wantedBy = [ "healthchecks.target" ]; + after = [ "healthchecks.service" ]; + + serviceConfig = commonConfig // { + Restart = "always"; + ExecStart = '' + ${pkg}/opt/healthchecks/manage.py sendalerts + ''; + }; + }; + + healthchecks-sendreports = { + description = "Healthchecks Reporting Service"; + wantedBy = [ "healthchecks.target" ]; + after = [ "healthchecks.service" ]; + + serviceConfig = commonConfig // { + Restart = "always"; + ExecStart = '' + ${pkg}/opt/healthchecks/manage.py sendreports --loop + ''; + }; + }; + }; + + users.users = optionalAttrs (cfg.user == defaultUser) { + ${defaultUser} = + { + description = "healthchecks service owner"; + isSystemUser = true; + group = defaultUser; + }; + }; + + users.groups = optionalAttrs (cfg.user == defaultUser) { + ${defaultUser} = + { + members = [ defaultUser ]; + }; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 671b1a8876a..6a72130be5f 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -203,6 +203,7 @@ in { haste-server = handleTest ./haste-server.nix {}; haproxy = handleTest ./haproxy.nix {}; hardened = handleTest ./hardened.nix {}; + healthchecks = handleTest ./web-apps/healthchecks.nix {}; hbase1 = handleTest ./hbase.nix { package=pkgs.hbase1; }; hbase2 = handleTest ./hbase.nix { package=pkgs.hbase2; }; hbase3 = handleTest ./hbase.nix { package=pkgs.hbase3; }; diff --git a/nixos/tests/web-apps/healthchecks.nix b/nixos/tests/web-apps/healthchecks.nix new file mode 100644 index 00000000000..41374f5e314 --- /dev/null +++ b/nixos/tests/web-apps/healthchecks.nix @@ -0,0 +1,42 @@ +import ../make-test-python.nix ({ lib, pkgs, ... }: { + name = "healthchecks"; + + meta = with lib.maintainers; { + maintainers = [ phaer ]; + }; + + nodes.machine = { ... }: { + services.healthchecks = { + enable = true; + settings = { + SITE_NAME = "MyUniqueInstance"; + COMPRESS_ENABLED = "True"; + SECRET_KEY_FILE = pkgs.writeText "secret" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + }; + }; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("healthchecks.target") + machine.wait_until_succeeds("journalctl --since -1m --unit healthchecks --grep Listening") + + with subtest("Home screen loads"): + machine.succeed( + "curl -sSfL http://localhost:8000 | grep 'Sign In'" + ) + + with subtest("Setting SITE_NAME via freeform option works"): + machine.succeed( + "curl -sSfL http://localhost:8000 | grep 'MyUniqueInstance'" + ) + + with subtest("Manage script works"): + # Should fail if not called by healthchecks user + machine.fail("echo 'print(\"foo\")' | healthchecks-manage help") + + # "shell" sucommand should succeed, needs python in PATH. + assert "foo\n" == machine.succeed("echo 'print(\"foo\")' | sudo -u healthchecks healthchecks-manage shell") + ''; +}) diff --git a/pkgs/development/python-modules/cron-descriptor/default.nix b/pkgs/development/python-modules/cron-descriptor/default.nix new file mode 100644 index 00000000000..582ef48199f --- /dev/null +++ b/pkgs/development/python-modules/cron-descriptor/default.nix @@ -0,0 +1,36 @@ +{ lib +, python +, buildPythonPackage +, fetchFromGitHub +, pytestCheckHook +}: + +buildPythonPackage rec { + pname = "cron_descriptor"; + version = "1.2.24"; + + src = fetchFromGitHub { + owner = "Salamek"; + repo = "cron-descriptor"; + rev = version; + sha256 = "sha256-Gf7n8OiFuaN+8MqsXSg9RBPh2gXfPgjJ4xeuinGYKMw="; + }; + + # remove tests_require, as we don't do linting anyways + postPatch = '' + sed -i "/'pep8\|flake8\|pep8-naming',/d" setup.py + ''; + + checkPhase = '' + ${python.interpreter} setup.py test + ''; + + pythonImportsCheck = [ "cron_descriptor" ]; + + meta = with lib; { + description = "Library that converts cron expressions into human readable strings"; + homepage = "https://github.com/Salamek/cron-descriptor"; + license = licenses.mit; + maintainers = with maintainers; [ phaer ]; + }; +} diff --git a/pkgs/development/python-modules/cronsim/default.nix b/pkgs/development/python-modules/cronsim/default.nix new file mode 100644 index 00000000000..35208bbd7b7 --- /dev/null +++ b/pkgs/development/python-modules/cronsim/default.nix @@ -0,0 +1,28 @@ +{ lib +, buildPythonPackage +, fetchPypi +, pytestCheckHook +}: + +buildPythonPackage rec { + pname = "cronsim"; + version = "2.1"; + + src = fetchPypi { + inherit pname version; + sha256 = "sha256-nwlSAbD+y0l9jyVSVShzWeC7nC5RZRD/kAhCi3Nd9xY="; + }; + + checkInputs = [ + pytestCheckHook + ]; + + pythonImportsCheck = [ "cronsim" ]; + + meta = with lib; { + description = "Cron expression parser and evaluator"; + homepage = "https://github.com/cuu508/cronsim"; + license = licenses.bsd3; + maintainers = with maintainers; [ phaer ]; + }; +} diff --git a/pkgs/development/python-modules/segno/default.nix b/pkgs/development/python-modules/segno/default.nix new file mode 100644 index 00000000000..5db60d0d0bd --- /dev/null +++ b/pkgs/development/python-modules/segno/default.nix @@ -0,0 +1,34 @@ +{ lib +, buildPythonPackage +, fetchFromGitHub +, pytestCheckHook +, pypng +, pyzbar +}: + +buildPythonPackage rec { + pname = "segno"; + version = "1.5.2"; + + src = fetchFromGitHub { + owner = "heuer"; + repo = "segno"; + rev = version; + sha256 = "sha256-+OEXG5OvrZ5Ft7IO/7zodf+SgiRF+frwjltrBENNnHo="; + }; + + checkInputs = [ + pytestCheckHook + pypng + pyzbar + ]; + + pythonImportsCheck = [ "segno" ]; + + meta = with lib; { + description = "QR Code and Micro QR Code encoder"; + homepage = "https://github.com/heuer/segno/"; + license = licenses.bsd3; + maintainers = with maintainers; [ phaer ]; + }; +} diff --git a/pkgs/servers/web-apps/healthchecks/default.nix b/pkgs/servers/web-apps/healthchecks/default.nix new file mode 100644 index 00000000000..aef269907ac --- /dev/null +++ b/pkgs/servers/web-apps/healthchecks/default.nix @@ -0,0 +1,84 @@ +{ lib +, writeText +, fetchFromGitHub +, nixosTests +, python3 +}: +let + py = python3.override { + packageOverrides = final: prev: { + django = prev.django_4; + fido2 = prev.fido2.overridePythonAttrs (old: rec { + version = "0.9.3"; + src = prev.fetchPypi { + pname = "fido2"; + inherit version; + sha256 = "sha256-tF6JphCc/Lfxu1E3dqotZAjpXEgi+DolORi5RAg0Zuw="; + }; + }); + }; + }; +in +py.pkgs.buildPythonApplication rec { + pname = "healthchecks"; + version = "2.2.1"; + format = "other"; + + src = fetchFromGitHub { + owner = "healthchecks"; + repo = pname; + rev = "v${version}"; + sha256 = "sha256-C+NUvs5ijbj/l8G1sjSXvUJDNSOTVFAStfS5KtYFpUs="; + }; + + propagatedBuildInputs = with py.pkgs; [ + apprise + cffi + cron-descriptor + cronsim + cryptography + django + django_compressor + fido2 + minio + psycopg2 + py + pyotp + requests + segno + statsd + whitenoise + ]; + + localSettings = writeText "local_settings.py" '' + import os + STATIC_ROOT = os.getenv("STATIC_ROOT") + SECRET_KEY_FILE = os.getenv("SECRET_KEY_FILE") + if SECRET_KEY_FILE: + with open(SECRET_KEY_FILE, "r") as file: + SECRET_KEY = file.readline() + ''; + + installPhase = '' + mkdir -p $out/opt/healthchecks + cp -r . $out/opt/healthchecks + chmod +x $out/opt/healthchecks/manage.py + cp ${localSettings} $out/opt/healthchecks/hc/local_settings.py + ''; + + passthru = { + # PYTHONPATH of all dependencies used by the package + pythonPath = py.pkgs.makePythonPath propagatedBuildInputs; + + tests = { + inherit (nixosTests) healthchecks; + }; + }; + + meta = with lib; { + homepage = "https://github.com/healthchecks/healthchecks"; + description = "A cron monitoring tool written in Python & Django "; + license = licenses.bsd3; + maintainers = with maintainers; [ phaer ]; + }; +} diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index 7e164b393ba..f2d5472383b 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -7148,6 +7148,8 @@ with pkgs; buildGoModule = buildGo118Module; }; + healthchecks = callPackage ../servers/web-apps/healthchecks { }; + heisenbridge = callPackage ../servers/heisenbridge { }; helio-workstation = callPackage ../applications/audio/helio-workstation { }; diff --git a/pkgs/top-level/python-packages.nix b/pkgs/top-level/python-packages.nix index bbffc10a2de..3eb09abe3e4 100644 --- a/pkgs/top-level/python-packages.nix +++ b/pkgs/top-level/python-packages.nix @@ -1974,8 +1974,12 @@ in { criticality-score = callPackage ../development/python-modules/criticality-score { }; + cron-descriptor = callPackage ../development/python-modules/cron-descriptor { }; + croniter = callPackage ../development/python-modules/croniter { }; + cronsim = callPackage ../development/python-modules/cronsim { }; + crossplane = callPackage ../development/python-modules/crossplane { }; crownstone-cloud = callPackage ../development/python-modules/crownstone-cloud { }; @@ -9585,6 +9589,8 @@ in { segments = callPackage ../development/python-modules/segments { }; + segno = callPackage ../development/python-modules/segno { }; + segyio = toPythonModule (callPackage ../development/python-modules/segyio { inherit (pkgs) cmake ninja; });