Skip to content

Commit 3fa54d4

Browse files
committed
Large cleanup and refactoring
Removed use of global state and introduced a ServiceManager to handle health checks, long running background tasks and dependencies between services. Fixed a ton of bugs highlighted by a stricter type checker. Renamed the LOADFACTOR_* config parameters. Removed the 'run' cli for now. Improved logging when using the cli.
1 parent 2e7d146 commit 3fa54d4

29 files changed

Lines changed: 1460 additions & 968 deletions

.github/workflows/docker.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ on:
66
tags: [ 'v*' ]
77

88
jobs:
9-
build-and-push-main:
9+
10+
build-and-push:
1011
runs-on: ubuntu-latest
1112

1213
permissions:

bbblb/__init__.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,9 @@
11
import logging
2-
from .settings import config
32

43
__version__ = "0.0.7"
54
VERSION = __version__.split(".", 2)
65
VERSION[-1], _, BUILD = VERSION[-1].partition("-")
76

87
ROOT_LOGGER = logging.getLogger(__name__)
9-
ROOT_LOGGER.setLevel(logging.INFO)
10-
ROOT_LOGGER.propagate = False
11-
ch = logging.StreamHandler()
12-
ch.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
13-
ROOT_LOGGER.addHandler(ch)
148

159
BRANDING = "BBBLB (AGPL-3, https://github.com/defnull/bbblb)"
16-
17-
18-
@config.watch
19-
def watch_debug_level(name, old, new):
20-
if name == "DEBUG":
21-
level = logging.DEBUG if new else logging.INFO
22-
if level != ROOT_LOGGER.level:
23-
ROOT_LOGGER.setLevel(logging.DEBUG if new else logging.INFO)

bbblb/cli/__init__.py

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,20 @@
22
import functools
33
import importlib
44
import pkgutil
5-
from bbblb.settings import ConfigError, config as cfg
6-
import bbblb.model
5+
from bbblb.services import bootstrap
6+
from bbblb.settings import ConfigError, BBBLBConfig
77
import click
88
import os
99

1010

11-
def async_command(db=False):
12-
"""Decorator that wraps coroutine with asyncio.run."""
11+
def async_command():
12+
"""Decorator that wraps coroutine with asyncio.run and click.pass_obj."""
1313

1414
def decorator(func):
1515
@functools.wraps(func)
16-
async def wrapper(*a, **ka):
17-
try:
18-
if db:
19-
await bbblb.model.init_engine(cfg.DB)
20-
await func(*a, **ka)
21-
finally:
22-
if db:
23-
await bbblb.model.dispose_engine()
24-
25-
@functools.wraps(wrapper)
16+
@click.pass_obj
2617
def sync_wrapper(*args, **kwargs):
27-
return asyncio.run(wrapper(*args, **kwargs))
18+
return asyncio.run(func(*args, **kwargs))
2819

2920
return sync_wrapper
3021

@@ -46,20 +37,47 @@ def sync_wrapper(*args, **kwargs):
4637
help="Set or unset a BBBLB config parameter",
4738
multiple=True,
4839
)
49-
def main(config_file, config):
40+
@click.option(
41+
"-v", "--verbose", help="Increase verbosity. Can be repeated.", count=True
42+
)
43+
@async_command()
44+
@click.pass_context
45+
async def main(ctx, obj, config_file, config, verbose):
46+
import logging
47+
48+
logging.basicConfig(
49+
format="%(asctime)s %(levelname)s %(name)s %(message)s", level=logging.WARNING
50+
)
51+
52+
if verbose == 0:
53+
logging.getLogger("bbblb").setLevel(logging.WARNING)
54+
elif verbose == 1:
55+
logging.getLogger("bbblb").setLevel(logging.INFO)
56+
elif verbose == 2:
57+
logging.getLogger("bbblb").setLevel(logging.DEBUG)
58+
elif verbose == 3:
59+
logging.getLogger("bbblb").setLevel(logging.DEBUG)
60+
logging.getLogger("sqlalchemy.engine").setLevel(logging.DEBUG)
61+
elif verbose >= 4:
62+
logging.root.setLevel(logging.DEBUG)
63+
64+
config_ = BBBLBConfig()
65+
5066
if config_file:
5167
os.environ["BBBLB_CONFIG"] = config_file
5268
for kv in config:
5369
name, _, value = kv.partition("=")
5470
name = name.upper()
55-
if name not in cfg._options:
71+
if name not in config_._options:
5672
raise ConfigError(f"Unknown config parameter: {name}")
5773
env_name = f"BBBLB_{name}"
5874
if value:
5975
os.environ[env_name] = value
6076
elif env_name in os.environ:
6177
del os.environ[env_name]
62-
cfg.populate()
78+
79+
config_.populate()
80+
ctx.obj = await bootstrap(config_, autostart=False, logging=False)
6381

6482

6583
# Auto-load all modules in the bbblb.cli package to load all commands.

bbblb/cli/db.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import click
2-
import bbblb
3-
from bbblb.settings import config
2+
from bbblb.services import ServiceRegistry
3+
from bbblb.services.db import check_migration_state, create_database, migrate_db
4+
from bbblb.settings import BBBLBConfig
45

56
from bbblb.cli import async_command, main
6-
import bbblb.model
77

88

99
@main.group()
@@ -15,23 +15,24 @@ def db():
1515
@click.option(
1616
"--create", help="Create database if needed (only postgres).", is_flag=True
1717
)
18-
@async_command(db=False)
19-
async def migrate(create: bool):
18+
@async_command()
19+
async def migrate(obj: ServiceRegistry, create: bool):
2020
"""
2121
Migrate database to the current schema version.
2222
2323
WARNING: Make backups!
2424
"""
25+
config = await obj.use("config", BBBLBConfig)
2526

2627
try:
2728
if create:
28-
await bbblb.model.create_database(config.DB)
29-
current, target = await bbblb.model.check_migration_state(config.DB)
29+
await create_database(config.DB)
30+
current, target = await check_migration_state(config.DB)
3031
if current != target:
3132
click.echo(
3233
f"Migrating database schema from {current or 'empty'!r} to {target!r}..."
3334
)
34-
await bbblb.model.migrate_db(config.DB)
35+
await migrate_db(config.DB)
3536
click.echo("Migration complete!")
3637
else:
3738
click.echo("Database is up to date. Nothing to do")

bbblb/cli/maketoken.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from bbblb import model
2-
from bbblb.settings import config as cfg
2+
from bbblb.services import ServiceRegistry
33
import secrets
44
import sys
55
import time
66
import click
77
import jwt
88

9+
from bbblb.services.db import DBContext
10+
from bbblb.settings import BBBLBConfig
11+
912
from . import main, async_command
1013

1114

@@ -24,8 +27,10 @@
2427
)
2528
@click.argument("subject")
2629
@click.argument("scope", nargs=-1)
27-
@async_command(db=True)
28-
async def maketoken(subject, expire, server, tenant, scope, verbose):
30+
@async_command()
31+
async def maketoken(
32+
obj: ServiceRegistry, subject, expire, server, tenant, scope, verbose
33+
):
2934
"""Generate an Admin Token that can be used to authenticate against the BBBLB API.
3035
3136
The SUBJECT should be a short name or id that identifies the token
@@ -37,38 +42,41 @@ async def maketoken(subject, expire, server, tenant, scope, verbose):
3742
Tenant or Server tokens do not have scopes, their permissions are hard
3843
coded because tenants or servers can create their own tokens.
3944
"""
45+
config = await obj.use("config", BBBLBConfig)
46+
db = await obj.use("db", DBContext)
47+
4048
headers = {}
4149
payload = {
4250
"sub": subject,
43-
"aud": cfg.DOMAIN,
51+
"aud": config.DOMAIN,
4452
"scope": " ".join(sorted(set(scope))) or "admin",
4553
"jti": secrets.token_hex(8),
4654
}
4755
if expire > 0:
4856
payload["exp"] = int(time.time() + int(expire))
4957

5058
if server:
51-
async with model.session() as session:
59+
async with db.session() as session:
5260
stmt = model.Server.select(domain=server)
5361
try:
5462
server = (await session.execute(stmt)).scalar_one()
5563
except model.NoResultFound:
56-
raise RuntimeError("Server not found in database: {server}")
64+
raise RuntimeError(f"Server not found in database: {server}")
5765
headers["kid"] = f"bbb:{server.domain}"
5866
del payload["scope"]
5967
key = server.secret
6068
elif tenant:
61-
async with model.session() as session:
69+
async with db.session() as session:
6270
stmt = model.Tenant.select(name=tenant)
6371
try:
6472
tenant = (await session.execute(stmt)).scalar_one()
6573
except model.NoResultFound:
66-
raise RuntimeError("Tenant not found in database: {tenant}")
74+
raise RuntimeError(f"Tenant not found in database: {tenant}")
6775
headers["kid"] = f"tenant:{tenant.name}"
6876
del payload["scope"]
6977
key = tenant.secret
7078
else:
71-
key = cfg.SECRET
79+
key = config.SECRET
7280

7381
token = jwt.encode(payload, key, headers=headers)
7482

bbblb/cli/override.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
from bbblb import model
33
import click
44

5+
from bbblb.services import ServiceRegistry
6+
from bbblb.services.db import DBContext
7+
58
from . import main, async_command
69

710

@@ -12,10 +15,12 @@ def override():
1215

1316
@override.command("list")
1417
@click.argument("tenant", required=False)
15-
@async_command(db=True)
16-
async def list_(tenant: str):
18+
@async_command()
19+
async def list_(obj: ServiceRegistry, tenant: str):
1720
"""List overrides for all or a specific tenant."""
18-
async with model.session() as session:
21+
db = await obj.use("db", DBContext)
22+
23+
async with db.session() as session:
1924
if tenant:
2025
stmt = model.Tenant.select(name=tenant)
2126
else:
@@ -25,9 +30,10 @@ async def list_(tenant: str):
2530
if tenant and not tenants:
2631
click.echo(f"Tenant {tenant!r} not found")
2732
raise SystemExit(1)
28-
for tenant in tenants:
29-
for key, value in sorted(tenant.overrides.items()):
30-
click.echo(f"{tenant.name}: {key}{value}")
33+
34+
for ten in tenants:
35+
for key, value in sorted(ten.overrides.items()):
36+
click.echo(f"{ten.name}: {key}{value}")
3137

3238

3339
@override.command("set")
@@ -36,8 +42,8 @@ async def list_(tenant: str):
3642
)
3743
@click.argument("tenant")
3844
@click.argument("overrides", nargs=-1, metavar="NAME=VALUE")
39-
@async_command(db=True)
40-
async def set_(clear: bool, tenant: str, overrides: list[str]):
45+
@async_command()
46+
async def set_(obj: ServiceRegistry, clear: bool, tenant: str, overrides: list[str]):
4147
"""Override create call parameters for a given tenant.
4248
4349
You can define any number of create parameter overrides per tenant as
@@ -51,7 +57,8 @@ async def set_(clear: bool, tenant: str, overrides: list[str]):
5157
parameters (e.g. duration or maxParticipants), or '+' to add items
5258
to a comma separated list parameter (e.g. disabledFeatures).
5359
"""
54-
async with model.session() as session:
60+
db = await obj.use("db", DBContext)
61+
async with db.session() as session:
5562
db_tenant = (
5663
await session.execute(model.Tenant.select(name=tenant))
5764
).scalar_one_or_none()
@@ -66,11 +73,12 @@ async def set_(clear: bool, tenant: str, overrides: list[str]):
6673
raise SystemExit(1)
6774

6875
for override in overrides:
69-
m = re.match("^([a-zA-Z0-9-_]+)([=?<-])(.*)$", override)
76+
m = re.match("^([a-zA-Z0-9-_]+)([=?<+])(.*)$", override)
7077
if not m:
7178
click.echo(f"Failed to parse override {override!r}")
7279
raise SystemExit(1)
7380
name, operator, value = m.groups()
81+
assert operator in ("=", "?", "<", "+")
7482
db_tenant.add_override(name, operator, value)
7583

7684
await session.commit()
@@ -80,10 +88,12 @@ async def set_(clear: bool, tenant: str, overrides: list[str]):
8088
@override.command()
8189
@click.argument("tenant")
8290
@click.argument("overrides", nargs=-1, metavar="NAME")
83-
@async_command(db=True)
84-
async def unset(tenant: str, overrides: list[str]):
91+
@async_command()
92+
async def unset(obj: ServiceRegistry, tenant: str, overrides: list[str]):
8593
"""Remove overrides from a given tenant."""
86-
async with model.session() as session:
94+
db = await obj.use("db", DBContext)
95+
96+
async with db.session() as session:
8797
db_tenant = (
8898
await session.execute(model.Tenant.select(name=tenant))
8999
).scalar_one_or_none()

bbblb/cli/run.py

Lines changed: 0 additions & 28 deletions
This file was deleted.

0 commit comments

Comments
 (0)