oncall: init at 2.1.7; nixos/oncall: init (#388723)

This commit is contained in:
Jonas Heinrich
2025-05-13 23:21:54 +02:00
committed by GitHub
12 changed files with 783 additions and 0 deletions
@@ -58,6 +58,8 @@
- [vwifi](https://github.com/Raizo62/vwifi), a Wi-Fi simulator daemon leveraging the `mac80211_hwsim` and `vhost_vsock` kernel modules for efficient simulation of multi-node Wi-Fi networks. Available as {option}`services.vwifi`.
- [Oncall](https://oncall.tools), a web-based calendar tool designed for scheduling and managing on-call shifts. Available as [services.oncall](options.html#opt-services.oncall).
- [Homer](https://homer-demo.netlify.app/), a very simple static homepage for your server. Available as [services.homer](options.html#opt-services.homer).
- [Ghidra](https://ghidra-sre.org/), a software reverse engineering (SRE) suite of tools. Available as [programs.ghidra](options.html#opt-programs.ghidra).
+1
View File
@@ -1608,6 +1608,7 @@
./services/web-apps/nostr-rs-relay.nix
./services/web-apps/ocis.nix
./services/web-apps/olivetin.nix
./services/web-apps/oncall.nix
./services/web-apps/onlyoffice.nix
./services/web-apps/open-web-calendar.nix
./services/web-apps/openvscode-server.nix
+203
View File
@@ -0,0 +1,203 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.oncall;
settingsFormat = pkgs.formats.yaml { };
configFile = settingsFormat.generate "oncall_extra_settings.yaml" cfg.settings;
in
{
options.services.oncall = {
enable = lib.mkEnableOption "Oncall web app";
package = lib.mkPackageOption pkgs "oncall" { };
database.createLocally = lib.mkEnableOption "Create the database and database user locally." // {
default = true;
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType = settingsFormat.type;
options = {
oncall_host = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = "FQDN for the Oncall instance.";
};
db.conn = {
kwargs = {
user = lib.mkOption {
type = lib.types.str;
default = "oncall";
description = "Database user.";
};
host = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = "Database host.";
};
database = lib.mkOption {
type = lib.types.str;
default = "oncall";
description = "Database name.";
};
};
str = lib.mkOption {
type = lib.types.str;
default = "%(scheme)s://%(user)s@%(host)s:%(port)s/%(database)s?charset=%(charset)s&unix_socket=/run/mysqld/mysqld.sock";
description = ''
Database connection scheme. The default specifies the
connection through a local socket.
'';
};
require_auth = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether authentication is required to access the web app.
'';
};
};
};
};
default = { };
description = ''
Extra configuration options to append or override.
For available and default option values see
[upstream configuration file](https://github.com/linkedin/oncall/blob/master/configs/config.yaml)
and the administration part in the
[offical documentation](https://oncall.tools/docs/admin_guide.html).
'';
};
secretFile = lib.mkOption {
type = lib.types.pathWith {
inStore = false;
absolute = true;
};
example = "/run/keys/oncall-dbpassword";
description = ''
A YAML file containing secrets such as database or user passwords.
Some variables that can be considered secrets are:
- db.conn.kwargs.password:
Password used to authenticate to the database.
- session.encrypt_key:
Key for encrypting/signing session cookies.
Change to random long values in production.
- session.sign_key:
Key for encrypting/signing session cookies.
Change to random long values in production.
'';
};
};
config = lib.mkIf cfg.enable {
# Disable debug, only needed for development
services.oncall.settings = lib.mkMerge [
({
debug = lib.mkDefault false;
auth.debug = lib.mkDefault false;
})
];
services.uwsgi = {
enable = true;
plugins = [ "python3" ];
user = "oncall";
instance = {
type = "emperor";
vassals = {
oncall = {
type = "normal";
env = [
"PYTHONPATH=${pkgs.oncall.pythonPath}"
(
"ONCALL_EXTRA_CONFIG="
+ (lib.concatStringsSep "," (
[ configFile ] ++ lib.optional (cfg.secretFile != null) cfg.secretFile
))
)
"STATIC_ROOT=/var/lib/oncall"
];
module = "oncall.app:get_wsgi_app()";
socket = "${config.services.uwsgi.runDir}/oncall.sock";
socketGroup = "nginx";
immediate-gid = "nginx";
chmod-socket = "770";
pyargv = "${pkgs.oncall}/share/configs/config.yaml";
buffer-size = 32768;
};
};
};
};
services.nginx = {
enable = lib.mkDefault true;
virtualHosts."${cfg.settings.oncall_host}".locations = {
"/".extraConfig = "uwsgi_pass unix://${config.services.uwsgi.runDir}/oncall.sock;";
};
};
services.mysql = lib.mkIf cfg.database.createLocally {
enable = true;
package = lib.mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.settings.db.conn.kwargs.database ];
ensureUsers = [
{
name = cfg.settings.db.conn.kwargs.user;
ensurePermissions = {
"${cfg.settings.db.conn.kwargs.database}.*" = "ALL PRIVILEGES";
};
}
];
};
users.users.oncall = {
group = "nginx";
isSystemUser = true;
};
systemd = {
services = {
uwsgi.serviceConfig.StateDirectory = "oncall";
oncall-setup-database = lib.mkIf cfg.database.createLocally {
description = "Set up Oncall database";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
requiredBy = [ "uwsgi.service" ];
after = [ "mysql.service" ];
script =
let
mysql = "${lib.getExe' config.services.mysql.package "mysql"}";
in
''
if [ ! -f /var/lib/oncall/.dbexists ]; then
# Load database schema provided with package
${mysql} ${cfg.settings.db.conn.kwargs.database} < ${cfg.package}/share/db/schema.v0.sql
${mysql} ${cfg.settings.db.conn.kwargs.database} < ${cfg.package}/share/db/schema-update.v0-1602184489.sql
touch /var/lib/oncall/.dbexists
fi
'';
};
};
};
};
meta.maintainers = with lib.maintainers; [ onny ];
}
+1
View File
@@ -618,6 +618,7 @@ in
odoo = handleTest ./odoo.nix { };
odoo17 = handleTest ./odoo.nix { package = pkgs.odoo17; };
odoo16 = handleTest ./odoo.nix { package = pkgs.odoo16; };
oncall = runTest ./web-apps/oncall.nix;
# 9pnet_virtio used to mount /nix partition doesn't support
# hibernation. This test happens to work on x86_64-linux but
# not on other platforms.
+156
View File
@@ -0,0 +1,156 @@
{
lib,
pkgs,
config,
...
}:
let
ldapDomain = "example.org";
ldapSuffix = "dc=example,dc=org";
ldapRootUser = "root";
ldapRootPassword = "foobar23";
testUser = "myuser";
testPassword = "foobar23";
teamName = "myteam";
in
{
name = "oncall";
meta.maintainers = with lib.maintainers; [ onny ];
nodes = {
machine = {
virtualisation.memorySize = 2048;
environment.etc."oncall-secrets.yml".text = ''
auth:
ldap_bind_password: "${ldapRootPassword}"
'';
environment.systemPackages = [ pkgs.jq ];
services.oncall = {
enable = true;
settings = {
auth = {
module = "oncall.auth.modules.ldap_import";
ldap_url = "ldap://localhost";
ldap_user_suffix = "";
ldap_bind_user = "cn=${ldapRootUser},${ldapSuffix}";
ldap_base_dn = "ou=accounts,${ldapSuffix}";
ldap_search_filter = "(uid=%s)";
import_user = true;
attrs = {
username = "uid";
full_name = "cn";
email = "mail";
mobile = "telephoneNumber";
sms = "mobile";
};
};
};
secretFile = "/etc/oncall-secrets.yml";
};
services.openldap = {
enable = true;
settings = {
children = {
"cn=schema".includes = [
"${pkgs.openldap}/etc/schema/core.ldif"
"${pkgs.openldap}/etc/schema/cosine.ldif"
"${pkgs.openldap}/etc/schema/inetorgperson.ldif"
"${pkgs.openldap}/etc/schema/nis.ldif"
];
"olcDatabase={1}mdb" = {
attrs = {
objectClass = [
"olcDatabaseConfig"
"olcMdbConfig"
];
olcDatabase = "{1}mdb";
olcDbDirectory = "/var/lib/openldap/db";
olcSuffix = ldapSuffix;
olcRootDN = "cn=${ldapRootUser},${ldapSuffix}";
olcRootPW = ldapRootPassword;
};
};
};
};
declarativeContents = {
${ldapSuffix} = ''
dn: ${ldapSuffix}
objectClass: top
objectClass: dcObject
objectClass: organization
o: ${ldapDomain}
dn: ou=accounts,${ldapSuffix}
objectClass: top
objectClass: organizationalUnit
dn: uid=${testUser},ou=accounts,${ldapSuffix}
objectClass: top
objectClass: inetOrgPerson
uid: ${testUser}
userPassword: ${testPassword}
cn: Test User
sn: User
mail: test@example.org
telephoneNumber: 012345678910
mobile: 012345678910
'';
};
};
};
};
testScript = ''
start_all()
machine.wait_for_unit("uwsgi.service")
machine.wait_for_unit("nginx.service")
machine.wait_for_file("/run/uwsgi/oncall.sock")
machine.wait_for_unit("oncall-setup-database.service")
with subtest("Home screen loads"):
machine.succeed(
"curl -sSfL http://[::1]:80 | grep '<title>Oncall</title>'"
)
with subtest("Staticfiles can be fetched"):
machine.wait_until_succeeds(
"curl -sSfL http://[::1]:80/static/bundles/libs.js"
)
with subtest("Staticfiles are generated"):
machine.succeed(
"test -e /var/lib/oncall/static/bundles/libs.js"
)
with subtest("Create and verify team via REST API"):
import json
# Log in and store the session cookie
login_response = machine.succeed("""
curl -sSfL -c cookies -X POST \
--data-raw 'username=${testUser}&password=${testPassword}' \
http://[::1]:80/login
""")
# Parse csrf token
login_response_data = json.loads(login_response)
csrf_token = login_response_data["csrf_token"]
# Create the team
machine.succeed(
f"""curl -sSfL -b cookies -X POST -H 'Content-Type: application/json' -H 'X-CSRF-Token: {csrf_token}' -d '{{"name": "${teamName}", "email": "test@example.com", "scheduling_timezone": "Europe/Berlin", "iris_enabled": false}}' http://[::1]:80/api/v0/teams/"""
)
# Query the created team
machine.succeed("""
curl -sSfL -b cookies http://[::1]:80/api/v0/teams/${teamName} | jq -e '.name == "${teamName}"'
""")
'';
}
+128
View File
@@ -0,0 +1,128 @@
{
lib,
python3,
fetchFromGitHub,
fetchPypi,
oncall,
nixosTests,
# Override Python packages using
# self: super: { pkg = super.pkg.overridePythonAttrs (oldAttrs: { ... }); }
# Applied after defaultOverrides
packageOverrides ? self: super: { },
}:
let
defaultOverrides = [
# Override the version of some packages pinned in Oncall's setup.py
(self: super: {
# Support for Falcon 4.X missing
# https://github.com/linkedin/oncall/issues/430
falcon = super.falcon.overridePythonAttrs (oldAttrs: rec {
version = "3.1.3";
src = fetchFromGitHub {
owner = "falconry";
repo = "falcon";
tag = version;
hash = "sha256-7719gOM8WQVjODwOSo7HpH3HMFFeCGQQYBKktBAevig=";
};
});
})
];
python = python3.override {
self = python;
packageOverrides = lib.composeManyExtensions (defaultOverrides ++ [ packageOverrides ]);
};
in
python.pkgs.buildPythonApplication rec {
pname = "oncall";
version = "2.1.7";
format = "setuptools";
src = fetchFromGitHub {
owner = "linkedin";
repo = pname;
tag = "v${version}";
hash = "sha256-oqzU4UTpmAcZhqRilquxWQVyHv8bqq0AGraiSqwauiI=";
};
patches = [
# Add support for loading extra settings file
./support_extra_config.patch
# Support storing assets in custom state dir
./support_custom_state_dir.patch
# Log Python errors to uwsgi
./verbose_logging.patch
];
dependencies = with python.pkgs; [
beaker
falcon
falcon-cors
gevent
gunicorn
icalendar
irisclient
jinja2
phonenumbers
pymysql
python-ldap
pytz
pyyaml
ujson
webassets
];
postInstall = ''
mkdir "$out/share"
cp -r configs db "$out/share/"
'';
checkInputs = with python.pkgs; [
pytestCheckHook
pytest-mock
];
disabledTestPaths = [
# Tests require running web server
"e2e/test_audit.py"
"e2e/test_events.py"
"e2e/test_ical.py"
"e2e/test_login.py"
"e2e/test_notification.py"
"e2e/test_override.py"
"e2e/test_pin.py"
"e2e/test_populate.py"
"e2e/test_roles.py"
"e2e/test_roster_suggest.py"
"e2e/test_rosters.py"
"e2e/test_schedules.py"
"e2e/test_services.py"
"e2e/test_subscription.py"
"e2e/test_teams.py"
"e2e/test_users.py"
];
pythonImportsCheck = [
"oncall"
];
passthru = {
tests = {
inherit (nixosTests) oncall;
};
inherit python;
pythonPath = "${python.pkgs.makePythonPath dependencies}:${oncall}/${python.sitePackages}";
};
meta = {
description = "A calendar web-app designed for scheduling and managing on-call shifts";
homepage = "http://oncall.tools";
changelog = "https://github.com/linkedin/oncall/blob/${src.tag}/CHANGELOG.md";
license = lib.licenses.bsd2;
maintainers = with lib.maintainers; [ onny ];
mainProgram = "oncall";
};
}
@@ -0,0 +1,56 @@
diff --git a/src/oncall/ui/__init__.py b/src/oncall/ui/__init__.py
index a94fb17..364404a 100644
--- a/src/oncall/ui/__init__.py
+++ b/src/oncall/ui/__init__.py
@@ -18,8 +18,12 @@ from webassets.ext.jinja2 import AssetsExtension
from webassets.script import CommandLineEnvironment
STATIC_ROOT = environ.get('STATIC_ROOT', path.abspath(path.dirname(__file__)))
+SOURCE_ROOT = path.abspath(path.dirname(__file__))
assets_env = AssetsEnvironment(path.join(STATIC_ROOT, 'static'),
url='/static')
+assets_env.cache = False
+assets_env.manifest = False
+assets_env.load_path = [ path.join(SOURCE_ROOT, 'static') ]
assets_env.register('libs', Bundle(
'js/jquery-3.3.1.min.js', 'js/handlebars-4.0.12.min.js', 'js/bootstrap.min.js',
@@ -45,7 +49,7 @@ logger = logging.getLogger('webassets')
logger.addHandler(logging.StreamHandler())
jinja2_env = Jinja2Environment(extensions=[AssetsExtension], autoescape=True)
-jinja2_env.loader = FileSystemLoader(path.join(STATIC_ROOT, 'templates'))
+jinja2_env.loader = FileSystemLoader(path.join(SOURCE_ROOT, 'templates'))
jinja2_env.assets_environment = assets_env
_filename_ascii_strip_re = re.compile(r'[^A-Za-z0-9_.-]')
@@ -113,14 +117,15 @@ def secure_filename(filename):
class StaticResource(object):
allow_no_auth = True
- def __init__(self, path):
+ def __init__(self, path, root):
self.path = path.lstrip('/')
+ self.root = root
def on_get(self, req, resp, filename):
suffix = path.splitext(req.path)[1]
resp.content_type = mimes.get(suffix, 'application/octet-stream')
- filepath = path.join(STATIC_ROOT, self.path, secure_filename(filename))
+ filepath = path.join(self.root, self.path, secure_filename(filename))
try:
resp.stream = open(filepath, 'rb')
resp.content_length = path.getsize(filepath)
@@ -153,8 +158,8 @@ def init(application, config):
application.add_sink(index, '/')
application.add_route('/static/bundles/{filename}',
- StaticResource('/static/bundles'))
+ StaticResource('/static/bundles', STATIC_ROOT))
application.add_route('/static/images/{filename}',
- StaticResource('/static/images'))
+ StaticResource('/static/images', SOURCE_ROOT))
application.add_route('/static/fonts/{filename}',
- StaticResource('/static/fonts'))
+ StaticResource('/static/fonts', SOURCE_ROOT))
@@ -0,0 +1,120 @@
diff --git a/src/oncall/bin/notifier.py b/src/oncall/bin/notifier.py
index 25142b8..cbc92aa 100644
--- a/src/oncall/bin/notifier.py
+++ b/src/oncall/bin/notifier.py
@@ -32,11 +32,29 @@ send_queue = queue.Queue()
default_timezone = None
+def merge_dict(extend_me, extend_by):
+ if isinstance(extend_by, dict):
+ for k, v in extend_by.items():
+ if isinstance(v, dict) and isinstance(extend_me.get(k), dict):
+ merge_dict(extend_me[k], v)
+ else:
+ extend_me[k] = v
+ return extend_me
def load_config_file(config_path):
with open(config_path, 'r', encoding='utf-8') as h:
config = yaml.safe_load(h)
+ # Check for extra config files from environment variable
+ extra_config_paths = os.getenv('ONCALL_EXTRA_CONFIG')
+ if extra_config_paths:
+ for extra_path in extra_config_paths.split(','):
+ extra_path = extra_path.strip()
+ if os.path.isfile(extra_path):
+ with open(extra_path, 'r') as f:
+ extra_config = yaml.safe_load(f) or {}
+ config = merge_dict(config, extra_config)
+
if 'init_config_hook' in config:
try:
module = config['init_config_hook']
diff --git a/src/oncall/user_sync/ldap_sync.py b/src/oncall/user_sync/ldap_sync.py
index ef9a8ec..c5f027d 100644
--- a/src/oncall/user_sync/ldap_sync.py
+++ b/src/oncall/user_sync/ldap_sync.py
@@ -6,6 +6,7 @@ import time
import yaml
import logging
import ldap
+import os
from oncall import metrics
from ldap.controls import SimplePagedResultsControl
@@ -447,9 +448,28 @@ def main(config):
logger.info('Sleeping for %s seconds' % sleep_time)
sleep(sleep_time)
+def merge_dict(extend_me, extend_by):
+ if isinstance(extend_by, dict):
+ for k, v in extend_by.items():
+ if isinstance(v, dict) and isinstance(extend_me.get(k), dict):
+ merge_dict(extend_me[k], v)
+ else:
+ extend_me[k] = v
+ return extend_me
if __name__ == '__main__':
config_path = sys.argv[1]
with open(config_path, 'r', encoding='utf-8') as config_file:
config = yaml.safe_load(config_file)
+
+ # Check for extra config files from environment variable
+ extra_config_paths = os.getenv('ONCALL_EXTRA_CONFIG')
+ if extra_config_paths:
+ for extra_path in extra_config_paths.split(','):
+ extra_path = extra_path.strip()
+ if os.path.isfile(extra_path):
+ with open(extra_path, 'r') as f:
+ extra_config = yaml.safe_load(f) or {}
+ config = merge_dict(config, extra_config)
+
main(config)
diff --git a/src/oncall/utils.py b/src/oncall/utils.py
index a0b695c..278ca1d 100644
--- a/src/oncall/utils.py
+++ b/src/oncall/utils.py
@@ -13,6 +13,7 @@ from pytz import timezone
from .constants import ONCALL_REMINDER
from . import constants
import re
+import os
invalid_char_reg = re.compile(r'[!"#%-,\.\/;->@\[-\^`\{-~]+')
DAY = 86400
@@ -27,10 +28,31 @@ def insert_notification(x, y):
def update_notification(x, y):
pass
+def merge_dict(extend_me, extend_by):
+ if isinstance(extend_by, dict):
+ for k, v in extend_by.items():
+ if isinstance(v, dict) and isinstance(extend_me.get(k), dict):
+ merge_dict(extend_me[k], v)
+ else:
+ extend_me[k] = v
+ return extend_me
def read_config(config_path):
+
with open(config_path, 'r', encoding='utf8') as config_file:
- return yaml.safe_load(config_file)
+ config = yaml.safe_load(config_file)
+
+ # Check for extra config files from environment variable
+ extra_config_paths = os.getenv('ONCALL_EXTRA_CONFIG')
+ if extra_config_paths:
+ for extra_path in extra_config_paths.split(','):
+ extra_path = extra_path.strip()
+ if os.path.isfile(extra_path):
+ with open(extra_path, 'r') as f:
+ extra_config = yaml.safe_load(f) or {}
+ config = merge_dict(config, extra_config)
+
+ return config
def create_notification(context, team_id, role_ids, type_name, users_involved, cursor, **kwargs):
@@ -0,0 +1,33 @@
diff --git a/src/oncall/app.py b/src/oncall/app.py
index 370fcf4..59f014e 100644
--- a/src/oncall/app.py
+++ b/src/oncall/app.py
@@ -62,9 +62,19 @@ class AuthMiddleware(object):
application = None
+def handle_uncaught_exception(req, resp, ex, params):
+ logging.exception('Unhandled error')
+ raise falcon.HTTPInternalServerError(title='App error')
+
+
+def handle_http_error(req, resp, ex, params):
+ logging.exception('HTTP error')
+ raise ex
+
def init_falcon_api(config):
global application
+
cors = CORS(allow_origins_list=config.get('allow_origins_list', []))
middlewares = [
SecurityHeaderMiddleware(),
@@ -74,6 +84,8 @@ def init_falcon_api(config):
if config.get('require_auth'):
middlewares.append(AuthMiddleware())
application = falcon.App(middleware=middlewares)
+ application.add_error_handler(falcon.HTTPError, handle_http_error)
+ application.add_error_handler(Exception, handle_uncaught_exception)
application.req_options.auto_parse_form_urlencoded = False
application.set_error_serializer(json_error_serializer)
application.req_options.strip_url_path_trailing_slash = True
@@ -0,0 +1,36 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
falcon,
}:
buildPythonPackage rec {
pname = "falcon-cors";
version = "1.1.7";
src = fetchFromGitHub {
owner = "lwcolton";
repo = "falcon-cors";
tag = version;
hash = "sha256-jlEWP7gXbWfdY4coEIM6NWuBf4LOGbUAFMNvqip/FcA=";
};
build-system = [ setuptools ];
dependencies = [ falcon ];
# Test fail with falcon >= 4
# https://github.com/lwcolton/falcon-cors/issues/25
doCheck = false;
pythonImportsCheck = [ "falcon_cors" ];
meta = {
description = "CORS support for Falcon";
homepage = "https://github.com/lwcolton/falcon-cors";
license = lib.licenses.asl20;
maintainers = with lib.maintainers; [ onny ];
};
}
@@ -0,0 +1,43 @@
{
lib,
buildPythonPackage,
setuptools,
fetchFromGitHub,
requests,
pytestCheckHook,
httmock,
pytest-mock,
}:
buildPythonPackage rec {
pname = "irisclient";
version = "1.2.0";
pyproject = true;
src = fetchFromGitHub {
owner = "houqp";
repo = "iris-python-client";
tag = "v${version}";
hash = "sha256-fXMw2BopkEqjklR6jr7QQIZyxLq6NHKm2rHwTCbtxR0=";
};
build-system = [ setuptools ];
dependencies = [ requests ];
checkInputs = [
httmock
pytestCheckHook
pytest-mock
];
pythonImportsCheck = [ "irisclient" ];
meta = {
description = "Python client for Iris REST api";
changelog = "https://github.com/houqp/iris-python-client/blob/v${src.tag}/HISTORY.rst";
homepage = "https://github.com/houqp/iris-python-client";
license = lib.licenses.bsd2;
maintainers = with lib.maintainers; [ onny ];
};
}
+4
View File
@@ -4757,6 +4757,8 @@ self: super: with self; {
falcon = callPackage ../development/python-modules/falcon { };
falcon-cors = callPackage ../development/python-modules/falcon-cors { };
falconpy = callPackage ../development/python-modules/falconpy { };
faraday-agent-parameters-types =
@@ -6909,6 +6911,8 @@ self: super: with self; {
irctokens = callPackage ../development/python-modules/irctokens { };
irisclient = callPackage ../development/python-modules/irisclient { };
isal = callPackage ../development/python-modules/isal { };
isbnlib = callPackage ../development/python-modules/isbnlib { };