preserved batres 파일을 기수 단위로 검증·체크포인트하고 과거 장수 상세에 연결한다. check-plan에는 전체 이전 항목과 정보 설명을 함께 노출한다.
239 lines
10 KiB
TypeScript
239 lines
10 KiB
TypeScript
import { constants } from 'node:fs';
|
|
import { lstat, open } from 'node:fs/promises';
|
|
import { isIP } from 'node:net';
|
|
import path from 'node:path';
|
|
|
|
import { resolveBattleResultSourceConfig, type BattleResultSourceConfig } from './battleResultSource.js';
|
|
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js';
|
|
import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js';
|
|
|
|
export interface ResolvedMigrationStage {
|
|
kind: 'gateway' | 'game';
|
|
name: string;
|
|
profile?: LegacyArchiveProfile;
|
|
sourceUrl: string;
|
|
targetUrl: string;
|
|
sourceIdentity: MigrationSourceIdentity;
|
|
battleResults?: BattleResultSourceConfig;
|
|
}
|
|
|
|
export interface ResolvedMigrationPlan {
|
|
sourceSet: string;
|
|
stages: ResolvedMigrationStage[];
|
|
}
|
|
|
|
const SAFE_KEY = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,95}$/u;
|
|
const ENV_NAME = /^[A-Z][A-Z0-9_]{1,127}$/u;
|
|
|
|
const assertSecureRegularFile = async (filePath: string, label: string): Promise<void> => {
|
|
const info = await lstat(filePath);
|
|
if (!info.isFile() || info.isSymbolicLink()) {
|
|
throw new Error(`${label} must be a regular file and not a symbolic link`);
|
|
}
|
|
if ((info.mode & 0o077) !== 0) {
|
|
throw new Error(`${label} must not be readable or writable by group/other (expected mode 0600)`);
|
|
}
|
|
};
|
|
|
|
const readSecureText = async (filePath: string, label: string): Promise<string> => {
|
|
await assertSecureRegularFile(filePath, label);
|
|
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
try {
|
|
return await handle.readFile('utf8');
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
};
|
|
|
|
const asRecord = (value: unknown, label: string): Record<string, unknown> => {
|
|
if (value === null || Array.isArray(value) || typeof value !== 'object') {
|
|
throw new Error(`${label} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
};
|
|
|
|
const requiredString = (record: Record<string, unknown>, key: string, label: string): string => {
|
|
const value = record[key];
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
throw new Error(`${label}.${key} must be a non-empty string`);
|
|
}
|
|
return value.trim();
|
|
};
|
|
|
|
const rejectUnknownKeys = (record: Record<string, unknown>, allowed: readonly string[], label: string): void => {
|
|
const unknown = Object.keys(record).filter((key) => !allowed.includes(key));
|
|
if (unknown.length) {
|
|
throw new Error(`${label} has unknown keys: ${unknown.join(', ')}`);
|
|
}
|
|
};
|
|
|
|
const resolvePassword = async (
|
|
source: Record<string, unknown>,
|
|
configDirectory: string,
|
|
label: string
|
|
): Promise<string> => {
|
|
const configured = ['password', 'passwordEnv', 'passwordFile'].filter(
|
|
(key) => typeof source[key] === 'string' && Boolean(String(source[key]).trim())
|
|
);
|
|
if (configured.length !== 1) {
|
|
throw new Error(`${label} must configure exactly one of password, passwordEnv, or passwordFile`);
|
|
}
|
|
if (configured[0] === 'password') {
|
|
return requiredString(source, 'password', label);
|
|
}
|
|
if (configured[0] === 'passwordEnv') {
|
|
const environmentName = requiredString(source, 'passwordEnv', label);
|
|
if (!ENV_NAME.test(environmentName)) throw new Error(`${label}.passwordEnv is not a safe environment name`);
|
|
const value = process.env[environmentName];
|
|
if (!value) throw new Error(`${environmentName} is required by ${label}`);
|
|
return value;
|
|
}
|
|
const configuredPath = requiredString(source, 'passwordFile', label);
|
|
const passwordPath = path.resolve(configDirectory, configuredPath);
|
|
const value = (await readSecureText(passwordPath, `${label}.passwordFile`)).replace(/\r?\n$/u, '');
|
|
if (!value) throw new Error(`${label}.passwordFile is empty`);
|
|
return value;
|
|
};
|
|
|
|
const resolveSource = async (value: unknown, configDirectory: string, label: string): Promise<string> => {
|
|
const source = asRecord(value, label);
|
|
rejectUnknownKeys(
|
|
source,
|
|
['host', 'port', 'database', 'user', 'password', 'passwordEnv', 'passwordFile', 'tls'],
|
|
label
|
|
);
|
|
const host = requiredString(source, 'host', label);
|
|
const database = requiredString(source, 'database', label);
|
|
const user = requiredString(source, 'user', label);
|
|
const port = source.port === undefined ? 3306 : Number(source.port);
|
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
throw new Error(`${label}.port must be an integer from 1 through 65535`);
|
|
}
|
|
const dnsName =
|
|
host.length <= 253 && host.split('.').every((part) => /^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)$/u.test(part));
|
|
if (!isIP(host) && !dnsName) throw new Error(`${label}.host must be an IP address or DNS name`);
|
|
if (!/^[a-zA-Z0-9_$.-]{1,64}$/u.test(database)) {
|
|
throw new Error(`${label}.database must be a safe MariaDB database name`);
|
|
}
|
|
if (source.tls !== undefined && typeof source.tls !== 'boolean') {
|
|
throw new Error(`${label}.tls must be a boolean`);
|
|
}
|
|
const password = await resolvePassword(source, configDirectory, label);
|
|
const url = new URL('mariadb://localhost');
|
|
url.hostname = host;
|
|
url.port = String(port);
|
|
url.username = user;
|
|
url.password = password;
|
|
url.pathname = `/${database}`;
|
|
if (source.tls) url.searchParams.set('ssl', 'true');
|
|
return url.toString();
|
|
};
|
|
|
|
const resolveTargetUrl = (record: Record<string, unknown>, label: string): string => {
|
|
const environmentName = requiredString(record, 'targetUrlEnv', label);
|
|
if (!ENV_NAME.test(environmentName)) throw new Error(`${label}.targetUrlEnv is not a safe environment name`);
|
|
const value = process.env[environmentName]?.trim();
|
|
if (!value) throw new Error(`${environmentName} is required by ${label}`);
|
|
return value;
|
|
};
|
|
|
|
const parseStage = (value: unknown, label: string): Record<string, unknown> => {
|
|
const record = asRecord(value, label);
|
|
rejectUnknownKeys(record, ['source', 'targetUrlEnv', 'profile', 'enabled', 'battleResults'], label);
|
|
if (!('source' in record)) throw new Error(`${label}.source is required`);
|
|
return record;
|
|
};
|
|
|
|
export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => {
|
|
const configPath = path.resolve(configPathInput);
|
|
const rawText = await readSecureText(configPath, 'Migration config');
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(rawText);
|
|
} catch (error) {
|
|
throw new Error('Migration config is not valid JSON', { cause: error });
|
|
}
|
|
const root = asRecord(parsed, 'Migration config');
|
|
rejectUnknownKeys(root, ['version', 'sourceSet', 'gateway', 'profiles'], 'Migration config');
|
|
if (root.version !== 1) throw new Error('Migration config.version must be 1');
|
|
const sourceSet = requiredString(root, 'sourceSet', 'Migration config');
|
|
if (!SAFE_KEY.test(sourceSet)) throw new Error('Migration config.sourceSet must use safe characters');
|
|
const configDirectory = path.dirname(configPath);
|
|
const stages: ResolvedMigrationStage[] = [];
|
|
|
|
if (root.gateway !== undefined) {
|
|
const gateway = parseStage(root.gateway, 'gateway');
|
|
const sourceUrl = await resolveSource(gateway.source, configDirectory, 'gateway.source');
|
|
stages.push({
|
|
kind: 'gateway',
|
|
name: 'gateway',
|
|
sourceUrl,
|
|
targetUrl: resolveTargetUrl(gateway, 'gateway'),
|
|
sourceIdentity: {
|
|
key: `${sourceSet}:gateway`,
|
|
fingerprint: fingerprintMariaConnection(sourceUrl),
|
|
},
|
|
});
|
|
}
|
|
|
|
const profiles = root.profiles === undefined ? [] : root.profiles;
|
|
if (!Array.isArray(profiles)) throw new Error('Migration config.profiles must be an array');
|
|
const seen = new Set<string>();
|
|
const profileStages = new Map<LegacyArchiveProfile, ResolvedMigrationStage>();
|
|
for (const [index, value] of profiles.entries()) {
|
|
const label = `profiles[${index}]`;
|
|
const profileConfig = parseStage(value, label);
|
|
if (profileConfig.enabled === false) continue;
|
|
if (profileConfig.enabled !== undefined && profileConfig.enabled !== true) {
|
|
throw new Error(`${label}.enabled must be a boolean`);
|
|
}
|
|
const profile = requiredString(profileConfig, 'profile', label);
|
|
if (!isLegacyArchiveProfile(profile)) {
|
|
throw new Error(`${label}.profile must be one of ${LEGACY_ARCHIVE_PROFILES.join(', ')}`);
|
|
}
|
|
if (seen.has(profile)) throw new Error(`Duplicate profile in migration config: ${profile}`);
|
|
seen.add(profile);
|
|
const sourceUrl = await resolveSource(profileConfig.source, configDirectory, `${label}.source`);
|
|
let battleResults: BattleResultSourceConfig | undefined;
|
|
if (profileConfig.battleResults !== undefined) {
|
|
const battleResultConfig = asRecord(profileConfig.battleResults, `${label}.battleResults`);
|
|
rejectUnknownKeys(battleResultConfig, ['directory', 'sshHost'], `${label}.battleResults`);
|
|
const directory = requiredString(battleResultConfig, 'directory', `${label}.battleResults`);
|
|
const sshHostValue = battleResultConfig.sshHost;
|
|
if (sshHostValue !== undefined && typeof sshHostValue !== 'string') {
|
|
throw new Error(`${label}.battleResults.sshHost must be a string`);
|
|
}
|
|
battleResults = await resolveBattleResultSourceConfig(
|
|
{ directory, ...(sshHostValue === undefined ? {} : { sshHost: sshHostValue }) },
|
|
`${sourceSet}:${profile}`,
|
|
profile,
|
|
configDirectory
|
|
);
|
|
}
|
|
profileStages.set(profile, {
|
|
kind: 'game',
|
|
name: profile,
|
|
profile,
|
|
sourceUrl,
|
|
targetUrl: resolveTargetUrl(profileConfig, label),
|
|
sourceIdentity: {
|
|
key: `${sourceSet}:${profile}`,
|
|
fingerprint: fingerprintMariaConnection(sourceUrl),
|
|
},
|
|
...(battleResults ? { battleResults } : {}),
|
|
});
|
|
}
|
|
for (const profile of LEGACY_ARCHIVE_PROFILES) {
|
|
const stage = profileStages.get(profile);
|
|
if (stage) stages.push(stage);
|
|
}
|
|
if (!stages.length) throw new Error('Migration config has no enabled stages');
|
|
return { sourceSet, stages };
|
|
};
|
|
|
|
export const readPasswordFileForReset = async (passwordPath: string): Promise<string> => {
|
|
const value = (await readSecureText(path.resolve(passwordPath), 'Password file')).replace(/\r?\n$/u, '');
|
|
if (!value) throw new Error('Password file is empty');
|
|
return value;
|
|
};
|