root: add primary-replica db router (#9479)

* root: add primary-replica db router

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* copy all settings for database replicas

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* refresh read replicas config, switch to using a dict instead of a list for easier refresh

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* add test for get_keys

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* fix getting override

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* lint

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* nosec

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* small fixes

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* fix replica settings

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* generate config: add a dummy read replica

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* add doc

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* add healthchecks for replicas

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* fix

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* add note about hot reloading

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Jens L
2024-05-21 20:15:49 +02:00
committed by GitHub
parent 09832355e3
commit a5467c6e19
9 changed files with 94 additions and 7 deletions

View File

@ -304,6 +304,12 @@ class ConfigLoader:
"""Wrapper for get that converts value into boolean"""
return str(self.get(path, default)).lower() == "true"
def get_keys(self, path: str, sep=".") -> list[str]:
"""List attribute keys by using yaml path"""
root = self.raw
attr: Attr = get_path_from_dict(root, path, sep=sep, default=Attr({}))
return attr.keys()
def get_dict_from_b64_json(self, path: str, default=None) -> dict:
"""Wrapper for get that converts value from Base64 encoded string into dictionary"""
config_value = self.get(path)

View File

@ -10,6 +10,10 @@ postgresql:
use_pgpool: false
test:
name: test_authentik
read_replicas: {}
# For example
# 0:
# host: replica1.example.com
listen:
listen_http: 0.0.0.0:9000

View File

@ -169,3 +169,9 @@ class TestConfig(TestCase):
self.assertEqual(config.get("cache.timeout_flows"), "32m")
self.assertEqual(config.get("cache.timeout_policies"), "3920ns")
self.assertEqual(config.get("cache.timeout_reputation"), "298382us")
def test_get_keys(self):
"""Test get_keys"""
config = ConfigLoader()
config.set("foo.bar", "baz")
self.assertEqual(list(config.get_keys("foo")), ["bar"])

View File

@ -10,8 +10,15 @@ class DatabaseWrapper(BaseDatabaseWrapper):
def get_connection_params(self):
"""Refresh DB credentials before getting connection params"""
CONFIG.refresh("postgresql.password")
conn_params = super().get_connection_params()
conn_params["user"] = CONFIG.get("postgresql.user")
conn_params["password"] = CONFIG.get("postgresql.password")
prefix = "postgresql"
if self.alias.startswith("replica_"):
prefix = f"postgresql.read_replicas.{self.alias.removeprefix('replica_')}"
for setting in ("host", "port", "user", "password"):
conn_params[setting] = CONFIG.refresh(f"{prefix}.{setting}")
if conn_params[setting] is None and self.alias.startswith("replica_"):
conn_params[setting] = CONFIG.refresh(f"postgresql.{setting}")
return conn_params

View File

@ -47,8 +47,8 @@ class ReadyView(View):
def dispatch(self, request: HttpRequest) -> HttpResponse:
try:
db_conn = connections["default"]
_ = db_conn.cursor()
for db_conn in connections.all():
_ = db_conn.cursor()
except OperationalError: # pragma: no cover
return HttpResponse(status=503)
try:

View File

@ -293,7 +293,7 @@ DATABASES = {
"NAME": CONFIG.get("postgresql.name"),
"USER": CONFIG.get("postgresql.user"),
"PASSWORD": CONFIG.get("postgresql.password"),
"PORT": CONFIG.get_int("postgresql.port"),
"PORT": CONFIG.get("postgresql.port"),
"SSLMODE": CONFIG.get("postgresql.sslmode"),
"SSLROOTCERT": CONFIG.get("postgresql.sslrootcert"),
"SSLCERT": CONFIG.get("postgresql.sslcert"),
@ -313,7 +313,23 @@ if CONFIG.get_bool("postgresql.use_pgbouncer", False):
# https://docs.djangoproject.com/en/4.0/ref/databases/#persistent-connections
DATABASES["default"]["CONN_MAX_AGE"] = None # persistent
DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",)
for replica in CONFIG.get_keys("postgresql.read_replicas"):
_database = DATABASES["default"].copy()
for setting in DATABASES["default"].keys():
default = object()
if setting in ("TEST",):
continue
override = CONFIG.get(
f"postgresql.read_replicas.{replica}.{setting.lower()}", default=default
)
if override is not default:
_database[setting] = override
DATABASES[f"replica_{replica}"] = _database
DATABASE_ROUTERS = (
"authentik.tenants.db.FailoverRouter",
"django_tenants.routers.TenantSyncRouter",
)
# Email
# These values should never actually be used, emails are only sent from email stages, which

29
authentik/tenants/db.py Normal file
View File

@ -0,0 +1,29 @@
from random import choice
from django.conf import settings
class FailoverRouter:
"""Support an primary/read-replica PostgreSQL setup (reading from replicas
and write to primary only)"""
def __init__(self) -> None:
super().__init__()
self.database_aliases = set(settings.DATABASES.keys())
self.read_replica_aliases = list(self.database_aliases - {"default"})
self.replica_enabled = len(self.read_replica_aliases) > 0
def db_for_read(self, model, **hints):
if not self.replica_enabled:
return "default"
return choice(self.read_replica_aliases) # nosec
def db_for_write(self, model, **hints):
return "default"
def allow_relation(self, obj1, obj2, **hints):
"""Relations between objects are allowed if both objects are
in the primary/replica pool."""
if obj1._state.db in self.database_aliases and obj2._state.db in self.database_aliases:
return True
return None

View File

@ -12,6 +12,9 @@ with open("local.env.yml", "w", encoding="utf-8") as _config:
"secret_key": generate_id(),
"postgresql": {
"user": "postgres",
"read_replicas": {
"0": {},
},
},
"outposts": {
"container_image_base": "ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s",

View File

@ -77,6 +77,22 @@ To check if your config has been applied correctly, you can run the following co
- `AUTHENTIK_POSTGRESQL__SSLCERT`: Path to x509 client certificate to authenticate to server
- `AUTHENTIK_POSTGRESQL__SSLKEY`: Path to private key of `SSLCERT` certificate
Additionally, databases used only for read operations can be configured. Increase the number in the following configuration variables for each read replica.
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__HOST`: same as above
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__NAME`: same as above
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__USER`: same as above
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__PORT`: same as above
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__PASSWORD`: same as above
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__SSLMODE`: same as above
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__SSLROOTCERT`: same as above
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__SSLCERT`: same as above
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__SSLKEY`: same as above
Note that `USE_PGBOUNCER` and `USE_PGPOOL` are inherited from the main database configuration and are not overridable per read replica. By default, if read replicas are configured, the main database is not used for reads. If you'd like it to be included for reads, add it as a read replica.
All PostgreSQL settings, apart from `USE_PGBOUNCER` and `USE_PGPOOL`, support hot-reloading. Adding and removing read replicas doesn't support hot-reloading.
## Redis Settings
- `AUTHENTIK_REDIS__HOST`: Redis server host when not using configuration URL