diff --git a/deploy/bootstrap-hal.sh b/deploy/bootstrap-hal.sh index 95192fb..995cbbf 100755 --- a/deploy/bootstrap-hal.sh +++ b/deploy/bootstrap-hal.sh @@ -31,7 +31,7 @@ tar -xzf "$ART" -C "$APP_DIR/releases/$VERSION"; ln -sfn "$APP_DIR/releases/$VER cd "$APP_DIR/current" export ARIWEB_HAL_DATA="$DATA"; $NODE_BIN -e "import('./lib/vault.mjs').then(m=>{m.openVault('$DATA');console.log(' 보관소 준비(vault.key 0600)')})" $NODE_BIN hal/index.mjs bootstrap-release --version "$VERSION" --commit "$COMMIT" --sha256 "$SUM" -$NODE_BIN scripts/release.mjs register --hal-data "$DATA" --dist "$DIST" --approve --by bootstrap +$NODE_BIN hal/index.mjs register-release --manifest "$DIST/manifest.json" --approve --by bootstrap # hal artifact 에는 scripts/ 가 없으므로 HAL CLI 로 등록·artifact 복사 $NODE_BIN -e "const fs=require('fs');const f='$DATA/config.json';const v=fs.existsSync(f)?JSON.parse(fs.readFileSync(f,'utf8')).values:{};v.publicUrl='https://$HOST';v.portalUrl=v.portalUrl||'http://127.0.0.1:8910';fs.writeFileSync(f,JSON.stringify({version:1,updatedAt:new Date().toISOString(),updatedBy:'bootstrap',values:v},null,1),{mode:0o600})" 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 diff --git a/hal/core.mjs b/hal/core.mjs index a23a575..0326388 100644 --- a/hal/core.mjs +++ b/hal/core.mjs @@ -2,6 +2,7 @@ * hal/core.mjs — HAL 핵심 서비스: 서버·Agent 등록부, Agent 작업 배정·대기, 릴리스 등록부, 부트스트랩 상태, 지표, 포털 이벤트 outbox. * app.mjs(라우트)와 jobs.mjs(작업 종류)가 함께 쓴다. 모든 함수는 deps(store·vault·config·log·jobs) 를 받는다. */ +import { hostname } from 'node:os'; import { newId, randomToken, sha256, hashPassword } from '../lib/crypto.mjs'; import { nowIso, toJson, parseJson } from '../lib/db.mjs'; import { audit } from '../lib/audit.mjs'; @@ -23,11 +24,17 @@ export function bootstrapComplete(d) { const b = bootstrapState(d); return !!(b. /** HAL 기동 때: 실행 버전·인스턴스 임대 기록. 다른 인스턴스의 최근 임대가 있으면 거부(§34 "하나의 HAL 만 운영"). */ export function claimLive(d, { instanceId, version, takeover = false, now = Date.now() }) { const b = bootstrapState(d); - if (b.live_instance_id && b.live_instance_id !== instanceId && b.live_lease_at && now - Date.parse(b.live_lease_at) < 60000 && !takeover) { + // [통합 보완] 같은 호스트에서 이전 프로세스가 죽은 뒤(systemd 재시작·비정상 종료) 60초 임대가 남아 있으면 기동이 막힌다. + // 임대에 호스트·PID 를 함께 기록해 두고, 같은 호스트이며 그 PID 가 더 이상 살아 있지 않으면 같은 제어 위치의 재시작으로 보아 이어받는다. + // 다른 호스트(재해복구로 띄운 두 번째 HAL)는 여전히 거부된다(§34 "하나의 HAL 만 운영"). + const sameHostDead = b.live_host && b.live_host === hostname() && b.live_pid && !pidAlive(Number(b.live_pid)); + if (b.live_instance_id && b.live_instance_id !== instanceId && b.live_lease_at && now - Date.parse(b.live_lease_at) < 60000 && !takeover && !sameHostDead) { throw Object.assign(new Error(`다른 HAL 인스턴스(${b.live_instance_id})가 ${b.live_lease_at} 에 운영 중입니다. 기존 제어 위치를 먼저 중지하세요`), { code: 'another_hal_live' }); } - return setBootstrap(d, { live_instance_id: instanceId, live_lease_at: new Date(now).toISOString(), hal_running_version: version, hal_started_at: nowIso() }); + return setBootstrap(d, { live_instance_id: instanceId, live_lease_at: new Date(now).toISOString(), live_host: hostname(), live_pid: String(process.pid), hal_running_version: version, hal_started_at: nowIso() }); } +/** PID 가 살아 있는지(신호 0). 권한 오류(EPERM)는 살아 있는 것으로 본다. */ +function pidAlive(pid) { if (!pid) return false; try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } } export function renewLease(d, instanceId) { d.store.run('UPDATE bootstrap_state SET live_lease_at=? WHERE id=1 AND live_instance_id=?', nowIso(), instanceId); } /** 첫 Agent 가 승인되면 부트스트랩을 닫고 수동 설치를 잠근다 */ export function maybeCompleteBootstrap(d, agentId) { diff --git a/hal/schema.mjs b/hal/schema.mjs index feb7a6a..c8350b8 100644 --- a/hal/schema.mjs +++ b/hal/schema.mjs @@ -82,4 +82,6 @@ CREATE TABLE IF NOT EXISTS bootstrap_state ( CREATE TABLE IF NOT EXISTS transfers (id TEXT PRIMARY KEY, created_by_agent TEXT, path TEXT NOT NULL, size_bytes INTEGER, sha256 TEXT, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, purpose TEXT, meta_json TEXT, deleted_at TEXT); CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value_json TEXT, updated_at TEXT NOT NULL, updated_by TEXT); CREATE TABLE IF NOT EXISTS portal_events_outbox (id TEXT PRIMARY KEY, payload_json TEXT NOT NULL, created_at TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, next_at TEXT NOT NULL, delivered_at TEXT, last_error TEXT); -` }]; +` } // [통합 보완] 같은 호스트 재시작 판정을 위한 임대 소유자 호스트·PID + { id: 'hal_0002_live_host', sql: `ALTER TABLE bootstrap_state ADD COLUMN live_host TEXT; ALTER TABLE bootstrap_state ADD COLUMN live_pid TEXT;` }, +];