mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 15:34:36 +01:00
509 lines
18 KiB
Python
509 lines
18 KiB
Python
"""
|
|
Plugin Manifest Schema and Validation
|
|
Defines the structure and validation for plugin manifest files
|
|
"""
|
|
from typing import List, Dict, Any, Optional, Union
|
|
from pydantic import BaseModel, Field, validator, HttpUrl
|
|
from enum import Enum
|
|
import yaml
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
|
|
|
|
class PluginRuntimeSpec(BaseModel):
|
|
"""Plugin runtime requirements and dependencies"""
|
|
|
|
python_version: str = Field("3.11", description="Required Python version")
|
|
dependencies: List[str] = Field(
|
|
default_factory=list, description="Required Python packages"
|
|
)
|
|
environment_variables: Dict[str, str] = Field(
|
|
default_factory=dict, description="Required environment variables"
|
|
)
|
|
|
|
@validator("python_version")
|
|
def validate_python_version(cls, v):
|
|
if not v.startswith(("3.9", "3.10", "3.11", "3.12")):
|
|
raise ValueError("Python version must be 3.9, 3.10, 3.11, or 3.12")
|
|
return v
|
|
|
|
|
|
class PluginPermissions(BaseModel):
|
|
"""Plugin permission specifications"""
|
|
|
|
platform_apis: List[str] = Field(
|
|
default_factory=list, description="Platform API access scopes"
|
|
)
|
|
plugin_scopes: List[str] = Field(
|
|
default_factory=list, description="Plugin-specific permission scopes"
|
|
)
|
|
external_domains: List[str] = Field(
|
|
default_factory=list, description="Allowed external domains"
|
|
)
|
|
|
|
@validator("platform_apis")
|
|
def validate_platform_apis(cls, v):
|
|
allowed_apis = [
|
|
"chatbot:invoke",
|
|
"chatbot:manage",
|
|
"chatbot:read",
|
|
"rag:query",
|
|
"rag:manage",
|
|
"rag:read",
|
|
"llm:completion",
|
|
"llm:embeddings",
|
|
"llm:models",
|
|
"workflow:execute",
|
|
"workflow:read",
|
|
"cache:read",
|
|
"cache:write",
|
|
]
|
|
for api in v:
|
|
if api not in allowed_apis and not api.endswith(":*"):
|
|
raise ValueError(f"Invalid platform API scope: {api}")
|
|
return v
|
|
|
|
|
|
class PluginDatabaseSpec(BaseModel):
|
|
"""Plugin database configuration"""
|
|
|
|
schema: str = Field(..., description="Database schema name")
|
|
migrations_path: str = Field("./migrations", description="Path to migration files")
|
|
auto_migrate: bool = Field(True, description="Auto-run migrations on startup")
|
|
|
|
@validator("schema")
|
|
def validate_schema_name(cls, v):
|
|
if not v.startswith("plugin_"):
|
|
raise ValueError('Database schema must start with "plugin_"')
|
|
if not v.replace("plugin_", "").replace("_", "").isalnum():
|
|
raise ValueError(
|
|
"Schema name must contain only alphanumeric characters and underscores"
|
|
)
|
|
return v
|
|
|
|
|
|
class PluginAPIEndpoint(BaseModel):
|
|
"""Plugin API endpoint specification"""
|
|
|
|
path: str = Field(..., description="API endpoint path")
|
|
methods: List[str] = Field(default=["GET"], description="Allowed HTTP methods")
|
|
description: str = Field("", description="Endpoint description")
|
|
auth_required: bool = Field(True, description="Whether authentication is required")
|
|
|
|
@validator("methods")
|
|
def validate_methods(cls, v):
|
|
allowed_methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
|
|
for method in v:
|
|
if method not in allowed_methods:
|
|
raise ValueError(f"Invalid HTTP method: {method}")
|
|
return v
|
|
|
|
@validator("path")
|
|
def validate_path(cls, v):
|
|
if not v.startswith("/"):
|
|
raise ValueError('API path must start with "/"')
|
|
return v
|
|
|
|
|
|
class PluginCronJob(BaseModel):
|
|
"""Plugin scheduled job specification"""
|
|
|
|
name: str = Field(..., description="Job name")
|
|
schedule: str = Field(..., description="Cron expression")
|
|
function: str = Field(..., description="Function to execute")
|
|
description: str = Field("", description="Job description")
|
|
enabled: bool = Field(True, description="Whether job is enabled by default")
|
|
timeout_seconds: int = Field(300, description="Job timeout in seconds")
|
|
max_retries: int = Field(3, description="Maximum retry attempts")
|
|
|
|
@validator("schedule")
|
|
def validate_cron_expression(cls, v):
|
|
# Basic cron validation - should have 5 parts
|
|
parts = v.split()
|
|
if len(parts) != 5:
|
|
raise ValueError(
|
|
"Cron expression must have 5 parts (minute hour day month weekday)"
|
|
)
|
|
return v
|
|
|
|
|
|
class PluginUIConfig(BaseModel):
|
|
"""Plugin UI configuration"""
|
|
|
|
configuration_schema: str = Field(
|
|
"./config_schema.json", description="JSON schema for configuration"
|
|
)
|
|
ui_components: str = Field("./ui/components", description="Path to UI components")
|
|
pages: List[Dict[str, str]] = Field(
|
|
default_factory=list, description="Plugin pages"
|
|
)
|
|
|
|
@validator("pages")
|
|
def validate_pages(cls, v):
|
|
required_fields = ["name", "path", "component"]
|
|
for page in v:
|
|
for field in required_fields:
|
|
if field not in page:
|
|
raise ValueError(f"Page must have {field} field")
|
|
return v
|
|
|
|
|
|
class PluginExternalServices(BaseModel):
|
|
"""Plugin external service configuration"""
|
|
|
|
allowed_domains: List[str] = Field(
|
|
default_factory=list, description="Allowed external domains"
|
|
)
|
|
webhooks: List[Dict[str, str]] = Field(
|
|
default_factory=list, description="Webhook configurations"
|
|
)
|
|
rate_limits: Dict[str, int] = Field(
|
|
default_factory=dict, description="Rate limits per domain"
|
|
)
|
|
|
|
|
|
class PluginMetadata(BaseModel):
|
|
"""Plugin metadata information"""
|
|
|
|
name: str = Field(..., description="Plugin name (must be unique)")
|
|
version: str = Field(..., description="Plugin version (semantic versioning)")
|
|
description: str = Field(..., description="Plugin description")
|
|
author: str = Field(..., description="Plugin author")
|
|
license: str = Field("MIT", description="Plugin license")
|
|
homepage: Optional[HttpUrl] = Field(None, description="Plugin homepage URL")
|
|
repository: Optional[HttpUrl] = Field(None, description="Plugin repository URL")
|
|
tags: List[str] = Field(
|
|
default_factory=list, description="Plugin tags for discovery"
|
|
)
|
|
|
|
@validator("name")
|
|
def validate_name(cls, v):
|
|
if not v.replace("-", "").replace("_", "").isalnum():
|
|
raise ValueError(
|
|
"Plugin name must contain only alphanumeric characters, hyphens, and underscores"
|
|
)
|
|
if len(v) < 3 or len(v) > 50:
|
|
raise ValueError("Plugin name must be between 3 and 50 characters")
|
|
return v.lower()
|
|
|
|
@validator("version")
|
|
def validate_version(cls, v):
|
|
# Basic semantic versioning validation
|
|
parts = v.split(".")
|
|
if len(parts) != 3:
|
|
raise ValueError("Version must follow semantic versioning (x.y.z)")
|
|
for part in parts:
|
|
if not part.isdigit():
|
|
raise ValueError("Version parts must be numeric")
|
|
return v
|
|
|
|
|
|
class PluginManifest(BaseModel):
|
|
"""Complete plugin manifest specification"""
|
|
|
|
apiVersion: str = Field("v1", description="Manifest API version")
|
|
kind: str = Field("Plugin", description="Resource kind")
|
|
metadata: PluginMetadata = Field(..., description="Plugin metadata")
|
|
spec: "PluginSpec" = Field(..., description="Plugin specification")
|
|
|
|
@validator("apiVersion")
|
|
def validate_api_version(cls, v):
|
|
if v not in ["v1"]:
|
|
raise ValueError("Unsupported API version")
|
|
return v
|
|
|
|
@validator("kind")
|
|
def validate_kind(cls, v):
|
|
if v != "Plugin":
|
|
raise ValueError('Kind must be "Plugin"')
|
|
return v
|
|
|
|
|
|
class PluginSpec(BaseModel):
|
|
"""Plugin specification details"""
|
|
|
|
runtime: PluginRuntimeSpec = Field(
|
|
default_factory=PluginRuntimeSpec, description="Runtime requirements"
|
|
)
|
|
permissions: PluginPermissions = Field(
|
|
default_factory=PluginPermissions, description="Permission requirements"
|
|
)
|
|
database: Optional[PluginDatabaseSpec] = Field(
|
|
None, description="Database configuration"
|
|
)
|
|
api_endpoints: List[PluginAPIEndpoint] = Field(
|
|
default_factory=list, description="API endpoints"
|
|
)
|
|
cron_jobs: List[PluginCronJob] = Field(
|
|
default_factory=list, description="Scheduled jobs"
|
|
)
|
|
ui_config: Optional[PluginUIConfig] = Field(None, description="UI configuration")
|
|
external_services: Optional[PluginExternalServices] = Field(
|
|
None, description="External service configuration"
|
|
)
|
|
config_schema: Dict[str, Any] = Field(
|
|
default_factory=dict, description="Plugin configuration JSON schema"
|
|
)
|
|
|
|
|
|
# Update forward reference
|
|
PluginManifest.model_rebuild()
|
|
|
|
|
|
class PluginManifestValidator:
|
|
"""Plugin manifest validation and parsing utilities"""
|
|
|
|
REQUIRED_FILES = ["manifest.yaml", "main.py", "requirements.txt"]
|
|
|
|
OPTIONAL_FILES = [
|
|
"config_schema.json",
|
|
"README.md",
|
|
"ui/components",
|
|
"migrations",
|
|
"tests",
|
|
]
|
|
|
|
@classmethod
|
|
def load_from_file(cls, manifest_path: Union[str, Path]) -> PluginManifest:
|
|
"""Load and validate plugin manifest from YAML file"""
|
|
manifest_path = Path(manifest_path)
|
|
|
|
if not manifest_path.exists():
|
|
raise FileNotFoundError(f"Manifest file not found: {manifest_path}")
|
|
|
|
try:
|
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
manifest_data = yaml.safe_load(f)
|
|
except yaml.YAMLError as e:
|
|
raise ValueError(f"Invalid YAML in manifest file: {e}")
|
|
|
|
try:
|
|
manifest = PluginManifest(**manifest_data)
|
|
except Exception as e:
|
|
raise ValueError(f"Invalid manifest structure: {e}")
|
|
|
|
# Additional validation
|
|
cls._validate_plugin_structure(manifest_path.parent, manifest)
|
|
|
|
return manifest
|
|
|
|
@classmethod
|
|
def _validate_plugin_structure(cls, plugin_dir: Path, manifest: PluginManifest):
|
|
"""Validate plugin directory structure and required files"""
|
|
|
|
# Check required files
|
|
for required_file in cls.REQUIRED_FILES:
|
|
file_path = plugin_dir / required_file
|
|
if not file_path.exists():
|
|
raise FileNotFoundError(f"Required file missing: {required_file}")
|
|
|
|
# Validate main.py contains plugin class
|
|
main_py_path = plugin_dir / "main.py"
|
|
with open(main_py_path, "r", encoding="utf-8") as f:
|
|
main_content = f.read()
|
|
|
|
if "BasePlugin" not in main_content:
|
|
raise ValueError("main.py must contain a class inheriting from BasePlugin")
|
|
|
|
# Validate requirements.txt format
|
|
requirements_path = plugin_dir / "requirements.txt"
|
|
with open(requirements_path, "r", encoding="utf-8") as f:
|
|
requirements = f.read().strip()
|
|
|
|
if requirements and not all(line.strip() for line in requirements.split("\n")):
|
|
raise ValueError("Invalid requirements.txt format")
|
|
|
|
# Validate config schema if specified
|
|
if manifest.spec.ui_config and manifest.spec.ui_config.configuration_schema:
|
|
schema_path = plugin_dir / manifest.spec.ui_config.configuration_schema
|
|
if schema_path.exists():
|
|
try:
|
|
import json
|
|
|
|
with open(schema_path, "r", encoding="utf-8") as f:
|
|
json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
raise ValueError(f"Invalid JSON schema: {e}")
|
|
|
|
# Validate migrations if database is specified
|
|
if manifest.spec.database:
|
|
migrations_path = plugin_dir / manifest.spec.database.migrations_path
|
|
if migrations_path.exists() and not migrations_path.is_dir():
|
|
raise ValueError("Migrations path must be a directory")
|
|
|
|
@classmethod
|
|
def validate_plugin_compatibility(cls, manifest: PluginManifest) -> Dict[str, Any]:
|
|
"""Validate plugin compatibility with platform"""
|
|
|
|
compatibility_report = {
|
|
"compatible": True,
|
|
"warnings": [],
|
|
"errors": [],
|
|
"platform_version": "1.0.0",
|
|
}
|
|
|
|
# Check platform API compatibility
|
|
unsupported_apis = []
|
|
for api in manifest.spec.permissions.platform_apis:
|
|
if not cls._is_platform_api_supported(api):
|
|
unsupported_apis.append(api)
|
|
|
|
if unsupported_apis:
|
|
compatibility_report["errors"].append(
|
|
f"Unsupported platform APIs: {', '.join(unsupported_apis)}"
|
|
)
|
|
compatibility_report["compatible"] = False
|
|
|
|
# Check Python version compatibility
|
|
required_version = manifest.spec.runtime.python_version
|
|
if not cls._is_python_version_supported(required_version):
|
|
compatibility_report["errors"].append(
|
|
f"Unsupported Python version: {required_version}"
|
|
)
|
|
compatibility_report["compatible"] = False
|
|
|
|
# Check dependency compatibility
|
|
for dependency in manifest.spec.runtime.dependencies:
|
|
if cls._is_dependency_conflicting(dependency):
|
|
compatibility_report["warnings"].append(
|
|
f"Potential dependency conflict: {dependency}"
|
|
)
|
|
|
|
return compatibility_report
|
|
|
|
@classmethod
|
|
def _is_platform_api_supported(cls, api: str) -> bool:
|
|
"""Check if platform API is supported"""
|
|
supported_apis = [
|
|
"chatbot:invoke",
|
|
"chatbot:manage",
|
|
"chatbot:read",
|
|
"rag:query",
|
|
"rag:manage",
|
|
"rag:read",
|
|
"llm:completion",
|
|
"llm:embeddings",
|
|
"llm:models",
|
|
"workflow:execute",
|
|
"workflow:read",
|
|
"cache:read",
|
|
"cache:write",
|
|
]
|
|
|
|
# Support wildcard permissions
|
|
if api.endswith(":*"):
|
|
base_api = api[:-2]
|
|
return any(
|
|
supported.startswith(base_api + ":") for supported in supported_apis
|
|
)
|
|
|
|
return api in supported_apis
|
|
|
|
@classmethod
|
|
def _is_python_version_supported(cls, version: str) -> bool:
|
|
"""Check if Python version is supported"""
|
|
supported_versions = ["3.9", "3.10", "3.11", "3.12"]
|
|
return any(version.startswith(v) for v in supported_versions)
|
|
|
|
@classmethod
|
|
def _is_dependency_conflicting(cls, dependency: str) -> bool:
|
|
"""Check if dependency might conflict with platform"""
|
|
# Extract package name (before ==, >=, etc.)
|
|
package_name = (
|
|
dependency.split("==")[0]
|
|
.split(">=")[0]
|
|
.split("<=")[0]
|
|
.split(">")[0]
|
|
.split("<")[0]
|
|
.strip()
|
|
)
|
|
|
|
# Known conflicting packages
|
|
conflicting_packages = [
|
|
"sqlalchemy", # Platform uses specific version
|
|
"fastapi", # Platform uses specific version
|
|
"pydantic", # Platform uses specific version
|
|
"alembic", # Platform migration system
|
|
]
|
|
|
|
return package_name.lower() in conflicting_packages
|
|
|
|
@classmethod
|
|
def generate_manifest_hash(cls, manifest: PluginManifest) -> str:
|
|
"""Generate hash for manifest content verification"""
|
|
manifest_dict = manifest.dict()
|
|
manifest_str = yaml.dump(
|
|
manifest_dict, sort_keys=True, default_flow_style=False
|
|
)
|
|
return hashlib.sha256(manifest_str.encode("utf-8")).hexdigest()
|
|
|
|
@classmethod
|
|
def create_example_manifest(cls, plugin_name: str) -> PluginManifest:
|
|
"""Create an example plugin manifest for development"""
|
|
return PluginManifest(
|
|
metadata=PluginMetadata(
|
|
name=plugin_name,
|
|
version="1.0.0",
|
|
description=f"Example {plugin_name} plugin for Enclava platform",
|
|
author="Enclava Team",
|
|
license="MIT",
|
|
tags=["integration", "example"],
|
|
),
|
|
spec=PluginSpec(
|
|
runtime=PluginRuntimeSpec(
|
|
python_version="3.11",
|
|
dependencies=["aiohttp>=3.8.0", "pydantic>=2.0.0"],
|
|
),
|
|
permissions=PluginPermissions(
|
|
platform_apis=["chatbot:invoke", "rag:query"],
|
|
plugin_scopes=["read", "write"],
|
|
),
|
|
database=PluginDatabaseSpec(
|
|
schema=f"plugin_{plugin_name}", migrations_path="./migrations"
|
|
),
|
|
api_endpoints=[
|
|
PluginAPIEndpoint(
|
|
path="/status",
|
|
methods=["GET"],
|
|
description="Plugin health status",
|
|
)
|
|
],
|
|
ui_config=PluginUIConfig(
|
|
configuration_schema="./config_schema.json",
|
|
pages=[
|
|
{
|
|
"name": "dashboard",
|
|
"path": f"/plugins/{plugin_name}",
|
|
"component": f"{plugin_name.title()}Dashboard",
|
|
}
|
|
],
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def validate_manifest_file(manifest_path: Union[str, Path]) -> Dict[str, Any]:
|
|
"""Validate a plugin manifest file and return validation results"""
|
|
try:
|
|
manifest = PluginManifestValidator.load_from_file(manifest_path)
|
|
compatibility = PluginManifestValidator.validate_plugin_compatibility(manifest)
|
|
manifest_hash = PluginManifestValidator.generate_manifest_hash(manifest)
|
|
|
|
return {
|
|
"valid": True,
|
|
"manifest": manifest,
|
|
"compatibility": compatibility,
|
|
"hash": manifest_hash,
|
|
"errors": [],
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"valid": False,
|
|
"manifest": None,
|
|
"compatibility": None,
|
|
"hash": None,
|
|
"errors": [str(e)],
|
|
}
|