180 lines
5.9 KiB
JavaScript
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);
|
|
});
|