- Created docker-compose.docker-local.yml for local testing of frontend and backend services. - Added .env.development for development environment configuration. - Introduced .env.docker-local for local Docker environment settings. - Added .env.production for production environment configuration for Synology deployment.
728 lines
24 KiB
JavaScript
728 lines
24 KiB
JavaScript
/**
|
||
* DEPRECATED: Use deploy.sh instead
|
||
*
|
||
* This utility is being phased out in favor of the comprehensive deploy.sh script
|
||
* located at code/websites/pokedex.online/deploy.sh
|
||
*
|
||
* Migration guide:
|
||
* Old: node code/utils/deploy-pokedex.js --target internal
|
||
* New: cd code/websites/pokedex.online && ./deploy.sh --target production
|
||
*
|
||
* Old: node code/utils/deploy-pokedex.js --target local
|
||
* New: cd code/websites/pokedex.online && ./deploy.sh --target local
|
||
*
|
||
* The new deploy.sh provides:
|
||
* - Environment-specific builds using Vite modes
|
||
* - Automatic build verification
|
||
* - Pre-deployment validation
|
||
* - Integrated testing
|
||
* - Better error handling
|
||
*
|
||
* This file will be removed in a future update.
|
||
*/
|
||
|
||
console.warn('⚠️ WARNING: deploy-pokedex.js is DEPRECATED');
|
||
console.warn(' Please use deploy.sh instead:');
|
||
console.warn(' cd code/websites/pokedex.online');
|
||
console.warn(' ./deploy.sh --target local # Local Docker testing');
|
||
console.warn(' ./deploy.sh --target production # Deploy to Synology');
|
||
console.warn('');
|
||
process.exit(1);
|
||
|
||
/**
|
||
* 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 {string} path - Path to check (default: /)
|
||
* @param {number} retries - Number of retries
|
||
* @returns {Promise<boolean>} True if healthy
|
||
*/
|
||
async function healthCheck(host, port, path = '/', retries = 5) {
|
||
for (let i = 0; i < retries; i++) {
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
const req = http.get(
|
||
`http://${host}:${port}${path}`,
|
||
{ 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 */
|
||
}
|
||
}
|
||
|
||
// Set Discord redirect URI for local deployment
|
||
const discordRedirectUri = `http://localhost:${config.port}/oauth/callback`;
|
||
console.log(` 🔐 Discord Redirect URI: ${discordRedirectUri}`);
|
||
|
||
// Up with Discord redirect URI
|
||
console.log(' 🚀 Starting containers...');
|
||
execSync(`docker compose -f "${tmpComposePath}" up -d --build`, {
|
||
cwd: SOURCE_DIR,
|
||
stdio: 'inherit',
|
||
env: {
|
||
...process.env,
|
||
VITE_DISCORD_REDIRECT_URI: discordRedirectUri,
|
||
DISCORD_REDIRECT_URI: discordRedirectUri
|
||
}
|
||
});
|
||
|
||
// 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,
|
||
'/health'
|
||
);
|
||
// 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;
|
||
let config = null;
|
||
|
||
try {
|
||
// Parse arguments
|
||
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,
|
||
'/health'
|
||
);
|
||
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();
|