Files
enclava/backend/app/services/module_config_manager.py
2025-08-19 09:50:15 +02:00

308 lines
12 KiB
Python

"""
Module-specific configuration management service
Works alongside the general ConfigManager for module discovery and schema validation
"""
import json
import yaml
from typing import Dict, List, Any, Optional
from pathlib import Path
from jsonschema import validate, ValidationError, draft7_format_checker
from dataclasses import dataclass, asdict
from app.core.logging import get_logger
from app.utils.exceptions import ConfigurationError
logger = get_logger(__name__)
@dataclass
class ModuleManifest:
"""Module manifest loaded from module.yaml"""
name: str
version: str
description: str
author: str
category: str = "general"
enabled: bool = True
auto_start: bool = True
dependencies: List[str] = None
optional_dependencies: List[str] = None
config_schema: Optional[str] = None
ui_components: Optional[str] = None
provides: List[str] = None
consumes: List[str] = None
endpoints: List[Dict] = None
workflow_steps: List[Dict] = None
permissions: List[Dict] = None
analytics_events: List[Dict] = None
health_checks: List[Dict] = None
ui_config: Dict = None
documentation: Dict = None
def __post_init__(self):
if self.dependencies is None:
self.dependencies = []
if self.optional_dependencies is None:
self.optional_dependencies = []
if self.provides is None:
self.provides = []
if self.consumes is None:
self.consumes = []
if self.endpoints is None:
self.endpoints = []
if self.workflow_steps is None:
self.workflow_steps = []
if self.permissions is None:
self.permissions = []
if self.analytics_events is None:
self.analytics_events = []
if self.health_checks is None:
self.health_checks = []
if self.ui_config is None:
self.ui_config = {}
if self.documentation is None:
self.documentation = {}
class ModuleConfigManager:
"""Manages module configurations and JSON schema validation"""
def __init__(self):
self.manifests: Dict[str, ModuleManifest] = {}
self.schemas: Dict[str, Dict] = {}
self.configs: Dict[str, Dict] = {}
async def discover_modules(self, modules_path: str = "modules") -> Dict[str, ModuleManifest]:
"""Discover modules from filesystem using module.yaml manifests"""
discovered_modules = {}
modules_dir = Path(modules_path)
if not modules_dir.exists():
logger.warning(f"Modules directory not found: {modules_path}")
return discovered_modules
logger.info(f"Discovering modules in: {modules_dir.absolute()}")
for module_dir in modules_dir.iterdir():
if not module_dir.is_dir():
continue
manifest_path = module_dir / "module.yaml"
if not manifest_path.exists():
# Try module.yml as fallback
manifest_path = module_dir / "module.yml"
if not manifest_path.exists():
# Check if it's a legacy module (has main.py but no manifest)
if (module_dir / "main.py").exists():
logger.info(f"Legacy module found (no manifest): {module_dir.name}")
# Create a basic manifest for legacy modules
manifest = ModuleManifest(
name=module_dir.name,
version="1.0.0",
description=f"Legacy {module_dir.name} module",
author="System",
category="legacy"
)
discovered_modules[manifest.name] = manifest
continue
try:
manifest = await self._load_module_manifest(manifest_path)
discovered_modules[manifest.name] = manifest
logger.info(f"Discovered module: {manifest.name} v{manifest.version}")
except Exception as e:
logger.error(f"Failed to load manifest for {module_dir.name}: {e}")
continue
self.manifests = discovered_modules
return discovered_modules
async def _load_module_manifest(self, manifest_path: Path) -> ModuleManifest:
"""Load and validate a module manifest file"""
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest_data = yaml.safe_load(f)
# Validate required fields
required_fields = ['name', 'version', 'description', 'author']
for field in required_fields:
if field not in manifest_data:
raise ConfigurationError(f"Missing required field '{field}' in {manifest_path}")
manifest = ModuleManifest(**manifest_data)
# Load configuration schema if specified
if manifest.config_schema:
schema_path = manifest_path.parent / manifest.config_schema
if schema_path.exists():
await self._load_module_schema(manifest.name, schema_path)
else:
logger.warning(f"Config schema not found: {schema_path}")
return manifest
except yaml.YAMLError as e:
raise ConfigurationError(f"Invalid YAML in {manifest_path}: {e}")
except Exception as e:
raise ConfigurationError(f"Failed to load manifest {manifest_path}: {e}")
async def _load_module_schema(self, module_name: str, schema_path: Path):
"""Load JSON schema for module configuration"""
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
self.schemas[module_name] = schema
logger.info(f"Loaded configuration schema for module: {module_name}")
except json.JSONDecodeError as e:
raise ConfigurationError(f"Invalid JSON schema in {schema_path}: {e}")
except Exception as e:
raise ConfigurationError(f"Failed to load schema {schema_path}: {e}")
def get_module_manifest(self, module_name: str) -> Optional[ModuleManifest]:
"""Get module manifest by name"""
return self.manifests.get(module_name)
def get_module_schema(self, module_name: str) -> Optional[Dict]:
"""Get configuration schema for a module"""
return self.schemas.get(module_name)
def get_module_config(self, module_name: str) -> Dict:
"""Get current configuration for a module"""
return self.configs.get(module_name, {})
async def validate_config(self, module_name: str, config: Dict) -> Dict:
"""Validate module configuration against its schema"""
schema = self.schemas.get(module_name)
if not schema:
logger.info(f"No schema found for module {module_name}, skipping validation")
return {"valid": True, "errors": []}
try:
validate(instance=config, schema=schema, format_checker=draft7_format_checker)
return {"valid": True, "errors": []}
except ValidationError as e:
return {
"valid": False,
"errors": [{
"path": list(e.path),
"message": e.message,
"invalid_value": e.instance
}]
}
except Exception as e:
return {
"valid": False,
"errors": [{"message": f"Schema validation failed: {str(e)}"}]
}
async def save_module_config(self, module_name: str, config: Dict) -> bool:
"""Save module configuration"""
# Validate configuration first
validation_result = await self.validate_config(module_name, config)
if not validation_result["valid"]:
error_messages = [error["message"] for error in validation_result["errors"]]
raise ConfigurationError(f"Invalid configuration for {module_name}: {', '.join(error_messages)}")
# Save configuration
self.configs[module_name] = config
# In production, this would persist to database
# For now, we'll save to a local JSON file
config_dir = Path("backend/storage/module_configs")
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / f"{module_name}.json"
try:
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2)
logger.info(f"Saved configuration for module: {module_name}")
return True
except Exception as e:
logger.error(f"Failed to save config for {module_name}: {e}")
return False
async def load_saved_configs(self):
"""Load previously saved module configurations"""
config_dir = Path("backend/storage/module_configs")
if not config_dir.exists():
return
for config_file in config_dir.glob("*.json"):
module_name = config_file.stem
try:
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
self.configs[module_name] = config
logger.info(f"Loaded saved configuration for module: {module_name}")
except Exception as e:
logger.error(f"Failed to load saved config for {module_name}: {e}")
def list_available_modules(self) -> List[Dict]:
"""List all discovered modules with their metadata"""
modules = []
for name, manifest in self.manifests.items():
modules.append({
"name": manifest.name,
"version": manifest.version,
"description": manifest.description,
"author": manifest.author,
"category": manifest.category,
"enabled": manifest.enabled,
"dependencies": manifest.dependencies,
"provides": manifest.provides,
"consumes": manifest.consumes,
"has_schema": name in self.schemas,
"has_config": name in self.configs,
"ui_config": manifest.ui_config
})
return modules
def get_workflow_steps(self) -> Dict[str, List[Dict]]:
"""Get all available workflow steps from modules"""
workflow_steps = {}
for name, manifest in self.manifests.items():
if manifest.workflow_steps:
workflow_steps[name] = manifest.workflow_steps
return workflow_steps
async def update_module_status(self, module_name: str, enabled: bool) -> bool:
"""Update module enabled status"""
manifest = self.manifests.get(module_name)
if not manifest:
return False
manifest.enabled = enabled
# Update the manifest file
modules_dir = Path("modules")
manifest_path = modules_dir / module_name / "module.yaml"
if manifest_path.exists():
try:
manifest_dict = asdict(manifest)
with open(manifest_path, 'w', encoding='utf-8') as f:
yaml.dump(manifest_dict, f, default_flow_style=False)
logger.info(f"Updated module status: {module_name} enabled={enabled}")
return True
except Exception as e:
logger.error(f"Failed to update manifest for {module_name}: {e}")
return False
return False
# Global module config manager instance
module_config_manager = ModuleConfigManager()