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:
@@ -4,11 +4,37 @@ Consolidates settings from Reports, Data Entry, and Telegram modules
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import BaseModel
|
||||
from functools import lru_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OracleServerConfig(BaseModel):
|
||||
"""Configuration for a single Oracle server instance."""
|
||||
id: str # Unique identifier (e.g., "romfast", "client_a")
|
||||
name: str # Human-readable name (e.g., "Romfast - Producție")
|
||||
host: str = "localhost"
|
||||
port: int = 1521
|
||||
user: str
|
||||
password: str
|
||||
sid: Optional[str] = None
|
||||
service_name: Optional[str] = None
|
||||
|
||||
def get_dsn(self) -> str:
|
||||
"""Build DSN string for this server."""
|
||||
if self.service_name:
|
||||
return f"{self.host}:{self.port}/{self.service_name}"
|
||||
elif self.sid:
|
||||
return f"{self.host}:{self.port}:{self.sid}"
|
||||
else:
|
||||
return f"{self.host}:{self.port}/ROA"
|
||||
|
||||
|
||||
class UnifiedSettings(BaseSettings):
|
||||
"""Unified application settings for all modules."""
|
||||
@@ -25,12 +51,105 @@ class UnifiedSettings(BaseSettings):
|
||||
# ============================================================================
|
||||
# ORACLE DATABASE (Shared by all modules)
|
||||
# ============================================================================
|
||||
# Legacy single-server configuration (backward compatible)
|
||||
oracle_user: str = ""
|
||||
oracle_password: str = ""
|
||||
oracle_host: str = "localhost"
|
||||
oracle_port: int = 1526
|
||||
oracle_sid: str = "ROA"
|
||||
|
||||
# ============================================================================
|
||||
# MULTI-ORACLE SERVER CONFIGURATION (Optional)
|
||||
# ============================================================================
|
||||
# JSON array of server configs. If not set, uses legacy single-server config.
|
||||
# Example: ORACLE_SERVERS='[{"id": "romfast", "name": "Romfast", "host": "localhost", "port": 1521, "user": "USER", "password": "PASS", "sid": "ROA"}]'
|
||||
oracle_servers: Optional[str] = None # Raw JSON string from env
|
||||
|
||||
# Parsed server configurations (populated in model_post_init)
|
||||
_oracle_servers_parsed: List[OracleServerConfig] = []
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
"""Parse ORACLE_SERVERS JSON and build server list.
|
||||
|
||||
Oracle passwords are loaded from:
|
||||
1. secrets/{server_id}.oracle_pass file (preferred, more secure)
|
||||
2. password field in ORACLE_SERVERS JSON (fallback)
|
||||
"""
|
||||
servers = []
|
||||
secrets_dir = Path(__file__).parent / "secrets"
|
||||
|
||||
if self.oracle_servers:
|
||||
# Parse multi-server JSON configuration
|
||||
try:
|
||||
servers_data = json.loads(self.oracle_servers)
|
||||
if not isinstance(servers_data, list):
|
||||
raise ValueError("ORACLE_SERVERS must be a JSON array")
|
||||
|
||||
for server_data in servers_data:
|
||||
server_id = server_data.get("id", "default")
|
||||
|
||||
# Try to load password from secrets file
|
||||
pass_file = secrets_dir / f"{server_id}.oracle_pass"
|
||||
if pass_file.exists():
|
||||
server_data["password"] = pass_file.read_text().strip()
|
||||
logger.debug(f"Loaded Oracle password for '{server_id}' from {pass_file}")
|
||||
elif "password" not in server_data:
|
||||
logger.warning(f"No password found for server '{server_id}' - check secrets/{server_id}.oracle_pass")
|
||||
|
||||
servers.append(OracleServerConfig(**server_data))
|
||||
|
||||
logger.info(f"Loaded {len(servers)} Oracle servers from ORACLE_SERVERS config")
|
||||
for srv in servers:
|
||||
logger.info(f" - {srv.id}: {srv.name} ({srv.host}:{srv.port})")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse ORACLE_SERVERS JSON: {e}")
|
||||
raise ValueError(f"Invalid ORACLE_SERVERS JSON format: {e}")
|
||||
else:
|
||||
# Backward compatibility: build default server from legacy config
|
||||
if self.oracle_user:
|
||||
# Try to load password from secrets file
|
||||
password = self.oracle_password
|
||||
pass_file = secrets_dir / "default.oracle_pass"
|
||||
if pass_file.exists():
|
||||
password = pass_file.read_text().strip()
|
||||
logger.debug(f"Loaded Oracle password from {pass_file}")
|
||||
|
||||
default_server = OracleServerConfig(
|
||||
id="default",
|
||||
name="Default Server",
|
||||
host=self.oracle_host,
|
||||
port=self.oracle_port,
|
||||
user=self.oracle_user,
|
||||
password=password,
|
||||
sid=self.oracle_sid,
|
||||
)
|
||||
servers.append(default_server)
|
||||
logger.info("Using legacy single-server Oracle configuration (ORACLE_USER/HOST/etc)")
|
||||
logger.info(f" - default: {default_server.host}:{default_server.port}/{default_server.sid}")
|
||||
|
||||
object.__setattr__(self, '_oracle_servers_parsed', servers)
|
||||
|
||||
def get_oracle_servers(self) -> List[OracleServerConfig]:
|
||||
"""Get list of configured Oracle servers."""
|
||||
return self._oracle_servers_parsed
|
||||
|
||||
def get_oracle_server(self, server_id: str) -> Optional[OracleServerConfig]:
|
||||
"""Get a specific Oracle server by ID."""
|
||||
for server in self._oracle_servers_parsed:
|
||||
if server.id == server_id:
|
||||
return server
|
||||
return None
|
||||
|
||||
def get_default_oracle_server(self) -> Optional[OracleServerConfig]:
|
||||
"""Get the default Oracle server (first in list or 'default')."""
|
||||
if not self._oracle_servers_parsed:
|
||||
return None
|
||||
# Try to find server with id='default', otherwise return first
|
||||
for server in self._oracle_servers_parsed:
|
||||
if server.id == "default":
|
||||
return server
|
||||
return self._oracle_servers_parsed[0]
|
||||
|
||||
# ============================================================================
|
||||
# JWT AUTHENTICATION (Shared by all modules)
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user