feat(tenancy): harden shared cloud runtime boundaries

This commit is contained in:
Junyan Qin
2026-07-20 01:47:42 +08:00
parent 41772920ef
commit a47bfe8167
121 changed files with 18152 additions and 5588 deletions
@@ -11,9 +11,19 @@ Note: Uses import isolation to break circular import chains.
from __future__ import annotations
import sys
from unittest.mock import Mock, MagicMock
from contextlib import contextmanager
from typing import Generator
from unittest.mock import MagicMock, Mock
import pytest
@pytest.fixture(autouse=True)
def isolate_database_manager_registry(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep decorator tests from mutating the process-wide manager registry."""
from langbot.pkg.persistence import database
monkeypatch.setattr(database, 'preregistered_managers', list(database.preregistered_managers))
@contextmanager
@@ -0,0 +1,90 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
# Persistence manager performs the package's database-manager registration;
# importing a concrete manager first would enter the historical app/mgr cycle.
from langbot.pkg.persistence import mgr as _persistence_mgr # noqa: F401
from langbot.pkg.persistence.databases import postgresql
@pytest.mark.asyncio
async def test_postgresql_manager_parses_explicit_url_without_string_reassembly(monkeypatch) -> None:
captured = None
sentinel_engine = object()
def create_engine(url):
nonlocal captured
captured = url
return sentinel_engine
monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={
'database': {
'postgresql': {
'url': 'postgresql://runtime:p%40ss@db.internal:5432/langbot?sslmode=require',
}
}
}
)
)
manager = postgresql.PostgreSQLDatabaseManager(ap)
await manager.initialize()
assert captured.drivername == 'postgresql+asyncpg'
assert captured.password == 'p@ss'
assert captured.query['ssl'] == 'require'
assert 'sslmode' not in captured.query
assert manager.engine is sentinel_engine
@pytest.mark.asyncio
async def test_postgresql_manager_builds_structured_url_with_special_password(monkeypatch) -> None:
captured = None
def create_engine(url):
nonlocal captured
captured = url
return object()
monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={
'database': {
'postgresql': {
'host': 'db.internal',
'port': 5432,
'user': 'runtime',
'password': 'p@ss:/?#word',
'database': 'langbot',
}
}
}
)
)
await postgresql.PostgreSQLDatabaseManager(ap).initialize()
assert captured.password == 'p@ss:/?#word'
assert captured.host == 'db.internal'
assert captured.database == 'langbot'
@pytest.mark.asyncio
async def test_postgresql_manager_rejects_non_postgresql_url_without_echoing_secret() -> None:
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={'database': {'postgresql': {'url': 'sqlite:///operator-super-secret.db'}}}
)
)
manager = postgresql.PostgreSQLDatabaseManager(ap)
with pytest.raises(ValueError, match='valid PostgreSQL') as exc_info:
await manager.initialize()
assert 'operator-super-secret' not in str(exc_info.value)
@@ -0,0 +1,153 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import sqlalchemy
from langbot.__main__ import _build_parser
from langbot.pkg.persistence import release_migration
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
def _cloud_config(*, database_use: str = 'postgresql', runtime_user: str = 'langbot_runtime') -> dict:
return {
'database': {
'use': database_use,
'postgresql': {
'host': 'runtime-db',
'port': 5432,
'user': runtime_user,
'password': 'runtime-secret',
'database': 'langbot',
},
'cloud_migration': {
'operator_dsn_env': 'TEST_LANGBOT_OPERATOR_DSN',
},
},
'vdb': {
'use': 'pgvector',
'pgvector': {
'use_business_database': True,
'allowed_dimensions': [384, 1536],
},
},
}
def _operator_environ(
*,
user: str = 'langbot_migrator',
database: str = 'langbot',
host: str = 'runtime-db',
port: int = 5432,
) -> dict[str, str]:
return {
'TEST_LANGBOT_OPERATOR_DSN': (f'postgresql://{user}:operator%40secret@{host}:{port}/{database}?sslmode=require')
}
def test_cloud_migration_cli_is_explicit() -> None:
args = _build_parser().parse_args(['migrate', '--cloud'])
assert args.command == 'migrate'
assert args.cloud is True
with pytest.raises(SystemExit) as exc_info:
_build_parser().parse_args(['migrate'])
assert exc_info.value.code == 2
def test_operator_url_is_separate_and_preserves_escaped_secret() -> None:
url = release_migration._operator_database_url(
_cloud_config(),
environ=_operator_environ(),
)
assert url.drivername == 'postgresql+asyncpg'
assert url.username == 'langbot_migrator'
assert url.password == 'operator@secret'
assert url.host == 'runtime-db'
assert url.port == 5432
assert url.database == 'langbot'
assert url.query['ssl'] == 'require'
assert 'sslmode' not in url.query
@pytest.mark.parametrize(
('config', 'environ', 'message'),
[
(_cloud_config(database_use='sqlite'), _operator_environ(), 'SQLite fallback is forbidden'),
(_cloud_config(), {}, 'requires the operator DSN'),
(_cloud_config(), {'TEST_LANGBOT_OPERATOR_DSN': 'not a secret://operator-password'}, 'DSN is invalid'),
(
_cloud_config(),
{'TEST_LANGBOT_OPERATOR_DSN': 'postgresql://operator:secret@runtime-db:not-a-port/langbot'},
'DSN is invalid',
),
(_cloud_config(), _operator_environ(user='langbot_runtime'), 'distinct operator role'),
(_cloud_config(), _operator_environ(database='another_database'), 'configured runtime database'),
(_cloud_config(), _operator_environ(host='other-cluster'), 'runtime PostgreSQL endpoint'),
(_cloud_config(), _operator_environ(port=6432), 'runtime PostgreSQL endpoint'),
],
)
def test_operator_url_rejects_unsafe_configuration(config: dict, environ: dict[str, str], message: str) -> None:
with pytest.raises(release_migration.CloudReleaseMigrationConfigurationError, match=message) as exc_info:
release_migration._operator_database_url(config, environ=environ)
assert 'operator-password' not in str(exc_info.value)
@pytest.mark.asyncio
async def test_release_migration_disposes_operator_engine_on_failure(monkeypatch) -> None:
engine = SimpleNamespace(dispose=AsyncMock())
manager = SimpleNamespace(
db=SimpleNamespace(engine=engine),
initialize=AsyncMock(side_effect=RuntimeError('migration failed')),
)
def manager_factory(*args, **kwargs):
del args, kwargs
return manager
monkeypatch.setattr(release_migration, 'PersistenceManager', manager_factory)
ap = SimpleNamespace(
instance_config=SimpleNamespace(data=_cloud_config()),
logger=logging.getLogger('release-migration-disposal-test'),
persistence_mgr=None,
)
with pytest.raises(RuntimeError, match='migration failed'):
await release_migration.run_cloud_release_migration(ap, environ=_operator_environ())
assert ap.persistence_mgr is manager
engine.dispose.assert_awaited_once()
@pytest.mark.asyncio
async def test_release_mode_rejects_sqlite_before_schema_changes(tmp_path, monkeypatch) -> None:
from langbot.pkg.persistence import mgr as persistence_mgr_module
from langbot.pkg.persistence.databases.sqlite import SQLiteDatabaseManager
monkeypatch.setattr(persistence_mgr_module.database, 'preregistered_managers', [SQLiteDatabaseManager])
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={
'database': {
'use': 'sqlite',
'sqlite': {'path': str(tmp_path / 'must-not-migrate.db')},
}
}
),
logger=logging.getLogger('release-migration-sqlite-rejection-test'),
)
manager = PersistenceManager(ap, mode=PersistenceMode.RELEASE_MIGRATION)
with pytest.raises(RuntimeError, match='requires PostgreSQL'):
await manager.initialize()
await manager.get_db_engine().dispose()
engine = sqlalchemy.create_engine(f'sqlite:///{tmp_path / "must-not-migrate.db"}')
try:
assert sqlalchemy.inspect(engine).get_table_names() == []
finally:
engine.dispose()
+350 -1
View File
@@ -1,11 +1,23 @@
from __future__ import annotations
import asyncio
import contextvars
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.core.task_boundary import create_detached_task
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.persistence.tenant_uow import TenantUnitOfWork
from langbot.pkg.persistence.tenant_uow import (
CrossScopeTransactionError,
PersistenceScopeKind,
TenantScopeRequiredError,
TenantUnitOfWork,
TransactionRollbackOnlyError,
)
pytestmark = pytest.mark.asyncio
@@ -61,3 +73,340 @@ def test_persistence_mode_must_be_a_trusted_enum() -> None:
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
assert manager.mode is PersistenceMode.CLOUD_RUNTIME
async def test_manager_reuses_one_session_and_rejects_cross_workspace(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager-uow.db"}')
table = sa.Table(
'manager_rows',
sa.MetaData(),
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
)
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
with pytest.raises(TenantScopeRequiredError, match='explicit Workspace or discovery'):
await manager.execute_async(sa.select(table))
async with manager.tenant_uow('workspace-a') as outer:
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
async with manager.tenant_uow('workspace-a') as inner:
assert inner.session is outer.session
assert manager.current_session() is outer.session
assert (await manager.execute_async(sa.select(table.c.id))).scalar_one() == 1
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
async with manager.tenant_uow('workspace-b'):
pass
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
finally:
await engine.dispose()
async def test_manager_scoped_execute_preserves_core_row_and_scalar_contract(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager-result-contract.db"}')
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(User.__table__.create)
await conn.execute(
sa.insert(User).values(
uuid='account-a',
user='owner@example.com',
normalized_email='owner@example.com',
password='hashed-password',
)
)
async with manager.tenant_uow('workspace-a') as uow:
result = await manager.execute_async(sa.select(User))
row = result.first()
assert row is not None
assert row.uuid == 'account-a'
assert row.user == 'owner@example.com'
scalar_result = await manager.execute_async(sa.select(User.uuid))
assert scalar_result.scalar_one() == 'account-a'
list_result = await manager.execute_async(sa.select(User.user))
assert list_result.scalars().all() == ['owner@example.com']
# Direct UoW execution remains an ORM API for code that opts into it.
orm_result = await uow.execute(sa.select(User))
assert orm_result.scalars().one().uuid == 'account-a'
finally:
await engine.dispose()
async def test_transaction_free_tenant_scope_opens_one_short_uow_per_database_call(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "short-scope.db"}')
table = sa.Table(
'short_scope_rows',
sa.MetaData(),
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
)
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
async with manager.tenant_scope('workspace-a'):
assert manager.current_scope() is not None
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
assert manager.current_scope().settings == (('langbot.workspace_uuid', 'workspace-a'),)
assert manager.current_session() is None
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
assert manager.current_session() is None
# The first statement has already committed. Long external waits
# inside this boundary retain only identity, never a DB session.
await asyncio.sleep(0)
assert manager.current_session() is None
assert (await manager.execute_async(sa.select(table.c.id))).scalar_one() == 1
assert manager.current_session() is None
async with manager.tenant_scope('workspace-a'):
assert manager.current_session() is None
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
async with manager.tenant_scope('workspace-b'):
pass
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
async with manager.tenant_uow('workspace-b'):
pass
assert manager.current_scope() is None
with pytest.raises(TenantScopeRequiredError, match='explicit Workspace'):
await manager.execute_async(sa.select(table))
finally:
await engine.dispose()
async def test_transaction_free_scope_requires_explicit_child_task_scope(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "short-scope-child.db"}')
table = sa.Table('child_scope_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
async with manager.tenant_scope('workspace-a'):
async def inherited_access() -> None:
await manager.execute_async(sa.select(table))
with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
await asyncio.create_task(inherited_access())
async def explicitly_scoped_access() -> None:
async with manager.tenant_scope('workspace-a'):
await manager.execute_async(sa.insert(table).values(id=1))
assert manager.current_session() is None
await asyncio.create_task(explicitly_scoped_access())
assert manager.current_session() is None
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
finally:
await engine.dispose()
async def test_caught_nested_failure_marks_outer_transaction_rollback_only(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "rollback-only.db"}')
table = sa.Table('rollback_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
async with manager.tenant_uow('workspace-a'):
await manager.execute_async(sa.insert(table).values(id=1))
try:
async with manager.tenant_uow('workspace-a'):
raise ValueError('caught nested failure')
except ValueError:
pass
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table))).all() == []
finally:
await engine.dispose()
@pytest.mark.parametrize('executor_kind', ['manager', 'uow', 'session'])
async def test_caught_database_error_rolls_back_and_cancels_after_commit_gate(tmp_path, executor_kind: str) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / f"db-error-{executor_kind}.db"}')
table = sa.Table('unique_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
with pytest.raises(TransactionRollbackOnlyError, match='after-commit work was cancelled'):
async with manager.tenant_uow('workspace-a') as uow:
statement = sa.insert(table).values(id=1)
if executor_kind == 'manager':
await manager.execute_async(statement)
elif executor_kind == 'uow':
await uow.execute(statement)
else:
await uow.session.execute(statement)
gate = manager.create_after_commit_gate()
assert gate is not None
try:
if executor_kind == 'manager':
await manager.execute_async(statement)
elif executor_kind == 'uow':
await uow.execute(statement)
else:
await uow.session.execute(statement)
except sa.exc.IntegrityError:
pass
assert gate.cancelled()
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table))).all() == []
finally:
await engine.dispose()
async def test_child_task_must_open_its_own_explicit_uow(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "child-task.db"}')
table = sa.Table(
'child_rows',
sa.MetaData(),
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
)
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
async with manager.tenant_uow('workspace-a'):
async def inherited_access() -> None:
await manager.execute_async(sa.select(table))
with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
await asyncio.create_task(inherited_access())
async def explicitly_scoped_access() -> None:
async with manager.tenant_uow('workspace-a'):
await manager.execute_async(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
await asyncio.create_task(explicitly_scoped_access())
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [2]
finally:
await engine.dispose()
async def test_detached_task_starts_without_parent_scope_and_rolls_back_its_uow(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "detached-task.db"}')
table = sa.Table(
'detached_rows',
sa.MetaData(),
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
)
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
try:
async with engine.begin() as conn:
await conn.run_sync(table.metadata.create_all)
async with manager.tenant_uow('workspace-a'):
async def detached_write() -> None:
# Before the detached boundary this access raises
# CrossScopeTransactionError because asyncio copies the
# parent's ActiveScopedTransaction into this child task.
assert manager.current_scope() is None
async with manager.tenant_uow('workspace-a'):
await manager.execute_async(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
raise RuntimeError('roll back detached write')
task = create_detached_task(detached_write())
with pytest.raises(RuntimeError, match='roll back detached write'):
await task
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
async with engine.connect() as conn:
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
finally:
await engine.dispose()
async def test_after_commit_task_waits_for_commit_and_starts_with_empty_context(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "after-commit.db"}')
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
request_value = contextvars.ContextVar('after_commit_request_value', default=None)
observed = []
try:
async with manager.tenant_uow('workspace-a'):
token = request_value.set('request-scope')
async def after_commit_work() -> None:
observed.append((request_value.get(), manager.current_scope()))
try:
task = create_detached_task(
after_commit_work(),
after_commit_manager=manager,
)
await asyncio.sleep(0)
assert observed == []
finally:
request_value.reset(token)
await task
assert observed == [(None, None)]
finally:
await engine.dispose()
async def test_after_commit_task_is_cancelled_and_coroutine_closed_on_rollback(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "after-rollback.db"}')
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
started = False
async def should_not_start() -> None:
nonlocal started
started = True
coro = should_not_start()
try:
with pytest.raises(RuntimeError, match='rollback request'):
async with manager.tenant_uow('workspace-a'):
task = create_detached_task(coro, after_commit_manager=manager)
await asyncio.sleep(0)
assert not task.done()
raise RuntimeError('rollback request')
await asyncio.sleep(0)
assert task.cancelled()
assert not started
assert coro.cr_frame is None
finally:
await engine.dispose()