Files
electric-cherry/src/lib/server/provisioning/move-recipes.ts
T

114 lines
4.7 KiB
TypeScript

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
)
)
);
}