feat: multi-Oracle server support with runtime switching

Complete implementation of multi-server Oracle database support:

Backend:
- Multi-pool Oracle with lazy loading per server
- Email-to-server cache for automatic server discovery
- JWT tokens include server_id claim
- /auth/check-identity and /auth/check-email endpoints
- /auth/my-servers endpoint for listing user's accessible servers
- Server switch with password re-authentication

Frontend:
- New ServerSelector component for header dropdown
- Multi-step login flow (identity → server → password)
- Server switching from header with password modal
- Mobile drawer menu with server selection
- Dark mode support for all new components
- URL bookmark support with ?server= query param

Scripts:
- Unified start.sh replacing start-prod.sh/start-test.sh
- Unified ssh-tunnel.sh with multi-server support
- Updated status.sh for new architecture

Tests:
- E2E tests for multi-server and single-server login flows
- Backend unit tests for all new endpoints
- Oracle multi-pool integration tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-01-26 22:39:06 +00:00
parent 5f99ee2fd0
commit b137e80b71
102 changed files with 9398 additions and 2787 deletions

View File

@@ -1,112 +1,254 @@
"""
Oracle Database Connection Pool - Shared între toate aplicațiile ROA2WEB
Folosește oracledb cu connection pooling pentru performance optimă
Oracle Database Connection Pool - Multi-Server Support for ROA2WEB
Supports both single-server (backward compatible) and multi-server configurations.
Pool-uri sunt create lazy (la prima conexiune pe fiecare server) pentru optimizare.
"""
import asyncio
import oracledb
import os
from contextlib import asynccontextmanager
from typing import Optional
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger(__name__)
class OraclePool:
class OracleMultiPool:
"""
Singleton class pentru Oracle connection pool
Partajat între toate microservicele ROA2WEB
Multi-tenant Oracle connection pool manager.
Supports:
- Multiple Oracle servers with separate pools: {server_id: pool}
- Lazy pool creation (created on first connection)
- Backward compatibility (default server when no server_id specified)
- Graceful shutdown of all pools
"""
_instance: Optional['OraclePool'] = None
_pool: Optional[oracledb.ConnectionPool] = None
_instance: Optional['OracleMultiPool'] = None
_pools: Dict[str, oracledb.ConnectionPool]
_pool_configs: Dict[str, Dict[str, Any]]
_pool_lock: asyncio.Lock
_legacy_pool: Optional[oracledb.ConnectionPool] # For backward compatibility
_initialized: bool
def __new__(cls):
if cls._instance is None:
cls._instance = super(OraclePool, cls).__new__(cls)
cls._instance = super(OracleMultiPool, cls).__new__(cls)
cls._instance._pools = {}
cls._instance._pool_configs = {}
cls._instance._pool_lock = asyncio.Lock()
cls._instance._legacy_pool = None
cls._instance._initialized = False
return cls._instance
async def initialize(self, **config):
"""Inițializează pool-ul de conexiuni"""
if self._pool is None:
# Check if we have DSN or individual parameters
dsn = config.get('dsn', os.getenv('ORACLE_DSN'))
if dsn:
# Use DSN connection
self._pool = oracledb.create_pool(
user=config.get('user', os.getenv('ORACLE_USER')),
password=config.get('password', os.getenv('ORACLE_PASSWORD')),
dsn=dsn,
min=config.get('min_connections', 2),
max=config.get('max_connections', 10),
increment=config.get('increment', 1),
getmode=oracledb.POOL_GETMODE_WAIT
)
"""
Initialize pool manager.
For backward compatibility, this can:
1. Create a legacy single pool (if called with individual params)
2. Just mark as initialized (if using lazy multi-pool loading)
"""
if self._initialized:
logger.debug("Pool manager already initialized")
return
# Check if we have DSN or individual parameters (legacy mode)
dsn = config.get('dsn', os.getenv('ORACLE_DSN'))
user = config.get('user', os.getenv('ORACLE_USER'))
if dsn or user:
# Legacy single-pool mode - create pool immediately
await self._create_legacy_pool(config)
self._initialized = True
logger.info("Oracle pool manager initialized")
async def _create_legacy_pool(self, config: Dict[str, Any]) -> None:
"""Create legacy single pool for backward compatibility."""
dsn = config.get('dsn', os.getenv('ORACLE_DSN'))
if dsn:
# Use DSN connection
self._legacy_pool = oracledb.create_pool(
user=config.get('user', os.getenv('ORACLE_USER')),
password=config.get('password', os.getenv('ORACLE_PASSWORD')),
dsn=dsn,
min=config.get('min_connections', 2),
max=config.get('max_connections', 10),
increment=config.get('increment', 1),
getmode=oracledb.POOL_GETMODE_WAIT
)
else:
# Use individual parameters (host, port, service_name or sid)
service_name = config.get('service_name', os.getenv('ORACLE_SERVICE_NAME'))
sid = config.get('sid', os.getenv('ORACLE_SID'))
pool_params = {
'user': config.get('user', os.getenv('ORACLE_USER')),
'password': config.get('password', os.getenv('ORACLE_PASSWORD')),
'host': config.get('host', os.getenv('ORACLE_HOST', 'localhost')),
'port': config.get('port', int(os.getenv('ORACLE_PORT', '1526'))),
'min': config.get('min_connections', 2),
'max': config.get('max_connections', 10),
'increment': config.get('increment', 1),
'getmode': oracledb.POOL_GETMODE_WAIT
}
if service_name:
pool_params['service_name'] = service_name
logger.info(f"Using SERVICE_NAME: {service_name}")
elif sid:
pool_params['sid'] = sid
logger.info(f"Using SID: {sid}")
else:
# Use individual parameters (host, port, service_name or sid)
# Prefer SERVICE_NAME over SID (more modern Oracle approach)
service_name = config.get('service_name', os.getenv('ORACLE_SERVICE_NAME'))
sid = config.get('sid', os.getenv('ORACLE_SID'))
pool_params['service_name'] = 'ROA'
logger.info("Using default SERVICE_NAME: ROA")
pool_params = {
'user': config.get('user', os.getenv('ORACLE_USER')),
'password': config.get('password', os.getenv('ORACLE_PASSWORD')),
'host': config.get('host', os.getenv('ORACLE_HOST', 'localhost')),
'port': config.get('port', int(os.getenv('ORACLE_PORT', '1526'))),
'min': config.get('min_connections', 2),
'max': config.get('max_connections', 10),
'increment': config.get('increment', 1),
'getmode': oracledb.POOL_GETMODE_WAIT
}
self._legacy_pool = oracledb.create_pool(**pool_params)
# Use service_name if available, otherwise fall back to sid
if service_name:
pool_params['service_name'] = service_name
logger.info(f"Using SERVICE_NAME: {service_name}")
elif sid:
pool_params['sid'] = sid
logger.info(f"Using SID: {sid}")
else:
# Default fallback
pool_params['service_name'] = 'ROA'
logger.info("Using default SERVICE_NAME: ROA")
logger.info(f"Legacy Oracle pool created with {self._legacy_pool.opened} connections")
def register_server(
self,
server_id: str,
host: str,
port: int,
user: str,
password: str,
sid: Optional[str] = None,
service_name: Optional[str] = None,
min_connections: int = 2,
max_connections: int = 10,
**kwargs
) -> None:
"""
Register a server configuration for lazy pool creation.
Pool will be created on first get_connection(server_id) call.
"""
self._pool_configs[server_id] = {
'host': host,
'port': port,
'user': user,
'password': password,
'sid': sid,
'service_name': service_name,
'min_connections': min_connections,
'max_connections': max_connections,
}
logger.info(f"Registered server '{server_id}' ({host}:{port}) for lazy pool creation")
async def _get_or_create_pool(self, server_id: str) -> oracledb.ConnectionPool:
"""
Get existing pool or create new one (lazy loading).
Thread-safe: uses asyncio.Lock to prevent duplicate pool creation.
"""
# Fast path: pool already exists
if server_id in self._pools:
return self._pools[server_id]
# Slow path: need to create pool
async with self._pool_lock:
# Double-check after acquiring lock
if server_id in self._pools:
return self._pools[server_id]
# Check if server is registered
if server_id not in self._pool_configs:
raise ValueError(f"Server '{server_id}' not registered. Call register_server() first.")
config = self._pool_configs[server_id]
logger.info(f"Creating pool for server '{server_id}' (lazy initialization)...")
pool_params = {
'user': config['user'],
'password': config['password'],
'host': config['host'],
'port': config['port'],
'min': config['min_connections'],
'max': config['max_connections'],
'increment': 1,
'getmode': oracledb.POOL_GETMODE_WAIT
}
if config.get('service_name'):
pool_params['service_name'] = config['service_name']
elif config.get('sid'):
pool_params['sid'] = config['sid']
else:
pool_params['service_name'] = 'ROA'
pool = oracledb.create_pool(**pool_params)
self._pools[server_id] = pool
logger.info(f"Pool created for server '{server_id}' with {pool.opened} connections")
return pool
self._pool = oracledb.create_pool(**pool_params)
logger.info(f"Oracle pool created with {self._pool.opened} connections")
@asynccontextmanager
async def get_connection(self):
"""Context manager pentru obținerea unei conexiuni din pool"""
if self._pool is None:
raise RuntimeError("Pool not initialized. Call initialize() first.")
async def get_connection(self, server_id: Optional[str] = None):
"""
Context manager pentru obținerea unei conexiuni din pool.
Args:
server_id: ID-ul serverului. Dacă None, folosește legacy pool sau default.
Usage:
# Multi-server mode
async with oracle_pool.get_connection('romfast') as conn:
...
# Backward compatible (legacy single pool)
async with oracle_pool.get_connection() as conn:
...
"""
connection = None
pool = None
try:
connection = self._pool.acquire()
logger.debug("Connection acquired from pool")
if server_id is None:
# Backward compatibility: use legacy pool
if self._legacy_pool is None:
# If no legacy pool, try to use 'default' server
if 'default' in self._pool_configs:
pool = await self._get_or_create_pool('default')
else:
raise RuntimeError(
"No pool available. Either initialize() with config "
"or register_server() with server_id='default'."
)
else:
pool = self._legacy_pool
else:
pool = await self._get_or_create_pool(server_id)
connection = pool.acquire()
logger.debug(f"Connection acquired from pool (server_id={server_id})")
yield connection
finally:
if connection is not None:
connection.close()
logger.debug("Connection returned to pool")
logger.debug(f"Connection returned to pool (server_id={server_id})")
async def execute_query(self, query: str, parameters=None):
async def execute_query(self, query: str, parameters=None, server_id: Optional[str] = None):
"""
Execute a SQL query and return all results
Based on official Oracle python-oracledb patterns
Execute a SQL query and return all results.
Args:
query: SQL query string
parameters: Query parameters (dict or tuple)
server_id: Server ID for multi-pool mode (optional)
"""
if self._pool is None:
raise RuntimeError("Pool not initialized. Call initialize() first.")
connection = None
try:
connection = self._pool.acquire()
logger.debug(f"Executing query: {query[:100]}...")
async with self.get_connection(server_id) as connection:
logger.debug(f"Executing query on server '{server_id}': {query[:100]}...")
with connection.cursor() as cursor:
if parameters:
cursor.execute(query, parameters)
else:
cursor.execute(query)
# Check if this is a SELECT statement
if query.strip().upper().startswith('SELECT') or query.strip().upper().startswith('WITH'):
return cursor.fetchall()
@@ -114,23 +256,95 @@ class OraclePool:
# For DML statements, return affected row count
connection.commit()
return cursor.rowcount
except Exception as e:
if connection:
connection.rollback()
logger.error(f"Query execution failed: {str(e)}")
raise
finally:
if connection is not None:
connection.close()
logger.debug("Connection returned to pool")
async def close_pool(self):
"""Închide pool-ul de conexiuni"""
if self._pool is not None:
self._pool.close()
self._pool = None
logger.info("Oracle pool closed")
async def close_pool(self, server_id: Optional[str] = None):
"""
Close a specific pool or all pools.
Args:
server_id: Close specific pool. If None, close all pools.
"""
if server_id is not None:
# Close specific pool
if server_id in self._pools:
self._pools[server_id].close()
del self._pools[server_id]
logger.info(f"Closed pool for server '{server_id}'")
else:
# Close all pools (graceful shutdown)
if self._legacy_pool is not None:
self._legacy_pool.close()
self._legacy_pool = None
logger.info("Closed legacy pool")
for srv_id, pool in list(self._pools.items()):
pool.close()
logger.info(f"Closed pool for server '{srv_id}'")
self._pools.clear()
self._initialized = False
logger.info("All Oracle pools closed")
def get_pool_stats(self, server_id: Optional[str] = None) -> Dict[str, Any]:
"""
Get statistics for pool(s).
Args:
server_id: Get stats for specific server. If None, get all stats.
Returns:
Dict with pool statistics (opened, busy, min, max connections)
"""
stats = {}
if server_id is not None:
pool = self._pools.get(server_id)
if pool:
stats[server_id] = {
'opened': pool.opened,
'busy': pool.busy,
'min': pool.min,
'max': pool.max,
}
else:
# All pools including legacy
if self._legacy_pool:
stats['legacy'] = {
'opened': self._legacy_pool.opened,
'busy': self._legacy_pool.busy,
'min': self._legacy_pool.min,
'max': self._legacy_pool.max,
}
for srv_id, pool in self._pools.items():
stats[srv_id] = {
'opened': pool.opened,
'busy': pool.busy,
'min': pool.min,
'max': pool.max,
}
return stats
def is_server_registered(self, server_id: str) -> bool:
"""Check if a server is registered (config exists)."""
return server_id in self._pool_configs
def is_pool_active(self, server_id: str) -> bool:
"""Check if a pool is active (created) for a server."""
return server_id in self._pools
def get_registered_servers(self) -> list:
"""Get list of registered server IDs."""
return list(self._pool_configs.keys())
def get_active_pools(self) -> list:
"""Get list of server IDs with active pools."""
return list(self._pools.keys())
# Backward compatibility: keep old class name as alias
OraclePool = OracleMultiPool
# Instance globală pentru folosire în toate aplicațiile
oracle_pool = OraclePool()
oracle_pool = OracleMultiPool()