Files
multi-simadmin/apps/api/src/application/system/system-maintenance-service.ts
T
chick 635527dd26 feat(api): integrate identity, notification scope and connection settings into control plane
- Wire identity, metrics and update services into the assembled API.

- Add connection authorization modes and scoped console authorization.

- Preserve notification group IDs for filtered delivery.

- Extend maintenance, backup, resource and module flows with runtime metadata and upstream safety.
2026-09-07 00:45:18 +08:00

388 lines
14 KiB
TypeScript

import { createHash } from 'node:crypto';
import { chmod, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
import { basename, join } from 'node:path';
import type { SqliteDatabase } from '../../infrastructure/database/database.js';
export type DataComponentKey =
| 'instances'
| 'statusSnapshots'
| 'jobs'
| 'auditEvents'
| 'scheduledRuns'
| 'notificationQueue'
| 'smsMessages'
| 'eventJournal'
| 'connectionLogs';
export interface RetentionPolicy {
readonly enabled: boolean;
readonly days: number;
readonly maximumCount: number;
}
export interface MaintenanceOverview {
readonly runtime: {
readonly version: string;
readonly platform: NodeJS.Platform;
readonly arch: string;
readonly uptimeSeconds: number;
};
readonly storage: {
readonly databaseBytes: number;
/** SQLite file path; empty for in-memory databases. */
readonly databasePath: string;
/** Write-ahead log file size; 0 when the database is not in WAL mode. */
readonly walBytes: number;
/** Total size of the retained backup files. */
readonly backupBytes: number;
/** Free pages that "整理空间" can hand back to the filesystem. */
readonly reclaimableBytes: number;
readonly components: readonly {
readonly key: DataComponentKey;
readonly count: number;
readonly bytes: number;
}[];
};
readonly retention: Readonly<Record<DataComponentKey, RetentionPolicy>>;
}
export interface MaintenanceBackup {
readonly filename: string;
readonly path: string;
readonly sizeBytes: number;
readonly sha256: string;
readonly createdAt: string;
}
/** Download metadata without the full-file hash so large backups stay cheap to serve. */
export interface MaintenanceBackupFile {
readonly filename: string;
readonly path: string;
readonly sizeBytes: number;
readonly createdAt: string;
}
interface ServiceOptions {
readonly version: string;
readonly now?: () => Date;
readonly backupDirectory?: string;
readonly maximumBackups?: number;
}
const COMPONENT_TABLES: Readonly<Record<DataComponentKey, string>> = Object.freeze({
instances: 'instances',
statusSnapshots: 'status_snapshots',
jobs: 'jobs',
auditEvents: 'audit_events',
scheduledRuns: 'scheduled_runs',
notificationQueue: 'notification_queue',
smsMessages: 'sms_messages',
eventJournal: 'event_journal',
connectionLogs: 'connection_logs',
});
const RETENTION_SETTING_KEY = 'system.retention';
const DEFAULT_RETENTION: Readonly<Record<DataComponentKey, RetentionPolicy>> = Object.freeze({
instances: Object.freeze({ enabled: false, days: 365, maximumCount: 0 }),
statusSnapshots: Object.freeze({ enabled: false, days: 30, maximumCount: 0 }),
jobs: Object.freeze({ enabled: true, days: 90, maximumCount: 5_000 }),
auditEvents: Object.freeze({ enabled: true, days: 180, maximumCount: 50_000 }),
scheduledRuns: Object.freeze({ enabled: true, days: 180, maximumCount: 5_000 }),
notificationQueue: Object.freeze({ enabled: true, days: 180, maximumCount: 10_000 }),
smsMessages: Object.freeze({ enabled: false, days: 365, maximumCount: 0 }),
eventJournal: Object.freeze({ enabled: true, days: 30, maximumCount: 10_000 }),
// The probe journal already self-caps, so retention here is opt-in by day window.
connectionLogs: Object.freeze({ enabled: true, days: 30, maximumCount: 20_000 }),
});
function cloneRetention(
value: Readonly<Record<DataComponentKey, RetentionPolicy>>,
): Record<DataComponentKey, RetentionPolicy> {
return Object.fromEntries(
Object.entries(value).map(([key, policy]) => [key, { ...policy }]),
) as Record<DataComponentKey, RetentionPolicy>;
}
function validateRetention(value: unknown): Record<DataComponentKey, RetentionPolicy> {
const source =
value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const merged = cloneRetention(DEFAULT_RETENTION);
for (const key of Object.keys(DEFAULT_RETENTION) as DataComponentKey[]) {
const current = source[key];
if (current === undefined) continue;
const policy = current as Partial<RetentionPolicy>;
if (
typeof policy.enabled !== 'boolean' ||
!Number.isSafeInteger(policy.days) ||
(policy.days ?? 0) < 1 ||
(policy.days ?? 0) > 3_650 ||
!Number.isSafeInteger(policy.maximumCount) ||
(policy.maximumCount ?? 0) < 0
)
throw new RangeError('Retention days must be between 1 and 3650 and limits must be valid');
merged[key] = {
enabled: policy.enabled,
days: policy.days!,
maximumCount: policy.maximumCount!,
};
}
if (Object.keys(source).some((key) => !(key in DEFAULT_RETENTION)))
throw new TypeError('Unknown data component');
return merged;
}
function tableExists(db: SqliteDatabase, table: string): boolean {
return Boolean(
db.prepare('SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?').get('table', table),
);
}
function countTable(db: SqliteDatabase, table: string): number {
if (!tableExists(db, table)) return 0;
return Number(
(db.prepare(`SELECT COUNT(*) AS count FROM "${table}"`).get() as { count?: number } | undefined)
?.count ?? 0,
);
}
/** Per-table page usage from the dbstat virtual table; 0 when SQLite was built without it. */
function tableBytes(db: SqliteDatabase, table: string): number {
if (!tableExists(db, table)) return 0;
try {
const row = db
.prepare('SELECT COALESCE(SUM(pgsize), 0) AS bytes FROM dbstat WHERE name = ?')
.get(table) as { bytes?: number } | undefined;
return Number(row?.bytes ?? 0);
} catch {
return 0;
}
}
export class SystemMaintenanceService {
readonly #db: SqliteDatabase;
readonly #version: string;
readonly #now: () => Date;
readonly #backupDirectory: string;
readonly #maximumBackups: number;
constructor(db: SqliteDatabase, options: ServiceOptions) {
this.#db = db;
this.#version = options.version;
this.#now = options.now ?? (() => new Date());
this.#backupDirectory = options.backupDirectory ?? './data/backups';
this.#maximumBackups = Math.max(1, options.maximumBackups ?? 10);
}
async overview(): Promise<MaintenanceOverview> {
const pageCount = Number(this.#db.pragma('page_count', { simple: true }));
const pageSize = Number(this.#db.pragma('page_size', { simple: true }));
const databaseFile = String(
(this.#db.prepare('PRAGMA database_list').get() as { file?: string } | undefined)?.file ?? '',
);
const walBytes = databaseFile
? await stat(`${databaseFile}-wal`)
.then((details) => (details.isFile() ? details.size : 0))
.catch(() => 0)
: 0;
const freelist = Number(this.#db.pragma('freelist_count', { simple: true }));
return {
runtime: {
version: this.#version,
platform: process.platform,
arch: process.arch,
uptimeSeconds: Math.floor(process.uptime()),
},
storage: {
databaseBytes: pageCount * pageSize,
databasePath: databaseFile,
walBytes,
backupBytes: await this.#backupBytes(),
reclaimableBytes: freelist * pageSize,
components: (Object.keys(COMPONENT_TABLES) as DataComponentKey[]).map((key) => ({
key,
count: countTable(this.#db, COMPONENT_TABLES[key]),
bytes: tableBytes(this.#db, COMPONENT_TABLES[key]),
})),
},
retention: await this.getRetention(),
};
}
async #backupBytes(): Promise<number> {
const names = await readdir(this.#backupDirectory).catch(() => [] as string[]);
let total = 0;
for (const name of names.filter((entry) => entry.startsWith('multi-simadmin-'))) {
const details = await stat(join(this.#backupDirectory, name)).catch(() => undefined);
if (details?.isFile()) total += details.size;
}
return total;
}
defaultRetention(): Readonly<Record<DataComponentKey, RetentionPolicy>> {
return cloneRetention(DEFAULT_RETENTION);
}
async getRetention(): Promise<Readonly<Record<DataComponentKey, RetentionPolicy>>> {
const row = this.#db
.prepare('SELECT value_json FROM app_settings WHERE key = ?')
.get(RETENTION_SETTING_KEY) as { value_json?: string } | undefined;
if (!row?.value_json) return this.defaultRetention();
return validateRetention(JSON.parse(row.value_json));
}
async updateRetention(
value: unknown,
): Promise<Readonly<Record<DataComponentKey, RetentionPolicy>>> {
const policy = validateRetention(value);
const now = this.#now().toISOString();
this.#db
.prepare(
`INSERT INTO app_settings (key,value_json,created_at,updated_at)
VALUES (?,?,?,?)
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json,
updated_at = excluded.updated_at`,
)
.run(RETENTION_SETTING_KEY, JSON.stringify(policy), now, now);
return policy;
}
async cleanup(
components: readonly DataComponentKey[],
): Promise<Partial<Record<DataComponentKey, number>>> {
const known = new Set(Object.keys(COMPONENT_TABLES));
if (components.some((component) => !known.has(component)))
throw new Error('Unknown data component');
const retention = await this.getRetention();
const result: Partial<Record<DataComponentKey, number>> = {};
for (const component of components) {
const policy = retention[component];
const table = COMPONENT_TABLES[component];
if (!policy.enabled || !tableExists(this.#db, table)) continue;
const cutoff = new Date(this.#now().getTime() - policy.days * 86_400_000).toISOString();
if (component === 'auditEvents')
result[component] = this.#delete(`DELETE FROM ${table} WHERE created_at < ?`, cutoff);
else if (component === 'jobs')
result[component] = this.#delete(
`DELETE FROM ${table} WHERE created_at < ? AND status NOT IN ('running','pending')`,
cutoff,
);
else if (component === 'scheduledRuns')
result[component] = this.#delete(`DELETE FROM ${table} WHERE due_at < ?`, cutoff);
else if (component === 'eventJournal')
result[component] = this.#delete(`DELETE FROM ${table}`);
else if (component === 'connectionLogs')
result[component] = this.#delete(`DELETE FROM ${table} WHERE observed_at < ?`, cutoff);
else if (policy.maximumCount > 0)
result[component] = this.#delete(
`DELETE FROM ${table} WHERE rowid IN (
SELECT rowid FROM ${table} LIMIT ?
)`,
Math.max(0, countTable(this.#db, table) - policy.maximumCount),
);
}
return result;
}
async optimize(): Promise<{ checkpointed: boolean; vacuumed: boolean; analyzed: boolean }> {
this.#db.pragma('wal_checkpoint(TRUNCATE)');
this.#db.exec('VACUUM');
this.#db.exec('ANALYZE');
return { checkpointed: true, vacuumed: true, analyzed: true };
}
async createBackup(): Promise<MaintenanceBackup> {
await mkdir(this.#backupDirectory, { recursive: true });
const filename = `multi-simadmin-${this.#now().toISOString().replaceAll(/[:.]/gu, '-')}.db`;
const path = join(this.#backupDirectory, filename);
await this.#db.backup(path);
await chmod(path, 0o600);
const details = await stat(path);
await this.#pruneBackups();
return {
filename,
path,
sizeBytes: details.size,
sha256: await readFile(path).then((content) =>
createHash('sha256').update(content).digest('hex'),
),
createdAt: this.#now().toISOString(),
};
}
async listBackups(): Promise<readonly MaintenanceBackup[]> {
await mkdir(this.#backupDirectory, { recursive: true });
const names = (await readdir(this.#backupDirectory))
.filter((name) => name.startsWith('multi-simadmin-') && name.endsWith('.db'))
.sort()
.reverse();
const backups: MaintenanceBackup[] = [];
for (const filename of names.slice(0, this.#maximumBackups)) {
const path = join(this.#backupDirectory, filename);
const details = await stat(path);
if (!details.isFile()) continue;
backups.push({
filename,
path,
sizeBytes: details.size,
sha256: createHash('sha256')
.update(await readFile(path))
.digest('hex'),
createdAt: details.mtime.toISOString(),
});
}
return backups;
}
/** Resolves a listed backup name to a path that stays inside the backup directory. */
#backupPath(filename: string): string {
if (
!/^multi-simadmin-[A-Za-z0-9._-]{1,120}\.db$/u.test(filename) ||
basename(filename) !== filename
)
throw new TypeError('Backup filename is invalid');
return join(this.#backupDirectory, filename);
}
async backupFile(filename: string): Promise<MaintenanceBackupFile | undefined> {
const path = this.#backupPath(filename);
const details = await stat(path).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
});
if (!details?.isFile()) return undefined;
return {
filename,
path,
sizeBytes: details.size,
createdAt: details.mtime.toISOString(),
};
}
async deleteBackup(filename: string): Promise<{ filename: string }> {
await rm(this.#backupPath(filename), { force: true });
return { filename };
}
/** Names sort newest-first because they embed an ISO timestamp. */
async #pruneBackups(): Promise<number> {
const names = (await readdir(this.#backupDirectory))
.filter((name) => name.startsWith('multi-simadmin-') && name.endsWith('.db'))
.sort()
.reverse();
let removed = 0;
for (const filename of names.slice(this.#maximumBackups)) {
await rm(join(this.#backupDirectory, filename), { force: true });
removed += 1;
}
return removed;
}
#delete(statement: string, ...parameters: readonly unknown[]): number {
return Number(this.#db.prepare(statement).run(...parameters).changes);
}
}