관리형 사이트 백업·복원, 서버 이전, 소유권 이전, 중지·해지·보존 생명주기와 운영 사이트 수 반환 (§14·§19~§23)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c397452acd
commit
a014e3cd05
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* portal/relocation/api.mjs — 서버 이전 화면·API(§21)와 운영자 내부 API(§30.4).
|
||||
* GET /sites/:id/relocation 이전 화면(종류·경계·진행·되돌리기)
|
||||
* GET /api/relocation/sites/:id 현황·자동 이전 조건·경계값
|
||||
* POST /api/relocation/sites/:id { toServerId?, reason, confirm:true } 고객 요청(유료)
|
||||
* GET /api/relocation/:id 상세(작업 포함) POST /api/relocation/:id/cancel POST /api/relocation/:id/revert {confirm:true}
|
||||
* 운영자: GET /internal/ops/relocation · GET /internal/ops/relocation/:id · POST /internal/ops/relocation {subjectType, subjectId, toServerId, kind, reason, operator}
|
||||
* POST /internal/ops/relocation/:id/approve {operator, scheduledAt, confirm:{subjectId,toServerId,paymentConfirmed}} · /reject · /retry · /revert
|
||||
*/
|
||||
import { HttpError, esc } from '../../lib/http.mjs';
|
||||
import { requireGroup } from '../auth/guard.mjs';
|
||||
import * as svc from './service.mjs';
|
||||
import { getSite, opsGuard, operatorOf, jobView, requireConfirm, nowIso, toJson } from '../lifecycle/common.mjs';
|
||||
|
||||
export async function register(app, deps) {
|
||||
svc.registerJobs(deps);
|
||||
const { page, t } = deps;
|
||||
const shell = (ctx, o) => page({ user: ctx.user, group: ctx.group, groups: ctx.groups || (ctx.group ? [ctx.group] : []), csrf: ctx.session?.csrf, unread: ctx.unread || 0, ...o });
|
||||
app.static('/ui/relocation', new URL('./public', import.meta.url).pathname);
|
||||
app.get('/sites/:id/relocation', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return shell(ctx, { title: `${site.name} · 서버 이전`, nav: 'sites', crumbs: [{ label: t('nav.sites'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: '서버 이전' }], body: `<div id="rl-site" data-site="${esc(site.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/relocation/relocation.js'] }); });
|
||||
const mine = (ctx, id) => { const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); if (r.subject_type === 'site') getSite(deps, ctx, r.subject_id, 'view'); else { requireGroup(ctx); if (r.group_id !== ctx.group.id) throw new HttpError(404, 'not_found', t('error.notFound')); } return r; };
|
||||
app.get('/api/relocation/sites/:id', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); const subject = { type: 'site', id: site.id, site, serverId: site.server_id }; const list = deps.store.all('SELECT * FROM relocations WHERE subject_type=\'site\' AND subject_id=? ORDER BY created_at DESC LIMIT 20', site.id).map((r) => ({ ...svc.view(deps, r), job: jobView(deps, r.job_id) })); return { ok: true, managed: site.deploy_mode === 'managed', runtime: svc.runtimeOf(site), auto: svc.autoEligible(deps, subject), boundaries: { customer: svc.boundaries(deps, subject, 'customer_paid'), platform: svc.boundaries(deps, subject, svc.runtimeOf(site) === 'static' ? 'auto_static' : 'approved') }, relocations: list, stepTitles: svc.STEP_TITLES, currentLocation: site.server_id ? '관리형 서버(내부 위치는 표시하지 않습니다)' : '미배치' }; });
|
||||
app.post('/api/relocation/sites/:id', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'admin'); const body = await ctx.body(); requireConfirm(body); const r = await svc.request(deps, { subjectType: 'site', subjectId: site.id, toServerId: body.toServerId || null, kind: 'customer_paid', reason: String(body.reason || '고객 요청'), origin: 'customer', by: ctx.user.id }); return { ok: true, relocation: svc.view(deps, r.relocation), created: r.created }; });
|
||||
app.get('/api/relocation/:id', (ctx) => { const r = mine(ctx, ctx.params.id); return { ok: true, relocation: svc.view(deps, r), job: jobView(deps, r.job_id) }; });
|
||||
app.post('/api/relocation/:id/cancel', async (ctx) => { const r = mine(ctx, ctx.params.id); if (r.subject_type === 'site') getSite(deps, ctx, r.subject_id, 'admin'); return { ok: true, relocation: await svc.cancel(deps, r.id, ctx.user.id) }; });
|
||||
app.post('/api/relocation/:id/revert', async (ctx) => { const r = mine(ctx, ctx.params.id); if (r.subject_type === 'site') getSite(deps, ctx, r.subject_id, 'admin'); const body = await ctx.body(); requireConfirm(body); if (!r.job_id) throw new HttpError(409, 'no_job', '되돌릴 작업이 없습니다'); deps.store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...deps.jobs.get(r.job_id).subject, by: ctx.user.id }), r.job_id); const j = await deps.jobs.revert(r.job_id, ctx.user.id); return { ok: true, job: jobView(deps, j.id), relocation: deps.store.get('SELECT * FROM relocations WHERE id=?', r.id) }; });
|
||||
const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); };
|
||||
app.get('/internal/ops/relocation', ops((ctx) => ({ ok: true, relocations: deps.store.all(`SELECT r.*, g.name AS group_name FROM relocations r JOIN groups g ON g.id=r.group_id ${ctx.query.status ? 'WHERE r.status=?' : ''} ORDER BY r.created_at DESC LIMIT 300`, ...(ctx.query.status ? [ctx.query.status] : [])).map((r) => ({ ...svc.view(deps, r), job: jobView(deps, r.job_id) })) })));
|
||||
app.get('/internal/ops/relocation/:id', ops((ctx) => { const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); const subject = r.subject_type === 'site' ? deps.store.get('SELECT id, name, primary_domain, server_id, lifecycle FROM sites WHERE id=?', r.subject_id) : deps.store.get('SELECT id, name, domain, arimail_server_id FROM mail_services WHERE id=?', r.subject_id); return { ok: true, relocation: svc.view(deps, r), job: jobView(deps, r.job_id), subject, children: r.job_id ? deps.jobs.childrenSummary(r.job_id) : null, finalConfirmation: { subjectId: r.subject_id, fromServerId: r.from_server_id, toServerId: r.to_server_id, cost: r.cost, charged: !!r.charged } }; }));
|
||||
app.post('/internal/ops/relocation', ops(async (ctx) => { const body = await ctx.body(); const r = await svc.request(deps, { subjectType: body.subjectType || 'site', subjectId: body.subjectId, toServerId: body.toServerId || null, kind: body.kind || null, reason: body.reason || '플랫폼 운영', origin: 'platform', by: operatorOf(body), scheduledAt: body.scheduledAt || null }); return { ok: true, relocation: svc.view(deps, r.relocation), created: r.created }; }));
|
||||
app.post('/internal/ops/relocation/:id/approve', ops(async (ctx) => { const body = await ctx.body(); const out = svc.approve(deps, ctx.params.id, { by: operatorOf(body), scheduledAt: body.scheduledAt || null, confirm: body.confirm || {} }); return { ok: true, relocation: svc.view(deps, out.relocation), job: out.job ? jobView(deps, out.job.id) : null, created: out.created }; }));
|
||||
app.post('/internal/ops/relocation/:id/reject', ops(async (ctx) => { const body = await ctx.body(); return { ok: true, relocation: svc.reject(deps, ctx.params.id, { by: operatorOf(body), reason: body.reason }) }; }));
|
||||
app.post('/internal/ops/relocation/:id/retry', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '이전 작업이 없습니다'); const j = deps.jobs.retry(r.job_id, operatorOf(body)); deps.store.run('UPDATE relocations SET status=\'queued\', updated_at=? WHERE id=?', nowIso(), r.id); return { ok: true, job: jobView(deps, j.id) }; }));
|
||||
app.post('/internal/ops/relocation/:id/resume', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '이전 작업이 없습니다'); const j = deps.jobs.retry(r.job_id, operatorOf(body)); deps.store.run('UPDATE relocations SET status=\'queued\', updated_at=? WHERE id=?', nowIso(), r.id); return { ok: true, job: jobView(deps, j.id) }; }));
|
||||
app.post('/internal/ops/relocation/:id/revert', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '이전 작업이 없습니다'); const j = await deps.jobs.revert(r.job_id, operatorOf(body)); return { ok: true, job: jobView(deps, j.id) }; }));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* portal/relocation/model.mjs — 서버 이전 영역 추가 열.
|
||||
* relocations.precheck_json 시작 때 찍은 조건 스냅샷(전환 직전에 다시 비교 §21.2)
|
||||
* relocations.hal_job_id HAL site.relocate 작업 id
|
||||
* relocations.old_retained_until 이전 위치 보존 종료 시각
|
||||
* relocations.charged 고객 과금 여부(플랫폼 사유 이전은 0 §21.2)
|
||||
*/
|
||||
export const MIGRATIONS = [{ id: 'relocation_0001', sql: `
|
||||
ALTER TABLE relocations ADD COLUMN precheck_json TEXT;
|
||||
ALTER TABLE relocations ADD COLUMN hal_job_id TEXT;
|
||||
ALTER TABLE relocations ADD COLUMN old_retained_until TEXT;
|
||||
ALTER TABLE relocations ADD COLUMN charged INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE relocations ADD COLUMN notices_json TEXT;
|
||||
` }];
|
||||
@@ -0,0 +1,22 @@
|
||||
/* portal/relocation/public/relocation.js — 서버 이전 화면(§21). 경계값·조건·진행·취소·되돌리기 */
|
||||
(() => {
|
||||
const { api, esc, state, toast, confirmDanger, poll, job, fmt } = window.aw;
|
||||
const el = document.getElementById('rl-site'); if (!el) return; const siteId = el.dataset.site;
|
||||
const ST = { awaiting_approval: ['waiting', '승인 대기'], queued: ['waiting', '처리 대기'], running: ['running', '진행 중'], paused: ['warning', '일시 중지'], cancelling: ['warning', '취소 중'], cancelled: ['neutral', '취소됨'], done: ['done', '완료'], failed: ['failed', '실패'], reverting: ['running', '되돌리는 중'], reverted: ['ok', '되돌림 완료'], rejected: ['failed', '거절됨'], needs_user: ['warning', '사용자 확인 필요'] };
|
||||
const badge = (k) => { const [c, l] = ST[k] || ['neutral', k]; return `<span class="badge ${c}">${esc(l)}</span>`; };
|
||||
const v = (x, u = '') => (x == null ? '<span class="muted">미확정(후속 상품정책)</span>' : esc(x) + u);
|
||||
const bounds = (b) => `<dl class="kv"><dt>취소 가능 마지막 단계</dt><dd>${esc(b.lastCancelLabel)}</dd><dt>쓰기 제한</dt><dd>${b.writeFreeze ? `${esc(b.writeFreeze.startsAt)} ~ ${esc(b.writeFreeze.endsAt)} — ${esc(b.writeFreeze.note)}` : '없음(정적 사이트)'}</dd><dt>목표 중단 시간</dt><dd>${v(b.targetDowntimeMin, '분')}</dd><dt>새 위치가 유일한 운영 위치가 되는 시점</dt><dd>${esc(b.soleLocationAt)}</dd><dt>전환 후 되돌리기 기간</dt><dd>${v(b.revertHours, '시간')}</dd><dt>이전 위치 보존·삭제</dt><dd>${b.oldRetentionDays != null ? `전환 후 ${b.oldRetentionDays}일 보존 뒤 삭제` : v(null)}</dd><dt>DNS·인증서 반영 대기 중</dt><dd>${esc(b.dnsWaitHandling)}</dd><dt>비용</dt><dd>${b.chargeable ? v(b.cost) + ' ' + esc(b.currency) : '없음(플랫폼 사유)'}</dd></dl>`;
|
||||
const render = async () => {
|
||||
let d; try { d = await api('GET', `/api/relocation/sites/${siteId}`); } catch (e) { state(el, e.status === 403 ? 'forbidden' : e.code === 'hal_unavailable' ? 'stale' : 'failed', e.message, { retry: render }); return false; }
|
||||
if (!d.managed) { state(el, 'empty', '외부 배포 사이트는 Ariweb 가 서버를 관리하지 않아 서버 이전 대상이 아닙니다'); return false; }
|
||||
const open = d.relocations.find((r) => !['done', 'failed', 'cancelled', 'reverted', 'rejected'].includes(r.status));
|
||||
el.innerHTML = `<div class="card"><h2>이전 안내</h2><dl class="kv"><dt>현재 위치</dt><dd>${esc(d.currentLocation)}</dd><dt>실행 형태</dt><dd>${d.runtime === 'static' ? '정적 사이트(조건이 맞으면 플랫폼이 자동 이전할 수 있습니다)' : '동적 사이트(운영자 승인과 작업 시간 확인 뒤 이전)'}</dd><dt>자동 이전 조건</dt><dd>${d.auto.ok ? '충족' : d.auto.reasons.map(esc).join('<br>')}</dd></dl><h3>고객 요청 이전(유료)</h3>${bounds(d.boundaries.customer)}<h3>플랫폼 사유 이전(장애 회피·용량 조정, 무료)</h3>${bounds(d.boundaries.platform)}<div class="actions">${open ? '' : '<button class="btn primary" id="rl-req">서버 이전 요청</button>'}</div></div>
|
||||
${d.relocations.length ? d.relocations.map((r) => `<div class="card"><h2>${badge(r.status)} ${r.kind === 'customer_paid' ? '고객 요청' : r.kind === 'auto_static' ? '자동(정적)' : '승인형'} · 요청 ${esc(fmt(r.created_at))}</h2><p class="sub">이유: ${esc(r.reason || '')}${r.scheduled_at ? ` · 예정 ${esc(fmt(r.scheduled_at))}` : ''}${r.old_retained_until ? ` · 이전 위치 보존 종료 ${esc(fmt(r.old_retained_until))}` : ''}${r.charged ? ` · 비용 ${r.cost}` : ' · 과금 없음'}</p><div id="rl-job-${r.id}"></div><div class="actions">${['awaiting_approval', 'queued', 'running'].includes(r.status) ? `<button class="btn danger" data-cancel="${r.id}">취소</button>` : ''}${r.status === 'done' ? `<button class="btn danger" data-revert="${r.id}">되돌리기</button>` : ''}</div></div>`).join('') : ''}`;
|
||||
for (const r of d.relocations) if (r.job) job(`#rl-job-${r.id}`, r.job, { onAction: async (a) => { try { if (a === 'cancel') await api('POST', `/api/relocation/${r.id}/cancel`); toast('처리했습니다'); render(); } catch (e) { toast(e.message); } } });
|
||||
el.querySelectorAll('[data-cancel]').forEach((b) => (b.onclick = async () => { try { await api('POST', `/api/relocation/${b.dataset.cancel}/cancel`); toast('취소했습니다'); render(); } catch (e) { toast(e.message); } }));
|
||||
el.querySelectorAll('[data-revert]').forEach((b) => (b.onclick = async () => { if (!(await confirmDanger({ title: '이전 위치로 되돌리기', target: '이 사이트', impacts: ['전환 뒤 새 위치에서 생긴 변경은 사라질 수 있습니다', '되돌리기 기간 안에서만 가능합니다'], confirmLabel: '되돌리기' }))) return; try { await api('POST', `/api/relocation/${b.dataset.revert}/revert`, { confirm: true }); toast('되돌리기를 시작했습니다'); render(); } catch (e) { toast(e.message); } }));
|
||||
const req = document.getElementById('rl-req'); if (req) req.onclick = async () => { if (!(await confirmDanger({ title: '서버 이전 요청(유료)', target: '이 사이트', impacts: ['운영자 승인 뒤 안내된 시간에 진행됩니다', d.boundaries.customer.writeFreeze ? '최종 반영~전환 사이 쓰기가 제한됩니다' : '정적 사이트라 쓰기 제한은 없습니다', `비용: ${d.boundaries.customer.cost ?? '미확정'} ${d.boundaries.customer.currency}`], confirmLabel: '요청' }))) return; try { await api('POST', `/api/relocation/sites/${siteId}`, { reason: '고객 요청', confirm: true }); toast('이전을 요청했습니다'); render(); } catch (e) { toast(e.message); } };
|
||||
return true;
|
||||
};
|
||||
poll(render, 6000);
|
||||
})();
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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` }); } });
|
||||
}
|
||||
Reference in New Issue
Block a user