121 lines
5.0 KiB
TypeScript
121 lines
5.0 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.release_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;
|
|
release_version: string | null;
|
|
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,
|
|
releaseVersion: version?.release_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
|
|
)
|
|
)
|
|
);
|
|
}
|