Skip to content

Commit ea5ef8b

Browse files
authored
Merge pull request #20 from BrainDriveAI/feature/local-install
Feature/local install
2 parents 41d4705 + 098d0ef commit ea5ef8b

File tree

11 files changed

+1953
-120
lines changed

11 files changed

+1953
-120
lines changed

backend/app/plugins/lifecycle_api.py

Lines changed: 201 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
Now includes remote plugin installation from GitHub repositories.
1010
"""
1111

12-
from fastapi import APIRouter, HTTPException, Depends, status
12+
from fastapi import APIRouter, HTTPException, Depends, status, File, UploadFile, Form
1313
from sqlalchemy.ext.asyncio import AsyncSession
14-
from typing import Dict, Any, Optional
14+
from typing import Dict, Any, Optional, Union
1515
from pathlib import Path
1616
import importlib.util
1717
import json
1818
import structlog
19+
import tempfile
20+
import shutil
1921
from pydantic import BaseModel
2022

2123
# Import the remote installer
@@ -45,11 +47,19 @@ def _get_error_suggestions(step: str, error_message: str) -> list:
4547
"Verify the release contains downloadable assets",
4648
"Ensure the release archive format is supported (tar.gz, zip)"
4749
])
50+
elif step == 'file_extraction':
51+
suggestions.extend([
52+
"Ensure the uploaded file is a valid archive (ZIP, TAR.GZ)",
53+
"Check that the file is not corrupted",
54+
"Verify the file size is within limits (100MB max)",
55+
"Try re-uploading the file if extraction fails"
56+
])
4857
elif step == 'plugin_validation':
4958
suggestions.extend([
5059
"Ensure the plugin contains a 'lifecycle_manager.py' file",
5160
"Check that the lifecycle manager extends BaseLifecycleManager",
52-
"Verify the plugin structure follows BrainDrive plugin standards"
61+
"Verify the plugin structure follows BrainDrive plugin standards",
62+
"Make sure the archive contains a valid BrainDrive plugin"
5363
])
5464
elif step == 'lifecycle_manager_install':
5565
suggestions.extend([
@@ -66,8 +76,8 @@ def _get_error_suggestions(step: str, error_message: str) -> list:
6676
else:
6777
suggestions.extend([
6878
"Check the server logs for more detailed error information",
69-
"Ensure the plugin repository follows BrainDrive plugin standards",
70-
"Try installing a different version of the plugin"
79+
"Ensure the plugin follows BrainDrive plugin standards",
80+
"Try installing a different version or format of the plugin"
7181
])
7282

7383
return suggestions
@@ -77,6 +87,12 @@ class RemoteInstallRequest(BaseModel):
7787
repo_url: str
7888
version: str = "latest"
7989

90+
class UnifiedInstallRequest(BaseModel):
91+
method: str # 'github' or 'local-file'
92+
repo_url: Optional[str] = None
93+
version: Optional[str] = "latest"
94+
filename: Optional[str] = None
95+
8096
class UpdateCheckResponse(BaseModel):
8197
plugin_id: str
8298
current_version: str
@@ -733,6 +749,186 @@ async def get_plugin_info(plugin_slug: str):
733749
)
734750

735751
# Remote plugin installation endpoints
752+
@router.post("/install")
753+
async def install_plugin_unified(
754+
method: str = Form(...),
755+
repo_url: Optional[str] = Form(None),
756+
version: Optional[str] = Form("latest"),
757+
filename: Optional[str] = Form(None),
758+
file: Optional[UploadFile] = File(None),
759+
current_user = Depends(get_current_user),
760+
db: AsyncSession = Depends(get_db)
761+
):
762+
"""
763+
Unified plugin installation endpoint supporting both GitHub and local file methods.
764+
765+
For GitHub installation:
766+
- method: 'github'
767+
- repo_url: GitHub repository URL
768+
- version: Version to install (optional, defaults to 'latest')
769+
770+
For local file installation:
771+
- method: 'local-file'
772+
- file: Archive file (ZIP, RAR, TAR.GZ)
773+
- filename: Original filename
774+
"""
775+
try:
776+
logger.info(f"Unified plugin installation requested by user {current_user.id}")
777+
logger.info(f"Method: {method}")
778+
779+
if method == 'github':
780+
if not repo_url:
781+
raise HTTPException(
782+
status_code=status.HTTP_400_BAD_REQUEST,
783+
detail="repo_url is required for GitHub installation"
784+
)
785+
786+
logger.info(f"GitHub installation - Repository URL: {repo_url}, Version: {version}")
787+
788+
# Use the remote installer to install the plugin
789+
result = await remote_installer.install_from_url(
790+
repo_url=repo_url,
791+
user_id=current_user.id,
792+
version=version or "latest"
793+
)
794+
795+
if result['success']:
796+
return {
797+
"status": "success",
798+
"message": f"Plugin installed successfully from {repo_url}",
799+
"data": {
800+
"plugin_id": result.get('plugin_id'),
801+
"plugin_slug": result.get('plugin_slug'),
802+
"modules_created": result.get('modules_created', []),
803+
"plugin_directory": result.get('plugin_directory'),
804+
"source": "github",
805+
"repo_url": repo_url,
806+
"version": version or "latest"
807+
}
808+
}
809+
else:
810+
# Enhanced error response with suggestions
811+
error_details = result.get('details', {})
812+
step = error_details.get('step', 'unknown')
813+
error_message = result.get('error', 'Installation failed')
814+
suggestions = _get_error_suggestions(step, error_message)
815+
816+
raise HTTPException(
817+
status_code=status.HTTP_400_BAD_REQUEST,
818+
detail={
819+
"message": error_message,
820+
"details": error_details,
821+
"suggestions": suggestions
822+
}
823+
)
824+
825+
elif method == 'local-file':
826+
if not file:
827+
raise HTTPException(
828+
status_code=status.HTTP_400_BAD_REQUEST,
829+
detail="file is required for local file installation"
830+
)
831+
832+
logger.info(f"Local file installation - Filename: {filename}, Size: {file.size if hasattr(file, 'size') else 'unknown'}")
833+
834+
# Validate file size (100MB limit)
835+
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
836+
if hasattr(file, 'size') and file.size > MAX_FILE_SIZE:
837+
raise HTTPException(
838+
status_code=status.HTTP_400_BAD_REQUEST,
839+
detail=f"File size ({file.size} bytes) exceeds maximum allowed size ({MAX_FILE_SIZE} bytes)"
840+
)
841+
842+
# Validate file format
843+
if filename:
844+
supported_formats = ['.zip', '.rar', '.tar.gz', '.tgz']
845+
file_ext = None
846+
filename_lower = filename.lower()
847+
for ext in supported_formats:
848+
if filename_lower.endswith(ext):
849+
file_ext = ext
850+
break
851+
852+
if not file_ext:
853+
raise HTTPException(
854+
status_code=status.HTTP_400_BAD_REQUEST,
855+
detail=f"Unsupported file format. Supported formats: {', '.join(supported_formats)}"
856+
)
857+
858+
# Save uploaded file to temporary location
859+
import tempfile
860+
import shutil
861+
temp_dir = Path(tempfile.mkdtemp())
862+
temp_file_path = temp_dir / (filename or "uploaded_plugin")
863+
864+
try:
865+
# Write uploaded file to temporary location
866+
with open(temp_file_path, "wb") as buffer:
867+
shutil.copyfileobj(file.file, buffer)
868+
869+
logger.info(f"File saved to temporary location: {temp_file_path}")
870+
871+
# Use the remote installer to install from file
872+
result = await remote_installer.install_from_file(
873+
file_path=temp_file_path,
874+
user_id=current_user.id,
875+
filename=filename
876+
)
877+
878+
if result['success']:
879+
return {
880+
"status": "success",
881+
"message": f"Plugin '{filename}' installed successfully from local file",
882+
"data": {
883+
"plugin_id": result.get('plugin_id'),
884+
"plugin_slug": result.get('plugin_slug'),
885+
"modules_created": result.get('modules_created', []),
886+
"plugin_directory": result.get('plugin_directory'),
887+
"source": "local-file",
888+
"filename": filename,
889+
"file_size": temp_file_path.stat().st_size if temp_file_path.exists() else 0
890+
}
891+
}
892+
else:
893+
# Enhanced error response with suggestions
894+
error_details = result.get('details', {})
895+
step = error_details.get('step', 'unknown')
896+
error_message = result.get('error', 'Installation failed')
897+
suggestions = _get_error_suggestions(step, error_message)
898+
899+
raise HTTPException(
900+
status_code=status.HTTP_400_BAD_REQUEST,
901+
detail={
902+
"message": error_message,
903+
"details": error_details,
904+
"suggestions": suggestions
905+
}
906+
)
907+
908+
finally:
909+
# Clean up temporary file
910+
try:
911+
if temp_file_path.exists():
912+
temp_file_path.unlink()
913+
temp_dir.rmdir()
914+
except Exception as cleanup_error:
915+
logger.warning(f"Failed to clean up temporary file: {cleanup_error}")
916+
917+
else:
918+
raise HTTPException(
919+
status_code=status.HTTP_400_BAD_REQUEST,
920+
detail=f"Unsupported installation method: {method}. Supported methods: 'github', 'local-file'"
921+
)
922+
923+
except HTTPException:
924+
raise
925+
except Exception as e:
926+
logger.error(f"Unexpected error during unified plugin installation: {e}")
927+
raise HTTPException(
928+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
929+
detail=f"Internal server error during plugin installation: {str(e)}"
930+
)
931+
736932
@router.post("/install-from-url")
737933
async def install_plugin_from_repository(
738934
request: RemoteInstallRequest,

0 commit comments

Comments
 (0)