mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 15:34:36 +01:00
313 lines
11 KiB
Python
313 lines
11 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
|
|
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.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
|
|
|
|
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()
|