From 00c42abca1ac347a175bd7f7667aff07631dcdef Mon Sep 17 00:00:00 2001 From: zzabisagent-lab Date: Mon, 26 Oct 2026 13:38:45 +0000 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EA=B2=B0=ED=95=A8=20=EC=88=98=EC=A0=95:?= =?UTF-8?q?=20Agent=20=EC=8B=A4=ED=96=89=20=EA=B3=84=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=84=A4=EC=B9=98(module-as-user.sh),=20AriMail=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20DB=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C,=20=EB=8F=84=EC=9E=85=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20REVISION=20=EB=B2=84=EC=A0=84,=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=9B=20=EB=B3=B4=EC=A1=B4,=20=ED=8F=AC=ED=84=B8?= =?UTF-8?q?=20nginx=20=EC=A0=91=EA=B7=BC=20=EA=B2=BD=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent: 유닛 User 가 Agent 계정과 다르면 deploy/module-as-user.sh 로 그 계정이 풀기·전환·DB 백업·마이그레이션 수행(sudoers 최소 규칙) - AriMail 마이그레이션에 유닛 EnvironmentFile(DB_FILE) 적용 - 도입 모듈 실제 버전은 REVISION.json 의 Suite 버전, 상류 버전은 upstreamVersion - Webmaker: keepExistingUnit 으로 운영 유닛의 강화 지시문 보존 - bootstrap-hal.sh: my.ariweb.net nginx 접근 경로 설치 - test/agent.test.mjs 회귀 테스트, VERSION_HISTORY·RUNBOOK 갱신, 로컬 E2E 보고서(릴리스 빌드 실행분) 반영 Co-Authored-By: Claude Fable 5.1 --- VERSION_HISTORY.md | 8 + agent/jobs/module.mjs | 112 +++++++--- agent/sys.mjs | 4 +- deploy/bootstrap-hal.sh | 8 +- deploy/module-as-user.sh | 49 +++++ deploy/sudoers/ariweb-agent | 3 + docs/RUNBOOK-0.1.1.md | 11 +- docs/verification/local-e2e-2026-10-26.json | 226 ++++++++++---------- docs/verification/local-e2e-2026-10-26.md | 152 ++++++------- hal/modules/index.mjs | 6 +- test/agent.test.mjs | 39 ++++ 11 files changed, 394 insertions(+), 224 deletions(-) create mode 100755 deploy/module-as-user.sh diff --git a/VERSION_HISTORY.md b/VERSION_HISTORY.md index 00aaf12..1e89843 100644 --- a/VERSION_HISTORY.md +++ b/VERSION_HISTORY.md @@ -91,6 +91,14 @@ - 긴급 복구(§33): 사건 번호·사유·대상·허용 작업·짧은 유효 시간, 재인증 + 서로 다른 내용의 3단계 승인(한 명도 가능, 단계별 시각 기록), 모든 작업 기록, 자격 1회 표시·정상화 후 만료, 허용 범위(HAL·HAL 저장 정보·비밀정보·인증서 기반·artifact 저장소·최초 Agent 연결) 밖 작업 거부, HAL 정상화 뒤 실제/원하는 상태 비교·조정. - 플랫폼 재해복구(§34): HAL 운영 정보·진행 작업·로그인·비밀정보·인증서 기반·배치·서버 등록 정보의 복구 사본·외부 보관·검증·훈련 기록, RTO/RPO/주기/보존 설정과 측정값 비교, 복구 시작 별도 승인과 기존 제어 위치 중지 확인, 하나의 HAL 만 운영(임대, 같은 호스트의 죽은 프로세스는 이어받음), 훈련 성공 전 정식 운영 준비 미표시. +### 실제 환경 배포에서 발견한 결함 수정(2026-10-26, ariweb.net 호스트 첫 배포) +- **Agent 실행 계정 모드**(`agent/jobs/module.mjs`, `deploy/module-as-user.sh`, `deploy/sudoers/ariweb-agent`): 도입 모듈 AriMail 은 서버에서 다른 계정(`arihakdang`)으로 운영되어 `/srv/arihakdang-mail` 을 Agent(master)가 쓸 수 없었다. 유닛의 `User=` 가 Agent 계정과 다르면 풀기·REVISION·current 전환·정리·DB 백업·마이그레이션을 `sudo -n -u <계정> bash deploy/module-as-user.sh` 로 그 계정이 수행한다(sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용, 경로는 모듈 디렉터리 안으로 제한). artifact 는 `/agent/work` 에 받는다. +- **AriMail 마이그레이션 DB 경로**: `server/db-migrate.mjs` 는 `DB_FILE` 환경이 없으면 릴리스 디렉터리 안에 빈 DB 를 만든다. 유닛의 `EnvironmentFile`(`/etc/<유닛>/gateway.env`)을 실행 계정으로 읽어 실행하도록 고쳤다(직접 모드도 같은 파일을 읽는다). +- **도입 모듈 실제 버전**: Webmaker(`package.json` 1.7.3)·AriMail(1.0.11)의 상류 버전이 HAL 의 목표 버전(0.1.1)과 달라 `version_mismatch` 로 배포가 실패했을 것이다. 풀 때 적는 `REVISION.json` 의 Suite 릴리스 버전을 실제 버전으로 읽고 상류 버전은 `upstreamVersion` 으로 함께 보고한다. +- **기존 유닛 보존**(`hal/modules/index.mjs` `keepExistingUnit`): Webmaker 의 운영 유닛은 `ProtectSystem=strict`·`ReadWritePaths` 등 강화 지시문을 갖는데 템플릿이 Environment 줄만 보존하고 나머지를 지웠을 것이다. 유닛이 이미 `/current` 를 가리키면 다시 쓰지 않는다. +- **포털 nginx 접근 경로**(`deploy/bootstrap-hal.sh`): `deploy/nginx/ariweb-portal.conf.tmpl` 이 어디서도 적용되지 않아 `my.ariweb.net` 이 열리지 않았다. 부트스트랩 4단계(접근 경로 준비)에서 HAL 과 함께 설치한다. +- 회귀 테스트: `test/agent.test.mjs` 에 실행 계정 모드(도우미 스크립트 실제 실행·경로 제한 거부)·REVISION 버전·기존 유닛 보존 검사를 추가했다. + ### 도입 소스 - 가져오기: `node scripts/import-sources.mjs` 가 고정 커밋(Webmaker `3b10859884ef2fd0f2af11934ab6c16370fd64b8`, AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`)을 검사하고 실행에 필요 없는 문서·보고서·샘플을 제외한 전체를 `modules/webmaker`(176 파일)·`modules/arimail`(88 파일)로 복사, 파일별 원본 경로·대상 경로·수정 여부·원본 해시·라이선스 검토를 `IMPORTED_SOURCES.md`/`.json` 에 기록한다. - 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함. diff --git a/agent/jobs/module.mjs b/agent/jobs/module.mjs index 9c8079d..03ccf6f 100644 --- a/agent/jobs/module.mjs +++ b/agent/jobs/module.mjs @@ -5,29 +5,79 @@ * 절차(원본 remote-update.sh / deploy.sh 의 순서를 따른다): artifact 내려받기·sha256 확인 → releases/ 에 풀기 → preStop 절차(데이터 백업) → 유닛 갱신(템플릿이 있을 때) → current 전환 → postStart 절차(마이그레이션 등) → 재시작 → 건강 확인 → 실제 버전 읽기. * 실패하면 current 를 이전 릴리스로 되돌리고 재시작한 뒤 오류를 보고한다(명령 성공만으로 완료하지 않는다). * 자기 자신(agent)·HAL 갱신: 결과를 먼저 보고한 뒤 재시작하도록 restartAfterReport 를 돌려준다. + * + * [실제 환경 결함 수정 0.1.1] 실행 계정이 다른 도입 모듈: 유닛의 User= 가 Agent 계정과 다르면(예: AriMail 의 arihakdang) 그 디렉터리에 Agent 가 직접 쓸 수 없다. + * 이때는 모든 파일 작업(풀기·REVISION·current 전환·정리·DB 백업·마이그레이션)을 `sudo -n -u <계정> bash deploy/module-as-user.sh …` 로 그 계정이 수행한다 + * (sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용). artifact 는 Agent 의 작업 디렉터리(/agent/work)에 받아 그 계정이 읽게 한다. + * 마이그레이션은 유닛의 EnvironmentFile(DB_FILE 등)을 같은 계정으로 읽어 실행한다 — 없으면 릴리스 디렉터리 안에 빈 DB 를 만드는 잘못된 동작이 된다. + * [실제 환경 결함 수정 0.1.1] 실제 버전: 도입 모듈의 package.json 버전(Webmaker 1.7.3·AriMail 1.0.11)은 Suite 릴리스 버전(0.1.1)과 다르다. + * HAL 은 "목표 릴리스 버전 = 실제 버전" 으로 판정하므로, 풀 때 적은 REVISION.json 의 version 을 실제 버전으로 읽고 package.json 버전은 upstreamVersion 으로 함께 보고한다. + * [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 /current 를 가리키면 유닛을 다시 쓰지 않는다 + * (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다. */ -import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, copyFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import { userInfo } from 'node:os'; import http from 'node:http'; -import { readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs'; +import { ROOT, readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs'; /** 내부 건강 확인 URL 호출 */ function httpGet(url, timeoutMs = 8000) { return new Promise((resolve) => { const t0 = Date.now(); const req = http.get(url, (res) => { let body = ''; res.on('data', (c) => { if (body.length < 100000) body += c; }); res.on('end', () => resolve({ status: res.statusCode, body, ms: Date.now() - t0 })); }); req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout'))); req.on('error', (e) => resolve({ status: 0, error: e.message, ms: Date.now() - t0 })); }); } async function health(ctx, desc) { if (ctx.sys.stub) return { ok: true, stubbed: true }; if (!desc?.health?.url) return { ok: true, skipped: 'no_health_url' }; let last = null; for (let i = 0; i < 20; i++) { const r = await httpGet(desc.health.url); last = r; if (r.status && r.status < 500 && (desc.health.expect !== 'ok' || /"ok"\s*:\s*true|ok/.test(r.body))) return { ok: true, status: r.status, ms: r.ms }; await new Promise((s) => setTimeout(s, 1500)); } return { ok: false, status: last?.status, error: last?.error || `상태 ${last?.status}` }; } -async function detectVersion(ctx, desc, appDir) { const cur = readLink(join(appDir, 'current')); if (desc?.version?.http && !ctx.sys.stub) { const r = await httpGet(desc.version.http); try { const j = JSON.parse(r.body); if (j[desc.version.field]) return j[desc.version.field]; } catch (_) {} } const pkg = cur ? readJson(join(cur, 'package.json'), null) : null; return pkg?.version || (cur ? cur.split('/').pop() : null); } +/** + * 실제 버전 읽기. 우선순위: 건강 응답의 version(HAL·포털) → current/REVISION.json 의 Suite 릴리스 버전(도입 모듈) → package.json → 디렉터리 이름. + * 도입 모듈은 package.json 이 상류 버전이라 upstreamVersion 으로 따로 돌려준다(화면·영수증 표시용). + */ +async function detectVersion(ctx, desc, appDir) { + const cur = readLink(join(appDir, 'current')); const pkg = cur ? readJson(join(cur, 'package.json'), null) : null; const rev = cur ? readJson(join(cur, 'REVISION.json'), null) : null; + if (desc?.version?.http && !ctx.sys.stub) { const r = await httpGet(desc.version.http); try { const j = JSON.parse(r.body); if (j[desc.version.field]) return { version: j[desc.version.field], upstreamVersion: pkg?.version || null }; } catch (_) {} } + if (rev?.version) return { version: rev.version, upstreamVersion: pkg?.version && pkg.version !== rev.version ? pkg.version : null }; + return { version: pkg?.version || (cur ? cur.split('/').pop() : null), upstreamVersion: null }; +} +/** 유닛에서 실행 계정·EnvironmentFile·node 경로를 읽는다. 계정이 Agent 계정과 다르면 asUser 모드(파일 작업을 그 계정으로). */ +async function serviceContext(ctx, unit) { + const txt = unit ? await ctx.sys.unitText(unit) : ''; const me = (() => { try { return userInfo().username; } catch (_) { return process.env.USER || ''; } })(); + const user = (/^User=(.+)$/m.exec(txt) || [])[1]?.trim() || null; const envFile = ((/^EnvironmentFile=(.+)$/m.exec(txt) || [])[1] || '').replace(/^-/, '').trim() || null; const node = (/^ExecStart=(\S+)/m.exec(txt) || [])[1] || null; + return { unitText: txt, user, envFile, nodeBin: node && /node$/.test(node) ? node : null, asUser: !!(user && user !== me && user !== 'root'), me }; +} +/** 실행 계정으로 파일 작업(deploy/module-as-user.sh). 실패하면 stderr 를 포함한 오류를 던진다. */ +async function asUser(ctx, svc, appDir, sub, args, { input = null } = {}) { + const r = await ctx.sys.run('sudo', ['-n', '-u', svc.user, '/usr/bin/bash', join(ROOT, 'deploy', 'module-as-user.sh'), sub, '--app-dir', appDir, ...args], { input, timeoutMs: 600000 }); + if (r.code !== 0) throw Object.assign(new Error(`실행 계정(${svc.user})으로 ${sub} 실패: ${(r.stderr || r.stdout || '').trim().slice(0, 300) || `코드 ${r.code}`}`), { code: r.code === 1 && /sudo|password/i.test(r.stderr || '') ? 'sudo_denied' : 'as_user_failed', hint: 'deploy/sudoers/ariweb-agent 의 module-as-user.sh 규칙이 설치되어 있어야 합니다' }); + return r; +} +/** 파일 쓰기·심링크 전환·삭제를 모드(직접/실행 계정)에 맞게 수행 */ +const fsOps = (ctx, svc, appDir) => ({ + writeJson: async (p, v) => (svc.asUser ? asUser(ctx, svc, appDir, 'write', ['--file', p], { input: JSON.stringify(v, null, 1) }) : writeJson(p, v)), + switchLink: async (link, target) => (svc.asUser ? asUser(ctx, svc, appDir, 'switch', ['--link', link, '--target', target]) : switchLink(link, target)), + remove: async (dir) => (svc.asUser ? asUser(ctx, svc, appDir, 'remove', ['--dir', dir]) : rmrf(dir)), +}); /** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */ -async function writeUnit(ctx, desc, unit, appDir, version) { +async function writeUnit(ctx, desc, unit, appDir, version, svc) { if (!ctx.payload.unitTemplate) return { skipped: 'no_template' }; - const old = await ctx.sys.unitText(unit); const envLines = old.split('\n').filter((l) => /^Environment=/.test(l) && !/^Environment=(NODE_ENV|HOST|PORT|ARIWEB_[A-Z_]+_DATA|NODE_OPTIONS)=/.test(l)).join('\n'); - const user = (/^User=(.+)$/m.exec(old) || [])[1] || desc.user || process.env.USER || 'master'; const envFile = (/^EnvironmentFile=(.+)$/m.exec(old) || [])[1] || ''; const node = (/^ExecStart=(\S+)/m.exec(old) || [])[1] || process.execPath; - const txt = render(ctx.payload.unitTemplate, { APP_DIR: appDir, USER: user, NODE: /node$/.test(node) ? node : process.execPath, ENV_LINES: envLines, VERSION: version, UNIT: unit, ENV_FILE: envFile }); + const old = svc.unitText; + if (desc.keepExistingUnit && new RegExp(`^WorkingDirectory=${appDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/current/?$`, 'm').test(old)) return { skipped: 'existing_unit_kept', user: svc.user }; + const envLines = old.split('\n').filter((l) => /^Environment=/.test(l) && !/^Environment=(NODE_ENV|HOST|PORT|ARIWEB_[A-Z_]+_DATA|NODE_OPTIONS)=/.test(l)).join('\n'); + const user = svc.user || desc.user || process.env.USER || 'master'; const envFile = svc.envFile || ''; const node = svc.nodeBin || process.execPath; + const txt = render(ctx.payload.unitTemplate, { APP_DIR: appDir, USER: user, NODE: node, ENV_LINES: envLines, VERSION: version, UNIT: unit, ENV_FILE: envFile }); await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); return { written: true, preservedEnv: envLines.split('\n').filter(Boolean).length }; } +/** EnvironmentFile(KEY=값 줄) 읽기 — 직접 모드의 마이그레이션에 싣는다. 값은 돌려주되 로그에는 쓰지 않는다. */ +function readEnvFile(p) { const out = {}; if (!p || !existsSync(p)) return out; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); if (!m) continue; let v = m[2].trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); out[m[1]] = v; } return out; } /** preStop/postStart 절차 */ -async function hook(ctx, name, { appDir, cur, unit }) { - if (name === 'webmaker.backupData') { const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(await ctx.sys.unitText(unit)) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true }); await ctx.sys.tar(['-czf', out, '-C', dataRoot, '--exclude=./backups', '.']); const old = readdirSync(join(appDir, 'backups')).sort(); for (const f of old.slice(0, Math.max(0, old.length - 5))) rmSync(join(appDir, 'backups', f), { force: true }); return { backup: out }; } - if (name === 'arimail.backupDb') { const db = join(appDir, 'data', 'mail-gateway.sqlite'); if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out}'`); } finally { d.close(); } return { backup: out }; } - if (name === 'arimail.migrate') { const db = join(appDir, 'data', 'mail-gateway.sqlite'); const r = await ctx.sys.run(process.execPath, [join(cur, 'server', 'db-migrate.mjs')], { allowStub: false, timeoutMs: 120000 }).catch((e) => ({ code: 1, stderr: e.message })); if (!ctx.sys.stub && !existsSync(join(cur, 'server', 'db-migrate.mjs'))) return { skipped: 'no_migrate' }; if (r.code !== 0 && !ctx.sys.stub) throw Object.assign(new Error(`AriMail 마이그레이션 실패: ${(r.stderr || '').slice(0, 300)}`), { code: 'migrate_failed' }); return { migrated: true, db }; } +async function hook(ctx, name, { appDir, cur, unit, svc }) { + if (name === 'webmaker.backupData') { const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(svc.unitText) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true }); await ctx.sys.tar(['-czf', out, '-C', dataRoot, '--exclude=./backups', '.']); const old = readdirSync(join(appDir, 'backups')).sort(); for (const f of old.slice(0, Math.max(0, old.length - 5))) rmSync(join(appDir, 'backups', f), { force: true }); return { backup: out }; } + if (name === 'arimail.backupDb') { + const env = svc.asUser ? {} : readEnvFile(svc.envFile); const db = env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite'); const out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); + if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'backup-db', ['--db', db, '--out', out, ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_db' } : { backup: out, via: svc.user }; } + if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`); } finally { d.close(); } return { backup: out }; + } + if (name === 'arimail.migrate') { + const script = join('server', 'db-migrate.mjs'); if (!ctx.sys.stub && !existsSync(join(cur, script))) return { skipped: 'no_migrate' }; + if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'migrate', ['--cwd', cur, '--script', script, ...(svc.envFile ? ['--env-file', svc.envFile] : []), ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_migrate' } : { migrated: true, via: svc.user, envFile: svc.envFile || null }; } + const env = readEnvFile(svc.envFile); const r = await ctx.sys.run(svc.nodeBin || process.execPath, [join(cur, script)], { allowStub: false, timeoutMs: 120000, env: { ...process.env, ...env } }).catch((e) => ({ code: 1, stderr: e.message })); + if (r.code !== 0 && !ctx.sys.stub) throw Object.assign(new Error(`AriMail 마이그레이션 실패: ${(r.stderr || '').slice(0, 300)}`), { code: 'migrate_failed' }); return { migrated: true, db: env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite') }; + } if (name === 'arimail.haraka') { const r = await ctx.sys.systemctl('restart', `${unit}-haraka`, { optional: true }); return { haraka: r.code === 0 ? 'restarted' : 'skipped' }; } if (name === 'webmaker.unitCheck') { const r = await ctx.sys.run(process.execPath, [join(cur, 'scripts', 'unit-check.js')], { allowStub: true, timeoutMs: 60000 }).catch(() => ({ code: 1 })); return { unitCheck: r.code === 0 ? 'ok' : 'warn' }; } return { skipped: `unknown hook ${name}` }; @@ -42,36 +92,46 @@ function resolveAppDir(ctx, p) { if (srv !== '/srv/ariweb' && String(given).startsWith('/srv/ariweb/')) return join(srv, String(given).slice('/srv/ariweb/'.length)); return given; } -async function installRelease(ctx, { module, version, appDir, desc, unit, action, toVersion }) { +async function installRelease(ctx, { module, version, appDir, desc, action, toVersion, svc }) { const relDir = join(appDir, 'releases', action === 'rollback' ? (toVersion || '') : version); const prev = readLink(join(appDir, 'current')); if (action === 'rollback') { const target = toVersion ? relDir : readJson(join(appDir, '.previous.json'), {}).previous; if (!target || !existsSync(target)) throw Object.assign(new Error('되돌릴 이전 릴리스가 없습니다'), { code: 'no_previous_release' }); return { relDir: target, prev }; } - await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(join(appDir, 'work'), { recursive: true }); const f = join(appDir, 'work', `${module}-${version}.tar.gz`); const r = await ctx.client.download(ctx.payload.artifactUrl, f, ctx.payload.sha256 || null); return { file: f, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증'); - await ctx.step('extract', async () => { rmrf(relDir); mkdirSync(relDir, { recursive: true }); await untar(ctx, join(appDir, 'work', `${module}-${version}.tar.gz`), relDir); if (desc.copyPaths && !existsSync(join(relDir, 'package.json'))) throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); writeJson(join(relDir, 'REVISION.json'), { module, version, installedAt: new Date().toISOString() }); rmSync(join(appDir, 'work', `${module}-${version}.tar.gz`), { force: true }); return { dir: relDir }; }, '릴리스 풀기'); + // 실행 계정 모드에서는 appDir 에 쓸 수 없으므로 Agent 의 작업 디렉터리에 받는다(그 계정이 읽을 수 있게 0644). + const workDir = svc.asUser ? join(ctx.roots.srv, 'agent', 'work') : join(appDir, 'work'); const tgz = join(workDir, `${module}-${version}.tar.gz`); + await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(workDir, { recursive: true }); const r = await ctx.client.download(ctx.payload.artifactUrl, tgz, ctx.payload.sha256 || null); return { file: tgz, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증'); + await ctx.step('extract', async () => { + if (svc.asUser) await asUser(ctx, svc, appDir, 'extract', ['--tgz', tgz, '--dest', relDir]); else { rmrf(relDir); mkdirSync(relDir, { recursive: true }); await untar(ctx, tgz, relDir); } + if (desc.copyPaths && !existsSync(join(relDir, 'package.json'))) throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); + await fsOps(ctx, svc, appDir).writeJson(join(relDir, 'REVISION.json'), { module, version, upstreamVersion: readJson(join(relDir, 'package.json'), {}).version || null, installedAt: new Date().toISOString(), by: svc.asUser ? svc.user : svc.me }); + rmSync(tgz, { force: true }); return { dir: relDir, asUser: svc.asUser ? svc.user : null }; + }, '릴리스 풀기'); return { relDir, prev }; } export const kinds = { 'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } }, 'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } }, - 'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); return { module: p.module, actualVersion: await detectVersion(ctx, desc, appDir), active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } }, + 'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); const v = await detectVersion(ctx, desc, appDir); return { module: p.module, actualVersion: v.version, upstreamVersion: v.upstreamVersion, active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } }, }; async function installOrRollback(ctx, action) { const p = ctx.payload; const { module, version, descriptor: desc = {}, toVersion = null } = p; const appDir = resolveAppDir(ctx, p); const unit = (desc && desc.unit) || `ariweb-${module}`; - mkdirSync(join(appDir, 'releases'), { recursive: true }); - const { relDir, prev } = await ctx.step('place', () => installRelease(ctx, { module, version, appDir, desc, unit, action, toVersion }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치'); + const svc = await serviceContext(ctx, unit); const ops = fsOps(ctx, svc, appDir); + if (!svc.asUser) mkdirSync(join(appDir, 'releases'), { recursive: true }); + if (svc.asUser) ctx.log(`실행 계정 모드: 유닛 ${unit} 의 User=${svc.user} 로 파일 작업을 수행합니다`); + const { relDir, prev } = await ctx.step('place', () => installRelease(ctx, { module, version, appDir, desc, action, toVersion, svc }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치'); await ctx.progress(40); - for (const h of desc.preStop || []) await ctx.step(`pre:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit }), `사전 절차 ${h}`); - await ctx.step('unit', () => writeUnit(ctx, desc, unit, appDir, version), '실행 유닛 갱신'); - await ctx.step('switch', () => { switchLink(join(appDir, 'current'), relDir); writeJson(join(appDir, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { from: prev, to: relDir }; }, 'current 전환'); - for (const h of desc.postStart || []) await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit }), `사후 절차 ${h}`); + for (const h of desc.preStop || []) await ctx.step(`pre:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사전 절차 ${h}`); + await ctx.step('unit', () => writeUnit(ctx, desc, unit, appDir, version, svc), '실행 유닛 갱신'); + await ctx.step('switch', async () => { await ops.switchLink(join(appDir, 'current'), relDir); await ops.writeJson(join(appDir, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { from: prev, to: relDir }; }, 'current 전환'); + for (const h of desc.postStart || []) await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사후 절차 ${h}`); const self = module === 'agent'; let healthRes = { ok: true }; + const revert = async () => { if (prev && existsSync(prev)) { await ops.switchLink(join(appDir, 'current'), prev).catch(() => {}); await ctx.sys.systemctl('restart', unit, { optional: true }); } }; if (!self && desc.layout !== 'agent-feature') { try { await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '서비스 재시작'); for (const u of desc.extraUnits || []) await ctx.sys.systemctl('restart', u, { optional: true }); } - catch (e) { if (prev && existsSync(prev)) { switchLink(join(appDir, 'current'), prev); await ctx.sys.systemctl('restart', unit, { optional: true }); } throw Object.assign(e, { code: e.code || 'restart_failed', partial: { rolledBackTo: prev } }); } + catch (e) { await revert(); throw Object.assign(e, { code: e.code || 'restart_failed', partial: { rolledBackTo: prev } }); } healthRes = await ctx.step('health', () => health(ctx, desc), '건강 확인'); - if (!healthRes.ok) { if (prev && existsSync(prev)) { switchLink(join(appDir, 'current'), prev); await ctx.sys.systemctl('restart', unit, { optional: true }); } throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); } + if (!healthRes.ok) { await revert(); throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); } } - const actual = self ? readJson(join(relDir, 'package.json'), {}).version || version : await ctx.step('version', () => detectVersion(ctx, desc, appDir), '실제 버전 읽기'); + const ver = self ? { version: readJson(join(relDir, 'package.json'), {}).version || version, upstreamVersion: null } : await ctx.step('version', () => detectVersion(ctx, desc, appDir), '실제 버전 읽기'); // 오래된 릴리스 정리(최근 3개 보존) - const rels = readdirSync(join(appDir, 'releases')).sort(); for (const r of rels.slice(0, Math.max(0, rels.length - 3))) { const d = join(appDir, 'releases', r); if (d !== relDir && d !== prev) rmrf(d); } - return { module, actualVersion: actual, previousVersion: prev ? prev.split('/').pop() : null, health: healthRes, active: true, restartAfterReport: self ? unit : null, releaseDir: relDir }; + const rels = readdirSync(join(appDir, 'releases')).sort(); for (const r of rels.slice(0, Math.max(0, rels.length - 3))) { const d = join(appDir, 'releases', r); if (d !== relDir && d !== prev) await ops.remove(d).catch((e) => ctx.log(`오래된 릴리스 정리 실패(${r}): ${e.message}`)); } + return { module, actualVersion: ver.version, upstreamVersion: ver.upstreamVersion, previousVersion: prev ? prev.split('/').pop() : null, health: healthRes, active: true, restartAfterReport: self ? unit : null, releaseDir: relDir, runUser: svc.user || svc.me }; } diff --git a/agent/sys.mjs b/agent/sys.mjs index 9276929..4fab945 100644 --- a/agent/sys.mjs +++ b/agent/sys.mjs @@ -13,10 +13,10 @@ export class NeedsUser extends Error { constructor(message, hint) { super(messag export function createSys({ stub = process.env.ARIWEB_AGENT_STUB_CMDS === '1', log = () => {} } = {}) { const calls = []; /** 명령 실행. 실패해도 예외 대신 { code, stdout, stderr } 를 돌려준다(호출자가 판단). */ - function run(cmd, args = [], { input = null, timeoutMs = 600000, allowStub = true } = {}) { + function run(cmd, args = [], { input = null, timeoutMs = 600000, allowStub = true, env = null } = {}) { calls.push({ cmd, args }); if (stub && allowStub && cmd !== 'tar' && cmd !== 'node') return Promise.resolve({ code: 0, stdout: '', stderr: '', stubbed: true }); - return new Promise((resolve) => { const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); let out = '', err = ''; const t = setTimeout(() => p.kill('SIGKILL'), timeoutMs); p.stdout.on('data', (c) => { out += c; }); p.stderr.on('data', (c) => { err += c; }); p.on('error', (e) => { clearTimeout(t); resolve({ code: 127, stdout: out, stderr: e.message }); }); p.on('close', (code) => { clearTimeout(t); resolve({ code, stdout: out, stderr: err }); }); if (input != null) p.stdin.end(input); else p.stdin.end(); }); + return new Promise((resolve) => { const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: env || process.env }); let out = '', err = ''; const t = setTimeout(() => p.kill('SIGKILL'), timeoutMs); p.stdout.on('data', (c) => { out += c; }); p.stderr.on('data', (c) => { err += c; }); p.on('error', (e) => { clearTimeout(t); resolve({ code: 127, stdout: out, stderr: e.message }); }); p.on('close', (code) => { clearTimeout(t); resolve({ code, stdout: out, stderr: err }); }); if (input != null) p.stdin.end(input); else p.stdin.end(); }); } const sudo = (args, o) => run('sudo', ['-n', ...args], o); async function hasSudo() { if (stub) return true; const r = await run('sudo', ['-n', 'true']); return r.code === 0; } diff --git a/deploy/bootstrap-hal.sh b/deploy/bootstrap-hal.sh index 995cbbf..8f81475 100755 --- a/deploy/bootstrap-hal.sh +++ b/deploy/bootstrap-hal.sh @@ -37,10 +37,14 @@ echo "== 3/6 systemd 유닛 ariweb-hal" sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" deploy/units/ariweb-hal.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-hal.service >/dev/null sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent; sudo -n visudo -c >/dev/null sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-hal >/dev/null; sleep 2; systemctl is-active --quiet ariweb-hal || { echo "ariweb-hal 기동 실패 — journalctl -u ariweb-hal" >&2; exit 1; } -echo "== 4/6 nginx $HOST (인증서 $CERT)" +echo "== 4/6 nginx $HOST · $PORTAL_HOST (인증서 $CERT)" [ -f "$CERT/fullchain.pem" ] || { echo "인증서 $CERT/fullchain.pem 이 없습니다" >&2; exit 1; } sed -e "s#{{HAL_HOST}}#$HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8900#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-hal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-hal.conf >/dev/null -sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf; sudo -n nginx -t && sudo -n systemctl reload nginx +sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf +# 포털 접근 경로(my.ariweb.net → 127.0.0.1:8910)도 여기서 준비한다 — 포털 자체는 HAL 작업으로 설치되지만 nginx 접근 경로는 "접근 경로 준비"(§32.2 2단계)에 속한다(실제 환경 결함 수정: 이전에는 어디서도 만들지 않았다). +sed -e "s#{{PORTAL_HOST}}#$PORTAL_HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8910#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-portal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-portal.conf >/dev/null +sudo -n ln -sf /etc/nginx/sites-available/ariweb-portal.conf /etc/nginx/sites-enabled/ariweb-portal.conf +sudo -n nginx -t && sudo -n systemctl reload nginx echo "== 5/6 HAL 자체 건강 확인" curl -fsS http://127.0.0.1:8900/api/health | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const h=JSON.parse(s);console.log(' HAL',h.version,'db',h.db,'jobs',h.jobs,'bootstrap',JSON.stringify(h.bootstrap))})" curl -fsS --resolve "$HOST:443:127.0.0.1" "https://$HOST/api/health" >/dev/null && echo " https://$HOST/api/health OK (로컬 resolve)" || echo " 경고: https://$HOST 확인 실패 — DNS A 레코드($HOST → 이 서버)와 인증서를 확인하세요" diff --git a/deploy/module-as-user.sh b/deploy/module-as-user.sh new file mode 100755 index 0000000..cfb3696 --- /dev/null +++ b/deploy/module-as-user.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# deploy/module-as-user.sh — 도입 모듈을 "그 모듈의 실행 계정" 으로 설치하기 위한 최소 파일 작업 도우미(§32). +# +# 왜 — Node Agent 는 master 계정으로 돌지만 도입 모듈(AriMail 등)은 서버에 이미 다른 계정(arihakdang 등)으로 설치되어 있고 +# 그 디렉터리(/srv/<모듈>/releases, data)는 그 계정만 쓸 수 있다. Agent 가 직접 쓰면 EACCES 로 끝나므로(실제 환경 검증에서 발견), +# sudoers(deploy/sudoers/ariweb-agent)에 "이 스크립트만 root 가 아닌 임의 계정으로" 허용하고 Agent 가 `sudo -n -u <계정> bash <이 파일> <하위 명령>` 으로 부른다. +# 하위 명령은 모듈 배치에 필요한 것만 두고, 모든 경로 인자는 --app-dir 아래(또는 명시한 artifact)로 제한한다. 비밀값은 다루지 않고 출력하지 않는다. +# +# 사용: module-as-user.sh <하위 명령> --app-dir <모듈 디렉터리> [인자…] +# extract --tgz --dest > 릴리스 디렉터리를 비우고 artifact 를 푼다 +# write --file 표준 입력을 파일로 쓴다(REVISION.json·.previous.json) +# switch --link --target <릴리스 디렉터리> 심링크 원자 교체(ln → mv -T) +# remove --dir > 오래된 릴리스 삭제(releases 아래만) +# backup-db --db --out <백업 파일> [--node ] SQLite 온라인 백업(VACUUM INTO) +# migrate --cwd <릴리스 디렉터리> --script <상대 경로> [--env-file ] [--node ] EnvironmentFile 을 읽어 마이그레이션 실행 +set -euo pipefail +SUB="${1:-}"; shift || true +APP_DIR=""; TGZ=""; DEST=""; FILE=""; LINK=""; TARGET=""; DIR=""; DB=""; OUT=""; NODE_BIN=""; CWD=""; SCRIPT=""; ENV_FILE="" +while [ $# -gt 0 ]; do case "$1" in + --app-dir) APP_DIR="$2"; shift 2;; --tgz) TGZ="$2"; shift 2;; --dest) DEST="$2"; shift 2;; --file) FILE="$2"; shift 2;; --link) LINK="$2"; shift 2;; --target) TARGET="$2"; shift 2;; + --dir) DIR="$2"; shift 2;; --db) DB="$2"; shift 2;; --out) OUT="$2"; shift 2;; --node) NODE_BIN="$2"; shift 2;; --cwd) CWD="$2"; shift 2;; --script) SCRIPT="$2"; shift 2;; --env-file) ENV_FILE="$2"; shift 2;; + *) echo "알 수 없는 인자 $1" >&2; exit 2;; esac; done +[ -n "$APP_DIR" ] && [ -d "$APP_DIR" ] || { echo "--app-dir <존재하는 디렉터리> 가 필요합니다" >&2; exit 2; } +APP_DIR="$(cd "$APP_DIR" && pwd -P)" +# 경로가 appDir 아래인지(심링크를 따라간 실제 경로 기준, 아직 없는 경로는 부모 기준) 확인한다 — 다른 디렉터리를 건드리지 못하게. +inside() { local p="$1" d real; d="$(dirname "$p")"; while [ ! -d "$d" ] && [ "$d" != "/" ]; do d="$(dirname "$d")"; done; real="$(cd "$d" && pwd -P)"; case "$real" in "$APP_DIR"|"$APP_DIR"/*) return 0;; esac; echo "경로가 모듈 디렉터리($APP_DIR) 밖입니다: $p" >&2; exit 3; } +[ -n "$NODE_BIN" ] || NODE_BIN="$(command -v node || true)" +case "$SUB" in + extract) + [ -f "$TGZ" ] || { echo "artifact 가 없습니다: $TGZ" >&2; exit 2; }; inside "$DEST"; case "$DEST" in "$APP_DIR"/releases/*) ;; *) echo "--dest 는 releases 아래여야 합니다" >&2; exit 3;; esac + rm -rf "$DEST"; mkdir -p "$DEST"; tar -xzf "$TGZ" -C "$DEST"; echo "extracted $DEST";; + write) + inside "$FILE"; mkdir -p "$(dirname "$FILE")"; tmp="$FILE.tmp-$$"; cat > "$tmp"; mv -f "$tmp" "$FILE"; echo "written $FILE";; + switch) + inside "$LINK"; inside "$TARGET"; [ -d "$TARGET" ] || { echo "대상 디렉터리가 없습니다: $TARGET" >&2; exit 2; } + ln -sfn "$TARGET" "$LINK.tmp-$$"; mv -Tf "$LINK.tmp-$$" "$LINK"; echo "switched $LINK -> $TARGET";; + remove) + inside "$DIR"; case "$DIR" in "$APP_DIR"/releases/*) ;; *) echo "--dir 는 releases 아래여야 합니다" >&2; exit 3;; esac; rm -rf "$DIR"; echo "removed $DIR";; + backup-db) + inside "$OUT"; [ -f "$DB" ] || { echo "skipped no_db"; exit 0; }; mkdir -p "$(dirname "$OUT")" + # node:sqlite 의 VACUUM INTO 로 WAL 포함 일관된 사본을 만든다(원본 deploy.sh 의 python backup 과 같은 목적). + "$NODE_BIN" --no-warnings -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync(process.argv[1],{readOnly:true});try{d.exec(\"VACUUM INTO '\"+process.argv[2].replace(/'/g,\"''\")+\"'\")}finally{d.close()}" "$DB" "$OUT"; echo "backup $OUT";; + migrate) + inside "$CWD"; [ -f "$CWD/$SCRIPT" ] || { echo "skipped no_migrate"; exit 0; } + # systemd 유닛의 EnvironmentFile(DB_FILE·DATA_DIR 등)을 같은 계정으로 읽어 환경에 싣는다 — 없으면 모듈 기본 경로(릴리스 디렉터리 안)를 쓰게 되어 운영 DB 가 아닌 빈 DB 를 만드는 문제를 막는다. + if [ -n "$ENV_FILE" ]; then [ -r "$ENV_FILE" ] || { echo "EnvironmentFile 을 읽을 수 없습니다: $ENV_FILE" >&2; exit 2; }; set -a; . "$ENV_FILE"; set +a; fi + cd "$CWD"; exec "$NODE_BIN" --no-warnings "$SCRIPT";; + *) echo "알 수 없는 하위 명령: $SUB" >&2; exit 2;; +esac diff --git a/deploy/sudoers/ariweb-agent b/deploy/sudoers/ariweb-agent index e9b97c9..9577c83 100644 --- a/deploy/sudoers/ariweb-agent +++ b/deploy/sudoers/ariweb-agent @@ -7,3 +7,6 @@ master ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/sites-available/aw-*, /usr/b master ALL=(root) NOPASSWD: /usr/bin/ln -sf /etc/nginx/sites-available/aw-* /etc/nginx/sites-enabled/*, /usr/bin/ln -sf /etc/nginx/sites-available/ariweb-* /etc/nginx/sites-enabled/*, /usr/bin/rm -f /etc/nginx/sites-enabled/aw-*, /usr/bin/rm -f /etc/nginx/sites-available/aw-*, /usr/bin/rm -f /etc/systemd/system/ariweb-site-* master ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/nginx/*, /usr/bin/mkdir -p /var/www/*, /usr/bin/chown master\:master /var/www/*, /usr/bin/chmod * /etc/nginx/sites-available/aw-*, /usr/bin/chmod * /etc/systemd/system/ariweb-*, /usr/bin/chmod * /etc/nginx/conf.d/awstats.conf master ALL=(root) NOPASSWD: /usr/bin/true +# 실행 계정이 다른 도입 모듈(AriMail 의 arihakdang 등) 설치: 이 도우미 스크립트만 root 가 아닌 계정으로 허용(agent/jobs/module.mjs 실행 계정 모드) +Runas_Alias ARIWEB_SVC = ALL, !root +master ALL=(ARIWEB_SVC) NOPASSWD: /usr/bin/bash /srv/ariweb/agent/current/deploy/module-as-user.sh * diff --git a/docs/RUNBOOK-0.1.1.md b/docs/RUNBOOK-0.1.1.md index 26f285b..0cc8c89 100644 --- a/docs/RUNBOOK-0.1.1.md +++ b/docs/RUNBOOK-0.1.1.md @@ -23,18 +23,23 @@ bash /tmp/ariweb-hal-src/deploy/bootstrap-hal.sh --dist /tmp/ariweb-dist-0.1.1 - 출력된 **운영자 설정 토큰(1회)** 으로 첫 운영자를 만든다: `POST https://hal.ariweb.net/ops/api/setup {setupToken, login, password}` (허용 네트워크 밖이면 `node hal/index.mjs grant --login ` 로 접근권 URL 발급). 확인: `curl -s https://hal.ariweb.net/api/health` 의 `version`·`db`·`jobs`·`bootstrap.halRunningVersion`. -## 3. 첫 Node Agent(같은 서버, 역할 control+web) +DNS 레코드가 아직 없으면 같은 서버의 Agent·포털이 `https://hal.ariweb.net` 에 닿도록 `/etc/hosts` 에 `127.0.0.1 hal.ariweb.net my.ariweb.net` 을 임시로 둔다(cloud-init 이 `/etc/hosts` 를 관리하는 호스트는 재부팅 때 지워질 수 있다 — DNS 레코드가 생기면 필요 없다). +부트스트랩은 `my.ariweb.net` nginx 접근 경로(→ 127.0.0.1:8910)도 함께 만든다. + +## 3. 첫 Node Agent(같은 서버, 역할 control) 운영자 콘솔 › 서버 › 새 서버(역할 control, 위치 ariweb.net) → 등록 토큰(10분·1회) → ```bash curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token <토큰> --role control ``` -운영자 승인 → `bootstrap.complete=true`, 수동 설치 잠김. 포털 연동 키: `node hal/index.mjs link-portal` (출력 값은 포털 보관소에 1회 입력). +운영자 승인 → `bootstrap.complete=true`, 수동 설치 잠김. 모듈 기술서는 서버 역할을 배포 조건으로 두지 않으므로 한 대(control)에 portal·webmaker·arimail 을 모두 둘 수 있다. +포털 연동 키: `node hal/index.mjs link-portal` 의 출력 값을 포털 보관소에 1회 입력한다. 포털 데이터 디렉터리(`/srv/ariweb/portal/data`)는 배포 전에 만들어 보관소를 먼저 채워도 된다(같은 `lib/vault.mjs`). 값을 화면·로그에 남기지 않으려면 서버 안에서 파이프로 바로 넣는다. ## 4. 모듈 배포(HAL 작업) 운영자 콘솔 › 배포 릴리스 › 0.1.1 승인 확인 → 모듈 배포 순서: `portal` → `webmaker` → `arimail` → (`agent` 갱신). 각 작업의 대상별 결과·실제 버전·건강 상태를 확인한다. HAL 자체 건강이 실패하면 나머지를 배포하지 않는다. - portal: `/srv/ariweb/portal`, 유닛 `ariweb-portal`, nginx `my.ariweb.net`(와일드카드 인증서). 포털 보관소에 `service:hal`(link-portal 출력), `sso:webmaker`, `arimail:*` 키 입력. - webmaker: 기존 `/srv/webmaker` + `/data/webmaker` 유지(HAL 모듈 설명자가 기존 유닛명·데이터 경로를 발견해 보존), 설정 `ariweb.portalUrl`·`ariweb.secret`. -- arimail: 기존 `/srv/arihakdang-mail` 유지, 마이그레이션 `0009_ariweb_queue.sql` 적용, 테넌트 `ariweb.net` 에 `ARIWEB_SSO_SECRET`. +- arimail: 기존 `/srv/arihakdang-mail` 유지(실행 계정 `arihakdang` — Agent 가 `deploy/module-as-user.sh` 로 그 계정이 파일 작업을 하게 한다), 마이그레이션 `0009_ariweb_queue.sql` 적용(유닛의 `EnvironmentFile` 의 `DB_FILE`). 포털 › 운영자 › 메일 서버 등록(`POST /internal/ops/mail/servers`)에 setup-token(`/data/setup-token`)·usage-key(기본 도메인 설정의 `USAGE_API_KEY`)를 넣고, 고객 도메인의 `ARIWEB_SSO_SECRET` 은 포털이 서비스 생성 때 스스로 넣는다. +- 도입 모듈의 실제 버전은 `REVISION.json` 의 Suite 버전(0.1.1)이고 상류 버전(1.7.3·1.0.11)은 `upstreamVersion` 으로 함께 보인다. ## 5. 검증(Release 0.1.1 완료 조건 34개) `docs/verification/` 의 로컬 E2E 결과와 실제 환경 확인 기록을 함께 둔다. 각 흐름은 버튼 성공이 아니라 최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전으로 판정한다. 충족하지 못한 흐름은 완료로 표시하지 않는다. diff --git a/docs/verification/local-e2e-2026-10-26.json b/docs/verification/local-e2e-2026-10-26.json index 4f5b540..16ee493 100644 --- a/docs/verification/local-e2e-2026-10-26.json +++ b/docs/verification/local-e2e-2026-10-26.json @@ -1,14 +1,14 @@ { - "generatedAt": "2026-10-26T13:18:11.518Z", - "startedAt": "2026-10-26T13:15:47.881Z", + "generatedAt": "2026-10-26T13:21:32.783Z", + "startedAt": "2026-10-26T13:19:12.504Z", "env": { "agents": 4, "ports": { - "hal": 42511, - "portal": 32983, - "wm": 35307, - "am": 33813, - "mail": 41473 + "hal": 34077, + "portal": 38431, + "wm": 38359, + "am": 40037, + "mail": 37609 }, "mailServerRegistered": true, "node": "v24.22.1" @@ -99,12 +99,12 @@ { "name": "팀 그룹 만들기 → 현재 그룹 전환", "ok": true, - "detail": "g_01a1785a66113e63a08c972cc5" + "detail": "g_01a1785d8565c2082fe744502a" }, { "name": "이메일 초대(7일) → 초대 메일 링크", "ok": true, - "detail": "uVdQrHwu9UjZmbh1PlCVMIPsJFxbHnvo" + "detail": "rpRdbrRDLUZnw57-ToRz1HleEH4d8Vh3" }, { "name": "초대 수락(1회용) → 별칭 user-02 · 역할 member", @@ -162,7 +162,7 @@ "detail": { "count": 2, "team": { - "id": "pv_01a1785a6358a08c3194ac03c2", + "id": "pv_01a1785d82c1aac82579226bce", "price": 49000, "sites": 3 }, @@ -179,7 +179,7 @@ "orderNo": "AW-20261026-0001", "status": "awaiting_payment", "total": 63800, - "dueAt": "2026-11-02T13:15:52.839Z", + "dueAt": "2026-11-02T13:19:17.470Z", "hasInstructions": true } }, @@ -221,7 +221,7 @@ "detail": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T13:15:52.905Z" + "nextBilling": "2026-11-26T13:19:17.536Z" } } ], @@ -233,7 +233,7 @@ "bill": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T13:15:52.905Z" + "nextBilling": "2026-11-26T13:19:17.536Z" } } }, @@ -248,7 +248,7 @@ "name": "사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내", "ok": true, "detail": { - "id": "s_01a1785a670945da067e955da4", + "id": "s_01a1785d86626c4268d23e8c8c", "lifecycle": "draft", "projectId": "00000000000000000000000000000001", "nextSteps": [ @@ -283,7 +283,7 @@ "name": "제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용)", "ok": true, "detail": { - "portalRedirect": "http://127.0.0.1:35307/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:38359/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 } @@ -299,7 +299,7 @@ ], "evidence": { "sso": { - "portalRedirect": "http://127.0.0.1:35307/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:38359/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 }, @@ -320,7 +320,7 @@ "name": "배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나", "ok": true, "detail": { - "jobId": "j_01a1785a6757555a33d6269bd8", + "jobId": "j_01a1785d86b049e14cbf94b74a", "steps": 10, "again": false, "siteCount": "reserved", @@ -351,7 +351,7 @@ "ok": true, "detail": { "lifecycle": "needs_domain", - "version": "20261026131553-rev-c1-000000", + "version": "20261026131918-rev-c1-000000", "verify": "needs_domain", "health": "ok", "agentHasSite": true @@ -360,7 +360,7 @@ ], "evidence": { "d": { - "jobId": "j_01a1785a6757555a33d6269bd8", + "jobId": "j_01a1785d86b049e14cbf94b74a", "steps": 10, "again": false, "siteCount": "reserved", @@ -383,7 +383,7 @@ }, "after": { "lifecycle": "needs_domain", - "version": "20261026131553-rev-c1-000000", + "version": "20261026131918-rev-c1-000000", "verify": "needs_domain", "health": "ok", "agentHasSite": true @@ -401,7 +401,7 @@ "name": "도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)", "ok": true, "detail": { - "domainId": "d_01a1785a772932b8936927ad0a", + "domainId": "d_01a1785d9684c7a5694cbcebfa", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -422,7 +422,7 @@ ], "evidence": { "dm": { - "domainId": "d_01a1785a772932b8936927ad0a", + "domainId": "d_01a1785d9684c7a5694cbcebfa", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -500,7 +500,7 @@ "name": "사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시", "ok": true, "detail": { - "id": "msv_01a1785affa395a9602c99bbb5", + "id": "msv_01a1785e1f18f7002057347a34", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -517,7 +517,7 @@ "name": "메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음", "ok": true, "detail": { - "id": "mb_01a1785affda99dffac745d1e9", + "id": "mb_01a1785e1f53a4fcfe5daf70b5", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -584,7 +584,7 @@ ], "evidence": { "svc": { - "id": "msv_01a1785affa395a9602c99bbb5", + "id": "msv_01a1785e1f18f7002057347a34", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -592,7 +592,7 @@ "siteCountAllocated": 0 }, "mb": { - "id": "mb_01a1785affda99dffac745d1e9", + "id": "mb_01a1785e1f53a4fcfe5daf70b5", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -644,7 +644,7 @@ "name": "메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)", "ok": true, "detail": { - "id": "d_01a1785b2be9c7ee6d0d94a95c", + "id": "d_01a1785e47703780e4f9bdfe16", "jobCancelled": null } }, @@ -652,7 +652,7 @@ "name": "독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)", "ok": true, "detail": { - "id": "msv_01a1785b2bf14cefa9e1f81fd7", + "id": "msv_01a1785e477805a9a147d2fd87", "kind": "standalone", "mailServicesAllocated": 1, "sitesAllocated": 0 @@ -661,7 +661,7 @@ ], "evidence": { "r": { - "id": "msv_01a1785b2bf14cefa9e1f81fd7", + "id": "msv_01a1785e477805a9a147d2fd87", "kind": "standalone", "mailServicesAllocated": 1, "sitesAllocated": 0 @@ -701,8 +701,8 @@ "ok": true, "detail": { "status": "active", - "firstDue": "2026-10-27T13:16:43.530Z", - "nextRun": "2026-10-26T13:16:43.530Z" + "firstDue": "2026-10-27T13:20:07.184Z", + "nextRun": "2026-10-26T13:20:07.184Z" } }, { @@ -712,7 +712,7 @@ "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": "srv_01a1785a5d26219326f7ad26ec", + "siteServer": "srv_01a1785d7c8e7c81c8953e5bad", "backup": { "status": "done", "tier": "monthly", @@ -733,14 +733,14 @@ }, "opt": { "status": "active", - "firstDue": "2026-10-27T13:16:43.530Z", - "nextRun": "2026-10-26T13:16:43.530Z" + "firstDue": "2026-10-27T13:20:07.184Z", + "nextRun": "2026-10-26T13:20:07.184Z" }, "run1": { "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": "srv_01a1785a5d26219326f7ad26ec", + "siteServer": "srv_01a1785d7c8e7c81c8953e5bad", "backup": { "status": "done", "tier": "monthly", @@ -763,19 +763,19 @@ "ok": true, "detail": { "backup": { - "id": "bk_01a1785b465fad18eaeb8a679e", - "createdAt": "2026-10-26T13:16:43.538Z", + "id": "bk_01a1785e5a2387339b7355af21", + "createdAt": "2026-10-26T13:20:07.192Z", "scope": { "files": true, "db": true, "uploads": true, "config": true, - "storeServerId": "srv_01a1785a5dd874fe86b67f0f38", + "storeServerId": "srv_01a1785d7d38e2cdafa814388f", "verifyMethod": "sha256 + 격리 환경 읽기 확인" }, - "sizeBytes": 1896, + "sizeBytes": 1899, "verifyState": "verified", - "expiresAt": "2027-01-27T13:16:50.144Z", + "expiresAt": "2027-01-27T13:20:11.812Z", "tier": "monthly" }, "included": [ @@ -809,7 +809,7 @@ "name": "복원 요청 → 운영자 승인 대기 · 중복 요청 없음", "ok": true, "detail": { - "id": "rs_01a1785b4a2808cc16818090d9", + "id": "rs_01a1785e5fceb0b45b4373fd82", "status": "awaiting_approval", "dup": false, "finalConfirmation": { @@ -838,19 +838,19 @@ "evidence": { "info": { "backup": { - "id": "bk_01a1785b465fad18eaeb8a679e", - "createdAt": "2026-10-26T13:16:43.538Z", + "id": "bk_01a1785e5a2387339b7355af21", + "createdAt": "2026-10-26T13:20:07.192Z", "scope": { "files": true, "db": true, "uploads": true, "config": true, - "storeServerId": "srv_01a1785a5dd874fe86b67f0f38", + "storeServerId": "srv_01a1785d7d38e2cdafa814388f", "verifyMethod": "sha256 + 격리 환경 읽기 확인" }, - "sizeBytes": 1896, + "sizeBytes": 1899, "verifyState": "verified", - "expiresAt": "2027-01-27T13:16:50.144Z", + "expiresAt": "2027-01-27T13:20:11.812Z", "tier": "monthly" }, "included": [ @@ -880,7 +880,7 @@ "deferred": [] }, "rq": { - "id": "rs_01a1785b4a2808cc16818090d9", + "id": "rs_01a1785e5fceb0b45b4373fd82", "status": "awaiting_approval", "dup": false, "finalConfirmation": { @@ -970,8 +970,8 @@ "name": "외부 배포 사이트 생성(초안) + 저장본", "ok": true, "detail": { - "id": "s_01a1785c35f8d649e375f0c391", - "group_id": "g_01a1785a66113e63a08c972cc5", + "id": "s_01a1785f4b4aa64e53cac1fb30", + "group_id": "g_01a1785d8565c2082fe744502a", "name": "E2E 외부 사이트", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000004", @@ -999,9 +999,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1785a65614c4deeec61ea3d", - "created_at": "2026-10-26T13:17:51.480Z", - "updated_at": "2026-10-26T13:17:51.485Z", + "created_by": "u_01a1785d84b8758824db3f9dbd", + "created_at": "2026-10-26T13:21:13.547Z", + "updated_at": "2026-10-26T13:21:13.553Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -1042,8 +1042,8 @@ "ok": true, "detail": { "token": "[redacted]", - "install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391", - "expiresAt": "2026-10-26T13:27:51.761Z", + "install": "curl -fsSL http://127.0.0.1:34077/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:34077 --token … --external --site s_01a1785f4b4aa64e53cac1fb30", + "expiresAt": "2026-10-26T13:31:13.830Z", "status": "pending" } }, @@ -1126,8 +1126,8 @@ "agreed": false }, "tok": { - "install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391", - "expiresAt": "2026-10-26T13:27:51.761Z" + "install": "curl -fsSL http://127.0.0.1:34077/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:34077 --token … --external --site s_01a1785f4b4aa64e53cac1fb30", + "expiresAt": "2026-10-26T13:31:13.830Z" }, "conn": { "status": "connected", @@ -1311,7 +1311,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a1785a6358a0946ad508ee8d" + "product": "pv_01a1785d82c0d8957482c88fa6" } } ], @@ -1347,7 +1347,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a1785a6358a0946ad508ee8d" + "product": "pv_01a1785d82c0d8957482c88fa6" } } }, @@ -1430,9 +1430,9 @@ "name": "요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림", "ok": true, "detail": { - "id": "tr_01a1785c58c47561320ed6f007", + "id": "tr_01a1785f6a46960e607c665537", "status": "requested", - "expiresAt": "2026-11-02T13:18:00.388Z", + "expiresAt": "2026-11-02T13:21:21.478Z", "bobNotified": true } }, @@ -1460,7 +1460,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T13:18:00.432Z" + "cutover": "2026-10-26T13:21:21.527Z" } }, { @@ -1489,9 +1489,9 @@ "excluded": 3 }, "rq": { - "id": "tr_01a1785c58c47561320ed6f007", + "id": "tr_01a1785f6a46960e607c665537", "status": "requested", - "expiresAt": "2026-11-02T13:18:00.388Z", + "expiresAt": "2026-11-02T13:21:21.478Z", "bobNotified": true }, "acc": { @@ -1503,7 +1503,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T13:18:00.432Z" + "cutover": "2026-10-26T13:21:21.527Z" }, "after": { "aliceSees": 404, @@ -1781,7 +1781,7 @@ "detail": { "status": "done", "canRetry": false, - "jobId": "j_01a1785a6757555a33d6269bd8" + "jobId": "j_01a1785d86b049e14cbf94b74a" } }, { @@ -1806,7 +1806,7 @@ "dep": { "status": "done", "canRetry": false, - "jobId": "j_01a1785a6757555a33d6269bd8" + "jobId": "j_01a1785d86b049e14cbf94b74a" }, "rt": { "status": 400, @@ -1832,7 +1832,7 @@ "detail": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a1785a6686ffeba5d2781add" + "link": "/orders/o_01a1785d85dd70547b90cf2822" } }, { @@ -1848,7 +1848,7 @@ "ok": true, "detail": { "status": "approved", - "approvedAt": "2026-10-26T13:15:52.905Z" + "approvedAt": "2026-10-26T13:19:17.536Z" } } ], @@ -1856,7 +1856,7 @@ "n": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a1785a6686ffeba5d2781add" + "link": "/orders/o_01a1785d85dd70547b90cf2822" }, "a": { "action": "order.approve", @@ -1864,7 +1864,7 @@ }, "same": { "status": "approved", - "approvedAt": "2026-10-26T13:15:52.905Z" + "approvedAt": "2026-10-26T13:19:17.536Z" } } }, @@ -1907,9 +1907,9 @@ "rpo_minutes": 30, "snapshot_interval_minutes": 60, "retention_count": 3, - "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external", - "updated_at": "2026-10-26T13:18:08.570Z", - "updated_by": "op_01a1785a5c27caed1a96f25d99" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131912/dr-external", + "updated_at": "2026-10-26T13:21:29.825Z", + "updated_by": "op_01a1785d7b8c4698a9c7d245c2" } }, { @@ -1969,9 +1969,9 @@ "rpo_minutes": 30, "snapshot_interval_minutes": 60, "retention_count": 3, - "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external", - "updated_at": "2026-10-26T13:18:08.570Z", - "updated_by": "op_01a1785a5c27caed1a96f25d99" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131912/dr-external", + "updated_at": "2026-10-26T13:21:29.825Z", + "updated_by": "op_01a1785d7b8c4698a9c7d245c2" }, "snap": { "verify": "ok", @@ -2005,10 +2005,10 @@ "ok": true, "detail": { "mailJob": { - "id": "j_01a1785b141b2aba663738d139", + "id": "j_01a1785e3392b1c5f416c21f63", "status": "awaiting_external" }, - "halJobs": 18 + "halJobs": 19 } }, { @@ -2017,7 +2017,7 @@ "detail": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a1785b141b2aba663738d139", + "id": "j_01a1785e3392b1c5f416c21f63", "status": "awaiting_external" }, "same": true @@ -2028,7 +2028,7 @@ "ok": true, "detail": { "opsSessionKept": "[redacted]", - "halJobs": 18, + "halJobs": 19, "sameCount": true } } @@ -2036,22 +2036,22 @@ "evidence": { "before": { "mailJob": { - "id": "j_01a1785b141b2aba663738d139", + "id": "j_01a1785e3392b1c5f416c21f63", "status": "awaiting_external" }, - "halJobs": 18 + "halJobs": 19 }, "pr": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a1785b141b2aba663738d139", + "id": "j_01a1785e3392b1c5f416c21f63", "status": "awaiting_external" }, "same": true }, "hr": { "opsSessionKept": "[redacted]", - "halJobs": 18, + "halJobs": 19, "sameCount": true } } @@ -2075,8 +2075,8 @@ "ok": true, "detail": [ { - "id": "s_01a1785af3ab8d0d00e0063124", - "group_id": "g_01a1785a663da04c2c5802e990", + "id": "s_01a1785e13123aa6795709778d", + "group_id": "g_01a1785d8595f7a47722a893d6", "name": "동시 A", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000002", @@ -2104,9 +2104,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1785a663df67cc036d6bc02", - "created_at": "2026-10-26T13:16:28.971Z", - "updated_at": "2026-10-26T13:16:28.978Z", + "created_by": "u_01a1785d85951776044f575b03", + "created_at": "2026-10-26T13:19:53.618Z", + "updated_at": "2026-10-26T13:19:53.628Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -2125,8 +2125,8 @@ "nextJob": null }, { - "id": "s_01a1785af3aeacb2a4c3e486f3", - "group_id": "g_01a1785a663da04c2c5802e990", + "id": "s_01a1785e13174139814ca637b9", + "group_id": "g_01a1785d8595f7a47722a893d6", "name": "동시 B", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000003", @@ -2154,9 +2154,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1785a663df67cc036d6bc02", - "created_at": "2026-10-26T13:16:28.974Z", - "updated_at": "2026-10-26T13:16:28.980Z", + "created_by": "u_01a1785d85951776044f575b03", + "created_at": "2026-10-26T13:19:53.623Z", + "updated_at": "2026-10-26T13:19:53.631Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -2214,8 +2214,8 @@ "name": "외부 배포 사이트 생성(초안) + 저장본", "ok": true, "detail": { - "id": "s_01a1785c35f8d649e375f0c391", - "group_id": "g_01a1785a66113e63a08c972cc5", + "id": "s_01a1785f4b4aa64e53cac1fb30", + "group_id": "g_01a1785d8565c2082fe744502a", "name": "E2E 외부 사이트", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000004", @@ -2243,9 +2243,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1785a65614c4deeec61ea3d", - "created_at": "2026-10-26T13:17:51.480Z", - "updated_at": "2026-10-26T13:17:51.485Z", + "created_by": "u_01a1785d84b8758824db3f9dbd", + "created_at": "2026-10-26T13:21:13.547Z", + "updated_at": "2026-10-26T13:21:13.553Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -2286,8 +2286,8 @@ "ok": true, "detail": { "token": "[redacted]", - "install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391", - "expiresAt": "2026-10-26T13:27:51.761Z", + "install": "curl -fsSL http://127.0.0.1:34077/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:34077 --token … --external --site s_01a1785f4b4aa64e53cac1fb30", + "expiresAt": "2026-10-26T13:31:13.830Z", "status": "pending" } }, @@ -2482,9 +2482,9 @@ "errorCode": "partial_failure", "overall": "partial", "children": [ + "srv_01a1785d7d38e2cdafa814388f:done:", "srv_missing_e2e:failed:서버가 없습니다", - "srv_01a1785a5d26219326f7ad26ec:done:", - "srv_01a1785a5dd874fe86b67f0f38:done:" + "srv_01a1785d7c8e7c81c8953e5bad:done:" ], "timedOut": false } @@ -2521,9 +2521,9 @@ "errorCode": "partial_failure", "overall": "partial", "children": [ + "srv_01a1785d7d38e2cdafa814388f:done:", "srv_missing_e2e:failed:서버가 없습니다", - "srv_01a1785a5d26219326f7ad26ec:done:", - "srv_01a1785a5dd874fe86b67f0f38:done:" + "srv_01a1785d7c8e7c81c8953e5bad:done:" ], "timedOut": false }, @@ -2562,7 +2562,7 @@ "name": "critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)", "ok": true, "detail": { - "id": "em_01a1785c79d6aa634fc6516b7b", + "id": "em_01a1785f8c0286085c36135621", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", @@ -2570,7 +2570,7 @@ "approved": "approved", "approvals": 3, "credential": "[redacted]", - "expiresAt": "2026-10-26T13:48:08.882Z" + "expiresAt": "2026-10-26T13:51:30.142Z" } }, { @@ -2591,14 +2591,14 @@ "evidence": { "healthy": "hal_healthy", "sess": { - "id": "em_01a1785c79d6aa634fc6516b7b", + "id": "em_01a1785f8c0286085c36135621", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", "badText": "final_confirm_mismatch", "approved": "approved", "approvals": 3, - "expiresAt": "2026-10-26T13:48:08.882Z" + "expiresAt": "2026-10-26T13:51:30.142Z" }, "perf": { "forbidden": "action_forbidden", @@ -2650,8 +2650,8 @@ "ok": true, "detail": { "status": "readonly", - "deleteAfter": "2026-11-25T13:18:00.329Z", - "readonlyUntil": "2026-11-25T13:18:00.329Z" + "deleteAfter": "2026-11-25T13:21:21.408Z", + "readonlyUntil": "2026-11-25T13:21:21.408Z" } }, { @@ -2677,8 +2677,8 @@ }, "un": { "status": "readonly", - "deleteAfter": "2026-11-25T13:18:00.329Z", - "readonlyUntil": "2026-11-25T13:18:00.329Z" + "deleteAfter": "2026-11-25T13:21:21.408Z", + "readonlyUntil": "2026-11-25T13:21:21.408Z" }, "sweep": { "sweep": 200, diff --git a/docs/verification/local-e2e-2026-10-26.md b/docs/verification/local-e2e-2026-10-26.md index 63ef1cf..02db04e 100644 --- a/docs/verification/local-e2e-2026-10-26.md +++ b/docs/verification/local-e2e-2026-10-26.md @@ -1,6 +1,6 @@ # 로컬 종단 간 검증 기록 — 2026-10-26 -생성: 2026-10-26T13:18:11.518Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/) +생성: 2026-10-26T13:21:32.783Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/) 실행 환경: 실제 HAL·고객 포털·Node Agent(명령 stub 모드) 프로세스 4대 + 가짜 Webmaker/AriMail/발신 메일 서버(127.0.0.1). 실 DNS·실 메일 왕복·실 서버는 포함하지 않는다. @@ -88,20 +88,20 @@ ### 2. 개인 사용자 그룹 생성과 다른 사용자 초대 -- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a1785a66113e63a08c972cc5 -- ✔ 이메일 초대(7일) → 초대 메일 링크 — uVdQrHwu9UjZmbh1PlCVMIPsJFxbHnvo +- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a1785d8565c2082fe744502a +- ✔ 이메일 초대(7일) → 초대 메일 링크 — rpRdbrRDLUZnw57-ToRz1HleEH4d8Vh3 - ✔ 초대 수락(1회용) → 별칭 user-02 · 역할 member — {"alias":"user-02","role":"member","reuse":"invite_invalid"} - ✔ 권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할 — {"requiredRole":["owner","admin"],"yourRole":"member"} - ✔ 그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음 — {"team":{"live":0,"purchased":0,"inUse":0,"reserved":0,"unallocated":0},"personal":{"live":0,"purchased":0,"inUse":0,"reserved":0,"unallocated":0}} ### 3. 상품 주문, 결제 확인, 권리 활성화 -- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a1785a6358a08c3194ac03c2","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]} -- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T13:15:52.839Z","hasInstructions":true} +- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a1785d82c1aac82579226bce","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]} +- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T13:19:17.470Z","hasInstructions":true} - ✔ 입금 확인 전 승인 거부(409 payment_not_confirmed) — payment_not_confirmed - ✔ 운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시 — {"status":"awaiting_approval","delta":["sites","web_disk","web_out","mail_storage","mail_msgs","mailboxes","mail_services","mail_storage","mail_msgs","mailboxes"]} - ✔ 운영자 승인 → 권리 활성화(재승인은 중복 없음) — {"granted":10,"already":true} -- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T13:15:52.905Z"} +- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T13:19:17.536Z"} ```json { @@ -112,25 +112,25 @@ "bill": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T13:15:52.905Z" + "nextBilling": "2026-11-26T13:19:17.536Z" } } ``` ### 4. 초안 사이트 생성 -- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a1785a670945da067e955da4","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]} +- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a1785d86626c4268d23e8c8c","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]} - ✔ 사이트 목록 필터(초안)·사용량 요약에 예약 0 — {"drafts":1,"sitesReserved":0,"sitesAllocated":0} ### 5. Webmaker에서 페이지 제작 -- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:35307/api/auth/sso?ticket=…","webmaker":302,"reuse":401} +- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:38359/api/auth/sso?ticket=…","webmaker":302,"reuse":401} - ✔ Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과 — {"versions":1,"precheckOk":true} ```json { "sso": { - "portalRedirect": "http://127.0.0.1:35307/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:38359/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 }, @@ -143,14 +143,14 @@ ### 6. 관리형 배포 -- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a1785a6757555a33d6269bd8","steps":10,"again":false,"siteCount":"reserved","server":null} +- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a1785d86b049e14cbf94b74a","steps":10,"again":false,"siteCount":"reserved","server":null} - ✔ HAL·Agent 를 거쳐 작업 완료 대기(최대 40초) — {"status":"done","steps":["accept:done","prepare_version:done","prepare_target:done","apply:done","data_check:done","start:done","internal_check:done","external_check:done","domain_https:done","done:done"]} -- ✔ 배포 뒤 사이트 상태·버전·건강 — {"lifecycle":"needs_domain","version":"20261026131553-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true} +- ✔ 배포 뒤 사이트 상태·버전·건강 — {"lifecycle":"needs_domain","version":"20261026131918-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true} ```json { "d": { - "jobId": "j_01a1785a6757555a33d6269bd8", + "jobId": "j_01a1785d86b049e14cbf94b74a", "steps": 10, "again": false, "siteCount": "reserved", @@ -173,7 +173,7 @@ }, "after": { "lifecycle": "needs_domain", - "version": "20261026131553-rev-c1-000000", + "version": "20261026131918-rev-c1-000000", "verify": "needs_domain", "health": "ok", "agentHasSite": true @@ -183,14 +183,14 @@ ### 7. 도메인 연결과 HTTPS 발급 -- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1785a772932b8936927ad0a","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true} +- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1785d9684c7a5694cbcebfa","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true} - ✔ 도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시 — {} - ✔ (정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다 — {"queuedProbes":0} ```json { "dm": { - "domainId": "d_01a1785a772932b8936927ad0a", + "domainId": "d_01a1785d9684c7a5694cbcebfa", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -233,9 +233,9 @@ ### 10. 사이트 대표 메일과 메일함 생성 -- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1785affa395a9602c99bbb5","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0} +- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1785e1f18f7002057347a34","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0} - ✔ 같은 사이트에 두 번째 대표 메일 거부 — mail_domain_in_use -- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a1785affda99dffac745d1e9","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true} +- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a1785e1f53a4fcfe5daf70b5","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true} - ✔ 초대 방식 메일함(링크 만료·재발송·사용 완료 상태) — {"inviteState":"pending","accepted":200,"reuse":"invite_used"} - ✔ DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing) — {"ok":false,"missing":3,"items":["mx:missing","spf:missing","dkim:missing","dmarc:recommended"]} - ✔ 활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등 — {"status":"awaiting_external","waitReason":"DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요","sameJob":true,"steps":["entitlement:done","dns:waiting","prepare:pending","verify:pending","activate:pending"]} @@ -244,7 +244,7 @@ ```json { "svc": { - "id": "msv_01a1785affa395a9602c99bbb5", + "id": "msv_01a1785e1f18f7002057347a34", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -252,7 +252,7 @@ "siteCountAllocated": 0 }, "mb": { - "id": "mb_01a1785affda99dffac745d1e9", + "id": "mb_01a1785e1f53a4fcfe5daf70b5", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -287,13 +287,13 @@ ### 12. 독립 메일 서비스 생성 -- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a1785b2be9c7ee6d0d94a95c","jobCancelled":null} -- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1785b2bf14cefa9e1f81fd7","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0} +- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a1785e47703780e4f9bdfe16","jobCancelled":null} +- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1785e477805a9a147d2fd87","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0} ```json { "r": { - "id": "msv_01a1785b2bf14cefa9e1f81fd7", + "id": "msv_01a1785e477805a9a147d2fd87", "kind": "standalone", "mailServicesAllocated": 1, "sitesAllocated": 0 @@ -307,8 +307,8 @@ - ✔ 백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리 — {"granted":7,"orderStatus":"approved"} - ✔ 백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서) — {"eligible":true,"reasons":[],"deferred":[],"policy":null,"status":200} -- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T13:16:43.530Z","nextRun":"2026-10-26T13:16:43.530Z"} -- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a1785a5d26219326f7ad26ec","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0} +- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T13:20:07.184Z","nextRun":"2026-10-26T13:20:07.184Z"} +- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a1785d7c8e7c81c8953e5bad","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0} ```json { @@ -321,14 +321,14 @@ }, "opt": { "status": "active", - "firstDue": "2026-10-27T13:16:43.530Z", - "nextRun": "2026-10-26T13:16:43.530Z" + "firstDue": "2026-10-27T13:20:07.184Z", + "nextRun": "2026-10-26T13:20:07.184Z" }, "run1": { "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": "srv_01a1785a5d26219326f7ad26ec", + "siteServer": "srv_01a1785d7c8e7c81c8953e5bad", "backup": { "status": "done", "tier": "monthly", @@ -342,8 +342,8 @@ ### 14. 백업 복원과 외부 사이트 확인 -- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a1785b465fad18eaeb8a679e","createdAt":"2026-10-26T13:16:43.538Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a1785a5dd874fe86b67f0f38","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1896,"verifyState":"verified","expiresAt":"2027-01-2 -- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a1785b4a2808cc16818090d9","status":"awaiting_approval","dup":false,"finalConfirmation":{"group":"E2E 팀","site":"E2E 사이트","domain":null}} +- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a1785e5a2387339b7355af21","createdAt":"2026-10-26T13:20:07.192Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a1785d7d38e2cdafa814388f","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1899,"verifyState":"verified","expiresAt":"2027-01-2 +- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a1785e5fceb0b45b4373fd82","status":"awaiting_approval","dup":false,"finalConfirmation":{"group":"E2E 팀","site":"E2E 사이트","domain":null}} - ✔ 운영자 최종 확인(그룹·사이트·백업 시각) 불일치 거부 → 일치 승인(멱등) — {"bad":"final_confirmation_mismatch","ok":400} - ✔ 9단계 작업 진행 → 사용자 확인 필요(보호 시점·되돌리기 가능) — {"steps":[]} @@ -351,19 +351,19 @@ { "info": { "backup": { - "id": "bk_01a1785b465fad18eaeb8a679e", - "createdAt": "2026-10-26T13:16:43.538Z", + "id": "bk_01a1785e5a2387339b7355af21", + "createdAt": "2026-10-26T13:20:07.192Z", "scope": { "files": true, "db": true, "uploads": true, "config": true, - "storeServerId": "srv_01a1785a5dd874fe86b67f0f38", + "storeServerId": "srv_01a1785d7d38e2cdafa814388f", "verifyMethod": "sha256 + 격리 환경 읽기 확인" }, - "sizeBytes": 1896, + "sizeBytes": 1899, "verifyState": "verified", - "expiresAt": "2027-01-27T13:16:50.144Z", + "expiresAt": "2027-01-27T13:20:11.812Z", "tier": "monthly" }, "included": [ @@ -393,7 +393,7 @@ "deferred": [] }, "rq": { - "id": "rs_01a1785b4a2808cc16818090d9", + "id": "rs_01a1785e5fceb0b45b4373fd82", "status": "awaiting_approval", "dup": false, "finalConfirmation": { @@ -445,10 +445,10 @@ ### 16. 외부 배포 사이트 생성과 배포 -- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1785c35f8d649e375f0c391","group_id":"g_01a1785a66113e63a08c972cc5","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou +- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1785f4b4aa64e53cac1fb30","group_id":"g_01a1785d8565c2082fe744502a","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou - ✔ 배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전 — {"notProvided":"[redacted]","consentVersion":"external-deploy-2026-10-0.1.1","agreed":false} - ✔ 동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전) — {"before":"consent_required","consent":200} -- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391","expiresAt":"2026-10-26T13:27:51.761Z","status":"pending"} +- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:34077/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:34077 --token … --external --site s_01a1785f4b4aa64e53cac1fb30","expiresAt":"2026-10-26T13:31:13.830Z","status":"pending"} - ✔ 고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속) — {"agentId":"[redacted]"} - ✔ 포털 연결 확인(외부 Agent 상태 connected) — {"status":"connected","stale":false,"halAgentId":"[redacted]"} - ✔ 배포 전 확인(외부: 관리형 용량 확인 없음) — {"ok":true,"blocks":[]} @@ -465,8 +465,8 @@ "agreed": false }, "tok": { - "install": "curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391", - "expiresAt": "2026-10-26T13:27:51.761Z" + "install": "curl -fsSL http://127.0.0.1:34077/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:34077 --token … --external --site s_01a1785f4b4aa64e53cac1fb30", + "expiresAt": "2026-10-26T13:31:13.830Z" }, "conn": { "status": "connected", @@ -554,7 +554,7 @@ - ✔ 축소 미리보기(팀→베이직): 줄여야 할 항목(메일함 6→5) 구체 표시 — {"shortages":["mailboxes:1"]} - ✔ 축소 요청 → 보류(사용 중 권리 즉시 회수 없음) — {"fits":false,"hold":"holding","sitesStillPurchased":3} - ✔ 정리: 메일함 배정 6→5 축소 → 재검증 적합 → 축소 주문 생성 — {"allocate":200,"fits":true,"shortages":[],"order":"awaiting_payment"} -- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a1785a6358a0946ad508ee8d"} +- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a1785d82c0d8957482c88fa6"} ```json { @@ -589,7 +589,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a1785a6358a0946ad508ee8d" + "product": "pv_01a1785d82c0d8957482c88fa6" } } ``` @@ -623,10 +623,10 @@ ### 20. 사이트 소유권 이전 - ✔ 이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목) — {"sites":1,"domains":1,"siteMail":1,"needed":{"backup":1,"web_disk":1073741824,"mail_msgs":500,"mail_storage":2147483648,"mailboxes":6},"excluded":3} -- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a1785c58c47561320ed6f007","status":"requested","expiresAt":"2026-11-02T13:18:00.388Z","bobNotified":true} +- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a1785f6a46960e607c665537","status":"requested","expiresAt":"2026-11-02T13:21:21.478Z","bobNotified":true} - ✔ (정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개) — {"endBackup":200,"alloc":200} - ✔ 받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내) — {"status":200,"tstatus":"reviewing","hold":null} -- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T13:18:00.432Z"} +- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T13:21:21.527Z"} - ✔ 완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사 — {"aliceSees":404,"bobSees":200,"bobUsageSites":0,"audit":true} ```json @@ -645,9 +645,9 @@ "excluded": 3 }, "rq": { - "id": "tr_01a1785c58c47561320ed6f007", + "id": "tr_01a1785f6a46960e607c665537", "status": "requested", - "expiresAt": "2026-11-02T13:18:00.388Z", + "expiresAt": "2026-11-02T13:21:21.478Z", "bobNotified": true }, "acc": { @@ -659,7 +659,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T13:18:00.432Z" + "cutover": "2026-10-26T13:21:21.527Z" }, "after": { "aliceSees": 404, @@ -798,7 +798,7 @@ ### 23. 실패 작업의 재시도와 되돌리기 -- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a1785a6757555a33d6269bd8"} +- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a1785d86b049e14cbf94b74a"} - ✔ 재시도 요청(POST /api/sites/:id/jobs/:jobId/retry) — {"status":400,"error":"job_not_retryable"} - ✔ 되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부 — {"status":409,"error":"no_previous","message":"되돌릴 이전 버전이 없습니다"} @@ -807,7 +807,7 @@ "dep": { "status": "done", "canRetry": false, - "jobId": "j_01a1785a6757555a33d6269bd8" + "jobId": "j_01a1785d86b049e14cbf94b74a" }, "rt": { "status": 400, @@ -823,16 +823,16 @@ ### 24. 운영자 승인, 감사 기록, 고객 알림의 일치 -- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a1785a6686ffeba5d2781add"} +- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a1785d85dd70547b90cf2822"} - ✔ 감사 기록: order.approve 는 삭제 불가(immutable) — {"action":"order.approve","immutable":true} -- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T13:15:52.905Z"} +- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T13:19:17.536Z"} ```json { "n": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a1785a6686ffeba5d2781add" + "link": "/orders/o_01a1785d85dd70547b90cf2822" }, "a": { "action": "order.approve", @@ -840,7 +840,7 @@ }, "same": { "status": "approved", - "approvedAt": "2026-10-26T13:15:52.905Z" + "approvedAt": "2026-10-26T13:19:17.536Z" } } ``` @@ -848,7 +848,7 @@ ### 25. HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련 - ✔ 설정 전 준비 상태(미설정 항목) — {"ready":false,"missingConfig":["rto_minutes","rpo_minutes","snapshot_interval_minutes","retention_count"],"lastDrill":null,"lastSnapshot":null,"config":{"rto_minutes":null,"rpo_minutes":null,"snapshot_interval_minutes":null,"retention_count":null,"external_store":null},"label":"정식 운영 준비 전(복구 훈련 또는 -- ✔ RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정 — {"id":1,"rto_minutes":60,"rpo_minutes":30,"snapshot_interval_minutes":60,"retention_count":3,"external_store":"/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external","updated_at":"2026-10-26T13:18:08.570Z","updated_by":"op_01a1785a5c27caed1a96f25d99"} +- ✔ RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정 — {"id":1,"rto_minutes":60,"rpo_minutes":30,"snapshot_interval_minutes":60,"retention_count":3,"external_store":"/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131912/dr-external","updated_at":"2026-10-26T13:21:29.825Z","updated_by":"op_01a1785d7b8c4698a9c7d245c2"} - ✔ 복구 사본 생성·검증·외부 보관(vault.key 제외) — {"verify":"ok","external":true,"sha":true} - ✔ 복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료 — {"early":"restore_not_approved","noStop":"old_control_not_confirmed","command":true,"incomplete":"checks_incomplete","final":"completed"} - ✔ 복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료 — {"first":"failed","second":"success","ready":true,"measured":{}} @@ -880,9 +880,9 @@ "rpo_minutes": 30, "snapshot_interval_minutes": 60, "retention_count": 3, - "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131547/dr-external", - "updated_at": "2026-10-26T13:18:08.570Z", - "updated_by": "op_01a1785a5c27caed1a96f25d99" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026131912/dr-external", + "updated_at": "2026-10-26T13:21:29.825Z", + "updated_by": "op_01a1785d7b8c4698a9c7d245c2" }, "snap": { "verify": "ok", @@ -907,30 +907,30 @@ ### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 -- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a1785b141b2aba663738d139","status":"awaiting_external"},"halJobs":18} -- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a1785b141b2aba663738d139","status":"awaiting_external"},"same":true} -- ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":18,"sameCount":true} +- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a1785e3392b1c5f416c21f63","status":"awaiting_external"},"halJobs":19} +- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a1785e3392b1c5f416c21f63","status":"awaiting_external"},"same":true} +- ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":19,"sameCount":true} ```json { "before": { "mailJob": { - "id": "j_01a1785b141b2aba663738d139", + "id": "j_01a1785e3392b1c5f416c21f63", "status": "awaiting_external" }, - "halJobs": 18 + "halJobs": 19 }, "pr": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a1785b141b2aba663738d139", + "id": "j_01a1785e3392b1c5f416c21f63", "status": "awaiting_external" }, "same": true }, "hr": { "opsSessionKept": "[redacted]", - "halJobs": 18, + "halJobs": 19, "sameCount": true } } @@ -939,7 +939,7 @@ ### 27. 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 - ✔ 밥 개인 그룹: 베이직(사이트 1) 주문·승인 — {"granted":6} -- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1785af3ab8d0d00e0063124","group_id":"g_01a1785a663da04c2c5802e990","name":"동시 A","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000002","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_sta +- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1785e13123aa6795709778d","group_id":"g_01a1785d8595f7a47722a893d6","name":"동시 A","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000002","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_sta - ✔ 두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족) — {"ok":1,"blocked":"precheck_failed","shortage":"block"} - ✔ (정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환 — {"cancel":200,"cleanup":200,"unallocated":0} @@ -955,10 +955,10 @@ ### 28. 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 -- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1785c35f8d649e375f0c391","group_id":"g_01a1785a66113e63a08c972cc5","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou +- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1785f4b4aa64e53cac1fb30","group_id":"g_01a1785d8565c2082fe744502a","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou - ✔ 배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전 — {"notProvided":"[redacted]","consentVersion":"external-deploy-2026-10-0.1.1","agreed":false} - ✔ 동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전) — {"before":"consent_required","consent":200} -- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:42511/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:42511 --token … --external --site s_01a1785c35f8d649e375f0c391","expiresAt":"2026-10-26T13:27:51.761Z","status":"pending"} +- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:34077/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:34077 --token … --external --site s_01a1785f4b4aa64e53cac1fb30","expiresAt":"2026-10-26T13:31:13.830Z","status":"pending"} - ✔ 고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속) — {"agentId":"[redacted]"} - ✔ 포털 연결 확인(외부 Agent 상태 connected) — {"status":"connected","stale":false,"halAgentId":"[redacted]"} - ✔ 배포 전 확인(외부: 관리형 용량 확인 없음) — {"ok":true,"blocks":[]} @@ -1039,7 +1039,7 @@ ### 30. 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 -- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"failed","errorCode":"partial_failure","overall":"partial","children":["srv_missing_e2e:failed:서버가 없습니다","srv_01a1785a5d26219326f7ad26ec:done:","srv_01a1785a5dd874fe86b67f0f38:done:"],"timedOut":false} +- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"failed","errorCode":"partial_failure","overall":"partial","children":["srv_01a1785d7d38e2cdafa814388f:done:","srv_missing_e2e:failed:서버가 없습니다","srv_01a1785d7c8e7c81c8953e5bad:done:"],"timedOut":false} - ✔ 모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업 — {"health":"done","drift":0,"reconcile":200,"overviewComponents":["HAL:ok","portal:unknown","webmaker:unknown","arimail:unknown","backup:ok","observability:ok"]} - ✔ 되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부 — {"step1":"confirm_impact_required","step2":"identifiers_mismatch"} @@ -1050,9 +1050,9 @@ "errorCode": "partial_failure", "overall": "partial", "children": [ + "srv_01a1785d7d38e2cdafa814388f:done:", "srv_missing_e2e:failed:서버가 없습니다", - "srv_01a1785a5d26219326f7ad26ec:done:", - "srv_01a1785a5dd874fe86b67f0f38:done:" + "srv_01a1785d7c8e7c81c8953e5bad:done:" ], "timedOut": false }, @@ -1079,21 +1079,21 @@ ### 31. 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 - ✔ HAL 정상 상태에서 선언 거부(hal_healthy) — hal_healthy -- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a1785c79d6aa634fc6516b7b","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T13:48:08.882Z"} +- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a1785f8c0286085c36135621","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T13:51:30.142Z"} - ✔ 일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료 — {"forbidden":"action_forbidden","outOfScope":"out_of_scope","badCred":"bad_credential","performed":400,"revoked":true,"afterRevoke":"credential_revoked","reconcile":200,"closed":"closed"} ```json { "healthy": "hal_healthy", "sess": { - "id": "em_01a1785c79d6aa634fc6516b7b", + "id": "em_01a1785f8c0286085c36135621", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", "badText": "final_confirm_mismatch", "approved": "approved", "approvals": 3, - "expiresAt": "2026-10-26T13:48:08.882Z" + "expiresAt": "2026-10-26T13:51:30.142Z" }, "perf": { "forbidden": "action_forbidden", @@ -1113,7 +1113,7 @@ - ✔ Source 옵션 주문(대상 사이트)·승인 → source 권리 — {"granted":7} - ✔ Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보 — {"activate":200,"status":"active","provider":"gitea","changes":1,"clone":true} - ✔ export(저장본 내려받기) — {"status":200,"type":"application/gzip"} -- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T13:18:00.329Z","readonlyUntil":"2026-11-25T13:18:00.329Z"} +- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T13:21:21.408Z","readonlyUntil":"2026-11-25T13:21:21.408Z"} - ✔ 삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음 — {"sweep":200,"statusAfter":"readonly"} ```json @@ -1131,8 +1131,8 @@ }, "un": { "status": "readonly", - "deleteAfter": "2026-11-25T13:18:00.329Z", - "readonlyUntil": "2026-11-25T13:18:00.329Z" + "deleteAfter": "2026-11-25T13:21:21.408Z", + "readonlyUntil": "2026-11-25T13:21:21.408Z" }, "sweep": { "sweep": 200, diff --git a/hal/modules/index.mjs b/hal/modules/index.mjs index cbafc2b..4734e20 100644 --- a/hal/modules/index.mjs +++ b/hal/modules/index.mjs @@ -23,10 +23,12 @@ export const MODULES = { agent: { name: 'agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master', health: { heartbeat: true }, version: { file: 'package.json' }, dataDirs: ['/srv/ariweb/agent/data'], selfUpdate: true }, // 도입 모듈: Webmaker(WebHosting_AI_agent 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며, - // 유닛은 deploy/units/webmaker.service.tmpl 로 다시 쓰되 기존 유닛의 Environment 줄(DATA_ROOT 등)을 facts 로 읽어 유지한다. 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. - webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', user: 'master', port: 3080, baseDir: '/srv/webmaker', + // 유닛은 서버에 이미 있고 /current 를 가리키면 그대로 둔다(keepExistingUnit — 운영 서버의 강화 지시문·ReadWritePaths 보존, 실제 환경 결함 수정). 없을 때만 deploy/units/webmaker.service.tmpl 로 만든다. + // 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.7.3 은 upstreamVersion). + webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker', health: { url: 'http://127.0.0.1:3080/api/auth/csrf', expect: 'any' }, version: { file: 'package.json' }, preStop: ['webmaker.backupData'], postStart: ['webmaker.unitCheck'], dataDirs: ['/data/webmaker', '/var/www/webmaker-sites'], extraUnits: ['webmaker-setup'] }, // 도입 모듈: AriMail 1.0.11. 서버마다 유닛 이름이 다르다(arihakdang-mail / arimail / heart-mail) — 서버 facts.arimail.unit 이 정한다. 설치는 원본 deploy/deploy.sh 와 같은 절차(테스트→릴리스 복사→DB 백업·마이그레이션→재시작→health)를 Agent 가 수행한다. + // 실행 계정이 Agent 와 다르면(ariweb.net 호스트의 arihakdang) Agent 가 deploy/module-as-user.sh 로 그 계정이 파일 작업을 하게 한다(실제 환경 결함 수정). 마이그레이션은 유닛의 EnvironmentFile(DB_FILE)을 같은 계정으로 읽어 실행한다. arimail: { name: 'arimail', roles: ['mail'], layout: 'releases', unit: 'arimail', unitTemplate: null, user: null, port: 8790, health: { url: 'http://127.0.0.1:8790/api/health', expect: 'ok' }, version: { file: 'package.json' }, preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], dataDirs: ['/data'], copyPaths: ['server', 'web', 'lang', 'migrations', 'package.json', 'haraka'] }, backup: { name: 'backup', roles: ['backup', 'web'], layout: 'agent-feature', unit: null, health: { feature: 'backup' }, version: { agent: true }, dataDirs: ['/srv/ariweb/backups'] }, diff --git a/test/agent.test.mjs b/test/agent.test.mjs index 357db4f..d7d8972 100644 --- a/test/agent.test.mjs +++ b/test/agent.test.mjs @@ -10,6 +10,7 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readli import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; +import { ROOT as ROOT_DIR, readJson } from '../agent/jobs/common.mjs'; /** 가짜 HAL 클라이언트: 전송은 메모리(파일 경로) 저장, artifact 는 미리 둔 파일 */ function fakeClient({ artifacts = {} } = {}) { @@ -113,4 +114,42 @@ await test('module.install: artifact 검증·releases/current·유닛(기존 Env // agent 자기 갱신은 재시작을 결과 보고 뒤로 미룬다 r = await runner2.execute({ id: 'm6', kind: 'module.install', payload: { module: 'agent', version: '0.1.2', artifactUrl: '/art/portal2', sha256: sum2, descriptor: { ...desc, name: 'agent', unit: 'ariweb-agent' }, appDir: join(e.roots.srv, 'agent') } }); eq(r.result.restartAfterReport, 'ariweb-agent'); }); +await test('module.install(실제 환경 결함 수정): REVISION.json 의 Suite 버전이 실제 버전(상류 package.json 은 upstreamVersion) · keepExistingUnit 은 current 를 가리키는 기존 유닛을 보존 · 실행 계정이 다른 도입 모듈은 module-as-user.sh 로 풀기·전환·DB 백업·EnvironmentFile 마이그레이션', async () => { + const e = env(); const { DatabaseSync } = await import('node:sqlite'); + // 상류 버전 1.0.11 인 AriMail 모양의 artifact: server/db-migrate.mjs 는 DB_FILE 환경을 파일에 남겨 EnvironmentFile 이 실렸는지 증명한다 + const app = join(e.base, 'bundles', 'arimail'); mkdirSync(join(app, 'server'), { recursive: true }); writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'arimail', version: '1.0.11' })); + writeFileSync(join(app, 'server', 'db-migrate.mjs'), "import { writeFileSync } from 'node:fs'; writeFileSync(process.env.DB_FILE + '.migrated', JSON.stringify({ db: process.env.DB_FILE, cwd: process.cwd() }));\n"); + const tgz = join(e.base, 'arimail-0.1.1.tar.gz'); execFileSync('tar', ['-czf', tgz, '-C', app, '.']); const sum = createHash('sha256').update(readFileSync(tgz)).digest('hex'); + const client = fakeClient({ artifacts: { '/art/arimail': tgz } }); const runner = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client, dataDir: e.dataDir }); + // 서버에 이미 있던 AriMail 배치: appDir·data·DB·EnvironmentFile. 유닛의 User 는 Agent 계정과 다르다. + const appDir = join(e.roots.srv, 'arihakdang-mail'); mkdirSync(join(appDir, 'releases', '20261024-114605'), { recursive: true }); mkdirSync(join(appDir, 'data'), { recursive: true }); execFileSync('ln', ['-sfn', join(appDir, 'releases', '20261024-114605'), join(appDir, 'current')]); + const db = join(appDir, 'data', 'mail-gateway.sqlite'); { const d = new DatabaseSync(db); d.exec('CREATE TABLE t(x)'); d.exec('INSERT INTO t VALUES(1)'); d.close(); } + const envFile = join(e.base, 'gateway.env'); writeFileSync(envFile, `DB_FILE=${db}\nSECRET_X="not-printed"\n`); + e.sys.unitText = async (u) => (u === 'arihakdang-mail' ? `[Service]\nUser=svc-other\nWorkingDirectory=${appDir}/current\nEnvironmentFile=${envFile}\nExecStart=${process.execPath} server/index.mjs\n` : ''); + // sudo -n -u <계정> /usr/bin/bash <스크립트> … 를 같은 계정으로 직접 실행해(권한 없는 테스트 환경) 스크립트 자체의 동작을 검증한다 + const origRun = e.sys.run; const sudoCalls = []; e.sys.run = (cmd, args, o) => { if (cmd === 'sudo' && args[3] === '/usr/bin/bash') { sudoCalls.push(args.slice(5)); return origRun('bash', args.slice(4), { ...(o || {}), allowStub: false }); } return origRun(cmd, args, o); }; + const desc = { name: 'arimail', unit: 'arihakdang-mail', unitTemplate: null, user: null, port: 8790, health: { url: 'http://127.0.0.1:1/api/health', expect: 'ok' }, version: { file: 'package.json' }, layout: 'releases', preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], copyPaths: ['server', 'package.json'] }; + let r = await runner.execute({ id: 'u1', kind: 'module.install', payload: { module: 'arimail', version: '0.1.1', artifactUrl: '/art/arimail', sha256: sum, descriptor: desc, appDir } }); + eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1'); eq(r.result.upstreamVersion, '1.0.11'); eq(r.result.runUser, 'svc-other'); eq(r.result.previousVersion, '20261024-114605'); + eq(readlinkSync(join(appDir, 'current')), join(appDir, 'releases', '0.1.1')); eq(readJson(join(appDir, 'releases', '0.1.1', 'REVISION.json')).version, '0.1.1'); eq(readJson(join(appDir, '.previous.json')).previous, join(appDir, 'releases', '20261024-114605')); + ok(['extract', 'write', 'backup-db', 'switch', 'write', 'migrate'].every((s) => sudoCalls.some((c) => c[0] === s)), '실행 계정 하위 명령: ' + sudoCalls.map((c) => c[0]).join(',')); + ok(existsSync(join(appDir, 'work')) === false, 'appDir 에 work 디렉터리를 만들지 않는다'); ok(existsSync(join(e.roots.srv, 'agent', 'work'))); + const backups = readdirSync(join(appDir, 'data', 'backups')); eq(backups.length, 1); { const d = new DatabaseSync(join(appDir, 'data', 'backups', backups[0]), { readOnly: true }); eq(d.prepare('SELECT COUNT(*) AS n FROM t').get().n, 1); d.close(); } + eq(readJson(db + '.migrated').db, db, 'EnvironmentFile 의 DB_FILE 로 마이그레이션'); eq(readJson(db + '.migrated').cwd, join(appDir, 'releases', '0.1.1')); + ok(e.sys.calls.some((c) => c.cmd === 'sudo' && c.args.join(' ').includes('systemctl restart arihakdang-mail-haraka'))); + // module-as-user.sh 는 모듈 디렉터리 밖 경로를 거부한다 + const bad = await origRun('bash', [join(ROOT_DIR, 'deploy', 'module-as-user.sh'), 'remove', '--app-dir', appDir, '--dir', join(e.base, 'bundles')], { allowStub: false }); eq(bad.code, 3); + // keepExistingUnit: 유닛이 이미 /current 를 가리키면 템플릿으로 다시 쓰지 않는다(강화 지시문 보존) + const wmApp = join(e.base, 'bundles', 'webmaker'); mkdirSync(wmApp, { recursive: true }); writeFileSync(join(wmApp, 'package.json'), JSON.stringify({ name: 'webmaker', version: '1.7.3' })); const wtgz = join(e.base, 'webmaker-0.1.1.tar.gz'); execFileSync('tar', ['-czf', wtgz, '-C', wmApp, '.']); const wsum = createHash('sha256').update(readFileSync(wtgz)).digest('hex'); + const wmDir = join(e.roots.srv, 'webmaker'); mkdirSync(join(wmDir, 'releases', 'old'), { recursive: true }); execFileSync('ln', ['-sfn', join(wmDir, 'releases', 'old'), join(wmDir, 'current')]); + e.sys.unitText = async (u) => (u === 'webmaker' ? `[Service]\nUser=${(await import('node:os')).userInfo().username}\nWorkingDirectory=${wmDir}/current\nProtectSystem=strict\nEnvironment=DATA_ROOT=${join(e.base, 'nodata')}\n` : ''); + const before = e.sys.calls.length; const runner2 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/wm': wtgz } }), dataDir: e.dataDir }); + r = await runner2.execute({ id: 'u2', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases', preStop: ['webmaker.backupData'], postStart: [], extraUnits: ['webmaker-setup'] }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } }); + eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1'); eq(r.result.upstreamVersion, '1.7.3'); eq(r.steps.find((s) => s.id === 'unit').result.skipped, 'existing_unit_kept'); + ok(!e.sys.calls.slice(before).some((c) => c.cmd === 'sudo' && c.args.includes('tee')), '유닛 파일을 다시 쓰지 않는다'); ok(e.sys.calls.slice(before).some((c) => c.args.join(' ').includes('systemctl restart webmaker-setup'))); + // 유닛이 다른 곳을 가리키면(또는 없으면) 템플릿으로 쓴다 + e.sys.unitText = async () => ''; const runner3 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/wm': wtgz } }), dataDir: e.dataDir }); const b2 = e.sys.calls.length; + r = await runner3.execute({ id: 'u3', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases' }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } }); + eq(r.status, 'done', JSON.stringify(r)); eq(r.steps.find((s) => s.id === 'unit').result.written, true); ok(e.sys.calls.slice(b2).some((c) => c.cmd === 'sudo' && c.args.includes('tee')) || existsSync('/etc/systemd/system/webmaker.service')); +}); done();