/** * Bundled Workflows - Pre-configured automation templates * * Workflows attach to events (container-down, pre-update, resource-alert, scheduled) * and execute a sequence of actions when triggered. */ const EventEmitter = require('events'); const fs = require('fs'); const path = require('path'); const WORKFLOWS_FILE = process.env.WORKFLOWS_FILE || path.join(__dirname, 'workflows-config.json'); const WORKFLOW_HISTORY_FILE = process.env.WORKFLOW_HISTORY_FILE || path.join(__dirname, 'workflow-history.json'); /** * Bundled workflow templates */ const BUNDLED_WORKFLOWS = { 'auto-restart-on-crash': { id: 'auto-restart-on-crash', name: 'Auto-Restart on Crash', description: 'Automatically restart a container when it goes down', trigger: 'container-down', actions: [ { type: 'docker-restart', containerId: '{{containerId}}' }, { type: 'notify', message: 'Container {{containerId}} restarted automatically' } ] }, 'backup-before-update': { id: 'backup-before-update', name: 'Backup Before Update', description: 'Create a backup before any app update', trigger: 'pre-update', actions: [ { type: 'backup-create', appId: '{{appId}}', label: 'pre-update' }, { type: 'notify', message: 'Backup created before updating {{appId}}' } ] }, 'health-check-on-interval': { id: 'health-check-on-interval', name: 'Periodic Health Check', description: 'Run health checks every 15 minutes and alert if degraded', trigger: 'scheduled', interval: 15 * 60 * 1000, // 15 minutes actions: [ { type: 'health-check', target: '{{serviceId}}' }, { type: 'notify-on-failure', message: 'Health check failed for {{serviceId}}' } ] }, 'disk-space-alert': { id: 'disk-space-alert', name: 'Disk Space Alert', description: 'Alert when disk usage exceeds 80%', trigger: 'resource-alert', condition: 'diskPercent > 80', actions: [ { type: 'notify', message: '⚠️ Disk usage at {{diskPercent}}% on {{host}}' } ] }, 'weekly-container-report': { id: 'weekly-container-report', name: 'Weekly Container Report', description: 'Send a weekly summary of container status and resource usage', trigger: 'scheduled', interval: 7 * 24 * 60 * 60 * 1000, // weekly actions: [ { type: 'collect-metrics', period: '7d' }, { type: 'notify', message: '{{report}}' } ] } }; /** * WorkflowEngine - Executes bundled workflows */ class WorkflowEngine extends EventEmitter { constructor(ctx) { super(); this.ctx = ctx; this.enabled = new Map(); this.history = []; this.scheduledJobs = new Map(); this.loadConfig(); this.loadHistory(); this.startScheduledWorkflows(); } /** * Load enabled/disabled state for workflows */ loadConfig() { try { if (fs.existsSync(WORKFLOWS_FILE)) { const data = JSON.parse(fs.readFileSync(WORKFLOWS_FILE, 'utf8')); this.enabled = new Map(Object.entries(data.enabled || {})); } } catch (error) { console.error('[WorkflowEngine] Error loading config:', error.message); } // Default all workflows to enabled if not explicitly set for (const [id, workflow] of Object.entries(BUNDLED_WORKFLOWS)) { if (!this.enabled.has(id)) { this.enabled.set(id, true); } } } /** * Save enabled/disabled state */ saveConfig() { try { const data = { enabled: Object.fromEntries(this.enabled) }; fs.writeFileSync(WORKFLOWS_FILE, JSON.stringify(data, null, 2)); } catch (error) { console.error('[WorkflowEngine] Error saving config:', error.message); } } /** * Load execution history */ loadHistory() { try { if (fs.existsSync(WORKFLOW_HISTORY_FILE)) { this.history = JSON.parse(fs.readFileSync(WORKFLOW_HISTORY_FILE, 'utf8')); } } catch (error) { console.error('[WorkflowEngine] Error loading history:', error.message); this.history = []; } } /** * Save execution history */ saveHistory() { try { fs.writeFileSync(WORKFLOW_HISTORY_FILE, JSON.stringify(this.history, null, 2)); } catch (error) { console.error('[WorkflowEngine] Error saving history:', error.message); } } /** * Start scheduled workflows */ startScheduledWorkflows() { for (const [id, workflow] of Object.entries(BUNDLED_WORKFLOWS)) { if (workflow.trigger === 'scheduled' && workflow.interval) { this.startScheduledWorkflow(id, workflow); } } } /** * Start a scheduled workflow */ startScheduledWorkflow(workflowId, workflow) { if (!this.enabled.get(workflowId)) return; // Clear existing job if any this.stopScheduledWorkflow(workflowId); const job = setInterval(() => { this.executeWorkflow(workflowId, { trigger: 'scheduled', timestamp: new Date().toISOString() }) .catch(err => console.error(`[WorkflowEngine] Scheduled workflow ${workflowId} failed:`, err.message)); }, workflow.interval); this.scheduledJobs.set(workflowId, job); console.log(`[WorkflowEngine] Scheduled workflow '${workflowId}' every ${workflow.interval}ms`); } /** * Stop a scheduled workflow */ stopScheduledWorkflow(workflowId) { if (this.scheduledJobs.has(workflowId)) { clearInterval(this.scheduledJobs.get(workflowId)); this.scheduledJobs.delete(workflowId); } } /** * Execute a workflow by ID */ async executeWorkflow(workflowId, triggerData = {}) { const workflow = BUNDLED_WORKFLOWS[workflowId]; if (!workflow) { throw new Error(`Unknown workflow: ${workflowId}`); } if (!this.enabled.get(workflowId)) { console.log(`[WorkflowEngine] Workflow ${workflowId} is disabled, skipping`); return { skipped: true, reason: 'disabled' }; } const executionId = `${workflowId}-${Date.now()}`; const startTime = Date.now(); console.log(`[WorkflowEngine] Executing workflow: ${workflowId}`); this.emit('workflow-start', { workflowId, executionId, triggerData }); const results = []; for (const action of workflow.actions) { try { const result = await this.executeAction(action, triggerData); results.push({ action: action.type, success: true, result }); } catch (error) { console.error(`[WorkflowEngine] Action ${action.type} failed:`, error.message); results.push({ action: action.type, success: false, error: error.message }); // Continue with other actions but log failure } } const duration = Date.now() - startTime; const allSucceeded = results.every(r => r.success); const historyEntry = { executionId, workflowId, workflowName: workflow.name, trigger: triggerData.trigger || 'manual', timestamp: new Date().toISOString(), duration, success: allSucceeded, results }; this.history.push(historyEntry); // Keep history to last 500 entries if (this.history.length > 500) { this.history = this.history.slice(-500); } this.saveHistory(); this.emit('workflow-complete', historyEntry); console.log(`[WorkflowEngine] Workflow ${workflowId} completed in ${duration}ms, success: ${allSucceeded}`); return historyEntry; } /** * Execute a single action */ async executeAction(action, context) { switch (action.type) { case 'docker-restart': return this.restartContainer(this.interpolate(action.containerId, context)); case 'backup-create': return this.createBackup( this.interpolate(action.appId, context), action.label ); case 'notify': return this.notify( this.interpolate(action.message, context), action.channel ); case 'notify-on-failure': // Only send if previous action failed return this.notify( this.interpolate(action.message, context), action.channel ); case 'health-check': return this.healthCheckService(this.interpolate(action.target, context)); case 'collect-metrics': return this.collectMetrics(context.containerId, action.period); default: console.warn(`[WorkflowEngine] Unknown action type: ${action.type}`); return { skipped: true, reason: `Unknown action type: ${action.type}` }; } } /** * Interpolate template variables in a string */ interpolate(str, context) { if (!str || typeof str !== 'string') return str; return str.replace(/\{\{(\w+)\}\}/g, (match, key) => { return context[key] !== undefined ? context[key] : match; }); } /** * Health check action */ async healthCheckService(serviceId) { if (!serviceId || serviceId === '{{serviceId}}') { // Run health check on all services const results = []; const servicesStateManager = this.ctx.servicesStateManager; if (servicesStateManager) { const services = servicesStateManager.getState() || []; for (const service of services) { if (service.containerId) { try { const healthy = await this.checkContainerHealth(service.containerId); results.push({ service: service.id, healthy }); } catch (e) { results.push({ service: service.id, healthy: false, error: e.message }); } } } } return { checked: results.length, healthy: results.filter(r => r.healthy).length, results }; } // Single service check const healthy = await this.checkContainerHealth(serviceId); return { serviceId, healthy }; } /** * Check if a container is healthy */ async checkContainerHealth(containerId) { try { const docker = this.ctx.docker?.client; if (!docker) return false; const container = docker.getContainer(containerId); const info = await container.inspect(); return info.State && info.State.Running && info.State.Health !== 'unhealthy'; } catch (error) { return false; } } /** * Docker restart action */ async restartContainer(containerId) { const docker = this.ctx.docker?.client; if (!docker) { throw new Error('Docker client not available'); } if (!containerId || containerId === '{{containerId}}') { throw new Error('Container ID not provided'); } console.log(`[WorkflowEngine] Restarting container: ${containerId}`); const container = docker.getContainer(containerId); await container.restart(); return { restarted: containerId }; } /** * Backup create action */ async createBackup(appId, label = 'workflow') { const backupManager = this.ctx.backupManager; if (!backupManager) { throw new Error('Backup manager not available'); } if (!appId || appId === '{{appId}}') { throw new Error('App ID not provided'); } console.log(`[WorkflowEngine] Creating backup for: ${appId}`); // Use backup manager's executeBackup if available const backupName = `${appId}-${label}`; const backupConfig = backupManager.config?.backups?.[appId]; if (backupConfig) { const result = await backupManager.executeBackup(backupName, backupConfig); return { backupId: result.backupId, appId, label }; } // Fallback: trigger manual backup via backup manager if (backupManager.executeBackup) { const result = await backupManager.executeBackup(appId, { include: ['config', 'data'], schedule: 'manual' }); return { backupId: result.backupId, appId, label }; } throw new Error('Backup execution not available'); } /** * Notify action */ async notify(message, channel) { const notification = this.ctx.notification; if (!notification) { console.warn('[WorkflowEngine] Notification manager not available'); return { notified: false, reason: 'no notification manager' }; } console.log(`[WorkflowEngine] Sending notification: ${message}`); notification.send('workflow', 'Workflow Notification', message, 'info'); return { notified: true, message }; } /** * Collect metrics action */ async collectMetrics(containerId, period = '7d') { const resourceMonitor = this.ctx.resourceMonitor; if (!resourceMonitor) { throw new Error('Resource monitor not available'); } // Get aggregated stats const stats = resourceMonitor.getAllStats(); // Build report let report = `# Weekly Container Report\n\n`; report += `Generated: ${new Date().toLocaleString()}\n\n`; for (const [id, info] of Object.entries(stats)) { const agg = info.aggregated; report += `## ${info.name || id}\n`; report += `- Status: ${info.current?.status || 'unknown'}\n`; if (agg) { report += `- CPU: avg ${agg.cpu?.avg?.toFixed(1)}%, max ${agg.cpu?.max?.toFixed(1)}%\n`; report += `- Memory: avg ${agg.memory?.avg?.toFixed(1)}%, max ${agg.memory?.max?.toFixed(1)}%\n`; } report += '\n'; } return { report, containerCount: Object.keys(stats).length }; } /** * List all available workflows */ listWorkflows() { return Object.entries(BUNDLED_WORKFLOWS).map(([id, workflow]) => ({ ...workflow, enabled: this.enabled.get(id) ?? true })); } /** * Enable or disable a workflow */ setWorkflowEnabled(workflowId, enabled) { if (!BUNDLED_WORKFLOWS[workflowId]) { throw new Error(`Unknown workflow: ${workflowId}`); } this.enabled.set(workflowId, enabled); this.saveConfig(); // Handle scheduled workflows const workflow = BUNDLED_WORKFLOWS[workflowId]; if (workflow.trigger === 'scheduled' && workflow.interval) { if (enabled) { this.startScheduledWorkflow(workflowId, workflow); } else { this.stopScheduledWorkflow(workflowId); } } console.log(`[WorkflowEngine] Workflow ${workflowId} ${enabled ? 'enabled' : 'disabled'}`); return { workflowId, enabled }; } /** * Get execution history for a workflow */ getHistory(workflowId = null, limit = 50) { let history = this.history; if (workflowId) { history = history.filter(h => h.workflowId === workflowId); } return history.slice(-limit).reverse(); } /** * Trigger workflows for a specific event */ async triggerForEvent(eventType, eventData) { const matchingWorkflows = Object.entries(BUNDLED_WORKFLOWS) .filter(([id, workflow]) => { if (workflow.trigger !== eventType) return false; if (!this.enabled.get(id)) return false; // Check condition if specified if (workflow.condition && eventData) { try { // Simple condition evaluation const conditionMet = this.evaluateCondition(workflow.condition, eventData); return conditionMet; } catch (e) { console.warn(`[WorkflowEngine] Condition evaluation failed for ${id}:`, e.message); return false; } } return true; }); const results = []; for (const [workflowId, workflow] of matchingWorkflows) { try { const result = await this.executeWorkflow(workflowId, { trigger: eventType, timestamp: new Date().toISOString(), ...eventData }); results.push({ workflowId, success: true, result }); } catch (error) { results.push({ workflowId, success: false, error: error.message }); } } return results; } /** * Evaluate a simple condition string */ evaluateCondition(condition, data) { // Simple condition like "diskPercent > 80" // Supports: >, <, >=, <=, ==, != const match = condition.match(/^(\w+)\s*(>=|<=|==|!=|>|<)\s*(\S+)$/); if (!match) return true; const [, field, operator, value] = match; const fieldValue = data[field]; if (fieldValue === undefined) return false; const numValue = parseFloat(value); const numFieldValue = parseFloat(fieldValue); switch (operator) { case '>': return numFieldValue > numValue; case '<': return numFieldValue < numValue; case '>=': return numFieldValue >= numValue; case '<=': return numFieldValue <= numValue; case '==': return fieldValue == value; case '!=': return fieldValue != value; default: return false; } } /** * Stop all scheduled workflows */ stop() { for (const [workflowId] of this.scheduledJobs) { this.stopScheduledWorkflow(workflowId); } console.log('[WorkflowEngine] All scheduled workflows stopped'); } } module.exports = { WorkflowEngine, BUNDLED_WORKFLOWS };