diff --git a/hal/index.mjs b/hal/index.mjs index d2afd37..7134e12 100644 --- a/hal/index.mjs +++ b/hal/index.mjs @@ -12,6 +12,11 @@ * 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs) * node hal/index.mjs mail-import-rollback --import [--by ] 적용된 가져오기 되돌리기 * node hal/index.mjs mail-services HAL 관리형 메일 서비스 readback(JSON, 비밀 없음) + * node hal/index.mjs config-set [--publicUrl ] [--portalUrl ] [--offlineAfterMs ] [--metricsRetentionDays ] [--by ] [--reason ] + * 운영 값(data/config.json) 기록 — 비밀값 거부, 감사 기록. 서비스 프로세스는 기동 때 읽으므로 재시작해야 반영된다 + * node hal/index.mjs module-deploy --module --version --servers [--by ] [--reason ] [--nonce ] + * 모듈 배포 작업(module.deploy)을 운영자 API 와 같은 멱등 규칙으로 큐에 넣는다(시스템 신원, 감사 기록). 실행은 serve 프로세스의 작업 엔진이 맡는다 + * node hal/index.mjs job --id 작업·자식 작업 readback(JSON) */ import { loadConfig } from './config.mjs'; import { createHalDeps, VERSION } from './deps.mjs'; @@ -22,6 +27,9 @@ import * as emergency from './emergency.mjs'; import * as dr from './dr.mjs'; import * as mailsvc from './mailsvc.mjs'; import { purgeSessions } from '../lib/session.mjs'; +import { audit } from '../lib/audit.mjs'; +import { describe } from '../lib/jobs.mjs'; +import { descriptor } from './modules/index.mjs'; import { randomToken } from '../lib/crypto.mjs'; import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs'; import { join, dirname, basename } from 'node:path'; @@ -65,6 +73,26 @@ if (cmd === 'setup-token') { try { const r = mailsvc.rollbackImport(d, opt('import'), `cli:${opt('by', 'cli')}`); console.log(JSON.stringify(r)); } catch (e) { console.error(`되돌리기 실패: ${e.code || ''} ${e.message}`); process.exit(2); } } else if (cmd === 'mail-services') { console.log(JSON.stringify(mailsvc.readback(d), null, 1)); +} else if (cmd === 'config-set') { + // 운영 중 바꾸는 값만(hal/config.mjs 의 set — 비밀 키 이름은 거부). 받는 키를 고정해 비밀값이 설정 파일에 들어갈 길을 막는다. 감사 actor cli:. + const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) { const v = opt(k); if (v != null && v !== true) patch[k] = String(v); } + if (!Object.keys(patch).length) { console.error('바꿀 값이 없습니다: --publicUrl / --portalUrl / --offlineAfterMs / --metricsRetentionDays 중 하나 이상'); process.exit(1); } + const by = `cli:${opt('by', 'cli')}`; cfg.set(patch, by); + audit(d.store, { actorType: 'operator', actorId: by, action: 'config.update', subjectType: 'config', subjectId: 'hal', detail: { keys: Object.keys(patch), values: patch, reason: opt('reason', '') } }); + console.log(JSON.stringify({ updated: Object.keys(patch), file: cfg.file, note: '서비스 프로세스(ariweb-hal)는 기동 때 설정을 읽는다 — 재시작 뒤 반영' })); +} else if (cmd === 'module-deploy') { + // 운영자 API(POST /ops/api/modules/:name/deploy)와 같은 작업·멱등 키 규칙. 검증(모듈·릴리스·서버)은 여기서, 배포 전제 검사(승인·HAL 먼저·부트스트랩)는 작업의 precheck 단계가 한다. + const module = opt('module'); const version = opt('version'); const serverIds = String(opt('servers', '') || '').split(',').map((x) => x.trim()).filter(Boolean); const by = `cli:${opt('by', 'cli')}`; + if (!module || module === true || !version || version === true || !serverIds.length) { console.error('--module --version --servers 가 필요합니다'); process.exit(1); } + try { descriptor(module); } catch (e) { console.error(e.message); process.exit(1); } + if (!core.getRelease(d, version)) { console.error(`릴리스 ${version} 이(가) 등록되어 있지 않습니다`); process.exit(1); } + const missing = serverIds.filter((sid) => !core.getServer(d, sid)); if (missing.length) { console.error(`없는 서버: ${missing.join(', ')}`); process.exit(1); } + const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version, serverIds }, idempotencyKey: `module.deploy:${module}:${version}:${serverIds.slice().sort().join(',')}:${opt('nonce', '') === true ? '' : opt('nonce', '')}`, createdBy: by, actorType: 'operator', subjectType: 'module', subjectId: module, label: `${module} ${version} 배포` }); + audit(d.store, { actorType: 'operator', actorId: by, action: 'deploy.module.request', subjectType: 'module', subjectId: module, detail: { version, serverIds, reason: opt('reason', ''), jobId: r.job.id, created: r.created } }); + console.log(JSON.stringify({ jobId: r.job.id, created: r.created, status: r.job.status, kind: r.job.kind, label: r.job.label, note: '실행은 ariweb-hal 서비스 프로세스의 작업 엔진이 맡는다 — `job --id` 로 확인' })); +} else if (cmd === 'job') { + const j = opt('id') && opt('id') !== true ? d.jobs.get(opt('id')) : null; if (!j) { console.error('--id 가 필요합니다(없는 작업)'); process.exit(1); } + console.log(JSON.stringify({ job: describe(j), children: d.jobs.list({ parentId: j.id, limit: 100 }).map(describe) }, null, 1)); } else if (cmd === 'serve') { // "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만). try { core.claimLive(d, { instanceId: d.instanceId, version: VERSION, takeover: !!opt('takeover') }); } catch (e) { console.error(`[hal] 기동 거부: ${e.message}`); process.exit(2); } diff --git a/test/hal-cli.test.mjs b/test/hal-cli.test.mjs new file mode 100644 index 0000000..e6f9d45 --- /dev/null +++ b/test/hal-cli.test.mjs @@ -0,0 +1,58 @@ +/** test/hal-cli.test.mjs — HAL CLI(시스템 신원) 회귀 테스트: config-set / module-deploy / job. + * 운영자 콘솔이 닿지 않는 연동 단계에서 HAL 데이터 소유자가 쓰는 명령이다. 별도 프로세스(node hal/index.mjs …)로 실행해 실제 CLI 경로를 검증하고, + * 같은 데이터 디렉터리를 연 서비스 측 작업 엔진이 CLI 가 넣은 작업을 집어 실행하는지(실행은 serve 프로세스 몫) 확인한다. 비밀값은 출력하지 않는다. */ +import { test, eq, ok, tmpDir, done } from '../lib/test.mjs'; +import { createHalDeps } from '../hal/deps.mjs'; +import * as core from '../hal/core.mjs'; +import { listAudit } from '../lib/audit.mjs'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +function makeDataDir() { const dir = join(tmpDir('hal-cli-'), 'data'); mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir; } +/** CLI 를 별도 프로세스로. 실패(종료 코드≠0)는 { code, stdout, stderr } 로 돌려준다. */ +function cli(dataDir, args) { + try { const out = execFileSync(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, PORT: '0' }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 }); return { code: 0, stdout: out, stderr: '' }; } + catch (e) { return { code: e.status, stdout: String(e.stdout || ''), stderr: String(e.stderr || '') }; } +} +const deps = (dataDir) => createHalDeps({ config: { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1000, emergencyTtlMinutes: 30, agentPollMs: 10, values: {}, set() {} } }); + +await test('config-set: 운영 값만 data/config.json 에 기록(actor cli:, 감사), 인자 없으면 거부, 비밀 키는 받지 않음', () => { + const dir = makeDataDir(); + eq(cli(dir, ['config-set']).code, 1, '바꿀 값이 없으면 실패'); + const r = cli(dir, ['config-set', '--portalUrl', 'https://my.example.test', '--publicUrl', 'https://hal21.example.test', '--by', 'hera', '--reason', 'control-plane move']); + eq(r.code, 0, r.stderr); const out = JSON.parse(r.stdout.trim().split('\n').pop()); eq(out.updated.sort(), ['portalUrl', 'publicUrl']); + const cfg = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8')); eq(cfg.values.portalUrl, 'https://my.example.test'); eq(cfg.values.publicUrl, 'https://hal21.example.test'); eq(cfg.updatedBy, 'cli:hera'); + const d = deps(dir); const a = listAudit(d.store, { action: 'config.update' }); eq(a.length, 1); eq(a[0].actor_id, 'cli:hera'); eq(JSON.parse(a[0].detail_json).keys.sort(), ['portalUrl', 'publicUrl']); eq(JSON.parse(a[0].detail_json).reason, 'control-plane move'); + // 비밀값 이름은 config-set 이 받는 키 목록에 없다 — 무시되어 '바꿀 값 없음' 으로 거부된다(설정 파일에 비밀을 두지 않는다) + const s = cli(dir, ['config-set', '--apiKey', 'x']); eq(s.code, 1); ok(!readFileSync(join(dir, 'config.json'), 'utf8').includes('apiKey')); +}); + +await test('module-deploy: 운영자 API 와 같은 멱등 규칙으로 module.deploy 작업을 큐에 넣는다(실행은 서비스 프로세스), 검증 실패는 종료 코드 1, job 명령으로 readback', () => { + const dir = makeDataDir(); const d = deps(dir); + const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' }); + eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id]).code, 1, '릴리스가 없으면 거부'); + core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, approve: true, by: 'test' }); + eq(cli(dir, ['module-deploy', '--module', 'nope', '--version', '0.1.1', '--servers', srv.id]).code, 1, '알 수 없는 모듈 거부'); + eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', 'srv_none']).code, 1, '없는 서버 거부'); + const r = cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--reason', 'portal → hal21 link']); + eq(r.code, 0, r.stderr); const out = JSON.parse(r.stdout.trim().split('\n').pop()); ok(out.jobId.startsWith('j_')); eq(out.created, true); eq(out.status, 'queued'); + const j = d.jobs.get(out.jobId); eq(j.kind, 'module.deploy'); eq(j.subject, { module: 'portal', version: '0.1.1', serverIds: [srv.id] }); eq(j.created_by, 'cli:hera'); eq(j.actor_type, 'operator'); eq(j.idempotency_key, `module.deploy:portal:0.1.1:${srv.id}:`); + const again = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop()); eq(again.jobId, out.jobId); eq(again.created, false, '같은 멱등 키 → 같은 작업'); + const nonce = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--nonce', '2']).stdout.trim().split('\n').pop()); ok(nonce.jobId !== out.jobId && nonce.created, 'nonce 로 새 작업'); + const a = listAudit(d.store, { action: 'deploy.module.request' }); eq(a.length, 3); eq(a[0].actor_id, 'cli:hera'); eq(JSON.parse(a.find((x) => JSON.parse(x.detail_json).jobId === out.jobId && JSON.parse(x.detail_json).created).detail_json).reason, 'portal → hal21 link'); + const rb = cli(dir, ['job', '--id', out.jobId]); eq(rb.code, 0, rb.stderr); const view = JSON.parse(rb.stdout); eq(view.job.id, out.jobId); eq(view.job.status, 'queued'); eq(view.children, []); + eq(cli(dir, ['job', '--id', 'j_none']).code, 1); +}); + +await test('CLI 가 넣은 작업을 같은 데이터의 서비스 측 작업 엔진이 집어 실행한다(artifact 없음·부트스트랩 미완료 → precheck 실패로 끝남, 큐에 남지 않음)', async () => { + const dir = makeDataDir(); const d = deps(dir); const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' }); + core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, approve: true, by: 'test' }); + const out = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop()); + d.jobsRunning = true; const n = await d.jobs.drain(); ok(n >= 1, '서비스 측 엔진이 큐 작업을 집었다'); + const j = d.jobs.get(out.jobId); eq(j.status, 'failed'); ok(['artifact_missing', 'bootstrap_incomplete'].includes(j.error_code), `precheck 실패로 끝남: ${j.error_code}`); eq(d.jobs.list({ status: 'queued' }).length, 0); +}); + +done();