실제 환경 배포 결함 수정: Agent 실행 계정 모듈 설치(module-as-user.sh), AriMail 마이그레이션 DB 경로, 도입 모듈 REVISION 버전, 기존 유닛 보존, 포털 nginx 접근 경로

- Agent: 유닛 User 가 Agent 계정과 다르면 deploy/module-as-user.sh 로 그 계정이 풀기·전환·DB 백업·마이그레이션 수행(sudoers 최소 규칙)
- AriMail 마이그레이션에 유닛 EnvironmentFile(DB_FILE) 적용
- 도입 모듈 실제 버전은 REVISION.json 의 Suite 버전, 상류 버전은 upstreamVersion
- Webmaker: keepExistingUnit 으로 운영 유닛의 강화 지시문 보존
- bootstrap-hal.sh: my.ariweb.net nginx 접근 경로 설치
- test/agent.test.mjs 회귀 테스트, VERSION_HISTORY·RUNBOOK 갱신, 로컬 E2E 보고서(릴리스 빌드 실행분) 반영

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 13:38:45 +00:00
co-authored by Claude Fable 5.1
parent 45b591b18c
commit 00c42abca1
11 changed files with 394 additions and 224 deletions
+86 -26
View File
@@ -5,29 +5,79 @@
* 절차(원본 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, renameSync, copyFileSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { userInfo } from 'node:os';
import http from 'node:http';
import { readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
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}` }; }
async function detectVersion(ctx, desc, appDir) { const cur = readLink(join(appDir, 'current')); 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 j[desc.version.field]; } catch (_) {} } const pkg = cur ? readJson(join(cur, 'package.json'), null) : null; return pkg?.version || (cur ? cur.split('/').pop() : null); }
/**
* 실제 버전 읽기. 우선순위: 건강 응답의 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 } = {}) {
const r = await ctx.sys.run('sudo', ['-n', '-u', svc.user, '/usr/bin/bash', join(ROOT, 'deploy', 'module-as-user.sh'), 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)),
});
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
async function writeUnit(ctx, desc, unit, appDir, version) {
async function writeUnit(ctx, desc, unit, appDir, version, svc) {
if (!ctx.payload.unitTemplate) return { skipped: 'no_template' };
const old = await ctx.sys.unitText(unit); 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 = (/^User=(.+)$/m.exec(old) || [])[1] || desc.user || process.env.USER || 'master'; const envFile = (/^EnvironmentFile=(.+)$/m.exec(old) || [])[1] || ''; const node = (/^ExecStart=(\S+)/m.exec(old) || [])[1] || process.execPath;
const txt = render(ctx.payload.unitTemplate, { APP_DIR: appDir, USER: user, NODE: /node$/.test(node) ? node : process.execPath, ENV_LINES: envLines, VERSION: version, UNIT: unit, ENV_FILE: envFile });
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 }) {
if (name === 'webmaker.backupData') { const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(await ctx.sys.unitText(unit)) || [])[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 }); await ctx.sys.tar(['-czf', out, '-C', dataRoot, '--exclude=./backups', '.']); 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 }; }
if (name === 'arimail.backupDb') { const db = join(appDir, 'data', 'mail-gateway.sqlite'); if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out}'`); } finally { d.close(); } return { backup: out }; }
if (name === 'arimail.migrate') { const db = join(appDir, 'data', 'mail-gateway.sqlite'); const r = await ctx.sys.run(process.execPath, [join(cur, 'server', 'db-migrate.mjs')], { allowStub: false, timeoutMs: 120000 }).catch((e) => ({ code: 1, stderr: e.message })); if (!ctx.sys.stub && !existsSync(join(cur, 'server', 'db-migrate.mjs'))) return { skipped: 'no_migrate' }; 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 }; }
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 }); await ctx.sys.tar(['-czf', out, '-C', dataRoot, '--exclude=./backups', '.']); 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 }; }
if (name === 'arimail.backupDb') {
const env = svc.asUser ? {} : readEnvFile(svc.envFile); const db = env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite'); const out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`);
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}` };
@@ -42,36 +92,46 @@ function resolveAppDir(ctx, p) {
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, unit, action, toVersion }) {
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 }; }
await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(join(appDir, 'work'), { recursive: true }); const f = join(appDir, 'work', `${module}-${version}.tar.gz`); const r = await ctx.client.download(ctx.payload.artifactUrl, f, ctx.payload.sha256 || null); return { file: f, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증');
await ctx.step('extract', async () => { rmrf(relDir); mkdirSync(relDir, { recursive: true }); await untar(ctx, join(appDir, 'work', `${module}-${version}.tar.gz`), relDir); if (desc.copyPaths && !existsSync(join(relDir, 'package.json'))) throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); writeJson(join(relDir, 'REVISION.json'), { module, version, installedAt: new Date().toISOString() }); rmSync(join(appDir, 'work', `${module}-${version}.tar.gz`), { force: true }); return { dir: relDir }; }, '릴리스 풀기');
// 실행 계정 모드에서는 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); return { file: tgz, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증');
await ctx.step('extract', async () => {
if (svc.asUser) await asUser(ctx, svc, appDir, 'extract', ['--tgz', tgz, '--dest', relDir]); else { rmrf(relDir); mkdirSync(relDir, { recursive: true }); await untar(ctx, tgz, relDir); }
if (desc.copyPaths && !existsSync(join(relDir, 'package.json'))) throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' });
await fsOps(ctx, svc, appDir).writeJson(join(relDir, 'REVISION.json'), { module, version, upstreamVersion: readJson(join(relDir, 'package.json'), {}).version || null, installedAt: new Date().toISOString(), by: svc.asUser ? svc.user : svc.me });
rmSync(tgz, { force: true }); return { dir: relDir, asUser: svc.asUser ? svc.user : null };
}, '릴리스 풀기');
return { relDir, prev };
}
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); return { module: p.module, actualVersion: await detectVersion(ctx, desc, appDir), active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
'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}`;
mkdirSync(join(appDir, 'releases'), { recursive: true });
const { relDir, prev } = await ctx.step('place', () => installRelease(ctx, { module, version, appDir, desc, unit, action, toVersion }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치');
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 }), `사전 절차 ${h}`);
await ctx.step('unit', () => writeUnit(ctx, desc, unit, appDir, version), '실행 유닛 갱신');
await ctx.step('switch', () => { switchLink(join(appDir, 'current'), relDir); writeJson(join(appDir, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { from: prev, to: relDir }; }, 'current 전환');
for (const h of desc.postStart || []) await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit }), `사후 절차 ${h}`);
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 전환');
for (const h of desc.postStart || []) await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사후 절차 ${h}`);
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 }); } };
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) { if (prev && existsSync(prev)) { switchLink(join(appDir, 'current'), prev); await ctx.sys.systemctl('restart', unit, { optional: true }); } throw Object.assign(e, { code: e.code || 'restart_failed', partial: { rolledBackTo: prev } }); }
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) { if (prev && existsSync(prev)) { switchLink(join(appDir, 'current'), prev); await ctx.sys.systemctl('restart', unit, { optional: true }); } throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); }
if (!healthRes.ok) { await revert(); throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); }
}
const actual = self ? readJson(join(relDir, 'package.json'), {}).version || version : await ctx.step('version', () => detectVersion(ctx, desc, appDir), '실제 버전 읽기');
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) rmrf(d); }
return { module, actualVersion: actual, previousVersion: prev ? prev.split('/').pop() : null, health: healthRes, active: true, restartAfterReport: self ? unit : null, releaseDir: relDir };
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 };
}
+2 -2
View File
@@ -13,10 +13,10 @@ export class NeedsUser extends Error { constructor(message, hint) { super(messag
export function createSys({ stub = process.env.ARIWEB_AGENT_STUB_CMDS === '1', log = () => {} } = {}) {
const calls = [];
/** 명령 실행. 실패해도 예외 대신 { code, stdout, stderr } 를 돌려준다(호출자가 판단). */
function run(cmd, args = [], { input = null, timeoutMs = 600000, allowStub = true } = {}) {
function run(cmd, args = [], { input = null, timeoutMs = 600000, allowStub = true, env = null } = {}) {
calls.push({ cmd, args });
if (stub && allowStub && cmd !== 'tar' && cmd !== 'node') return Promise.resolve({ code: 0, stdout: '', stderr: '', stubbed: true });
return new Promise((resolve) => { const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); let out = '', err = ''; const t = setTimeout(() => p.kill('SIGKILL'), timeoutMs); p.stdout.on('data', (c) => { out += c; }); p.stderr.on('data', (c) => { err += c; }); p.on('error', (e) => { clearTimeout(t); resolve({ code: 127, stdout: out, stderr: e.message }); }); p.on('close', (code) => { clearTimeout(t); resolve({ code, stdout: out, stderr: err }); }); if (input != null) p.stdin.end(input); else p.stdin.end(); });
return new Promise((resolve) => { const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: env || process.env }); let out = '', err = ''; const t = setTimeout(() => p.kill('SIGKILL'), timeoutMs); p.stdout.on('data', (c) => { out += c; }); p.stderr.on('data', (c) => { err += c; }); p.on('error', (e) => { clearTimeout(t); resolve({ code: 127, stdout: out, stderr: e.message }); }); p.on('close', (code) => { clearTimeout(t); resolve({ code, stdout: out, stderr: err }); }); if (input != null) p.stdin.end(input); else p.stdin.end(); });
}
const sudo = (args, o) => run('sudo', ['-n', ...args], o);
async function hasSudo() { if (stub) return true; const r = await run('sudo', ['-n', 'true']); return r.code === 0; }