/** * Инкрементальный деплой public/ на VPS в Docker volume nginx. * Загружает только изменённые файлы через tar-пайп над SSH (одно соединение). * * Использование: * node scripts/deploy-vps.mjs [sourceDir] */ import path from "node:path"; import fs from "node:fs"; import os from "node:os"; import { execFile, spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); const SSH_KEY = path.join(os.homedir(), ".ssh", "beget"); const SSH_RAW_OPTS = [ "-i", SSH_KEY, "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=30", ]; const SSH_HOST = "root@155.212.186.39"; const TARGET_DIR = "/var/lib/docker/volumes/s9mrcps8xw02r1dak0fsznix-www-data/_data"; const MANIFEST_PATH = path.join(projectRoot, ".deploy-manifest.json"); const SOURCE_DIR = (() => { const raw = process.argv[2] ?? "public"; return path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw); })(); function ssh(args) { return new Promise((resolve, reject) => { execFile("ssh", [...SSH_RAW_OPTS, SSH_HOST, ...args], { maxBuffer: 1024 * 1024 }, (err, stdout) => { if (err) reject(new Error((err.stderr || "").trim() || err.message)); else resolve((stdout || "").trim()); }); }); } function scanFiles(dir) { const entries = []; const walk = (current, relative) => { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const rel = relative ? `${relative}/${entry.name}` : entry.name; const full = path.join(current, entry.name); if (entry.isDirectory()) { walk(full, rel); } else if (entry.isFile()) { const stat = fs.statSync(full); entries.push({ relPath: rel.replace(/\\/g, "/"), mtime: stat.mtimeMs, size: stat.size, }); } } }; walk(dir, ""); return entries; } function loadManifest() { try { return JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8")); } catch { return {}; } } function saveManifest(manifest) { fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), "utf-8"); } /** Загрузить изменённые файлы одним tar-пайпом через SSH */ function uploadViaTar(filePaths, sourceDir) { return new Promise((resolve, reject) => { const listPath = path.join(os.tmpdir(), `deploy-files-${Date.now()}.txt`); fs.writeFileSync(listPath, filePaths.join("\n"), "utf-8"); // cwd вместо -C — обходит баг bsdtar с кириллицей в пути const tar = spawn("tar", [ "-c", `--files-from=${listPath}`, "--format=ustar", ], { cwd: sourceDir }); const sshProc = spawn("ssh", [ ...SSH_RAW_OPTS, SSH_HOST, `tar xf - -C ${TARGET_DIR}`, ]); tar.stdout.pipe(sshProc.stdin); let stderrChunks = []; sshProc.stderr.on("data", (d) => { stderrChunks.push(d); }); tar.stderr.on("data", (d) => { stderrChunks.push(d); }); function cleanup() { try { fs.unlinkSync(listPath); } catch { /* ok */ } } sshProc.on("close", (code) => { cleanup(); if (code !== 0) { const msg = Buffer.concat(stderrChunks).toString().trim(); reject(new Error(msg || `ssh exited with code ${code}`)); } else { resolve(); } }); sshProc.on("error", (err) => { cleanup(); reject(err); }); tar.on("error", (err) => { cleanup(); sshProc.kill(); reject(err); }); }); } async function deploy() { // 1. Убедиться, что целевая директория существует console.log("Проверка соединения..."); await ssh([`mkdir -p ${TARGET_DIR}`]); // 2. Сравнить локальные файлы с манифестом console.log("Сканирование..."); const localFiles = scanFiles(SOURCE_DIR); const manifest = loadManifest(); const changedPaths = []; for (const f of localFiles) { const prev = manifest[f.relPath]; if (!prev || prev.mtime !== f.mtime || prev.size !== f.size) { changedPaths.push(f.relPath); } } const localPaths = new Set(localFiles.map((f) => f.relPath)); const removedPaths = Object.keys(manifest).filter((p) => !localPaths.has(p)); if (changedPaths.length === 0 && removedPaths.length === 0) { console.log("Изменений нет — деплой не требуется."); return; } console.log(`Изменено/новых: ${changedPaths.length}, удалено: ${removedPaths.length}`); // 3. Загрузить изменённые файлы одним tar-пайпом if (changedPaths.length > 0) { console.log("Загрузка файлов..."); await uploadViaTar(changedPaths, SOURCE_DIR); console.log("Загрузка завершена."); } // 4. Удалить файлы, которых больше нет локально if (removedPaths.length > 0) { console.log("Удаление лишних файлов..."); const rmList = removedPaths.map((p) => `${TARGET_DIR}/${p}`).join(" "); await ssh([`rm -f ${rmList}`]); await ssh([`find ${TARGET_DIR} -type d -empty -delete 2>/dev/null || true`]); } // 5. Обновить манифест saveManifest( Object.fromEntries(localFiles.map((f) => [f.relPath, { mtime: f.mtime, size: f.size }])), ); } const relDir = path.relative(projectRoot, SOURCE_DIR); deploy() .then(() => { console.log(`Готово: ${relDir}/ → ${SSH_HOST}:${TARGET_DIR}`); }) .catch((err) => { console.error("Ошибка деплоя:", err.message || err); process.exit(1); });