561 lines
18 KiB
JavaScript
561 lines
18 KiB
JavaScript
/**
|
||
* Pokedex.Online Deployment Script
|
||
*
|
||
* Deploys the Vue 3 pokedex.online application with backend to Synology NAS via SSH.
|
||
* - Builds the Vue 3 application locally (npm run build)
|
||
* - Connects to Synology using configured SSH hosts
|
||
* - Transfers built files, backend code, and Docker configuration via SFTP
|
||
* - Manages multi-container Docker deployment (frontend + backend) with rollback on failure
|
||
* - Performs health checks on both frontend and backend containers
|
||
*
|
||
* Usage:
|
||
* node code/utils/deploy-pokedex.js [--target internal|external] [--port 8080] [--ssl-port 8443] [--backend-port 3000]
|
||
* npm run deploy:pokedex -- --target external --port 8081 --ssl-port 8444 --backend-port 3001
|
||
*
|
||
* Examples:
|
||
* npm run deploy:pokedex # Deploy to internal (10.0.0.81) on port 8080
|
||
* npm run deploy:pokedex -- --target external # Deploy to external (home.gregrjacobs.com)
|
||
* npm run deploy:pokedex -- --port 8081 # Deploy to internal on port 8081
|
||
* npm run deploy:pokedex -- --port 8080 --ssl-port 8443 # Deploy with HTTPS on port 8443
|
||
* npm run deploy:pokedex -- --target external --port 3000 --ssl-port 3443
|
||
*/
|
||
import { NodeSSH } from 'node-ssh';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import http from 'http';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
// Configuration
|
||
const SSH_HOSTS = {
|
||
internal: {
|
||
host: '10.0.0.81',
|
||
port: 2323,
|
||
username: 'GregRJacobs',
|
||
privateKeyPath: '~/.ssh/ds3627xs_gregrjacobs',
|
||
password: 'J@Cubs88'
|
||
},
|
||
external: {
|
||
host: 'home.gregrjacobs.com',
|
||
port: 2323,
|
||
username: 'GregRJacobs',
|
||
privateKeyPath: '~/.ssh/ds3627xs_gregrjacobs',
|
||
password: 'J@Cubs88'
|
||
}
|
||
};
|
||
|
||
const REMOTE_PATH = '/volume1/docker/pokedex-online/base';
|
||
const CONTAINER_NAME = 'pokedex-online';
|
||
const SOURCE_DIR = path.resolve(__dirname, '../websites/pokedex.online');
|
||
const DIST_DIR = path.join(SOURCE_DIR, 'dist');
|
||
|
||
/**
|
||
* Parse command line arguments
|
||
* @returns {Object} Parsed arguments
|
||
*/
|
||
function parseArgs() {
|
||
const args = process.argv.slice(2);
|
||
const config = {
|
||
target: 'internal',
|
||
port: 8099,
|
||
sslPort: null,
|
||
backendPort: 3099
|
||
};
|
||
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === '--target' && args[i + 1]) {
|
||
config.target = args[i + 1];
|
||
i++;
|
||
} else if (args[i] === '--port' && args[i + 1]) {
|
||
config.port = parseInt(args[i + 1], 10);
|
||
i++;
|
||
} else if (args[i] === '--ssl-port' && args[i + 1]) {
|
||
config.sslPort = parseInt(args[i + 1], 10);
|
||
i++;
|
||
} else if (args[i] === '--backend-port' && args[i + 1]) {
|
||
config.backendPort = parseInt(args[i + 1], 10);
|
||
i++;
|
||
}
|
||
}
|
||
|
||
// Validate target
|
||
if (!SSH_HOSTS[config.target]) {
|
||
throw new Error(
|
||
`Invalid target: ${config.target}. Must be 'internal' or 'external'.`
|
||
);
|
||
}
|
||
|
||
// Validate port
|
||
if (isNaN(config.port) || config.port < 1 || config.port > 65535) {
|
||
throw new Error(
|
||
`Invalid port: ${config.port}. Must be between 1 and 65535.`
|
||
);
|
||
}
|
||
|
||
// Validate SSL port if provided
|
||
if (
|
||
config.sslPort !== null &&
|
||
(isNaN(config.sslPort) || config.sslPort < 1 || config.sslPort > 65535)
|
||
) {
|
||
throw new Error(
|
||
`Invalid SSL port: ${config.sslPort}. Must be between 1 and 65535.`
|
||
);
|
||
}
|
||
|
||
return config;
|
||
}
|
||
|
||
/**
|
||
* Expand tilde in file paths
|
||
* @param {string} filepath - Path potentially starting with ~
|
||
* @returns {string} Expanded path
|
||
*/
|
||
function expandTilde(filepath) {
|
||
if (filepath.startsWith('~/')) {
|
||
return path.join(process.env.HOME, filepath.slice(2));
|
||
}
|
||
return filepath;
|
||
}
|
||
|
||
/**
|
||
* Create modified docker-compose.production.yml with custom ports
|
||
* @param {number} port - HTTP port to map to frontend container
|
||
* @param {number|null} sslPort - HTTPS port to map to frontend container (optional)
|
||
* @param {number} backendPort - Port to map to backend container
|
||
* @returns {string} Modified docker-compose content
|
||
*/
|
||
function createModifiedDockerCompose(port, sslPort, backendPort) {
|
||
const originalPath = path.join(SOURCE_DIR, 'docker-compose.production.yml');
|
||
let content = fs.readFileSync(originalPath, 'utf8');
|
||
|
||
// Replace frontend HTTP port mapping
|
||
content = content.replace(
|
||
/(frontend:[\s\S]*?ports:[\s\S]*?- ['"])(\d+)(:80['"])/,
|
||
`$1${port}$3`
|
||
);
|
||
|
||
// Replace frontend HTTPS port mapping if SSL port provided
|
||
if (sslPort !== null) {
|
||
content = content.replace(
|
||
/(frontend:[\s\S]*?ports:[\s\S]*?- ['"])(\d+)(:443['"])/,
|
||
`$1${sslPort}$3`
|
||
);
|
||
} else {
|
||
// Remove HTTPS port mapping line if no SSL port specified
|
||
content = content.replace(/\s*- ['"](\d+):443['"]\n?/g, '');
|
||
}
|
||
|
||
// Replace backend port mapping
|
||
content = content.replace(
|
||
/(backend:[\s\S]*?ports:[\s\S]*?- ['"])(\d+)(:3000['"])/,
|
||
`$1${backendPort}$3`
|
||
);
|
||
|
||
return content;
|
||
}
|
||
|
||
/**
|
||
* Perform HTTP health check
|
||
* @param {string} host - Host to check
|
||
* @param {number} port - Port to check
|
||
* @param {number} retries - Number of retries
|
||
* @returns {Promise<boolean>} True if healthy
|
||
*/
|
||
async function healthCheck(host, port, retries = 5) {
|
||
for (let i = 0; i < retries; i++) {
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
const req = http.get(
|
||
`http://${host}:${port}`,
|
||
{ timeout: 5000 },
|
||
res => {
|
||
if (res.statusCode === 200) {
|
||
resolve();
|
||
} else {
|
||
reject(new Error(`HTTP ${res.statusCode}`));
|
||
}
|
||
}
|
||
);
|
||
req.on('error', reject);
|
||
req.on('timeout', () => {
|
||
req.destroy();
|
||
reject(new Error('Request timeout'));
|
||
});
|
||
});
|
||
return true;
|
||
} catch {
|
||
if (i < retries - 1) {
|
||
console.log(
|
||
`⏳ Health check attempt ${i + 1}/${retries} failed, retrying in 3s...`
|
||
);
|
||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Main deployment function
|
||
*/
|
||
async function deploy() {
|
||
const ssh = new NodeSSH();
|
||
let previousImage = null;
|
||
let containerExisted = false;
|
||
|
||
try {
|
||
// Parse arguments
|
||
const config = parseArgs();
|
||
const sshConfig = SSH_HOSTS[config.target];
|
||
|
||
console.log('🚀 Starting Pokedex.Online deployment');
|
||
console.log(
|
||
`📡 Target: ${config.target} (${sshConfig.host}:${sshConfig.port})`
|
||
);
|
||
console.log(`🔌 Frontend Port: ${config.port}`);
|
||
if (config.sslPort) {
|
||
console.log(`🔒 HTTPS Port: ${config.sslPort}`);
|
||
}
|
||
console.log(`🔌 Backend Port: ${config.backendPort}`);
|
||
|
||
// Connect to Synology
|
||
console.log('\n🔐 Connecting to Synology...');
|
||
await ssh.connect({
|
||
host: sshConfig.host,
|
||
port: sshConfig.port,
|
||
username: sshConfig.username,
|
||
privateKeyPath: expandTilde(sshConfig.privateKeyPath),
|
||
password: sshConfig.password,
|
||
tryKeyboard: true
|
||
});
|
||
console.log('✅ Connected successfully');
|
||
|
||
// Build Vue 3 application
|
||
console.log('\n🔨 Building Vue 3 application...');
|
||
console.log(` Source: ${SOURCE_DIR}`);
|
||
console.log(` Output: ${DIST_DIR}`);
|
||
|
||
const { execSync } = await import('child_process');
|
||
try {
|
||
// Check if node_modules exists
|
||
if (!fs.existsSync(path.join(SOURCE_DIR, 'node_modules'))) {
|
||
console.log(' 📦 Installing dependencies...');
|
||
execSync('npm install', {
|
||
cwd: SOURCE_DIR,
|
||
stdio: 'inherit'
|
||
});
|
||
}
|
||
|
||
// Build the application
|
||
console.log(' ⚙️ Running build...');
|
||
execSync('npm run build', {
|
||
cwd: SOURCE_DIR,
|
||
stdio: 'inherit'
|
||
});
|
||
|
||
// Verify dist directory exists
|
||
if (!fs.existsSync(DIST_DIR)) {
|
||
throw new Error('Build failed - dist directory not found');
|
||
}
|
||
|
||
console.log(' ✅ Build completed successfully');
|
||
} catch (error) {
|
||
throw new Error(`Build failed: ${error.message}`);
|
||
}
|
||
|
||
// Check if container exists and capture current image
|
||
console.log('\n📦 Checking for existing container...');
|
||
console.log(` Container name: ${CONTAINER_NAME}`);
|
||
try {
|
||
const result = await ssh.execCommand(
|
||
`/usr/local/bin/docker inspect --format='{{.Image}}' ${CONTAINER_NAME} || /usr/bin/docker inspect --format='{{.Image}}' ${CONTAINER_NAME}`
|
||
);
|
||
console.log(` Command exit code: ${result.code}`);
|
||
if (result.stdout) console.log(` Stdout: ${result.stdout.trim()}`);
|
||
if (result.stderr) console.log(` Stderr: ${result.stderr.trim()}`);
|
||
|
||
if (result.code === 0 && result.stdout.trim()) {
|
||
previousImage = result.stdout.trim();
|
||
containerExisted = true;
|
||
console.log(
|
||
`✅ Found existing container (image: ${previousImage.substring(0, 12)}...)`
|
||
);
|
||
} else {
|
||
console.log('ℹ️ No existing container found');
|
||
}
|
||
} catch (error) {
|
||
console.log(` Error: ${error.message}`);
|
||
console.log('ℹ️ No existing container found');
|
||
}
|
||
|
||
// Create remote directory
|
||
console.log('\n📁 Creating remote directory...');
|
||
const mkdirResult = await ssh.execCommand(`mkdir -p ${REMOTE_PATH}`);
|
||
console.log(` Command: mkdir -p ${REMOTE_PATH}`);
|
||
if (mkdirResult.stdout) console.log(` Output: ${mkdirResult.stdout}`);
|
||
if (mkdirResult.stderr) console.log(` Stderr: ${mkdirResult.stderr}`);
|
||
console.log(` ✅ Directory ready`);
|
||
|
||
// Create modified docker-compose.yml
|
||
const modifiedDockerCompose = createModifiedDockerCompose(
|
||
config.port,
|
||
config.sslPort,
|
||
config.backendPort
|
||
);
|
||
const tempDockerComposePath = path.join(
|
||
SOURCE_DIR,
|
||
'docker-compose.tmp.yml'
|
||
);
|
||
fs.writeFileSync(tempDockerComposePath, modifiedDockerCompose);
|
||
|
||
// Transfer files
|
||
console.log('\n📤 Transferring files...');
|
||
|
||
// First transfer the dist directory
|
||
console.log(' 📦 Transferring dist directory...');
|
||
const distFiles = [];
|
||
|
||
function getDistFiles(dir, baseDir = DIST_DIR) {
|
||
const files = fs.readdirSync(dir, { withFileTypes: true });
|
||
for (const file of files) {
|
||
const fullPath = path.join(dir, file.name);
|
||
if (file.isDirectory()) {
|
||
getDistFiles(fullPath, baseDir);
|
||
} else {
|
||
const relativePath = path.relative(baseDir, fullPath);
|
||
distFiles.push({
|
||
local: fullPath,
|
||
remote: `${REMOTE_PATH}/dist/${relativePath.replace(/\\/g, '/')}`
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
getDistFiles(DIST_DIR);
|
||
console.log(` Found ${distFiles.length} files in dist/`);
|
||
|
||
// Create dist directory on remote
|
||
await ssh.execCommand(`mkdir -p ${REMOTE_PATH}/dist`);
|
||
|
||
// Transfer dist files
|
||
let transferred = 0;
|
||
for (const file of distFiles) {
|
||
try {
|
||
// Create remote directory for this file
|
||
const remoteDir = path.dirname(file.remote);
|
||
await ssh.execCommand(`mkdir -p ${remoteDir}`);
|
||
await ssh.putFile(file.local, file.remote);
|
||
transferred++;
|
||
if (transferred % 10 === 0) {
|
||
console.log(
|
||
` 📁 Transferred ${transferred}/${distFiles.length} files...`
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.log(
|
||
` ⚠️ Failed to transfer ${path.relative(DIST_DIR, file.local)}: ${error.message}`
|
||
);
|
||
}
|
||
}
|
||
console.log(` ✅ Transferred ${transferred} files from dist/`);
|
||
|
||
// Transfer backend server files
|
||
console.log(' 📦 Transferring backend server files...');
|
||
const serverDir = path.join(SOURCE_DIR, 'server');
|
||
await ssh.execCommand(`mkdir -p ${REMOTE_PATH}/server`);
|
||
|
||
// Transfer server package files
|
||
const serverFiles = [
|
||
'package.json',
|
||
'oauth-proxy.js',
|
||
'gamemaster-api.js',
|
||
'.env.example'
|
||
];
|
||
|
||
for (const file of serverFiles) {
|
||
const localPath = path.join(serverDir, file);
|
||
const remotePath = `${REMOTE_PATH}/server/${file}`;
|
||
if (fs.existsSync(localPath)) {
|
||
await ssh.putFile(localPath, remotePath);
|
||
console.log(` ✅ server/${file}`);
|
||
}
|
||
}
|
||
|
||
// Transfer server subdirectories
|
||
const serverDirs = ['middleware', 'routes', 'utils', 'data'];
|
||
for (const dir of serverDirs) {
|
||
const localDir = path.join(serverDir, dir);
|
||
if (fs.existsSync(localDir)) {
|
||
await ssh.execCommand(`mkdir -p ${REMOTE_PATH}/server/${dir}`);
|
||
const files = fs.readdirSync(localDir, { withFileTypes: true });
|
||
for (const file of files) {
|
||
if (file.isFile()) {
|
||
const localFile = path.join(localDir, file.name);
|
||
const remoteFile = `${REMOTE_PATH}/server/${dir}/${file.name}`;
|
||
await ssh.putFile(localFile, remoteFile);
|
||
}
|
||
}
|
||
console.log(` ✅ server/${dir}/ (${files.length} files)`);
|
||
}
|
||
}
|
||
console.log(` ✅ Backend files transferred`);
|
||
|
||
// Now transfer config files
|
||
const filesToTransfer = [
|
||
{
|
||
local: path.join(SOURCE_DIR, 'Dockerfile.frontend'),
|
||
remote: `${REMOTE_PATH}/Dockerfile.frontend`
|
||
},
|
||
{
|
||
local: path.join(SOURCE_DIR, 'server', 'Dockerfile'),
|
||
remote: `${REMOTE_PATH}/server/Dockerfile`
|
||
},
|
||
{
|
||
local: path.join(SOURCE_DIR, 'nginx.conf'),
|
||
remote: `${REMOTE_PATH}/nginx.conf`
|
||
},
|
||
{
|
||
local: tempDockerComposePath,
|
||
remote: `${REMOTE_PATH}/docker-compose.yml`
|
||
}
|
||
];
|
||
|
||
for (const file of filesToTransfer) {
|
||
try {
|
||
await ssh.putFile(file.local, file.remote);
|
||
console.log(` ✅ ${path.basename(file.local)}`);
|
||
} catch {
|
||
// If SFTP fails, fall back to cat method
|
||
console.log(
|
||
` ⚠️ SFTP failed for ${path.basename(file.local)}, using cat fallback...`
|
||
);
|
||
const fileContent = fs.readFileSync(file.local, 'utf8');
|
||
const catResult = await ssh.execCommand(
|
||
`cat > '${file.remote}' << 'EOFMARKER'\n${fileContent}\nEOFMARKER`
|
||
);
|
||
if (catResult.stdout) console.log(` Output: ${catResult.stdout}`);
|
||
if (catResult.stderr) console.log(` Stderr: ${catResult.stderr}`);
|
||
if (catResult.code !== 0) {
|
||
throw new Error(
|
||
`Failed to transfer ${path.basename(file.local)}: ${catResult.stderr}`
|
||
);
|
||
}
|
||
console.log(
|
||
` ✅ ${path.basename(file.local)} (${fs.statSync(file.local).size} bytes)`
|
||
);
|
||
}
|
||
}
|
||
|
||
// Clean up temp file
|
||
fs.unlinkSync(tempDockerComposePath);
|
||
|
||
// Stop existing container first to avoid port conflicts
|
||
if (containerExisted) {
|
||
console.log('\n🛑 Stopping existing container...');
|
||
const stopResult = await ssh.execCommand(
|
||
`cd ${REMOTE_PATH} && /usr/local/bin/docker compose down || /usr/local/bin/docker-compose down`
|
||
);
|
||
if (stopResult.stdout) console.log(` ${stopResult.stdout.trim()}`);
|
||
console.log(' ✅ Container stopped');
|
||
}
|
||
|
||
// Deploy with docker-compose
|
||
console.log('\n🐳 Building and starting Docker container...');
|
||
console.log(` Working directory: ${REMOTE_PATH}`);
|
||
|
||
// Try Docker Compose V2 first (docker compose), then fall back to V1 (docker-compose)
|
||
// Use full paths for Synology
|
||
console.log(' Attempting: /usr/local/bin/docker compose up -d --build');
|
||
let deployResult = await ssh.execCommand(
|
||
`cd ${REMOTE_PATH} && /usr/local/bin/docker compose up -d --build || /usr/local/bin/docker-compose up -d --build || /usr/bin/docker compose up -d --build`,
|
||
{ stream: 'both' }
|
||
);
|
||
|
||
console.log('\n 📋 Docker Output:');
|
||
if (deployResult.stdout) {
|
||
deployResult.stdout.split('\n').forEach(line => {
|
||
if (line.trim()) console.log(` ${line}`);
|
||
});
|
||
}
|
||
if (deployResult.stderr) {
|
||
console.log('\n ⚠️ Docker Stderr:');
|
||
deployResult.stderr.split('\n').forEach(line => {
|
||
if (line.trim()) console.log(` ${line}`);
|
||
});
|
||
}
|
||
console.log(` Exit code: ${deployResult.code}`);
|
||
|
||
if (deployResult.code !== 0) {
|
||
throw new Error(`Docker deployment failed: ${deployResult.stderr}`);
|
||
}
|
||
|
||
console.log('\n✅ Container started');
|
||
|
||
// Health checks
|
||
console.log('\n🏥 Performing health checks...');
|
||
console.log(' Checking frontend...');
|
||
const frontendHealthy = await healthCheck(sshConfig.host, config.port);
|
||
if (!frontendHealthy) {
|
||
throw new Error(
|
||
'Frontend health check failed - container is not responding'
|
||
);
|
||
}
|
||
console.log(' ✅ Frontend healthy');
|
||
|
||
console.log(' Checking backend...');
|
||
const backendHealthy = await healthCheck(
|
||
sshConfig.host,
|
||
config.backendPort
|
||
);
|
||
if (!backendHealthy) {
|
||
throw new Error(
|
||
'Backend health check failed - container is not responding'
|
||
);
|
||
}
|
||
console.log(' ✅ Backend healthy');
|
||
|
||
console.log('\n✅ All health checks passed');
|
||
console.log(`\n🎉 Deployment successful!`);
|
||
console.log(`🌐 Frontend: http://${sshConfig.host}:${config.port}`);
|
||
if (config.sslPort) {
|
||
console.log(`🔒 HTTPS: https://${sshConfig.host}:${config.sslPort}`);
|
||
}
|
||
console.log(`🔌 Backend: http://${sshConfig.host}:${config.backendPort}`);
|
||
|
||
ssh.dispose();
|
||
} catch (error) {
|
||
console.error('\n❌ Deployment failed:', error.message);
|
||
|
||
// Rollback
|
||
if (previousImage) {
|
||
console.log('\n🔄 Rolling back to previous image...');
|
||
try {
|
||
await ssh.execCommand(
|
||
`cd ${REMOTE_PATH} && docker-compose down && docker tag ${previousImage} pokedex-online:latest && docker-compose up -d`
|
||
);
|
||
console.log('✅ Rollback successful');
|
||
} catch (rollbackError) {
|
||
console.error('❌ Rollback failed:', rollbackError.message);
|
||
}
|
||
} else if (containerExisted === false) {
|
||
console.log('\n🧹 Cleaning up failed deployment...');
|
||
try {
|
||
await ssh.execCommand(
|
||
`cd ${REMOTE_PATH} && docker-compose down --volumes --remove-orphans`
|
||
);
|
||
console.log('✅ Cleanup successful');
|
||
} catch (cleanupError) {
|
||
console.error('❌ Cleanup failed:', cleanupError.message);
|
||
}
|
||
}
|
||
|
||
ssh.dispose();
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Run deployment
|
||
deploy();
|