Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
279 lines
43 KiB
JavaScript
279 lines
43 KiB
JavaScript
/**
|
|
* portal/backup/service.mjs — 관리형 사이트 백업 옵션(§19)과 복원(§20)의 업무 규칙.
|
|
*
|
|
* 흐름
|
|
* 구매(purchaseOption) → 옵션 활성 → 정기 실행(runDue → 작업 backup.scheduled) → HAL backup.create(운영 서버와 다른 저장 서버) → 검증 → 보관 목록 → 보존 정리(prune)
|
|
* 복원 요청(requestRestore) → 비용·결제 → 운영자 승인(approveRestore) → 작업 backup.restore(§20.2 의 9단계) → 사용자 확인 또는 자동 완료 → 이전 상태 정리
|
|
* 옵션 만료(expireOption) → 백업 7일 보관 뒤 삭제(runDue 가 삭제 작업)
|
|
* 정책 값은 활성 상품정책(product_versions, product_id 'backup')에서 읽고 코드에 고정하지 않는다. 값이 없으면 deferred 로 화면에 "미확정" 을 보인다.
|
|
* 모든 작업은 lib/jobs 로 내구성 있게 실행되며 HAL 호출은 단계로 나누어 재시작 뒤 이어서 진행한다(제출 단계 결과에 HAL 작업 id 저장 → 대기 단계는 retryLater 로 다시 확인).
|
|
*/
|
|
import { HttpError } from '../../lib/http.mjs';
|
|
import { newId } from '../../lib/crypto.mjs';
|
|
import { notify, resolveIncident } from '../../lib/notify.mjs';
|
|
import { audit } from '../../lib/audit.mjs';
|
|
import * as quota from '../../lib/quota.mjs';
|
|
import { JobError } from '../../lib/jobs.mjs';
|
|
import { kstDate, nextMonthStart } from '../../lib/kst.mjs';
|
|
import { productPolicy, halOf, asTransient, inProgress, nowIso, parseJson, toJson, addMs, DAY, HOUR, optional } from '../lifecycle/common.mjs';
|
|
|
|
export const PRODUCT_ID = 'backup';
|
|
/** 구매에 꼭 필요한 정책 항목(없으면 구매 버튼 대신 미확정 안내) */
|
|
export const REQUIRED_POLICY = ['limits.storage_bytes', 'policy.schedule', 'policy.retention', 'policy.first_backup_within_hours', 'policy.verify_method', 'policy.restore_cost', 'policy.restore_completion', 'policy.protect_point_days', 'policy.revert_hours'];
|
|
export const EXPIRY_RETENTION_DAYS = 7; // §20.3 옵션 만료 후 백업 보관 일수(기본 정책)
|
|
export const SCOPE = { files: '현재 배포본(파일)', db: '데이터베이스', uploads: '업로드 데이터', config: '복원에 필요한 사이트 설정' };
|
|
export const EXCLUDED = ['메일 원문', '소스 저장소', 'Webmaker 프로젝트 이력', '외부 배포 데이터', '플랫폼 내부 복구 사본'];
|
|
|
|
/** 사이트가 백업 옵션을 살 수 있는지(관리형·운영/제작 상태·외부 배포 아님 §19.1) */
|
|
export function eligibility(deps, site) {
|
|
const reasons = [];
|
|
if (!site) return { ok: false, reasons: ['사이트가 없습니다'] };
|
|
if (site.deploy_mode !== 'managed') reasons.push(site.deploy_mode === 'external' ? '외부 배포 사이트에는 백업 옵션을 제공하지 않습니다' : '관리형 배포로 설정된 사이트만 백업 옵션을 살 수 있습니다');
|
|
if (['deleted', 'pending_delete', 'readonly_retention', 'cancel_scheduled'].includes(site.lifecycle)) reasons.push('해지·보존 중인 사이트에는 새 옵션을 추가할 수 없습니다');
|
|
const existing = optionOf(deps, site.id);
|
|
if (existing && existing.status !== 'ended') reasons.push('이 사이트에는 이미 백업 옵션이 있습니다(사이트당 하나)');
|
|
const pol = productPolicy(deps, PRODUCT_ID, REQUIRED_POLICY);
|
|
if (!pol.version) reasons.push('백업 상품정책이 아직 활성화되지 않았습니다');
|
|
else if (pol.deferred.length) reasons.push(`상품정책의 미확정 항목(${pol.deferred.join(', ')})이 정해져야 구매할 수 있습니다`);
|
|
return { ok: reasons.length === 0, reasons, policy: pol };
|
|
}
|
|
export function optionOf(deps, siteId) { return deps.store.get('SELECT * FROM backup_options WHERE site_id=?', siteId) || null; }
|
|
export function optionById(deps, id) { return deps.store.get('SELECT * FROM backup_options WHERE id=?', id) || null; }
|
|
/** 보관 중(삭제되지 않은) 백업의 총 크기 */
|
|
export function storedBytes(deps, siteId) { return deps.store.get('SELECT COALESCE(SUM(size_bytes),0) AS n FROM backups WHERE site_id=? AND deleted_at IS NULL AND status=\'done\'', siteId).n; }
|
|
/** 화면용 구매 정보(§19.1): 저장 한도·현재 사용량·일정·보존 개수·첫 백업 예정·검증 방식·추가 저장 옵션·포함/제외 */
|
|
export function purchaseInfo(deps, site) {
|
|
const el = eligibility(deps, site); const { limits, policy, deferred, version } = el.policy;
|
|
return {
|
|
eligible: el.ok, reasons: el.reasons, deferred, productVersion: version ? { id: version.id, name: version.name, version: version.version, price: version.price, currency: version.currency, taxRate: version.tax_rate, billingCycle: version.billing_cycle } : null,
|
|
storageLimitBytes: limits.storage_bytes ?? null, currentUsageBytes: storedBytes(deps, site.id), schedule: policy.schedule ?? null, retention: policy.retention ?? null,
|
|
firstBackupWithinHours: policy.first_backup_within_hours ?? null, firstBackupDueAt: policy.first_backup_within_hours ? addMs(nowIso(), policy.first_backup_within_hours * HOUR) : null,
|
|
verifyMethod: policy.verify_method ?? null, extraStorageOptions: limits.extra_storage_options ?? [], restoreCost: policy.restore_cost ?? null, restoreCompletion: policy.restore_completion ?? null,
|
|
scope: SCOPE, excluded: EXCLUDED, expiryRetentionDays: policy.expiry_retention_days ?? EXPIRY_RETENTION_DAYS,
|
|
};
|
|
}
|
|
/**
|
|
* 옵션 구매(결제·승인은 주문 영역이 맡는다). 여기서는 권리(backup 자원) 1개를 예약→확정해 옵션을 활성화한다.
|
|
* 권리가 없으면 QuotaError(quota_insufficient) → 화면은 상품 구매를 안내한다.
|
|
*/
|
|
export function purchaseOption(deps, site, { by, orderId = null } = {}) {
|
|
const el = eligibility(deps, site); if (!el.ok) throw new HttpError(409, 'backup_not_eligible', el.reasons.join(' / '), { reasons: el.reasons });
|
|
const { limits, policy, version } = el.policy; const now = nowIso();
|
|
return deps.store.tx(() => {
|
|
const r = quota.reserve(deps.store, { groupId: site.group_id, resource: 'backup', amount: 1, subjectType: 'site', subjectId: site.id, reason: 'backup_option' }); quota.commit(deps.store, r.id);
|
|
const alloc = quota.allocationOf(deps.store, site.group_id, 'backup', 'site', site.id);
|
|
const id = newId('bo_'); const first = addMs(now, (policy.first_backup_within_hours || 24) * HOUR);
|
|
deps.store.run('INSERT INTO backup_options(id, site_id, group_id, product_version_id, order_id, status, schedule_json, retention_json, storage_limit_bytes, first_backup_due_at, next_run_at, resume_mode, allocation_id, created_at, updated_at) VALUES(?,?,?,?,?,\'active\',?,?,?,?,?,?,?,?,?)',
|
|
id, site.id, site.group_id, version.id, orderId, toJson(policy.schedule), toJson(policy.retention), limits.storage_bytes, first, now, policy.resume_after_pause || 'auto', alloc ? alloc.id : null, now, now);
|
|
audit(deps.store, { actorType: 'user', actorId: by, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'backup.option.activate', detail: { optionId: id, productVersionId: version.id } });
|
|
notify(deps.store, { groupId: site.group_id, category: 'backup', title: `${site.name}: 백업 옵션이 활성화되었습니다`, body: `첫 백업은 ${first} 까지 완료됩니다. 진행 상태는 사이트 › 백업 탭에서 확인하세요`, link: `/sites/${site.id}/backup`, subjectType: 'site', subjectId: site.id });
|
|
return optionById(deps, id);
|
|
});
|
|
}
|
|
/** 일정(JSON {daily:'HH:MM'(KST)}) 기준 다음 실행 시각. 단순화: 매일 지정 시각(KST). */
|
|
export function nextRunFrom(schedule, from = nowIso()) {
|
|
const hhmm = String((schedule && schedule.daily) || '03:00'); const [h, m] = hhmm.split(':').map(Number);
|
|
const d = new Date(from); const kstMs = d.getTime() + 9 * HOUR; const day = new Date(kstMs); day.setUTCHours(h, m || 0, 0, 0);
|
|
let next = day.getTime() - 9 * HOUR; if (next <= d.getTime()) next += DAY; return new Date(next).toISOString();
|
|
}
|
|
/** 이번 실행의 보존 등급: 그 달 첫 실행 → monthly, 일요일(KST) → weekly, 그 밖 → daily */
|
|
export function tierFor(deps, siteId, at = nowIso()) {
|
|
const month = kstDate(at).slice(0, 7);
|
|
const monthly = deps.store.get('SELECT 1 AS x FROM backups WHERE site_id=? AND tier=\'monthly\' AND substr(created_at,1,7)=? AND deleted_at IS NULL', siteId, month);
|
|
if (!monthly) return 'monthly';
|
|
return new Date(Date.parse(at) + 9 * HOUR).getUTCDay() === 0 ? 'weekly' : 'daily';
|
|
}
|
|
/** 정기 백업 중지(사이트 일시 중지·해지 §19.2·§35). 다른 영역(생명주기)이 부른다. */
|
|
export function stopForSite(deps, siteId, reason = 'site_paused') {
|
|
const o = optionOf(deps, siteId); if (!o || o.status !== 'active') return null;
|
|
deps.store.run('UPDATE backup_options SET status=\'paused\', paused_reason=?, updated_at=? WHERE id=?', reason, nowIso(), o.id);
|
|
notify(deps.store, { groupId: o.group_id, category: 'backup', incidentKey: `backup:${o.id}:paused`, title: '정기 백업이 중지되었습니다', body: `사유: ${reasonLabel(reason)}. 기존 백업은 정책에 따라 유지됩니다. 사이트 재개 후 ${o.resume_mode === 'auto' ? '자동으로 다시 시작됩니다' : '백업 화면에서 다시 시작을 확인해야 합니다'}`, link: `/sites/${siteId}/backup`, subjectType: 'site', subjectId: siteId });
|
|
return optionById(deps, o.id);
|
|
}
|
|
/** 재개: resume_mode 가 auto 면 생명주기 영역이 자동으로, confirm 이면 사용자가 버튼으로 부른다 */
|
|
export function resumeForSite(deps, siteId, { by = 'system', userConfirmed = false, clearStorageFull = false } = {}) {
|
|
const o = optionOf(deps, siteId); if (!o || o.status !== 'paused') return o;
|
|
// 저장 공간 부족으로 멈춘 옵션은 용량이 늘거나 복구점이 정리되어 여유가 생겨야 재개된다(운영자 clearStorageFull 또는 실제 여유 확인)
|
|
if (o.paused_reason === 'storage_full' && !clearStorageFull) { const used = storedBytes(deps, siteId); const last = (deps.store.get('SELECT size_bytes FROM backups WHERE site_id=? AND status=\'done\' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1', siteId) || {}).size_bytes || 0; if (o.storage_limit_bytes && used + last > o.storage_limit_bytes) throw new HttpError(409, 'backup_storage_full', '저장 공간이 가득 차 재개할 수 없습니다. 추가 용량을 적용하거나 오래된 복구점을 정리하세요', { used, limit: o.storage_limit_bytes }); }
|
|
if (o.resume_mode === 'confirm' && by === 'system' && !userConfirmed) return o; // 사용자 확인 필요
|
|
deps.store.run('UPDATE backup_options SET status=\'active\', paused_reason=NULL, next_run_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id);
|
|
return optionById(deps, o.id);
|
|
}
|
|
export function reasonLabel(r) { return { site_paused: '사이트 일시 중지', site_not_deployed: '사이트 미배포(배포 뒤 다시 시작)', site_cancelled: '사이트 해지', storage_full: '저장 공간 부족', operator: '운영자 조치', option_ended: '옵션 종료' }[r] || r; }
|
|
/** 옵션 종료(만료·해지): 새 백업 중지, 백업은 7일 보관 뒤 삭제(§20.3·§23.2). 권리 반환. */
|
|
export function expireOption(deps, optionId, { by = 'system', reason = 'option_ended' } = {}) {
|
|
const o = optionById(deps, optionId); if (!o || o.status === 'ended') return o;
|
|
const pol = productPolicy(deps, PRODUCT_ID); const days = pol.policy.expiry_retention_days ?? EXPIRY_RETENTION_DAYS; const now = nowIso(); const del = addMs(now, days * DAY);
|
|
deps.store.run('UPDATE backup_options SET status=\'ended\', paused_reason=?, ends_at=?, delete_after=?, next_run_at=NULL, updated_at=? WHERE id=?', reason, now, del, now, o.id);
|
|
deps.store.run('UPDATE backups SET expires_at=? WHERE option_id=? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at>?)', del, o.id, del);
|
|
if (o.allocation_id) quota.finishReturn(deps.store, o.allocation_id);
|
|
audit(deps.store, { actorType: by === 'system' ? 'system' : 'user', actorId: by, groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, action: 'backup.option.end', detail: { optionId: o.id, deleteAfter: del, reason } });
|
|
notify(deps.store, { groupId: o.group_id, category: 'backup', severity: 'warning', mandatory: true, title: '백업 옵션이 종료되었습니다 — 백업 삭제 예정', body: `기존 백업은 ${del} 까지 보관된 뒤 삭제됩니다. 보존 중에는 복원을 요청할 수 있습니다(복원 비용은 정책에 따릅니다)`, link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id, incidentKey: `backup:${o.id}:ended` });
|
|
return optionById(deps, o.id);
|
|
}
|
|
/** 저장 서버 고르기: 사이트 운영 서버와 다른 서버(backup 역할 우선, 없으면 web). 운영자 override 가 아니면 같은 서버는 절대 고르지 않는다. */
|
|
export async function pickStoreServer(deps, site, option) {
|
|
const hal = halOf(deps); let servers = [];
|
|
try { servers = await hal.listServers({}); } catch (e) { throw asTransient(e); }
|
|
// [실제 환경 결함 수정] 서버 한 대(control) 운영: 전용 backup·web 서버가 없으면 control 서버도 저장 후보로 본다(같은 서버 저장은 여전히 운영자 override 가 필요).
|
|
const pool = servers.filter((s) => s.acceptsNew && s.status === 'ready'); const ok = pool.some((s) => ['backup', 'web'].includes(s.role)) ? pool.filter((s) => ['backup', 'web'].includes(s.role)) : pool.filter((s) => s.role === 'control');
|
|
const pref = ok.filter((s) => s.id !== site.server_id).sort((a, b) => (a.role === 'backup' ? -1 : 1) - (b.role === 'backup' ? -1 : 1) || ((b.capacity?.diskFreeBytes || 0) - (a.capacity?.diskFreeBytes || 0)));
|
|
if (pref.length) return pref[0].id;
|
|
if (option.same_server_override && ok.some((s) => s.id === site.server_id)) return site.server_id;
|
|
throw new JobError('no_backup_store', '운영 서버와 분리된 백업 저장 서버가 없습니다. 운영자가 백업 서버를 등록하거나 같은 서버 저장을 명시적으로 허용해야 합니다', { retryable: true });
|
|
}
|
|
/** 보존 정리(§19.1 Daily·Weekly·Monthly 개수). 최소 보존 세트(등급별 최신 1개·전체 최신 1개)는 어떤 경우에도 지우지 않는다. */
|
|
export function pruneList(deps, option) {
|
|
const ret = parseJson(option.retention_json, {}) || {}; const all = deps.store.all('SELECT * FROM backups WHERE option_id=? AND deleted_at IS NULL AND status=\'done\' ORDER BY created_at DESC', option.id);
|
|
const keep = new Set(); if (all[0]) keep.add(all[0].id);
|
|
const toDelete = [];
|
|
for (const tier of ['daily', 'weekly', 'monthly']) { const list = all.filter((b) => b.tier === tier); const n = Math.max(1, Number(ret[tier] ?? 1)); list.forEach((b, i) => { if (i < n) keep.add(b.id); }); if (list[0]) keep.add(list[0].id); }
|
|
for (const b of all) if (!keep.has(b.id)) toDelete.push(b);
|
|
return toDelete;
|
|
}
|
|
/** 실행 예정 옵션 → 작업 넣기(주기 작업). 만료 옵션의 삭제 예정도 처리. */
|
|
export function runDue(deps, now = nowIso()) {
|
|
const started = [];
|
|
for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'active\' AND next_run_at IS NOT NULL AND next_run_at<=?', now)) {
|
|
const site = deps.store.get('SELECT * FROM sites WHERE id=?', o.site_id);
|
|
if (!site || !['live', 'needs_domain', 'deploying'].includes(site.lifecycle)) { stopForSite(deps, o.site_id, site && site.lifecycle === 'paused' ? 'site_paused' : site && site.lifecycle === 'draft' ? 'site_not_deployed' : 'site_cancelled'); continue; } // 초안(미배포) 사이트는 '해지' 가 아니라 '미배포' 로 표시한다(실제 환경 확인)
|
|
const slot = kstDate(now) + ':' + String(o.next_run_at);
|
|
const { job } = deps.jobs.enqueue({ kind: 'backup.scheduled', subject: { optionId: o.id, siteId: o.site_id, groupId: o.group_id, slot }, idempotencyKey: `backup:${o.id}:${slot}`, actorType: 'system', groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, label: `정기 백업 (${site.name})` });
|
|
deps.store.run('UPDATE backup_options SET next_run_at=?, updated_at=? WHERE id=?', nextRunFrom(parseJson(o.schedule_json, {}), now), now, o.id);
|
|
started.push(job.id);
|
|
}
|
|
// 만료된 옵션의 백업 삭제(delete_after 경과)
|
|
for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'ended\' AND delete_after IS NOT NULL AND delete_after<=?', now)) {
|
|
const left = deps.store.all('SELECT id FROM backups WHERE option_id=? AND deleted_at IS NULL', o.id);
|
|
if (!left.length) { deps.store.run('UPDATE backup_options SET delete_after=NULL, updated_at=? WHERE id=?', now, o.id); continue; }
|
|
const { job } = deps.jobs.enqueue({ kind: 'backup.purge', subject: { optionId: o.id, siteId: o.site_id, groupId: o.group_id }, idempotencyKey: `backup-purge:${o.id}:${o.delete_after}`, actorType: 'system', groupId: o.group_id, subjectType: 'site', subjectId: o.site_id, label: '만료 백업 삭제' }); started.push(job.id);
|
|
}
|
|
// 첫 백업 기한 초과 경고
|
|
for (const o of deps.store.all('SELECT * FROM backup_options WHERE status=\'active\' AND first_backup_due_at<? AND last_success_at IS NULL', now)) notify(deps.store, { groupId: o.group_id, category: 'backup', severity: 'warning', incidentKey: `backup:${o.id}:first_overdue`, title: '첫 백업이 기한 안에 완료되지 않았습니다', body: '운영자가 확인 중입니다. 진행 상태는 백업 화면에서 볼 수 있습니다', link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id });
|
|
return started;
|
|
}
|
|
/** HAL 작업 제출·대기 단계 공통: 제출 결과(halJobId)는 단계 결과로 저장되어 재시작 뒤 다시 제출하지 않는다. */
|
|
async function halSubmit(deps, ctx, stepId, title, req) { return ctx.step(stepId, async () => { try { const r = await halOf(deps).createJob(req); return { halJobId: r.halJobId }; } catch (e) { throw asTransient(e); } }, title); }
|
|
async function halWait(deps, ctx, stepId, title, halJobId, { pollMs = 5000 } = {}) {
|
|
return ctx.step(stepId, async () => {
|
|
let j; try { j = await halOf(deps).getJob(halJobId); } catch (e) { throw asTransient(e); }
|
|
if (['done'].includes(j.status)) return j.result || {};
|
|
if (['failed', 'cancelled', 'reverted'].includes(j.status)) throw new JobError(j.errorCode || 'hal_job_failed', j.error || `HAL 작업이 ${j.status} 로 끝났습니다`);
|
|
if (j.status === 'needs_user') ctx.needsUser(j.error || 'HAL 작업이 운영자 확인을 기다립니다');
|
|
ctx.retryLater(pollMs, `HAL 작업 ${halJobId} 진행 중 (${j.status})`);
|
|
}, title);
|
|
}
|
|
/** 작업 종류 등록 */
|
|
export function registerJobs(deps) {
|
|
deps.jobs.register('backup.scheduled', { title: '정기 백업', maxAttempts: 3, retryable: (e) => !!e.retryable, steps: [{ id: 'precheck', title: '사전 확인(옵션·저장 공간)' }, { id: 'pick', title: '저장 서버 선택' }, { id: 'submit', title: '백업 생성 요청' }, { id: 'wait', title: '백업 생성·전송' }, { id: 'verify', title: '사본 읽기 검증' }, { id: 'record', title: '보관 목록 기록' }, { id: 'prune', title: '보존 정리' }],
|
|
async run(job, ctx) {
|
|
const { optionId, siteId } = job.subject; const o = optionById(deps, optionId); const site = deps.store.get('SELECT * FROM sites WHERE id=?', siteId);
|
|
if (!o || o.status !== 'active') throw new JobError('backup_option_inactive', '백업 옵션이 활성 상태가 아닙니다');
|
|
await ctx.step('precheck', () => {
|
|
const used = storedBytes(deps, siteId); const limit = o.storage_limit_bytes || 0; const est = Math.max(1, (deps.store.get('SELECT size_bytes FROM backups WHERE site_id=? AND status=\'done\' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1', siteId) || {}).size_bytes || 0);
|
|
if (limit && used + est > limit && pruneList(deps, o).length === 0) { deps.store.run('UPDATE backup_options SET status=\'paused\', paused_reason=\'storage_full\', updated_at=? WHERE id=?', nowIso(), o.id); notify(deps.store, { groupId: o.group_id, category: 'backup', severity: 'critical', mandatory: true, incidentKey: `backup:${o.id}:storage_full`, title: '백업 저장 공간이 부족해 다음 백업을 중지했습니다', body: `최소 보존 세트는 삭제하지 않습니다. 추가 저장 옵션을 적용하거나 오래된 복구점을 정리한 뒤 백업 화면에서 다시 시작하세요 (사용 ${used} / 한도 ${limit} 바이트)`, link: `/sites/${siteId}/backup`, subjectType: 'site', subjectId: siteId }); throw new JobError('backup_storage_full', '저장 공간 부족 — 백업 중지'); }
|
|
return { used, limit };
|
|
});
|
|
const { storeServerId } = await ctx.step('pick', async () => ({ storeServerId: await pickStoreServer(deps, site, o) }));
|
|
const tier = tierFor(deps, siteId, job.created_at);
|
|
const { halJobId } = await halSubmit(deps, ctx, 'submit', '백업 생성 요청', { kind: 'backup.create', target: site.server_id, payload: { siteId, scope: { files: true, db: true, uploads: true, config: true }, storeServerId, retentionTag: tier }, idempotencyKey: `hal:${job.id}:create`, groupId: o.group_id, subjectType: 'site', subjectId: siteId });
|
|
const res = await halWait(deps, ctx, 'wait', '백업 생성·전송', halJobId);
|
|
const verified = await ctx.step('verify', async () => { if (res.verified) return true; try { const r = await halOf(deps).createJob({ kind: 'backup.verify', target: storeServerId, payload: { location: res.location, checksum: res.checksum }, idempotencyKey: `hal:${job.id}:verify` }); const j = await halOf(deps).getJob(r.halJobId); if (!(j.result && j.result.ok)) throw new JobError('backup_verify_failed', '백업 사본을 읽을 수 없습니다'); return true; } catch (e) { throw asTransient(e); } });
|
|
const backupId = await ctx.step('record', () => { const id = newId('bk_'); const pol = productPolicy(deps, PRODUCT_ID); const ret = parseJson(o.retention_json, {}) || {}; const days = { daily: Number(ret.daily || 7), weekly: Number(ret.weekly || 4) * 7, monthly: Number(ret.monthly || 3) * 31 }[tier]; deps.store.run('INSERT INTO backups(id, site_id, group_id, option_id, kind, tier, created_at, completed_at, scope_json, size_bytes, location, checksum, verify_state, verified_at, expires_at, status, job_id) VALUES(?,?,?,?,\'scheduled\',?,?,?,?,?,?,?,?,?,?,\'done\',?)', id, siteId, o.group_id, o.id, tier, job.created_at, nowIso(), toJson({ files: true, db: true, uploads: true, config: true, storeServerId, verifyMethod: pol.policy.verify_method || null }), res.sizeBytes || 0, res.location, res.checksum, verified ? 'verified' : 'unverified', verified ? nowIso() : null, addMs(nowIso(), days * DAY), job.id); deps.store.run('UPDATE backup_options SET last_success_at=?, consecutive_failures=0, store_server_id=?, updated_at=? WHERE id=?', nowIso(), storeServerId, nowIso(), o.id); return id; });
|
|
await ctx.step('prune', async () => { const del = pruneList(deps, o); for (const b of del) { try { await halOf(deps).createJob({ kind: 'backup.delete', target: storeServerId, payload: { location: b.location }, idempotencyKey: `hal:${job.id}:del:${b.id}` }); } catch (e) { throw asTransient(e); } deps.store.run('UPDATE backups SET deleted_at=?, status=\'deleted\' WHERE id=?', nowIso(), b.id); } return { deleted: del.length }; });
|
|
resolveIncident(deps.store, `backup:${o.id}:failed`, '백업이 다시 정상적으로 완료되었습니다'); resolveIncident(deps.store, `ops:backup:${o.id}:failed`, '정상화');
|
|
return { backupId, tier, storeServerId };
|
|
} });
|
|
deps.jobs.register('backup.purge', { title: '만료 백업 삭제', maxAttempts: 5, retryable: (e) => !!e.retryable, steps: [{ id: 'delete', title: '백업 사본 삭제' }],
|
|
async run(job, ctx) { const o = optionById(deps, job.subject.optionId); await ctx.step('delete', async () => { let n = 0; for (const b of deps.store.all('SELECT * FROM backups WHERE option_id=? AND deleted_at IS NULL', o.id)) { if (b.location) { try { await halOf(deps).createJob({ kind: 'backup.delete', target: o.store_server_id, payload: { location: b.location }, idempotencyKey: `hal:${job.id}:del:${b.id}` }); } catch (e) { throw asTransient(e); } } deps.store.run('UPDATE backups SET deleted_at=?, status=\'deleted\' WHERE id=?', nowIso(), b.id); n++; } deps.store.run('UPDATE backup_options SET delete_after=NULL, updated_at=? WHERE id=?', nowIso(), o.id); notify(deps.store, { groupId: o.group_id, category: 'backup', mandatory: true, title: '만료된 백업이 삭제되었습니다', body: `삭제된 백업 ${n}개. 삭제된 백업은 복구할 수 없습니다`, link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id }); return { deleted: n }; }); } });
|
|
deps.jobs.register('backup.restore', { title: '사이트 복원', maxAttempts: 2, retryable: (e) => !!e.retryable,
|
|
steps: [{ id: 'received', title: '요청 접수' }, { id: 'approval', title: '비용과 승인 확인' }, { id: 'protect', title: '현재 상태 보호' }, { id: 'isolated', title: '격리된 환경에서 백업 확인' }, { id: 'restore', title: '데이터 복원' }, { id: 'function', title: '사이트 기능 확인' }, { id: 'external', title: '외부 접속 확인' }, { id: 'confirm', title: '사용자 확인 또는 자동 완료' }, { id: 'cleanup', title: '이전 상태 정리' }],
|
|
async run(job, ctx) {
|
|
const r = deps.store.get('SELECT * FROM restores WHERE id=?', job.subject.restoreId); const site = deps.store.get('SELECT * FROM sites WHERE id=?', r.site_id); const b = deps.store.get('SELECT * FROM backups WHERE id=?', r.backup_id); const pol = parseJson(r.policy_json, {}) || {};
|
|
const setStatus = (s, extra = {}) => deps.store.run(`UPDATE restores SET status=?, updated_at=? ${Object.keys(extra).map((k) => `, ${k}=?`).join('')} WHERE id=?`, s, nowIso(), ...Object.values(extra), r.id);
|
|
await ctx.step('received', () => { setStatus('running'); return { backupId: b.id, backupAt: b.created_at }; });
|
|
await ctx.step('approval', () => { if (r.approval_state !== 'approved') throw new JobError('restore_not_approved', '운영자 승인이 없습니다'); if (r.cost > 0 && !r.paid_ok && !job.subject.paid) { const o = r.order_id ? deps.store.get('SELECT status FROM orders WHERE id=?', r.order_id) : null; if (o && o.status !== 'approved') throw new JobError('restore_unpaid', '복원 비용 결제가 확인되지 않았습니다'); } return { cost: r.cost }; });
|
|
const prot = await ctx.step('protect', async () => { const h = await halSubmit(deps, ctx, 'protect.submit', '보호 시점 생성 요청', { kind: 'backup.create', target: site.server_id, payload: { siteId: site.id, scope: { files: true, db: true, uploads: true, config: true }, storeServerId: parseJson(b.scope_json, {}).storeServerId || null, retentionTag: 'protect' }, idempotencyKey: `hal:${job.id}:protect` }); const res = await halWait(deps, ctx, 'protect.wait', '보호 시점 생성', h.halJobId); const until = addMs(nowIso(), (pol.protect_point_days || 7) * DAY); setStatus('running', { protect_point_id: res.location || res.backupId || null, protect_until: until, revert_until: addMs(nowIso(), (pol.revert_hours || 72) * HOUR) }); return { location: res.location, checksum: res.checksum, until }; });
|
|
await ctx.step('isolated', async () => { try { const h = await halOf(deps).createJob({ kind: 'backup.verify', target: parseJson(b.scope_json, {}).storeServerId || site.server_id, payload: { location: b.location, checksum: b.checksum, isolated: true }, idempotencyKey: `hal:${job.id}:isolated` }); const j = await halOf(deps).getJob(h.halJobId); if (!(j.result && j.result.ok)) throw new JobError('restore_backup_unreadable', '격리 환경에서 백업을 읽을 수 없습니다 — 복원을 시작하지 않았습니다(현재 운영 상태 유지)'); return true; } catch (e) { throw asTransient(e); } });
|
|
const h = await halSubmit(deps, ctx, 'restore', '데이터 복원', { kind: 'backup.restore', target: site.server_id, payload: { siteId: site.id, location: b.location, checksum: b.checksum, protectPoint: true, isolatedVerify: true }, idempotencyKey: `hal:${job.id}:restore` });
|
|
const res = await halWait(deps, ctx, 'restore.wait', '복원 진행', h.halJobId);
|
|
await ctx.step('function', () => { if (res.functionCheck && res.functionCheck.ok === false) throw new JobError('restore_function_failed', '복원 뒤 사이트 기능 확인에 실패했습니다'); return res.functionCheck || { ok: true }; });
|
|
await ctx.step('external', async () => { const ext = res.externalCheck; if (ext && ext.ok === false) throw new JobError('restore_external_failed', '복원 뒤 외부 접속 확인에 실패했습니다'); return ext || { ok: true, note: '외부 확인 결과 없음' }; });
|
|
await ctx.step('confirm', () => { if (r.completion_mode === 'user_confirm' && !job.subject.userConfirmed) { setStatus('needs_user'); ctx.needsUser(`복원된 사이트를 확인하고 완료 또는 되돌리기를 선택하세요. 되돌릴 수 있는 마지막 시각: ${r.revert_until || addMs(nowIso(), (pol.revert_hours || 72) * HOUR)}`, [{ id: 'confirm', label: '복원 완료 확정' }, { id: 'revert', label: '복원 전으로 되돌리기' }]); } return { mode: r.completion_mode }; });
|
|
await ctx.step('cleanup', () => { setStatus('done', { ended_at: nowIso() }); deps.store.run('UPDATE sites SET current_version=?, updated_at=? WHERE id=?', res.restoredVersion || site.current_version, nowIso(), site.id); audit(deps.store, { actorType: 'system', groupId: r.group_id, subjectType: 'site', subjectId: site.id, action: 'restore.complete', detail: { restoreId: r.id, backupId: b.id } }); notify(deps.store, { groupId: r.group_id, category: 'backup', title: `${site.name}: 복원이 완료되었습니다`, body: `보호 시점은 ${r.protect_until || prot.until} 까지 유지됩니다`, link: `/sites/${site.id}/backup`, subjectType: 'site', subjectId: site.id }); return { done: true }; });
|
|
return { restoreId: r.id, restoredVersion: res.restoredVersion || null };
|
|
},
|
|
async revert(job, ctx) {
|
|
const r = deps.store.get('SELECT * FROM restores WHERE id=?', job.subject.restoreId); const site = deps.store.get('SELECT * FROM sites WHERE id=?', r.site_id);
|
|
if (!r.protect_point_id) throw new JobError('no_protect_point', '보호 시점이 없어 되돌릴 수 없습니다');
|
|
if (r.revert_until && Date.parse(r.revert_until) < Date.now()) throw new JobError('revert_window_passed', '되돌릴 수 있는 기간이 지났습니다');
|
|
const h = await halSubmit(deps, ctx, 'revert.submit', '보호 시점으로 되돌리기 요청', { kind: 'backup.restore', target: site.server_id, payload: { siteId: site.id, location: r.protect_point_id, protectPoint: false, isolatedVerify: true }, idempotencyKey: `hal:${job.id}:revert` });
|
|
const res = await halWait(deps, ctx, 'revert.wait', '되돌리기 진행', h.halJobId);
|
|
if (res.externalCheck && res.externalCheck.ok === false) throw new JobError('revert_external_failed', '되돌린 뒤 외부 접속 확인에 실패했습니다 — 운영자 확인 필요');
|
|
deps.store.run('UPDATE restores 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: 'site', subjectId: site.id, action: 'restore.revert', detail: { restoreId: r.id } });
|
|
} });
|
|
deps.jobs.onChange((job) => { // 실패 시 옵션 실패 횟수·알림(고객+운영자)과 다음 재시도 시각
|
|
if (job.kind === 'backup.scheduled' && ['failed', 'queued'].includes(job.status) && job.error && job.attempt > 0) {
|
|
const o = optionById(deps, job.subject.optionId); if (!o) return;
|
|
if (job.status === 'failed') deps.store.run('UPDATE backup_options SET consecutive_failures=consecutive_failures+1, last_failure_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), o.id);
|
|
const n = (o.consecutive_failures || 0) + (job.status === 'failed' ? 1 : 0); const pol = productPolicy(deps, PRODUCT_ID); const threshold = Number(pol.policy.failure_notify_after || 1);
|
|
const body = job.status === 'queued' ? `일시 오류로 ${job.next_run_at} 에 다시 시도합니다 (${job.attempt}/${job.max_attempts}회). 오류: ${job.error}` : `백업이 실패했습니다 (연속 ${n}회). 운영자에게 알렸습니다. 오류: ${job.error}`;
|
|
if (job.status === 'failed' || n >= threshold) { notify(deps.store, { groupId: o.group_id, category: 'backup', severity: job.status === 'failed' ? 'critical' : 'warning', incidentKey: `backup:${o.id}:failed`, title: '백업 실패', body, link: `/sites/${o.site_id}/backup`, subjectType: 'site', subjectId: o.site_id, actionRequired: job.status === 'failed' ? '백업 화면에서 다시 시도하거나 문의하세요' : null }); notify(deps.store, { groupId: null, category: 'backup', severity: 'warning', incidentKey: `ops:backup:${o.id}:failed`, title: `[운영] 백업 실패 — 사이트 ${o.site_id}`, body, link: `/ops/backup?site=${o.site_id}` }); }
|
|
}
|
|
if (job.kind === 'backup.restore' && ['failed'].includes(job.status)) { const r = deps.store.get('SELECT * FROM restores WHERE id=?', job.subject.restoreId); if (r) { deps.store.run('UPDATE restores SET status=\'failed\', error=?, updated_at=? WHERE id=?', job.error, nowIso(), r.id); notify(deps.store, { groupId: r.group_id, category: 'backup', severity: 'critical', mandatory: true, incidentKey: `restore:${r.id}:failed`, title: '복원이 실패했습니다', body: `기존 운영 상태는 ${r.protect_point_id ? '보호 시점으로 되돌릴 수 있습니다' : '유지됩니다'}. 자동 복구가 불가능해 운영자가 확인 중입니다. 오류: ${job.error} (작업 번호 ${job.id})`, link: `/sites/${r.site_id}/backup`, subjectType: 'site', subjectId: r.site_id }); notify(deps.store, { groupId: null, category: 'backup', severity: 'critical', incidentKey: `ops:restore:${r.id}:failed`, title: `[운영] 복원 실패 — ${r.id}`, body: job.error, link: `/ops/backup/restores/${r.id}` }); } }
|
|
});
|
|
}
|
|
|
|
/** 복원 요청 화면 정보(§20.1) */
|
|
export function restoreInfo(deps, site, backup) {
|
|
const pol = productPolicy(deps, PRODUCT_ID); const p = pol.policy; const conflicts = inProgress(deps, 'site', site.id);
|
|
return { backup: { id: backup.id, createdAt: backup.created_at, scope: parseJson(backup.scope_json, {}), sizeBytes: backup.size_bytes, verifyState: backup.verify_state, expiresAt: backup.expires_at, tier: backup.tier }, included: Object.values(SCOPE), excluded: EXCLUDED,
|
|
impact: '복원 중 사이트가 일시적으로 이전 상태로 바뀌며, 복원 뒤 생성된 데이터는 보호 시점으로만 되돌릴 수 있습니다', estimatedDowntimeMin: p.restore_downtime_min ?? null, cost: p.restore_cost ?? null, currency: pol.currency, costDeferred: p.restore_cost === undefined,
|
|
completionMode: p.restore_completion || 'user_confirm', protectPointDays: p.protect_point_days ?? null, revertHours: p.revert_hours ?? null, approvalRequired: true, conflicts, canRequest: conflicts.length === 0 && backup.status === 'done' && !backup.deleted_at && (!backup.expires_at || Date.parse(backup.expires_at) > Date.now()), deferred: pol.deferred };
|
|
}
|
|
/** 복원 요청(§20.1). 같은 백업에 열린 요청이 있으면 그것을 돌려준다(중복 방지 §36). */
|
|
export async function requestRestore(deps, site, backup, { by, completionMode = null } = {}) {
|
|
const open = deps.store.get('SELECT * FROM restores WHERE site_id=? AND backup_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\',\'queued\',\'running\',\'needs_user\')', site.id, backup.id); if (open) return { restore: open, created: false };
|
|
const info = restoreInfo(deps, site, backup); if (!info.canRequest) throw new HttpError(409, 'restore_not_possible', info.conflicts.length ? '진행 중인 배포·복원·이전이 끝나야 복원을 요청할 수 있습니다' : '이 백업은 복원할 수 없습니다(만료·삭제·미완료)', { conflicts: info.conflicts });
|
|
const id = newId('rs_'); const now = nowIso(); const cost = Number(info.cost || 0); const pol = productPolicy(deps, PRODUCT_ID).policy;
|
|
let orderId = null; let status = cost > 0 ? 'awaiting_payment' : 'awaiting_approval';
|
|
if (cost > 0) { const orders = await optional('../commerce/orders.mjs'); if (orders && orders.createInternalOrder) { try { const o = await orders.createInternalOrder(deps, { groupId: site.group_id, kind: 'restore', amount: cost, targetType: 'restore', targetId: id, label: `사이트 복원 (${site.name})`, by }); orderId = o.id; } catch (_) { /* 주문 영역 없으면 결제 대기 기록만 */ } } }
|
|
deps.store.run('INSERT INTO restores(id, site_id, group_id, backup_id, status, cost, order_id, approval_state, completion_mode, requested_by, created_at, updated_at, policy_json) VALUES(?,?,?,?,?,?,?,\'pending\',?,?,?,?,?)', id, site.id, site.group_id, backup.id, status, cost, orderId, completionMode || info.completionMode, by, now, now, toJson(pol));
|
|
audit(deps.store, { actorType: 'user', actorId: by, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'restore.request', detail: { restoreId: id, backupId: backup.id, cost } });
|
|
notify(deps.store, { groupId: null, category: 'backup', incidentKey: `ops:restore:${id}:approval`, title: `[운영] 복원 승인 대기 — ${site.name}`, body: `백업 ${backup.created_at}, 비용 ${cost}`, link: `/ops/backup/restores/${id}` });
|
|
return { restore: deps.store.get('SELECT * FROM restores WHERE id=?', id), created: true };
|
|
}
|
|
/** 결제 확인(주문 승인 이벤트 또는 운영자) → 승인 대기 */
|
|
export function markRestorePaid(deps, restoreId, by = 'system') { const r = deps.store.get('SELECT * FROM restores WHERE id=?', restoreId); if (!r || r.status !== 'awaiting_payment') return r; deps.store.run('UPDATE restores SET status=\'awaiting_approval\', updated_at=? WHERE id=?', nowIso(), r.id); audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'payment.restore.confirmed', detail: { restoreId } }); return deps.store.get('SELECT * FROM restores WHERE id=?', r.id); }
|
|
/** 운영자 승인(§30.3): 최종 확인 내용(고객·그룹·사이트·도메인·백업 시각)과 함께. 같은 승인을 반복해도 작업은 하나(멱등). */
|
|
export function approveRestore(deps, restoreId, { by, window = null }) {
|
|
const r = deps.store.get('SELECT * FROM restores WHERE id=?', restoreId); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다');
|
|
if (r.approval_state === 'approved' && r.job_id) return { restore: r, job: deps.jobs.get(r.job_id), created: false };
|
|
if (!['awaiting_approval', 'awaiting_payment'].includes(r.status)) throw new HttpError(409, 'restore_not_approvable', `지금 상태(${r.status})에서는 승인할 수 없습니다`);
|
|
if (r.status === 'awaiting_payment') throw new HttpError(409, 'restore_unpaid', '결제 확인 전에는 승인할 수 없습니다');
|
|
const site = deps.store.get('SELECT * FROM sites WHERE id=?', r.site_id);
|
|
const { job } = deps.jobs.enqueue({ kind: 'backup.restore', subject: { restoreId: r.id, siteId: r.site_id, groupId: r.group_id }, idempotencyKey: `restore:${r.id}`, actorType: 'operator', createdBy: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, label: `사이트 복원 (${site ? site.name : r.site_id})`, runAt: window || null });
|
|
deps.store.run('UPDATE restores SET approval_state=\'approved\', status=\'queued\', job_id=?, updated_at=? WHERE id=?', job.id, nowIso(), r.id);
|
|
audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'restore.approve', detail: { restoreId: r.id, backupId: r.backup_id, window } });
|
|
notify(deps.store, { groupId: r.group_id, category: 'backup', title: '복원이 승인되어 진행됩니다', body: window ? `작업 시간: ${window}` : '곧 시작합니다', link: `/sites/${r.site_id}/backup`, subjectType: 'site', subjectId: r.site_id });
|
|
return { restore: deps.store.get('SELECT * FROM restores WHERE id=?', r.id), job, created: true };
|
|
}
|
|
export function rejectRestore(deps, restoreId, { by, reason }) { const r = deps.store.get('SELECT * FROM restores WHERE id=?', restoreId); if (!r) throw new HttpError(404, 'not_found', '복원 요청이 없습니다'); if (!['awaiting_approval', 'awaiting_payment'].includes(r.status)) throw new HttpError(409, 'restore_not_rejectable', '지금 상태에서는 거절할 수 없습니다'); deps.store.run('UPDATE restores SET status=\'rejected\', approval_state=\'rejected\', error=?, updated_at=?, ended_at=? WHERE id=?', reason || null, nowIso(), nowIso(), r.id); audit(deps.store, { actorType: 'operator', actorId: by, groupId: r.group_id, subjectType: 'site', subjectId: r.site_id, action: 'restore.reject', detail: { restoreId, reason } }); notify(deps.store, { groupId: r.group_id, category: 'backup', severity: 'warning', title: '복원 요청이 거절되었습니다', body: reason || '사유 없음', link: `/sites/${r.site_id}/backup`, subjectType: 'site', subjectId: r.site_id }); return deps.store.get('SELECT * FROM restores WHERE id=?', r.id); }
|
|
/** 사용자 확인 단계의 선택(§20.2 8단계): confirm → 완료 진행, revert → 되돌리기 */
|
|
export async function userDecision(deps, restore, action, by) {
|
|
const job = deps.jobs.get(restore.job_id); if (!job || job.status !== 'needs_user') throw new HttpError(409, 'restore_not_waiting', '사용자 확인을 기다리는 상태가 아닙니다');
|
|
if (action === 'confirm') { deps.store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...job.subject, userConfirmed: true, by }), job.id); deps.jobs.resume(job.id, by); deps.store.run('UPDATE restores SET status=\'running\', updated_at=? WHERE id=?', nowIso(), restore.id); audit(deps.store, { actorType: 'user', actorId: by, groupId: restore.group_id, subjectType: 'site', subjectId: restore.site_id, action: 'restore.user_confirm', detail: { restoreId: restore.id } }); return { resumed: true }; }
|
|
if (action === 'revert') { deps.store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...job.subject, by }), job.id); const j = await deps.jobs.revert(job.id, by); return { reverted: j.status === 'reverted', job: j }; }
|
|
throw new HttpError(400, 'bad_action', '알 수 없는 선택입니다');
|
|
}
|
|
/** 사이트 백업 화면 데이터(§19.2) */
|
|
export function siteView(deps, site) {
|
|
const o = optionOf(deps, site.id); const pol = productPolicy(deps, PRODUCT_ID);
|
|
const list = deps.store.all('SELECT * FROM backups WHERE site_id=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 100', site.id).map((b) => ({ id: b.id, createdAt: b.created_at, tier: b.tier, kind: b.kind, scope: parseJson(b.scope_json, {}), sizeBytes: b.size_bytes, verifyState: b.verify_state, expiresAt: b.expires_at, status: b.status, canRestore: b.status === 'done' && (!b.expires_at || Date.parse(b.expires_at) > Date.now()) }));
|
|
const restores = deps.store.all('SELECT * FROM restores WHERE site_id=? ORDER BY created_at DESC LIMIT 20', site.id).map((r) => ({ ...r, job: r.job_id ? deps.jobs.get(r.job_id) : null }));
|
|
const lastJob = o ? deps.store.get('SELECT id FROM jobs WHERE kind=\'backup.scheduled\' AND subject_id=? ORDER BY created_at DESC LIMIT 1', site.id) : null;
|
|
return { eligibility: eligibility(deps, site), purchase: purchaseInfo(deps, site), option: o ? { ...o, schedule: parseJson(o.schedule_json, {}), retention: parseJson(o.retention_json, {}), pausedReasonLabel: o.paused_reason ? reasonLabel(o.paused_reason) : null, storedBytes: storedBytes(deps, site.id), firstBackupPending: !o.last_success_at, failureNotifyAfter: Number(pol.policy.failure_notify_after || 1) } : null,
|
|
lastRun: lastJob ? deps.jobs.get(lastJob.id) : null, backups: list, restores, scope: SCOPE, excluded: EXCLUDED, siteState: site.lifecycle, mailBackupNotice: '메일 원문은 고객용 백업·복원에 포함되지 않습니다(§17.4)' };
|
|
}
|