mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +09:00
agent_plugin_service and server_template_service dispatched agent commands through get_agent_gateway() — a function that does not exist — inside a bare try/except. Every call raised ImportError, was swallowed, and left DB rows stuck in 'installing'/'checking'/'remediating' forever while the UI implied the feature worked. The agent also has no handlers for these commands. - Plugin install now reports STATUS_ERROR with "Agent-side plugin support is not implemented yet" instead of hanging in 'installing'. - Template drift-check/remediate report 'unknown' + an explanatory drift_report.error. - Local-only ops (disable, uninstall, configure) just drop the dead dispatch. - test_agent_features_honest.py: 3 tests asserting honest states. Implementing the feature for real (agent handlers + sandbox) remains future work; this stops it from lying in the meantime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
267 lines
9.4 KiB
Python
267 lines
9.4 KiB
Python
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from app import db
|
|
from app.models.agent_plugin import AgentPlugin, AgentPluginInstall
|
|
from app.models.server import Server
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AgentPluginService:
|
|
"""Service for managing agent plugins."""
|
|
|
|
# Plugin specification interface
|
|
PLUGIN_SPEC = {
|
|
'required_fields': ['name', 'display_name', 'version'],
|
|
'capability_types': ['metrics', 'health_checks', 'commands', 'scheduled_tasks', 'event_hooks'],
|
|
'permission_types': ['filesystem', 'network', 'docker', 'process', 'system'],
|
|
}
|
|
|
|
@staticmethod
|
|
def list_plugins(status=None):
|
|
query = AgentPlugin.query
|
|
if status:
|
|
query = query.filter_by(status=status)
|
|
return query.order_by(AgentPlugin.display_name).all()
|
|
|
|
@staticmethod
|
|
def get_plugin(plugin_id):
|
|
return AgentPlugin.query.get(plugin_id)
|
|
|
|
@staticmethod
|
|
def get_plugin_by_name(name):
|
|
return AgentPlugin.query.filter_by(name=name).first()
|
|
|
|
@staticmethod
|
|
def create_plugin(data):
|
|
"""Register a new plugin from manifest data."""
|
|
if AgentPlugin.query.filter_by(name=data['name']).first():
|
|
raise ValueError(f"Plugin '{data['name']}' already exists")
|
|
|
|
plugin = AgentPlugin(
|
|
name=data['name'],
|
|
display_name=data.get('display_name', data['name']),
|
|
version=data['version'],
|
|
description=data.get('description', ''),
|
|
author=data.get('author', ''),
|
|
homepage=data.get('homepage', ''),
|
|
max_memory_mb=data.get('max_memory_mb', 128),
|
|
max_cpu_percent=data.get('max_cpu_percent', 10),
|
|
)
|
|
plugin.manifest = data.get('manifest', data)
|
|
plugin.capabilities = data.get('capabilities', [])
|
|
plugin.dependencies = data.get('dependencies', [])
|
|
plugin.permissions = data.get('permissions', [])
|
|
|
|
db.session.add(plugin)
|
|
db.session.commit()
|
|
return plugin
|
|
|
|
@staticmethod
|
|
def update_plugin(plugin_id, data):
|
|
plugin = AgentPlugin.query.get(plugin_id)
|
|
if not plugin:
|
|
return None
|
|
|
|
for field in ['display_name', 'version', 'description', 'author', 'homepage',
|
|
'max_memory_mb', 'max_cpu_percent', 'status']:
|
|
if field in data:
|
|
setattr(plugin, field, data[field])
|
|
|
|
if 'capabilities' in data:
|
|
plugin.capabilities = data['capabilities']
|
|
if 'dependencies' in data:
|
|
plugin.dependencies = data['dependencies']
|
|
if 'permissions' in data:
|
|
plugin.permissions = data['permissions']
|
|
if 'manifest' in data:
|
|
plugin.manifest = data['manifest']
|
|
|
|
db.session.commit()
|
|
return plugin
|
|
|
|
@staticmethod
|
|
def delete_plugin(plugin_id):
|
|
plugin = AgentPlugin.query.get(plugin_id)
|
|
if not plugin:
|
|
return False
|
|
|
|
# Check for active installations
|
|
active = plugin.installations.filter(
|
|
AgentPluginInstall.status.in_([
|
|
AgentPluginInstall.STATUS_ENABLED,
|
|
AgentPluginInstall.STATUS_INSTALLING
|
|
])
|
|
).count()
|
|
if active > 0:
|
|
raise ValueError(f'Cannot delete plugin with {active} active installations')
|
|
|
|
# Remove all installation records
|
|
AgentPluginInstall.query.filter_by(plugin_id=plugin_id).delete()
|
|
db.session.delete(plugin)
|
|
db.session.commit()
|
|
return True
|
|
|
|
# --- Installation Management ---
|
|
|
|
@staticmethod
|
|
def install_plugin(plugin_id, server_id, config=None):
|
|
"""Install a plugin on a server."""
|
|
plugin = AgentPlugin.query.get(plugin_id)
|
|
if not plugin:
|
|
raise ValueError('Plugin not found')
|
|
|
|
server = Server.query.get(server_id)
|
|
if not server:
|
|
raise ValueError('Server not found')
|
|
|
|
# Check if already installed
|
|
existing = AgentPluginInstall.query.filter_by(
|
|
plugin_id=plugin_id, server_id=server_id
|
|
).first()
|
|
if existing and existing.status in ['enabled', 'installing']:
|
|
raise ValueError('Plugin already installed on this server')
|
|
|
|
# Check dependencies
|
|
for dep_name in plugin.dependencies:
|
|
dep_plugin = AgentPlugin.query.filter_by(name=dep_name).first()
|
|
if not dep_plugin:
|
|
raise ValueError(f"Required dependency '{dep_name}' not available")
|
|
dep_install = AgentPluginInstall.query.filter_by(
|
|
plugin_id=dep_plugin.id, server_id=server_id, status='enabled'
|
|
).first()
|
|
if not dep_install:
|
|
raise ValueError(f"Dependency '{dep_name}' not installed on server")
|
|
|
|
if existing:
|
|
existing.status = AgentPluginInstall.STATUS_INSTALLING
|
|
existing.installed_version = plugin.version
|
|
existing.error_message = None
|
|
if config:
|
|
existing.config = config
|
|
install = existing
|
|
else:
|
|
install = AgentPluginInstall(
|
|
plugin_id=plugin_id,
|
|
server_id=server_id,
|
|
installed_version=plugin.version,
|
|
status=AgentPluginInstall.STATUS_INSTALLING,
|
|
)
|
|
if config:
|
|
install.config = config
|
|
db.session.add(install)
|
|
|
|
# Agent-side plugin execution is NOT implemented: the Go agent has no
|
|
# plugin_install handler and there is no panel gateway to route one to.
|
|
# The previous code called a non-existent get_agent_gateway() and
|
|
# swallowed the resulting ImportError, leaving the row stuck in
|
|
# 'installing' forever. Fail honestly so the UI reflects reality.
|
|
install.status = AgentPluginInstall.STATUS_ERROR
|
|
install.error_message = 'Agent-side plugin support is not implemented yet'
|
|
db.session.commit()
|
|
|
|
return install
|
|
|
|
@staticmethod
|
|
def uninstall_plugin(install_id):
|
|
install = AgentPluginInstall.query.get(install_id)
|
|
if not install:
|
|
return False
|
|
|
|
# No agent-side plugin runtime exists to notify; uninstall is local-only
|
|
# bookkeeping. (Previously dispatched to a non-existent get_agent_gateway.)
|
|
install.status = AgentPluginInstall.STATUS_UNINSTALLING
|
|
db.session.commit()
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def enable_plugin(install_id):
|
|
install = AgentPluginInstall.query.get(install_id)
|
|
if not install:
|
|
return None
|
|
install.status = AgentPluginInstall.STATUS_ENABLED
|
|
db.session.commit()
|
|
return install
|
|
|
|
@staticmethod
|
|
def disable_plugin(install_id):
|
|
install = AgentPluginInstall.query.get(install_id)
|
|
if not install:
|
|
return None
|
|
# Local-only state change; no agent-side plugin runtime to notify.
|
|
install.status = AgentPluginInstall.STATUS_DISABLED
|
|
db.session.commit()
|
|
|
|
return install
|
|
|
|
@staticmethod
|
|
def update_install_status(install_id, status, error=None, health=None, metrics=None):
|
|
install = AgentPluginInstall.query.get(install_id)
|
|
if not install:
|
|
return None
|
|
install.status = status
|
|
if error is not None:
|
|
install.error_message = error
|
|
if health is not None:
|
|
install.health_status = health
|
|
install.last_health_check = datetime.utcnow()
|
|
if metrics is not None:
|
|
install.metrics_json = json.dumps(metrics)
|
|
db.session.commit()
|
|
return install
|
|
|
|
@staticmethod
|
|
def update_install_config(install_id, config):
|
|
install = AgentPluginInstall.query.get(install_id)
|
|
if not install:
|
|
return None
|
|
# Local-only config persistence; no agent-side plugin runtime to notify.
|
|
install.config = config
|
|
db.session.commit()
|
|
|
|
return install
|
|
|
|
@staticmethod
|
|
def get_server_plugins(server_id):
|
|
return AgentPluginInstall.query.filter_by(server_id=server_id).all()
|
|
|
|
@staticmethod
|
|
def get_plugin_installations(plugin_id):
|
|
return AgentPluginInstall.query.filter_by(plugin_id=plugin_id).all()
|
|
|
|
@staticmethod
|
|
def get_install(install_id):
|
|
return AgentPluginInstall.query.get(install_id)
|
|
|
|
@staticmethod
|
|
def bulk_install(plugin_id, server_ids, config=None):
|
|
"""Install plugin on multiple servers."""
|
|
results = []
|
|
for sid in server_ids:
|
|
try:
|
|
install = AgentPluginService.install_plugin(plugin_id, sid, config)
|
|
results.append({'server_id': sid, 'status': 'installing', 'install_id': install.id})
|
|
except ValueError as e:
|
|
results.append({'server_id': sid, 'status': 'error', 'error': str(e)})
|
|
return results
|
|
|
|
@staticmethod
|
|
def validate_manifest(manifest):
|
|
"""Validate a plugin manifest against the spec."""
|
|
errors = []
|
|
for field in AgentPluginService.PLUGIN_SPEC['required_fields']:
|
|
if field not in manifest:
|
|
errors.append(f"Missing required field: {field}")
|
|
|
|
for cap in manifest.get('capabilities', []):
|
|
if cap not in AgentPluginService.PLUGIN_SPEC['capability_types']:
|
|
errors.append(f"Unknown capability: {cap}")
|
|
|
|
for perm in manifest.get('permissions', []):
|
|
if perm not in AgentPluginService.PLUGIN_SPEC['permission_types']:
|
|
errors.append(f"Unknown permission: {perm}")
|
|
|
|
return errors
|