Adds success and error logging to the Next.js app preparation process in server.js, including guidance for missing production builds. In versionRouter, always fetches the latest update.sh from GitHub before running updates, logging the outcome and falling back to the local script if fetching fails.
1725 lines
56 KiB
JavaScript
1725 lines
56 KiB
JavaScript
import { createServer } from 'http';
|
||
import { parse } from 'url';
|
||
import next from 'next';
|
||
import { WebSocketServer } from 'ws';
|
||
import { spawn } from 'child_process';
|
||
import { join, resolve } from 'path';
|
||
import stripAnsi from 'strip-ansi';
|
||
import { spawn as ptySpawn } from 'node-pty';
|
||
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
||
import { getDatabase } from './src/server/database-prisma.js';
|
||
import dotenv from 'dotenv';
|
||
|
||
// Dynamic import for auto sync init to avoid tsx caching issues
|
||
/** @type {any} */
|
||
let autoSyncModule = null;
|
||
|
||
// Load environment variables from .env file
|
||
dotenv.config();
|
||
// Fallback minimal global error handlers for Node runtime (avoid TS import)
|
||
function registerGlobalErrorHandlers() {
|
||
if (registerGlobalErrorHandlers._registered) return;
|
||
registerGlobalErrorHandlers._registered = true;
|
||
process.on('uncaughtException', (err) => {
|
||
console.error('uncaught_exception', err);
|
||
});
|
||
process.on('unhandledRejection', (reason) => {
|
||
console.error('unhandled_rejection', reason);
|
||
});
|
||
}
|
||
registerGlobalErrorHandlers._registered = false;
|
||
|
||
const dev = process.env.NODE_ENV !== 'production';
|
||
const hostname = '0.0.0.0';
|
||
const port = parseInt(process.env.PORT || '3000', 10);
|
||
|
||
const app = next({ dev, hostname, port });
|
||
// Register global handlers once at bootstrap
|
||
registerGlobalErrorHandlers();
|
||
const handle = app.getRequestHandler();
|
||
|
||
// WebSocket handler for script execution
|
||
/**
|
||
* @typedef {import('ws').WebSocket & {connectionTime?: number, clientIP?: string}} ExtendedWebSocket
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} Execution
|
||
* @property {any} process
|
||
* @property {ExtendedWebSocket} ws
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} ServerInfo
|
||
* @property {string} name
|
||
* @property {string} ip
|
||
* @property {string} user
|
||
* @property {string} password
|
||
* @property {number} [id]
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} ExecutionResult
|
||
* @property {any} process
|
||
* @property {Function} kill
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} WebSocketMessage
|
||
* @property {string} action
|
||
* @property {string} [scriptPath]
|
||
* @property {string} [executionId]
|
||
* @property {string} [input]
|
||
* @property {string} [mode]
|
||
* @property {ServerInfo} [server]
|
||
* @property {boolean} [isUpdate]
|
||
* @property {boolean} [isShell]
|
||
* @property {boolean} [isBackup]
|
||
* @property {boolean} [isClone]
|
||
* @property {string} [containerId]
|
||
* @property {string} [storage]
|
||
* @property {string} [backupStorage]
|
||
* @property {number} [cloneCount]
|
||
* @property {string[]} [hostnames]
|
||
* @property {'lxc'|'vm'} [containerType]
|
||
* @property {Record<string, string|number|boolean>} [envVars]
|
||
*/
|
||
|
||
class ScriptExecutionHandler {
|
||
/**
|
||
* @param {import('http').Server} server
|
||
*/
|
||
constructor(server) {
|
||
// Create WebSocketServer without attaching to server
|
||
// We'll handle upgrades manually to avoid interfering with Next.js HMR
|
||
this.wss = new WebSocketServer({
|
||
noServer: true
|
||
});
|
||
this.activeExecutions = new Map();
|
||
this.db = getDatabase();
|
||
this.setupWebSocket();
|
||
}
|
||
|
||
/**
|
||
* Handle WebSocket upgrade for our endpoint
|
||
* @param {import('http').IncomingMessage} request
|
||
* @param {import('stream').Duplex} socket
|
||
* @param {Buffer} head
|
||
*/
|
||
handleUpgrade(request, socket, head) {
|
||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||
this.wss.emit('connection', ws, request);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Parse Container ID from terminal output
|
||
* @param {string} output - Terminal output to parse
|
||
* @returns {string|null} - Container ID if found, null otherwise
|
||
*/
|
||
parseContainerId(output) {
|
||
// First, strip ANSI color codes to make pattern matching more reliable
|
||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||
|
||
// Look for various patterns that Proxmox scripts might use
|
||
const patterns = [
|
||
// Primary pattern - the exact format from the output
|
||
/🆔\s+Container\s+ID:\s+(\d+)/i,
|
||
|
||
// Standard patterns with flexible spacing
|
||
/🆔\s*Container\s*ID:\s*(\d+)/i,
|
||
/Container\s*ID:\s*(\d+)/i,
|
||
/CT\s*ID:\s*(\d+)/i,
|
||
/Container\s*(\d+)/i,
|
||
|
||
// Alternative patterns
|
||
/CT\s*(\d+)/i,
|
||
/Container\s*created\s*with\s*ID\s*(\d+)/i,
|
||
/Created\s*container\s*(\d+)/i,
|
||
/Container\s*(\d+)\s*created/i,
|
||
/ID:\s*(\d+)/i,
|
||
|
||
// Patterns with different spacing and punctuation
|
||
/Container\s*ID\s*:\s*(\d+)/i,
|
||
/CT\s*ID\s*:\s*(\d+)/i,
|
||
/Container\s*#\s*(\d+)/i,
|
||
/CT\s*#\s*(\d+)/i,
|
||
|
||
// Patterns that might appear in success messages
|
||
/Successfully\s*created\s*container\s*(\d+)/i,
|
||
/Container\s*(\d+)\s*is\s*ready/i,
|
||
/Container\s*(\d+)\s*started/i,
|
||
|
||
// Generic number patterns that might be container IDs (3-4 digits)
|
||
/(?:^|\s)(\d{3,4})(?:\s|$)/m,
|
||
];
|
||
|
||
// Try patterns on both original and cleaned output
|
||
const outputsToTry = [output, cleanOutput];
|
||
|
||
for (const testOutput of outputsToTry) {
|
||
for (const pattern of patterns) {
|
||
const match = testOutput.match(pattern);
|
||
if (match && match[1]) {
|
||
const containerId = match[1];
|
||
// Additional validation: container IDs are typically 3-4 digits
|
||
if (containerId.length >= 3 && containerId.length <= 4) {
|
||
return containerId;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Parse Web UI URL from terminal output
|
||
* @param {string} output - Terminal output to parse
|
||
* @returns {{ip: string, port: number}|null} - Object with ip and port if found, null otherwise
|
||
*/
|
||
parseWebUIUrl(output) {
|
||
// First, strip ANSI color codes to make pattern matching more reliable
|
||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||
|
||
// Look for URL patterns with any valid IP address (private or public)
|
||
const patterns = [
|
||
// HTTP/HTTPS URLs with IP and port
|
||
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)/gi,
|
||
// URLs without explicit port (assume default ports)
|
||
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/|$|\s)/gi,
|
||
// URLs with trailing slash and port
|
||
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)\//gi,
|
||
// URLs with just IP and port (no protocol)
|
||
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)(?:\s|$)/gi,
|
||
// URLs with just IP (no protocol, no port)
|
||
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\s|$)/gi,
|
||
];
|
||
|
||
// Try patterns on both original and cleaned output
|
||
const outputsToTry = [output, cleanOutput];
|
||
|
||
for (const testOutput of outputsToTry) {
|
||
for (const pattern of patterns) {
|
||
const matches = [...testOutput.matchAll(pattern)];
|
||
for (const match of matches) {
|
||
if (match[1]) {
|
||
const ip = match[1];
|
||
const port = match[2] || (match[0].startsWith('https') ? '443' : '80');
|
||
|
||
// Validate IP address format
|
||
if (ip.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
|
||
return {
|
||
ip: ip,
|
||
port: parseInt(port, 10)
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Create installation record
|
||
* @param {string} scriptName - Name of the script
|
||
* @param {string} scriptPath - Path to the script
|
||
* @param {string} executionMode - 'local' or 'ssh'
|
||
* @param {number|null} serverId - Server ID for SSH executions
|
||
* @returns {Promise<number|null>} - Installation record ID
|
||
*/
|
||
async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
|
||
try {
|
||
const result = await this.db.createInstalledScript({
|
||
script_name: scriptName,
|
||
script_path: scriptPath,
|
||
container_id: undefined,
|
||
server_id: serverId ?? undefined,
|
||
execution_mode: executionMode,
|
||
status: 'in_progress',
|
||
output_log: ''
|
||
});
|
||
return Number(result.id);
|
||
} catch (error) {
|
||
console.error('Error creating installation record:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update installation record
|
||
* @param {number} installationId - Installation record ID
|
||
* @param {Object} updateData - Data to update
|
||
*/
|
||
async updateInstallationRecord(installationId, updateData) {
|
||
try {
|
||
await this.db.updateInstalledScript(installationId, updateData);
|
||
} catch (error) {
|
||
console.error('Error updating installation record:', error);
|
||
}
|
||
}
|
||
|
||
setupWebSocket() {
|
||
this.wss.on('connection', (ws, request) => {
|
||
|
||
// Set connection metadata
|
||
/** @type {ExtendedWebSocket} */ (ws).connectionTime = Date.now();
|
||
/** @type {ExtendedWebSocket} */ (ws).clientIP = request.socket.remoteAddress || 'unknown';
|
||
|
||
ws.on('message', (data) => {
|
||
try {
|
||
const rawMessage = data.toString();
|
||
const message = JSON.parse(rawMessage);
|
||
this.handleMessage(/** @type {ExtendedWebSocket} */ (ws), message);
|
||
} catch (error) {
|
||
console.error('Error parsing WebSocket message:', error);
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Invalid message format',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
});
|
||
|
||
ws.on('close', (code, reason) => {
|
||
this.cleanupActiveExecutions(/** @type {ExtendedWebSocket} */ (ws));
|
||
});
|
||
|
||
ws.on('error', (error) => {
|
||
console.error('WebSocket error:', error);
|
||
this.cleanupActiveExecutions(/** @type {ExtendedWebSocket} */ (ws));
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {WebSocketMessage} message
|
||
*/
|
||
async handleMessage(ws, message) {
|
||
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, isClone, containerId, storage, backupStorage, cloneCount, hostnames, containerType, envVars } = message;
|
||
|
||
switch (action) {
|
||
case 'start':
|
||
if (scriptPath && executionId) {
|
||
if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) {
|
||
await this.startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames);
|
||
} else if (isBackup && containerId && storage) {
|
||
await this.startBackupExecution(ws, containerId, executionId, storage, mode, server);
|
||
} else if (isUpdate && containerId) {
|
||
await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage);
|
||
} else if (isShell && containerId) {
|
||
await this.startShellExecution(ws, containerId, executionId, mode, server);
|
||
} else {
|
||
await this.startScriptExecution(ws, scriptPath, executionId, mode, server, envVars);
|
||
}
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Missing scriptPath or executionId',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
break;
|
||
|
||
case 'stop':
|
||
if (executionId) {
|
||
this.stopScriptExecution(executionId);
|
||
}
|
||
break;
|
||
|
||
case 'input':
|
||
if (executionId && input !== undefined) {
|
||
this.sendInputToProcess(executionId, input);
|
||
}
|
||
break;
|
||
|
||
default:
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Unknown action',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} scriptPath
|
||
* @param {string} executionId
|
||
* @param {string} mode
|
||
* @param {ServerInfo|null} server
|
||
* @param {Object} [envVars] - Optional environment variables to pass to the script
|
||
*/
|
||
async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null, envVars = {}) {
|
||
/** @type {number|null} */
|
||
let installationId = null;
|
||
|
||
try {
|
||
|
||
// Check if execution is already running
|
||
if (this.activeExecutions.has(executionId)) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Script execution already running',
|
||
timestamp: Date.now()
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Extract script name from path
|
||
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
|
||
|
||
// Create installation record
|
||
const serverId = server ? (server.id ?? null) : null;
|
||
installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
|
||
|
||
if (!installationId) {
|
||
console.error('Failed to create installation record');
|
||
}
|
||
|
||
// Handle SSH execution
|
||
if (mode === 'ssh' && server) {
|
||
await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId, envVars);
|
||
return;
|
||
}
|
||
|
||
if (mode === 'ssh' && !server) {
|
||
// SSH mode requested but no server provided, falling back to local execution
|
||
}
|
||
|
||
// Basic validation for local execution
|
||
const scriptsDir = join(process.cwd(), 'scripts');
|
||
const resolvedPath = resolve(scriptPath);
|
||
|
||
if (!resolvedPath.startsWith(resolve(scriptsDir))) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Script path is not within the allowed scripts directory',
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Update installation record with failure
|
||
if (installationId) {
|
||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Format environment variables for local execution
|
||
// Convert envVars object to environment variables
|
||
const envWithVars = {
|
||
...process.env,
|
||
TERM: 'xterm-256color', // Enable proper terminal support
|
||
FORCE_ANSI: 'true', // Allow ANSI codes for proper display
|
||
COLUMNS: '80', // Set terminal width
|
||
LINES: '24' // Set terminal height
|
||
};
|
||
|
||
// Add envVars to environment
|
||
if (envVars && typeof envVars === 'object') {
|
||
for (const [key, value] of Object.entries(envVars)) {
|
||
/** @type {Record<string, string>} */
|
||
const envRecord = envWithVars;
|
||
envRecord[key] = String(value);
|
||
}
|
||
}
|
||
|
||
// Start script execution with pty for proper TTY support
|
||
const childProcess = ptySpawn('bash', [resolvedPath], {
|
||
cwd: scriptsDir,
|
||
name: 'xterm-256color',
|
||
cols: 80,
|
||
rows: 24,
|
||
env: envWithVars
|
||
});
|
||
|
||
// pty handles encoding automatically
|
||
|
||
// Store the execution with installation ID
|
||
this.activeExecutions.set(executionId, {
|
||
process: childProcess,
|
||
ws,
|
||
installationId,
|
||
outputBuffer: ''
|
||
});
|
||
|
||
// Send start message
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting execution of ${scriptPath}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Handle pty data (both stdout and stderr combined)
|
||
childProcess.onData(async (data) => {
|
||
const output = data.toString();
|
||
|
||
// Store output in buffer for logging
|
||
const execution = this.activeExecutions.get(executionId);
|
||
if (execution) {
|
||
execution.outputBuffer += output;
|
||
// Keep only last 1000 characters to avoid memory issues
|
||
if (execution.outputBuffer.length > 1000) {
|
||
execution.outputBuffer = execution.outputBuffer.slice(-1000);
|
||
}
|
||
}
|
||
|
||
// Parse for Container ID
|
||
const containerId = this.parseContainerId(output);
|
||
if (containerId && installationId) {
|
||
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||
}
|
||
|
||
// Parse for Web UI URL
|
||
const webUIUrl = this.parseWebUIUrl(output);
|
||
if (webUIUrl && installationId) {
|
||
const { ip, port } = webUIUrl;
|
||
if (ip && port) {
|
||
await this.updateInstallationRecord(installationId, {
|
||
web_ui_ip: ip,
|
||
web_ui_port: port
|
||
});
|
||
}
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: output,
|
||
timestamp: Date.now()
|
||
});
|
||
});
|
||
|
||
// Handle process exit
|
||
childProcess.onExit((e) => {
|
||
const execution = this.activeExecutions.get(executionId);
|
||
const isSuccess = e.exitCode === 0;
|
||
|
||
// Update installation record with final status and output
|
||
if (installationId && execution) {
|
||
this.updateInstallationRecord(installationId, {
|
||
status: isSuccess ? 'success' : 'failed',
|
||
output_log: execution.outputBuffer
|
||
});
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `Script execution finished with code: ${e.exitCode}, signal: ${e.signal}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Clean up
|
||
this.activeExecutions.delete(executionId);
|
||
});
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Failed to start script: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Update installation record with failure
|
||
if (installationId) {
|
||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start SSH script execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} scriptPath
|
||
* @param {string} executionId
|
||
* @param {ServerInfo} server
|
||
* @param {number|null} installationId
|
||
* @param {Object} [envVars] - Optional environment variables to pass to the script
|
||
*/
|
||
async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null, envVars = {}) {
|
||
const sshService = getSSHExecutionService();
|
||
|
||
// Send start message
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting SSH execution of ${scriptPath} on ${server.name} (${server.ip})`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
try {
|
||
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
|
||
server,
|
||
scriptPath,
|
||
/** @param {string} data */ async (data) => {
|
||
// Store output in buffer for logging
|
||
const exec = this.activeExecutions.get(executionId);
|
||
if (exec) {
|
||
exec.outputBuffer += data;
|
||
// Keep only last 1000 characters to avoid memory issues
|
||
if (exec.outputBuffer.length > 1000) {
|
||
exec.outputBuffer = exec.outputBuffer.slice(-1000);
|
||
}
|
||
}
|
||
|
||
// Parse for Container ID
|
||
const containerId = this.parseContainerId(data);
|
||
if (containerId && installationId) {
|
||
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||
}
|
||
|
||
// Parse for Web UI URL
|
||
const webUIUrl = this.parseWebUIUrl(data);
|
||
if (webUIUrl && installationId) {
|
||
const { ip, port } = webUIUrl;
|
||
if (ip && port) {
|
||
await this.updateInstallationRecord(installationId, {
|
||
web_ui_ip: ip,
|
||
web_ui_port: port
|
||
});
|
||
}
|
||
}
|
||
|
||
// Handle data output
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */ (error) => {
|
||
// Store error in buffer for logging
|
||
const exec = this.activeExecutions.get(executionId);
|
||
if (exec) {
|
||
exec.outputBuffer += error;
|
||
// Keep only last 1000 characters to avoid memory issues
|
||
if (exec.outputBuffer.length > 1000) {
|
||
exec.outputBuffer = exec.outputBuffer.slice(-1000);
|
||
}
|
||
}
|
||
|
||
// Handle errors
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */ async (code) => {
|
||
const exec = this.activeExecutions.get(executionId);
|
||
const isSuccess = code === 0;
|
||
|
||
// Update installation record with final status and output
|
||
if (installationId && exec) {
|
||
await this.updateInstallationRecord(installationId, {
|
||
status: isSuccess ? 'success' : 'failed',
|
||
output_log: exec.outputBuffer
|
||
});
|
||
}
|
||
|
||
// Handle process exit
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `SSH script execution finished with code: ${code}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Clean up
|
||
this.activeExecutions.delete(executionId);
|
||
},
|
||
envVars
|
||
));
|
||
|
||
// Store the execution with installation ID
|
||
this.activeExecutions.set(executionId, {
|
||
process: execution.process,
|
||
ws,
|
||
installationId,
|
||
outputBuffer: ''
|
||
});
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Failed to start SSH execution: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Update installation record with failure
|
||
if (installationId) {
|
||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} executionId
|
||
*/
|
||
stopScriptExecution(executionId) {
|
||
const execution = this.activeExecutions.get(executionId);
|
||
if (execution) {
|
||
execution.process.kill('SIGTERM');
|
||
this.activeExecutions.delete(executionId);
|
||
|
||
this.sendMessage(execution.ws, {
|
||
type: 'end',
|
||
data: 'Script execution stopped by user',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} executionId
|
||
* @param {string} input
|
||
*/
|
||
sendInputToProcess(executionId, input) {
|
||
const execution = this.activeExecutions.get(executionId);
|
||
if (execution && execution.process.write) {
|
||
execution.process.write(input);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {any} message
|
||
*/
|
||
sendMessage(ws, message) {
|
||
if (ws.readyState === 1) { // WebSocket.OPEN
|
||
ws.send(JSON.stringify(message));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {ExtendedWebSocket} ws
|
||
*/
|
||
cleanupActiveExecutions(ws) {
|
||
for (const [executionId, execution] of this.activeExecutions.entries()) {
|
||
if (execution.ws === ws) {
|
||
execution.process.kill('SIGTERM');
|
||
this.activeExecutions.delete(executionId);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start backup execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {string} storage
|
||
* @param {string} mode
|
||
* @param {ServerInfo|null} server
|
||
*/
|
||
async startBackupExecution(ws, containerId, executionId, storage, mode = 'local', server = null) {
|
||
try {
|
||
// Send start message
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting backup for container ${containerId} to storage ${storage}...`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
if (mode === 'ssh' && server) {
|
||
await this.startSSHBackupExecution(ws, containerId, executionId, storage, server);
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: 'Backup is only supported via SSH',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Failed to start backup: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start SSH backup execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {string} storage
|
||
* @param {ServerInfo} server
|
||
* @param {Function} [onComplete] - Optional callback when backup completes
|
||
*/
|
||
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = undefined) {
|
||
const sshService = getSSHExecutionService();
|
||
|
||
return new Promise((resolve, reject) => {
|
||
try {
|
||
const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
|
||
|
||
// Wrap the onExit callback to resolve our promise
|
||
let promiseResolved = false;
|
||
|
||
sshService.executeCommand(
|
||
server,
|
||
backupCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
// Don't send 'end' message here if this is part of a backup+update flow
|
||
// The update flow will handle completion messages
|
||
const success = code === 0;
|
||
|
||
if (!success) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Backup failed with exit code: ${code}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
// Send a completion message (but not 'end' type to avoid stopping terminal)
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
if (onComplete) onComplete(success);
|
||
|
||
// Resolve the promise when backup completes
|
||
// Use setImmediate to ensure resolution happens in the right execution context
|
||
if (!promiseResolved) {
|
||
promiseResolved = true;
|
||
const result = { success, code };
|
||
|
||
// Use setImmediate to ensure promise resolution happens in the next tick
|
||
// This ensures the await in startUpdateExecution can properly resume
|
||
setImmediate(() => {
|
||
try {
|
||
resolve(result);
|
||
} catch (resolveError) {
|
||
console.error('Error resolving backup promise:', resolveError);
|
||
reject(resolveError);
|
||
}
|
||
});
|
||
}
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
}
|
||
).then((execution) => {
|
||
// Store the execution
|
||
this.activeExecutions.set(executionId, {
|
||
process: /** @type {any} */ (execution).process,
|
||
ws
|
||
});
|
||
// Note: Don't resolve here - wait for onExit callback
|
||
}).catch((error) => {
|
||
console.error('Error starting backup execution:', error);
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
if (onComplete) onComplete(false);
|
||
if (!promiseResolved) {
|
||
promiseResolved = true;
|
||
reject(error);
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error in startSSHBackupExecution:', error);
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
if (onComplete) onComplete(false);
|
||
reject(error);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Start SSH clone execution
|
||
* Gets next IDs sequentially: get next ID → clone → get next ID → clone, etc.
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {string} storage
|
||
* @param {ServerInfo} server
|
||
* @param {'lxc'|'vm'} containerType
|
||
* @param {number} cloneCount
|
||
* @param {string[]} hostnames
|
||
*/
|
||
async startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames) {
|
||
const sshService = getSSHExecutionService();
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting clone operation: Creating ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}...`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
try {
|
||
// Step 1: Stop source container/VM
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step 1/${4 + cloneCount}] Stopping source ${containerType.toUpperCase()} ${containerId}...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
const stopCommand = containerType === 'lxc' ? `pct stop ${containerId}` : `qm stop ${containerId}`;
|
||
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
stopCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
if (code === 0) {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step 1/${4 + cloneCount}] Source ${containerType.toUpperCase()} stopped successfully.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
resolve();
|
||
} else {
|
||
// Continue even if stop fails (might already be stopped)
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step 1/${4 + cloneCount}] Stop command completed with exit code ${code} (container may already be stopped).\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
resolve();
|
||
}
|
||
}
|
||
);
|
||
}));
|
||
|
||
// Step 2: Clone for each clone count (get next ID sequentially before each clone)
|
||
const clonedIds = [];
|
||
for (let i = 0; i < cloneCount; i++) {
|
||
const cloneNumber = i + 1;
|
||
const hostname = hostnames[i];
|
||
|
||
// Get next ID for this clone
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + i}/${4 + cloneCount}] Getting next available ID for clone ${cloneNumber}...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
let nextId = '';
|
||
try {
|
||
let output = '';
|
||
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
'pvesh get /cluster/nextid',
|
||
/** @param {string} data */
|
||
(data) => {
|
||
output += data;
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
reject(new Error(`Failed to get next ID: ${error}`));
|
||
},
|
||
/** @param {number} exitCode */
|
||
(exitCode) => {
|
||
if (exitCode === 0) {
|
||
resolve();
|
||
} else {
|
||
reject(new Error(`pvesh command failed with exit code ${exitCode}`));
|
||
}
|
||
}
|
||
);
|
||
}));
|
||
|
||
nextId = output.trim();
|
||
if (!nextId || !/^\d+$/.test(nextId)) {
|
||
throw new Error('Invalid next ID received');
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + i}/${4 + cloneCount}] Got next ID: ${nextId}\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `\n[Step ${2 + i}/${4 + cloneCount}] Failed to get next ID: ${error instanceof Error ? error.message : String(error)}\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
throw error;
|
||
}
|
||
|
||
clonedIds.push(nextId);
|
||
|
||
// Clone the container/VM
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + i}/${4 + cloneCount}] Cloning ${containerType.toUpperCase()} ${containerId} to ${nextId} with hostname ${hostname}...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
const cloneCommand = containerType === 'lxc'
|
||
? `pct clone ${containerId} ${nextId} --hostname ${hostname} --storage ${storage}`
|
||
: `qm clone ${containerId} ${nextId} --name ${hostname} --storage ${storage}`;
|
||
|
||
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
cloneCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
if (code === 0) {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + i}/${4 + cloneCount}] Clone ${cloneNumber} created successfully.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
resolve();
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `\nClone ${cloneNumber} failed with exit code: ${code}\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
reject(new Error(`Clone ${cloneNumber} failed with exit code ${code}`));
|
||
}
|
||
}
|
||
);
|
||
}));
|
||
}
|
||
|
||
// Step 3: Start source container/VM
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Starting source ${containerType.toUpperCase()} ${containerId}...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
const startSourceCommand = containerType === 'lxc' ? `pct start ${containerId}` : `qm start ${containerId}`;
|
||
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
startSourceCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
if (code === 0) {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Source ${containerType.toUpperCase()} started successfully.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Start command completed with exit code ${code}.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
resolve();
|
||
}
|
||
);
|
||
}));
|
||
|
||
// Step 4: Start target containers/VMs
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + cloneCount + 2}/${4 + cloneCount}] Starting cloned ${containerType.toUpperCase()}(s)...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
for (let i = 0; i < cloneCount; i++) {
|
||
const cloneNumber = i + 1;
|
||
const nextId = clonedIds[i];
|
||
|
||
const startTargetCommand = containerType === 'lxc' ? `pct start ${nextId}` : `qm start ${nextId}`;
|
||
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
startTargetCommand,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
if (code === 0) {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\nClone ${cloneNumber} (ID: ${nextId}) started successfully.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\nClone ${cloneNumber} (ID: ${nextId}) start completed with exit code ${code}.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
resolve();
|
||
}
|
||
);
|
||
}));
|
||
}
|
||
|
||
// Step 5: Add to database
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n[Step ${2 + cloneCount + 3}/${4 + cloneCount}] Adding cloned ${containerType.toUpperCase()}(s) to database...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
for (let i = 0; i < cloneCount; i++) {
|
||
const nextId = clonedIds[i];
|
||
const hostname = hostnames[i];
|
||
|
||
try {
|
||
// Read config file to get hostname/name
|
||
const configPath = containerType === 'lxc'
|
||
? `/etc/pve/lxc/${nextId}.conf`
|
||
: `/etc/pve/qemu-server/${nextId}.conf`;
|
||
|
||
let configContent = '';
|
||
await new Promise(/** @type {(resolve: (value?: void) => void) => void} */ ((resolve) => {
|
||
sshService.executeCommand(
|
||
server,
|
||
`cat "${configPath}" 2>/dev/null || echo ""`,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
configContent += data;
|
||
},
|
||
() => resolve(),
|
||
() => resolve()
|
||
);
|
||
}));
|
||
|
||
// Parse config for hostname/name
|
||
let finalHostname = hostname;
|
||
if (configContent.trim()) {
|
||
const lines = configContent.split('\n');
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (containerType === 'lxc' && trimmed.startsWith('hostname:')) {
|
||
finalHostname = trimmed.substring(9).trim();
|
||
break;
|
||
} else if (containerType === 'vm' && trimmed.startsWith('name:')) {
|
||
finalHostname = trimmed.substring(5).trim();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!finalHostname) {
|
||
finalHostname = `${containerType}-${nextId}`;
|
||
}
|
||
|
||
// Create installed script record
|
||
const script = await this.db.createInstalledScript({
|
||
script_name: finalHostname,
|
||
script_path: `cloned/${finalHostname}`,
|
||
container_id: nextId,
|
||
server_id: server.id,
|
||
execution_mode: 'ssh',
|
||
status: 'success',
|
||
output_log: `Cloned ${containerType.toUpperCase()}`
|
||
});
|
||
|
||
// For LXC, store config in database
|
||
if (containerType === 'lxc' && configContent.trim()) {
|
||
// Simple config parser
|
||
/** @type {any} */
|
||
const configData = {};
|
||
const lines = configContent.split('\n');
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||
|
||
const [key, ...valueParts] = trimmed.split(':');
|
||
const value = valueParts.join(':').trim();
|
||
|
||
if (key === 'hostname') configData.hostname = value;
|
||
else if (key === 'arch') configData.arch = value;
|
||
else if (key === 'cores') configData.cores = parseInt(value) || null;
|
||
else if (key === 'memory') configData.memory = parseInt(value) || null;
|
||
else if (key === 'swap') configData.swap = parseInt(value) || null;
|
||
else if (key === 'onboot') configData.onboot = parseInt(value) || null;
|
||
else if (key === 'ostype') configData.ostype = value;
|
||
else if (key === 'unprivileged') configData.unprivileged = parseInt(value) || null;
|
||
else if (key === 'tags') configData.tags = value;
|
||
else if (key === 'rootfs') {
|
||
const match = value.match(/^([^:]+):([^,]+)/);
|
||
if (match) {
|
||
configData.rootfs_storage = match[1];
|
||
const sizeMatch = value.match(/size=([^,]+)/);
|
||
if (sizeMatch) {
|
||
configData.rootfs_size = sizeMatch[1];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
await this.db.createLXCConfig(script.id, configData);
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\nClone ${i + 1} (ID: ${nextId}, Hostname: ${finalHostname}) added to database successfully.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `\nError adding clone ${i + 1} (ID: ${nextId}) to database: ${error instanceof Error ? error.message : String(error)}\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n\n[Clone operation completed successfully!]\nCreated ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}.\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `\n\n[Clone operation failed!]\nError: ${error instanceof Error ? error.message : String(error)}\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
this.activeExecutions.delete(executionId);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start update execution (pct enter + update command)
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {string} mode
|
||
* @param {ServerInfo|undefined} server
|
||
* @param {string} [backupStorage] - Optional storage to backup to before update
|
||
*/
|
||
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = undefined, backupStorage = undefined) {
|
||
try {
|
||
// If backup storage is provided, run backup first
|
||
if (backupStorage && mode === 'ssh' && server) {
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting backup before update for container ${containerId}...`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Create a separate execution ID for backup
|
||
const backupExecutionId = `backup_${executionId}`;
|
||
|
||
// Run backup and wait for it to complete
|
||
try {
|
||
const backupResult = await this.startSSHBackupExecution(
|
||
ws,
|
||
containerId,
|
||
backupExecutionId,
|
||
backupStorage,
|
||
server
|
||
);
|
||
|
||
// Backup completed (successfully or not)
|
||
if (!backupResult || !backupResult.success) {
|
||
// Backup failed, but we'll still allow update (per requirement 1b)
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: '\n⚠️ Backup failed, but proceeding with update as requested...\n',
|
||
timestamp: Date.now()
|
||
});
|
||
} else {
|
||
// Backup succeeded
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: '\n✅ Backup completed successfully. Starting update...\n',
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Backup error before update:', error);
|
||
// Backup failed to start, but allow update to proceed
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: `\n⚠️ Backup error: ${error instanceof Error ? error.message : String(error)}. Proceeding with update...\n`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
// Small delay before starting update
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
}
|
||
|
||
// Send start message for update (only if we're actually starting an update)
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting update for container ${containerId}...`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
if (mode === 'ssh' && server) {
|
||
await this.startSSHUpdateExecution(ws, containerId, executionId, server);
|
||
} else {
|
||
await this.startLocalUpdateExecution(ws, containerId, executionId);
|
||
}
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Failed to start update: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start local update execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
*/
|
||
async startLocalUpdateExecution(ws, containerId, executionId) {
|
||
const { spawn } = await import('node-pty');
|
||
|
||
// Create a shell process that will run pct enter and then update
|
||
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
|
||
name: 'xterm-color',
|
||
cols: 80,
|
||
rows: 24,
|
||
cwd: process.cwd(),
|
||
env: process.env
|
||
});
|
||
|
||
// Store the execution
|
||
this.activeExecutions.set(executionId, {
|
||
process: childProcess,
|
||
ws
|
||
});
|
||
|
||
// Handle pty data
|
||
childProcess.onData((data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data.toString(),
|
||
timestamp: Date.now()
|
||
});
|
||
});
|
||
|
||
// Send the update command after a delay to ensure we're in the container
|
||
setTimeout(() => {
|
||
childProcess.write('update\n');
|
||
}, 4000);
|
||
|
||
// Handle process exit
|
||
childProcess.onExit((e) => {
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `Update completed with exit code: ${e.exitCode}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Start SSH update execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {ServerInfo} server
|
||
*/
|
||
async startSSHUpdateExecution(ws, containerId, executionId, server) {
|
||
const sshService = getSSHExecutionService();
|
||
|
||
try {
|
||
const execution = await sshService.executeCommand(
|
||
server,
|
||
`pct enter ${containerId}`,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `Update completed with exit code: ${code}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
}
|
||
);
|
||
|
||
// Store the execution
|
||
this.activeExecutions.set(executionId, {
|
||
process: /** @type {any} */ (execution).process,
|
||
ws
|
||
});
|
||
|
||
// Send the update command after a delay to ensure we're in the container
|
||
setTimeout(() => {
|
||
/** @type {any} */ (execution).process.write('update\n');
|
||
}, 4000);
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `SSH execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start shell execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {string} mode
|
||
* @param {ServerInfo|null} server
|
||
*/
|
||
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
|
||
try {
|
||
|
||
// Send start message
|
||
this.sendMessage(ws, {
|
||
type: 'start',
|
||
data: `Starting shell session for container ${containerId}...`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
if (mode === 'ssh' && server) {
|
||
await this.startSSHShellExecution(ws, containerId, executionId, server);
|
||
} else {
|
||
await this.startLocalShellExecution(ws, containerId, executionId);
|
||
}
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `Failed to start shell: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start local shell execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
*/
|
||
async startLocalShellExecution(ws, containerId, executionId) {
|
||
const { spawn } = await import('node-pty');
|
||
|
||
// Create a shell process that will run pct enter
|
||
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
|
||
name: 'xterm-color',
|
||
cols: 80,
|
||
rows: 24,
|
||
cwd: process.cwd(),
|
||
env: process.env
|
||
});
|
||
|
||
// Store the execution
|
||
this.activeExecutions.set(executionId, {
|
||
process: childProcess,
|
||
ws
|
||
});
|
||
|
||
// Handle pty data
|
||
childProcess.onData((data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data.toString(),
|
||
timestamp: Date.now()
|
||
});
|
||
});
|
||
|
||
// Note: No automatic command is sent - user can type commands interactively
|
||
|
||
// Handle process exit
|
||
childProcess.onExit((e) => {
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `Shell session ended with exit code: ${e.exitCode}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Start SSH shell execution
|
||
* @param {ExtendedWebSocket} ws
|
||
* @param {string} containerId
|
||
* @param {string} executionId
|
||
* @param {ServerInfo} server
|
||
*/
|
||
async startSSHShellExecution(ws, containerId, executionId, server) {
|
||
const sshService = getSSHExecutionService();
|
||
|
||
try {
|
||
const execution = await sshService.executeCommand(
|
||
server,
|
||
`pct enter ${containerId}`,
|
||
/** @param {string} data */
|
||
(data) => {
|
||
this.sendMessage(ws, {
|
||
type: 'output',
|
||
data: data,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {string} error */
|
||
(error) => {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: error,
|
||
timestamp: Date.now()
|
||
});
|
||
},
|
||
/** @param {number} code */
|
||
(code) => {
|
||
this.sendMessage(ws, {
|
||
type: 'end',
|
||
data: `Shell session ended with exit code: ${code}`,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
this.activeExecutions.delete(executionId);
|
||
}
|
||
);
|
||
|
||
// Store the execution
|
||
this.activeExecutions.set(executionId, {
|
||
process: /** @type {any} */ (execution).process,
|
||
ws
|
||
});
|
||
|
||
// Note: No automatic command is sent - user can type commands interactively
|
||
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
type: 'error',
|
||
data: `SSH shell execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// TerminalHandler removed - not used by current application
|
||
|
||
app.prepare().then(() => {
|
||
console.log('> Next.js app prepared successfully');
|
||
const httpServer = createServer(async (req, res) => {
|
||
try {
|
||
// Be sure to pass `true` as the second argument to `url.parse`.
|
||
// This tells it to parse the query portion of the URL.
|
||
const parsedUrl = parse(req.url || '', true);
|
||
const { pathname, query } = parsedUrl;
|
||
|
||
// Check if this is a WebSocket upgrade request
|
||
const isWebSocketUpgrade = req.headers.upgrade === 'websocket';
|
||
|
||
// Only intercept WebSocket upgrades for /ws/script-execution
|
||
// Let Next.js handle all other WebSocket upgrades (like HMR) and all HTTP requests
|
||
if (isWebSocketUpgrade && pathname === '/ws/script-execution') {
|
||
// WebSocket upgrade will be handled by the WebSocket server
|
||
// Don't call handle() for this path - let WebSocketServer handle it
|
||
return;
|
||
}
|
||
|
||
// Let Next.js handle all other requests including:
|
||
// - HTTP requests to /ws/script-execution (non-WebSocket)
|
||
// - WebSocket upgrades to other paths (like /_next/webpack-hmr)
|
||
// - All static assets (_next routes)
|
||
// - All other routes
|
||
await handle(req, res, parsedUrl);
|
||
} catch (err) {
|
||
console.error('Error occurred handling', req.url, err);
|
||
res.statusCode = 500;
|
||
res.end('internal server error');
|
||
}
|
||
});
|
||
|
||
// Create WebSocket handlers
|
||
const scriptHandler = new ScriptExecutionHandler(httpServer);
|
||
|
||
// Handle WebSocket upgrades manually to avoid interfering with Next.js HMR
|
||
// We need to preserve Next.js's upgrade handlers and call them for non-matching paths
|
||
// Save any existing upgrade listeners (Next.js might have set them up)
|
||
const existingUpgradeListeners = httpServer.listeners('upgrade').slice();
|
||
httpServer.removeAllListeners('upgrade');
|
||
|
||
// Add our upgrade handler that routes based on path
|
||
httpServer.on('upgrade', (request, socket, head) => {
|
||
const parsedUrl = parse(request.url || '', true);
|
||
const { pathname } = parsedUrl;
|
||
|
||
if (pathname === '/ws/script-execution') {
|
||
// Handle our custom WebSocket endpoint
|
||
scriptHandler.handleUpgrade(request, socket, head);
|
||
} else {
|
||
// For all other paths (including Next.js HMR), call existing listeners
|
||
// This allows Next.js to handle its own WebSocket upgrades
|
||
for (const listener of existingUpgradeListeners) {
|
||
try {
|
||
listener.call(httpServer, request, socket, head);
|
||
} catch (err) {
|
||
console.error('Error in upgrade listener:', err);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
// Note: TerminalHandler removed as it's not being used by the current application
|
||
|
||
httpServer
|
||
.once('error', (err) => {
|
||
console.error(err);
|
||
process.exit(1);
|
||
})
|
||
.listen(port, hostname, async () => {
|
||
console.log(`> Ready on http://${hostname}:${port}`);
|
||
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
|
||
|
||
// Initialize auto sync module and run initialization
|
||
if (!autoSyncModule) {
|
||
try {
|
||
console.log('Dynamically importing autoSyncInit...');
|
||
autoSyncModule = await import('./src/server/lib/autoSyncInit.js');
|
||
console.log('autoSyncModule loaded, exports:', Object.keys(autoSyncModule));
|
||
} catch (error) {
|
||
const err = error instanceof Error ? error : new Error(String(error));
|
||
console.error('Failed to import autoSyncInit:', err.message);
|
||
console.error('Stack:', err.stack);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Initialize default repositories
|
||
if (typeof autoSyncModule.initializeRepositories === 'function') {
|
||
console.log('Calling initializeRepositories...');
|
||
await autoSyncModule.initializeRepositories();
|
||
} else {
|
||
console.warn('initializeRepositories is not a function, type:', typeof autoSyncModule.initializeRepositories);
|
||
}
|
||
|
||
// Initialize auto-sync service
|
||
if (typeof autoSyncModule.initializeAutoSync === 'function') {
|
||
console.log('Calling initializeAutoSync...');
|
||
autoSyncModule.initializeAutoSync();
|
||
}
|
||
|
||
// Setup graceful shutdown handlers
|
||
if (typeof autoSyncModule.setupGracefulShutdown === 'function') {
|
||
console.log('Setting up graceful shutdown...');
|
||
autoSyncModule.setupGracefulShutdown();
|
||
}
|
||
});
|
||
}).catch((err) => {
|
||
console.error('> Failed to start server:', err.message);
|
||
console.error('> If you see "Could not find a production build", run: npm run build');
|
||
console.error('> Full error:', err);
|
||
process.exit(1);
|
||
});
|