Files
electric-cherry/src/lib/server/provisioning/launch-worker.ts
T
2026-10-15 21:33:50 -04:00

167 lines
7.4 KiB
TypeScript

import { parentPort } from 'node:worker_threads';
import { posix } from 'node:path';
import { applyActivationFiles } from './activation-files.js';
import { db } from '../db/index.js';
import { relDestForKind } from '../config.js';
import {
captureRecipe,
recipeForDeployment,
summarizeRecipe,
type LaunchRecipe
} from '../fleet/launch-recipes.js';
import { sha256Buffer } from '../fleet/files.js';
import type { NativeLaunchInput } from './launch-preparation.js';
import { choosePlacement, type PlacementOptions } from '../hosts/capacity.js';
import { isPrivateConfigPath } from '../fleet/private-config.js';
parentPort!.on(
'message',
(
message:
| { id: number; kind: 'launch'; input: NativeLaunchInput }
| { id: number; kind: 'placement'; input: PlacementOptions }
) => {
const { id } = message;
try {
if (message.kind === 'placement') {
const decision = db().transaction(() => choosePlacement(message.input))();
parentPort!.postMessage({
id,
value: { hostId: decision.host.id, lastHeartbeat: decision.host.lastHeartbeat }
});
return;
}
const input = message.input;
// All file versions and composed configuration belong to one snapshot.
const { recipe, password } = db().transaction(() => {
if (!input.frozenRecipeId) {
const recipe = {
...captureRecipe(input.game, input.mapSlug, input.rconPassword),
native: input.native,
image: input.instance.image
};
if (input.activationId) applyActivationFiles(recipe, input.activationId, true);
return { recipe, password: input.rconPassword };
}
const stored = recipeForDeployment(input.frozenRecipeId)?.recipe;
const migration = db()
.prepare(
'SELECT runtime_password FROM ec_host_migrations WHERE deployment_id=?'
)
.get(input.frozenRecipeId) as { runtime_password: string | null } | undefined;
if (
!stored ||
!migration?.runtime_password ||
stored.files.some((file) => file.content === undefined)
) {
throw new Error(
'The preserved deployed recipe or runtime credentials are unavailable.'
);
}
for (const file of stored.files) {
const bytes = Buffer.from(file.content!, 'base64');
if (bytes.length !== file.size || sha256Buffer(bytes) !== file.sha256)
throw new Error('Preserved deployed file verification failed.');
}
return {
recipe: {
...stored,
native: input.native,
image: input.instance.image
} as LaunchRecipe,
password: migration.runtime_password
};
})();
const instance = input.instance;
// Credential files travel only in this host's private operation payload, never
// shared recipes, promoted files, or the content-addressed download store.
const bankPath = posix.join(
relDestForKind('config', recipe.game),
'electric_cherry_bank.cfg'
);
if (
recipe.files.some((file) =>
isPrivateConfigPath(
posix.join(relDestForKind(file.kind, recipe.game), file.path),
recipe.game
)
)
)
throw new Error(
'Private configuration files are reserved for instance credentials.'
);
const payload = {
...(input.activationId ? { stageId: input.activationId } : {}),
name: instance.server,
game: recipe.game,
libraryId: instance.library_id,
image: instance.image,
port: instance.port,
bindAddress: instance.bind_address,
rconPassword: password,
bank: { token: input.bankToken, configPath: bankPath },
...(input.moderation ? { moderation: input.moderation } : {}),
env: {
PLUTAINER_SERVER_NAME: instance.server,
PLUTAINER_PORT: String(instance.port),
PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp',
PLUTAINER_CONFIG_FILE: recipe.configFile,
PLUTAINER_MAP_ROTATE: 'false',
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg${input.moderation ? ' +exec electric_cherry_moderation.cfg' : ''} +set tws_server_id ${instance.server} +map_rotate`,
PLUTO_SERVER_KEY: input.serverKey
},
files: recipe.files.map((file) => ({
path: posix.join(relDestForKind(file.kind, recipe.game), file.path),
contentSha256: file.sha256,
sha256: file.sha256
})),
...(input.rebuild
? {
nextGeneration: instance.generation + 1,
...(input.safetyOverride ? { safetyOverride: true } : {}),
...(input.unhealthyRecovery ? { unhealthyRecovery: true } : {})
}
: {})
};
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
const blobs = [
...new Map(
recipe.files.map((file) => [
file.sha256,
{
sha256: file.sha256,
content: new Uint8Array(Buffer.from(file.content, 'base64'))
}
])
).values()
];
const storedRecipe = {
...recipe,
files: recipe.files.map(({ content, ...file }) => ({
...file,
contentHash: file.sha256
}))
};
const recipeJson = encode(storedRecipe);
const payloadJson = encode(payload);
const plan = {
...recipe,
files: recipe.files.map(({ content: _content, ...file }) => file)
};
const recipeSummary = JSON.stringify(
summarizeRecipe(plan, sha256Buffer(Buffer.from(JSON.stringify(recipe))))
);
// Transfer ownership of the encoded bytes; do not clone giant strings or objects.
parentPort!.postMessage(
{ id, value: { recipe: plan, recipeSummary, recipeJson, payloadJson, blobs } },
[recipeJson.buffer, payloadJson.buffer, ...blobs.map((blob) => blob.content.buffer)]
);
} catch (error) {
parentPort!.postMessage({
id,
error: error instanceof Error ? error.message : 'Launch preparation failed.'
});
}
}
);