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:
zzabisagent-lab
2026-10-26 11:58:15 +00:00
co-authored by Claude Fable 5.1
parent 403d0fb845
commit 6b5a014041
49 changed files with 2584 additions and 0 deletions
+50
View File
@@ -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 };
} },
};
+66
View File
@@ -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, '&#39;');
/** 사이트 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)); };
+27
View File
@@ -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; } },
};
+6
View File
@@ -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 };
+67
View File
@@ -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 };
}
+114
View File
@@ -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 };
}