Skip to content

Commit f9e12a0

Browse files
authored
Merge pull request #42 from BrainDriveAI/feature/plugin-services-runtime
Feature/plugin services runtime
2 parents 2aff721 + c876e1b commit f9e12a0

19 files changed

+1465
-6
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ backend/alembic/versions/
4747
*.log
4848
backend/logs/
4949

50+
backend/services_runtime/
51+
5052
# Environment variables
5153
.env
5254
.env.local

backend/app/dto/__init__.py

Whitespace-only changes.

backend/app/dto/plugin.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from pydantic import BaseModel
2+
from typing import List, Optional
3+
from datetime import datetime
4+
5+
# This schema is used for returning data from the repository.
6+
# It ensures that JSON fields are correctly converted to Python types.
7+
class PluginServiceRuntimeDTO(BaseModel):
8+
"""
9+
A Pydantic model to represent a PluginServiceRuntime object,
10+
with required_env_vars as a list of strings.
11+
"""
12+
id: str
13+
plugin_id: str
14+
plugin_slug: str
15+
name: str
16+
source_url: Optional[str] = None
17+
type: Optional[str] = None
18+
install_command: Optional[str] = None
19+
start_command: Optional[str] = None
20+
healthcheck_url: Optional[str] = None
21+
required_env_vars: List[str] = []
22+
status: Optional[str] = None
23+
user_id: str
24+
created_at: Optional[datetime] = None
25+
updated_at: Optional[datetime] = None

backend/app/main.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from app.api.v1.api import api_router
66
from app.core.config import settings
77
from app.routers.plugins import plugin_manager
8+
from backend.app.plugins.service_installler.start_stop_plugin_services import start_plugin_services, stop_plugin_services
89
import logging
910
import time
1011
import structlog
@@ -29,8 +30,21 @@ async def startup_event():
2930
logger.info("Initializing application settings...")
3031
from app.init_settings import init_ollama_settings
3132
await init_ollama_settings()
33+
# Start plugin services
34+
await start_plugin_services()
3235
logger.info("Settings initialization completed")
3336

37+
38+
# Add shutdown event to gracefully stop services
39+
@app.on_event("shutdown")
40+
async def shutdown_event():
41+
"""Gracefully stop all plugin services on application shutdown."""
42+
logger.info("Shutting down application and stopping plugin services...")
43+
# Stop all plugin services gracefully
44+
await stop_plugin_services()
45+
logger.info("Application shutdown completed.")
46+
47+
3448
# Add middleware to log all requests
3549
logger = structlog.get_logger()
3650

@@ -94,5 +108,3 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
94108

95109
# Include API routers
96110
app.include_router(api_router)
97-
98-

backend/app/models/plugin.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
from sqlalchemy import Column, String, Integer, Boolean, ForeignKey, Text, JSON, UniqueConstraint, TIMESTAMP
1+
from sqlalchemy import Column, String, Integer, Boolean, ForeignKey, Text, JSON, UniqueConstraint, TIMESTAMP, DateTime
22
import sqlalchemy
33
from sqlalchemy.orm import relationship
44
from sqlalchemy.sql import func
5+
from datetime import datetime, UTC
6+
import json
57

68
from app.models.base import Base
79

10+
811
class Plugin(Base):
912
"""SQLAlchemy model for plugins."""
1013

@@ -44,6 +47,7 @@ class Plugin(Base):
4447
config_fields = Column(Text) # Stored as JSON string
4548
messages = Column(Text) # Stored as JSON string
4649
dependencies = Column(Text) # Stored as JSON string
50+
required_services_runtime = Column(Text, nullable=True)
4751

4852
# Timestamps
4953
created_at = Column(String, default=func.now())
@@ -60,6 +64,7 @@ class Plugin(Base):
6064

6165
# Relationships
6266
modules = relationship("Module", back_populates="plugin", cascade="all, delete-orphan")
67+
service_runtimes = relationship("PluginServiceRuntime", back_populates="plugin", cascade="all, delete-orphan")
6368

6469
def to_dict(self):
6570
"""Convert model to dictionary."""
@@ -118,6 +123,11 @@ def to_dict(self):
118123
else:
119124
result["permissions"] = []
120125

126+
if self.required_services_runtime:
127+
result["requiredServicesRuntime"] = json.loads(self.required_services_runtime)
128+
else:
129+
result["requiredServicesRuntime"] = []
130+
121131
return result
122132

123133
@classmethod
@@ -162,10 +172,81 @@ def from_dict(cls, data):
162172
# Remove modules from data as they are handled separately
163173
if "modules" in db_data:
164174
db_data.pop("modules")
175+
176+
# Handle service runtimes (only store names in plugin table)
177+
if "requiredServicesRuntime" in db_data and db_data["requiredServicesRuntime"] is not None:
178+
db_data["required_services_runtime"] = json.dumps([
179+
r["name"] for r in db_data["requiredServicesRuntime"]
180+
])
181+
db_data.pop("requiredServicesRuntime")
165182

166183
return cls(**db_data)
167184

168185

186+
class PluginServiceRuntime(Base):
187+
"""SQLAlchemy model for plugin service runtimes."""
188+
189+
__tablename__ = "plugin_service_runtime"
190+
191+
id = Column(String, primary_key=True, index=True)
192+
plugin_id = Column(String, ForeignKey("plugin.id"), nullable=False, index=True)
193+
plugin_slug = Column(String, nullable=False, index=True)
194+
195+
name = Column(String, nullable=False)
196+
source_url = Column(String)
197+
type = Column(String)
198+
install_command = Column(Text)
199+
start_command = Column(Text)
200+
healthcheck_url = Column(String)
201+
required_env_vars = Column(Text) # store as JSON string
202+
status = Column(String, default="pending")
203+
204+
created_at = Column(DateTime, default=datetime.now(UTC))
205+
updated_at = Column(DateTime, default=datetime.now(UTC), onupdate=datetime.now(UTC))
206+
207+
user_id = Column(String(32), ForeignKey("users.id", name="fk_plugin_service_runtime_user_id"), nullable=False)
208+
user = relationship("User")
209+
210+
# Relationship back to plugin
211+
plugin = relationship("Plugin", back_populates="service_runtimes")
212+
213+
def to_dict(self):
214+
"""
215+
Convert the model instance to a dictionary, handling JSON fields.
216+
"""
217+
return {
218+
"id": self.id,
219+
"plugin_id": self.plugin_id,
220+
"plugin_slug": self.plugin_slug,
221+
"name": self.name,
222+
"source_url": self.source_url,
223+
"type": self.type,
224+
"install_command": self.install_command,
225+
"start_command": self.start_command,
226+
"healthcheck_url": self.healthcheck_url,
227+
"required_env_vars": json.loads(self.required_env_vars) if self.required_env_vars else [],
228+
"status": self.status,
229+
"user_id": self.user_id,
230+
"created_at": self.created_at.isoformat() if self.created_at else None,
231+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
232+
}
233+
234+
@classmethod
235+
def from_dict(cls, data: dict):
236+
"""
237+
Create a new instance from a dictionary, serializing JSON fields.
238+
"""
239+
db_data = data.copy()
240+
241+
if "required_env_vars" in db_data and db_data["required_env_vars"] is not None:
242+
db_data["required_env_vars"] = json.dumps(db_data["required_env_vars"])
243+
244+
# Handle conversion from camelCase to snake_case if necessary
245+
# For simplicity, we are assuming keys in the incoming dict match model attributes
246+
247+
return cls(**db_data)
248+
249+
169250
class Module(Base):
170251
"""SQLAlchemy model for plugin modules."""
171252

backend/app/plugins/remote_installer.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from typing import Dict, Any, Optional, List, Tuple
2121
from urllib.parse import urlparse
2222
import structlog
23+
from .service_installler.plugin_service_manager import install_and_start_docker_service
24+
from .service_installler.service_runtime_extractor import extract_required_services_runtime
2325

2426
logger = structlog.get_logger()
2527

@@ -237,6 +239,15 @@ async def install_from_url(self, repo_url: str, user_id: str, version: str = "la
237239
if install_result['success']:
238240
logger.info(f"Plugin installation successful: {install_result}")
239241

242+
service_runtime: list = validation_result.get("service_runtime", [])
243+
logger.info(f"\n\n>>>>>>>>SERVICE RUNTIME\n\n: {service_runtime}\n\n>>>>>>>>>>")
244+
if service_runtime:
245+
plugin_slug = validation_result["plugin_info"].get("plugin_slug")
246+
await install_and_start_docker_service(
247+
service_runtime,
248+
plugin_slug
249+
)
250+
240251
# Store installation metadata
241252
try:
242253
await self._store_installation_metadata(
@@ -718,6 +729,7 @@ async def _validate_plugin_structure(self, plugin_dir: Path) -> Dict[str, Any]:
718729

719730
# Try to load plugin metadata
720731
plugin_info = {}
732+
service_runtime = []
721733

722734
# Check package.json
723735
package_json_path = plugin_dir / 'package.json'
@@ -828,6 +840,12 @@ async def _validate_plugin_structure(self, plugin_dir: Path) -> Dict[str, Any]:
828840
extracted_slug = slug_match.group(1)
829841
plugin_info['plugin_slug'] = extracted_slug
830842
logger.info(f"Extracted plugin_slug from source: {extracted_slug}")
843+
844+
# Extract services using the dedicated function
845+
services = extract_required_services_runtime(content, plugin_info.get('plugin_slug'))
846+
if services:
847+
plugin_info['required_services_runtime'] = services
848+
service_runtime.extend(services)
831849
except Exception as extract_error:
832850
logger.warning(f"Could not extract plugin_slug from source: {extract_error}")
833851

@@ -839,7 +857,8 @@ async def _validate_plugin_structure(self, plugin_dir: Path) -> Dict[str, Any]:
839857

840858
return {
841859
'valid': True,
842-
'plugin_info': plugin_info
860+
'plugin_info': plugin_info,
861+
'service_runtime': service_runtime
843862
}
844863

845864
except Exception as e:

backend/app/plugins/repository.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
from sqlalchemy.ext.asyncio import AsyncSession
77
import structlog
88

9-
from app.models.plugin import Plugin, Module
9+
from app.models.plugin import Plugin, Module, PluginServiceRuntime
10+
from app.dto.plugin import PluginServiceRuntimeDTO
1011

1112
logger = structlog.get_logger()
1213

@@ -85,7 +86,7 @@ async def get_all_plugins_with_modules(self, user_id: str = None) -> List[Dict[s
8586
plugin_dicts = []
8687
for plugin in plugins:
8788
plugin_dict = plugin.to_dict()
88-
89+
8990
# Get modules for this plugin
9091
modules_query = select(Module).where(Module.plugin_id == plugin.id)
9192

@@ -104,6 +105,25 @@ async def get_all_plugins_with_modules(self, user_id: str = None) -> List[Dict[s
104105
except Exception as e:
105106
logger.error("Error getting plugins with modules", error=str(e))
106107
raise
108+
109+
async def get_all_service_runtimes(self) -> List[PluginServiceRuntimeDTO]:
110+
"""
111+
Get all plugin service runtimes for startup and return them as DTOs.
112+
"""
113+
try:
114+
query = select(PluginServiceRuntime).where(
115+
PluginServiceRuntime.status.in_(["pending", "stopped", "running"])
116+
)
117+
118+
result = await self.db.execute(query)
119+
services = result.scalars().all()
120+
121+
# Convert SQLAlchemy models to Pydantic DTOs for a typed return
122+
return [PluginServiceRuntimeDTO(**service.to_dict()) for service in services]
123+
124+
except Exception as e:
125+
logger.error("Error getting service runtimes", error=str(e))
126+
raise
107127

108128
async def get_plugin(self, plugin_id: str) -> Optional[Dict[str, Any]]:
109129
"""Get a specific plugin by ID."""

0 commit comments

Comments
 (0)