From 5322b17a780e96220521efdf4213a66bca23489b Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Mon, 15 Sep 2025 09:34:46 +0200 Subject: [PATCH] orphaned plugin cleanup --- backend/app/services/plugin_registry.py | 13 +- backend/scripts/cleanup_orphaned_plugin.py | 162 +++++++++++++++++++++ 2 files changed, 172 insertions(+), 3 deletions(-) create mode 100755 backend/scripts/cleanup_orphaned_plugin.py diff --git a/backend/app/services/plugin_registry.py b/backend/app/services/plugin_registry.py index fc550c3..e95aeb6 100644 --- a/backend/app/services/plugin_registry.py +++ b/backend/app/services/plugin_registry.py @@ -398,19 +398,26 @@ class PluginInstaller: if plugin_id in plugin_loader.loaded_plugins: await plugin_loader.unload_plugin(plugin_id) - # Backup data if requested + # Backup data if requested (handle missing files gracefully) backup_path = None if keep_data: - backup_path = await plugin_db_manager.backup_plugin_data(plugin_id) + try: + backup_path = await plugin_db_manager.backup_plugin_data(plugin_id) + except Exception as e: + logger.warning(f"Could not backup plugin data (files may be missing): {e}") + # Continue with uninstall even if backup fails # Delete database schema if not keeping data if not keep_data: await plugin_db_manager.delete_plugin_schema(plugin_id) - # Remove plugin files + # Remove plugin files (handle missing directories gracefully) plugin_dir = self.plugins_dir / plugin_id if plugin_dir.exists(): shutil.rmtree(plugin_dir) + logger.info(f"Removed plugin directory: {plugin_dir}") + else: + logger.warning(f"Plugin directory not found (already removed?): {plugin_dir}") # Update database plugin.status = "uninstalled" diff --git a/backend/scripts/cleanup_orphaned_plugin.py b/backend/scripts/cleanup_orphaned_plugin.py new file mode 100755 index 0000000..df74580 --- /dev/null +++ b/backend/scripts/cleanup_orphaned_plugin.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +Script to clean up orphaned plugin registrations from the database +when plugin files have been manually removed from the filesystem. + +Usage: + python cleanup_orphaned_plugin.py [plugin_name_or_id] + + If no plugin name/id is provided, it will list all orphaned plugins + and prompt for confirmation to clean them up. +""" + +import sys +import os +import asyncio +from pathlib import Path +from uuid import UUID + +# Add backend directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from app.db.database import async_session_factory, engine +from app.models.plugin import Plugin +from app.core.config import settings +from app.core.logging import get_logger + +logger = get_logger("plugin.cleanup") + + +async def find_orphaned_plugins(session: AsyncSession): + """Find plugins registered in database but missing from filesystem""" + plugins_dir = Path(settings.PLUGINS_DIR or "/plugins") + + # Get all plugins from database + stmt = select(Plugin) + result = await session.execute(stmt) + all_plugins = result.scalars().all() + + orphaned = [] + for plugin in all_plugins: + # Check if plugin directory exists + plugin_path = plugins_dir / str(plugin.id) + if not plugin_path.exists(): + orphaned.append(plugin) + logger.info(f"Found orphaned plugin: {plugin.name} (ID: {plugin.id})") + + return orphaned + + +async def cleanup_plugin(session: AsyncSession, plugin: Plugin, keep_data: bool = True): + """Clean up a single orphaned plugin registration""" + try: + logger.info(f"Cleaning up plugin: {plugin.name} (ID: {plugin.id})") + + # Delete plugin configurations if they exist + try: + from app.models.plugin_configuration import PluginConfiguration + config_stmt = delete(PluginConfiguration).where( + PluginConfiguration.plugin_id == plugin.id + ) + await session.execute(config_stmt) + logger.info(f"Deleted configurations for plugin {plugin.id}") + except ImportError: + pass # Plugin configuration model might not exist + + # Delete the plugin record + await session.delete(plugin) + await session.commit() + + logger.info(f"Successfully cleaned up plugin: {plugin.name}") + return True + + except Exception as e: + logger.error(f"Failed to cleanup plugin {plugin.name}: {e}") + await session.rollback() + return False + + +async def main(): + """Main cleanup function""" + target_plugin = sys.argv[1] if len(sys.argv) > 1 else None + + async with async_session_factory() as session: + if target_plugin: + # Clean up specific plugin + try: + # Try to parse as UUID first + plugin_id = UUID(target_plugin) + stmt = select(Plugin).where(Plugin.id == plugin_id) + except ValueError: + # Not a UUID, search by name + stmt = select(Plugin).where(Plugin.name == target_plugin) + + result = await session.execute(stmt) + plugin = result.scalar_one_or_none() + + if not plugin: + print(f"Plugin '{target_plugin}' not found in database") + return + + # Check if plugin directory exists + plugins_dir = Path(settings.PLUGINS_DIR or "/plugins") + plugin_path = plugins_dir / str(plugin.id) + + if plugin_path.exists(): + print(f"Plugin directory exists at {plugin_path}") + response = input("Plugin files exist. Are you sure you want to cleanup the database entry? (y/N): ") + if response.lower() != 'y': + print("Cleanup cancelled") + return + + print(f"\nFound plugin:") + print(f" Name: {plugin.name}") + print(f" ID: {plugin.id}") + print(f" Version: {plugin.version}") + print(f" Status: {plugin.status}") + print(f" Directory: {plugin_path} (exists: {plugin_path.exists()})") + + response = input("\nProceed with cleanup? (y/N): ") + if response.lower() == 'y': + success = await cleanup_plugin(session, plugin) + if success: + print("āœ“ Plugin cleaned up successfully") + else: + print("āœ— Failed to cleanup plugin") + else: + print("Cleanup cancelled") + + else: + # List all orphaned plugins + orphaned = await find_orphaned_plugins(session) + + if not orphaned: + print("No orphaned plugins found") + return + + print(f"\nFound {len(orphaned)} orphaned plugin(s):") + for plugin in orphaned: + plugins_dir = Path(settings.PLUGINS_DIR or "/plugins") + plugin_path = plugins_dir / str(plugin.id) + print(f"\n • {plugin.name}") + print(f" ID: {plugin.id}") + print(f" Version: {plugin.version}") + print(f" Status: {plugin.status}") + print(f" Expected path: {plugin_path}") + + response = input(f"\nCleanup all {len(orphaned)} orphaned plugin(s)? (y/N): ") + if response.lower() == 'y': + success_count = 0 + for plugin in orphaned: + if await cleanup_plugin(session, plugin): + success_count += 1 + + print(f"\nāœ“ Cleaned up {success_count}/{len(orphaned)} plugin(s)") + else: + print("Cleanup cancelled") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file