Files
site-portfolio/scripts/deploy-vps.mjs
T

180 lines
5.9 KiB
JavaScript

/**
* Инкрементальный деплой 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);
});