From 3641a703dbe8e563294805609b990e7fa45eba10 Mon Sep 17 00:00:00 2001 From: zzabisagent-lab Date: Mon, 26 Oct 2026 13:56:42 +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=20?= =?UTF-8?q?2:=20artifact=20=EB=AA=A8=EB=93=9C=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=99=94,=20=EC=8B=A4=ED=96=89=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20artifact=20=EC=9D=BD=EA=B8=B0=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=C2=B7current=20=EA=B2=BD=EB=A1=9C,=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=B2=84=EC=A0=84=20=EC=9E=AC=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4,=20Webmaker=20=EB=B0=B1=EC=97=85=20=EA=B4=80?= =?UTF-8?q?=EB=8C=80=20=EC=B2=98=EB=A6=AC,=20=EB=B6=80=ED=8A=B8=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=9E=A9=20=EB=A6=B4=EB=A6=AC=EC=8A=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=ED=96=89=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- VERSION_HISTORY.md | 5 + agent/jobs/module.mjs | 38 +++- deploy/agent-install.sh | 2 + deploy/module-as-user.sh | 4 + docs/verification/local-e2e-2026-10-26.json | 226 ++++++++++---------- docs/verification/local-e2e-2026-10-26.md | 152 ++++++------- hal/core.mjs | 3 +- scripts/release.mjs | 3 +- test/agent.test.mjs | 9 +- test/hal.test.mjs | 4 + 10 files changed, 243 insertions(+), 203 deletions(-) diff --git a/VERSION_HISTORY.md b/VERSION_HISTORY.md index 2e16a87..00e1a56 100644 --- a/VERSION_HISTORY.md +++ b/VERSION_HISTORY.md @@ -99,6 +99,11 @@ - **포털 nginx 접근 경로**(`deploy/bootstrap-hal.sh`): `deploy/nginx/ariweb-portal.conf.tmpl` 이 어디서도 적용되지 않아 `my.ariweb.net` 이 열리지 않았다. 부트스트랩 4단계(접근 경로 준비)에서 HAL 과 함께 설치한다. - **부트스트랩 인증서 확인**(`deploy/bootstrap-hal.sh`): `/etc/letsencrypt/live` 는 root 만 읽을 수 있어 일반 계정의 `[ -f ]` 가 항상 거짓이었다(실제 부트스트랩이 4단계에서 중단). `sudo -n test -f` 로 확인한다. - **포털 artifact 에 Catalog 누락**(`scripts/release.mjs`): 상품정책 시드 파일 `catalog/release-0.1.1.json` 이 portal artifact 에 들어가지 않아 운영자 시드(`/internal/ops/commerce/catalog/seed`)가 실서버에서 실패했을 것이다. portal 경로에 `catalog` 를 넣었다. +- **artifact 디렉터리 모드**(`scripts/release.mjs`): 개발 PC 의 umask(077)가 artifact 에 0700 디렉터리로 들어가 서버에서 다른 실행 계정이 Agent 의 `deploy/module-as-user.sh` 와 풀린 릴리스를 읽지 못했다. `tar --mode=u=rwX,go=rX --owner=0 --group=0` 로 정규화한다. `deploy/agent-install.sh` 도 호출자의 umask 와 무관하게 코드 트리를 `a+rX` 로 맞춘다. +- **실행 계정 모드 보완**(`agent/jobs/module.mjs`): 내려받은 artifact(0600)를 실행 계정이 읽도록 0644 로 바꾸고, 도우미 스크립트는 sudoers 규칙과 같은 `/agent/current/deploy/module-as-user.sh` 경로로 부른다(ESM 의 `import.meta.url` 은 `releases/` 실제 경로로 풀린다). +- **같은 버전 재설치**(`agent/jobs/module.mjs`, `module-as-user.sh rename`): 실행 중인 current 디렉터리를 지우고 다시 풀던 것을, 임시 디렉터리에 풀고 기존 디렉터리를 `.prev-<시각>` 으로 보존(되돌리기 대상)한 뒤 제자리에 두도록 바꿨다. DB 백업 파일 이름은 같은 초에 겹치지 않게 한다. +- **Webmaker 데이터 백업**(`agent/jobs/module.mjs`): 운영 데이터 디렉터리의 읽을 수 없는 파일(root 가 남긴 마이그레이션 기록)이 tar 를 실패시켜 배포가 막혔다. `--ignore-failed-read` 로 경고만 남기고(결과 `unreadable` 목록) 진행한다. ariweb.net 호스트의 해당 파일(`/data/webmaker/migrations`)은 소유권을 master 로 바로잡았다. +- **부트스트랩 릴리스 기록**(`hal/core.mjs setBootstrap`): HAL 기동 전 CLI `bootstrap-release` 가 쓴 UPDATE 가 행(id=1)이 없어 무효였다(건강 응답의 `releaseVersion` 이 null). 행을 먼저 만든다(`test/hal.test.mjs` 회귀 테스트). - 회귀 테스트: `test/agent.test.mjs` 에 실행 계정 모드(도우미 스크립트 실제 실행·경로 제한 거부)·REVISION 버전·기존 유닛 보존 검사를 추가했다. ### 도입 소스 diff --git a/agent/jobs/module.mjs b/agent/jobs/module.mjs index 03ccf6f..91b62ba 100644 --- a/agent/jobs/module.mjs +++ b/agent/jobs/module.mjs @@ -15,7 +15,7 @@ * [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 /current 를 가리키면 유닛을 다시 쓰지 않는다 * (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다. */ -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync } from 'node:fs'; import { join } from 'node:path'; import { userInfo } from 'node:os'; import http from 'node:http'; @@ -42,7 +42,9 @@ async function serviceContext(ctx, unit) { } /** 실행 계정으로 파일 작업(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 }); + // sudoers 규칙은 current 심링크 경로(/agent/current/deploy/module-as-user.sh)로 허용한다 — ESM 의 import.meta.url 은 실제 경로(releases/)로 풀리므로 current 경로를 우선 쓴다. + const viaCurrent = join(ctx.roots.srv, 'agent', 'current', 'deploy', 'module-as-user.sh'); const script = existsSync(viaCurrent) ? viaCurrent : join(ROOT, 'deploy', 'module-as-user.sh'); + const r = await ctx.sys.run('sudo', ['-n', '-u', svc.user, '/usr/bin/bash', script, sub, '--app-dir', appDir, ...args], { input, timeoutMs: 600000 }); if (r.code !== 0) throw Object.assign(new Error(`실행 계정(${svc.user})으로 ${sub} 실패: ${(r.stderr || r.stdout || '').trim().slice(0, 300) || `코드 ${r.code}`}`), { code: r.code === 1 && /sudo|password/i.test(r.stderr || '') ? 'sudo_denied' : 'as_user_failed', hint: 'deploy/sudoers/ariweb-agent 의 module-as-user.sh 규칙이 설치되어 있어야 합니다' }); return r; } @@ -51,6 +53,7 @@ const fsOps = (ctx, svc, appDir) => ({ writeJson: async (p, v) => (svc.asUser ? asUser(ctx, svc, appDir, 'write', ['--file', p], { input: JSON.stringify(v, null, 1) }) : writeJson(p, v)), switchLink: async (link, target) => (svc.asUser ? asUser(ctx, svc, appDir, 'switch', ['--link', link, '--target', target]) : switchLink(link, target)), remove: async (dir) => (svc.asUser ? asUser(ctx, svc, appDir, 'remove', ['--dir', dir]) : rmrf(dir)), + rename: async (from, to) => (svc.asUser ? asUser(ctx, svc, appDir, 'rename', ['--dir', from, '--target', to]) : renameSync(from, to)), }); /** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */ async function writeUnit(ctx, desc, unit, appDir, version, svc) { @@ -66,9 +69,17 @@ async function writeUnit(ctx, desc, unit, appDir, version, svc) { function readEnvFile(p) { const out = {}; if (!p || !existsSync(p)) return out; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); if (!m) continue; let v = m[2].trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); out[m[1]] = v; } return out; } /** preStop/postStart 절차 */ async function hook(ctx, name, { appDir, cur, unit, svc }) { - if (name === 'webmaker.backupData') { const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(svc.unitText) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true }); 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 === 'webmaker.backupData') { + const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(svc.unitText) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true }); + // [실제 환경 결함 수정] 운영 데이터 디렉터리에는 다른 계정(root)이 남긴 파일이나 백업 중 바뀌는 파일이 있을 수 있다. 읽지 못한 파일은 경고로 남기고 배포를 막지 않는다(tar 종료 코드 2 = 치명적 오류만 실패). + const r = await ctx.sys.run('tar', ['-czf', out, '-C', dataRoot, '--exclude=./backups', '--ignore-failed-read', '--warning=no-file-changed', '--warning=no-file-removed', '.'], { allowStub: false }); + if (r.code !== 0 && r.code !== 1) throw new Error(`데이터 백업 실패: ${(r.stderr || '').slice(0, 300)}`); + const warnings = (r.stderr || '').split('\n').filter((l) => /Cannot open|Permission denied|Cannot stat/.test(l)).map((l) => l.replace(/^tar: /, '')).slice(0, 20); + if (warnings.length) ctx.log(`데이터 백업에서 읽지 못한 파일 ${warnings.length}개(경고): ${warnings[0]}`); + const old = readdirSync(join(appDir, 'backups')).sort(); for (const f of old.slice(0, Math.max(0, old.length - 5))) rmSync(join(appDir, 'backups', f), { force: true }); return { backup: out, unreadable: warnings.length ? warnings : undefined }; + } if (name === 'arimail.backupDb') { - const env = svc.asUser ? {} : readEnvFile(svc.envFile); const db = env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite'); const out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); + const env = svc.asUser ? {} : readEnvFile(svc.envFile); const db = env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite'); let out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); for (let n = 1; existsSync(out); n++) out = out.replace(/(-\d+)?\.sqlite$/, `-${n}.sqlite`); // 같은 초에 두 번 설치해도 VACUUM INTO 대상이 겹치지 않게 if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'backup-db', ['--db', db, '--out', out, ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_db' } : { backup: out, via: svc.user }; } if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`); } finally { d.close(); } return { backup: out }; } @@ -97,14 +108,21 @@ async function installRelease(ctx, { module, version, appDir, desc, action, toVe if (action === 'rollback') { const target = toVersion ? relDir : readJson(join(appDir, '.previous.json'), {}).previous; if (!target || !existsSync(target)) throw Object.assign(new Error('되돌릴 이전 릴리스가 없습니다'), { code: 'no_previous_release' }); return { relDir: target, prev }; } // 실행 계정 모드에서는 appDir 에 쓸 수 없으므로 Agent 의 작업 디렉터리에 받는다(그 계정이 읽을 수 있게 0644). const workDir = svc.asUser ? join(ctx.roots.srv, 'agent', 'work') : join(appDir, 'work'); const tgz = join(workDir, `${module}-${version}.tar.gz`); - await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(workDir, { recursive: true }); const r = await ctx.client.download(ctx.payload.artifactUrl, tgz, ctx.payload.sha256 || null); return { file: tgz, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증'); + await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(workDir, { recursive: true }); const r = await ctx.client.download(ctx.payload.artifactUrl, tgz, ctx.payload.sha256 || null); if (svc.asUser) chmodSync(tgz, 0o644); /* 내려받기는 0600 으로 쓰므로 실행 계정이 읽게 한다(artifact 는 비밀이 아니다) */ return { file: tgz, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증'); + // [실제 환경 결함 수정] 같은 버전 재설치: 실행 중인 current 가 가리키는 디렉터리를 지우고 다시 풀면 실행 중인 서비스가 잠깐 코드를 잃는다(지연 require 실패) 데다 되돌릴 곳도 없다. + // 임시 디렉터리에 먼저 풀고, 기존 디렉터리는 .prev-<시각> 으로 이름을 바꿔 보존한 뒤(이것이 되돌리기 대상) 새 디렉터리를 제자리에 둔다. + let prevDir = prev; 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 }; + const ops = fsOps(ctx, svc, appDir); const tmp = `${relDir}.tmp`; + if (svc.asUser) await asUser(ctx, svc, appDir, 'extract', ['--tgz', tgz, '--dest', tmp]); else { rmrf(tmp); mkdirSync(tmp, { recursive: true }); await untar(ctx, tgz, tmp); } + if (desc.copyPaths && !existsSync(join(tmp, 'package.json'))) { await ops.remove(tmp).catch(() => {}); throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); } + await ops.writeJson(join(tmp, 'REVISION.json'), { module, version, upstreamVersion: readJson(join(tmp, 'package.json'), {}).version || null, installedAt: new Date().toISOString(), by: svc.asUser ? svc.user : svc.me }); + let kept = null; + if (existsSync(relDir)) { if (prev && prev.replace(/\/$/, '') === relDir) { kept = `${relDir}.prev-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; await ops.rename(relDir, kept); prevDir = kept; } else await ops.remove(relDir); } + await ops.rename(tmp, relDir); + rmSync(tgz, { force: true }); return { dir: relDir, asUser: svc.asUser ? svc.user : null, replacedRunning: kept }; }, '릴리스 풀기'); - return { relDir, prev }; + return { relDir, prev: prevDir }; } export const kinds = { 'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } }, diff --git a/deploy/agent-install.sh b/deploy/agent-install.sh index ab8ec28..14f9be8 100755 --- a/deploy/agent-install.sh +++ b/deploy/agent-install.sh @@ -17,6 +17,8 @@ if [ ! -f "$APP_DIR/releases/$VERSION/agent/index.mjs" ]; then # 등록 전에는 서명을 만들 수 없으므로 부트스트랩 전용 공개 경로가 아니라 등록 토큰으로 1회 받는다: HAL 의 install.sh 는 토큰과 함께 artifact 를 함께 보낸다(아래 등록 뒤 재시도 경로). if [ -n "$TOKEN" ]; then curl -fsS -H "X-Registration-Token: $TOKEN" "$HAL/agent/v1/bootstrap-artifact?module=agent&version=$VERSION" -o /tmp/ariweb-agent-$$.tgz; tar -xzf /tmp/ariweb-agent-$$.tgz -C "$APP_DIR/releases/$VERSION"; rm -f /tmp/ariweb-agent-$$.tgz; else echo "코드가 없고 토큰도 없습니다" >&2; exit 1; fi fi +# 실행 계정이 다른 도입 모듈 설치(deploy/module-as-user.sh)를 위해 코드 트리는 다른 계정도 읽을 수 있어야 한다 — 호출자의 umask 와 무관하게 맞춘다(실제 환경 결함 수정) +chmod 755 "$APP_DIR" "$APP_DIR/releases"; chmod -R a+rX "$APP_DIR/releases/$VERSION" ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"; cd "$APP_DIR/current" export ARIWEB_AGENT_DATA="$APP_DIR/data" if [ ! -f "$APP_DIR/data/agent.json" ]; then [ -n "$TOKEN" ] || { echo "등록되지 않았고 --token 도 없습니다" >&2; exit 1; }; echo "== 등록"; if [ "$EXTERNAL" = "1" ]; then $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --external --site "$SITE" --label "$LABEL"; else $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --role "$ROLE" --label "$LABEL"; fi; else echo "== 이미 등록됨($(cat "$APP_DIR/data/agent.json" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>console.log(JSON.parse(s).id))")) — 코드·유닛만 갱신"; fi diff --git a/deploy/module-as-user.sh b/deploy/module-as-user.sh index cfb3696..c72d89a 100755 --- a/deploy/module-as-user.sh +++ b/deploy/module-as-user.sh @@ -10,6 +10,7 @@ # extract --tgz --dest > 릴리스 디렉터리를 비우고 artifact 를 푼다 # write --file 표준 입력을 파일로 쓴다(REVISION.json·.previous.json) # switch --link --target <릴리스 디렉터리> 심링크 원자 교체(ln → mv -T) +# rename --dir --target 디렉터리 이름 바꾸기(같은 버전 재설치 때 실행 중인 디렉터리 보존) # remove --dir > 오래된 릴리스 삭제(releases 아래만) # backup-db --db --out <백업 파일> [--node ] SQLite 온라인 백업(VACUUM INTO) # migrate --cwd <릴리스 디렉터리> --script <상대 경로> [--env-file ] [--node ] EnvironmentFile 을 읽어 마이그레이션 실행 @@ -34,6 +35,9 @@ case "$SUB" in 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";; + rename) + inside "$DIR"; inside "$TARGET"; case "$DIR" in "$APP_DIR"/releases/*) ;; *) echo "--dir 는 releases 아래여야 합니다" >&2; exit 3;; esac; case "$TARGET" in "$APP_DIR"/releases/*) ;; *) echo "--target 은 releases 아래여야 합니다" >&2; exit 3;; esac + mv -T "$DIR" "$TARGET"; echo "renamed $DIR -> $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) diff --git a/docs/verification/local-e2e-2026-10-26.json b/docs/verification/local-e2e-2026-10-26.json index 9285942..ad7ee70 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:41:15.840Z", - "startedAt": "2026-10-26T13:38:57.273Z", + "generatedAt": "2026-10-26T13:48:14.316Z", + "startedAt": "2026-10-26T13:45:53.302Z", "env": { "agents": 4, "ports": { - "hal": 36777, - "portal": 41737, - "wm": 35879, - "am": 33601, - "mail": 35379 + "hal": 46607, + "portal": 39069, + "wm": 45365, + "am": 42395, + "mail": 33497 }, "mailServerRegistered": true, "node": "v24.22.1" @@ -99,12 +99,12 @@ { "name": "팀 그룹 만들기 → 현재 그룹 전환", "ok": true, - "detail": "g_01a1786f982923b9f2a5d42da0" + "detail": "g_01a17875f11b9489b3cffd6c2f" }, { "name": "이메일 초대(7일) → 초대 메일 링크", "ok": true, - "detail": "1Q4qKwkMTVqYSZzHwOiCiXEDb2bTjzyT" + "detail": "iFfW1b9wDpP5xaranX9C91Y7Ca9Wpzz_" }, { "name": "초대 수락(1회용) → 별칭 user-02 · 역할 member", @@ -162,7 +162,7 @@ "detail": { "count": 2, "team": { - "id": "pv_01a1786f96aeaf5dbc68cb97ca", + "id": "pv_01a17875ef9c7d4bc90d899709", "price": 49000, "sites": 3 }, @@ -179,7 +179,7 @@ "orderNo": "AW-20261026-0001", "status": "awaiting_payment", "total": 63800, - "dueAt": "2026-11-02T13:39:01.930Z", + "dueAt": "2026-11-02T13:45:57.903Z", "hasInstructions": true } }, @@ -221,7 +221,7 @@ "detail": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T13:39:01.993Z" + "nextBilling": "2026-11-26T13:45:57.967Z" } } ], @@ -233,7 +233,7 @@ "bill": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T13:39:01.993Z" + "nextBilling": "2026-11-26T13:45:57.967Z" } } }, @@ -248,7 +248,7 @@ "name": "사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내", "ok": true, "detail": { - "id": "s_01a1786f992b08508f0cad57b8", + "id": "s_01a17875f20eda91e0a9944ad5", "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:35879/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:45365/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 } @@ -299,7 +299,7 @@ ], "evidence": { "sso": { - "portalRedirect": "http://127.0.0.1:35879/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:45365/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 }, @@ -320,7 +320,7 @@ "name": "배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나", "ok": true, "detail": { - "jobId": "j_01a1786f997765631aebc605be", + "jobId": "j_01a17875f2583fac6a21c03d83", "steps": 10, "again": false, "siteCount": "reserved", @@ -351,7 +351,7 @@ "ok": true, "detail": { "lifecycle": "needs_domain", - "version": "20261026133903-rev-c1-000000", + "version": "20261026134559-rev-c1-000000", "verify": "needs_domain", "health": "ok", "agentHasSite": true @@ -360,7 +360,7 @@ ], "evidence": { "d": { - "jobId": "j_01a1786f997765631aebc605be", + "jobId": "j_01a17875f2583fac6a21c03d83", "steps": 10, "again": false, "siteCount": "reserved", @@ -383,7 +383,7 @@ }, "after": { "lifecycle": "needs_domain", - "version": "20261026133903-rev-c1-000000", + "version": "20261026134559-rev-c1-000000", "verify": "needs_domain", "health": "ok", "agentHasSite": true @@ -401,7 +401,7 @@ "name": "도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)", "ok": true, "detail": { - "domainId": "d_01a1786fa94c717fb17b4eda3a", + "domainId": "d_01a17876022d5b03636ffc037c", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -422,7 +422,7 @@ ], "evidence": { "dm": { - "domainId": "d_01a1786fa94c717fb17b4eda3a", + "domainId": "d_01a17876022d5b03636ffc037c", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -500,7 +500,7 @@ "name": "사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시", "ok": true, "detail": { - "id": "msv_01a1787031d54f3f250dac0ce2", + "id": "msv_01a178768ab769ac75250c2587", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -517,7 +517,7 @@ "name": "메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음", "ok": true, "detail": { - "id": "mb_01a17870320f11eedf7f6c6e3b", + "id": "mb_01a178768af07ad31d4101a950", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -584,7 +584,7 @@ ], "evidence": { "svc": { - "id": "msv_01a1787031d54f3f250dac0ce2", + "id": "msv_01a178768ab769ac75250c2587", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -592,7 +592,7 @@ "siteCountAllocated": 0 }, "mb": { - "id": "mb_01a17870320f11eedf7f6c6e3b", + "id": "mb_01a178768af07ad31d4101a950", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -644,7 +644,7 @@ "name": "메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)", "ok": true, "detail": { - "id": "d_01a178705c269810ff7cbfe471", + "id": "d_01a17876b11c58953269f9068b", "jobCancelled": null } }, @@ -652,7 +652,7 @@ "name": "독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)", "ok": true, "detail": { - "id": "msv_01a178705c2f17aa866ea77237", + "id": "msv_01a17876b1243f5ffd967e048f", "kind": "standalone", "mailServicesAllocated": 1, "sitesAllocated": 0 @@ -661,7 +661,7 @@ ], "evidence": { "r": { - "id": "msv_01a178705c2f17aa866ea77237", + "id": "msv_01a17876b1243f5ffd967e048f", "kind": "standalone", "mailServicesAllocated": 1, "sitesAllocated": 0 @@ -701,8 +701,8 @@ "ok": true, "detail": { "status": "active", - "firstDue": "2026-10-27T13:39:52.132Z", - "nextRun": "2026-10-26T13:39:52.132Z" + "firstDue": "2026-10-27T13:46:47.104Z", + "nextRun": "2026-10-26T13:46:47.104Z" } }, { @@ -712,7 +712,7 @@ "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": "srv_01a1786f908cc425993b51166d", + "siteServer": "srv_01a17875e98da87bc2146b9eb2", "backup": { "status": "done", "tier": "monthly", @@ -733,14 +733,14 @@ }, "opt": { "status": "active", - "firstDue": "2026-10-27T13:39:52.132Z", - "nextRun": "2026-10-26T13:39:52.132Z" + "firstDue": "2026-10-27T13:46:47.104Z", + "nextRun": "2026-10-26T13:46:47.104Z" }, "run1": { "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": "srv_01a1786f908cc425993b51166d", + "siteServer": "srv_01a17875e98da87bc2146b9eb2", "backup": { "status": "done", "tier": "monthly", @@ -763,19 +763,19 @@ "ok": true, "detail": { "backup": { - "id": "bk_01a178706df58e2ec6b8315964", - "createdAt": "2026-10-26T13:39:52.139Z", + "id": "bk_01a17876c6e6fa42376ed65982", + "createdAt": "2026-10-26T13:46:47.110Z", "scope": { "files": true, "db": true, "uploads": true, "config": true, - "storeServerId": "srv_01a1786f913e88093d5c9ab3a8", + "storeServerId": "srv_01a17875ea3e7b517b81c4fcc5", "verifyMethod": "sha256 + 격리 환경 읽기 확인" }, - "sizeBytes": 1891, + "sizeBytes": 1896, "verifyState": "verified", - "expiresAt": "2027-01-27T13:39:56.534Z", + "expiresAt": "2027-01-27T13:46:52.519Z", "tier": "monthly" }, "included": [ @@ -809,7 +809,7 @@ "name": "복원 요청 → 운영자 승인 대기 · 중복 요청 없음", "ok": true, "detail": { - "id": "rs_01a178706e9a3a6cc10e6874f3", + "id": "rs_01a17876c977953a4880a342ae", "status": "awaiting_approval", "dup": false, "finalConfirmation": { @@ -838,19 +838,19 @@ "evidence": { "info": { "backup": { - "id": "bk_01a178706df58e2ec6b8315964", - "createdAt": "2026-10-26T13:39:52.139Z", + "id": "bk_01a17876c6e6fa42376ed65982", + "createdAt": "2026-10-26T13:46:47.110Z", "scope": { "files": true, "db": true, "uploads": true, "config": true, - "storeServerId": "srv_01a1786f913e88093d5c9ab3a8", + "storeServerId": "srv_01a17875ea3e7b517b81c4fcc5", "verifyMethod": "sha256 + 격리 환경 읽기 확인" }, - "sizeBytes": 1891, + "sizeBytes": 1896, "verifyState": "verified", - "expiresAt": "2027-01-27T13:39:56.534Z", + "expiresAt": "2027-01-27T13:46:52.519Z", "tier": "monthly" }, "included": [ @@ -880,7 +880,7 @@ "deferred": [] }, "rq": { - "id": "rs_01a178706e9a3a6cc10e6874f3", + "id": "rs_01a17876c977953a4880a342ae", "status": "awaiting_approval", "dup": false, "finalConfirmation": { @@ -970,8 +970,8 @@ "name": "외부 배포 사이트 생성(초안) + 저장본", "ok": true, "detail": { - "id": "s_01a178715a0ac510fc2723b7d8", - "group_id": "g_01a1786f982923b9f2a5d42da0", + "id": "s_01a17877b4eb507a3b6ef40b20", + "group_id": "g_01a17875f11b9489b3cffd6c2f", "name": "E2E 외부 사이트", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000004", @@ -999,9 +999,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1786f9784bbf039c9db24b5", - "created_at": "2026-10-26T13:40:56.970Z", - "updated_at": "2026-10-26T13:40:56.976Z", + "created_by": "u_01a17875f0756189702e036cfa", + "created_at": "2026-10-26T13:47:53.451Z", + "updated_at": "2026-10-26T13:47:53.456Z", "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:36777/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:36777 --token … --external --site s_01a178715a0ac510fc2723b7d8", - "expiresAt": "2026-10-26T13:50:57.173Z", + "install": "curl -fsSL http://127.0.0.1:46607/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:46607 --token … --external --site s_01a17877b4eb507a3b6ef40b20", + "expiresAt": "2026-10-26T13:57:53.658Z", "status": "pending" } }, @@ -1126,8 +1126,8 @@ "agreed": false }, "tok": { - "install": "curl -fsSL http://127.0.0.1:36777/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:36777 --token … --external --site s_01a178715a0ac510fc2723b7d8", - "expiresAt": "2026-10-26T13:50:57.173Z" + "install": "curl -fsSL http://127.0.0.1:46607/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:46607 --token … --external --site s_01a17877b4eb507a3b6ef40b20", + "expiresAt": "2026-10-26T13:57:53.658Z" }, "conn": { "status": "connected", @@ -1311,7 +1311,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a1786f96ae186ef39a5cdc8c" + "product": "pv_01a17875ef9bae0500f3b9cd05" } } ], @@ -1347,7 +1347,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a1786f96ae186ef39a5cdc8c" + "product": "pv_01a17875ef9bae0500f3b9cd05" } } }, @@ -1430,9 +1430,9 @@ "name": "요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림", "ok": true, "detail": { - "id": "tr_01a17871751ae3edec78ee78f6", + "id": "tr_01a17877d7ce974b1e1173d116", "status": "requested", - "expiresAt": "2026-11-02T13:41:03.898Z", + "expiresAt": "2026-11-02T13:48:02.382Z", "bobNotified": true } }, @@ -1460,7 +1460,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T13:41:03.948Z" + "cutover": "2026-10-26T13:48:02.429Z" } }, { @@ -1489,9 +1489,9 @@ "excluded": 3 }, "rq": { - "id": "tr_01a17871751ae3edec78ee78f6", + "id": "tr_01a17877d7ce974b1e1173d116", "status": "requested", - "expiresAt": "2026-11-02T13:41:03.898Z", + "expiresAt": "2026-11-02T13:48:02.382Z", "bobNotified": true }, "acc": { @@ -1503,7 +1503,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T13:41:03.948Z" + "cutover": "2026-10-26T13:48:02.429Z" }, "after": { "aliceSees": 404, @@ -1781,7 +1781,7 @@ "detail": { "status": "done", "canRetry": false, - "jobId": "j_01a1786f997765631aebc605be" + "jobId": "j_01a17875f2583fac6a21c03d83" } }, { @@ -1806,7 +1806,7 @@ "dep": { "status": "done", "canRetry": false, - "jobId": "j_01a1786f997765631aebc605be" + "jobId": "j_01a17875f2583fac6a21c03d83" }, "rt": { "status": 400, @@ -1832,7 +1832,7 @@ "detail": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a1786f98aada4e1d8cb6a80d" + "link": "/orders/o_01a17875f18f0b333e93586b59" } }, { @@ -1848,7 +1848,7 @@ "ok": true, "detail": { "status": "approved", - "approvedAt": "2026-10-26T13:39:01.993Z" + "approvedAt": "2026-10-26T13:45:57.967Z" } } ], @@ -1856,7 +1856,7 @@ "n": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a1786f98aada4e1d8cb6a80d" + "link": "/orders/o_01a17875f18f0b333e93586b59" }, "a": { "action": "order.approve", @@ -1864,7 +1864,7 @@ }, "same": { "status": "approved", - "approvedAt": "2026-10-26T13:39:01.993Z" + "approvedAt": "2026-10-26T13:45:57.967Z" } } }, @@ -1907,9 +1907,9 @@ "rpo_minutes": 30, "snapshot_interval_minutes": 60, "retention_count": 3, - "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026133857/dr-external", - "updated_at": "2026-10-26T13:41:12.888Z", - "updated_by": "op_01a1786f8f8e2933698e86e10b" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026134553/dr-external", + "updated_at": "2026-10-26T13:48:11.369Z", + "updated_by": "op_01a17875e8949751b9c2ad4e81" } }, { @@ -1969,9 +1969,9 @@ "rpo_minutes": 30, "snapshot_interval_minutes": 60, "retention_count": 3, - "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026133857/dr-external", - "updated_at": "2026-10-26T13:41:12.888Z", - "updated_by": "op_01a1786f8f8e2933698e86e10b" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026134553/dr-external", + "updated_at": "2026-10-26T13:48:11.369Z", + "updated_by": "op_01a17875e8949751b9c2ad4e81" }, "snap": { "verify": "ok", @@ -2005,10 +2005,10 @@ "ok": true, "detail": { "mailJob": { - "id": "j_01a178704650615751827d968f", + "id": "j_01a178769b490bf9d4d6789dbe", "status": "awaiting_external" }, - "halJobs": 19 + "halJobs": 18 } }, { @@ -2017,7 +2017,7 @@ "detail": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a178704650615751827d968f", + "id": "j_01a178769b490bf9d4d6789dbe", "status": "awaiting_external" }, "same": true @@ -2028,7 +2028,7 @@ "ok": true, "detail": { "opsSessionKept": "[redacted]", - "halJobs": 19, + "halJobs": 18, "sameCount": true } } @@ -2036,22 +2036,22 @@ "evidence": { "before": { "mailJob": { - "id": "j_01a178704650615751827d968f", + "id": "j_01a178769b490bf9d4d6789dbe", "status": "awaiting_external" }, - "halJobs": 19 + "halJobs": 18 }, "pr": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a178704650615751827d968f", + "id": "j_01a178769b490bf9d4d6789dbe", "status": "awaiting_external" }, "same": true }, "hr": { "opsSessionKept": "[redacted]", - "halJobs": 19, + "halJobs": 18, "sameCount": true } } @@ -2075,8 +2075,8 @@ "ok": true, "detail": [ { - "id": "s_01a1787025d63eec09b696bba4", - "group_id": "g_01a1786f985a2da7ec88926cd7", + "id": "s_01a178767eb898273f57f49eba", + "group_id": "g_01a17875f149365f9f71347786", "name": "동시 A", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000002", @@ -2104,9 +2104,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1786f9859846f72516fd3a8", - "created_at": "2026-10-26T13:39:38.070Z", - "updated_at": "2026-10-26T13:39:38.079Z", + "created_by": "u_01a17875f14967f663983b81d6", + "created_at": "2026-10-26T13:46:34.041Z", + "updated_at": "2026-10-26T13:46:34.049Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -2125,8 +2125,8 @@ "nextJob": null }, { - "id": "s_01a1787025da838857e5102652", - "group_id": "g_01a1786f985a2da7ec88926cd7", + "id": "s_01a178767ebc448a8da6015997", + "group_id": "g_01a17875f149365f9f71347786", "name": "동시 B", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000003", @@ -2154,9 +2154,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1786f9859846f72516fd3a8", - "created_at": "2026-10-26T13:39:38.074Z", - "updated_at": "2026-10-26T13:39:38.081Z", + "created_by": "u_01a17875f14967f663983b81d6", + "created_at": "2026-10-26T13:46:34.044Z", + "updated_at": "2026-10-26T13:46:34.052Z", "runtime": "static", "consent_version": null, "pending_cleanup_json": null, @@ -2214,8 +2214,8 @@ "name": "외부 배포 사이트 생성(초안) + 저장본", "ok": true, "detail": { - "id": "s_01a178715a0ac510fc2723b7d8", - "group_id": "g_01a1786f982923b9f2a5d42da0", + "id": "s_01a17877b4eb507a3b6ef40b20", + "group_id": "g_01a17875f11b9489b3cffd6c2f", "name": "E2E 외부 사이트", "purpose": "e2e", "webmaker_project_id": "00000000000000000000000000000004", @@ -2243,9 +2243,9 @@ "retention_until": null, "delete_after": null, "deleted_at": null, - "created_by": "u_01a1786f9784bbf039c9db24b5", - "created_at": "2026-10-26T13:40:56.970Z", - "updated_at": "2026-10-26T13:40:56.976Z", + "created_by": "u_01a17875f0756189702e036cfa", + "created_at": "2026-10-26T13:47:53.451Z", + "updated_at": "2026-10-26T13:47:53.456Z", "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:36777/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:36777 --token … --external --site s_01a178715a0ac510fc2723b7d8", - "expiresAt": "2026-10-26T13:50:57.173Z", + "install": "curl -fsSL http://127.0.0.1:46607/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:46607 --token … --external --site s_01a17877b4eb507a3b6ef40b20", + "expiresAt": "2026-10-26T13:57:53.658Z", "status": "pending" } }, @@ -2483,8 +2483,8 @@ "overall": "partial", "children": [ "srv_missing_e2e:failed:서버가 없습니다", - "srv_01a1786f913e88093d5c9ab3a8:done:", - "srv_01a1786f908cc425993b51166d:done:" + "srv_01a17875ea3e7b517b81c4fcc5:done:", + "srv_01a17875e98da87bc2146b9eb2:done:" ], "timedOut": false } @@ -2522,8 +2522,8 @@ "overall": "partial", "children": [ "srv_missing_e2e:failed:서버가 없습니다", - "srv_01a1786f913e88093d5c9ab3a8:done:", - "srv_01a1786f908cc425993b51166d:done:" + "srv_01a17875ea3e7b517b81c4fcc5:done:", + "srv_01a17875e98da87bc2146b9eb2:done:" ], "timedOut": false }, @@ -2562,7 +2562,7 @@ "name": "critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)", "ok": true, "detail": { - "id": "em_01a17871994d6c62aa90109d35", + "id": "em_01a17877fbffa993b6adc0d89c", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", @@ -2570,7 +2570,7 @@ "approved": "approved", "approvals": 3, "credential": "[redacted]", - "expiresAt": "2026-10-26T14:11:13.193Z" + "expiresAt": "2026-10-26T14:18:11.675Z" } }, { @@ -2591,14 +2591,14 @@ "evidence": { "healthy": "hal_healthy", "sess": { - "id": "em_01a17871994d6c62aa90109d35", + "id": "em_01a17877fbffa993b6adc0d89c", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", "badText": "final_confirm_mismatch", "approved": "approved", "approvals": 3, - "expiresAt": "2026-10-26T14:11:13.193Z" + "expiresAt": "2026-10-26T14:18:11.675Z" }, "perf": { "forbidden": "action_forbidden", @@ -2650,8 +2650,8 @@ "ok": true, "detail": { "status": "readonly", - "deleteAfter": "2026-11-25T13:41:03.834Z", - "readonlyUntil": "2026-11-25T13:41:03.834Z" + "deleteAfter": "2026-11-25T13:48:02.316Z", + "readonlyUntil": "2026-11-25T13:48:02.316Z" } }, { @@ -2677,8 +2677,8 @@ }, "un": { "status": "readonly", - "deleteAfter": "2026-11-25T13:41:03.834Z", - "readonlyUntil": "2026-11-25T13:41:03.834Z" + "deleteAfter": "2026-11-25T13:48:02.316Z", + "readonlyUntil": "2026-11-25T13:48:02.316Z" }, "sweep": { "sweep": 200, diff --git a/docs/verification/local-e2e-2026-10-26.md b/docs/verification/local-e2e-2026-10-26.md index c0d6bf7..c729374 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:41:15.840Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/) +생성: 2026-10-26T13:48:14.316Z · `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_01a1786f982923b9f2a5d42da0 -- ✔ 이메일 초대(7일) → 초대 메일 링크 — 1Q4qKwkMTVqYSZzHwOiCiXEDb2bTjzyT +- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a17875f11b9489b3cffd6c2f +- ✔ 이메일 초대(7일) → 초대 메일 링크 — iFfW1b9wDpP5xaranX9C91Y7Ca9Wpzz_ - ✔ 초대 수락(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_01a1786f96aeaf5dbc68cb97ca","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]} -- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T13:39:01.930Z","hasInstructions":true} +- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a17875ef9c7d4bc90d899709","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]} +- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T13:45:57.903Z","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:39:01.993Z"} +- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T13:45:57.967Z"} ```json { @@ -112,25 +112,25 @@ "bill": { "sites": 3, "subs": 1, - "nextBilling": "2026-11-26T13:39:01.993Z" + "nextBilling": "2026-11-26T13:45:57.967Z" } } ``` ### 4. 초안 사이트 생성 -- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a1786f992b08508f0cad57b8","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]} +- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a17875f20eda91e0a9944ad5","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:35879/api/auth/sso?ticket=…","webmaker":302,"reuse":401} +- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:45365/api/auth/sso?ticket=…","webmaker":302,"reuse":401} - ✔ Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과 — {"versions":1,"precheckOk":true} ```json { "sso": { - "portalRedirect": "http://127.0.0.1:35879/api/auth/sso?ticket=…", + "portalRedirect": "http://127.0.0.1:45365/api/auth/sso?ticket=…", "webmaker": 302, "reuse": 401 }, @@ -143,14 +143,14 @@ ### 6. 관리형 배포 -- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a1786f997765631aebc605be","steps":10,"again":false,"siteCount":"reserved","server":null} +- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a17875f2583fac6a21c03d83","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":"20261026133903-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true} +- ✔ 배포 뒤 사이트 상태·버전·건강 — {"lifecycle":"needs_domain","version":"20261026134559-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true} ```json { "d": { - "jobId": "j_01a1786f997765631aebc605be", + "jobId": "j_01a17875f2583fac6a21c03d83", "steps": 10, "again": false, "siteCount": "reserved", @@ -173,7 +173,7 @@ }, "after": { "lifecycle": "needs_domain", - "version": "20261026133903-rev-c1-000000", + "version": "20261026134559-rev-c1-000000", "verify": "needs_domain", "health": "ok", "agentHasSite": true @@ -183,14 +183,14 @@ ### 7. 도메인 연결과 HTTPS 발급 -- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1786fa94c717fb17b4eda3a","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true} +- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a17876022d5b03636ffc037c","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true} - ✔ 도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시 — {} - ✔ (정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다 — {"queuedProbes":0} ```json { "dm": { - "domainId": "d_01a1786fa94c717fb17b4eda3a", + "domainId": "d_01a17876022d5b03636ffc037c", "txtName": "_ariweb-verify.aw-e2e.localhost", "txtValueShape": "ariweb-verify=…", "copyable": true @@ -233,9 +233,9 @@ ### 10. 사이트 대표 메일과 메일함 생성 -- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1787031d54f3f250dac0ce2","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0} +- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a178768ab769ac75250c2587","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0} - ✔ 같은 사이트에 두 번째 대표 메일 거부 — mail_domain_in_use -- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a17870320f11eedf7f6c6e3b","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true} +- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a178768af07ad31d4101a950","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_01a1787031d54f3f250dac0ce2", + "id": "msv_01a178768ab769ac75250c2587", "status": "needs_setup", "tenant": true, "ariwebKind": "site_bound", @@ -252,7 +252,7 @@ "siteCountAllocated": 0 }, "mb": { - "id": "mb_01a17870320f11eedf7f6c6e3b", + "id": "mb_01a178768af07ad31d4101a950", "address": "kim@aw-e2e.localhost", "passwordShownOnce": "[redacted]", "mustChange": true, @@ -287,13 +287,13 @@ ### 12. 독립 메일 서비스 생성 -- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a178705c269810ff7cbfe471","jobCancelled":null} -- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a178705c2f17aa866ea77237","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0} +- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a17876b11c58953269f9068b","jobCancelled":null} +- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a17876b1243f5ffd967e048f","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0} ```json { "r": { - "id": "msv_01a178705c2f17aa866ea77237", + "id": "msv_01a17876b1243f5ffd967e048f", "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:39:52.132Z","nextRun":"2026-10-26T13:39:52.132Z"} -- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a1786f908cc425993b51166d","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0} +- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T13:46:47.104Z","nextRun":"2026-10-26T13:46:47.104Z"} +- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a17875e98da87bc2146b9eb2","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0} ```json { @@ -321,14 +321,14 @@ }, "opt": { "status": "active", - "firstDue": "2026-10-27T13:39:52.132Z", - "nextRun": "2026-10-26T13:39:52.132Z" + "firstDue": "2026-10-27T13:46:47.104Z", + "nextRun": "2026-10-26T13:46:47.104Z" }, "run1": { "timedOut": false, "optionStatus": "active", "lastJob": null, - "siteServer": "srv_01a1786f908cc425993b51166d", + "siteServer": "srv_01a17875e98da87bc2146b9eb2", "backup": { "status": "done", "tier": "monthly", @@ -342,8 +342,8 @@ ### 14. 백업 복원과 외부 사이트 확인 -- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a178706df58e2ec6b8315964","createdAt":"2026-10-26T13:39:52.139Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a1786f913e88093d5c9ab3a8","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1891,"verifyState":"verified","expiresAt":"2027-01-2 -- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a178706e9a3a6cc10e6874f3","status":"awaiting_approval","dup":false,"finalConfirmation":{"group":"E2E 팀","site":"E2E 사이트","domain":null}} +- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a17876c6e6fa42376ed65982","createdAt":"2026-10-26T13:46:47.110Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a17875ea3e7b517b81c4fcc5","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1896,"verifyState":"verified","expiresAt":"2027-01-2 +- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a17876c977953a4880a342ae","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_01a178706df58e2ec6b8315964", - "createdAt": "2026-10-26T13:39:52.139Z", + "id": "bk_01a17876c6e6fa42376ed65982", + "createdAt": "2026-10-26T13:46:47.110Z", "scope": { "files": true, "db": true, "uploads": true, "config": true, - "storeServerId": "srv_01a1786f913e88093d5c9ab3a8", + "storeServerId": "srv_01a17875ea3e7b517b81c4fcc5", "verifyMethod": "sha256 + 격리 환경 읽기 확인" }, - "sizeBytes": 1891, + "sizeBytes": 1896, "verifyState": "verified", - "expiresAt": "2027-01-27T13:39:56.534Z", + "expiresAt": "2027-01-27T13:46:52.519Z", "tier": "monthly" }, "included": [ @@ -393,7 +393,7 @@ "deferred": [] }, "rq": { - "id": "rs_01a178706e9a3a6cc10e6874f3", + "id": "rs_01a17876c977953a4880a342ae", "status": "awaiting_approval", "dup": false, "finalConfirmation": { @@ -445,10 +445,10 @@ ### 16. 외부 배포 사이트 생성과 배포 -- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a178715a0ac510fc2723b7d8","group_id":"g_01a1786f982923b9f2a5d42da0","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_01a17877b4eb507a3b6ef40b20","group_id":"g_01a17875f11b9489b3cffd6c2f","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:36777/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:36777 --token … --external --site s_01a178715a0ac510fc2723b7d8","expiresAt":"2026-10-26T13:50:57.173Z","status":"pending"} +- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:46607/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:46607 --token … --external --site s_01a17877b4eb507a3b6ef40b20","expiresAt":"2026-10-26T13:57:53.658Z","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:36777/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:36777 --token … --external --site s_01a178715a0ac510fc2723b7d8", - "expiresAt": "2026-10-26T13:50:57.173Z" + "install": "curl -fsSL http://127.0.0.1:46607/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:46607 --token … --external --site s_01a17877b4eb507a3b6ef40b20", + "expiresAt": "2026-10-26T13:57:53.658Z" }, "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_01a1786f96ae186ef39a5cdc8c"} +- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a17875ef9bae0500f3b9cd05"} ```json { @@ -589,7 +589,7 @@ "approve": 200, "sites": 1, "mailboxes": 5, - "product": "pv_01a1786f96ae186ef39a5cdc8c" + "product": "pv_01a17875ef9bae0500f3b9cd05" } } ``` @@ -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_01a17871751ae3edec78ee78f6","status":"requested","expiresAt":"2026-11-02T13:41:03.898Z","bobNotified":true} +- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a17877d7ce974b1e1173d116","status":"requested","expiresAt":"2026-11-02T13:48:02.382Z","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:41:03.948Z"} +- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T13:48:02.429Z"} - ✔ 완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사 — {"aliceSees":404,"bobSees":200,"bobUsageSites":0,"audit":true} ```json @@ -645,9 +645,9 @@ "excluded": 3 }, "rq": { - "id": "tr_01a17871751ae3edec78ee78f6", + "id": "tr_01a17877d7ce974b1e1173d116", "status": "requested", - "expiresAt": "2026-11-02T13:41:03.898Z", + "expiresAt": "2026-11-02T13:48:02.382Z", "bobNotified": true }, "acc": { @@ -659,7 +659,7 @@ "bad": "final_confirmation_mismatch", "ok": 200, "status": "completed", - "cutover": "2026-10-26T13:41:03.948Z" + "cutover": "2026-10-26T13:48:02.429Z" }, "after": { "aliceSees": 404, @@ -798,7 +798,7 @@ ### 23. 실패 작업의 재시도와 되돌리기 -- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a1786f997765631aebc605be"} +- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a17875f2583fac6a21c03d83"} - ✔ 재시도 요청(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_01a1786f997765631aebc605be" + "jobId": "j_01a17875f2583fac6a21c03d83" }, "rt": { "status": 400, @@ -823,16 +823,16 @@ ### 24. 운영자 승인, 감사 기록, 고객 알림의 일치 -- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a1786f98aada4e1d8cb6a80d"} +- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a17875f18f0b333e93586b59"} - ✔ 감사 기록: order.approve 는 삭제 불가(immutable) — {"action":"order.approve","immutable":true} -- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T13:39:01.993Z"} +- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T13:45:57.967Z"} ```json { "n": { "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", "category": "billing", - "link": "/orders/o_01a1786f98aada4e1d8cb6a80d" + "link": "/orders/o_01a17875f18f0b333e93586b59" }, "a": { "action": "order.approve", @@ -840,7 +840,7 @@ }, "same": { "status": "approved", - "approvedAt": "2026-10-26T13:39:01.993Z" + "approvedAt": "2026-10-26T13:45:57.967Z" } } ``` @@ -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-20261026133857/dr-external","updated_at":"2026-10-26T13:41:12.888Z","updated_by":"op_01a1786f8f8e2933698e86e10b"} +- ✔ 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-20261026134553/dr-external","updated_at":"2026-10-26T13:48:11.369Z","updated_by":"op_01a17875e8949751b9c2ad4e81"} - ✔ 복구 사본 생성·검증·외부 보관(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-20261026133857/dr-external", - "updated_at": "2026-10-26T13:41:12.888Z", - "updated_by": "op_01a1786f8f8e2933698e86e10b" + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026134553/dr-external", + "updated_at": "2026-10-26T13:48:11.369Z", + "updated_by": "op_01a17875e8949751b9c2ad4e81" }, "snap": { "verify": "ok", @@ -907,30 +907,30 @@ ### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 -- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a178704650615751827d968f","status":"awaiting_external"},"halJobs":19} -- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a178704650615751827d968f","status":"awaiting_external"},"same":true} -- ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":19,"sameCount":true} +- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a178769b490bf9d4d6789dbe","status":"awaiting_external"},"halJobs":18} +- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a178769b490bf9d4d6789dbe","status":"awaiting_external"},"same":true} +- ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":18,"sameCount":true} ```json { "before": { "mailJob": { - "id": "j_01a178704650615751827d968f", + "id": "j_01a178769b490bf9d4d6789dbe", "status": "awaiting_external" }, - "halJobs": 19 + "halJobs": 18 }, "pr": { "sessionKept": "[redacted]", "mailJob": { - "id": "j_01a178704650615751827d968f", + "id": "j_01a178769b490bf9d4d6789dbe", "status": "awaiting_external" }, "same": true }, "hr": { "opsSessionKept": "[redacted]", - "halJobs": 19, + "halJobs": 18, "sameCount": true } } @@ -939,7 +939,7 @@ ### 27. 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 - ✔ 밥 개인 그룹: 베이직(사이트 1) 주문·승인 — {"granted":6} -- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1787025d63eec09b696bba4","group_id":"g_01a1786f985a2da7ec88926cd7","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_01a178767eb898273f57f49eba","group_id":"g_01a17875f149365f9f71347786","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_01a178715a0ac510fc2723b7d8","group_id":"g_01a1786f982923b9f2a5d42da0","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_01a17877b4eb507a3b6ef40b20","group_id":"g_01a17875f11b9489b3cffd6c2f","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:36777/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:36777 --token … --external --site s_01a178715a0ac510fc2723b7d8","expiresAt":"2026-10-26T13:50:57.173Z","status":"pending"} +- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:46607/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:46607 --token … --external --site s_01a17877b4eb507a3b6ef40b20","expiresAt":"2026-10-26T13:57:53.658Z","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_01a1786f913e88093d5c9ab3a8:done:","srv_01a1786f908cc425993b51166d:done:"],"timedOut":false} +- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"failed","errorCode":"partial_failure","overall":"partial","children":["srv_missing_e2e:failed:서버가 없습니다","srv_01a17875ea3e7b517b81c4fcc5:done:","srv_01a17875e98da87bc2146b9eb2: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"} @@ -1051,8 +1051,8 @@ "overall": "partial", "children": [ "srv_missing_e2e:failed:서버가 없습니다", - "srv_01a1786f913e88093d5c9ab3a8:done:", - "srv_01a1786f908cc425993b51166d:done:" + "srv_01a17875ea3e7b517b81c4fcc5:done:", + "srv_01a17875e98da87bc2146b9eb2:done:" ], "timedOut": false }, @@ -1079,21 +1079,21 @@ ### 31. 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 - ✔ HAL 정상 상태에서 선언 거부(hal_healthy) — hal_healthy -- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a17871994d6c62aa90109d35","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T14:11:13.193Z"} +- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a17877fbffa993b6adc0d89c","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T14:18:11.675Z"} - ✔ 일반 배포(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_01a17871994d6c62aa90109d35", + "id": "em_01a17877fbffa993b6adc0d89c", "noCred": "approvals_insufficient", "wrongStep": "wrong_step", "badIds": "identifiers_mismatch", "badText": "final_confirm_mismatch", "approved": "approved", "approvals": 3, - "expiresAt": "2026-10-26T14:11:13.193Z" + "expiresAt": "2026-10-26T14:18:11.675Z" }, "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:41:03.834Z","readonlyUntil":"2026-11-25T13:41:03.834Z"} +- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T13:48:02.316Z","readonlyUntil":"2026-11-25T13:48:02.316Z"} - ✔ 삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음 — {"sweep":200,"statusAfter":"readonly"} ```json @@ -1131,8 +1131,8 @@ }, "un": { "status": "readonly", - "deleteAfter": "2026-11-25T13:41:03.834Z", - "readonlyUntil": "2026-11-25T13:41:03.834Z" + "deleteAfter": "2026-11-25T13:48:02.316Z", + "readonlyUntil": "2026-11-25T13:48:02.316Z" }, "sweep": { "sweep": 200, diff --git a/hal/core.mjs b/hal/core.mjs index 0326388..7ae1937 100644 --- a/hal/core.mjs +++ b/hal/core.mjs @@ -18,7 +18,8 @@ export const SERVER_ROLES = ['web', 'mail', 'backup', 'control']; // ------------------------------------------------------------------ 부트스트랩 상태(§32.2) export function bootstrapState(d) { let r = d.store.get('SELECT * FROM bootstrap_state WHERE id=1'); if (!r) { d.store.run('INSERT INTO bootstrap_state(id, updated_at) VALUES(1, ?)', nowIso()); r = d.store.get('SELECT * FROM bootstrap_state WHERE id=1'); } return r; } -export function setBootstrap(d, patch) { const sets = Object.keys(patch).map((k) => `${k}=?`); d.store.run(`UPDATE bootstrap_state SET ${sets.join(', ')}, updated_at=? WHERE id=1`, ...Object.values(patch), nowIso()); return bootstrapState(d); } +/** [실제 환경 결함 수정] 행(id=1)이 아직 없으면(HAL 기동 전 CLI bootstrap-release) UPDATE 가 아무 행도 바꾸지 않아 릴리스 정보가 비었다 — 먼저 행을 만든다. */ +export function setBootstrap(d, patch) { bootstrapState(d); const sets = Object.keys(patch).map((k) => `${k}=?`); d.store.run(`UPDATE bootstrap_state SET ${sets.join(', ')}, updated_at=? WHERE id=1`, ...Object.values(patch), nowIso()); return bootstrapState(d); } /** 부트스트랩 완료 여부: HAL 실행 버전 기록 + 첫 Agent 등록·승인 */ export function bootstrapComplete(d) { const b = bootstrapState(d); return !!(b.hal_running_version && b.first_agent_id && b.completed_at); } /** HAL 기동 때: 실행 버전·인스턴스 임대 기록. 다른 인스턴스의 최근 임대가 있으면 거부(§34 "하나의 HAL 만 운영"). */ diff --git a/scripts/release.mjs b/scripts/release.mjs index 954bcae..29c3637 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -29,7 +29,8 @@ if (cmd === 'build') { const artifacts = {}; for (const [m, paths] of Object.entries(MODULES)) { const file = `${m}-${VERSION}.tar.gz`; const abs = join(out, file); const present = paths.filter((p) => existsSync(join(ROOT, p))); - const tarArgs = ['-czf', abs, '-C', ROOT, '--exclude=.git', '--exclude=node_modules', '--exclude=*/data', '--exclude=*/data/*', '--exclude=modules/*/data', '--exclude=dist', '--exclude=*.sqlite', '--exclude=*.log', '--exclude=*.tmp']; + // 디렉터리·파일 모드 정규화(실제 환경 결함 수정): 개발 PC 의 umask(077)가 artifact 에 0700 으로 들어가면 서버에서 다른 실행 계정(arihakdang 등)이 코드를 읽지 못한다. + const tarArgs = ['-czf', abs, '-C', ROOT, '--mode=u=rwX,go=rX', '--owner=0', '--group=0', '--numeric-owner', '--exclude=.git', '--exclude=node_modules', '--exclude=*/data', '--exclude=*/data/*', '--exclude=modules/*/data', '--exclude=dist', '--exclude=*.sqlite', '--exclude=*.log', '--exclude=*.tmp']; if (m === 'webmaker' || m === 'arimail') { tarArgs.push('--transform', `s,^modules/${m},.,`); } tarArgs.push(...present); execFileSync('tar', tarArgs); artifacts[m] = { file, sha256: sha(abs), size: statSync(abs).size, paths: present }; } diff --git a/test/agent.test.mjs b/test/agent.test.mjs index d7d8972..8277895 100644 --- a/test/agent.test.mjs +++ b/test/agent.test.mjs @@ -6,7 +6,7 @@ import { createRunner } from '../agent/runner.mjs'; import { KINDS } from '../agent/jobs/index.mjs'; import { ingestLog } from '../agent/jobs/site.mjs'; import { sample } from '../agent/metrics.mjs'; -import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readlinkSync, copyFileSync, statSync, appendFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readlinkSync, copyFileSync, statSync, appendFileSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; @@ -135,6 +135,9 @@ await test('module.install(실제 환경 결함 수정): REVISION.json 의 Suite 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(); } + // 같은 버전 재설치(실행 중인 current 디렉터리 보존 → .prev-<시각> 가 되돌리기 대상) + r = await runner.execute({ id: 'u1b', 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)); ok(/^0\.1\.1\.prev-\d{14}$/.test(r.result.previousVersion), '보존된 이전 디렉터리: ' + r.result.previousVersion); eq(readlinkSync(join(appDir, 'current')), join(appDir, 'releases', '0.1.1')); ok(existsSync(join(appDir, 'releases', r.result.previousVersion, 'REVISION.json'))); ok(!existsSync(join(appDir, 'releases', '0.1.1.tmp'))); 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 는 모듈 디렉터리 밖 경로를 거부한다 @@ -142,10 +145,12 @@ await test('module.install(실제 환경 결함 수정): REVISION.json 의 Suite // 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 wmData = join(e.base, 'wmdata'); mkdirSync(wmData, { recursive: true }); writeFileSync(join(wmData, 'ok.json'), '{}'); writeFileSync(join(wmData, 'unreadable.json'), '{}'); chmodSync(join(wmData, 'unreadable.json'), 0o000); // root 가 남긴 파일처럼 읽을 수 없는 항목 + e.sys.unitText = async (u) => (u === 'webmaker' ? `[Service]\nUser=${(await import('node:os')).userInfo().username}\nWorkingDirectory=${wmDir}/current\nProtectSystem=strict\nEnvironment=DATA_ROOT=${wmData}\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'); + { const pre = r.steps.find((s) => s.id === 'pre:webmaker.backupData').result; ok(pre.backup && existsSync(pre.backup), '데이터 백업 파일'); ok(pre.unreadable && pre.unreadable.length === 1 && /unreadable\.json/.test(pre.unreadable[0]), '읽지 못한 파일은 경고로: ' + JSON.stringify(pre.unreadable)); } 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; diff --git a/test/hal.test.mjs b/test/hal.test.mjs index 073d83f..a0b681f 100644 --- a/test/hal.test.mjs +++ b/test/hal.test.mjs @@ -64,6 +64,10 @@ await test('heartbeat → 지표·용량·모듈 버전 저장, 무응답 → of await sleep(80); eq(core.markOffline(h.d).length, 1); eq(core.listServers(h.d)[0].status, 'offline'); await a.hb(); eq(core.listServers(h.d)[0].status, 'ready', 'heartbeat 복귀'); h.d.store.run('INSERT INTO metrics_samples(server_id, at, cpu) VALUES(?,?,?)', a.serverId, new Date(Date.now() - 100 * 86400000).toISOString(), 1); eq(core.purgeMetrics(h.d), 1); ok(core.metrics(h.d, a.serverId, { since: new Date(0).toISOString() }).every((m) => Date.now() - Date.parse(m.at) < 91 * 86400000)); }); +await test('setBootstrap: HAL 기동 전(행 없음) CLI bootstrap-release 의 기록이 남는다(실제 환경 결함 수정)', async () => { + const h = makeHal(); eq(h.d.store.get('SELECT COUNT(*) AS n FROM bootstrap_state').n, 0); core.setBootstrap(h.d, { release_version: '0.1.1', main_commit: 'abc', artifact_sha256: 'def' }); + eq(core.bootstrapState(h.d).release_version, '0.1.1'); eq(core.bootstrapState(h.d).main_commit, 'abc'); +}); await test('부트스트랩 관문: 첫 Agent 승인 전 포털 API 503, 승인·HAL 실행 버전 뒤 완료·수동 설치 잠김', async () => { const h = makeHal(); h.d.vault.set('service:portal', portalSecret); let r = await call(h.app, 'portal', portalSecret, 'GET', '/portal/v1/servers'); eq(r.status, 503); eq(r.json.error, 'bootstrap_incomplete');