HAL 제어 평면(릴리스·서버·Agent·작업·모듈 배포·운영자 계정·긴급 복구·재해복구·공개 상태), Node Agent, 배포 스크립트·유닛·nginx 템플릿, 릴리스 도구, 포털 HAL 클라이언트 (§28·§29·§32~§34)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
403d0fb845
commit
6b5a014041
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* agent/client.mjs — HAL 호출(서명·시계 차이·artifact 내려받기·전송 올리기/내려받기).
|
||||
* 모든 요청은 lib/serviceauth 로 서명한다(keyId 'agent:<id>'). 응답의 Date/X-HAL-Time 으로 시계 차이를 재어 heartbeat 에 싣는다.
|
||||
* 큰 파일 업로드는 본문 해시를 서명에 넣지 않고(빈 본문 서명) X-Transfer-Sha256 헤더로 무결성을 전한다(HAL 이 받은 뒤 다시 계산해 비교).
|
||||
*/
|
||||
import { signHeaders } from '../lib/serviceauth.mjs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createWriteStream, createReadStream, statSync } from 'node:fs';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
export function createClient({ halUrl, agentId, secret, fetchImpl = fetch }) {
|
||||
const base = String(halUrl).replace(/\/$/, ''); const keyId = `agent:${agentId}`; let clockSkewMs = 0;
|
||||
function skew(r) { const t = Number(r.headers.get('x-hal-time') || 0) || Date.parse(r.headers.get('date') || ''); if (t) clockSkewMs = Date.now() - t; }
|
||||
async function request(method, path, body, { timeoutMs = 30000 } = {}) {
|
||||
const text = body == null ? undefined : JSON.stringify(body);
|
||||
const r = await fetchImpl(base + path, { method, headers: signHeaders(keyId, secret, method, path, text), body: text, signal: AbortSignal.timeout(timeoutMs) }); skew(r);
|
||||
let json = null; try { json = await r.json(); } catch (_) {}
|
||||
if (!r.ok || (json && json.ok === false)) { const e = new Error(`HAL ${method} ${path} → ${r.status} ${json?.error || ''} ${json?.message || ''}`.trim()); e.status = r.status; e.code = json?.error || `http_${r.status}`; throw e; }
|
||||
return json;
|
||||
}
|
||||
/** 파일 내려받기 + sha256 검증(expected 가 있으면) */
|
||||
async function download(path, dest, expected = null) {
|
||||
const r = await fetchImpl(base + path, { headers: signHeaders(keyId, secret, 'GET', path, undefined), signal: AbortSignal.timeout(3600000) }); skew(r);
|
||||
if (!r.ok) { const e = new Error(`HAL 내려받기 실패 ${path} → ${r.status}`); e.code = `http_${r.status}`; throw e; }
|
||||
const hash = createHash('sha256'); const src = Readable.fromWeb(r.body); src.on('data', (c) => hash.update(c)); await pipeline(src, createWriteStream(dest, { mode: 0o600 }));
|
||||
const sum = hash.digest('hex'); const want = String(expected || r.headers.get('x-sha256') || '').replace(/^sha256:/i, '').toLowerCase(); if (want && want !== sum) throw Object.assign(new Error(`내려받은 파일 해시가 다릅니다(기대 ${want.slice(0, 12)}… 실제 ${sum.slice(0, 12)}…)`), { code: 'checksum_mismatch' });
|
||||
return { sha256: sum, size: statSync(dest).size };
|
||||
}
|
||||
/** 전송 올리기(스트리밍). 반환 { id, sha256, size } */
|
||||
async function upload(transferId, file, { purpose = '' } = {}) {
|
||||
const hash = createHash('sha256'); for await (const c of createReadStream(file)) hash.update(c); const sum = hash.digest('hex'); const size = statSync(file).size;
|
||||
const path = `/agent/v1/transfers/${transferId}${purpose ? `?purpose=${encodeURIComponent(purpose)}` : ''}`;
|
||||
const r = await fetchImpl(base + path, { method: 'POST', headers: { ...signHeaders(keyId, secret, 'POST', path, ''), 'Content-Type': 'application/octet-stream', 'Content-Length': String(size), 'X-Transfer-Sha256': sum }, body: createReadStream(file), duplex: 'half', signal: AbortSignal.timeout(3600000) }); skew(r);
|
||||
const json = await r.json().catch(() => null); if (!r.ok) throw Object.assign(new Error(`전송 올리기 실패 ${r.status} ${json?.error || ''}`), { code: json?.error || `http_${r.status}` });
|
||||
return { id: transferId, sha256: sum, size };
|
||||
}
|
||||
const downloadTransfer = (id, dest, expected = null) => download(`/agent/v1/transfers/${id}`, dest, expected);
|
||||
return { request, download, upload, downloadTransfer, get clockSkewMs() { return clockSkewMs; }, agentId, base };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* agent/config.mjs — Node Agent 설정과 경로.
|
||||
* 데이터 디렉터리(기본 /srv/ariweb/agent/data)의 agent.json 에 id·HAL 주소·역할·사이트(외부 모드)를 두고, 비밀은 같은 디렉터리의 보관소(lib/vault)에 'agent-secret' 로 둔다.
|
||||
* 실제 시스템 경로는 환경 변수로 바꿀 수 있어 테스트는 임시 디렉터리에서 돈다.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { openVault } from '../lib/vault.mjs';
|
||||
|
||||
export function loadAgentConfig(env = process.env) {
|
||||
const dataDir = env.ARIWEB_AGENT_DATA || '/srv/ariweb/agent/data'; mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
||||
const file = join(dataDir, 'agent.json'); let saved = {}; try { saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}; } catch (_) {}
|
||||
const cfg = {
|
||||
dataDir, file, ...saved,
|
||||
roots: {
|
||||
sites: env.ARIWEB_SITES_ROOT || '/var/www/ariweb-sites', // 사이트 파일: <sites>/<siteId>/releases/<n>, current, data(uploads)
|
||||
srv: env.ARIWEB_SRV_ROOT || '/srv/ariweb', // 모듈 설치: <srv>/<module>/releases/<ver>, current
|
||||
nginx: env.ARIWEB_NGINX_DIR || '/etc/nginx', // sites-available/sites-enabled
|
||||
backups: env.ARIWEB_BACKUP_ROOT || '/srv/ariweb/backups', // 백업 저장소
|
||||
logs: env.ARIWEB_LOG_DIR || '/var/log/nginx', // 사이트별 접속 로그 aw-<siteId>.access.log
|
||||
acme: env.ARIWEB_ACME_WEBROOT || '/var/www/letsencrypt', // certbot webroot
|
||||
letsencrypt: env.ARIWEB_LE_DIR || '/etc/letsencrypt/live',
|
||||
wildcardCert: env.ARIWEB_WILDCARD_CERT || '', // 플랫폼 와일드카드 인증서 디렉터리(예: /etc/letsencrypt/live/wh-wild-ariweb.net)
|
||||
platformDomain: env.ARIWEB_PLATFORM_DOMAIN || 'ariweb.net',
|
||||
maintPage: env.ARIWEB_MAINT_PAGE || '/var/www/ariweb-maint.html',
|
||||
},
|
||||
heartbeatMs: Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000),
|
||||
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label']) if (cfg[k] !== undefined) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
|
||||
vault: () => openVault(dataDir),
|
||||
};
|
||||
return cfg;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* agent/index.mjs — Node Agent 진입점·CLI(§5·§28.2·§10.2).
|
||||
*
|
||||
* node agent/index.mjs register --hal <url> --token <t> [--role web|mail|backup|control] [--external --site <siteId>] [--label <이름>]
|
||||
* node agent/index.mjs run 서비스 루프: heartbeat(30초) + 작업 long-poll + 재시작 뒤 미완료 작업 재개
|
||||
* node agent/index.mjs status 등록 상태·HAL 연결 확인
|
||||
* 비밀(Agent secret)은 데이터 디렉터리의 보관소에만 둔다. 등록 응답의 비밀은 그 자리에서 저장하고 화면에 보이지 않는다.
|
||||
*/
|
||||
import { loadAgentConfig } from './config.mjs';
|
||||
import { createClient } from './client.mjs';
|
||||
import { createSys } from './sys.mjs';
|
||||
import { createRunner } from './runner.mjs';
|
||||
import { KINDS } from './jobs/index.mjs';
|
||||
import { sample, facts } from './metrics.mjs';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { hostname } from 'node:os';
|
||||
|
||||
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
||||
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||
const args = process.argv.slice(2); const cmd = args[0] || 'run'; const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
|
||||
const cfg = loadAgentConfig(); const sys = createSys({ log });
|
||||
|
||||
if (cmd === 'register') {
|
||||
const hal = opt('hal'); const token = opt('token'); if (!hal || !token) { console.error('--hal 과 --token 이 필요합니다'); process.exit(1); }
|
||||
const f = facts(sys); f.role = opt('role', 'web'); f.location = opt('location', '');
|
||||
const r = await fetch(hal.replace(/\/$/, '') + '/agent/v1/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, facts: f, version: VERSION }) }); const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !j.ok) { console.error(`등록 실패: ${j.error || r.status} ${j.message || ''}`); process.exit(1); }
|
||||
cfg.vault().set('agent-secret', j.secret, { kind: 'agent-secret' }); cfg.save({ id: j.agentId, halUrl: hal, serverId: j.serverId || null, kind: j.kind, siteId: j.siteId || null, groupId: j.groupId || null, registeredAt: new Date().toISOString(), label: opt('label', hostname()) });
|
||||
console.log(`등록 완료: agent ${j.agentId} (${j.kind}${j.serverId ? `, 서버 ${j.serverId}` : ''}). 비밀은 ${cfg.dataDir} 의 보관소에 저장했습니다. 운영자 승인 뒤 배치가 가능합니다`);
|
||||
} else if (cmd === 'status') {
|
||||
if (!cfg.id) { console.log('등록되지 않음'); process.exit(1); } const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
|
||||
try { const r = await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: {}, clockSkewMs: 0 }); console.log(`연결 정상: agent ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}, 대기 작업 ${r.pendingJobs}, 시계 차이 ${client.clockSkewMs}ms`); } catch (e) { console.error(`HAL 연결 실패: ${e.message}`); process.exit(1); }
|
||||
} else if (cmd === 'run') {
|
||||
if (!cfg.id) { console.error('등록되지 않았습니다 — register 먼저'); process.exit(1); }
|
||||
const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
|
||||
const runner = createRunner({ kinds: KINDS, roots: cfg.roots, sys, client, dataDir: cfg.dataDir, log });
|
||||
let stopping = false; let running = 0; let lastFacts = null; let restartUnit = null;
|
||||
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/package.json 버전 */
|
||||
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); const cur = join(appDir, 'current', 'package.json'); let version = null; try { version = JSON.parse(readFileSync(cur, 'utf8')).version; } catch (_) {} const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
|
||||
async function heartbeat() { try { if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: facts(sys) }; const sites = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))).length : 0; await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: lastFacts.f, sitesHosted: sites, jobs: { running } }); } catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } }
|
||||
async function execute(job) { running++; try { const out = await runner.execute(job); if (out?.result?.restartAfterReport) restartUnit = out.result.restartAfterReport; } finally { running--; } if (restartUnit) { const u = restartUnit; restartUnit = null; log(`[agent] 자기 갱신 — ${u} 재시작`); setTimeout(() => sys.systemctl('restart', u, { optional: true }), 1500); } }
|
||||
// 재시작 뒤: 보고 못 한 결과 전송, 미완료 작업 재개
|
||||
await runner.flushPending(); try { const u = await client.request('GET', '/agent/v1/jobs/unfinished'); for (const j of u.jobs || []) { log(`[agent] 미완료 작업 재개 ${j.id} ${j.kind}`); execute(j); } } catch (e) { log(`[agent] 미완료 작업 조회 실패: ${e.message}`); }
|
||||
await heartbeat(); const hb = setInterval(heartbeat, cfg.heartbeatMs);
|
||||
process.on('SIGTERM', () => { stopping = true; clearInterval(hb); });
|
||||
log(`[agent] Ariweb Node Agent v${VERSION} ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}`);
|
||||
while (!stopping) { try { const r = await client.request('GET', '/agent/v1/jobs/next?wait=25', undefined, { timeoutMs: 40000 }); if (r.job) execute(r.job); } catch (e) { log(`[agent] 작업 조회 실패: ${e.message}`); await new Promise((s) => setTimeout(s, 5000)); } }
|
||||
process.exit(0);
|
||||
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* agent/jobs/backup.mjs — 관리형 사이트 백업·복원(§19·§20).
|
||||
*
|
||||
* 백업 묶음(tar.gz) 구조: files/(현재 배포본) data/(uploads 등 영속 데이터) db/(SQLite 는 VACUUM INTO 일관 사본; 그 밖의 DB 는 payload.db.dumpCommand 가 있을 때만) config/(vhost·버전 정보·ariweb.json) meta.json
|
||||
* 메일 원문·소스 저장소·Webmaker 이력은 포함하지 않는다(§19.1). 만든 뒤 목록을 읽어 검증하고 sha256 을 돌려준다.
|
||||
* 저장소: <backups>/<siteId>/<backupId>.tar.gz. 운영 서버와 다른 저장 서버로 보내야 하면 uploadToHal 로 HAL 전송에 올린다(backup.store 가 저장 서버에서 받는다).
|
||||
* 복원: 보호 시점(현재 상태 백업) → 격리 디렉터리에서 풀어 확인 → 새 릴리스로 배치·데이터 교체(이전 데이터는 .pre-restore 로 보존) → current 전환 → 내부 확인.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readdirSync, renameSync, copyFileSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { join, basename, dirname } from 'node:path';
|
||||
import { siteDir, assertSiteId, readLink, switchLink, tarDir, untar, tarList, sha256File, rmrf, readJson, writeJson, internalCheck, fmtSum, normSum } from './common.mjs';
|
||||
import { configureVhost } from './site.mjs';
|
||||
|
||||
const stamp = () => new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
||||
const backupDir = (ctx, siteId) => join(ctx.roots.backups, assertSiteId(siteId));
|
||||
/** SQLite 일관 사본 */
|
||||
async function copySqlite(src, dest) { const { DatabaseSync } = await import('node:sqlite'); const db = new DatabaseSync(src, { readOnly: true }); try { db.exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`); } finally { db.close(); } }
|
||||
/** 현재 상태를 백업 파일로 묶는다 */
|
||||
export async function makeBackup(ctx, siteId, { backupId, scope = {}, outDir, db = null, tag = null }) {
|
||||
const sd = siteDir(ctx.roots, siteId); const cur = readLink(join(sd, 'current')); const work = join(sd, 'work', `bk-${backupId}`); rmrf(work); mkdirSync(work, { recursive: true });
|
||||
const included = {};
|
||||
if (scope.files !== false && cur && existsSync(cur)) { mkdirSync(join(work, 'files'), { recursive: true }); await ctx.sys.tar(['-cf', join(work, 'files.tar'), '-C', cur, '.']); included.files = true; }
|
||||
if (scope.uploads !== false && existsSync(join(sd, 'data'))) { await ctx.sys.tar(['-cf', join(work, 'data.tar'), '-C', join(sd, 'data'), '.']); included.data = true; }
|
||||
if (scope.db !== false) { mkdirSync(join(work, 'db'), { recursive: true }); const dbs = []; const dbDir = join(sd, 'data', 'db'); if (existsSync(dbDir)) for (const f of readdirSync(dbDir).filter((x) => /\.(sqlite|db)$/.test(x))) { await copySqlite(join(dbDir, f), join(work, 'db', f)); dbs.push(f); } if (db?.sqlite && existsSync(db.sqlite)) { await copySqlite(db.sqlite, join(work, 'db', basename(db.sqlite))); dbs.push(basename(db.sqlite)); } included.db = dbs; }
|
||||
if (scope.config !== false) { mkdirSync(join(work, 'config'), { recursive: true }); const vh = join(ctx.roots.nginx, 'sites-available', `aw-${siteId}.conf`); if (existsSync(vh)) copyFileSync(vh, join(work, 'config', 'vhost.conf')); if (cur) for (const f of ['.ariweb-version.json', 'ariweb.json']) if (existsSync(join(cur, f))) copyFileSync(join(cur, f), join(work, 'config', f)); included.config = true; }
|
||||
writeJson(join(work, 'meta.json'), { backupId, siteId, createdAt: new Date().toISOString(), version: cur ? cur.split('/').pop() : null, included, tag, excluded: ['mail', 'source_repo', 'webmaker_history', 'external_deploy_data'] });
|
||||
mkdirSync(outDir, { recursive: true }); const out = join(outDir, `${backupId}.tar.gz`); const t = await tarDir(ctx, out, work); rmrf(work);
|
||||
const list = await tarList(ctx, out); if (!list.some((x) => x.endsWith('meta.json'))) throw Object.assign(new Error('백업 파일을 읽을 수 없습니다'), { code: 'backup_unreadable' });
|
||||
return { backupId, path: out, sizeBytes: t.size, checksum: t.sha256, createdAt: new Date().toISOString(), scope: included };
|
||||
}
|
||||
export const kinds = {
|
||||
'backup.create': { title: '백업 생성', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const backupId = p.backupId || `bk-${stamp()}-${siteId.slice(0, 8)}`; const r = await ctx.step('create', () => makeBackup(ctx, siteId, { backupId, scope: p.scope || {}, outDir: backupDir(ctx, siteId), db: p.db || null, tag: p.retentionTag || null }), '백업 생성'); if (p.uploadToHal) { const up = await ctx.step('upload', () => ctx.client.upload(p.transferId || `tr_bk_${backupId}`, r.path, { purpose: 'backup' }), 'HAL 전송에 올리기'); r.transferId = up.id; r.uploaded = true; if (!p.keepLocalCopy) rmrf(r.path); } return r; } },
|
||||
'backup.store': { title: '백업 저장소에 받기', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const out = join(backupDir(ctx, siteId), `${p.backupId}.tar.gz`); mkdirSync(dirname(out), { recursive: true }); const dl = await ctx.step('download', () => ctx.client.downloadTransfer(p.transferId, out, p.sha256 || null), '받기'); const list = await tarList(ctx, out); if (!list.some((x) => x.endsWith('meta.json'))) throw Object.assign(new Error('받은 백업을 읽을 수 없습니다'), { code: 'backup_unreadable' }); return { path: out, sizeBytes: dl.size, checksum: fmtSum(dl.sha256), verified: true }; } },
|
||||
'backup.verify': { title: '백업 검증', async run(ctx) { const p = ctx.payload; if (!p.location || !existsSync(p.location)) return { ok: false, reason: 'missing' }; const sum = sha256File(p.location); const list = await tarList(ctx, p.location).catch(() => []); const ok = (!p.checksum || normSum(p.checksum) === sum) && list.some((x) => x.endsWith('meta.json')); return { ok, checksum: fmtSum(sum), entries: list.length, reason: ok ? null : p.checksum && normSum(p.checksum) !== sum ? 'checksum_mismatch' : 'unreadable' }; } },
|
||||
'backup.fetch': { title: '백업 저장소에서 보내기', async run(ctx) { const p = ctx.payload; if (!existsSync(p.location)) throw Object.assign(new Error('백업 파일이 없습니다'), { code: 'backup_missing' }); if (p.checksum && sha256File(p.location) !== normSum(p.checksum)) throw Object.assign(new Error('백업 파일 해시가 다릅니다'), { code: 'checksum_mismatch' }); const up = await ctx.step('upload', () => ctx.client.upload(p.transferId, p.location, { purpose: 'restore' }), '올리기'); return { transferId: up.id, checksum: fmtSum(up.sha256), sizeBytes: up.size }; } },
|
||||
'backup.delete': { title: '백업 삭제', async run(ctx) { const p = ctx.payload; const ok = existsSync(p.location) && p.location.startsWith(ctx.roots.backups); if (ok) rmrf(p.location); return { deleted: ok, location: p.location }; } },
|
||||
'backup.restore': { title: '백업 복원', async run(ctx) {
|
||||
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const work = join(sd, 'work', `rs-${stamp()}`); mkdirSync(work, { recursive: true });
|
||||
const src = await ctx.step('fetch', async () => { if (p.transferId) { const f = join(work, 'backup.tar.gz'); await ctx.client.downloadTransfer(p.transferId, f, p.checksum || null); return f; } if (!p.location || !existsSync(p.location)) throw Object.assign(new Error('복원할 백업이 없습니다'), { code: 'backup_missing' }); if (p.checksum && sha256File(p.location) !== normSum(p.checksum)) throw Object.assign(new Error('백업 해시가 다릅니다'), { code: 'checksum_mismatch' }); return p.location; }, '백업 가져오기');
|
||||
const protect = await ctx.step('protect', async () => { if (p.protectPoint === false) return null; const r = await makeBackup(ctx, siteId, { backupId: `protect-${stamp()}`, outDir: join(backupDir(ctx, siteId), 'protect'), tag: 'protect' }); return r.path; }, '현재 상태 보호 시점 만들기');
|
||||
const iso = await ctx.step('isolated', async () => { const dir = join(work, 'iso'); await untar(ctx, src, dir); const meta = readJson(join(dir, 'meta.json'), null); if (!meta || meta.siteId !== siteId) throw Object.assign(new Error('백업의 사이트 정보가 다릅니다'), { code: 'backup_site_mismatch' }); if (existsSync(join(dir, 'files.tar'))) { mkdirSync(join(dir, 'files'), { recursive: true }); await ctx.sys.tar(['-xf', join(dir, 'files.tar'), '-C', join(dir, 'files')]); } if (existsSync(join(dir, 'data.tar'))) { mkdirSync(join(dir, 'data'), { recursive: true }); await ctx.sys.tar(['-xf', join(dir, 'data.tar'), '-C', join(dir, 'data')]); } const files = existsSync(join(dir, 'files')) ? readdirSync(join(dir, 'files')).length : 0; if (!files) throw Object.assign(new Error('백업에 사이트 파일이 없습니다'), { code: 'backup_empty' }); return { dir, meta, files }; }, '격리된 환경에서 백업 확인');
|
||||
await ctx.progress(50);
|
||||
const placed = await ctx.step('restore', async () => { const rel = join(sd, 'releases', `${stamp()}-restore`); mkdirSync(join(sd, 'releases'), { recursive: true }); renameSync(join(iso.dir, 'files'), rel); writeJson(join(rel, '.ariweb-version.json'), { version: rel.split('/').pop(), restoredFrom: iso.meta.backupId, restoredAt: new Date().toISOString(), originalVersion: iso.meta.version }); if (existsSync(join(iso.dir, 'data'))) { const old = join(sd, 'data'); if (existsSync(old)) renameSync(old, join(sd, `data.pre-restore-${stamp()}`)); renameSync(join(iso.dir, 'data'), old); } const dbSrc = join(iso.dir, 'db'); if (existsSync(dbSrc)) { mkdirSync(join(sd, 'data', 'db'), { recursive: true }); for (const f of readdirSync(dbSrc)) { const dest = p.db?.sqlite && basename(p.db.sqlite) === f ? p.db.sqlite : join(sd, 'data', 'db', f); if (existsSync(dest)) copyFileSync(dest, dest + `.pre-restore-${stamp()}`); copyFileSync(join(dbSrc, f), dest); } } const prev = readLink(join(sd, 'current')); switchLink(join(sd, 'current'), rel); writeJson(join(sd, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { rel, prev }; }, '데이터 복원');
|
||||
const meta = readJson(join(placed.rel, 'ariweb.json'), {}); if (meta.runtime === 'node') await ctx.sys.systemctl('restart', `ariweb-site-${siteId}`, { optional: true }); else await ctx.sys.nginxReload().catch(() => {});
|
||||
const ic = await ctx.step('check', () => internalCheck(ctx, siteId, (p.domains && p.domains[0]) || `${siteId}.preview.${ctx.roots.platformDomain}`), '사이트 기능 확인');
|
||||
rmrf(work);
|
||||
if (!ic.ok) { if (placed.prev && existsSync(placed.prev)) switchLink(join(sd, 'current'), placed.prev); throw Object.assign(new Error('복원 뒤 내부 확인 실패 — 이전 상태로 되돌렸습니다'), { code: 'internal_check_failed', partial: { protectPointLocation: protect, internalCheck: ic } }); }
|
||||
return { protectPointLocation: protect, restoredVersion: placed.rel.split('/').pop(), isolatedVerify: true, internalCheck: ic, restoredFrom: iso.meta.backupId };
|
||||
} },
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* agent/jobs/common.mjs — 사이트 작업 공통: 경로·템플릿·vhost 생성·내부 확인·tar·체크섬.
|
||||
* 사이트 레이아웃: <sites>/<siteId>/releases/<버전디렉터리>, current(심링크), data/(uploads·db 등 영속 데이터), .maintenance / .limited 표식 파일.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, symlinkSync, unlinkSync, renameSync, rmSync, lstatSync, readlinkSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
export const SITE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}$/;
|
||||
export function assertSiteId(id) { if (!SITE_ID_RE.test(String(id || ''))) throw Object.assign(new Error(`사이트 id 형식이 올바르지 않습니다: ${id}`), { code: 'bad_site_id' }); return id; }
|
||||
export const siteDir = (roots, siteId) => join(roots.sites, assertSiteId(siteId));
|
||||
export const tmpl = (name) => readFileSync(join(ROOT, 'deploy', 'nginx', name), 'utf8');
|
||||
export const render = (t, vars) => t.replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] === undefined || vars[k] === null ? '' : String(vars[k])));
|
||||
export function sha256File(file) { const h = createHash('sha256'); h.update(readFileSync(file)); return h.digest('hex'); }
|
||||
/**
|
||||
* 체크섬 표기: 결과·payload 에 넣는 해시는 'sha256:<hex>' 형식으로 쓴다. 긴 hex 그대로 두면 lib/redact 가 비밀값으로 보고 '[redacted]' 로 가려
|
||||
* 포털·HAL 이 검증에 쓸 수 없기 때문이다. 비교할 때는 normSum 으로 접두어·대소문자를 벗긴다(두 형식 모두 허용).
|
||||
*/
|
||||
export const fmtSum = (hex) => (hex ? `sha256:${String(hex).replace(/^sha256:/i, '').toLowerCase()}` : null);
|
||||
export const normSum = (v) => (v ? String(v).replace(/^sha256:/i, '').toLowerCase() : null);
|
||||
export function dirSize(dir) { let n = 0; if (!existsSync(dir)) return 0; for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isSymbolicLink()) continue; if (e.isDirectory()) n += dirSize(p); else n += statSync(p).size; } return n; }
|
||||
/** 심링크 원자 교체 */
|
||||
export function switchLink(linkPath, target) { const tmp = `${linkPath}.tmp-${process.pid}`; try { unlinkSync(tmp); } catch (_) {} symlinkSync(target, tmp); renameSync(tmp, linkPath); }
|
||||
export function readLink(linkPath) { try { return lstatSync(linkPath).isSymbolicLink() ? readlinkSync(linkPath) : null; } catch (_) { return null; } }
|
||||
export const MAINT_HTML = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>점검 중</title></head><body style="font-family:sans-serif;text-align:center;padding:60px"><h1>사이트 점검 중입니다</h1><p>잠시 후 다시 접속해 주세요.</p><p style="color:#888;font-size:12px">supported by ariweb.net</p></body></html>';
|
||||
export const LIMIT_HTML = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>이용 제한</title></head><body style="font-family:sans-serif;text-align:center;padding:60px"><h1>월간 전송량 한도에 도달했습니다</h1><p>다음 달(KST 1일) 또는 추가 용량 적용 뒤 다시 제공됩니다.</p><p style="color:#888;font-size:12px">supported by ariweb.net</p></body></html>';
|
||||
const esc1 = (s) => s.replace(/'/g, ''');
|
||||
/** 사이트 vhost 설정 문자열 만들기 */
|
||||
export function renderVhost({ roots, siteId, domains = [], primaryDomain = null, runtime = 'static', port = null, certDir = null, redirectOld = [] }) {
|
||||
const sd = siteDir(roots, siteId); const names = domains.length ? domains : [`${siteId}.preview.${roots.platformDomain}`];
|
||||
const body = render(tmpl(runtime === 'node' ? 'site-body-node.tmpl' : 'site-body-static.tmpl'), { ROOT: join(sd, 'current'), SITE_DIR: sd, LOG: join(roots.logs, `aw-${siteId}.access.log`), MAINT_HTML: esc1(MAINT_HTML), LIMIT_HTML: esc1(LIMIT_HTML), PORT: port || 0 });
|
||||
const https = certDir ? render(tmpl('site-https.tmpl'), { SERVER_NAMES: names.join(' '), CERT_DIR: certDir, BODY: body }) : '';
|
||||
const httpBody = certDir ? ` location / { return 301 https://${primaryDomain || names[0]}$request_uri; }` : body;
|
||||
let out = render(tmpl('site.conf.tmpl'), { SITE_ID: siteId, DOMAIN_LIST: names.join(', '), GENERATED_AT: new Date().toISOString(), SERVER_NAMES: names.join(' '), ACME_ROOT: roots.acme, HTTP_BODY: httpBody, HTTPS_SERVER: https });
|
||||
if (redirectOld.length && primaryDomain) out += '\n' + render(tmpl('redirect.tmpl'), { OLD_NAMES: redirectOld.join(' '), ACME_ROOT: roots.acme, PRIMARY: primaryDomain });
|
||||
return out;
|
||||
}
|
||||
/** 와일드카드 인증서 적용 가능 여부: 모든 도메인이 플랫폼 도메인의 1단계 하위이고 인증서 디렉터리가 있을 때 */
|
||||
export function wildcardCertFor(roots, domains) { if (!roots.wildcardCert || !domains.length) return null; const pd = roots.platformDomain.toLowerCase(); const ok = domains.every((d) => { const x = String(d).toLowerCase(); return x.endsWith('.' + pd) && !x.slice(0, -pd.length - 1).includes('.'); }); return ok && existsSync(join(roots.wildcardCert, 'fullchain.pem')) ? roots.wildcardCert : null; }
|
||||
/** 사이트별 인증서 디렉터리(certbot --cert-name aw-<siteId>) */
|
||||
export function ownCertFor(roots, siteId) { const p = join(roots.letsencrypt, `aw-${siteId}`); return existsSync(join(p, 'fullchain.pem')) ? p : null; }
|
||||
/** 인증서 만료 읽기(openssl 없이: PEM 의 notAfter 는 파싱이 번거로워 파일 mtime+90일로 추정, 정확한 값은 domain.cert 결과가 준다) */
|
||||
export function certExpiry(certDir) { try { const st = statSync(join(certDir, 'fullchain.pem')); return new Date(st.mtimeMs + 90 * 86400000).toISOString(); } catch (_) { return null; } }
|
||||
/** vhost 파일 쓰고 nginx 적용. stub 모드에서는 파일만 쓴다. */
|
||||
export async function applyVhost(ctx, siteId, content) {
|
||||
const roots = ctx.roots; const avail = join(roots.nginx, 'sites-available', `aw-${siteId}.conf`); const enabled = join(roots.nginx, 'sites-enabled', `aw-${siteId}.conf`);
|
||||
await ctx.sys.writeFile(avail, content);
|
||||
try { mkdirSync(dirname(enabled), { recursive: true }); if (!existsSync(enabled)) symlinkSync(avail, enabled); } catch (e) { if (e.code === 'EACCES' || e.code === 'EPERM') { await ctx.sys.sudo(['ln', '-sf', avail, enabled]); } else if (e.code !== 'EEXIST') throw e; }
|
||||
await ctx.sys.nginxReload(); return { file: avail };
|
||||
}
|
||||
export async function removeVhost(ctx, siteId) { const roots = ctx.roots; const files = [join(roots.nginx, 'sites-enabled', `aw-${siteId}.conf`), join(roots.nginx, 'sites-available', `aw-${siteId}.conf`)]; let removed = 0; for (const f of files) { try { unlinkSync(f); removed++; } catch (e) { if (e.code === 'EACCES' || e.code === 'EPERM') { await ctx.sys.sudo(['rm', '-f', f]); removed++; } } } if (removed) await ctx.sys.nginxReload().catch(() => {}); return removed; }
|
||||
/** 내부 접속 확인: 127.0.0.1:80 에 Host 헤더로. stub 모드면 current/index.html 존재로 대신한다. */
|
||||
export function internalCheck(ctx, siteId, host, { path = '/', expectMarker = null, port = 80 } = {}) {
|
||||
if (ctx.sys.stub) { const sd = siteDir(ctx.roots, siteId); const idx = join(sd, 'current', 'index.html'); const ok = existsSync(idx) && (!expectMarker || readFileSync(idx, 'utf8').includes(expectMarker)); return Promise.resolve({ ok, status: ok ? 200 : 404, ms: 0, stubbed: true, host }); }
|
||||
return new Promise((resolve) => { const t0 = Date.now(); const req = http.get({ host: '127.0.0.1', port, path, headers: { Host: host } }, (res) => { let body = ''; res.on('data', (c) => { if (body.length < 200000) body += c; }); res.on('end', () => resolve({ ok: res.statusCode < 400 && (!expectMarker || body.includes(expectMarker)), status: res.statusCode, ms: Date.now() - t0, host, markerFound: expectMarker ? body.includes(expectMarker) : null })); }); req.setTimeout(15000, () => { req.destroy(new Error('timeout')); }); req.on('error', (e) => resolve({ ok: false, error: e.message, ms: Date.now() - t0, host })); });
|
||||
}
|
||||
/** 디렉터리를 tar.gz 로 */
|
||||
export async function tarDir(ctx, outFile, dir, items = ['.']) { mkdirSync(dirname(outFile), { recursive: true }); await ctx.sys.tar(['-czf', outFile, '-C', dir, ...items]); return { file: outFile, size: statSync(outFile).size, sha256: fmtSum(sha256File(outFile)) }; }
|
||||
export async function untar(ctx, file, destDir) { mkdirSync(destDir, { recursive: true }); await ctx.sys.tar(['-xzf', file, '-C', destDir]); return destDir; }
|
||||
export async function tarList(ctx, file) { const r = await ctx.sys.run('tar', ['-tzf', file], { allowStub: false }); if (r.code !== 0) throw new Error(`tar 목록 실패: ${r.stderr.slice(0, 200)}`); return r.stdout.split('\n').filter(Boolean); }
|
||||
export const rmrf = (p) => rmSync(p, { recursive: true, force: true });
|
||||
export const readJson = (p, fb = null) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch (_) { return fb; } };
|
||||
export const writeJson = (p, v) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(v, null, 1)); };
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* agent/jobs/domain.mjs — 도메인 작업: vhost 재구성, HTTPS 인증서 발급(certbot webroot), DNS 확인(공용 리졸버).
|
||||
* 인증서: 플랫폼 서브 도메인은 와일드카드(*.ariweb.net)를 재사용하고, 고객 도메인은 certbot --webroot 로 사이트별(aw-<siteId>) 인증서를 받는다(Webmaker deploy.js 와 같은 원칙).
|
||||
* 유효하지 않은 인증서를 정상 HTTPS 로 표시하지 않는다(§12.3): certbot 결과가 성공이고 fullchain.pem 이 있을 때만 'issued'.
|
||||
*/
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { promises as dns } from 'node:dns';
|
||||
import { assertSiteId, ownCertFor, wildcardCertFor, certExpiry } from './common.mjs';
|
||||
import { configureVhost } from './site.mjs';
|
||||
|
||||
export const kinds = {
|
||||
'domain.vhost': { title: '도메인 연결 설정', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const r = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains: p.domains || [], primaryDomain: p.primaryDomain || null, runtime: p.runtime || 'static', port: p.port || null, redirectOld: p.redirectOld ? (p.oldDomains || []) : [] }), 'nginx 설정'); if (p.maintenance !== undefined) { const { kinds: site } = await import('./site.mjs'); await site['site.maintenance'].run({ ...ctx, payload: { siteId, on: !!p.maintenance } }); } return { applied: r.serverNames, certs: r.certs, https: r.https }; } },
|
||||
'domain.cert': { title: 'HTTPS 인증서 발급', async run(ctx) {
|
||||
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const domains = (p.domains || []).map((d) => String(d).toLowerCase()); if (!domains.length) throw Object.assign(new Error('도메인이 없습니다'), { code: 'domains_required' });
|
||||
const wild = wildcardCertFor(ctx.roots, domains); if (wild) { const r = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains, primaryDomain: p.primaryDomain || domains[0], runtime: p.runtime || 'static', port: p.port || null }), '와일드카드 인증서 적용'); return { issued: domains, failed: [], source: 'wildcard', expiresAt: certExpiry(wild), https: r.https }; }
|
||||
// 1) HTTP vhost 가 먼저 있어야 webroot 인증이 된다
|
||||
await ctx.step('vhost-http', () => configureVhost(ctx, siteId, { domains, primaryDomain: p.primaryDomain || domains[0], runtime: p.runtime || 'static', port: p.port || null }), 'HTTP 연결 준비');
|
||||
const issued = []; const failed = [];
|
||||
await ctx.step('certbot', async () => { const args = ['certonly', '--webroot', '-w', ctx.roots.acme, '--cert-name', `aw-${siteId}`, '--non-interactive', '--agree-tos', '--keep-until-expiring', '--expand', ...(p.email ? ['-m', p.email] : ['--register-unsafely-without-email'])]; for (const d of domains) args.push('-d', d); const r = await ctx.sys.certbot(args); if (r.code !== 0 && !ctx.sys.stub) { const msg = (r.stderr || r.stdout).trim(); for (const d of domains) failed.push({ domain: d, reason: msg.slice(0, 300) || 'certbot 실패' }); } else { issued.push(...domains); if (ctx.sys.stub && !ownCertFor(ctx.roots, siteId)) { const { mkdirSync, writeFileSync } = await import('node:fs'); const dir = join(ctx.roots.letsencrypt, `aw-${siteId}`); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'fullchain.pem'), 'stub'); writeFileSync(join(dir, 'privkey.pem'), 'stub'); } } return { code: r.code }; }, 'certbot 실행');
|
||||
if (!issued.length) { const e = new Error(`인증서 발급 실패: ${failed[0]?.reason || '원인 불명'}`); e.code = 'cert_failed'; e.partial = { failed }; throw e; }
|
||||
const own = ownCertFor(ctx.roots, siteId); if (!own) throw Object.assign(new Error('certbot 은 성공했지만 인증서 파일이 없습니다'), { code: 'cert_missing' });
|
||||
const r = await ctx.step('vhost-https', () => configureVhost(ctx, siteId, { domains, primaryDomain: p.primaryDomain || domains[0], runtime: p.runtime || 'static', port: p.port || null }), 'HTTPS 연결');
|
||||
return { issued, failed, source: 'own', expiresAt: certExpiry(own), https: r.https };
|
||||
} },
|
||||
'domain.probe': { title: 'DNS 확인', async run(ctx) { const p = ctx.payload; const name = String(p.name || '').toLowerCase(); if (!name) throw Object.assign(new Error('이름이 없습니다'), { code: 'name_required' }); if (ctx.sys.stub && p._stubAnswer) return { ...p._stubAnswer, resolvedAt: new Date().toISOString() }; const r = new dns.Resolver({ timeout: 5000, tries: 2 }); r.setServers(['1.1.1.1', '8.8.8.8']); const out = { name, a: [], txtOk: null, cname: [], mx: [], resolvedAt: new Date().toISOString(), resolver: 'public' }; try { out.a = await r.resolve4(name); } catch (e) { out.aError = e.code; } try { out.cname = await r.resolveCname(name); } catch (_) {} if (p.expectTxt) { try { const txts = (await r.resolveTxt(p.expectTxt.name || name)).map((x) => x.join('')); out.txtOk = txts.includes(p.expectTxt.value); out.txtFound = txts.length; } catch (e) { out.txtOk = false; out.txtError = e.code; } } if (p.mx) { try { out.mx = await r.resolveMx(name); } catch (_) {} } if (p.expectA) { const want = new Set(p.expectA); out.aOk = out.a.length > 0 && out.a.every((x) => want.has(x)); out.aMismatch = out.a.filter((x) => !want.has(x)); } return out; } },
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/** agent/jobs/index.mjs — 작업 종류 등록부. HAL 이 보내는 kind 이름과 같다. External Pull Agent 는 HAL 이 배정을 제한하므로 여기서는 모두 등록한다. */
|
||||
import { kinds as site } from './site.mjs';
|
||||
import { kinds as domain } from './domain.mjs';
|
||||
import { kinds as backup } from './backup.mjs';
|
||||
import { kinds as mod } from './module.mjs';
|
||||
export const KINDS = { ...site, ...domain, ...backup, ...mod };
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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 를 돌려준다.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, copyFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import http from 'node:http';
|
||||
import { 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); }
|
||||
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
|
||||
async function writeUnit(ctx, desc, unit, appDir, version) {
|
||||
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 });
|
||||
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 };
|
||||
}
|
||||
/** 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 }; }
|
||||
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}` };
|
||||
}
|
||||
async function installRelease(ctx, { module, version, appDir, desc, unit, action, toVersion }) {
|
||||
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 }; }, '릴리스 풀기');
|
||||
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 = p.appDir || join(ctx.roots.srv, p.module); 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() }; } },
|
||||
};
|
||||
async function installOrRollback(ctx, action) {
|
||||
const p = ctx.payload; const { module, version, descriptor: desc = {}, toVersion = null } = p; const appDir = p.appDir || join(ctx.roots.srv, module); 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' ? '되돌릴 릴리스 확인' : '릴리스 배치');
|
||||
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}`);
|
||||
const self = module === 'agent'; let healthRes = { ok: 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 } }); }
|
||||
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 } }); }
|
||||
}
|
||||
const actual = self ? readJson(join(relDir, 'package.json'), {}).version || version : 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 };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* agent/jobs/site.mjs — 사이트 작업: 배포·되돌리기·제거·확인·사용량, 이전(relocate) 보조 작업.
|
||||
*
|
||||
* site.deploy(§11.2): 번들 가져오기(HAL 전송·서명 URL·로컬 경로) → releases/<n> 에 풀기 → 버전 기록 → current 교체 → (동적) 실행 유닛 → vhost → nginx → 내부 확인.
|
||||
* 실패하면 current 를 바꾸지 않고(기존 버전 유지), 만들어 둔 디렉터리를 leftovers 로 보고해 포털이 "남은 파일" 과 정리를 보일 수 있게 한다.
|
||||
* site.usage(§13.1): 사이트 디렉터리 크기(du) + nginx awstats 로그 증분 파싱 → 일별 집계(data/usage/<siteId>.json, 400일) → 24h/7d/30d/누적.
|
||||
* 이전 보조: site.prepare / site.export(전송 올리기) / site.import(받아 staging) / site.stage_check / site.activate / site.deactivate / site.freeze.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync, openSync, readSync, closeSync, fstatSync, copyFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { siteDir, assertSiteId, switchLink, readLink, renderVhost, applyVhost, removeVhost, internalCheck, wildcardCertFor, ownCertFor, certExpiry, dirSize, tarDir, untar, sha256File, rmrf, readJson, writeJson, render, ROOT, fmtSum, normSum } from './common.mjs';
|
||||
|
||||
const stamp = () => new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
||||
/** 번들을 작업 디렉터리의 파일로 가져온다 */
|
||||
async function fetchBundle(ctx, siteId, dest) {
|
||||
const p = ctx.payload; mkdirSync(join(dest), { recursive: true }); const file = join(dest, 'bundle.tar.gz');
|
||||
if (p.transferId) { const r = await ctx.client.downloadTransfer(p.transferId, file, p.checksum || null); return { file, sha256: fmtSum(r.sha256), source: 'transfer' }; }
|
||||
if (p.bundleUrl) { const r = await ctx.client.download(p.bundleUrl.startsWith('http') ? p.bundleUrl : p.bundleUrl, file, p.checksum || null); return { file, sha256: fmtSum(r.sha256), source: 'url' }; }
|
||||
if (p.bundlePath && existsSync(p.bundlePath)) { if (statSync(p.bundlePath).isDirectory()) { await tarDir(ctx, file, p.bundlePath); return { file, sha256: fmtSum(sha256File(file)), source: 'dir' }; } copyFileSync(p.bundlePath, file); return { file, sha256: fmtSum(sha256File(file)), source: 'file' }; }
|
||||
if (p.bundleFromWebmaker?.path && existsSync(p.bundleFromWebmaker.path)) { await tarDir(ctx, file, p.bundleFromWebmaker.path); return { file, sha256: fmtSum(sha256File(file)), source: 'webmaker' }; }
|
||||
throw Object.assign(new Error('배포할 저장본(transferId / bundleUrl / bundlePath / bundleFromWebmaker)이 없습니다'), { code: 'bundle_missing' });
|
||||
}
|
||||
/** 동적(node) 사이트 포트: 지정 없으면 siteId 해시로 20000~29999 */
|
||||
function sitePort(siteId, p) { if (p.port) return Number(p.port); let h = 0; for (const c of siteId) h = (h * 31 + c.charCodeAt(0)) >>> 0; return 20000 + (h % 10000); }
|
||||
async function nodeUnit(ctx, siteId, sd, start, port) { const unit = `ariweb-site-${siteId}`; const txt = render(readFileSync(join(ROOT, 'deploy', 'units', 'ariweb-site.service.tmpl'), 'utf8'), { SITE_ID: siteId, SITE_DIR: sd, PORT: port, USER: process.env.USER || 'master', NODE: process.execPath, START: start }); await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); await ctx.sys.systemctl('enable', unit, { optional: true }); await ctx.sys.systemctl('restart', unit); return unit; }
|
||||
/** 현재 운영 중인 도메인 설정으로 vhost 다시 쓰기 */
|
||||
export async function configureVhost(ctx, siteId, { domains = [], primaryDomain = null, runtime = 'static', port = null, redirectOld = [] }) {
|
||||
const own = ownCertFor(ctx.roots, siteId); const wild = wildcardCertFor(ctx.roots, domains); const certDir = own || wild;
|
||||
const conf = renderVhost({ roots: ctx.roots, siteId, domains, primaryDomain, runtime, port, certDir, redirectOld });
|
||||
const r = await applyVhost(ctx, siteId, conf);
|
||||
const certs = Object.fromEntries(domains.map((dm) => [dm, certDir ? { state: 'active', source: own ? 'own' : 'wildcard', expiresAt: certExpiry(certDir) } : { state: 'none' }]));
|
||||
return { ...r, https: !!certDir, certs, serverNames: domains.length ? domains : [`${siteId}.preview.${ctx.roots.platformDomain}`] };
|
||||
}
|
||||
|
||||
export const kinds = {
|
||||
'site.deploy': { title: '사이트 배포', async run(ctx) {
|
||||
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const leftovers = [];
|
||||
const rel = await ctx.step('prepare', () => { mkdirSync(join(sd, 'releases'), { recursive: true }); mkdirSync(join(sd, 'data', 'uploads'), { recursive: true }); const n = `${stamp()}-${String(p.versionRef || 'v').replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 24)}`; return { dir: join(sd, 'releases', n), name: n }; }, '배포 위치 준비');
|
||||
leftovers.push(rel.dir);
|
||||
try {
|
||||
const b = await ctx.step('fetch', () => fetchBundle(ctx, siteId, join(sd, 'work', rel.name)), '저장본 준비'); leftovers.push(join(sd, 'work', rel.name));
|
||||
await ctx.step('extract', async () => { await untar(ctx, b.file, rel.dir); writeJson(join(rel.dir, '.ariweb-version.json'), { version: rel.name, versionRef: p.versionRef || null, deployedAt: new Date().toISOString(), sha256: b.sha256, source: b.source }); rmrf(join(sd, 'work', rel.name)); return { files: readdirSync(rel.dir).length }; }, '파일과 설정 적용');
|
||||
await ctx.step('data', () => { const meta = readJson(join(rel.dir, 'ariweb.json'), {}); if (meta.db?.sqlite) mkdirSync(join(sd, 'data', 'db'), { recursive: true }); return { runtime: p.runtime || meta.runtime || 'static', db: meta.db || null }; }, '데이터베이스와 업로드 데이터 확인');
|
||||
await ctx.progress(50); ctx.checkCancel();
|
||||
const prev = readLink(join(sd, 'current'));
|
||||
const sw = await ctx.step('switch', async () => { switchLink(join(sd, 'current'), rel.dir); writeJson(join(sd, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { previous: prev }; }, '서비스 시작(버전 전환)');
|
||||
const meta = readJson(join(rel.dir, 'ariweb.json'), {}); const runtime = p.runtime || meta.runtime || 'static'; let port = null;
|
||||
if (runtime === 'node') { port = sitePort(siteId, p); await ctx.step('unit', () => nodeUnit(ctx, siteId, sd, meta.start || 'server.js', port), '실행 유닛 준비'); }
|
||||
const vh = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains: p.domains || [], primaryDomain: p.primaryDomain || null, runtime, port }), '도메인 연결(nginx)');
|
||||
await ctx.progress(80);
|
||||
const ic = await ctx.step('internal', () => internalCheck(ctx, siteId, vh.serverNames[0], { expectMarker: p.expectMarker || null }), '내부 확인');
|
||||
if (!ic.ok) { if (prev && existsSync(prev)) { switchLink(join(sd, 'current'), prev); await ctx.sys.nginxReload().catch(() => {}); } const e = new Error(`내부 확인 실패(${ic.status || ic.error}) — 이전 버전을 유지합니다`); e.code = 'internal_check_failed'; e.partial = { leftovers, previousKept: !!prev, internalCheck: ic }; throw e; }
|
||||
// 오래된 릴리스 정리(최근 5개 보존 — 되돌리기 대상)
|
||||
await ctx.step('prune', () => { const rels = readdirSync(join(sd, 'releases')).sort(); const del = rels.slice(0, Math.max(0, rels.length - 5)).filter((r) => join(sd, 'releases', r) !== rel.dir); for (const r of del) rmrf(join(sd, 'releases', r)); return { pruned: del.length }; }, '이전 배포본 정리');
|
||||
return { version: rel.name, deployedAt: new Date().toISOString(), internalCheck: ic, siteUrl: p.primaryDomain ? `https://${p.primaryDomain}/` : `http://${vh.serverNames[0]}/`, serverNames: vh.serverNames, https: vh.https, certs: vh.certs, previousVersion: sw.previous ? sw.previous.split('/').pop() : null, runtime, port, leftovers: [] };
|
||||
} catch (e) { if (!e.partial) e.partial = { leftovers: leftovers.filter((x) => existsSync(x)), previousKept: true }; throw e; }
|
||||
} },
|
||||
'site.rollback': { title: '이전 버전으로 되돌리기', async run(ctx) {
|
||||
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId);
|
||||
const target = await ctx.step('find', () => { const want = p.toVersion ? join(sd, 'releases', p.toVersion) : readJson(join(sd, '.previous.json'), {}).previous; if (!want || !existsSync(want)) throw Object.assign(new Error('되돌릴 이전 버전이 없습니다'), { code: 'no_previous_version' }); return want; }, '되돌릴 버전 확인');
|
||||
const cur = readLink(join(sd, 'current'));
|
||||
await ctx.step('switch', () => { switchLink(join(sd, 'current'), target); writeJson(join(sd, '.previous.json'), { previous: cur, at: new Date().toISOString() }); return { from: cur, to: target }; }, '버전 전환');
|
||||
const unit = `ariweb-site-${siteId}`; if (readJson(join(target, 'ariweb.json'), {}).runtime === 'node') await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '실행 유닛 재시작'); else await ctx.sys.nginxReload().catch(() => {});
|
||||
const ic = await ctx.step('internal', () => internalCheck(ctx, siteId, (p.domains && p.domains[0]) || `${siteId}.preview.${ctx.roots.platformDomain}`), '내부 확인');
|
||||
if (!ic.ok) throw Object.assign(new Error('되돌린 버전의 내부 확인에 실패했습니다'), { code: 'internal_check_failed', partial: { internalCheck: ic } });
|
||||
return { version: target.split('/').pop(), internalCheck: ic };
|
||||
} },
|
||||
'site.remove': { title: '배포 제거', async run(ctx) {
|
||||
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const left = [];
|
||||
await ctx.step('vhost', () => removeVhost(ctx, siteId), '도메인 연결 해제');
|
||||
await ctx.step('unit', async () => { if (existsSync(`/etc/systemd/system/ariweb-site-${siteId}.service`) || ctx.sys.stub) { await ctx.sys.systemctl('stop', `ariweb-site-${siteId}`, { optional: true }); await ctx.sys.systemctl('disable', `ariweb-site-${siteId}`, { optional: true }); await ctx.sys.sudo(['rm', '-f', `/etc/systemd/system/ariweb-site-${siteId}.service`]); } return true; }, '실행 유닛 제거');
|
||||
await ctx.step('files', () => { if (p.keepData) { for (const x of ['releases', 'current', 'work']) rmrf(join(sd, x)); if (existsSync(join(sd, 'data'))) left.push(join(sd, 'data')); } else rmrf(sd); return { removed: true }; }, '파일 정리');
|
||||
return { removed: true, leftovers: left.filter((x) => existsSync(x)) };
|
||||
} },
|
||||
'site.check': { title: '사이트 확인', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const checks = await ctx.step('check', async () => { const out = []; for (const u of p.urls || []) { let host = u; try { host = new URL(u).hostname; } catch (_) {} out.push({ url: u, ...(await internalCheck(ctx, siteId, host, { expectMarker: p.expectMarker || null })) }); } return out; }, '내부 확인'); const sd = siteDir(ctx.roots, siteId); return { checks, currentVersion: (readLink(join(sd, 'current')) || '').split('/').pop() || null, maintenance: existsSync(join(sd, '.maintenance')), limited: existsSync(join(sd, '.limited')), tls: (() => { const c = ownCertFor(ctx.roots, siteId) || wildcardCertFor(ctx.roots, p.domains || []); return { valid: !!c, expiresAt: c ? certExpiry(c) : null }; })() }; } },
|
||||
'site.usage': { title: '사용량 수집', async run(ctx) { const out = {}; for (const siteId of ctx.payload.siteIds || []) { try { out[siteId] = await ctx.step(`usage:${siteId}`, () => collectUsage(ctx, siteId), `사용량 ${siteId}`); } catch (e) { out[siteId] = { error: e.message }; } } return { sites: out, measuredAt: new Date().toISOString() }; } },
|
||||
'site.limit': { title: '전송량 제한 표시', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(sd, { recursive: true }); const f = join(sd, '.limited'); if (ctx.payload.limited) writeFileSync(f, new Date().toISOString()); else rmrf(f); return { limited: !!ctx.payload.limited }; } },
|
||||
'site.maintenance': { title: '유지보수 모드', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(sd, { recursive: true }); const f = join(sd, '.maintenance'); if (ctx.payload.on) writeFileSync(f, new Date().toISOString()); else rmrf(f); return { maintenance: !!ctx.payload.on }; } },
|
||||
// ---------------- 이전 보조(§21)
|
||||
'site.prepare': { title: '새 위치 준비', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(join(sd, 'releases'), { recursive: true }); mkdirSync(join(sd, 'staging'), { recursive: true }); mkdirSync(join(sd, 'data'), { recursive: true }); const free = (() => { try { const { statfsSync } = ctx.sys.stub ? { statfsSync: () => ({ bfree: 1e9, bsize: 1 }) } : {}; return null; } catch (_) { return null; } })(); return { prepared: true, dir: sd }; } },
|
||||
'site.export': { title: '사이트 내보내기', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const cur = readLink(join(sd, 'current')); if (!cur) throw Object.assign(new Error('운영 중인 배포본이 없습니다'), { code: 'no_current' }); const work = join(sd, 'work', `export-${stamp()}`); mkdirSync(work, { recursive: true }); const t = await ctx.step('tar', async () => { mkdirSync(join(work, 'pack'), { recursive: true }); await ctx.sys.tar(['-cf', join(work, 'pack', 'files.tar'), '-C', cur, '.']); if (p.includeData !== false && existsSync(join(sd, 'data'))) await ctx.sys.tar(['-cf', join(work, 'pack', 'data.tar'), '-C', join(sd, 'data'), '.']); writeJson(join(work, 'pack', 'meta.json'), { siteId, version: cur.split('/').pop(), exportedAt: new Date().toISOString(), ariweb: readJson(join(cur, 'ariweb.json'), {}) }); return tarDir(ctx, join(work, 'export.tar.gz'), join(work, 'pack')); }, '묶기'); const up = await ctx.step('upload', () => ctx.client.upload(p.transferId, t.file, { purpose: 'relocate' }), 'HAL 로 올리기'); rmrf(work); return { transferId: up.id, checksum: fmtSum(up.sha256), sizeBytes: up.size, version: cur.split('/').pop() }; } },
|
||||
'site.import': { title: '사이트 받기', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const stage = join(sd, 'staging', p.stage || 'pre'); rmrf(stage); mkdirSync(stage, { recursive: true }); const dl = await ctx.step('download', () => ctx.client.downloadTransfer(p.transferId, join(stage, 'export.tar.gz'), p.checksum || null), '받기'); await ctx.step('unpack', async () => { await untar(ctx, join(stage, 'export.tar.gz'), join(stage, 'pack')); mkdirSync(join(stage, 'files'), { recursive: true }); await ctx.sys.tar(['-xf', join(stage, 'pack', 'files.tar'), '-C', join(stage, 'files')]); if (existsSync(join(stage, 'pack', 'data.tar'))) { mkdirSync(join(stage, 'data'), { recursive: true }); await ctx.sys.tar(['-xf', join(stage, 'pack', 'data.tar'), '-C', join(stage, 'data')]); } return true; }, '풀기'); const meta = readJson(join(stage, 'pack', 'meta.json'), {}); writeJson(join(sd, 'staging', 'latest.json'), { stage: p.stage || 'pre', checksum: fmtSum(dl.sha256), meta, at: new Date().toISOString() }); return { ok: dl.sha256 === normSum(p.checksum || dl.sha256), checksum: fmtSum(dl.sha256), sizeBytes: dl.size, version: meta.version || null, files: readdirSync(join(stage, 'files')).length }; } },
|
||||
'site.stage_check': { title: '새 위치 확인', async run(ctx) { const p = ctx.payload; const sd = siteDir(ctx.roots, assertSiteId(p.siteId)); const latest = readJson(join(sd, 'staging', 'latest.json'), null); const stage = latest ? join(sd, 'staging', latest.stage) : null; const ok = !!(latest && stage && existsSync(join(stage, 'files')) && (!p.expectedChecksum || normSum(latest.checksum) === normSum(p.expectedChecksum)) && readdirSync(join(stage, 'files')).length > 0); return { ok, checksum: latest?.checksum || null, expected: p.expectedChecksum || null, files: ok ? readdirSync(join(stage, 'files')).length : 0, reason: ok ? null : '스테이징 사본이 없거나 체크섬이 다릅니다' }; } },
|
||||
'site.activate': { title: '새 위치에서 서비스 시작', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const latest = readJson(join(sd, 'staging', 'latest.json'), null); const rel = await ctx.step('place', () => { if (p.reactivate && readLink(join(sd, 'current'))) return { dir: readLink(join(sd, 'current')), reused: true }; if (!latest) throw Object.assign(new Error('스테이징 사본이 없습니다'), { code: 'no_staging' }); const dir = join(sd, 'releases', `${stamp()}-reloc`); renameSync(join(sd, 'staging', latest.stage, 'files'), dir); if (existsSync(join(sd, 'staging', latest.stage, 'data'))) { const old = join(sd, 'data'); if (existsSync(old)) renameSync(old, join(sd, `data.pre-reloc-${stamp()}`)); renameSync(join(sd, 'staging', latest.stage, 'data'), old); } writeJson(join(dir, '.ariweb-version.json'), { version: latest.meta?.version || dir.split('/').pop(), relocatedAt: new Date().toISOString() }); return { dir }; }, '배포본 배치'); switchLink(join(sd, 'current'), rel.dir); rmrf(join(sd, '.frozen')); let port = null; const meta = readJson(join(rel.dir, 'ariweb.json'), {}); if ((p.runtime || meta.runtime) === 'node') { port = sitePort(siteId, p); await ctx.step('unit', () => nodeUnit(ctx, siteId, sd, meta.start || 'server.js', port), '실행 유닛'); } const vh = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains: p.domains || [], primaryDomain: p.primaryDomain || null, runtime: p.runtime || meta.runtime || 'static', port }), '도메인 연결'); const ic = await internalCheck(ctx, siteId, vh.serverNames[0]); if (!ic.ok) throw Object.assign(new Error('새 위치 내부 확인 실패'), { code: 'internal_check_failed', partial: { internalCheck: ic } }); return { version: rel.dir.split('/').pop(), internalCheck: ic, serverNames: vh.serverNames }; } },
|
||||
'site.deactivate': { title: '이전 위치 서비스 중지(사본 보존)', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); await ctx.step('vhost', () => removeVhost(ctx, siteId), '도메인 연결 해제'); await ctx.step('unit', () => ctx.sys.systemctl('stop', `ariweb-site-${siteId}`, { optional: true }), '실행 유닛 중지'); const until = new Date(Date.now() + (p.retainDays || 7) * 86400000).toISOString(); writeJson(join(sd, '.retained.json'), { until, reason: 'relocated', at: new Date().toISOString() }); rmrf(join(sd, '.frozen')); return { retainedUntil: until }; } },
|
||||
'site.freeze': { title: '쓰기 제한', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(sd, { recursive: true }); const f = join(sd, '.frozen'); if (ctx.payload.freeze) writeFileSync(f, new Date().toISOString()); else rmrf(f); return { frozen: !!ctx.payload.freeze, at: new Date().toISOString() }; } },
|
||||
};
|
||||
|
||||
// ---------------- 사용량(§13.1)
|
||||
const DAY_KEEP = 400;
|
||||
function usageFile(ctx, siteId) { return join(ctx.dataDir, 'usage', `${siteId}.json`); }
|
||||
/** awstats 로그 증분 읽기(inode·offset 저장) → 일별 집계 */
|
||||
export function ingestLog(ctx, siteId, logPath, state) {
|
||||
if (!existsSync(logPath)) return state;
|
||||
const fd = openSync(logPath, 'r'); try {
|
||||
const st = fstatSync(fd); const off = state.offsets?.[logPath] || { inode: null, offset: 0 }; let start = off.inode === st.ino && off.offset <= st.size ? off.offset : 0;
|
||||
const buf = Buffer.alloc(Math.min(st.size - start, 64 * 1024 * 1024)); const n = readSync(fd, buf, 0, buf.length, start); const text = buf.toString('utf8', 0, n); const lastNl = text.lastIndexOf('\n'); const usable = lastNl >= 0 ? text.slice(0, lastNl + 1) : '';
|
||||
for (const line of usable.split('\n')) { if (!line) continue; const f = line.split('|'); if (f.length < 7) continue; const day = f[0].slice(0, 10); const d = state.days[day] || (state.days[day] = { req: 0, bytesIn: 0, bytesOut: 0, err: 0, hist: {} }); d.req++; d.bytesIn += Number(f[2]) || 0; d.bytesOut += Number(f[3]) || 0; if (Number(f[4]) >= 500) d.err++; const ms = Math.round((Number(f[5]) || 0) * 1000); const bucket = ms < 100 ? '0' : ms < 300 ? '100' : ms < 1000 ? '300' : ms < 3000 ? '1000' : '3000'; d.hist[bucket] = (d.hist[bucket] || 0) + 1; }
|
||||
state.offsets = state.offsets || {}; state.offsets[logPath] = { inode: st.ino, offset: start + Buffer.byteLength(usable) };
|
||||
} finally { closeSync(fd); }
|
||||
const days = Object.keys(state.days).sort(); for (const k of days.slice(0, Math.max(0, days.length - DAY_KEEP))) delete state.days[k];
|
||||
return state;
|
||||
}
|
||||
function p95(hist) { const order = ['0', '100', '300', '1000', '3000']; const total = order.reduce((a, k) => a + (hist[k] || 0), 0); if (!total) return null; let acc = 0; for (const k of order) { acc += hist[k] || 0; if (acc / total >= 0.95) return Number(k); } return 3000; }
|
||||
export async function collectUsage(ctx, siteId) {
|
||||
assertSiteId(siteId); const sd = siteDir(ctx.roots, siteId); const f = usageFile(ctx, siteId); const state = readJson(f, { days: {}, offsets: {} });
|
||||
ingestLog(ctx, siteId, join(ctx.roots.logs, `aw-${siteId}.access.log`), state); writeJson(f, state);
|
||||
const today = new Date().toISOString().slice(0, 10); const kst = (d) => new Date(new Date(d).getTime() + 9 * 3600000).toISOString().slice(0, 10); const kToday = kst(Date.now()); const month = kToday.slice(0, 7);
|
||||
const sum = (filter, key) => Object.entries(state.days).filter(([d]) => filter(d)).reduce((a, [, v]) => a + (v[key] || 0), 0);
|
||||
const series = (n) => { const out = []; for (let i = n - 1; i >= 0; i--) { const d = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10); const v = state.days[d] || {}; out.push({ date: d, req: v.req || 0, bytesOut: v.bytesOut || 0, bytesIn: v.bytesIn || 0, err: v.err || 0 }); } return out; };
|
||||
const cur = readLink(join(sd, 'current')); const dbDir = join(sd, 'data', 'db'); const last30 = (d) => Date.now() - Date.parse(d) < 30 * 86400000;
|
||||
const errs = sum(last30, 'err'), reqs = sum(last30, 'req'); const histAll = {}; for (const [d, v] of Object.entries(state.days)) if (last30(d)) for (const [k, c] of Object.entries(v.hist || {})) histAll[k] = (histAll[k] || 0) + c;
|
||||
return { diskBytes: cur ? dirSize(cur) : 0, dbBytes: dirSize(dbDir), uploadsBytes: dirSize(join(sd, 'data', 'uploads')), outBytesDay: sum((d) => d === today, 'bytesOut'), outBytesMonth: sum((d) => kst(d + 'T12:00:00Z').startsWith(month), 'bytesOut'), outBytesTotal: sum(() => true, 'bytesOut'), inBytesDay: sum((d) => d === today, 'bytesIn'), inBytesMonth: sum((d) => kst(d + 'T12:00:00Z').startsWith(month), 'bytesIn'), inBytesTotal: sum(() => true, 'bytesIn'), req24h: sum((d) => d === today, 'req'), pv24h: sum((d) => d === today, 'req'), series7d: series(7), series30d: series(30), errorRate: reqs ? Math.round(errs / reqs * 10000) / 100 : 0, p95ms: p95(histAll), measuredAt: new Date().toISOString(), kstMonth: month };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* agent/metrics.mjs — 서버 지표(§28.1): CPU·메모리·디스크·inode·I/O·네트워크·load. /proc 와 df 만 읽는다(외부 도구 없음).
|
||||
* CPU 는 두 표본 사이의 사용률(%). I/O·네트워크는 누적 바이트(HAL 이 차분으로 속도를 구한다). 읽을 수 없는 항목은 null.
|
||||
*/
|
||||
import { readFileSync, statfsSync, existsSync } from 'node:fs';
|
||||
import { loadavg, hostname, release, totalmem, freemem } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
let lastCpu = null;
|
||||
function cpuPercent() {
|
||||
try { const l = readFileSync('/proc/stat', 'utf8').split('\n')[0].trim().split(/\s+/).slice(1).map(Number); const idle = l[3] + (l[4] || 0); const total = l.reduce((a, b) => a + b, 0); const prev = lastCpu; lastCpu = { idle, total }; if (!prev) return null; const dt = total - prev.total; return dt > 0 ? Math.round((1 - (idle - prev.idle) / dt) * 1000) / 10 : null; } catch (_) { return null; }
|
||||
}
|
||||
function mem() { try { const m = Object.fromEntries(readFileSync('/proc/meminfo', 'utf8').split('\n').filter(Boolean).map((l) => { const [k, v] = l.split(':'); return [k, parseInt(v, 10) * 1024]; })); return { total: m.MemTotal, used: m.MemTotal - (m.MemAvailable ?? m.MemFree) }; } catch (_) { return { total: totalmem(), used: totalmem() - freemem() }; } }
|
||||
function disk(path = '/') { try { const s = statfsSync(path); return { total: s.blocks * s.bsize, used: (s.blocks - s.bfree) * s.bsize, inode: { total: s.files, used: s.files - s.ffree } }; } catch (_) { return null; } }
|
||||
function io() { try { let r = 0, w = 0; for (const l of readFileSync('/proc/diskstats', 'utf8').split('\n')) { const f = l.trim().split(/\s+/); if (f.length < 14 || !/^(sd|vd|nvme|xvd|md|dm)/.test(f[2]) || /\d+p?\d+$/.test(f[2]) && !/^(md|dm)/.test(f[2])) continue; r += Number(f[5]) * 512; w += Number(f[9]) * 512; } return { read: r, write: w }; } catch (_) { return null; } }
|
||||
function net() { try { let i = 0, o = 0; for (const l of readFileSync('/proc/net/dev', 'utf8').split('\n').slice(2)) { const [name, rest] = l.split(':'); if (!rest || name.trim() === 'lo') continue; const f = rest.trim().split(/\s+/); i += Number(f[0]); o += Number(f[8]); } return { in: i, out: o }; } catch (_) { return null; } }
|
||||
/** heartbeat 에 실을 지표 묶음 */
|
||||
export function sample(roots = {}) { const dk = disk('/'); return { cpu: cpuPercent(), mem: mem(), disk: dk ? { total: dk.total, used: dk.used } : null, inode: dk ? dk.inode : null, io: io(), net: net(), load1: loadavg()[0], at: new Date().toISOString() }; }
|
||||
/** 서버 사실(facts): 등록·heartbeat 때 HAL 에 알린다. 비밀 없음. */
|
||||
export function facts(sys) {
|
||||
const f = { hostname: hostname(), kernel: release(), node: process.version, nginx: existsSync('/usr/sbin/nginx') || existsSync('/usr/bin/nginx'), certbot: existsSync('/usr/bin/certbot') || existsSync('/snap/bin/certbot'), units: {}, appDirs: {} };
|
||||
try { const out = execFileSync('systemctl', ['list-units', '--type=service', '--all', '--no-pager', '--no-legend', '--plain'], { encoding: 'utf8', timeout: 5000 }); const names = out.split('\n').map((l) => l.trim().split(/\s+/)[0]).filter(Boolean).map((n) => n.replace(/\.service$/, ''));
|
||||
for (const [mod, cands] of Object.entries({ webmaker: ['webmaker'], arimail: ['arimail', 'arihakdang-mail', 'heart-mail', 'mail-gateway'], hal: ['ariweb-hal'], portal: ['ariweb-portal'], agent: ['ariweb-agent'] })) { const u = cands.find((c) => names.includes(c)); if (u) { f.units[mod] = u; try { const txt = execFileSync('systemctl', ['cat', u], { encoding: 'utf8', timeout: 5000 }); const wd = /^WorkingDirectory=(.+)$/m.exec(txt); if (wd) f.appDirs[mod] = wd[1].replace(/\/current\/?$/, ''); const user = /^User=(.+)$/m.exec(txt); if (user) (f.users = f.users || {})[mod] = user[1]; const envf = /^EnvironmentFile=(.+)$/m.exec(txt); if (envf) (f.envFiles = f.envFiles || {})[mod] = envf[1]; const node = /^ExecStart=(\S+)/m.exec(txt); if (node) (f.nodeBins = f.nodeBins || {})[mod] = node[1]; } catch (_) {} } } } catch (_) {}
|
||||
return f;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* agent/runner.mjs — Agent 작업 실행기(단계 저장·재개·진행 보고).
|
||||
*
|
||||
* HAL 의 lib/jobs 와 같은 원칙: 작업은 재진입 가능하고 끝난 단계는 건너뛴다. 단계 상태는 data/jobs/<id>.json 에 저장되어 Agent 재시작 뒤 이어서 실행된다.
|
||||
* ctx = { job, payload, roots, sys, client, dataDir, step(id, fn, title), log(line), progress(pct), needsUser(message, hint), checkCancel(), steps() }
|
||||
* 결과 보고: 성공 { status:'done', result } / 실패 { status:'failed', error, errorCode } / 사용자·운영자 확인 { status:'needs_user', userMessage }.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, renameSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { redact, safeError } from '../lib/redact.mjs';
|
||||
import { NeedsUser } from './sys.mjs';
|
||||
|
||||
class Signal extends Error { constructor(type, payload) { super(type); this.signal = type; this.payload = payload || {}; } }
|
||||
|
||||
export function createRunner({ kinds, roots, sys, client, dataDir, log = () => {} }) {
|
||||
const jobsDir = join(dataDir, 'jobs'); mkdirSync(jobsDir, { recursive: true });
|
||||
const stateFile = (id) => join(jobsDir, `${id}.json`);
|
||||
const loadState = (id) => { try { return JSON.parse(readFileSync(stateFile(id), 'utf8')); } catch (_) { return { steps: [], log: [] }; } };
|
||||
const saveState = (id, st) => { const tmp = stateFile(id) + '.tmp'; writeFileSync(tmp, JSON.stringify(st)); renameSync(tmp, stateFile(id)); };
|
||||
/** 진행 보고(HAL). 실패해도 작업은 계속한다(다음 보고에서 따라잡음). */
|
||||
async function report(id, body) { if (!client) return null; try { return await client.request('POST', `/agent/v1/jobs/${id}/progress`, body); } catch (e) { log(`[agent] 진행 보고 실패 ${id}: ${safeError(e)}`); return null; } }
|
||||
async function execute(job) {
|
||||
const def = kinds[job.kind]; const st = loadState(job.id); let cancelled = false;
|
||||
const ctx = {
|
||||
job, payload: job.payload || {}, roots, sys, client, dataDir, kinds,
|
||||
async step(id, fn, title) {
|
||||
let s = st.steps.find((x) => x.id === id); if (!s) { s = { id, title: title || id, state: 'pending' }; st.steps.push(s); }
|
||||
if (s.state === 'done') return s.result;
|
||||
s.state = 'running'; s.startedAt = new Date().toISOString(); delete s.error; saveState(job.id, st); await report(job.id, { steps: st.steps });
|
||||
try { const r = await fn(); s.state = 'done'; s.endedAt = new Date().toISOString(); s.result = r === undefined ? null : redact(r); saveState(job.id, st); return r; }
|
||||
catch (e) { s.state = e instanceof Signal ? 'needs_user' : 'failed'; s.error = safeError(e); s.endedAt = new Date().toISOString(); saveState(job.id, st); throw e; }
|
||||
},
|
||||
log(line) { st.log.push({ at: new Date().toISOString(), line: String(line).slice(0, 500) }); st.log = st.log.slice(-200); saveState(job.id, st); log(`[${job.kind} ${job.id}] ${line}`); },
|
||||
async progress(pct) { st.progress = Math.max(0, Math.min(100, Math.round(pct))); saveState(job.id, st); const r = await report(job.id, { progress: st.progress, steps: st.steps, log: st.log.slice(-20) }); if (r && r.cancelRequested) cancelled = true; },
|
||||
needsUser(message, hint) { throw new Signal('needs_user', { message, hint }); },
|
||||
checkCancel() { if (cancelled) throw new Signal('cancel', {}); },
|
||||
steps: () => st.steps, state: st,
|
||||
};
|
||||
let out;
|
||||
if (!def) out = { status: 'failed', error: `알 수 없는 작업 종류: ${job.kind}`, errorCode: 'unknown_kind' };
|
||||
else {
|
||||
try { const result = await def.run(ctx); out = { status: 'done', result: redact(result === undefined ? {} : result), steps: st.steps, log: st.log.slice(-50) }; }
|
||||
catch (e) {
|
||||
if (e instanceof Signal && e.signal === 'needs_user') out = { status: 'needs_user', userMessage: `${e.payload.message}${e.payload.hint ? ` (${e.payload.hint})` : ''}`, steps: st.steps, log: st.log.slice(-50) };
|
||||
else if (e instanceof Signal && e.signal === 'cancel') out = { status: 'failed', error: '취소 요청으로 중단', errorCode: 'cancelled', steps: st.steps, log: st.log.slice(-50) };
|
||||
else if (e instanceof NeedsUser || e.needsUser) out = { status: 'needs_user', userMessage: `${e.message}${e.hint ? ` — ${e.hint}` : ''}`, steps: st.steps, log: st.log.slice(-50) };
|
||||
else out = { status: 'failed', error: safeError(e), errorCode: e.code || 'failed', steps: st.steps, log: st.log.slice(-50), result: e.partial ? redact(e.partial) : undefined };
|
||||
}
|
||||
}
|
||||
try { unlinkSync(stateFile(job.id)); } catch (_) {}
|
||||
if (client) { try { await client.request('POST', `/agent/v1/jobs/${job.id}/result`, out); } catch (e) { log(`[agent] 결과 보고 실패 ${job.id}: ${safeError(e)}`); saveState(job.id, { ...st, pendingResult: out }); } }
|
||||
return out;
|
||||
}
|
||||
/** 재시작 뒤: 보고하지 못한 결과가 남아 있으면 다시 보고 */
|
||||
async function flushPending() { if (!client) return; const { readdirSync } = await import('node:fs'); for (const f of readdirSync(jobsDir).filter((x) => x.endsWith('.json'))) { const st = loadState(f.slice(0, -5)); if (st.pendingResult) { try { await client.request('POST', `/agent/v1/jobs/${f.slice(0, -5)}/result`, st.pendingResult); unlinkSync(join(jobsDir, f)); } catch (_) {} } } }
|
||||
return { execute, loadState, flushPending, kinds };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* agent/sys.mjs — 시스템 명령 실행 계층(sudo·systemctl·nginx·certbot·tar) 과 권한 있는 파일 쓰기.
|
||||
*
|
||||
* Agent 는 일반 계정(master)으로 돌고 systemctl/nginx/certbot/tee 만 sudo -n(비밀번호 없음)으로 쓴다(deploy/sudoers/ariweb-agent).
|
||||
* sudo 가 막혀 있으면 조용히 실패하지 않고 NEEDS_USER 로 운영자에게 이유(힌트)를 보인다.
|
||||
* stub 모드(ARIWEB_AGENT_STUB_CMDS=1, 테스트): 외부 명령을 실행하지 않고 호출만 기록하며 성공으로 돌려준다. tar 는 실제로 실행한다(파일 결과를 검증하기 위해).
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export class NeedsUser extends Error { constructor(message, hint) { super(message); this.needsUser = true; this.hint = hint; } }
|
||||
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 } = {}) {
|
||||
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(); });
|
||||
}
|
||||
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; }
|
||||
async function requireSudo(what) { if (!(await hasSudo())) throw new NeedsUser(`${what} 에 sudo 권한이 필요하지만 이 서버에서 비밀번호 없는 sudo 가 허용되지 않았습니다`, 'deploy/sudoers/ariweb-agent 를 /etc/sudoers.d/ 에 설치하세요'); }
|
||||
/** 권한이 필요한 경로에 파일 쓰기: 직접 쓰기 → 안 되면 sudo tee */
|
||||
async function writeFile(path, content, mode = '644') {
|
||||
try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); return { via: 'direct' }; } catch (e) { if (e.code !== 'EACCES' && e.code !== 'EPERM') throw e; }
|
||||
await requireSudo(`${path} 쓰기`); await sudo(['mkdir', '-p', dirname(path)]); const r = await sudo(['tee', path], { input: content }); if (r.code !== 0) throw new Error(`쓰기 실패 ${path}: ${r.stderr}`); await sudo(['chmod', mode, path]); return { via: 'sudo' };
|
||||
}
|
||||
async function systemctl(action, unit, { optional = false } = {}) { await requireSudo(`systemctl ${action} ${unit}`); const r = await sudo(['systemctl', action, unit]); if (r.code !== 0 && !optional) throw new Error(`systemctl ${action} ${unit} 실패: ${(r.stderr || r.stdout).trim().slice(0, 300)}`); return r; }
|
||||
async function isActive(unit) { if (stub) return true; const r = await run('systemctl', ['is-active', unit]); return r.stdout.trim() === 'active'; }
|
||||
async function unitText(unit) { if (stub) return ''; const r = await run('systemctl', ['cat', unit]); return r.code === 0 ? r.stdout : ''; }
|
||||
async function nginxReload() { await requireSudo('nginx 설정 적용'); const t = await sudo(['nginx', '-t']); if (t.code !== 0) throw new Error(`nginx -t 실패: ${(t.stderr || t.stdout).trim().slice(0, 400)}`); const r = await sudo(['systemctl', 'reload', 'nginx']); if (r.code !== 0) throw new Error(`nginx reload 실패: ${r.stderr.slice(0, 300)}`); return true; }
|
||||
async function certbot(args) { await requireSudo('certbot'); return sudo(['certbot', ...args], { timeoutMs: 300000 }); }
|
||||
async function tar(args, o) { const r = await run('tar', args, { ...o, allowStub: false }); if (r.code !== 0) throw new Error(`tar 실패: ${r.stderr.slice(0, 300)}`); return r; }
|
||||
return { stub, calls, run, sudo, hasSudo, requireSudo, writeFile, systemctl, isActive, unitText, nginxReload, certbot, tar, readIfExists: (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null) };
|
||||
}
|
||||
Reference in New Issue
Block a user