diff --git a/src/lib/server/fleet/launch-recipes.ts b/src/lib/server/fleet/launch-recipes.ts index 9e92a2f..14442e2 100644 --- a/src/lib/server/fleet/launch-recipes.ts +++ b/src/lib/server/fleet/launch-recipes.ts @@ -76,12 +76,12 @@ export function captureRecipe(game: ZombieGame, mapSlug: string, rconPassword?: } /** Preview and admission keep file references; execution captures the current bytes. */ -export function captureRecipePlan(game: ZombieGame, mapSlug: string): LaunchRecipePlan { - return buildRecipe(game, mapSlug, false); +export function captureRecipePlan(game: ZombieGame, mapSlug: string, rconPassword?: string): LaunchRecipePlan { + return buildRecipe(game, mapSlug, false, rconPassword); } function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: true, rconPassword?: string): LaunchRecipe; -function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: false): LaunchRecipePlan; +function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: false, rconPassword?: string): LaunchRecipePlan; function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: boolean, rconPassword?: string): LaunchRecipePlan { const map = zombieMapBySlug(game, mapSlug); if (!map) throw new Error('Choose a supported game and map.'); diff --git a/src/lib/server/provisioning/native.ts b/src/lib/server/provisioning/native.ts index c44c2cc..4860e33 100644 --- a/src/lib/server/provisioning/native.ts +++ b/src/lib/server/provisioning/native.ts @@ -683,6 +683,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) { if (!current || !['queued','waiting','interrupted'].includes(current.state) || eligibility(job)) return; let prepared: PreparedNativeLaunch | undefined; let preparedKey: string | null = null; + let preparedPassword: string | undefined; try { if (job.action === 'create' || job.action === 'rebuild') { if (count(['create','rebuild']) + otherPreparations(job.id) >= fleetSettings().createConcurrency) { @@ -696,8 +697,9 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) { const prior = recipeForDeployment(job.id)!.recipe as NativeRecipe; deploymentStage(job.id, 'Preparing launch files for the host agent', { phase: 'prepare', checkpoint: { key: 'startup-queue', label: 'Startup capacity available' } }); + preparedPassword = config.rcon.password || randomBytes(32).toString('hex'); prepared = await prepareNativeLaunch({ game: prior.game, mapSlug: prior.mapSlug, native: prior.native, - instance, serverKey: preparedKey, rconPassword: config.rcon.password || randomBytes(32).toString('hex'), + instance, serverKey: preparedKey, rconPassword: preparedPassword, rebuild: job.action === 'rebuild' && !migration, safetyOverride: overrideSafety }); if (job.action === 'rebuild' && !migration && !await checkOccupancy()) return; } @@ -728,8 +730,9 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) { } if (job.action === 'destroy' && count(['destroy']) >= fleetSettings().destroyConcurrency) { hold(job, 'Waiting for destruction capacity.'); return; } if (prepared) { + // Compare the same runtime config bytes while rechecking current promoted files. if (getServerKey(job.server) !== preparedKey || !sameLaunchFiles(prepared.recipe, - captureRecipePlan(prepared.recipe.game, prepared.recipe.mapSlug))) { + captureRecipePlan(prepared.recipe.game, prepared.recipe.mapSlug, preparedPassword))) { hold(job, 'Launch files or credentials changed during preparation; preparing the current selection next pass.'); return; } assertReadyLibrary(instance!.host_id, instance!.library_id); diff --git a/tests/unit/native-launch-preparation.test.ts b/tests/unit/native-launch-preparation.test.ts index e71d55f..0383693 100644 --- a/tests/unit/native-launch-preparation.test.ts +++ b/tests/unit/native-launch-preparation.test.ts @@ -179,17 +179,23 @@ describe('asynchronous native launch preparation', () => { expect(db().prepare('SELECT state FROM server_deployments WHERE id=?').get(job.deploymentId)).toEqual({ state: 'cancelled' }); }); - it('discards prepared bytes when the promoted version changes before dispatch', async () => { + it.each(['baseline', 'config', 'script', 'plugin'] as const)('discards prepared bytes when the promoted %s changes before dispatch', async kind => { const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); - const { script, version } = createScript({ name: 'Launch plugin', kind: 'plugin', targetRelPath: 'fixture.dll', - scopeType: 'game', scopeValue: 't5', content: Buffer.from('before promotion') }); + const baseline = createScript({ name: 'Launch baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't5', + content: Buffer.from('set rcon_password "password"\nset sv_voice "1"\n') }); + db().prepare('UPDATE scripts SET current_version_id=? WHERE id=?').run(baseline.version.id, baseline.script.id); + const isConfig = kind === 'baseline' || kind === 'config'; + const { script, version } = kind === 'baseline' ? baseline : createScript({ name: `Launch ${kind}`, kind, + targetRelPath: kind === 'script' ? 'fixture.gsc' : kind === 'plugin' ? 'fixture.dll' : undefined, + scopeType: kind === 'config' ? 'map' : 'game', scopeValue: kind === 'config' ? 'zombie_theater' : 't5', + content: Buffer.from(isConfig ? 'set sv_voice "1"\n' : 'before promotion') }); db().prepare('UPDATE scripts SET current_version_id=? WHERE id=?').run(version.id, script.id); const job = await queue(); const paused = await pausePreparation(); const pass = runPass(); await paused.ready; - const content = Buffer.from('newly promoted plugin'); + const content = Buffer.from(isConfig ? 'set rcon_password "password"\nset sv_voice "0"\n' : 'newly promoted file'); const latest = uploadNewVersion(script.id, content); db().prepare('UPDATE scripts SET current_version_id=? WHERE id=?').run(latest.id, script.id); paused.release(); @@ -200,7 +206,30 @@ describe('asynchronous native launch preparation', () => { expect(operation).toBeDefined(); const { getHostOperation } = await import('../../src/lib/server/hosts/index.js'); const payload = getHostOperation(operation!.id)!.payload as { files: { contentBase64: string; sha256: string }[] }; - expect(payload.files).toContainEqual(expect.objectContaining({ contentBase64: content.toString('base64'), sha256: latest.sha256 })); + if (isConfig) { + const { getPromotedServerConfig } = await import('../../src/lib/server/fleet/server-config.js'); + const expected = getPromotedServerConfig('t5', 'zombie_theater', 'theater', 'test-password')!; + expect(payload.files).toContainEqual(expect.objectContaining({ contentBase64: expected.toString('base64') })); + expect(expected.toString()).toContain('set sv_voice "0"'); + } else expect(payload.files).toContainEqual(expect.objectContaining({ contentBase64: content.toString('base64'), sha256: latest.sha256 })); + }); + + it('discards prepared bytes when the server key changes before dispatch', async () => { + const job = await queue(); + const paused = await pausePreparation(); + const pass = runPass(); + await paused.ready; + const { db } = await import('../../src/lib/server/db/index.js'); + db().prepare(`UPDATE server_keys SET pluto_key='changed-key' WHERE id IN + (SELECT key_id FROM server_key_leases WHERE server_id=? AND is_current=1)`).run(job.name); + paused.release(); + await pass; + expect(await operationFor(job.deploymentId)).toBeUndefined(); + await runPass(); + const operation = await operationFor(job.deploymentId); + expect(operation).toBeDefined(); + const { getHostOperation } = await import('../../src/lib/server/hosts/index.js'); + expect(getHostOperation(operation!.id)!.payload).toMatchObject({ env: { PLUTO_SERVER_KEY: 'changed-key' } }); }); it('prepares only one launch while the single startup slot is reserved', async () => { diff --git a/tests/unit/native-provisioning.test.ts b/tests/unit/native-provisioning.test.ts index 24e4278..4aec478 100644 --- a/tests/unit/native-provisioning.test.ts +++ b/tests/unit/native-provisioning.test.ts @@ -84,6 +84,40 @@ async function observations(server: string) { return [serverObservation(row), observationReader()(row), new MonitoringSnapshot().observe(row)]; } +describe('runtime configuration at dispatch', () => { + it.each([ + ['t5', 'shared'], ['t5', 'generated'], ['t6', 'shared'], ['t6', 'generated'] + ] as const)('dispatches %s with a promoted baseline and %s RCON password', async (game, source) => { + const { config, configFileForGame } = await import('../../src/lib/server/config.js'); + if (source === 'generated') Object.assign(config.rcon, { password: '' }); + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const { script, version } = createScript({ name: 'Launch baseline', kind: 'baseline', scopeType: 'game', scopeValue: game, + content: Buffer.from('set rcon_password "password"\nset sv_voice "1"\n') }); + db().prepare('UPDATE scripts SET current_version_id=? WHERE id=?').run(version.id, script.id); + + const job = await queue(game); + await advance(); + const operation = await operationFor(job.deploymentId); + expect(operation).toMatchObject({ kind: 'instance.create', state: 'pending' }); + const payload = operation!.payload as { rconPassword: string; files: { path: string; contentBase64: string; sha256: string }[] }; + expect(payload.rconPassword).toEqual(source === 'shared' ? 'test-password' : expect.stringMatching(/^[a-f0-9]{64}$/)); + const configFile = configFileForGame(game); + const file = payload.files.find(file => file.path === `configs/${configFile}`)!; + const content = Buffer.from(file.contentBase64, 'base64'); + expect(content.toString()).toContain(`set rcon_password "${payload.rconPassword}"`); + expect(content.toString()).not.toContain('set rcon_password "password"'); + const hash = createHash('sha256').update(content).digest('hex'); + expect(file.sha256).toBe(hash); + const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); + expect(getServerScript(job.name, configFile)).toMatchObject({ sha256: hash, size: content.length, source: 'native-create' }); + const { recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js'); + expect(recipeForDeployment(job.deploymentId)!.recipe.files).toContainEqual(expect.objectContaining({ + path: configFile, sha256: hash, size: content.length, content: content.toString('base64') + })); + }); +}); + describe('provisioning health handoff', () => { it.each(['create', 'start', 'restart', 'rebuild'] as const)('%s publishes verified game health before completion becomes visible', async action => { const job = await queue(); await advance();