[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,155 @@
|
||||
/**
|
||||
* DC-080: Plugin manager tests
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { PluginManager } = require('../../src/plugins/plugin-manager');
|
||||
|
||||
describe('DC-080: Plugin Manager', () => {
|
||||
let tmpDir, manager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-plugins-'));
|
||||
manager = new PluginManager({
|
||||
dataDir: tmpDir,
|
||||
log: { info: jest.fn(), error: jest.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('loadAll()', () => {
|
||||
it('creates plugin directory if it does not exist', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins');
|
||||
expect(fs.existsSync(pluginDir)).toBe(false);
|
||||
await manager.loadAll();
|
||||
expect(fs.existsSync(pluginDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('loads successfully with empty plugin dir', async () => {
|
||||
await manager.loadAll();
|
||||
expect(manager.plugins.size).toBe(0);
|
||||
expect(manager.loaded).toBe(true);
|
||||
});
|
||||
|
||||
it('skips hidden directories', async () => {
|
||||
const hiddenDir = path.join(tmpDir, 'plugins', '.hidden');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
await manager.loadAll();
|
||||
expect(manager.plugins.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadOne()', () => {
|
||||
it('loads a plugin with valid manifest', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'test-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
name: 'test-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A test plugin',
|
||||
})
|
||||
);
|
||||
|
||||
await manager.loadOne(pluginDir);
|
||||
expect(manager.plugins.has('test-plugin')).toBe(true);
|
||||
});
|
||||
|
||||
it('throws if manifest.json is missing', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'no-manifest');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
await expect(manager.loadOne(pluginDir)).rejects.toThrow('manifest.json');
|
||||
});
|
||||
|
||||
it('throws if manifest lacks name or version', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'invalid');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'manifest.json'),
|
||||
JSON.stringify({ description: 'no name' })
|
||||
);
|
||||
|
||||
await expect(manager.loadOne(pluginDir)).rejects.toThrow('name and version');
|
||||
});
|
||||
|
||||
it('throws on duplicate plugin name', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'dup');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'manifest.json'),
|
||||
JSON.stringify({ name: 'dup', version: '1.0.0' })
|
||||
);
|
||||
|
||||
await manager.loadOne(pluginDir);
|
||||
await expect(manager.loadOne(pluginDir)).rejects.toThrow('already loaded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unload()', () => {
|
||||
it('unloads a loaded plugin', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'removable');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'manifest.json'),
|
||||
JSON.stringify({ name: 'removable', version: '1.0.0' })
|
||||
);
|
||||
|
||||
await manager.loadOne(pluginDir);
|
||||
expect(manager.plugins.has('removable')).toBe(true);
|
||||
|
||||
manager.unload('removable');
|
||||
expect(manager.plugins.has('removable')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown plugin', () => {
|
||||
expect(manager.unload('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list()', () => {
|
||||
it('returns empty array when no plugins', () => {
|
||||
expect(manager.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns plugin metadata', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'listed');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'manifest.json'),
|
||||
JSON.stringify({ name: 'listed', version: '2.0.0', description: 'Test' })
|
||||
);
|
||||
|
||||
await manager.loadOne(pluginDir);
|
||||
const list = manager.list();
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].name).toBe('listed');
|
||||
expect(list[0].version).toBe('2.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeHook()', () => {
|
||||
it('returns empty results when no plugins have the hook', async () => {
|
||||
await manager.loadAll();
|
||||
const results = await manager.executeHook('service:health-check');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWidgets()', () => {
|
||||
it('returns empty array by default', () => {
|
||||
expect(manager.getWidgets()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServiceTypes()', () => {
|
||||
it('returns empty array by default', () => {
|
||||
expect(manager.getServiceTypes()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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