Files
AriWeb-Suite/portal/relocation/service.mjs
T

146 lines
26 KiB
JavaScript

/**
* portal/relocation/service.mjs — 서버 이전(§21·§30.4) 업무 규칙.
*
* 종류(kind)
* auto_static 정적 사이트 자동 이전(플랫폼 사유: 장애 회피·용량 조정). 조건이 맞으면 승인 없이, 사전·완료 통지, 과금 없음.
* approved 동적 사이트·메일: 운영자 승인과 작업 시간 확인 뒤 진행. 플랫폼 사유면 과금 없음.
* customer_paid 고객 요청 유료 이전(정책 가격).
* 진행(§21.2 10단계)은 HAL 의 site.relocate 작업이 수행하고(docs/HAL-JOBS.md), 포털 작업은
* precheck(로컬 조건 스냅샷) → submit(HAL, 전환 전 멈춤) → wait_copy → precheck_switch(조건 재확인; 달라지면 자동 중지) → switch → wait_done → finalize
* 로 감싼다. 같은 사이트가 두 서버에서 동시에 운영되지 않도록 전환 전까지 sites.server_id 를 바꾸지 않고, 전환 뒤에만 바꾼다.
* 경계값(boundaries: 취소 가능 마지막 단계·쓰기 제한 시각·목표 중단 시간·유일 운영 시점·되돌리기 기간·이전 위치 보존 종료·DNS 대기 처리)은 활성 상품정책(product 'relocation')에서 읽는다.
*/
import { HttpError } from '../../lib/http.mjs';
import { newId } from '../../lib/crypto.mjs';
import { notify } from '../../lib/notify.mjs';
import { audit } from '../../lib/audit.mjs';
import { JobError } from '../../lib/jobs.mjs';
import { productPolicy, halOf, asTransient, inProgress, nowIso, parseJson, toJson, addMs, DAY, HOUR, optional } from '../lifecycle/common.mjs';
export const PRODUCT_ID = 'relocation';
export const KINDS = ['auto_static', 'approved', 'customer_paid'];
export const STEP_TITLES = [['precheck', '조건 확인'], ['prepare', '새 위치 준비'], ['precopy', '데이터 사전 복사'], ['verify_copy', '복사 결과 확인'], ['final_sync', '최종 변경분 반영'], ['verify_new', '새 위치 확인'], ['switch', '연결 전환'], ['external_check', '외부 접속 또는 송수신 확인'], ['cleanup', '이전 위치 정리'], ['done', '완료']];
/** 사이트 실행 형태(정적/동적). 사이트 settings_json.runtime 또는 기본 static */
export function runtimeOf(site) { return (parseJson(site.settings_json, {}) || {}).runtime || 'static'; }
/** 이전 화면 경계값(§21.2) */
export function boundaries(deps, subject, kind) {
const pol = productPolicy(deps, PRODUCT_ID); const p = pol.policy; const dyn = subject.type === 'mail' || runtimeOf(subject.site || {}) !== 'static';
return {
lastCancelStep: 'verify_new', lastCancelLabel: '새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다',
writeFreeze: dyn ? { startsAt: 'final_sync 단계 시작', endsAt: 'switch 완료 또는 되돌림 완료', note: '쓰기 제한 중 업로드·데이터 변경이 거부됩니다' } : null,
targetDowntimeMin: p.target_downtime_min ?? null, soleLocationAt: 'switch 단계 완료 시점(그 전에는 이전 위치가 유일한 운영 위치)',
revertHours: p.revert_hours ?? null, oldRetentionDays: p.old_retention_days ?? null, dnsWaitHandling: p.dns_wait_handling || '전환 뒤 DNS·인증서 반영 대기 중에는 두 위치 모두 같은 내용을 제공하되 쓰기는 새 위치만 받습니다',
cost: kind === 'customer_paid' ? (p.customer_cost ?? null) : 0, currency: pol.currency, deferred: pol.deferred, chargeable: kind === 'customer_paid',
};
}
/** 조건 스냅샷(시작·전환 직전 비교) */
export async function snapshot(deps, subject, toServerId) {
const hal = halOf(deps); let servers; try { servers = await hal.listServers({}); } catch (e) { throw asTransient(e); }
const from = servers.find((s) => s.id === subject.serverId) || null; const to = servers.find((s) => s.id === toServerId) || null;
const conflicts = inProgress(deps, subject.type === 'mail' ? 'mail_service' : 'site', subject.id).filter((c) => !(c.type === 'relocation' || c.kind === 'site.relocate')); // 자기 자신(이전 작업)은 충돌이 아니다
const domains = subject.type === 'site' ? deps.store.all('SELECT name, cert_state, dns_state FROM domains WHERE site_id=?', subject.id) : [];
const need = Number((parseJson(subject.site?.settings_json, {}) || {}).diskBytes || 0);
return { at: nowIso(), currentServer: subject.serverId, fromStatus: from ? from.status : 'unknown', toStatus: to ? to.status : 'missing', toAcceptsNew: !!(to && to.acceptsNew), toFree: to ? (to.capacity?.diskFreeBytes || 0) : 0, tempOk: !!(to && (to.capacity?.diskFreeBytes || 0) >= need * 2), conflicts: conflicts.length, domains: domains.map((d) => `${d.name}:${d.cert_state}:${d.dns_state}`), version: subject.site?.current_version || null };
}
/** 두 스냅샷 비교 — 달라진 항목 */
export function diffSnapshots(a, b) { const keys = ['currentServer', 'fromStatus', 'toStatus', 'toAcceptsNew', 'tempOk', 'conflicts', 'version']; const out = keys.filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k])); if (JSON.stringify(a.domains) !== JSON.stringify(b.domains)) out.push('domains'); return out; }
function subjectOf(deps, subjectType, subjectId) {
if (subjectType === 'mail_service') { const m = deps.store.get('SELECT * FROM mail_services WHERE id=?', subjectId); if (!m) return null; return { type: 'mail', id: m.id, groupId: m.group_id, name: m.name, serverId: m.arimail_server_id, row: m }; }
const s = deps.store.get('SELECT * FROM sites WHERE id=?', subjectId); if (!s) return null; return { type: 'site', id: s.id, groupId: s.group_id, name: s.name, serverId: s.server_id, site: s, row: s };
}
/** 자동 이전 조건(§21.1): 정적·관리형·진행 작업 없음·같은 대상의 메일 없음 */
export function autoEligible(deps, subject) {
const reasons = [];
if (subject.type !== 'site') reasons.push('메일 이전은 승인형입니다');
else { if (subject.site.deploy_mode !== 'managed') reasons.push('관리형 사이트만 이전할 수 있습니다'); if (runtimeOf(subject.site) !== 'static') reasons.push('동적 사이트는 운영자 승인과 작업 시간 확인이 필요합니다'); if (deps.store.get('SELECT 1 AS x FROM mail_services WHERE site_id=? AND status NOT IN (\'deleted\',\'cancelled\')', subject.id)) reasons.push('사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다'); }
const c = inProgress(deps, subject.type === 'mail' ? 'mail_service' : 'site', subject.id); if (c.length) reasons.push('진행 중인 배포·복원·이전이 있습니다');
return { ok: reasons.length === 0, reasons };
}
/** 이전 요청 만들기. origin: 'platform'(장애 회피·용량) | 'customer'. */
export async function request(deps, { subjectType = 'site', subjectId, toServerId = null, kind = null, reason = '', origin = 'customer', by = null, scheduledAt = null }) {
const subject = subjectOf(deps, subjectType, subjectId); if (!subject) throw new HttpError(404, 'not_found', '대상이 없습니다');
if (subject.type === 'site' && subject.site.deploy_mode !== 'managed') throw new HttpError(409, 'relocation_not_managed', '외부 배포 사이트는 Ariweb 가 서버를 관리하지 않아 이전 대상이 아닙니다');
const open = deps.store.get('SELECT * FROM relocations WHERE subject_type=? AND subject_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\',\'rejected\')', subjectType, subjectId); if (open) return { relocation: open, created: false };
const conflicts = inProgress(deps, subjectType === 'mail_service' ? 'mail_service' : 'site', subjectId); if (conflicts.length) throw new HttpError(409, 'conflict', '진행 중인 배포·복원·이전이 끝나야 이전을 시작할 수 있습니다', { conflicts });
const auto = autoEligible(deps, subject);
if (!kind) kind = origin === 'customer' ? 'customer_paid' : auto.ok ? 'auto_static' : 'approved';
if (kind === 'auto_static' && !auto.ok) throw new HttpError(409, 'relocation_not_auto', auto.reasons.join(' / '), { reasons: auto.reasons });
if (!toServerId) { // 도착 서버 자동 선택: 같은 역할·신규 배치 허용·현재 서버 제외, 여유 디스크가 큰 순
const hal = halOf(deps); let servers; try { servers = await hal.listServers({}); } catch (e) { throw new HttpError(503, 'hal_unavailable', '정보 확인 지연 — 도착 서버를 고를 수 없습니다'); }
const role = subject.type === 'mail' ? 'mail' : 'web'; const cand = servers.filter((x) => x.role === role && x.acceptsNew && x.status === 'ready' && x.id !== subject.serverId).sort((a, b2) => (b2.capacity?.diskFreeBytes || 0) - (a.capacity?.diskFreeBytes || 0));
if (!cand.length) throw new HttpError(409, 'no_target_server', '신규 배치를 받을 수 있는 다른 서버가 없습니다'); toServerId = cand[0].id;
}
if (toServerId === subject.serverId) throw new HttpError(400, 'same_server', '현재 운영 서버와 같은 서버로는 이전할 수 없습니다');
if (subject.type === 'mail') { const mail = await optional('../mail/service.mjs'); if (!mail || !mail.setMigrating) throw new HttpError(409, 'needs_setup', '메일 서비스 이전 기능이 아직 연결되지 않았습니다(메일 영역 필요)'); }
const b = boundaries(deps, subject, kind); const now = nowIso(); const id = newId('rl_');
const charged = kind === 'customer_paid' ? 1 : 0; const status = kind === 'auto_static' ? 'queued' : 'awaiting_approval';
deps.store.run('INSERT INTO relocations(id, group_id, subject_type, subject_id, kind, reason, from_server_id, to_server_id, status, boundaries_json, approval_state, scheduled_at, cost, requested_by, created_at, updated_at, charged) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', id, subject.groupId, subjectType, subjectId, kind, reason, subject.serverId, toServerId, status, toJson(b), kind === 'auto_static' ? 'not_required' : 'pending', scheduledAt, charged ? Number(b.cost || 0) : 0, by, now, now, charged);
audit(deps.store, { actorType: origin === 'customer' ? 'user' : 'operator', actorId: by, groupId: subject.groupId, subjectType, subjectId, action: 'relocate.request', detail: { relocationId: id, kind, toServerId, reason } });
// 사전 통지(§21.1): 대상·이유·출발/도착의 사용자용 설명·예상 시간·영향·변경 제한 시간·비용·사용자 행동
notify(deps.store, { groupId: subject.groupId, category: 'relocation', severity: 'info', mandatory: kind !== 'auto_static', title: `${subject.name}: 서버 이전이 ${kind === 'auto_static' ? '예정되었습니다(자동)' : '요청되었습니다'}`, body: `이유: ${reason || (origin === 'platform' ? '플랫폼 운영(장애 회피·용량 조정)' : '고객 요청')}. 현재 위치 → 새 위치로 옮기며 ${b.targetDowntimeMin != null ? `목표 중단 시간 ${b.targetDowntimeMin}분` : '중단 시간은 정책 미확정'}. ${b.writeFreeze ? '최종 반영 단계부터 전환까지 쓰기가 제한됩니다. ' : ''}비용: ${charged ? `${b.cost ?? '미확정'} ${b.currency}` : '없음(플랫폼 사유)'}. ${kind === 'auto_static' ? '별도 조치는 필요 없습니다' : '운영자 승인 뒤 안내된 시간에 진행됩니다'}`, link: `/sites/${subjectId}/relocation`, subjectType, subjectId, incidentKey: `relocation:${id}` });
if (kind === 'auto_static') start(deps, id, 'system');
return { relocation: deps.store.get('SELECT * FROM relocations WHERE id=?', id), created: true };
}
/** 운영자 승인(§30.4) → 작업 시작(scheduled_at 이 있으면 그 시각) */
export function approve(deps, id, { by, scheduledAt = null, confirm = {} }) {
const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); if (!r) throw new HttpError(404, 'not_found', '이전 요청이 없습니다');
if (r.approval_state === 'approved' && r.job_id) return { relocation: r, created: false };
if (r.status !== 'awaiting_approval') throw new HttpError(409, 'relocation_not_approvable', `지금 상태(${r.status})에서는 승인할 수 없습니다`);
if (confirm.subjectId !== r.subject_id || confirm.toServerId !== r.to_server_id) throw new HttpError(400, 'final_confirmation_mismatch', '최종 확인(대상·도착 서버)이 요청과 다릅니다', { expected: { subjectId: r.subject_id, fromServerId: r.from_server_id, toServerId: r.to_server_id } });
if (r.charged && r.cost > 0 && !confirm.paymentConfirmed) throw new HttpError(409, 'relocation_unpaid', '고객 요청 유료 이전은 결제 확인 뒤 승인합니다');
deps.store.run('UPDATE relocations SET approval_state=\'approved\', scheduled_at=COALESCE(?, scheduled_at), updated_at=? WHERE id=?', scheduledAt, nowIso(), id);
audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, action: 'relocate.approve', detail: { relocationId: id, scheduledAt } });
return { ...start(deps, id, by, scheduledAt || r.scheduled_at), created: true };
}
export function reject(deps, id, { by, reason }) { const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); if (!r || r.status !== 'awaiting_approval') throw new HttpError(409, 'relocation_not_rejectable', '승인 대기 상태가 아닙니다'); deps.store.run('UPDATE relocations SET status=\'rejected\', approval_state=\'rejected\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), id); notify(deps.store, { groupId: r.group_id, category: 'relocation', severity: 'warning', title: '서버 이전 요청이 거절되었습니다', body: reason || '', link: `/sites/${r.subject_id}/relocation`, incidentKey: `relocation:${id}` }); audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, action: 'relocate.reject', detail: { relocationId: id, reason } }); return deps.store.get('SELECT * FROM relocations WHERE id=?', id); }
function start(deps, id, by, runAt = null) {
const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); const subject = subjectOf(deps, r.subject_type, r.subject_id);
const { job } = deps.jobs.enqueue({ kind: 'site.relocate', subject: { relocationId: id, subjectType: r.subject_type, subjectId: r.subject_id, groupId: r.group_id }, idempotencyKey: `relocation:${id}`, actorType: by === 'system' ? 'system' : 'operator', createdBy: by, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, label: `서버 이전 (${subject ? subject.name : r.subject_id})`, runAt });
deps.store.run('UPDATE relocations SET status=\'queued\', job_id=?, updated_at=? WHERE id=?', job.id, nowIso(), id);
return { relocation: deps.store.get('SELECT * FROM relocations WHERE id=?', id), job };
}
/** 사용자 취소(전환 전까지 §21.2) */
export async function cancel(deps, id, by) {
const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); if (!r) throw new HttpError(404, 'not_found', '이전 요청이 없습니다');
if (['awaiting_approval', 'queued'].includes(r.status)) { if (r.job_id) deps.jobs.cancel(r.job_id, by); deps.store.run('UPDATE relocations SET status=\'cancelled\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), id); }
else if (r.status === 'running') { const job = deps.jobs.get(r.job_id); const switched = job.steps.find((s) => s.id === 'switch' && s.state === 'done'); if (switched) throw new HttpError(409, 'relocation_switched', '연결 전환이 끝나 취소할 수 없습니다. 되돌리기 기간 안에 되돌리기를 사용하세요'); deps.store.run('UPDATE relocations SET status=\'cancelling\', updated_at=? WHERE id=?', nowIso(), id); deps.jobs.cancel(r.job_id, by); if (r.hal_job_id) { try { await halOf(deps).cancelJob(r.hal_job_id); } catch (_) {} } if (['awaiting_external', 'queued', 'paused'].includes(job.status)) deps.store.run('UPDATE relocations SET status=\'cancelled\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), id); }
else throw new HttpError(409, 'relocation_not_cancellable', `지금 상태(${r.status})에서는 취소할 수 없습니다`);
audit(deps.store, { actorType: 'user', actorId: by, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, action: 'relocate.cancel', detail: { relocationId: id } });
return deps.store.get('SELECT * FROM relocations WHERE id=?', id);
}
/** 화면용 상세 */
export function view(deps, r) { const job = r.job_id ? deps.jobs.get(r.job_id) : null; return { ...r, boundaries: parseJson(r.boundaries_json, {}), precheck: parseJson(r.precheck_json, null), notices: parseJson(r.notices_json, []), stepTitles: STEP_TITLES, job }; }
export function registerJobs(deps) {
deps.jobs.register('site.relocate', { title: '서버 이전', maxAttempts: 3, retryable: (e) => !!e.retryable,
steps: [{ id: 'precheck', title: '조건 확인(시작)' }, { id: 'submit', title: '새 위치 준비·사전 복사 요청' }, { id: 'wait_copy', title: '데이터 복사·확인(전환 전)' }, { id: 'precheck_switch', title: '전환 직전 조건 재확인' }, { id: 'switch', title: '연결 전환' }, { id: 'wait_done', title: '외부 확인·이전 위치 정리' }, { id: 'finalize', title: '완료 처리' }],
async run(job, ctx) {
const r = deps.store.get('SELECT * FROM relocations WHERE id=?', job.subject.relocationId); const subject = subjectOf(deps, r.subject_type, r.subject_id); const b = parseJson(r.boundaries_json, {});
const set = (patch) => { const keys = Object.keys(patch); deps.store.run(`UPDATE relocations SET ${keys.map((k) => `${k}=?`).join(', ')}, updated_at=? WHERE id=?`, ...Object.values(patch), nowIso(), r.id); };
if (r.status === 'cancelled' || r.status === 'cancelling') { set({ status: 'cancelled', ended_at: nowIso() }); return { cancelled: true }; }
set({ status: 'running' });
const snap = await ctx.step('precheck', async () => { const s = await snapshot(deps, subject, r.to_server_id); const bad = []; if (s.toStatus !== 'ready' || !s.toAcceptsNew) bad.push('도착 서버가 신규 배치를 받을 수 없습니다'); if (s.fromStatus === 'offline') bad.push('출발 서버가 응답하지 않습니다'); if (!s.tempOk) bad.push('도착 서버의 임시 공간이 부족합니다'); if (s.conflicts) bad.push('다른 백업·복원·이전이 진행 중입니다'); if (bad.length) throw new JobError('relocation_precheck_failed', bad.join(' / ')); set({ precheck_json: toJson(s) }); return s; });
if (subject.type === 'mail') { const mail = await optional('../mail/service.mjs'); if (mail && mail.setMigrating) await ctx.step('mail_freeze', () => mail.setMigrating(deps, subject.id, true, { relocationId: r.id }), '메일 이전 상태 표시(두 서버 동시 처리 금지)'); }
const { halJobId } = await ctx.step('submit', async () => { try { const h = await halOf(deps).createJob({ kind: 'site.relocate', target: r.from_server_id, payload: { siteId: subject.id, subjectType: subject.type, fromServerId: r.from_server_id, toServerId: r.to_server_id, kind: runtimeOf(subject.site || {}) === 'static' && subject.type === 'site' ? 'static' : 'dynamic', writeFreeze: !!b.writeFreeze, pauseBeforeSwitch: true, domains: deps.store.all('SELECT name FROM domains WHERE site_id=?', subject.id).map((d) => d.name), primaryDomain: subject.site?.primary_domain || null }, idempotencyKey: `hal:${job.id}:relocate`, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id }); set({ hal_job_id: h.halJobId }); return { halJobId: h.halJobId }; } catch (e) { throw asTransient(e); } });
const copy = await ctx.step('wait_copy', async () => { let h; try { h = await halOf(deps).getJob(halJobId); } catch (e) { throw asTransient(e); } if (h.status === 'done') return { alreadyDone: true, result: h.result }; if (['failed', 'cancelled'].includes(h.status)) throw new JobError(h.errorCode || 'relocation_copy_failed', h.error || '복사 단계가 실패했습니다 — 이전 위치가 그대로 운영됩니다'); const sw = (h.steps || []).find((s) => s.id === 'switch'); if (h.status === 'awaiting_external' || (sw && sw.state === 'waiting')) return { gate: true }; ctx.checkCancel(); ctx.retryLater(5000, `복사 진행 중 (${h.status})`); });
if (!copy.alreadyDone) {
await ctx.step('precheck_switch', async () => { const again = await snapshot(deps, subject, r.to_server_id); const diff = diffSnapshots(snap, again); if (diff.length) { try { await halOf(deps).cancelJob(halJobId); } catch (_) {} set({ status: 'paused' }); notify(deps.store, { groupId: r.group_id, category: 'relocation', severity: 'warning', mandatory: true, title: '서버 이전을 전환 직전에 자동 중지했습니다', body: `조건이 달라졌습니다: ${diff.join(', ')}. 이전 위치가 계속 운영되며 운영자가 확인합니다`, link: `/sites/${r.subject_id}/relocation`, incidentKey: `relocation:${r.id}:stopped` }); notify(deps.store, { groupId: null, category: 'relocation', severity: 'warning', title: `[운영] 이전 자동 중지 — ${r.id}`, body: diff.join(', '), link: `/ops/relocation/${r.id}` }); throw new JobError('relocation_conditions_changed', `전환 직전 조건 변경: ${diff.join(', ')}`); } return { ok: true }; });
await ctx.step('switch', async () => { ctx.checkCancel(); try { await halOf(deps).resumeJob(halJobId); } catch (e) { throw asTransient(e); } return { resumedAt: nowIso() }; });
}
const res = await ctx.step('wait_done', async () => { let h; try { h = await halOf(deps).getJob(halJobId); } catch (e) { throw asTransient(e); } if (h.status === 'done') { if (h.result && h.result.externalCheck && h.result.externalCheck.ok === false) throw new JobError('relocation_final_check_failed', '최종 외부 확인 실패 — 새 위치를 운영 위치로 확정하지 않았습니다'); return h.result || {}; } if (['failed', 'cancelled'].includes(h.status)) throw new JobError(h.errorCode || 'relocation_failed', h.error || '이전이 실패했습니다'); ctx.retryLater(5000, `전환·정리 진행 중 (${h.status})`); });
await ctx.step('finalize', async () => { const now = nowIso(); const keep = b.oldRetentionDays != null ? addMs(now, b.oldRetentionDays * DAY) : null; if (subject.type === 'site') deps.store.run('UPDATE sites SET server_id=?, updated_at=? WHERE id=?', r.to_server_id, now, subject.id); else { deps.store.run('UPDATE mail_services SET arimail_server_id=?, migrating=0, updated_at=? WHERE id=?', r.to_server_id, now, subject.id); const mail = await optional('../mail/service.mjs'); if (mail && mail.setMigrating) await mail.setMigrating(deps, subject.id, false, { relocationId: r.id }); } set({ status: 'done', ended_at: now, old_retained_until: keep }); audit(deps.store, { actorType: 'system', groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, action: 'relocate.complete', detail: { relocationId: r.id, from: r.from_server_id, to: r.to_server_id } }); notify(deps.store, { groupId: r.group_id, category: 'relocation', title: `${subject.name}: 서버 이전이 완료되었습니다`, body: `새 위치가 유일한 운영 위치입니다. ${b.revertHours != null ? `되돌리기 가능 기간 ${b.revertHours}시간` : ''}${keep ? ` · 이전 위치 보존 종료 ${keep}` : ''}`, link: `/sites/${r.subject_id}/relocation`, incidentKey: `relocation:${r.id}` }); return { switchedAt: res.switchedAt || now }; });
return { newServerId: r.to_server_id };
},
async revert(job, ctx) {
const r = deps.store.get('SELECT * FROM relocations WHERE id=?', job.subject.relocationId); const b = parseJson(r.boundaries_json, {}); const subject = subjectOf(deps, r.subject_type, r.subject_id);
if (r.status === 'done' && b.revertHours != null && Date.parse(r.ended_at) + b.revertHours * HOUR < Date.now()) throw new JobError('revert_window_passed', '되돌릴 수 있는 기간이 지났습니다');
deps.store.run('UPDATE relocations SET status=\'reverting\', updated_at=? WHERE id=?', nowIso(), r.id);
const h = await ctx.step('revert.submit', async () => { try { return await halOf(deps).createJob({ kind: 'site.relocate', target: r.to_server_id, payload: { siteId: subject.id, subjectType: subject.type, fromServerId: r.to_server_id, toServerId: r.from_server_id, kind: 'static', revertOf: r.id }, idempotencyKey: `hal:${job.id}:revert` }); } catch (e) { throw asTransient(e); } }, '이전 위치로 되돌리기 요청');
await ctx.step('revert.wait', async () => { let j; try { j = await halOf(deps).getJob(h.halJobId); } catch (e) { throw asTransient(e); } if (j.status === 'done') return j.result; if (['failed', 'cancelled'].includes(j.status)) throw new JobError('revert_failed', j.error || '되돌리기 실패'); ctx.retryLater(5000, '되돌리는 중'); }, '되돌리기 진행');
if (subject.type === 'site') deps.store.run('UPDATE sites SET server_id=?, updated_at=? WHERE id=?', r.from_server_id, nowIso(), subject.id); else deps.store.run('UPDATE mail_services SET arimail_server_id=?, updated_at=? WHERE id=?', r.from_server_id, nowIso(), subject.id);
deps.store.run('UPDATE relocations SET status=\'reverted\', updated_at=?, ended_at=? WHERE id=?', nowIso(), nowIso(), r.id);
audit(deps.store, { actorType: 'user', actorId: job.subject.by || null, groupId: r.group_id, subjectType: r.subject_type, subjectId: r.subject_id, action: 'relocate.revert', detail: { relocationId: r.id } });
} });
deps.jobs.onChange((job) => { if (job.kind !== 'site.relocate') return; const r = deps.store.get('SELECT * FROM relocations WHERE id=?', job.subject.relocationId); if (!r) return; const map = { failed: 'failed', cancelled: 'cancelled', paused: 'paused', needs_user: 'needs_user', reverting: 'reverting', reverted: 'reverted' }; if (map[job.status] && !['done', 'reverted'].includes(r.status) && r.status !== map[job.status]) { deps.store.run('UPDATE relocations SET status=?, updated_at=?, ended_at=CASE WHEN ? IN (\'failed\',\'cancelled\',\'reverted\') THEN ? ELSE ended_at END WHERE id=?', map[job.status], nowIso(), map[job.status], nowIso(), r.id); if (job.status === 'failed') notify(deps.store, { groupId: r.group_id, category: 'relocation', severity: 'critical', mandatory: true, title: '서버 이전이 실패했습니다', body: `${job.error || ''} — 이전 위치가 계속 운영됩니다 (작업 번호 ${job.id})`, link: `/sites/${r.subject_id}/relocation`, incidentKey: `relocation:${r.id}:failed` }); } });
}