Unverified Commit 4f7fc6df authored by Sandro Jäckel's avatar Sandro Jäckel Committed by GitHub
Browse files

nixos/glitchtip: init module (#386013)

parents 1eeeaa08 dd87a30c
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -141,6 +141,8 @@

- [Zipline](https://zipline.diced.sh/), a ShareX/file upload server that is easy to use, packed with features, and with an easy setup. Available as [services.zipline](#opt-services.zipline.enable).

- [GlitchTip](https://glitchtip.com/), an open source Sentry API compatible error tracking platform. Available as [services.glitchtip](#opt-services.glitchtip.enable).

- [Stash](https://github.com/stashapp/stash), An organizer for your adult videos/images, written in Go. Available as [services.stash](#opt-services.stash.enable).

- [vsmartcard-vpcd](https://frankmorgner.github.io/vsmartcard/virtualsmartcard/README.html), a virtual smart card driver. Available as [services.vsmartcard-vpcd](#opt-services.vsmartcard-vpcd.enable).
+1 −0
Original line number Diff line number Diff line
@@ -1502,6 +1502,7 @@
  ./services/web-apps/gancio.nix
  ./services/web-apps/gerrit.nix
  ./services/web-apps/glance.nix
  ./services/web-apps/glitchtip.nix
  ./services/web-apps/gotify-server.nix
  ./services/web-apps/gotosocial.nix
  ./services/web-apps/grav.nix
+293 −0
Original line number Diff line number Diff line
{
  config,
  pkgs,
  lib,
  ...
}:

let
  cfg = config.services.glitchtip;
  pkg = cfg.package;
  inherit (pkg.passthru) python;

  environment = lib.mapAttrs (
    _: value:
    if value == true then
      "True"
    else if value == false then
      "False"
    else
      toString value
  ) cfg.settings;
in

{
  meta.maintainers = with lib.maintainers; [
    defelo
    felbinger
  ];

  options = {
    services.glitchtip = {
      enable = lib.mkEnableOption "GlitchTip";

      package = lib.mkPackageOption pkgs "glitchtip" { };

      user = lib.mkOption {
        type = lib.types.str;
        description = "The user account under which GlitchTip runs.";
        default = "glitchtip";
      };

      group = lib.mkOption {
        type = lib.types.str;
        description = "The group under which GlitchTip runs.";
        default = "glitchtip";
      };

      listenAddress = lib.mkOption {
        type = lib.types.str;
        description = "The address to listen on.";
        default = "127.0.0.1";
        example = "0.0.0.0";
      };

      port = lib.mkOption {
        type = lib.types.port;
        description = "The port to listen on.";
        default = 8000;
      };

      settings = lib.mkOption {
        description = ''
          Configuration of GlitchTip. See <https://glitchtip.com/documentation/install#configuration> for more information.
        '';
        default = { };
        defaultText = lib.literalExpression ''
          {
            DEBUG = 0;
            DEBUG_TOOLBAR = 0;
            DATABASE_URL = lib.mkIf config.services.glitchtip.database.createLocally "postgresql://@/glitchtip";
            REDIS_URL = lib.mkIf config.services.glitchtip.redis.createLocally "unix://''${config.services.redis.servers.glitchtip.unixSocket}";
            CELERY_BROKER_URL = lib.mkIf config.services.glitchtip.redis.createLocally "redis+socket://''${config.services.redis.servers.glitchtip.unixSocket}";
          }
        '';
        example = {
          GLITCHTIP_DOMAIN = "https://glitchtip.example.com";
          DATABASE_URL = "postgres://postgres:postgres@postgres/postgres";
        };

        type = lib.types.submodule {
          freeformType =
            with lib.types;
            attrsOf (oneOf [
              str
              int
              bool
            ]);

          options = {
            GLITCHTIP_DOMAIN = lib.mkOption {
              type = lib.types.str;
              description = "The URL under which GlitchTip is externally reachable.";
              example = "https://glitchtip.example.com";
            };

            ENABLE_USER_REGISTRATION = lib.mkOption {
              type = lib.types.bool;
              description = ''
                When true, any user will be able to register. When false, user self-signup is disabled after the first user is registered. Subsequent users must be created by a superuser on the backend and organization invitations may only be sent to existing users.
              '';
              default = false;
            };

            ENABLE_ORGANIZATION_CREATION = lib.mkOption {
              type = lib.types.bool;
              description = ''
                When false, only superusers will be able to create new organizations after the first. When true, any user can create a new organization.
              '';
              default = false;
            };
          };
        };
      };

      environmentFiles = lib.mkOption {
        type = lib.types.listOf lib.types.path;
        default = [ ];
        example = [ "/run/secrets/glitchtip.env" ];
        description = ''
          Files to load environment variables from in addition to [](#opt-services.glitchtip.settings).
          This is useful to avoid putting secrets into the nix store.
          See <https://glitchtip.com/documentation/install#configuration> for more information.
        '';
      };

      database.createLocally = lib.mkOption {
        type = lib.types.bool;
        default = true;
        description = ''
          Whether to enable and configure a local PostgreSQL database server.
        '';
      };

      redis.createLocally = lib.mkOption {
        type = lib.types.bool;
        default = true;
        description = ''
          Whether to enable and configure a local Redis instance.
        '';
      };

      gunicorn.extraArgs = lib.mkOption {
        type = lib.types.listOf lib.types.str;
        default = [ ];
        description = "Extra arguments for gunicorn.";
      };

      celery.extraArgs = lib.mkOption {
        type = lib.types.listOf lib.types.str;
        default = [ ];
        description = "Extra arguments for celery.";
      };
    };
  };

  config = lib.mkIf cfg.enable {
    services.glitchtip.settings = {
      DEBUG = lib.mkDefault 0;
      DEBUG_TOOLBAR = lib.mkDefault 0;
      PYTHONPATH = "${python.pkgs.makePythonPath pkg.propagatedBuildInputs}:${pkg}/lib/glitchtip";
      DATABASE_URL = lib.mkIf cfg.database.createLocally "postgresql://@/glitchtip";
      REDIS_URL = lib.mkIf cfg.redis.createLocally "unix://${config.services.redis.servers.glitchtip.unixSocket}";
      CELERY_BROKER_URL = lib.mkIf cfg.redis.createLocally "redis+socket://${config.services.redis.servers.glitchtip.unixSocket}";
      GLITCHTIP_VERSION = pkg.version;
    };

    systemd.services =
      let
        commonService = {
          wantedBy = [ "multi-user.target" ];

          wants = [ "network-online.target" ];
          requires =
            lib.optional cfg.database.createLocally "postgresql.service"
            ++ lib.optional cfg.redis.createLocally "redis-glitchtip.service";
          after =
            [ "network-online.target" ]
            ++ lib.optional cfg.database.createLocally "postgresql.service"
            ++ lib.optional cfg.redis.createLocally "redis-glitchtip.service";

          inherit environment;
        };

        commonServiceConfig = {
          User = cfg.user;
          Group = cfg.group;
          RuntimeDirectory = "glitchtip";
          StateDirectory = "glitchtip";
          EnvironmentFile = cfg.environmentFiles;
          WorkingDirectory = "${pkg}/lib/glitchtip";

          # hardening
          AmbientCapabilities = "";
          CapabilityBoundingSet = [ "" ];
          DevicePolicy = "closed";
          LockPersonality = true;
          MemoryDenyWriteExecute = true;
          NoNewPrivileges = true;
          PrivateDevices = true;
          PrivateTmp = true;
          PrivateUsers = true;
          ProcSubset = "pid";
          ProtectClock = true;
          ProtectControlGroups = true;
          ProtectHome = true;
          ProtectHostname = true;
          ProtectKernelLogs = true;
          ProtectKernelModules = true;
          ProtectKernelTunables = true;
          ProtectProc = "invisible";
          ProtectSystem = "strict";
          RemoveIPC = true;
          RestrictAddressFamilies = [ "AF_INET AF_INET6 AF_UNIX" ];
          RestrictNamespaces = true;
          RestrictRealtime = true;
          RestrictSUIDSGID = true;
          SystemCallArchitectures = "native";
          SystemCallFilter = [
            "@system-service"
            "~@privileged"
            "~@resources"
          ];
          UMask = "0077";
        };
      in
      {
        glitchtip = commonService // {
          description = "GlitchTip";

          preStart = ''
            ${lib.getExe pkg} migrate
          '';

          serviceConfig = commonServiceConfig // {
            ExecStart = ''
              ${lib.getExe python.pkgs.gunicorn} \
                --bind=${cfg.listenAddress}:${toString cfg.port} \
                ${lib.concatStringsSep " " cfg.gunicorn.extraArgs} \
                glitchtip.wsgi
            '';
          };
        };

        glitchtip-worker = commonService // {
          description = "GlitchTip Job Runner";

          serviceConfig = commonServiceConfig // {
            ExecStart = ''
              ${lib.getExe python.pkgs.celery} \
                -A glitchtip worker \
                -B -s /run/glitchtip/celerybeat-schedule \
                ${lib.concatStringsSep " " cfg.celery.extraArgs}
            '';
          };
        };
      };

    services.postgresql = lib.mkIf cfg.database.createLocally {
      enable = true;
      ensureDatabases = [ "glitchtip" ];
      ensureUsers = [
        {
          name = "glitchtip";
          ensureDBOwnership = true;
        }
      ];
    };

    services.redis.servers.glitchtip.enable = cfg.redis.createLocally;

    users.users = lib.mkIf (cfg.user == "glitchtip") {
      glitchtip = {
        home = "/var/lib/glitchtip";
        group = cfg.group;
        extraGroups = lib.optionals cfg.redis.createLocally [ "redis-glitchtip" ];
        isSystemUser = true;
      };
    };

    users.groups = lib.mkIf (cfg.group == "glitchtip") { glitchtip = { }; };

    environment.systemPackages =
      let
        glitchtip-manage = pkgs.writeShellScriptBin "glitchtip-manage" ''
          set -o allexport
          ${lib.toShellVars environment}
          ${lib.concatMapStringsSep "\n" (f: "source ${f}") cfg.environmentFiles}
          ${config.security.wrapperDir}/sudo -E -u ${cfg.user} ${lib.getExe pkg} "$@"
        '';
      in
      [ glitchtip-manage ];
  };
}
+1 −0
Original line number Diff line number Diff line
@@ -434,6 +434,7 @@ in {
  gitolite-fcgiwrap = handleTest ./gitolite-fcgiwrap.nix {};
  glance = runTest ./glance.nix;
  glances = runTest ./glances.nix;
  glitchtip = runTest ./glitchtip.nix;
  glusterfs = handleTest ./glusterfs.nix {};
  gnome = handleTest ./gnome.nix {};
  gnome-extensions = handleTest ./gnome-extensions.nix {};
+103 −0
Original line number Diff line number Diff line
{ lib, ... }:

let
  domain = "http://glitchtip.local:8000";
in

{
  name = "glitchtip";
  meta.maintainers = with lib.maintainers; [
    defelo
    felbinger
  ];

  nodes.machine =
    { pkgs, ... }:
    {
      services.glitchtip = {
        enable = true;
        port = 8000;
        settings.GLITCHTIP_DOMAIN = domain;
        environmentFiles = [
          (builtins.toFile "glitchtip.env" ''
            SECRET_KEY=8Hz7YCGzo7fiicHb8Qr22ZqwoIB7lSRx
          '')
        ];
      };

      environment.systemPackages = [ pkgs.sentry-cli ];

      networking.hosts."127.0.0.1" = [ "glitchtip.local" ];
    };

  interactive.nodes.machine = {
    services.glitchtip.listenAddress = "0.0.0.0";
    networking.firewall.allowedTCPPorts = [ 8000 ];
    virtualisation.forwardPorts = [
      {
        from = "host";
        host.port = 8000;
        guest.port = 8000;
      }
    ];
  };

  testScript = ''
    import json
    import re
    import time

    machine.wait_for_unit("glitchtip.service")
    machine.wait_for_unit("glitchtip-worker.service")
    machine.wait_for_open_port(8000)

    origin_url = "${domain}"
    cookie_jar_path = "/tmp/cookies.txt"
    curl = f"curl -b {cookie_jar_path} -c {cookie_jar_path} -fS -H 'Origin: {origin_url}'"

    # create superuser account
    machine.succeed("DJANGO_SUPERUSER_PASSWORD=password glitchtip-manage createsuperuser --no-input --email=admin@example.com")

    # login
    machine.fail(f"{curl} -s {origin_url}/_allauth/browser/v1/auth/session")  # get the csrf token, returns a 401
    csrf_token = machine.succeed(f"grep csrftoken {cookie_jar_path} | cut -f7").rstrip()
    machine.succeed(f"{curl} {origin_url}/_allauth/browser/v1/auth/login -s -H 'X-Csrftoken: {csrf_token}' -H 'Content-Type: application/json' -d '{{\"email\": \"admin@example.com\", \"password\": \"password\"}}'")

    resp = json.loads(machine.succeed(f"{curl} {origin_url}/api/0/users/me/"))
    assert resp["email"] == "admin@example.com"
    assert resp["isSuperuser"] is True

    # create organization
    csrf_token = machine.succeed(f"grep csrftoken {cookie_jar_path} | cut -f7").rstrip()
    machine.succeed(f"{curl} {origin_url}/api/0/organizations/ -s -H 'X-Csrftoken: {csrf_token}' -H 'Content-Type: application/json' -d '{{\"name\": \"main\"}}'")

    resp = json.loads(machine.succeed(f"{curl} {origin_url}/api/0/organizations/"))
    assert len(resp) == 1
    assert resp[0]["name"] == "main"
    assert resp[0]["slug"] == "main"

    # create team
    csrf_token = machine.succeed(f"grep csrftoken {cookie_jar_path} | cut -f7").rstrip()
    machine.succeed(f"{curl} {origin_url}/api/0/organizations/main/teams/ -s -H 'X-Csrftoken: {csrf_token}' -H 'Content-Type: application/json' -d '{{\"slug\": \"test\"}}'")

    # create project
    csrf_token = machine.succeed(f"grep csrftoken {cookie_jar_path} | cut -f7").rstrip()
    machine.succeed(f"{curl} {origin_url}/api/0/teams/main/test/projects/ -s -H 'X-Csrftoken: {csrf_token}' -H 'Content-Type: application/json' -d '{{\"name\": \"test\"}}'")

    # fetch dsn
    resp = json.loads(machine.succeed(f"{curl} {origin_url}/api/0/projects/main/test/keys/"))
    assert len(resp) == 1
    assert re.match(r"^http://[\da-f]+@glitchtip\.local:8000/\d+$", dsn := resp[0]["dsn"]["public"])

    # send event
    machine.succeed(f"SENTRY_DSN={dsn} sentry-cli send-event -m 'hello world'")

    for _ in range(20):
      resp = json.loads(machine.succeed(f"{curl} {origin_url}/api/0/organizations/main/issues/?query=is:unresolved"))
      if len(resp) != 0: break
      time.sleep(1)
    assert len(resp) == 1
    assert resp[0]["title"] == "hello world"
    assert int(resp[0]["count"]) == 1
  '';
}
Loading