diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..ebecd1c --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@p42:registry=https://gitea.internike.com/api/packages/P42/npm/ diff --git a/GPS/actions/arena/worldline.js b/GPS/actions/arena/worldline.js index b787778..115067a 100644 --- a/GPS/actions/arena/worldline.js +++ b/GPS/actions/arena/worldline.js @@ -19,7 +19,7 @@ export function positionAt(agent, t) { }) } -function distanceBetween(agentA, agentB, t) { +export function distanceBetween(agentA, agentB, t) { const a = positionAt(agentA, t) const b = positionAt(agentB, t) const dx = a.x - b.x diff --git a/SimMaestro/maestroServer.js b/SimMaestro/maestroServer.js index 36601f0..e4b3488 100644 --- a/SimMaestro/maestroServer.js +++ b/SimMaestro/maestroServer.js @@ -1,5 +1,5 @@ import { AccesRights } from '../accesRights.js' -import { createMysqlPool } from '../mysqlClient.js' +import { MySQLClient } from '@p42/p42modules' import { SimRepository } from './simRepository.js' import { ArenaGroom } from './arenaGroom.js' import { MaestroState } from './orchestrationState.js' @@ -61,8 +61,8 @@ export class maestroServer { console.error('[Maestro] Missing mysql config') return(false) } - this.db = await createMysqlPool(mysqlCfg) - this.simRepo = new SimRepository(this.db, this.debug) + this.db = await MySQLClient.createPool(mysqlCfg) + this.simRepo = new SimRepository(this.db, MySQLClient.resolveDatabases(mysqlCfg), this.debug) if(this.debug) console.log('[Maestro] MySQL pool ready') return(true) } diff --git a/SimMaestro/simRepository.js b/SimMaestro/simRepository.js index 6b412d1..f7b79d6 100644 --- a/SimMaestro/simRepository.js +++ b/SimMaestro/simRepository.js @@ -1,4 +1,4 @@ -import { mysqlExecute } from '../mysqlClient.js' +import { MySQLClient } from '@p42/p42modules' const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @@ -6,46 +6,52 @@ export function isValidUuid(val) { return(typeof(val) === 'string' && UUID_RE.test(val)) } -function parseGpsValues(raw) { - let v = raw - if(v == null) return(null) - if(typeof(v) === 'string') { - try { v = JSON.parse(v) } - catch { return(null) } - } - if(typeof(v) !== 'object') return(null) - const position = v.position - const speed = v.speed ?? v.vector - if(!position || !speed) return(null) - const axes = ['x', 'y', 'z'] - for(const axis of axes) { - if(typeof(position[axis]) !== 'number' || typeof(speed[axis]) !== 'number') return(null) - } - return({ - position: { x: position.x, y: position.y, z: position.z }, - vector: { x: speed.x, y: speed.y, z: speed.z }, - }) -} - export class SimRepository { - constructor(dbPool, debug = false) { + constructor(dbPool, databases, debug = false) { this.db = dbPool + this.guiDb = databases.guiDatabase + this.simDb = databases.simDatabase this.debug = debug } + #qualify(db, table) { + return(`\`${db}\`.${table}`) + } + + #parseGpsValues(raw) { + let v = raw + if(v == null) return(null) + if(typeof(v) === 'string') { + try { v = JSON.parse(v) } + catch { return(null) } + } + if(typeof(v) !== 'object') return(null) + const position = v.position + const speed = v.speed ?? v.vector + if(!position || !speed) return(null) + const axes = ['x', 'y', 'z'] + for(const axis of axes) { + if(typeof(position[axis]) !== 'number' || typeof(speed[axis]) !== 'number') return(null) + } + return({ + position: { x: position.x, y: position.y, z: position.z }, + vector: { x: speed.x, y: speed.y, z: speed.z }, + }) + } + async validateSimulationAccess(userUuid, simulationUuid, keyframeId) { if(!isValidUuid(userUuid)) return({ ok: false, err: 'Invalid user UUID' }) if(!isValidUuid(simulationUuid)) return({ ok: false, err: 'Invalid simulation UUID' }) if(!isValidUuid(keyframeId)) return({ ok: false, err: 'Invalid keyframe ID' }) - const rows = await mysqlExecute(this.db, ` + const rows = await MySQLClient.poolExecute(this.db, ` SELECT s.sim_id, BIN_TO_UUID(s.sim_uuid) AS sim_uuid, BIN_TO_UUID(s.sim_root_kf_uuid) AS sim_root_kf_uuid - FROM p42SIM.simulations s - INNER JOIN p42GUI.simowners o ON o.own_sim_uuid = s.sim_uuid - INNER JOIN p42GUI.users u ON o.own_usr_id = u.usr_id + FROM ${this.#qualify(this.simDb, 'simulations')} s + INNER JOIN ${this.#qualify(this.guiDb, 'simowners')} o ON o.own_sim_uuid = s.sim_uuid + INNER JOIN ${this.#qualify(this.guiDb, 'users')} u ON o.own_usr_id = u.usr_id WHERE u.usr_uuid = ? AND s.sim_uuid = UUID_TO_BIN(?) `, [userUuid, simulationUuid]) @@ -57,9 +63,9 @@ export class SimRepository { return({ ok: false, err: 'Keyframe does not match simulation root keyframe' }) } - const kfRows = await mysqlExecute(this.db, ` + const kfRows = await MySQLClient.poolExecute(this.db, ` SELECT ekf_uuid - FROM p42SIM.edited_keyframes + FROM ${this.#qualify(this.simDb, 'edited_keyframes')} WHERE ekf_uuid = UUID_TO_BIN(?) `, [keyframeId]) if(!kfRows.length) return({ ok: false, err: 'Keyframe not found' }) @@ -71,13 +77,13 @@ export class SimRepository { if(!isValidUuid(userUuid)) return({ ok: false, err: 'Invalid user UUID' }) if(!isValidUuid(simulationUuid)) return({ ok: false, err: 'Invalid simulation UUID' }) - const rows = await mysqlExecute(this.db, ` + const rows = await MySQLClient.poolExecute(this.db, ` SELECT s.sim_id, BIN_TO_UUID(s.sim_uuid) AS sim_uuid, BIN_TO_UUID(s.sim_root_kf_uuid) AS sim_root_kf_uuid - FROM p42SIM.simulations s - INNER JOIN p42GUI.simowners o ON o.own_sim_uuid = s.sim_uuid - INNER JOIN p42GUI.users u ON o.own_usr_id = u.usr_id + FROM ${this.#qualify(this.simDb, 'simulations')} s + INNER JOIN ${this.#qualify(this.guiDb, 'simowners')} o ON o.own_sim_uuid = s.sim_uuid + INNER JOIN ${this.#qualify(this.guiDb, 'users')} u ON o.own_usr_id = u.usr_id WHERE u.usr_uuid = ? AND s.sim_uuid = UUID_TO_BIN(?) `, [userUuid, simulationUuid]) @@ -87,9 +93,9 @@ export class SimRepository { } async loadKeyframeAgents(keyframeId) { - const rows = await mysqlExecute(this.db, ` + const rows = await MySQLClient.poolExecute(this.db, ` SELECT BIN_TO_UUID(ekfs_agent_id) AS agent_id, ekfs_gps_values - FROM p42SIM.edited_kf_store + FROM ${this.#qualify(this.simDb, 'edited_kf_store')} WHERE ekfs_ekf_uuid = UUID_TO_BIN(?) `, [keyframeId]) @@ -97,7 +103,7 @@ export class SimRepository { const errors = [] for(const row of rows) { - const parsed = parseGpsValues(row.ekfs_gps_values) + const parsed = this.#parseGpsValues(row.ekfs_gps_values) if(!parsed) { errors.push(`Invalid GPS values for agent ${row.agent_id}`) continue diff --git a/config.json b/config.json index 084978c..f715b78 100644 --- a/config.json +++ b/config.json @@ -63,7 +63,8 @@ }, "mysql": { "socketPath": "/var/run/mysqld/mysqld.sock", - "database": "p42GUI" + "guiDatabase": "p42GUI", + "simDatabase": "p42SIM" }, "observer": { "observerActionsChannel": "system:requests:observer", diff --git a/configSchema.json b/configSchema.json index 63a5c42..0596127 100644 --- a/configSchema.json +++ b/configSchema.json @@ -148,7 +148,8 @@ "socketPath": { "type": "string" }, "host": { "type": "string" }, "port": { "type": "integer" }, - "database": { "type": "string" }, + "guiDatabase": { "type": "string" }, + "simDatabase": { "type": "string" }, "connectionLimit": { "type": "integer", "minimum": 1 } }, "required": [] diff --git a/mysqlClient.js b/mysqlClient.js deleted file mode 100644 index 6211740..0000000 --- a/mysqlClient.js +++ /dev/null @@ -1,31 +0,0 @@ -import mysql from 'mysql2/promise' -import { loadP42Secrets } from './secretsLoader.js' - -export function resolveMysqlCredentials(config = {}) { - loadP42Secrets() - const user = process.env.mysql_user - const password = process.env.mysql_pass - if(!user || !password) { - throw new Error('Missing MySQL credentials: set mysql_user and mysql_pass in environment') - } - return({ - socketPath: config.socketPath, - host: config.host, - port: config.port, - user, - password, - database: config.database ?? 'p42GUI', - waitForConnections: true, - connectionLimit: config.connectionLimit ?? 5, - queueLimit: 0, - }) -} - -export async function createMysqlPool(config) { - return(await mysql.createPool(resolveMysqlCredentials(config))) -} - -export async function mysqlExecute(pool, query, values = []) { - const [rows] = await pool.execute(query, values) - return(rows) -} diff --git a/package.json b/package.json index 73b784a..8ffd89f 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "p42GodDaemons", "type": "module", "dependencies": { + "@p42/p42modules": "^0.1.0", "ajv": "^8.12.0", - "mysql2": "^3.11.0", "redis": "^4.3.0", "yargs": "^17.7.2" } diff --git a/secretsLoader.js b/secretsLoader.js deleted file mode 100644 index 8ec7b11..0000000 --- a/secretsLoader.js +++ /dev/null @@ -1,34 +0,0 @@ -import fs from 'fs' - -const DEFAULT_SECRETS_PATH = '/etc/p42/secrets.env' - -function stripQuotes(value) { - if( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - return(value.slice(1, -1)) - } - return(value) -} - -export function loadP42Secrets(filePath = DEFAULT_SECRETS_PATH) { - if(process.env.mysql_user && process.env.mysql_pass) return(true) - - if(!fs.existsSync(filePath)) return(false) - - const text = fs.readFileSync(filePath, 'utf8') - for(const rawLine of text.split('\n')) { - const line = rawLine.trim() - if(!line || line.startsWith('#')) continue - const eq = line.indexOf('=') - if(eq < 1) continue - const key = line.slice(0, eq).trim() - const value = stripQuotes(line.slice(eq + 1).trim()) - if(key === 'mysql_user' || key === 'mysql_pass') { - process.env[key] = value - } - } - - return(Boolean(process.env.mysql_user && process.env.mysql_pass)) -} diff --git a/tests/modules/maestro1.js b/tests/modules/maestro1.js new file mode 100644 index 0000000..f51b61e --- /dev/null +++ b/tests/modules/maestro1.js @@ -0,0 +1,196 @@ +import { MySQLClient } from '@p42/p42modules' +import { SimRepository } from '../../SimMaestro/simRepository.js' + +function agentHashKey(template, agentId) { + return(template.replace(/\[UID\]/g, agentId)) +} + +function parseJsonField(raw) { + if(raw == null) return(null) + if(typeof(raw) === 'object') return(raw) + try { return(JSON.parse(raw)) } + catch { return(null) } +} + +function vectorsEqual(a, b) { + for(const axis of ['x', 'y', 'z']) { + if(a[axis] !== b[axis]) return(false) + } + return(true) +} + +async function findSimulationFixture(ctx) { + const { guiDatabase, simDatabase } = ctx.databases + const rows = await MySQLClient.poolExecute(ctx.db, ` + SELECT u.usr_uuid AS user_uuid, + BIN_TO_UUID(s.sim_uuid) AS simulation_uuid, + BIN_TO_UUID(s.sim_root_kf_uuid) AS keyframe_id + FROM \`${guiDatabase}\`.users u + INNER JOIN \`${guiDatabase}\`.simowners o ON o.own_usr_id = u.usr_id + INNER JOIN \`${simDatabase}\`.simulations s ON o.own_sim_uuid = s.sim_uuid + INNER JOIN \`${simDatabase}\`.edited_kf_store ekfs ON ekfs.ekfs_ekf_uuid = s.sim_root_kf_uuid + GROUP BY u.usr_uuid, s.sim_uuid, s.sim_root_kf_uuid + HAVING COUNT(ekfs.ekfs_agent_id) > 0 + LIMIT 1 + `) + + if(!rows.length) { + throw(new Error( + 'No simulation fixture found in MySQL (need user-owned sim with root keyframe agents). ' + + 'Pass --userUuid, --simulationUuid, --keyframeId explicitly, or use --guiDatabase / --simDatabase for a test DB.' + )) + } + + return({ + userUuid: rows[0].user_uuid, + simulationUuid: rows[0].simulation_uuid, + keyframeId: rows[0].keyframe_id, + }) +} + +function waitForLifecycleEvent(ctx, eventType, timeoutMs) { + return(new Promise((resolve, reject) => { + const lifecyclePattern = ctx.config.maestro.lifecycle.arenaChannel + const timer = setTimeout(() => { + ctx.offArenaMessage(handler) + reject(new Error(`Timeout waiting for ${eventType} on ${lifecyclePattern} (${timeoutMs}ms)`)) + }, timeoutMs) + + const handler = (msg, chan) => { + if(!ctx.arenaCnx.matchesChan(chan, lifecyclePattern)) return + if(msg.eventType !== eventType) return + clearTimeout(timer) + ctx.offArenaMessage(handler) + resolve(msg) + } + + ctx.onArenaMessage(handler) + })) +} + +export function configureYargs(yargsBuilder) { + return(yargsBuilder.options({ + userUuid: { + describe: 'User UUID to send STARTSIMULATION as (auto-discovered if omitted)', + type: 'string', + }, + simulationUuid: { + describe: 'Simulation UUID (auto-discovered if omitted)', + type: 'string', + }, + keyframeId: { + describe: 'Root keyframe UUID (auto-discovered if omitted)', + type: 'string', + }, + timeout: { + describe: 'Milliseconds to wait for onYourMarks', + default: 15000, + type: 'number', + }, + })) +} + +export async function run(ctx) { + const { log, argv, config, systemCnx, arenaCnx } = ctx + const arenaStorage = config.gps?.arenaStorage ?? { + agentHashKey: 'arena:agents:[UID]', + agentsIndexKey: 'arena:agents', + } + + log('action', 'Resolving simulation fixture from MySQL...') + let userUuid = argv.userUuid + let simulationUuid = argv.simulationUuid + let keyframeId = argv.keyframeId + + if(!userUuid || !simulationUuid || !keyframeId) { + const fixture = await findSimulationFixture(ctx) + userUuid = userUuid ?? fixture.userUuid + simulationUuid = simulationUuid ?? fixture.simulationUuid + keyframeId = keyframeId ?? fixture.keyframeId + } + + log('action', `User: ${userUuid}`) + log('action', `Simulation: ${simulationUuid}`) + log('action', `Keyframe: ${keyframeId}`) + + const simRepo = new SimRepository(ctx.db, ctx.databases, false) + const access = await simRepo.validateSimulationAccess(userUuid, simulationUuid, keyframeId) + if(!access.ok) throw(new Error(`Simulation access check failed: ${access.err}`)) + + const agentsResult = await simRepo.loadKeyframeAgents(keyframeId) + if(!agentsResult.ok) throw(new Error(`Failed to load keyframe agents: ${agentsResult.err}`)) + if(!agentsResult.agents.length) throw(new Error('Keyframe has no agents with valid GPS values')) + + log('success', `Loaded ${agentsResult.agents.length} agent(s) from MySQL`) + + const expectedById = new Map(agentsResult.agents.map(a => [a.id, a])) + const lifecycleWait = waitForLifecycleEvent(ctx, 'onYourMarks', argv.timeout) + + const reqid = `maestro1-${Date.now()}` + const actionsChan = config.maestro.maestroActionsChannel + + log('action', `Publishing STARTSIMULATION on ${actionsChan} (reqid=${reqid})...`) + await systemCnx.redisPublish(actionsChan, { + action: 'STARTSIMULATION', + reqid, + sender: userUuid, + roles: ['*'], + payload: { + simulationUuid, + keyframeId, + infraId: null, + }, + }) + + log('action', `Waiting for onYourMarks on ${config.maestro.lifecycle.arenaChannel}...`) + const lifecycleMsg = await lifecycleWait + + const payload = lifecycleMsg.payload ?? {} + if(payload.simulationId !== simulationUuid) { + throw(new Error(`onYourMarks simulationId mismatch: expected ${simulationUuid}, got ${payload.simulationId}`)) + } + + log('success', `Received onYourMarks for simulationId=${payload.simulationId}`) + + log('action', 'Reading arena store...') + const indexIds = await arenaCnx.redisSmembers(arenaStorage.agentsIndexKey) + const expectedIds = [...expectedById.keys()].sort() + const actualIds = [...indexIds].sort() + + if(actualIds.length !== expectedIds.length) { + throw(new Error(`Agent index count mismatch: expected ${expectedIds.length}, got ${actualIds.length}`)) + } + + for(let i = 0; i < expectedIds.length; i++) { + if(actualIds[i] !== expectedIds[i]) { + throw(new Error(`Agent index mismatch at ${i}: expected ${expectedIds[i]}, got ${actualIds[i]}`)) + } + } + + log('success', `Arena agents index contains ${actualIds.length} agent(s)`) + + for(const agentId of expectedIds) { + const key = agentHashKey(arenaStorage.agentHashKey, agentId) + const hash = await arenaCnx.redisHgetall(key) + const expected = expectedById.get(agentId) + + const position = parseJsonField(hash.position) + const vector = parseJsonField(hash.vector) + + if(!position || !vector) { + throw(new Error(`Agent ${agentId}: missing position or vector in arena hash ${key}`)) + } + + if(!vectorsEqual(position, expected.position)) { + throw(new Error(`Agent ${agentId}: position mismatch (MySQL vs arena store)`)) + } + + if(!vectorsEqual(vector, expected.vector)) { + throw(new Error(`Agent ${agentId}: vector mismatch (MySQL vs arena store)`)) + } + + log('success', `Agent ${agentId}: position and vector match MySQL`) + } + + log('success', 'Arena store seeded correctly from MySQL (Maestro will stall waiting for readyToStart without GPS)') +} diff --git a/tests/test.js b/tests/test.js new file mode 100644 index 0000000..ad84e88 --- /dev/null +++ b/tests/test.js @@ -0,0 +1,179 @@ +import yargs from 'yargs/yargs' +import { hideBin } from 'yargs/helpers' +import { pathToFileURL } from 'url' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import { RedisConnexion } from '../redisConnexion.js' +import { configHelper } from '../configHelper.js' +import { MySQLClient } from '@p42/p42modules' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const LOG_COLORS = { + action: '\x1b[37m', + success: '\x1b[32m', + error: '\x1b[31m', + reset: '\x1b[0m', +} + +function createTestMesh(handlers) { + return({ + dispatchMessage(_cnx, msg, chan) { + for(const handler of handlers) handler(msg, chan) + }, + }) +} + +function buildBaseParser() { + return(yargs(hideBin(process.argv)) + .usage('$0 [options]') + .command('$0 ', 'Run a GodDaemons test module', y => y + .positional('module', { + describe: 'Test module name (e.g. maestro1)', + type: 'string', + }) + ) + .options({ + config: { + alias: 'c', + describe: 'Path to config.json', + default: join(__dirname, '..', 'config.json'), + type: 'string', + }, + guiDatabase: { + describe: 'Override mysql.guiDatabase for this run', + type: 'string', + }, + simDatabase: { + describe: 'Override mysql.simDatabase for this run', + type: 'string', + }, + }) + .help() + .version(false) + ) +} + +function meshRedisConn(mesh, meshName, rootConfig, meshModule) { + const { redis, ...meshConfig } = mesh + return(redis.map(cfg => + new RedisConnexion({ + debug: false, + config: { ...cfg, ...meshConfig, ...rootConfig }, + redisId: cfg.redisId, + meshName, + meshModule, + }) + )) +} + +async function loadTestModule(moduleName) { + const modulePath = join(__dirname, 'modules', `${moduleName}.js`) + try { + return(await import(pathToFileURL(modulePath).href)) + } catch(err) { + if(err.code === 'ERR_MODULE_NOT_FOUND') { + throw(new Error(`Test module not found: ${moduleName} (expected ${modulePath})`)) + } + throw(err) + } +} + +async function main() { + const preArgv = buildBaseParser().parseSync() + const moduleName = preArgv.module ?? preArgv._[0] + if(!moduleName) { + buildBaseParser().showHelp() + process.exit(1) + } + + const testModule = await loadTestModule(moduleName) + + let parser = buildBaseParser() + if(typeof(testModule.configureYargs) === 'function') { + parser = testModule.configureYargs(parser) + } + const argv = parser.parseSync() + const resolvedModule = argv.module ?? argv._[0] + + const cfgh = new configHelper({ localfile: argv.config }) + await cfgh.fetchConfigFile() + const config = cfgh.config + + if(argv.guiDatabase) config.mysql.guiDatabase = argv.guiDatabase + if(argv.simDatabase) config.mysql.simDatabase = argv.simDatabase + + const arenaHandlers = new Set() + const testMesh = createTestMesh(arenaHandlers) + + const systemConns = meshRedisConn(config.systemMesh, 'system', config, null) + const arenaConns = meshRedisConn(config.arenaMesh, 'arena', config, testMesh) + const systemCnx = systemConns.find(c => c.redisConfig.role === 'primary') ?? systemConns[0] + const arenaCnx = arenaConns.find(c => c.redisConfig.role === 'primary') ?? arenaConns[0] + + const log = (type, message) => { + const color = LOG_COLORS[type] ?? LOG_COLORS.action + console.log(`${color}${message}${LOG_COLORS.reset}`) + } + + log('action', `Loading test module: ${resolvedModule}`) + log('action', `Config: ${argv.config}`) + + const databases = MySQLClient.resolveDatabases(config.mysql) + if(argv.guiDatabase || argv.simDatabase) { + log('action', `MySQL databases: gui=${databases.guiDatabase}, sim=${databases.simDatabase}`) + } + + log('action', 'Connecting to Redis (system)...') + await systemCnx.redisLogin() + log('success', `System Redis connected (${systemCnx.redisConfig.host}:${systemCnx.redisConfig.port})`) + + log('action', 'Connecting to Redis (arena)...') + await arenaCnx.redisLogin() + await arenaCnx.redisChansStart() + log('success', `Arena Redis connected (${arenaCnx.redisConfig.host}:${arenaCnx.redisConfig.port})`) + + log('action', 'Connecting to MySQL...') + const db = await MySQLClient.createPool(config.mysql) + log('success', 'MySQL pool ready') + + const ctx = { + argv, + config, + databases, + db, + systemCnx, + arenaCnx, + log, + onArenaMessage(handler) { + arenaHandlers.add(handler) + }, + offArenaMessage(handler) { + arenaHandlers.delete(handler) + }, + } + + let exitCode = 0 + try { + if(typeof(testModule.run) !== 'function') { + throw(new Error(`Test module ${resolvedModule} must export a run(ctx) function`)) + } + await testModule.run(ctx) + log('success', `Test module ${resolvedModule} finished`) + } catch(err) { + log('error', err.message ?? String(err)) + exitCode = 1 + } finally { + await db.end() + await systemCnx.redisClient.quit() + await arenaCnx.redisClient.quit() + if(arenaCnx.redisSubscriber) await arenaCnx.redisSubscriber.quit() + } + + process.exit(exitCode) +} + +main().catch(err => { + console.error(`${LOG_COLORS.error}${err.message ?? err}${LOG_COLORS.reset}`) + process.exit(1) +})