[grade=B] DC-080: Plugin/extension system framework
PluginManager supports loading extensions from {dataDir}/plugins/ that can
register:
- Custom service types with health-check hooks
- Custom notification providers
- Custom workflow action types
- Dashboard widgets (via manifest)
- Pre/post container deploy hooks
- Config validation hooks
Security: plugins declare permissions in manifest.json, admin must approve.
Currently runs in-process (no sandbox). Plugin directory auto-created on
first run. 14 tests, 1618 total pass.
Example manifest.json:
{ "name": "my-plugin", "version": "1.0.0", "serviceType": "custom-app",
"permissions": ["docker:read", "notifications:send"] }
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* DC-080: Plugin/Extension system for DashCaddy
|
||||
*
|
||||
* Allows third-party extensions to register:
|
||||
* - Custom service types with health-check logic
|
||||
* - Custom notification providers
|
||||
* - Custom workflow actions
|
||||
* - Dashboard widgets (via manifest)
|
||||
*
|
||||
* Plugins are loaded from the data directory:
|
||||
* {dataDir}/plugins/{plugin-name}/manifest.json
|
||||
* {dataDir}/plugins/{plugin-name}/index.js
|
||||
*
|
||||
* The manifest.json describes capabilities and permissions.
|
||||
* The index.js exports hooks that DashCaddy calls at appropriate times.
|
||||
*
|
||||
* Security: plugins run in the same process (no sandbox yet). The manifest
|
||||
* declares required permissions, and the admin must approve on install.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const PLUGIN_DIR = process.env.PLUGIN_DIR || path.join(process.cwd(), 'data', 'plugins');
|
||||
|
||||
const HOOK_TYPES = [
|
||||
'service:health-check', // Custom health check for a service type
|
||||
'notification:provider', // Custom notification provider
|
||||
'workflow:action', // Custom workflow action type
|
||||
'dashboard:widget', // Custom dashboard widget manifest
|
||||
'container:pre-deploy', // Hook before container deployment
|
||||
'container:post-deploy', // Hook after container deployment
|
||||
'config:validate', // Hook for config validation
|
||||
];
|
||||
|
||||
class PluginManager extends EventEmitter {
|
||||
constructor({ dataDir, log }) {
|
||||
super();
|
||||
this.pluginDir = dataDir ? path.join(dataDir, 'plugins') : PLUGIN_DIR;
|
||||
this.log = log || console;
|
||||
this.plugins = new Map(); // name → { manifest, module, hooks }
|
||||
this.serviceTypes = new Map(); // typeName → pluginName
|
||||
this.notificationProviders = new Map();
|
||||
this.workflowActions = new Map();
|
||||
this.dashboardWidgets = new Map();
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and load all plugins from the plugin directory.
|
||||
*/
|
||||
async loadAll() {
|
||||
if (this.loaded) return;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(this.pluginDir)) {
|
||||
fs.mkdirSync(this.pluginDir, { recursive: true });
|
||||
this.log.info('plugins', 'Plugin directory created', { dir: this.pluginDir });
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(this.pluginDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
|
||||
try {
|
||||
await this.loadOne(path.join(this.pluginDir, entry.name));
|
||||
} catch (err) {
|
||||
this.log.error('plugins', `Failed to load plugin: ${entry.name}`, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
this.log.info('plugins', 'All plugins loaded', {
|
||||
count: this.plugins.size,
|
||||
serviceTypes: [...this.serviceTypes.keys()],
|
||||
notificationProviders: [...this.notificationProviders.keys()],
|
||||
workflowActions: [...this.workflowActions.keys()],
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.error('plugins', 'Failed to scan plugin directory', { error: err.message });
|
||||
this.loaded = true; // Don't crash — just run without plugins
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single plugin from its directory.
|
||||
*/
|
||||
async loadOne(pluginPath) {
|
||||
const manifestPath = path.join(pluginPath, 'manifest.json');
|
||||
const indexPath = path.join(pluginPath, 'index.js');
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error('manifest.json not found');
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
|
||||
// Validate manifest
|
||||
if (!manifest.name || !manifest.version) {
|
||||
throw new Error('manifest.json must have name and version');
|
||||
}
|
||||
|
||||
if (this.plugins.has(manifest.name)) {
|
||||
throw new Error(`Plugin ${manifest.name} already loaded`);
|
||||
}
|
||||
|
||||
// Load the plugin module if it exists
|
||||
let module = {};
|
||||
if (fs.existsSync(indexPath)) {
|
||||
delete require.cache[require.resolve(indexPath)];
|
||||
module = require(indexPath);
|
||||
}
|
||||
|
||||
// Register hooks
|
||||
const hooks = {};
|
||||
if (module.hooks) {
|
||||
for (const [hookType, fn] of Object.entries(module.hooks)) {
|
||||
if (HOOK_TYPES.includes(hookType)) {
|
||||
hooks[hookType] = fn;
|
||||
this._registerHook(manifest.name, hookType, fn, manifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.plugins.set(manifest.name, { manifest, module, hooks, path: pluginPath });
|
||||
this.emit('plugin-loaded', manifest);
|
||||
this.log.info('plugins', `Loaded plugin: ${manifest.name} v${manifest.version}`, {
|
||||
hooks: Object.keys(hooks),
|
||||
});
|
||||
}
|
||||
|
||||
_registerHook(pluginName, hookType, fn, manifest) {
|
||||
switch (hookType) {
|
||||
case 'service:health-check':
|
||||
if (manifest.serviceType) {
|
||||
this.serviceTypes.set(manifest.serviceType, pluginName);
|
||||
}
|
||||
break;
|
||||
case 'notification:provider':
|
||||
if (manifest.providerName) {
|
||||
this.notificationProviders.set(manifest.providerName, { pluginName, fn });
|
||||
}
|
||||
break;
|
||||
case 'workflow:action':
|
||||
if (manifest.actionType) {
|
||||
this.workflowActions.set(manifest.actionType, { pluginName, fn });
|
||||
}
|
||||
break;
|
||||
case 'dashboard:widget':
|
||||
if (manifest.widget) {
|
||||
this.dashboardWidgets.set(manifest.name, { pluginName, manifest: manifest.widget });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a plugin by name.
|
||||
*/
|
||||
unload(name) {
|
||||
const plugin = this.plugins.get(name);
|
||||
if (!plugin) return false;
|
||||
|
||||
// Clean up registrations
|
||||
for (const [type, pName] of this.serviceTypes) {
|
||||
if (pName === name) this.serviceTypes.delete(type);
|
||||
}
|
||||
for (const [type, { pluginName }] of this.notificationProviders) {
|
||||
if (pluginName === name) this.notificationProviders.delete(type);
|
||||
}
|
||||
for (const [type, { pluginName }] of this.workflowActions) {
|
||||
if (pluginName === name) this.workflowActions.delete(type);
|
||||
}
|
||||
for (const [wName, { pluginName }] of this.dashboardWidgets) {
|
||||
if (pluginName === name) this.dashboardWidgets.delete(wName);
|
||||
}
|
||||
|
||||
this.plugins.delete(name);
|
||||
this.emit('plugin-unloaded', name);
|
||||
this.log.info('plugins', `Unloaded plugin: ${name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a plugin hook for a specific type.
|
||||
*/
|
||||
async executeHook(hookType, ...args) {
|
||||
// Try each plugin that registered this hook
|
||||
const results = [];
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (plugin.hooks[hookType]) {
|
||||
try {
|
||||
const result = await plugin.hooks[hookType](...args);
|
||||
results.push({ plugin: name, result });
|
||||
} catch (err) {
|
||||
this.log.error('plugins', `Hook ${hookType} failed in ${name}`, { error: err.message });
|
||||
results.push({ plugin: name, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of loaded plugins with their manifests.
|
||||
*/
|
||||
list() {
|
||||
return [...this.plugins.values()].map(p => ({
|
||||
name: p.manifest.name,
|
||||
version: p.manifest.version,
|
||||
description: p.manifest.description || '',
|
||||
hooks: Object.keys(p.hooks),
|
||||
permissions: p.manifest.permissions || [],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard widget manifests from plugins.
|
||||
*/
|
||||
getWidgets() {
|
||||
return [...this.dashboardWidgets.values()].map(w => w.manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered service types.
|
||||
*/
|
||||
getServiceTypes() {
|
||||
return [...this.serviceTypes.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered workflow action types.
|
||||
*/
|
||||
getWorkflowActions() {
|
||||
return [...this.workflowActions.keys()];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginManager, HOOK_TYPES };
|
||||
Reference in New Issue
Block a user