Files
shop10/src/services/git-deploy.js
T

301 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
const { execFile, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
function resolveShopRoot() {
const candidates = [
process.env.SHOP_ROOT,
path.resolve(__dirname, '../..'),
'/opt/shop',
].filter(Boolean);
for (const dir of candidates) {
const resolved = path.resolve(dir);
if (fs.existsSync(path.join(resolved, 'package.json'))) {
return resolved;
}
}
return null;
}
function isUpdateEnabled() {
if (process.env.ADMIN_UPDATE_ENABLED === '0') return false;
const root = resolveShopRoot();
if (!root) return false;
if (!fs.existsSync(path.join(root, '.git'))) return false;
if (process.platform === 'win32') return false;
return fs.existsSync(path.join(root, 'scripts', 'admin-web-update.sh'));
}
function gitEnv(root) {
const resolved = path.resolve(root);
return {
...process.env,
GIT_TERMINAL_PROMPT: '0',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'safe.directory',
GIT_CONFIG_VALUE_0: resolved,
};
}
async function getRepoOwner(root) {
if (process.env.SHOP_GIT_USER) {
return process.env.SHOP_GIT_USER.trim();
}
if (process.platform === 'win32') return null;
const gitPath = path.join(root, '.git');
try {
const target = fs.statSync(gitPath).isDirectory() ? gitPath : root;
const { stdout } = await execFileAsync('stat', ['-c', '%U', target], { timeout: 5000 });
const user = stdout.trim();
return user || null;
} catch {
return null;
}
}
async function ensureSafeDirectory(root, user) {
const resolved = path.resolve(root);
const targets = [{ home: process.env.HOME || '/var/www' }];
if (user) {
try {
const { stdout } = await execFileAsync('getent', ['passwd', user], { timeout: 5000 });
const home = stdout.split(':')[5];
if (home) targets.push({ home });
} catch {
/* ignore */
}
}
for (const { home } of targets) {
try {
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', resolved], {
timeout: 15000,
env: { ...process.env, HOME: home },
});
} catch {
/* ignore */
}
}
}
async function execGit(args, cwd, runAs) {
const root = path.resolve(cwd);
const gitArgs = ['-c', `safe.directory=${root}`, ...args];
const opts = {
cwd: root,
maxBuffer: 1024 * 1024,
timeout: 120000,
env: gitEnv(root),
};
if (runAs && process.env.ADMIN_UPDATE_USE_SUDO === '1') {
const { stdout, stderr } = await execFileAsync('sudo', ['-n', '-u', runAs, 'git', ...gitArgs], opts);
return `${stdout}${stderr}`.trim();
}
const { stdout, stderr } = await execFileAsync('git', gitArgs, opts);
return `${stdout}${stderr}`.trim();
}
async function gitCmd(args, cwd, { needsWrite = false } = {}) {
const root = path.resolve(cwd);
const owner = await getRepoOwner(root);
try {
return await execGit(args, root, null);
} catch (err) {
const msg = err.message || '';
const denied = /permission denied|EACCES|FETCH_HEAD/i.test(msg);
if ((needsWrite || denied) && owner) {
await ensureSafeDirectory(root, owner);
return execGit(args, root, owner);
}
throw err;
}
}
/**
* Сравнение с origin/main через ls-remote + merge-base (без fetch, без записи в .git).
*/
async function getRemoteSyncStatus(root) {
const localHead = (await gitCmd(['rev-parse', 'HEAD'], root)).split('\n')[0].trim();
const remoteOut = await gitCmd(['ls-remote', 'origin', 'refs/heads/main'], root);
const remoteSha = remoteOut.split(/\s+/)[0]?.trim();
if (!remoteSha) {
throw new Error('Не найден refs/heads/main на origin');
}
const remoteShort = (
await gitCmd(['rev-parse', '--short', remoteSha], root)
).split('\n')[0].trim();
if (remoteSha === localHead) {
return { behind: 0, ahead: 0, diverged: false, remoteShort, remoteSha };
}
let mergeBase;
try {
mergeBase = (await gitCmd(['merge-base', localHead, remoteSha], root)).split('\n')[0].trim();
} catch {
throw new Error(
'Не удалось сравнить с origin/main (нет общего предка). Выполните на сервере: git fetch && git reset --hard origin/main'
);
}
if (!mergeBase) {
throw new Error('Нет общего предка с origin/main');
}
let behind = 0;
let ahead = 0;
if (mergeBase !== remoteSha) {
const behindStr = await gitCmd(['rev-list', '--count', `${mergeBase}..${remoteSha}`], root);
behind = parseInt(behindStr, 10) || 0;
}
if (mergeBase !== localHead) {
const aheadStr = await gitCmd(['rev-list', '--count', `${mergeBase}..${localHead}`], root);
ahead = parseInt(aheadStr, 10) || 0;
}
return {
behind,
ahead,
diverged: behind > 0 && ahead > 0,
remoteShort,
remoteSha,
};
}
async function getGitInfo({ fetchRemote = false } = {}) {
const root = resolveShopRoot();
const pkg = root ? JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) : null;
const repoOwner = root ? await getRepoOwner(root) : null;
if (!root || !fs.existsSync(path.join(root, '.git'))) {
return {
available: false,
packageVersion: pkg?.version || null,
shopRoot: root,
reason: 'Каталог не является git-репозиторием. Задайте SHOP_ROOT в .env.',
};
}
const info = {
available: true,
shopRoot: root,
repoOwner,
packageVersion: pkg?.version || null,
branch: null,
commit: null,
commitShort: null,
commitSubject: null,
commitDate: null,
dirty: false,
dirtyHint: null,
behind: null,
ahead: null,
diverged: false,
remoteShort: null,
updateEnabled: isUpdateEnabled(),
platform: process.platform,
};
try {
await ensureSafeDirectory(root, repoOwner);
info.branch = await gitCmd(['branch', '--show-current'], root);
if (!info.branch) {
info.branch = '(detached)';
info.commitShort = await gitCmd(['rev-parse', '--short', 'HEAD'], root);
}
info.commit = await gitCmd(['rev-parse', 'HEAD'], root);
info.commitShort = info.commitShort || (await gitCmd(['rev-parse', '--short', 'HEAD'], root));
info.commitSubject = await gitCmd(['log', '-1', '--pretty=%s'], root);
info.commitDate = await gitCmd(['log', '-1', '--pretty=%ci'], root);
try {
const status = await gitCmd(['status', '--porcelain'], root);
info.dirty = status.length > 0;
if (info.dirty) {
info.dirtyHint =
'На сервере есть локальные изменения. Обновление может их перезаписать; при ошибке выполните git stash или git reset --hard с машины администратора.';
}
} catch {
info.dirty = null;
info.dirtyHint = 'Не удалось прочитать статус (права на .git).';
}
} catch (err) {
info.available = false;
info.reason = err.message;
return info;
}
if (fetchRemote) {
try {
const sync = await getRemoteSyncStatus(root);
info.behind = sync.behind;
info.ahead = sync.ahead;
info.diverged = sync.diverged;
info.remoteShort = sync.remoteShort;
} catch (err) {
info.fetchError = err.message;
if (repoOwner) {
info.fetchError += ` Владелец репозитория: ${repoOwner}. Задайте SHOP_GIT_USER=${repoOwner} и ADMIN_UPDATE_USE_SUDO=1 в .env.`;
}
}
}
return info;
}
function runDeployUpdate() {
const root = resolveShopRoot();
const scriptPath = path.join(root, 'scripts', 'admin-web-update.sh');
return new Promise((resolve) => {
const useSudo = process.env.ADMIN_UPDATE_USE_SUDO === '1';
const cmd = useSudo ? 'sudo' : 'bash';
const args = useSudo ? ['-n', scriptPath] : [scriptPath];
const child = spawn(cmd, args, {
cwd: root,
env: { ...gitEnv(root), SHOP_ROOT: root },
timeout: 300000,
});
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk.toString();
});
child.stderr.on('data', (chunk) => {
output += chunk.toString();
});
child.on('error', (err) => {
resolve({
ok: false,
code: -1,
output: `${output}\n${err.message}`.trim(),
});
});
child.on('close', (code) => {
resolve({
ok: code === 0,
code: code ?? 1,
output: output.trim() || '(нет вывода)',
});
});
});
}
module.exports = {
resolveShopRoot,
isUpdateEnabled,
getGitInfo,
runDeployUpdate,
getRepoOwner,
};