Add safe manual instance rebalancing

This commit is contained in:
2026-10-08 12:37:56 -04:00
parent 26de43639f
commit 012cd51a66
45 changed files with 2006 additions and 154 deletions
@@ -0,0 +1,59 @@
import { db } from '../db/index.js';
import { nativeInstance, runtimePasswordForServer } from '../backends/native.js';
import { getServer } from '../fleet/servers.js';
import { listServerScripts } from '../fleet/inventory.js';
import { recipeStorage, recipeSummaryForDeployment, type LaunchRecipePlan } from '../fleet/launch-recipes.js';
/** Preserve the recorded deployed inventory, including changes made after launch. */
export function captureMoveRecipe(server: string): LaunchRecipePlan {
const instance = nativeInstance(server);
const row = getServer(server);
if (!instance || !row?.map_slug) throw new Error('A live native instance and saved map are required before moving.');
if (!runtimePasswordForServer(server)) throw new Error('The deployed runtime credentials are unavailable.');
const launch = db().prepare(`SELECT d.id FROM server_deployments d
JOIN native_provisioning n ON n.deployment_id=d.id
JOIN server_launch_recipes r ON r.deployment_id=d.id
WHERE n.instance_id=? AND d.state IN ('done','warning') AND d.server_deleted_at IS NULL
ORDER BY d.rowid DESC LIMIT 1`).get(instance.id) as { id: string } | undefined;
const previous = launch && recipeSummaryForDeployment(launch.id)?.recipe;
if (!previous || previous.game !== row.game || previous.mapSlug !== row.map_slug) {
throw new Error('The deployed launch recipe is unavailable or differs from the saved map.');
}
const inventory = listServerScripts(server);
if (!inventory.length) throw new Error('The deployed file inventory is unavailable.');
const files = inventory.map(file => {
const before = previous.files.find(item => item.path === file.rel_path);
const version = file.script_version_id === null ? undefined : db().prepare(`SELECT v.version,v.kind,s.name
FROM script_versions v JOIN scripts s ON s.id=v.script_id WHERE v.id=?`).get(file.script_version_id) as
{ version: number; kind: string; name: string } | undefined;
const kind = version?.kind === 'baseline' ? 'config' : version?.kind ?? before?.kind;
if (!['script', 'plugin', 'config'].includes(kind ?? '')) throw new Error(`The deployed file type is unavailable for ${file.rel_path}.`);
if (!db().prepare('SELECT 1 FROM file_blobs WHERE sha256=? AND size=?').get(file.sha256, file.size)) {
throw new Error(`The deployed bytes are unavailable for ${file.rel_path}.`);
}
return { name: version?.name ?? before?.name ?? file.rel_path, kind: kind as 'script' | 'plugin' | 'config',
path: file.rel_path, versionId: file.script_version_id, version: version?.version ?? null,
sha256: file.sha256, size: file.size };
});
if (!files.some(file => file.path === 'electric_cherry_map.cfg' && file.kind === 'config')) {
throw new Error('The deployed map configuration is unavailable.');
}
return { format: 1, game: previous.game, mapSlug: previous.mapSlug, mapName: previous.mapName,
expectedMap: previous.expectedMap, configFile: previous.configFile,
configSource: 'Preserved deployed files', capturedAt: new Date().toISOString(), image: instance.image,
native: { libraryId: instance.library_id, image: instance.image }, files };
}
/** Immutable hash references keep deployed bytes alive without decoding plugins on the controller. */
export function moveRecipeStorage(recipe: LaunchRecipePlan): ReturnType<typeof recipeStorage> {
const metadata = { ...recipe, files: recipe.files.map(({ content: _content, ...file }) => file) };
const stored = recipeStorage(metadata);
return { recipeSummary: stored.recipeSummary,
recipe: JSON.stringify({ ...metadata, files: metadata.files.map(file => ({ ...file, contentHash: file.sha256 })) }) };
}
export function moveRecipeMatches(server: string, recipe: LaunchRecipePlan): boolean {
const inventory = listServerScripts(server);
return inventory.length === recipe.files.length && inventory.every(file => recipe.files.some(saved =>
saved.path === file.rel_path && saved.sha256 === file.sha256 && saved.size === file.size));
}