실제 환경 배포 결함 수정 2: artifact 모드 정규화, 실행 계정 모드 artifact 읽기 권한·current 경로, 같은 버전 재설치 보존, Webmaker 백업 관대 처리, 부트스트랩 릴리스 기록 행 생성

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 13:56:42 +00:00
co-authored by Claude Fable 5.1
parent 7f307602a3
commit 3641a703db
10 changed files with 243 additions and 203 deletions
+7 -2
View File
@@ -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: 유닛이 이미 <appDir>/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;
+4
View File
@@ -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');