Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@lisglosips/worker-recording",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"dev": "cross-env LISGLOSIPS_SERVICE_NAME=worker-recording tsx watch src/main.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lisglosips/database": "workspace:*",
|
||||
"@lisglosips/observability": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "10.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createLogger } from '@lisglosips/observability';
|
||||
import { PrismaClient } from '@lisglosips/database';
|
||||
import { PrismaRecordingStore, RecordingTransferService, SshRecordingClient } from './transfer.js';
|
||||
|
||||
const serviceName = process.env.LISGLOSIPS_SERVICE_NAME ?? 'worker-recording';
|
||||
const logger = createLogger(serviceName, process.env.LISGLOSIPS_LOG_LEVEL ?? 'info');
|
||||
|
||||
const remoteHost = process.env.RECORDING_REMOTE_HOST ?? 'lisglosips-a';
|
||||
const sshConfig = process.env.RECORDING_SSH_CONFIG;
|
||||
const remoteReadyDir = process.env.RECORDING_REMOTE_READY_DIR ?? '/dev/shm/voip_rec/ready';
|
||||
const localRoot = process.env.RECORDING_LOCAL_ROOT ?? '/data/recordings';
|
||||
const scanIntervalMs = Number.parseInt(process.env.RECORDING_SCAN_INTERVAL_MS ?? '10000', 10);
|
||||
const maxFilesPerScan = Number.parseInt(process.env.RECORDING_MAX_FILES_PER_SCAN ?? '50', 10);
|
||||
const deleteSourceAfterCopy = process.env.RECORDING_DELETE_SOURCE_AFTER_COPY === 'true';
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prisma = new PrismaClient();
|
||||
const remote = new SshRecordingClient({ host: remoteHost, sshConfig, remoteReadyDir });
|
||||
const service = new RecordingTransferService(remote, new PrismaRecordingStore(prisma), {
|
||||
localRoot,
|
||||
deleteSourceAfterCopy,
|
||||
maxFilesPerScan
|
||||
});
|
||||
|
||||
await prisma.$connect();
|
||||
logger.info(
|
||||
{
|
||||
remoteHost,
|
||||
remoteReadyDir,
|
||||
localRoot,
|
||||
scanIntervalMs,
|
||||
maxFilesPerScan,
|
||||
deleteSourceAfterCopy
|
||||
},
|
||||
'Recording worker started'
|
||||
);
|
||||
|
||||
while (!shuttingDown) {
|
||||
const result = await service.scanOnce();
|
||||
if (result.moved || result.failed || result.skipped) {
|
||||
logger.info(result, 'Recording scan completed');
|
||||
}
|
||||
await sleep(scanIntervalMs);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('Recording worker stopping');
|
||||
shuttingDown = true;
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('Recording worker stopping');
|
||||
shuttingDown = true;
|
||||
});
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
logger.error({ error }, 'Recording worker failed');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
callIdFromStorageKey,
|
||||
normalizeRemoteRelativePath,
|
||||
type PulledRecordingFile,
|
||||
type RecordingStore,
|
||||
RecordingTransferService,
|
||||
type RemoteRecordingClient,
|
||||
type RemoteRecordingFile,
|
||||
storageKeyFromReadyPath
|
||||
} from './transfer.js';
|
||||
|
||||
class MemoryRemote implements RemoteRecordingClient {
|
||||
readonly deleted: string[] = [];
|
||||
|
||||
constructor(private readonly files: Map<string, Buffer>) {}
|
||||
|
||||
async listReadyFiles(): Promise<RemoteRecordingFile[]> {
|
||||
return Array.from(this.files.entries()).map(([relativePath, content]) => ({
|
||||
relativePath,
|
||||
bytes: content.length
|
||||
}));
|
||||
}
|
||||
|
||||
async pullFile(file: RemoteRecordingFile, localPath: string): Promise<void> {
|
||||
const content = this.files.get(file.relativePath);
|
||||
if (!content) {
|
||||
throw new Error('missing remote file');
|
||||
}
|
||||
await writeFile(localPath, content);
|
||||
}
|
||||
|
||||
async deleteFile(file: RemoteRecordingFile): Promise<void> {
|
||||
this.deleted.push(file.relativePath);
|
||||
this.files.delete(file.relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryStore implements RecordingStore {
|
||||
readonly ready: PulledRecordingFile[] = [];
|
||||
|
||||
async markReady(input: { storageKey: string; storagePath: string; sourceKey: string; sha256: string; bytes: bigint }): Promise<void> {
|
||||
this.ready.push({
|
||||
relativePath: input.sourceKey,
|
||||
bytes: Number(input.bytes),
|
||||
localPath: input.storagePath,
|
||||
sha256: input.sha256
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('recording transfer', () => {
|
||||
it('normalizes ready paths safely', () => {
|
||||
expect(normalizeRemoteRelativePath('/2026/06/21/call.wav.ready')).toBe('2026/06/21/call.wav.ready');
|
||||
expect(storageKeyFromReadyPath('2026/06/21/call.wav.ready')).toBe('2026/06/21/call.wav');
|
||||
expect(() => normalizeRemoteRelativePath('../call.wav.ready')).toThrow(/Unsafe/);
|
||||
expect(() => storageKeyFromReadyPath('2026/06/21/call.wav')).toThrow(/ready/);
|
||||
});
|
||||
|
||||
it('derives call IDs from RTPEngine recording names', () => {
|
||||
expect(callIdFromStorageKey('2026/06/22/s28-1782091361841-kntoq1y9%40lisglosips-t-c68d7ba1de875f62-mix.wav')).toBe(
|
||||
's28-1782091361841-kntoq1y9@lisglosips-t'
|
||||
);
|
||||
});
|
||||
|
||||
it('copies, hashes, marks ready, and deletes source only after verification', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-'));
|
||||
const remote = new MemoryRemote(new Map([['2026/06/21/call.wav.ready', Buffer.from('audio-bytes')]]));
|
||||
const store = new MemoryStore();
|
||||
const service = new RecordingTransferService(remote, store, {
|
||||
localRoot: root,
|
||||
deleteSourceAfterCopy: true,
|
||||
maxFilesPerScan: 10
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await service.scanOnce();
|
||||
expect(result).toEqual({ moved: 1, skipped: 0, failed: 0 });
|
||||
expect(store.ready).toHaveLength(1);
|
||||
expect(store.ready[0]?.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(remote.deleted).toEqual(['2026/06/21/call.wav.ready']);
|
||||
expect(await readFile(path.join(root, '2026/06/21/call.wav'), 'utf8')).toBe('audio-bytes');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps source file when local size verification fails', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-'));
|
||||
const remote = new MemoryRemote(new Map([['bad.wav.ready', Buffer.from('abc')]]));
|
||||
const originalList = remote.listReadyFiles.bind(remote);
|
||||
remote.listReadyFiles = async () => (await originalList()).map((file) => ({ ...file, bytes: file.bytes + 1 }));
|
||||
const store = new MemoryStore();
|
||||
const service = new RecordingTransferService(remote, store, {
|
||||
localRoot: root,
|
||||
deleteSourceAfterCopy: true,
|
||||
maxFilesPerScan: 10
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await service.scanOnce();
|
||||
expect(result).toEqual({ moved: 0, skipped: 0, failed: 1 });
|
||||
expect(store.ready).toHaveLength(0);
|
||||
expect(remote.deleted).toHaveLength(0);
|
||||
await expect(stat(path.join(root, 'bad.wav'))).rejects.toThrow();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdir, rename, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { PrismaClient } from '@lisglosips/database';
|
||||
|
||||
export interface RemoteRecordingFile {
|
||||
relativePath: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface PulledRecordingFile extends RemoteRecordingFile {
|
||||
localPath: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface RemoteRecordingClient {
|
||||
listReadyFiles(): Promise<RemoteRecordingFile[]>;
|
||||
pullFile(file: RemoteRecordingFile, localPath: string): Promise<void>;
|
||||
deleteFile(file: RemoteRecordingFile): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RecordingStore {
|
||||
markReady(input: {
|
||||
storageKey: string;
|
||||
storagePath: string;
|
||||
sourceKey: string;
|
||||
sha256: string;
|
||||
bytes: bigint;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RecordingTransferOptions {
|
||||
localRoot: string;
|
||||
deleteSourceAfterCopy: boolean;
|
||||
maxFilesPerScan: number;
|
||||
}
|
||||
|
||||
export interface RecordingTransferResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export class RecordingTransferService {
|
||||
constructor(
|
||||
private readonly remote: RemoteRecordingClient,
|
||||
private readonly store: RecordingStore,
|
||||
private readonly options: RecordingTransferOptions
|
||||
) {}
|
||||
|
||||
async scanOnce(): Promise<RecordingTransferResult> {
|
||||
const files = (await this.remote.listReadyFiles()).slice(0, this.options.maxFilesPerScan);
|
||||
const result: RecordingTransferResult = { moved: 0, skipped: 0, failed: 0 };
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
await this.moveOne(file);
|
||||
result.moved += 1;
|
||||
} catch {
|
||||
result.failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async moveOne(file: RemoteRecordingFile): Promise<void> {
|
||||
const sourceKey = normalizeRemoteRelativePath(file.relativePath);
|
||||
const storageKey = storageKeyFromReadyPath(sourceKey);
|
||||
const finalPath = safeJoin(this.options.localRoot, storageKey);
|
||||
const tmpPath = `${finalPath}.part-${randomUUID()}`;
|
||||
|
||||
await mkdir(path.dirname(finalPath), { recursive: true });
|
||||
await this.remote.pullFile({ ...file, relativePath: sourceKey }, tmpPath);
|
||||
|
||||
const localStat = await stat(tmpPath);
|
||||
if (localStat.size !== file.bytes) {
|
||||
await rm(tmpPath, { force: true });
|
||||
throw new Error(`Recording size mismatch for ${sourceKey}`);
|
||||
}
|
||||
|
||||
const sha256 = await sha256File(tmpPath);
|
||||
await rename(tmpPath, finalPath);
|
||||
await this.store.markReady({
|
||||
storageKey,
|
||||
storagePath: finalPath,
|
||||
sourceKey,
|
||||
sha256,
|
||||
bytes: BigInt(localStat.size)
|
||||
});
|
||||
|
||||
if (this.options.deleteSourceAfterCopy) {
|
||||
await this.remote.deleteFile({ ...file, relativePath: sourceKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PrismaRecordingStore implements RecordingStore {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async markReady(input: {
|
||||
storageKey: string;
|
||||
storagePath: string;
|
||||
sourceKey: string;
|
||||
sha256: string;
|
||||
bytes: bigint;
|
||||
}): Promise<void> {
|
||||
const callId = callIdFromStorageKey(input.storageKey);
|
||||
const rawCdr = await this.prisma.rawCdr.findFirst({
|
||||
where: {
|
||||
OR: [{ recordingKey: input.storageKey }, { recordingKey: input.sourceKey }, ...(callId ? [{ callId }] : [])]
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
await this.prisma.recording.upsert({
|
||||
where: { storageKey: input.storageKey },
|
||||
create: {
|
||||
id: prefixedId('rec'),
|
||||
rawCdrId: rawCdr?.id,
|
||||
storageKey: input.storageKey,
|
||||
storagePath: input.storagePath,
|
||||
sha256: input.sha256,
|
||||
bytes: input.bytes,
|
||||
durationSec: 0,
|
||||
status: 'READY',
|
||||
movedAt: new Date()
|
||||
},
|
||||
update: {
|
||||
rawCdrId: rawCdr?.id,
|
||||
storagePath: input.storagePath,
|
||||
sha256: input.sha256,
|
||||
bytes: input.bytes,
|
||||
status: 'READY',
|
||||
movedAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface SshRecordingClientOptions {
|
||||
host: string;
|
||||
sshConfig?: string;
|
||||
remoteReadyDir: string;
|
||||
}
|
||||
|
||||
export class SshRecordingClient implements RemoteRecordingClient {
|
||||
constructor(private readonly options: SshRecordingClientOptions) {}
|
||||
|
||||
async listReadyFiles(): Promise<RemoteRecordingFile[]> {
|
||||
const command = `find ${shellQuote(this.options.remoteReadyDir)} -type f -name '*.ready' -printf '%P\\t%s\\n'`;
|
||||
const stdout = await runCapture(this.sshArgs(command));
|
||||
return stdout
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [relativePath, sizeText] = line.split('\t');
|
||||
return {
|
||||
relativePath: normalizeRemoteRelativePath(relativePath),
|
||||
bytes: Number.parseInt(sizeText, 10)
|
||||
};
|
||||
})
|
||||
.filter((file) => Number.isSafeInteger(file.bytes) && file.bytes >= 0);
|
||||
}
|
||||
|
||||
async pullFile(file: RemoteRecordingFile, localPath: string): Promise<void> {
|
||||
const remotePath = remotePathFor(this.options.remoteReadyDir, file.relativePath);
|
||||
const command = `cat -- ${shellQuote(remotePath)}`;
|
||||
await pipeline(runStream(this.sshArgs(command)), createWriteStream(localPath, { mode: 0o640 }));
|
||||
}
|
||||
|
||||
async deleteFile(file: RemoteRecordingFile): Promise<void> {
|
||||
const remotePath = remotePathFor(this.options.remoteReadyDir, file.relativePath);
|
||||
await runCapture(this.sshArgs(`rm -- ${shellQuote(remotePath)}`));
|
||||
}
|
||||
|
||||
private sshArgs(command: string): string[] {
|
||||
const args = [];
|
||||
if (this.options.sshConfig) {
|
||||
args.push('-F', this.options.sshConfig);
|
||||
}
|
||||
args.push(this.options.host, command);
|
||||
return args;
|
||||
}
|
||||
}
|
||||
|
||||
export function storageKeyFromReadyPath(relativePath: string): string {
|
||||
const normalized = normalizeRemoteRelativePath(relativePath);
|
||||
if (!normalized.endsWith('.ready')) {
|
||||
throw new Error('Recording source must end with .ready');
|
||||
}
|
||||
return normalized.slice(0, -'.ready'.length);
|
||||
}
|
||||
|
||||
export function callIdFromStorageKey(storageKey: string): string | null {
|
||||
const filename = normalizeRemoteRelativePath(storageKey).split('/').at(-1);
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
const decoded = decodeURIComponent(filename);
|
||||
const match = /^(?<callId>.+)-[a-f0-9]{16}-(?:mix|tag-\d+)\.(?:wav|mp3)$/i.exec(decoded);
|
||||
return match?.groups?.callId ?? null;
|
||||
}
|
||||
|
||||
export function normalizeRemoteRelativePath(relativePath: string): string {
|
||||
const normalized = relativePath.replaceAll('\\', '/').replace(/^\/+/, '');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
if (parts.length === 0 || parts.some((part) => part === '.' || part === '..' || part.includes('\0'))) {
|
||||
throw new Error('Unsafe recording relative path');
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function safeJoin(root: string, relativePath: string): string {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const resolvedPath = path.resolve(resolvedRoot, relativePath);
|
||||
if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${path.sep}`)) {
|
||||
throw new Error('Recording path escapes local root');
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function remotePathFor(baseDir: string, relativePath: string): string {
|
||||
return `${baseDir.replace(/\/+$/, '')}/${normalizeRemoteRelativePath(relativePath)}`;
|
||||
}
|
||||
|
||||
async function sha256File(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
await pipeline(createReadStream(filePath), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function runCapture(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(Buffer.concat(stdout).toString('utf8'));
|
||||
return;
|
||||
}
|
||||
reject(new Error(Buffer.concat(stderr).toString('utf8') || `ssh exited with ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runStream(args: string[]): NodeJS.ReadableStream {
|
||||
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const stderr: Buffer[] = [];
|
||||
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
child.stdout.destroy(new Error(Buffer.concat(stderr).toString('utf8') || `ssh exited with ${code}`));
|
||||
}
|
||||
});
|
||||
return child.stdout;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function prefixedId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID().replaceAll('-', '').slice(0, 32)}`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../../packages/database" },
|
||||
{ "path": "../../packages/observability" }
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user