Files
memory-infrastructure-palace/code/utils/deploy-pokedex.js

681 lines
23 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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'
},
external: {
host: 'home.gregrjacobs.com',
port: 2323,
username: 'GregRJacobs',
privateKeyPath: '~/.ssh/ds3627xs_gregrjacobs'
},
local: {
host: 'localhost'
}
};
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', 'external', or 'local'.`
);
}
// 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
// Make sure to preserve newline structure
content = content.replace(/\n\s*- ['"](\d+):443['"]/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;
}
/**
* Local deployment function
* @param {Object} config - Deployment configuration
*/
async function deployLocal(config) {
const { execSync } = await import('child_process');
console.log('\n🐳 Deploying to local Docker...');
// Create modified docker-compose
const modifiedCompose = createModifiedDockerCompose(
config.port,
config.sslPort,
config.backendPort
);
const tmpComposePath = path.join(SOURCE_DIR, 'docker-compose.tmp.yml');
fs.writeFileSync(tmpComposePath, modifiedCompose);
try {
// Stop existing
console.log(' 🛑 Stopping existing containers...');
try {
execSync(`docker compose -f "${tmpComposePath}" down --remove-orphans`, {
cwd: SOURCE_DIR,
stdio: 'inherit'
});
} catch (e) {
// Ignore if file doesn't exist yet or other issues on down
try {
execSync(
`docker compose -f docker-compose.production.yml down --remove-orphans`,
{ cwd: SOURCE_DIR, stdio: 'inherit' }
);
} catch (e2) {
/* ignore */
}
}
// Up
console.log(' 🚀 Starting containers...');
execSync(`docker compose -f "${tmpComposePath}" up -d --build`, {
cwd: SOURCE_DIR,
stdio: 'inherit'
});
// Health Check
console.log('\n🏥 Performing health checks...');
console.log(' Checking frontend...');
const frontendHealthy = await healthCheck('localhost', config.port);
if (!frontendHealthy) throw new Error('Frontend health check failed');
console.log(' ✅ Frontend healthy');
console.log(' Checking backend...');
const backendHealthy = await healthCheck('localhost', config.backendPort);
// Backend might need more time
if (!backendHealthy) throw new Error('Backend health check failed');
console.log(' ✅ Backend healthy');
console.log(`\n🎉 Local Deployment successful!`);
console.log(`🌐 Frontend: http://localhost:${config.port}`);
if (config.sslPort)
console.log(`🔒 HTTPS: https://localhost:${config.sslPort}`);
console.log(`🔌 Backend: http://localhost:${config.backendPort}`);
} catch (e) {
console.error('❌ Local deployment failed:', e.message);
// Clean up tmp file? Maybe keep for debugging if failed
throw e;
} finally {
console.log(`\n Docker Compose file: ${tmpComposePath}`);
}
}
/**
* Main deployment function
*/
async function deploy() {
const ssh = new NodeSSH();
let previousImage = null;
let containerExisted = false;
try {
// Parse arguments
const config = parseArgs();
const isLocal = config.target === 'local';
const sshConfig = SSH_HOSTS[config.target];
console.log('🚀 Starting Pokedex.Online deployment');
if (isLocal) {
console.log(`📡 Target: local`);
} else {
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 using ~/.ssh/config
if (!isLocal) {
console.log('\n🔐 Connecting to Synology...');
const keyPath = expandTilde(sshConfig.privateKeyPath);
console.log(` 🔑 Using SSH key: ${keyPath}`);
console.log(
` 📍 Target: ${sshConfig.username}@${sshConfig.host}:${sshConfig.port}`
);
// Verify key file exists
if (!fs.existsSync(keyPath)) {
throw new Error(`SSH key file not found: ${keyPath}`);
}
try {
const privateKeyContent = fs.readFileSync(keyPath, 'utf8');
const keySize = privateKeyContent.length;
console.log(` 📂 Key file size: ${keySize} bytes`);
if (keySize === 0) {
throw new Error('SSH key file is empty');
}
// Use node-ssh with private key directly
await ssh.connect({
host: sshConfig.host,
port: sshConfig.port,
username: sshConfig.username,
privateKey: privateKeyContent,
readyTimeout: 60000,
tryKeyboard: false
});
console.log('✅ Connected successfully');
} catch (connError) {
console.error('\n❌ SSH Connection Failed');
console.error(`Error: ${connError.message}`);
console.error('\nPossible causes:');
console.error(
'1. SSH public key not added to ~/.ssh/authorized_keys on the server'
);
console.error('2. SSH key has wrong permissions (should be 600)');
console.error('3. SSH user home directory permissions are wrong');
console.error('\nVerify the key works manually:');
console.error(
` ssh -i ${keyPath} ${sshConfig.username}@${sshConfig.host} -p ${sshConfig.port} "whoami"`
);
console.error(
'\nIf that fails, the public key needs to be added on the server:'
);
console.error(
` cat ~/.ssh/${path.basename(keyPath)}.pub | ssh ${sshConfig.username}@${sshConfig.host} -p ${sshConfig.port} "cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"`
);
throw new Error(`SSH connection failed: ${connError.message}`);
}
}
// 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}`);
}
if (isLocal) {
await deployLocal(config);
return;
}
// 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...');
// Count files for reporting
let fileCount = 0;
function countFiles(dir) {
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
countFiles(fullPath);
} else {
fileCount++;
}
}
}
countFiles(DIST_DIR);
console.log(` Found ${fileCount} files in dist/`);
// Create dist directory on remote
await ssh.execCommand(`mkdir -p ${REMOTE_PATH}/dist`);
// Transfer dist directory using rsync
try {
console.log(` 📡 Transferring dist directory via rsync...`);
const { execSync } = await import('child_process');
const expandedKeyPath = expandTilde(sshConfig.privateKeyPath);
// Use rsync with SSH for reliable transfer
// Key options:
// - IdentitiesOnly=yes: Only use specified key, not ssh-agent keys
// - rsync-path: Ensure we use the correct rsync binary on remote
const rsyncCmd = `rsync -av --delete -e "ssh -p ${sshConfig.port} -i ${expandedKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes" "${DIST_DIR}/" "${sshConfig.username}@${sshConfig.host}:${REMOTE_PATH}/dist/" --rsync-path="/usr/bin/rsync"`;
try {
execSync(rsyncCmd, {
stdio: 'inherit',
shell: '/bin/bash'
});
console.log(` ✅ Transferred dist directory successfully via rsync`);
} catch (rsyncError) {
console.log(` ❌ Rsync failed: ${rsyncError.message}`);
throw rsyncError;
}
} catch (transferError) {
console.log(` ❌ File transfer failed: ${transferError.message}`);
throw new Error(`File transfer failed: ${transferError.message}`);
}
// 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`);
// Use rsync for server files with exclusions
try {
console.log(` 📡 Transferring server files via rsync...`);
const { execSync } = await import('child_process');
const expandedKeyPath = expandTilde(sshConfig.privateKeyPath);
// Use rsync with SSH for reliable transfer and exclusions
// Key options:
// - IdentitiesOnly=yes: Only use specified key, not ssh-agent keys
// - rsync-path: Ensure we use the correct rsync binary on remote
const rsyncCmd = `rsync -av --delete --exclude='node_modules' --exclude='tests' --exclude='.git' --exclude='dist' --exclude='build' -e "ssh -p ${sshConfig.port} -i ${expandedKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes" "${serverDir}/" "${sshConfig.username}@${sshConfig.host}:${REMOTE_PATH}/server/" --rsync-path="/usr/bin/rsync"`;
try {
execSync(rsyncCmd, {
stdio: 'inherit',
shell: '/bin/bash'
});
console.log(` ✅ Backend files transferred successfully via rsync`);
} catch (rsyncError) {
console.log(` ❌ Rsync failed: ${rsyncError.message}`);
throw rsyncError;
}
} catch (transferError) {
console.log(` ❌ File transfer failed: ${transferError.message}`);
throw new Error(`Backend file transfer failed: ${transferError.message}`);
}
// Now transfer config files
console.log(' 📦 Transferring configuration 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`
}
];
// Transfer config files using cat over SSH (more reliable than SFTP)
for (const file of filesToTransfer) {
const fileName = path.basename(file.local);
try {
const fileContent = fs.readFileSync(file.local, 'utf8');
const catResult = await ssh.execCommand(
`cat > '${file.remote}' << 'EOFMARKER'\n${fileContent}\nEOFMARKER`
);
if (catResult.code !== 0) {
throw new Error(
`Failed to transfer ${fileName}: ${catResult.stderr}`
);
}
console.log(`${fileName} (${fs.statSync(file.local).size} bytes)`);
} catch (error) {
throw new Error(`Failed to transfer ${fileName}: ${error.message}`);
}
}
// 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) {
if (config && config.target === 'local') {
console.error('\n❌ Deployment failed:', error.message);
process.exit(1);
}
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();