Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
157 lines
21 KiB
JavaScript
157 lines
21 KiB
JavaScript
/**
|
|
* agent/jobs/module.mjs — 모듈 설치·갱신·되돌리기·건강 확인(§32·§29).
|
|
*
|
|
* 레이아웃: <appDir>/releases/<version>/ + current 심링크. 도입 모듈은 서버에 이미 있던 appDir(/srv/webmaker, /srv/arihakdang-mail …)과 유닛 이름을 facts 로 받아 그대로 쓴다.
|
|
* 절차(원본 remote-update.sh / deploy.sh 의 순서를 따른다): artifact 내려받기·sha256 확인 → releases/<ver> 에 풀기 → preStop 절차(데이터 백업) → 유닛 갱신(템플릿이 있을 때) → current 전환 → postStart 절차(마이그레이션 등) → 재시작 → 건강 확인 → 실제 버전 읽기.
|
|
* 실패하면 current 를 이전 릴리스로 되돌리고 재시작한 뒤 오류를 보고한다(명령 성공만으로 완료하지 않는다).
|
|
* 자기 자신(agent)·HAL 갱신: 결과를 먼저 보고한 뒤 재시작하도록 restartAfterReport 를 돌려준다.
|
|
*
|
|
* [실제 환경 결함 수정 0.1.1] 실행 계정이 다른 도입 모듈: 유닛의 User= 가 Agent 계정과 다르면(예: AriMail 의 arihakdang) 그 디렉터리에 Agent 가 직접 쓸 수 없다.
|
|
* 이때는 모든 파일 작업(풀기·REVISION·current 전환·정리·DB 백업·마이그레이션)을 `sudo -n -u <계정> bash deploy/module-as-user.sh …` 로 그 계정이 수행한다
|
|
* (sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용). artifact 는 Agent 의 작업 디렉터리(<srv>/agent/work)에 받아 그 계정이 읽게 한다.
|
|
* 마이그레이션은 유닛의 EnvironmentFile(DB_FILE 등)을 같은 계정으로 읽어 실행한다 — 없으면 릴리스 디렉터리 안에 빈 DB 를 만드는 잘못된 동작이 된다.
|
|
* [실제 환경 결함 수정 0.1.1] 실제 버전: 도입 모듈의 package.json 버전(Webmaker 1.7.3·AriMail 1.0.11)은 Suite 릴리스 버전(0.1.1)과 다르다.
|
|
* HAL 은 "목표 릴리스 버전 = 실제 버전" 으로 판정하므로, 풀 때 적은 REVISION.json 의 version 을 실제 버전으로 읽고 package.json 버전은 upstreamVersion 으로 함께 보고한다.
|
|
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
|
|
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
|
|
*/
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { userInfo } from 'node:os';
|
|
import http from 'node:http';
|
|
import { ROOT, readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
|
|
|
|
/** 내부 건강 확인 URL 호출 */
|
|
function httpGet(url, timeoutMs = 8000) { return new Promise((resolve) => { const t0 = Date.now(); const req = http.get(url, (res) => { let body = ''; res.on('data', (c) => { if (body.length < 100000) body += c; }); res.on('end', () => resolve({ status: res.statusCode, body, ms: Date.now() - t0 })); }); req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout'))); req.on('error', (e) => resolve({ status: 0, error: e.message, ms: Date.now() - t0 })); }); }
|
|
async function health(ctx, desc) { if (ctx.sys.stub) return { ok: true, stubbed: true }; if (!desc?.health?.url) return { ok: true, skipped: 'no_health_url' }; let last = null; for (let i = 0; i < 20; i++) { const r = await httpGet(desc.health.url); last = r; if (r.status && r.status < 500 && (desc.health.expect !== 'ok' || /"ok"\s*:\s*true|ok/.test(r.body))) return { ok: true, status: r.status, ms: r.ms }; await new Promise((s) => setTimeout(s, 1500)); } return { ok: false, status: last?.status, error: last?.error || `상태 ${last?.status}` }; }
|
|
/**
|
|
* 실제 버전 읽기. 우선순위: 건강 응답의 version(HAL·포털) → current/REVISION.json 의 Suite 릴리스 버전(도입 모듈) → package.json → 디렉터리 이름.
|
|
* 도입 모듈은 package.json 이 상류 버전이라 upstreamVersion 으로 따로 돌려준다(화면·영수증 표시용).
|
|
*/
|
|
async function detectVersion(ctx, desc, appDir) {
|
|
const cur = readLink(join(appDir, 'current')); const pkg = cur ? readJson(join(cur, 'package.json'), null) : null; const rev = cur ? readJson(join(cur, 'REVISION.json'), null) : null;
|
|
if (desc?.version?.http && !ctx.sys.stub) { const r = await httpGet(desc.version.http); try { const j = JSON.parse(r.body); if (j[desc.version.field]) return { version: j[desc.version.field], upstreamVersion: pkg?.version || null }; } catch (_) {} }
|
|
if (rev?.version) return { version: rev.version, upstreamVersion: pkg?.version && pkg.version !== rev.version ? pkg.version : null };
|
|
return { version: pkg?.version || (cur ? cur.split('/').pop() : null), upstreamVersion: null };
|
|
}
|
|
/** 유닛에서 실행 계정·EnvironmentFile·node 경로를 읽는다. 계정이 Agent 계정과 다르면 asUser 모드(파일 작업을 그 계정으로). */
|
|
async function serviceContext(ctx, unit) {
|
|
const txt = unit ? await ctx.sys.unitText(unit) : ''; const me = (() => { try { return userInfo().username; } catch (_) { return process.env.USER || ''; } })();
|
|
const user = (/^User=(.+)$/m.exec(txt) || [])[1]?.trim() || null; const envFile = ((/^EnvironmentFile=(.+)$/m.exec(txt) || [])[1] || '').replace(/^-/, '').trim() || null; const node = (/^ExecStart=(\S+)/m.exec(txt) || [])[1] || null;
|
|
return { unitText: txt, user, envFile, nodeBin: node && /node$/.test(node) ? node : null, asUser: !!(user && user !== me && user !== 'root'), me };
|
|
}
|
|
/** 실행 계정으로 파일 작업(deploy/module-as-user.sh). 실패하면 stderr 를 포함한 오류를 던진다. */
|
|
async function asUser(ctx, svc, appDir, sub, args, { input = null } = {}) {
|
|
// sudoers 규칙은 current 심링크 경로(<srv>/agent/current/deploy/module-as-user.sh)로 허용한다 — ESM 의 import.meta.url 은 실제 경로(releases/<ver>)로 풀리므로 current 경로를 우선 쓴다.
|
|
const viaCurrent = join(ctx.roots.srv, 'agent', 'current', 'deploy', 'module-as-user.sh'); const script = existsSync(viaCurrent) ? viaCurrent : join(ROOT, 'deploy', 'module-as-user.sh');
|
|
const r = await ctx.sys.run('sudo', ['-n', '-u', svc.user, '/usr/bin/bash', script, sub, '--app-dir', appDir, ...args], { input, timeoutMs: 600000 });
|
|
if (r.code !== 0) throw Object.assign(new Error(`실행 계정(${svc.user})으로 ${sub} 실패: ${(r.stderr || r.stdout || '').trim().slice(0, 300) || `코드 ${r.code}`}`), { code: r.code === 1 && /sudo|password/i.test(r.stderr || '') ? 'sudo_denied' : 'as_user_failed', hint: 'deploy/sudoers/ariweb-agent 의 module-as-user.sh 규칙이 설치되어 있어야 합니다' });
|
|
return r;
|
|
}
|
|
/** 파일 쓰기·심링크 전환·삭제를 모드(직접/실행 계정)에 맞게 수행 */
|
|
const fsOps = (ctx, svc, appDir) => ({
|
|
writeJson: async (p, v) => (svc.asUser ? asUser(ctx, svc, appDir, 'write', ['--file', p], { input: JSON.stringify(v, null, 1) }) : writeJson(p, v)),
|
|
switchLink: async (link, target) => (svc.asUser ? asUser(ctx, svc, appDir, 'switch', ['--link', link, '--target', target]) : switchLink(link, target)),
|
|
remove: async (dir) => (svc.asUser ? asUser(ctx, svc, appDir, 'remove', ['--dir', dir]) : rmrf(dir)),
|
|
rename: async (from, to) => (svc.asUser ? asUser(ctx, svc, appDir, 'rename', ['--dir', from, '--target', to]) : renameSync(from, to)),
|
|
});
|
|
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
|
|
async function writeUnit(ctx, desc, unit, appDir, version, svc) {
|
|
if (!ctx.payload.unitTemplate) return { skipped: 'no_template' };
|
|
const old = svc.unitText;
|
|
if (desc.keepExistingUnit && new RegExp(`^WorkingDirectory=${appDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/current/?$`, 'm').test(old)) return { skipped: 'existing_unit_kept', user: svc.user };
|
|
const envLines = old.split('\n').filter((l) => /^Environment=/.test(l) && !/^Environment=(NODE_ENV|HOST|PORT|ARIWEB_[A-Z_]+_DATA|NODE_OPTIONS)=/.test(l)).join('\n');
|
|
const user = svc.user || desc.user || process.env.USER || 'master'; const envFile = svc.envFile || ''; const node = svc.nodeBin || process.execPath;
|
|
const txt = render(ctx.payload.unitTemplate, { APP_DIR: appDir, USER: user, NODE: node, ENV_LINES: envLines, VERSION: version, UNIT: unit, ENV_FILE: envFile });
|
|
await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); return { written: true, preservedEnv: envLines.split('\n').filter(Boolean).length };
|
|
}
|
|
/** EnvironmentFile(KEY=값 줄) 읽기 — 직접 모드의 마이그레이션에 싣는다. 값은 돌려주되 로그에는 쓰지 않는다. */
|
|
function readEnvFile(p) { const out = {}; if (!p || !existsSync(p)) return out; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); if (!m) continue; let v = m[2].trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); out[m[1]] = v; } return out; }
|
|
/** preStop/postStart 절차 */
|
|
async function hook(ctx, name, { appDir, cur, unit, svc }) {
|
|
if (name === 'webmaker.backupData') {
|
|
const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(svc.unitText) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true });
|
|
// [실제 환경 결함 수정] 운영 데이터 디렉터리에는 다른 계정(root)이 남긴 파일이나 백업 중 바뀌는 파일이 있을 수 있다. 읽지 못한 파일은 경고로 남기고 배포를 막지 않는다(tar 종료 코드 2 = 치명적 오류만 실패).
|
|
const r = await ctx.sys.run('tar', ['-czf', out, '-C', dataRoot, '--exclude=./backups', '--ignore-failed-read', '--warning=no-file-changed', '--warning=no-file-removed', '.'], { allowStub: false });
|
|
if (r.code !== 0 && r.code !== 1) throw new Error(`데이터 백업 실패: ${(r.stderr || '').slice(0, 300)}`);
|
|
const warnings = (r.stderr || '').split('\n').filter((l) => /Cannot open|Permission denied|Cannot stat/.test(l)).map((l) => l.replace(/^tar: /, '')).slice(0, 20);
|
|
if (warnings.length) ctx.log(`데이터 백업에서 읽지 못한 파일 ${warnings.length}개(경고): ${warnings[0]}`);
|
|
const old = readdirSync(join(appDir, 'backups')).sort(); for (const f of old.slice(0, Math.max(0, old.length - 5))) rmSync(join(appDir, 'backups', f), { force: true }); return { backup: out, unreadable: warnings.length ? warnings : undefined };
|
|
}
|
|
if (name === 'arimail.backupDb') {
|
|
const env = svc.asUser ? {} : readEnvFile(svc.envFile); const db = env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite'); let out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); for (let n = 1; existsSync(out); n++) out = out.replace(/(-\d+)?\.sqlite$/, `-${n}.sqlite`); // 같은 초에 두 번 설치해도 VACUUM INTO 대상이 겹치지 않게
|
|
if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'backup-db', ['--db', db, '--out', out, ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_db' } : { backup: out, via: svc.user }; }
|
|
if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`); } finally { d.close(); } return { backup: out };
|
|
}
|
|
if (name === 'arimail.migrate') {
|
|
const script = join('server', 'db-migrate.mjs'); if (!ctx.sys.stub && !existsSync(join(cur, script))) return { skipped: 'no_migrate' };
|
|
if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'migrate', ['--cwd', cur, '--script', script, ...(svc.envFile ? ['--env-file', svc.envFile] : []), ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_migrate' } : { migrated: true, via: svc.user, envFile: svc.envFile || null }; }
|
|
const env = readEnvFile(svc.envFile); const r = await ctx.sys.run(svc.nodeBin || process.execPath, [join(cur, script)], { allowStub: false, timeoutMs: 120000, env: { ...process.env, ...env } }).catch((e) => ({ code: 1, stderr: e.message }));
|
|
if (r.code !== 0 && !ctx.sys.stub) throw Object.assign(new Error(`AriMail 마이그레이션 실패: ${(r.stderr || '').slice(0, 300)}`), { code: 'migrate_failed' }); return { migrated: true, db: env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite') };
|
|
}
|
|
if (name === 'arimail.haraka') { const r = await ctx.sys.systemctl('restart', `${unit}-haraka`, { optional: true }); return { haraka: r.code === 0 ? 'restarted' : 'skipped' }; }
|
|
if (name === 'webmaker.unitCheck') { const r = await ctx.sys.run(process.execPath, [join(cur, 'scripts', 'unit-check.js')], { allowStub: true, timeoutMs: 60000 }).catch(() => ({ code: 1 })); return { unitCheck: r.code === 0 ? 'ok' : 'warn' }; }
|
|
return { skipped: `unknown hook ${name}` };
|
|
}
|
|
/**
|
|
* [E2E 결함 수정] 설치 디렉터리 결정. HAL 이 준 appDir 가 기본 루트(/srv/ariweb/…) 아래이고 이 Agent 의 루트(ARIWEB_SRV_ROOT)가 다른 곳으로
|
|
* 재정의되어 있으면(검증 환경·일반 계정) 그 루트 아래로 옮긴다. 서버에서 발견한 기존 경로(/srv/webmaker 등)는 그대로 둔다.
|
|
*/
|
|
function resolveAppDir(ctx, p) {
|
|
const srv = ctx.roots.srv; const given = p.appDir;
|
|
if (!given) return join(srv, p.module);
|
|
if (srv !== '/srv/ariweb' && String(given).startsWith('/srv/ariweb/')) return join(srv, String(given).slice('/srv/ariweb/'.length));
|
|
return given;
|
|
}
|
|
async function installRelease(ctx, { module, version, appDir, desc, action, toVersion, svc }) {
|
|
const relDir = join(appDir, 'releases', action === 'rollback' ? (toVersion || '') : version); const prev = readLink(join(appDir, 'current'));
|
|
if (action === 'rollback') { const target = toVersion ? relDir : readJson(join(appDir, '.previous.json'), {}).previous; if (!target || !existsSync(target)) throw Object.assign(new Error('되돌릴 이전 릴리스가 없습니다'), { code: 'no_previous_release' }); return { relDir: target, prev }; }
|
|
// 실행 계정 모드에서는 appDir 에 쓸 수 없으므로 Agent 의 작업 디렉터리에 받는다(그 계정이 읽을 수 있게 0644).
|
|
const workDir = svc.asUser ? join(ctx.roots.srv, 'agent', 'work') : join(appDir, 'work'); const tgz = join(workDir, `${module}-${version}.tar.gz`);
|
|
await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(workDir, { recursive: true }); const r = await ctx.client.download(ctx.payload.artifactUrl, tgz, ctx.payload.sha256 || null); if (svc.asUser) chmodSync(tgz, 0o644); /* 내려받기는 0600 으로 쓰므로 실행 계정이 읽게 한다(artifact 는 비밀이 아니다) */ return { file: tgz, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증');
|
|
// [실제 환경 결함 수정] 같은 버전 재설치: 실행 중인 current 가 가리키는 디렉터리를 지우고 다시 풀면 실행 중인 서비스가 잠깐 코드를 잃는다(지연 require 실패) 데다 되돌릴 곳도 없다.
|
|
// 임시 디렉터리에 먼저 풀고, 기존 디렉터리는 <ver>.prev-<시각> 으로 이름을 바꿔 보존한 뒤(이것이 되돌리기 대상) 새 디렉터리를 제자리에 둔다.
|
|
let prevDir = prev;
|
|
await ctx.step('extract', async () => {
|
|
const ops = fsOps(ctx, svc, appDir); const tmp = `${relDir}.tmp`;
|
|
if (svc.asUser) await asUser(ctx, svc, appDir, 'extract', ['--tgz', tgz, '--dest', tmp]); else { rmrf(tmp); mkdirSync(tmp, { recursive: true }); await untar(ctx, tgz, tmp); }
|
|
if (desc.copyPaths && !existsSync(join(tmp, 'package.json'))) { await ops.remove(tmp).catch(() => {}); throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); }
|
|
await ops.writeJson(join(tmp, 'REVISION.json'), { module, version, upstreamVersion: readJson(join(tmp, 'package.json'), {}).version || null, installedAt: new Date().toISOString(), by: svc.asUser ? svc.user : svc.me });
|
|
let kept = null;
|
|
if (existsSync(relDir)) { if (prev && prev.replace(/\/$/, '') === relDir) { const base = `${relDir}.prev-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; kept = base; for (let n = 1; existsSync(kept); n++) kept = `${base}.${n}`; await ops.rename(relDir, kept); prevDir = kept; } else await ops.remove(relDir); }
|
|
await ops.rename(tmp, relDir);
|
|
rmSync(tgz, { force: true }); return { dir: relDir, asUser: svc.asUser ? svc.user : null, replacedRunning: kept };
|
|
}, '릴리스 풀기');
|
|
return { relDir, prev: prevDir };
|
|
}
|
|
export const kinds = {
|
|
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
|
|
'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } },
|
|
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); const v = await detectVersion(ctx, desc, appDir); return { module: p.module, actualVersion: v.version, upstreamVersion: v.upstreamVersion, active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
|
|
};
|
|
async function installOrRollback(ctx, action) {
|
|
const p = ctx.payload; const { module, version, descriptor: desc = {}, toVersion = null } = p; const appDir = resolveAppDir(ctx, p); const unit = (desc && desc.unit) || `ariweb-${module}`;
|
|
const svc = await serviceContext(ctx, unit); const ops = fsOps(ctx, svc, appDir);
|
|
if (!svc.asUser) mkdirSync(join(appDir, 'releases'), { recursive: true });
|
|
if (svc.asUser) ctx.log(`실행 계정 모드: 유닛 ${unit} 의 User=${svc.user} 로 파일 작업을 수행합니다`);
|
|
const { relDir, prev } = await ctx.step('place', () => installRelease(ctx, { module, version, appDir, desc, action, toVersion, svc }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치');
|
|
await ctx.progress(40);
|
|
for (const h of desc.preStop || []) await ctx.step(`pre:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사전 절차 ${h}`);
|
|
await ctx.step('unit', () => writeUnit(ctx, desc, unit, appDir, version, svc), '실행 유닛 갱신');
|
|
await ctx.step('switch', async () => { await ops.switchLink(join(appDir, 'current'), relDir); await ops.writeJson(join(appDir, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { from: prev, to: relDir }; }, 'current 전환');
|
|
const self = module === 'agent'; let healthRes = { ok: true };
|
|
const revert = async () => { if (prev && existsSync(prev)) { await ops.switchLink(join(appDir, 'current'), prev).catch(() => {}); await ctx.sys.systemctl('restart', unit, { optional: true }); } };
|
|
// 사후 절차(마이그레이션 등)가 실패하면 current 를 이전 릴리스로 되돌린다 — 그대로 두면 다음 재시작 때 마이그레이션 없는 새 코드가 뜬다(실제 환경 결함 수정).
|
|
for (const h of desc.postStart || []) { try { await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사후 절차 ${h}`); } catch (e) { await revert(); throw Object.assign(e, { code: e.code || 'post_hook_failed', partial: { rolledBackTo: prev, hook: h } }); } }
|
|
if (!self && desc.layout !== 'agent-feature') {
|
|
try { await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '서비스 재시작'); for (const u of desc.extraUnits || []) await ctx.sys.systemctl('restart', u, { optional: true }); }
|
|
catch (e) { await revert(); throw Object.assign(e, { code: e.code || 'restart_failed', partial: { rolledBackTo: prev } }); }
|
|
healthRes = await ctx.step('health', () => health(ctx, desc), '건강 확인');
|
|
if (!healthRes.ok) { await revert(); throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); }
|
|
}
|
|
const ver = self ? { version: readJson(join(relDir, 'package.json'), {}).version || version, upstreamVersion: null } : await ctx.step('version', () => detectVersion(ctx, desc, appDir), '실제 버전 읽기');
|
|
// 오래된 릴리스 정리(최근 3개 보존)
|
|
const rels = readdirSync(join(appDir, 'releases')).sort(); for (const r of rels.slice(0, Math.max(0, rels.length - 3))) { const d = join(appDir, 'releases', r); if (d !== relDir && d !== prev) await ops.remove(d).catch((e) => ctx.log(`오래된 릴리스 정리 실패(${r}): ${e.message}`)); }
|
|
return { module, actualVersion: ver.version, upstreamVersion: ver.upstreamVersion, previousVersion: prev ? prev.split('/').pop() : null, health: healthRes, active: true, restartAfterReport: self ? unit : null, releaseDir: relDir, runUser: svc.user || svc.me };
|
|
}
|