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

545 lines
17 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',
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: 8080,
sslPort: null,
backendPort: 3000
};
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 check
console.log('\n🏥 Performing health check...');
const isHealthy = await healthCheck(sshConfig.host, config.port);
if (!isHealthy) {
throw new Error('Health check failed - container is not responding');
}
console.log('✅ Health check passed');
console.log(`\n🎉 Deployment successful!`);
console.log(`🌐 HTTP: http://${sshConfig.host}:${config.port}`);
if (config.sslPort) {
console.log(`🔒 HTTPS: https://${sshConfig.host}:${config.sslPort}`);
}
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();