mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 14:26:06 +00:00
feat(tenancy): implement workspace isolation
This commit is contained in:
@@ -19,10 +19,32 @@ from types import SimpleNamespace
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
||||
from langbot.pkg.api.http.service.maintenance import MaintenanceService
|
||||
from langbot.pkg.api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.bstorage import BinaryStorage
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringMessage
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
TEST_CONTEXT = ExecutionContext(
|
||||
instance_uuid='test-instance',
|
||||
workspace_uuid='test-workspace',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def assume_oss_singleton(monkeypatch):
|
||||
async def is_oss_singleton(_self, _context):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(MaintenanceService, '_is_oss_singleton', is_oss_singleton)
|
||||
|
||||
|
||||
def _create_mock_result(scalar_value=None):
|
||||
@@ -32,6 +54,14 @@ def _create_mock_result(scalar_value=None):
|
||||
return result
|
||||
|
||||
|
||||
def _scoped_storage_manager():
|
||||
prefix = 'instances/i/workspaces/w/generations/1/owners/upload/o/'
|
||||
return SimpleNamespace(
|
||||
scoped_prefix=Mock(return_value=prefix),
|
||||
is_scoped_object_key=Mock(side_effect=lambda key, **_: key == f'{prefix}uploaded_file.txt'),
|
||||
)
|
||||
|
||||
|
||||
class TestMaintenanceServiceCleanupExpiredFiles:
|
||||
"""Tests for cleanup_expired_files method."""
|
||||
|
||||
@@ -39,6 +69,7 @@ class TestMaintenanceServiceCleanupExpiredFiles:
|
||||
"""Uses default retention days when config not set."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.storage_mgr = _scoped_storage_manager()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {}
|
||||
ap.storage_mgr = SimpleNamespace()
|
||||
@@ -58,7 +89,7 @@ class TestMaintenanceServiceCleanupExpiredFiles:
|
||||
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async!
|
||||
|
||||
# Execute
|
||||
result = await service.cleanup_expired_files()
|
||||
result = await service.cleanup_expired_files(TEST_CONTEXT)
|
||||
|
||||
# Verify - returns counts
|
||||
assert 'uploaded_files' in result
|
||||
@@ -95,7 +126,7 @@ class TestMaintenanceServiceCleanupExpiredFiles:
|
||||
service._cleanup_expired_log_files = Mock(return_value=3) # NOT async
|
||||
|
||||
# Execute
|
||||
result = await service.cleanup_expired_files()
|
||||
result = await service.cleanup_expired_files(TEST_CONTEXT)
|
||||
|
||||
# Verify
|
||||
assert result['uploaded_files'] == 2
|
||||
@@ -124,7 +155,7 @@ class TestMaintenanceServiceCleanupExpiredFiles:
|
||||
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async
|
||||
|
||||
# Execute
|
||||
result = await service.cleanup_expired_files()
|
||||
result = await service.cleanup_expired_files(TEST_CONTEXT)
|
||||
|
||||
# Verify
|
||||
assert result['uploaded_files'] == 1
|
||||
@@ -159,7 +190,7 @@ class TestMaintenanceServiceCleanupExpiredFiles:
|
||||
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async
|
||||
|
||||
# Execute
|
||||
result = await service.cleanup_expired_files()
|
||||
result = await service.cleanup_expired_files(TEST_CONTEXT)
|
||||
|
||||
# Verify - warning logged, defaults used
|
||||
assert ap.logger.warning.called
|
||||
@@ -196,7 +227,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
|
||||
service._expired_log_candidates = Mock(return_value=[])
|
||||
|
||||
# Execute
|
||||
result = await service.get_storage_analysis()
|
||||
result = await service.get_storage_analysis(TEST_CONTEXT)
|
||||
|
||||
# Verify
|
||||
assert 'generated_at' in result
|
||||
@@ -229,7 +260,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
|
||||
service._expired_log_candidates = Mock(return_value=[])
|
||||
|
||||
# Execute
|
||||
result = await service.get_storage_analysis()
|
||||
result = await service.get_storage_analysis(TEST_CONTEXT)
|
||||
|
||||
# Verify - all sections present
|
||||
sections = {s['key'] for s in result['sections']}
|
||||
@@ -265,7 +296,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
|
||||
service._expired_log_candidates = Mock(return_value=[])
|
||||
|
||||
# Execute
|
||||
result = await service.get_storage_analysis()
|
||||
result = await service.get_storage_analysis(TEST_CONTEXT)
|
||||
|
||||
# Verify
|
||||
assert result['database']['type'] == 'postgresql'
|
||||
@@ -294,7 +325,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
|
||||
service._expired_log_candidates = Mock(return_value=[{'name': 'old_log', 'size_bytes': 50}])
|
||||
|
||||
# Execute
|
||||
result = await service.get_storage_analysis()
|
||||
result = await service.get_storage_analysis(TEST_CONTEXT)
|
||||
|
||||
# Verify
|
||||
assert len(result['cleanup_candidates']['uploaded_files']) == 1
|
||||
@@ -316,7 +347,7 @@ class TestMaintenanceServiceMonitoringCounts:
|
||||
service = MaintenanceService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service._monitoring_counts()
|
||||
result = await service._monitoring_counts(TEST_CONTEXT)
|
||||
|
||||
# Verify - all table keys present
|
||||
assert 'messages' in result
|
||||
@@ -338,7 +369,7 @@ class TestMaintenanceServiceMonitoringCounts:
|
||||
service = MaintenanceService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service._monitoring_counts()
|
||||
result = await service._monitoring_counts(TEST_CONTEXT)
|
||||
|
||||
# Verify - all zero
|
||||
assert all(v == 0 for v in result.values())
|
||||
@@ -374,7 +405,7 @@ class TestMaintenanceServiceBinaryStorageStats:
|
||||
service = MaintenanceService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service._binary_storage_stats()
|
||||
result = await service._binary_storage_stats(TEST_CONTEXT)
|
||||
|
||||
# Verify
|
||||
assert result['count'] == 10
|
||||
@@ -404,7 +435,7 @@ class TestMaintenanceServiceBinaryStorageStats:
|
||||
service = MaintenanceService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service._binary_storage_stats()
|
||||
result = await service._binary_storage_stats(TEST_CONTEXT)
|
||||
|
||||
# Verify - warning logged, size_bytes None or 0
|
||||
assert ap.logger.warning.called
|
||||
@@ -618,11 +649,13 @@ class TestMaintenanceServiceIsUploadedFileKey:
|
||||
"""Returns True for valid upload file key."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.storage_mgr = _scoped_storage_manager()
|
||||
|
||||
service = MaintenanceService(ap)
|
||||
|
||||
# Execute - simple filename without path
|
||||
result = service._is_uploaded_file_key('uploaded_file.txt')
|
||||
key = f'{ap.storage_mgr.scoped_prefix(TEST_CONTEXT, owner_type="upload")}uploaded_file.txt'
|
||||
result = service._is_uploaded_file_key(TEST_CONTEXT, key)
|
||||
|
||||
# Verify
|
||||
assert result is True
|
||||
@@ -631,11 +664,12 @@ class TestMaintenanceServiceIsUploadedFileKey:
|
||||
"""Returns False for key with path separator."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.storage_mgr = _scoped_storage_manager()
|
||||
|
||||
service = MaintenanceService(ap)
|
||||
|
||||
# Execute - key with path
|
||||
result = service._is_uploaded_file_key('path/to/file.txt')
|
||||
result = service._is_uploaded_file_key(TEST_CONTEXT, 'path/to/file.txt')
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
@@ -644,11 +678,12 @@ class TestMaintenanceServiceIsUploadedFileKey:
|
||||
"""Returns False for plugin config prefix."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.storage_mgr = _scoped_storage_manager()
|
||||
|
||||
service = MaintenanceService(ap)
|
||||
|
||||
# Execute - plugin config file
|
||||
result = service._is_uploaded_file_key('plugin_config_some_plugin.json')
|
||||
result = service._is_uploaded_file_key(TEST_CONTEXT, 'plugin_config_some_plugin.json')
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
@@ -662,6 +697,7 @@ class TestMaintenanceServiceExpiredLogCandidates:
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.logger = SimpleNamespace()
|
||||
ap.storage_mgr = _scoped_storage_manager()
|
||||
|
||||
service = MaintenanceService(ap)
|
||||
|
||||
@@ -748,11 +784,12 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.logger = SimpleNamespace()
|
||||
ap.storage_mgr = _scoped_storage_manager()
|
||||
|
||||
service = MaintenanceService(ap)
|
||||
|
||||
with patch.object(Path, 'exists', return_value=False):
|
||||
result = service._expired_local_upload_candidates(7)
|
||||
result = service._expired_local_upload_candidates(TEST_CONTEXT, 7)
|
||||
|
||||
# Verify
|
||||
assert result == []
|
||||
@@ -762,12 +799,10 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.logger = SimpleNamespace()
|
||||
ap.storage_mgr = _scoped_storage_manager()
|
||||
|
||||
service = MaintenanceService(ap)
|
||||
# Mock _is_uploaded_file_key
|
||||
service._is_uploaded_file_key = Mock(side_effect=lambda key: 'plugin_config_' not in key and '/' not in key)
|
||||
|
||||
# Create mock files - one valid, one plugin config
|
||||
# Create one file and one non-file entry under the scoped upload root.
|
||||
mock_entry_valid = Mock(spec=Path)
|
||||
mock_entry_valid.is_file = Mock(return_value=True)
|
||||
mock_entry_valid.name = 'valid_upload.txt'
|
||||
@@ -775,9 +810,10 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
|
||||
mock_stat.st_size = 100
|
||||
mock_stat.st_mtime = 0 # Very old
|
||||
mock_entry_valid.stat = Mock(return_value=mock_stat)
|
||||
mock_entry_valid.relative_to = Mock(return_value=Path('scoped/valid_upload.txt'))
|
||||
|
||||
mock_entry_plugin = Mock(spec=Path)
|
||||
mock_entry_plugin.is_file = Mock(return_value=True)
|
||||
mock_entry_plugin.is_file = Mock(return_value=False)
|
||||
mock_entry_plugin.name = 'plugin_config_test.json'
|
||||
mock_stat2 = Mock()
|
||||
mock_stat2.st_size = 200
|
||||
@@ -785,23 +821,22 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
|
||||
mock_entry_plugin.stat = Mock(return_value=mock_stat2)
|
||||
|
||||
with patch.object(Path, 'exists', return_value=True):
|
||||
with patch.object(Path, 'iterdir') as mock_iterdir:
|
||||
mock_iterdir.return_value = [mock_entry_valid, mock_entry_plugin]
|
||||
result = service._expired_local_upload_candidates(7)
|
||||
with patch.object(Path, 'rglob') as mock_rglob:
|
||||
mock_rglob.return_value = [mock_entry_valid, mock_entry_plugin]
|
||||
result = service._expired_local_upload_candidates(TEST_CONTEXT, 7)
|
||||
|
||||
# Verify - only valid upload included
|
||||
assert len(result) == 1
|
||||
assert result[0]['key'] == 'valid_upload.txt'
|
||||
assert result[0]['key'] == 'scoped/valid_upload.txt'
|
||||
|
||||
def test_expired_local_upload_candidates_includes_path(self):
|
||||
"""Includes path when include_paths=True."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.logger = SimpleNamespace()
|
||||
ap.storage_mgr = _scoped_storage_manager()
|
||||
|
||||
service = MaintenanceService(ap)
|
||||
service._is_uploaded_file_key = Mock(return_value=True)
|
||||
|
||||
mock_entry = Mock(spec=Path)
|
||||
mock_entry.is_file = Mock(return_value=True)
|
||||
mock_entry.name = 'old_file.txt'
|
||||
@@ -810,11 +845,152 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
|
||||
mock_stat.st_size = 100
|
||||
mock_stat.st_mtime = 0
|
||||
mock_entry.stat = Mock(return_value=mock_stat)
|
||||
mock_entry.relative_to = Mock(return_value=Path('scoped/old_file.txt'))
|
||||
|
||||
with patch.object(Path, 'exists', return_value=True):
|
||||
with patch.object(Path, 'iterdir') as mock_iterdir:
|
||||
mock_iterdir.return_value = [mock_entry]
|
||||
result = service._expired_local_upload_candidates(7, include_paths=True)
|
||||
with patch.object(Path, 'rglob') as mock_rglob:
|
||||
mock_rglob.return_value = [mock_entry]
|
||||
result = service._expired_local_upload_candidates(
|
||||
TEST_CONTEXT,
|
||||
7,
|
||||
include_paths=True,
|
||||
)
|
||||
|
||||
# Verify - path included
|
||||
assert 'path' in result[0]
|
||||
|
||||
|
||||
ISOLATION_WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
||||
ISOLATION_WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
||||
|
||||
|
||||
def _tenant_context(workspace_uuid: str) -> ExecutionContext:
|
||||
return ExecutionContext(
|
||||
instance_uuid='instance',
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=1,
|
||||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||
)
|
||||
|
||||
|
||||
class _RealPersistenceManager:
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
async def execute_async(self, *args, **kwargs):
|
||||
async with self.engine.connect() as connection:
|
||||
result = await connection.execute(*args, **kwargs)
|
||||
await connection.commit()
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant_maintenance_service(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "maintenance.db"}')
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(Workspace),
|
||||
[
|
||||
{
|
||||
'uuid': ISOLATION_WORKSPACE_A,
|
||||
'instance_uuid': 'instance',
|
||||
'name': 'A',
|
||||
'slug': 'a',
|
||||
'source': 'cloud_projection',
|
||||
},
|
||||
{
|
||||
'uuid': ISOLATION_WORKSPACE_B,
|
||||
'instance_uuid': 'instance',
|
||||
'name': 'B',
|
||||
'slug': 'b',
|
||||
'source': 'cloud_projection',
|
||||
},
|
||||
],
|
||||
)
|
||||
now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(MonitoringMessage),
|
||||
[
|
||||
{
|
||||
'id': 'message-a',
|
||||
'workspace_uuid': ISOLATION_WORKSPACE_A,
|
||||
'timestamp': now,
|
||||
'bot_id': 'bot',
|
||||
'bot_name': 'Bot',
|
||||
'pipeline_id': 'pipeline',
|
||||
'pipeline_name': 'Pipeline',
|
||||
'message_content': 'A',
|
||||
'session_id': 'same-session',
|
||||
'status': 'success',
|
||||
'level': 'info',
|
||||
},
|
||||
{
|
||||
'id': 'message-b',
|
||||
'workspace_uuid': ISOLATION_WORKSPACE_B,
|
||||
'timestamp': now,
|
||||
'bot_id': 'bot',
|
||||
'bot_name': 'Bot',
|
||||
'pipeline_id': 'pipeline',
|
||||
'pipeline_name': 'Pipeline',
|
||||
'message_content': 'B',
|
||||
'session_id': 'same-session',
|
||||
'status': 'success',
|
||||
'level': 'info',
|
||||
},
|
||||
],
|
||||
)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(BinaryStorage),
|
||||
[
|
||||
{
|
||||
'workspace_uuid': ISOLATION_WORKSPACE_A,
|
||||
'unique_key': 'a',
|
||||
'key': 'same',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'same',
|
||||
'value': b'aaa',
|
||||
},
|
||||
{
|
||||
'workspace_uuid': ISOLATION_WORKSPACE_B,
|
||||
'unique_key': 'b',
|
||||
'key': 'same',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'same',
|
||||
'value': b'bbbbb',
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
application = SimpleNamespace(
|
||||
persistence_mgr=_RealPersistenceManager(engine),
|
||||
instance_config=SimpleNamespace(data={}),
|
||||
logger=SimpleNamespace(warning=lambda *_: None),
|
||||
)
|
||||
yield MaintenanceService(application)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_cleanup_requires_execution_context(tenant_maintenance_service):
|
||||
with pytest.raises(WorkspaceRequiredError):
|
||||
await tenant_maintenance_service.cleanup_expired_files(None)
|
||||
|
||||
|
||||
async def test_monitoring_counts_are_workspace_scoped(tenant_maintenance_service):
|
||||
counts_a = await tenant_maintenance_service._monitoring_counts(_tenant_context(ISOLATION_WORKSPACE_A))
|
||||
counts_b = await tenant_maintenance_service._monitoring_counts(_tenant_context(ISOLATION_WORKSPACE_B))
|
||||
assert counts_a['messages'] == 1
|
||||
assert counts_b['messages'] == 1
|
||||
|
||||
|
||||
async def test_binary_storage_stats_are_workspace_scoped(tenant_maintenance_service):
|
||||
stats_a = await tenant_maintenance_service._binary_storage_stats(_tenant_context(ISOLATION_WORKSPACE_A))
|
||||
stats_b = await tenant_maintenance_service._binary_storage_stats(_tenant_context(ISOLATION_WORKSPACE_B))
|
||||
assert stats_a == {'count': 1, 'size_bytes': 3}
|
||||
assert stats_b == {'count': 1, 'size_bytes': 5}
|
||||
|
||||
|
||||
async def test_path_helpers_handle_missing_paths(tenant_maintenance_service, tmp_path):
|
||||
missing = tmp_path / 'missing'
|
||||
assert tenant_maintenance_service._path_size(missing) == 0
|
||||
assert tenant_maintenance_service._file_count(missing) == 0
|
||||
|
||||
Reference in New Issue
Block a user