Fix release version labels across deployment views

This commit is contained in:
2026-10-17 09:45:53 -04:00
parent b5037a70fe
commit ba9bed6a0d
20 changed files with 485 additions and 39 deletions
+13
View File
@@ -1,3 +1,5 @@
import type { LaunchFileSummary } from './deployment-plan.js';
/** Keep the upload number visible: two builds may share a publisher's release tag. */ /** Keep the upload number visible: two builds may share a publisher's release tag. */
export function artifactVersionLabel( export function artifactVersionLabel(
version: { version: number; release_version?: string | null } | null | undefined, version: { version: number; release_version?: string | null } | null | undefined,
@@ -10,3 +12,14 @@ export function artifactVersionLabel(
? `Unversioned (upload ${version.version})` ? `Unversioned (upload ${version.version})`
: `v${version.version}`; : `v${version.version}`;
} }
export function launchFileVersionLabel(
file: Pick<LaunchFileSummary, 'version' | 'releaseVersion' | 'kind'>
): string {
return file.version === null
? 'Generated'
: artifactVersionLabel(
{ version: file.version, release_version: file.releaseVersion },
file.kind
);
}
+2
View File
@@ -6,6 +6,8 @@ export interface LaunchFileSummary {
path: string; path: string;
versionId: number | null; versionId: number | null;
version: number | null; version: number | null;
/** Missing on snapshots recorded before publisher release versions were retained. */
releaseVersion?: string | null;
sha256: string; sha256: string;
size: number; size: number;
} }
+28 -3
View File
@@ -171,6 +171,7 @@ function buildRecipe(
path: script.target_rel_path, path: script.target_rel_path,
versionId: version.id, versionId: version.id,
version: version.version, version: version.version,
releaseVersion: version.release_version,
sha256: version.sha256, sha256: version.sha256,
size: version.size size: version.size
}); });
@@ -187,6 +188,7 @@ function buildRecipe(
kind: 'config', kind: 'config',
versionId: null, versionId: null,
version: null, version: null,
releaseVersion: null,
sha256: sha256Buffer(content), sha256: sha256Buffer(content),
size: content.length, size: content.length,
...(includeContent ? { content: content.toString('base64') } : {}) ...(includeContent ? { content: content.toString('base64') } : {})
@@ -249,18 +251,41 @@ export function recipeSummaryForDeployment(
) )
.get(id) as { recipe_summary: string | null; blueprint_name: string | null } | undefined; .get(id) as { recipe_summary: string | null; blueprint_name: string | null } | undefined;
if (!row) return null; if (!row) return null;
if (row.recipe_summary !== null) if (row.recipe_summary !== null) {
return { recipe: JSON.parse(row.recipe_summary), blueprintName: row.blueprint_name }; const saved: LaunchRecipeSummary = JSON.parse(row.recipe_summary);
const summary = withHistoricalReleaseVersions(saved);
if (summary !== saved)
db()
.prepare(
'UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?'
)
.run(JSON.stringify(summary), id);
return { recipe: summary, blueprintName: row.blueprint_name };
}
// Old snapshots remain readable. Populate on first access, without making // Old snapshots remain readable. Populate on first access, without making
// controller startup scan and decode every retained plugin in its history. // controller startup scan and decode every retained plugin in its history.
const launch = recipeForDeployment(id)!; const launch = recipeForDeployment(id)!;
const summary = summarizeRecipe(launch.recipe); const summary = withHistoricalReleaseVersions(summarizeRecipe(launch.recipe));
db() db()
.prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?') .prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?')
.run(JSON.stringify(summary), id); .run(JSON.stringify(summary), id);
return { recipe: summary, blueprintName: row.blueprint_name }; return { recipe: summary, blueprintName: row.blueprint_name };
} }
/** Restore display metadata from the exact retained upload, preserving the captured recipe. */
function withHistoricalReleaseVersions(summary: LaunchRecipeSummary): LaunchRecipeSummary {
const files = summary.files.map((file) => {
if (file.releaseVersion !== undefined || file.versionId === null) return file;
const version = getVersionMetadata(file.versionId);
if (!version || version.version !== file.version || version.sha256 !== file.sha256)
return file;
return { ...file, releaseVersion: version.release_version };
});
return files.some((file, index) => file !== summary.files[index])
? { ...summary, files }
: summary;
}
export function recipeForDeployment( export function recipeForDeployment(
id: string id: string
): { recipe: LaunchRecipePlan; blueprintName: string | null } | null { ): { recipe: LaunchRecipePlan; blueprintName: string | null } | null {
+7 -2
View File
@@ -192,12 +192,17 @@ export function getVersion(id: number): ScriptVersionRow | undefined {
ScriptVersionRow | undefined; ScriptVersionRow | undefined;
} }
export type ScriptVersionMetadata = Pick<ScriptVersionRow, 'id' | 'version' | 'sha256' | 'size'>; export type ScriptVersionMetadata = Pick<
ScriptVersionRow,
'id' | 'version' | 'release_version' | 'sha256' | 'size'
>;
/** Planning a launch never needs to load potentially large script or plugin bytes. */ /** Planning a launch never needs to load potentially large script or plugin bytes. */
export function getVersionMetadata(id: number): ScriptVersionMetadata | undefined { export function getVersionMetadata(id: number): ScriptVersionMetadata | undefined {
return db() return db()
.prepare('SELECT id, version, sha256, size FROM script_versions WHERE id = ?') .prepare(
'SELECT id, version, release_version, sha256, size FROM script_versions WHERE id = ?'
)
.get(id) as ScriptVersionMetadata | undefined; .get(id) as ScriptVersionMetadata | undefined;
} }
@@ -12,7 +12,7 @@ export function applyActivationFiles(
): void { ): void {
const changes = db() const changes = db()
.prepare( .prepare(
`SELECT f.*, ${blobSize('f.content_hash')} AS size, v.version FROM staged_file_changes f `SELECT f.*, ${blobSize('f.content_hash')} AS size, v.version, v.release_version FROM staged_file_changes f
JOIN provisioning_sources s ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server JOIN provisioning_sources s ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
LEFT JOIN script_versions v ON v.id=f.script_version_id LEFT JOIN script_versions v ON v.id=f.script_version_id
WHERE s.operation_id=? AND f.state='pending' ORDER BY f.id` WHERE s.operation_id=? AND f.state='pending' ORDER BY f.id`
@@ -21,6 +21,7 @@ export function applyActivationFiles(
content_hash: string | null; content_hash: string | null;
size: number; size: number;
version: number | null; version: number | null;
release_version: string | null;
})[]; })[];
for (const change of changes) { for (const change of changes) {
assertPublicFileTarget(change.server, change.kind, change.rel_path); assertPublicFileTarget(change.server, change.kind, change.rel_path);
@@ -36,6 +37,7 @@ export function applyActivationFiles(
path: change.rel_path, path: change.rel_path,
versionId: change.script_version_id, versionId: change.script_version_id,
version: change.version, version: change.version,
releaseVersion: change.release_version,
sha256: change.content_hash, sha256: change.content_hash,
size: change.size, size: change.size,
...(includeContent ...(includeContent
+9 -2
View File
@@ -38,11 +38,17 @@ export function captureMoveRecipe(server: string): LaunchRecipePlan {
? undefined ? undefined
: (db() : (db()
.prepare( .prepare(
`SELECT v.version,v.kind,s.name `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=?` FROM script_versions v JOIN scripts s ON s.id=v.script_id WHERE v.id=?`
) )
.get(file.script_version_id) as .get(file.script_version_id) as
{ version: number; kind: string; name: string } | undefined); | {
version: number;
release_version: string | null;
kind: string;
name: string;
}
| undefined);
const kind = version?.kind === 'baseline' ? 'config' : (version?.kind ?? before?.kind); const kind = version?.kind === 'baseline' ? 'config' : (version?.kind ?? before?.kind);
if (!['script', 'plugin', 'config'].includes(kind ?? '')) if (!['script', 'plugin', 'config'].includes(kind ?? ''))
throw new Error(`The deployed file type is unavailable for ${file.rel_path}.`); throw new Error(`The deployed file type is unavailable for ${file.rel_path}.`);
@@ -59,6 +65,7 @@ export function captureMoveRecipe(server: string): LaunchRecipePlan {
path: file.rel_path, path: file.rel_path,
versionId: file.script_version_id, versionId: file.script_version_id,
version: version?.version ?? null, version: version?.version ?? null,
releaseVersion: version?.release_version ?? null,
sha256: file.sha256, sha256: file.sha256,
size: file.size size: file.size
}; };
+2 -1
View File
@@ -1149,7 +1149,8 @@ function sameLaunchFiles(first: LaunchRecipePlan, second: LaunchRecipePlan): boo
expectedMap: recipe.expectedMap, expectedMap: recipe.expectedMap,
configFile: recipe.configFile, configFile: recipe.configFile,
configSource: recipe.configSource, configSource: recipe.configSource,
files: recipe.files // Older frozen recipes lack release labels; display metadata does not change a launch.
files: recipe.files.map(({ releaseVersion: _releaseVersion, ...file }) => file)
}); });
return JSON.stringify(selected(first)) === JSON.stringify(selected(second)); return JSON.stringify(selected(first)) === JSON.stringify(selected(second));
} }
+23 -6
View File
@@ -1,15 +1,21 @@
import { statusPresentation, type StatusTone } from './status.js'; import { statusPresentation, type StatusTone } from './status.js';
import { plural } from './plural.js'; import { plural } from './plural.js';
import { artifactVersionLabel } from '../artifact-version.js';
export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive'; export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive';
export interface CampaignSummary { interface CampaignScriptSummary {
script_name: string | null;
script_version: number | null;
script_release_version: string | null;
script_kind: string | null;
}
export interface CampaignSummary extends CampaignScriptSummary {
action: string; action: string;
beta_id: number | null; beta_id: number | null;
rolled_back_by: string | null; rolled_back_by: string | null;
script_name: string | null; scripts: CampaignScriptSummary[];
script_version: number | null;
scripts: { script_name: string | null; script_version: number | null }[];
servers: { restart_state: string; server_deleted_at?: string | null }[]; servers: { restart_state: string; server_deleted_at?: string | null }[];
} }
@@ -23,8 +29,19 @@ export function campaignTitle(campaign: CampaignSummary): string {
return `${action} · Beta #${campaign.beta_id}`; return `${action} · Beta #${campaign.beta_id}`;
if (campaign.scripts.length > 1) return `${action} · ${campaign.scripts.length} scripts`; if (campaign.scripts.length > 1) return `${action} · ${campaign.scripts.length} scripts`;
const script = campaign.scripts[0] ?? campaign; const script = campaign.scripts[0] ?? campaign;
if (script.script_name) if (script.script_name) {
return `${action} · ${script.script_name}${script.script_version !== null ? ` v${script.script_version}` : ''}`; const version =
script.script_version === null
? ''
: ` ${artifactVersionLabel(
{
version: script.script_version,
release_version: script.script_release_version
},
script.script_kind ?? 'script'
)}`;
return `${action} · ${script.script_name}${version}`;
}
return campaign.beta_id ? `${action} · Beta #${campaign.beta_id}` : action; return campaign.beta_id ? `${action} · Beta #${campaign.beta_id}` : action;
} }
@@ -15,6 +15,7 @@
} from '$ui/collection-summary.js'; } from '$ui/collection-summary.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { launchFileVersionLabel } from '$lib/artifact-version.js';
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js'; import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
import type { NativeDeploymentSelection } from '$lib/hosts.js'; import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { ZombieGame } from '$lib/map-aliases.js'; import type { ZombieGame } from '$lib/map-aliases.js';
@@ -609,9 +610,7 @@
<ul class="file-list"> <ul class="file-list">
{#each entry.recipe.files as file}<li> {#each entry.recipe.files as file}<li>
<strong>{file.name}</strong><span <strong>{file.name}</strong><span
>{file.version === null >{launchFileVersionLabel(file)} · {file.kind}</span
? 'Generated'
: `v${file.version}`} · {file.kind}</span
><code>{file.path}</code> ><code>{file.path}</code>
</li>{/each} </li>{/each}
</ul> </ul>
@@ -9,6 +9,7 @@
import { createServerLaunchController, type ServerLaunchState } from '$ui/server-launch.js'; import { createServerLaunchController, type ServerLaunchState } from '$ui/server-launch.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { launchFileVersionLabel } from '$lib/artifact-version.js';
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js'; import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
import type { NativeDeploymentSelection } from '$lib/hosts.js'; import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js'; import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
@@ -378,7 +379,7 @@
{#each preview.recipe.files as file}<li> {#each preview.recipe.files as file}<li>
<div> <div>
<strong>{file.name}</strong><span <strong>{file.name}</strong><span
>{file.version === null ? 'Generated' : `v${file.version}`} · >{launchFileVersionLabel(file)} ·
{file.kind}</span {file.kind}</span
> >
</div> </div>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { launchFileVersionLabel } from '$lib/artifact-version.js';
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js'; import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } = let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } =
$props(); $props();
@@ -36,7 +37,7 @@
{#each recipe.files as file}<li> {#each recipe.files as file}<li>
<div> <div>
<strong>{file.name}</strong><span <strong>{file.name}</strong><span
>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} >{launchFileVersionLabel(file)} · {file.kind}
· {file.size.toLocaleString()} bytes</span · {file.size.toLocaleString()} bytes</span
> >
</div> </div>
+12 -4
View File
@@ -70,8 +70,9 @@ export async function GET({ url }: { url: URL }) {
.prepare( .prepare(
`SELECT d.id, d.action, d.started_at, d.finished_at, d.triggered_by, `SELECT d.id, d.action, d.started_at, d.finished_at, d.triggered_by,
d.script_id, d.script_version_id, d.prior_version_id, d.beta_id, d.rolled_back_by, d.script_id, d.script_version_id, d.prior_version_id, d.beta_id, d.rolled_back_by,
s.name AS script_name, sv.version AS script_version, s.name AS script_name, s.kind AS script_kind,
pv.version AS prior_version sv.version AS script_version, sv.release_version AS script_release_version,
pv.version AS prior_version, pv.release_version AS prior_release_version
FROM deployments d FROM deployments d
LEFT JOIN scripts s ON s.id = d.script_id LEFT JOIN scripts s ON s.id = d.script_id
LEFT JOIN script_versions sv ON sv.id = d.script_version_id LEFT JOIN script_versions sv ON sv.id = d.script_version_id
@@ -92,8 +93,11 @@ export async function GET({ url }: { url: URL }) {
beta_id: number | null; beta_id: number | null;
rolled_back_by: string | null; rolled_back_by: string | null;
script_name: string | null; script_name: string | null;
script_kind: string | null;
script_version: number | null; script_version: number | null;
script_release_version: string | null;
prior_version: number | null; prior_version: number | null;
prior_release_version: string | null;
}>; }>;
const ids = deployments.map((deployment) => deployment.id); const ids = deployments.map((deployment) => deployment.id);
@@ -126,8 +130,9 @@ export async function GET({ url }: { url: URL }) {
const scriptRows = db() const scriptRows = db()
.prepare( .prepare(
`SELECT ds.deployment_id, ds.script_id, s.name AS script_name, `SELECT ds.deployment_id, ds.script_id, s.name AS script_name, ds.kind AS script_kind,
sv.version AS script_version, pv.version AS prior_version sv.version AS script_version, sv.release_version AS script_release_version,
pv.version AS prior_version, pv.release_version AS prior_release_version
FROM deployment_scripts ds FROM deployment_scripts ds
LEFT JOIN scripts s ON s.id = ds.script_id LEFT JOIN scripts s ON s.id = ds.script_id
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
@@ -138,8 +143,11 @@ export async function GET({ url }: { url: URL }) {
deployment_id: string; deployment_id: string;
script_id: number; script_id: number;
script_name: string | null; script_name: string | null;
script_kind: string | null;
script_version: number | null; script_version: number | null;
script_release_version: string | null;
prior_version: number | null; prior_version: number | null;
prior_release_version: string | null;
}>; }>;
const scriptsByDep = new Map<string, typeof scriptRows>(); const scriptsByDep = new Map<string, typeof scriptRows>();
for (const s of scriptRows) { for (const s of scriptRows) {
+3 -3
View File
@@ -27,9 +27,9 @@ export async function GET({ params }: { params: { id: string } }) {
const scripts = db() const scripts = db()
.prepare( .prepare(
`SELECT ds.script_id, s.name AS script_name, s.target_rel_path, `SELECT ds.script_id, s.name AS script_name, s.target_rel_path, ds.kind AS script_kind,
ds.script_version_id, sv.version AS script_version, ds.script_version_id, sv.version AS script_version, sv.release_version AS script_release_version,
ds.prior_version_id, pv.version AS prior_version ds.prior_version_id, pv.version AS prior_version, pv.release_version AS prior_release_version
FROM deployment_scripts ds FROM deployment_scripts ds
LEFT JOIN scripts s ON s.id = ds.script_id LEFT JOIN scripts s ON s.id = ds.script_id
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
+3 -3
View File
@@ -31,9 +31,9 @@ export async function GET({ params, url }: { params: { id: string }; url?: URL }
const scripts = db() const scripts = db()
.prepare( .prepare(
`SELECT ds.script_id, s.name AS script_name, s.target_rel_path, `SELECT ds.script_id, s.name AS script_name, s.target_rel_path, ds.kind AS script_kind,
ds.script_version_id, sv.version AS script_version, ds.script_version_id, sv.version AS script_version, sv.release_version AS script_release_version,
ds.prior_version_id, pv.version AS prior_version ds.prior_version_id, pv.version AS prior_version, pv.release_version AS prior_release_version
FROM deployment_scripts ds FROM deployment_scripts ds
LEFT JOIN scripts s ON s.id = ds.script_id LEFT JOIN scripts s ON s.id = ds.script_id
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
+47 -1
View File
@@ -1,6 +1,10 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
import { campaignOutcome, type CampaignSummary } from '../../src/lib/ui/campaigns.js'; import {
campaignOutcome,
campaignTitle,
type CampaignSummary
} from '../../src/lib/ui/campaigns.js';
let fixture: Fixture; let fixture: Fixture;
beforeEach(async () => { beforeEach(async () => {
@@ -61,6 +65,48 @@ it('searches script names, campaign IDs and friendly server names across all ret
expect((await get('q=not-found')).total).toBe(0); expect((await get('q=not-found')).total).toBe(0);
}); });
it('exposes release versions for campaign titles and both campaign detail endpoints', async () => {
const { handle, get, add } = await setup();
add('aggregate-script', ['done']);
add('legacy-script', ['done']);
add('config', ['done']);
handle.exec(`
INSERT INTO scripts(id,name,target_rel_path,kind)
VALUES(1,'Double Tap','double.gsc','script'),(2,'Server settings','settings.cfg','config');
INSERT INTO script_versions(id,script_id,version,release_version,kind,sha256,size)
VALUES(1,1,2,'1.3.0','script','old',0),(2,1,11,'2.4.1','script','new',0),
(3,2,4,NULL,'config','settings',0);
INSERT INTO deployment_scripts(deployment_id,script_id,script_version_id,prior_version_id,kind)
VALUES('aggregate-script',1,2,1,'script'),('config',2,3,NULL,'config');
UPDATE deployments SET script_id=1,script_version_id=2,prior_version_id=1 WHERE id='legacy-script';
`);
const { deployments } = await get();
const versions = {
script_kind: 'script',
script_version: 11,
script_release_version: '2.4.1',
prior_version: 2,
prior_release_version: '1.3.0'
};
const aggregate = deployments.find((row) => row.id === 'aggregate-script')!;
const legacy = deployments.find((row) => row.id === 'legacy-script')!;
expect(aggregate.scripts).toEqual([expect.objectContaining(versions)]);
expect(legacy).toMatchObject(versions);
expect(campaignTitle(aggregate)).toBe('Promote · Double Tap 2.4.1 (upload 11)');
expect(campaignTitle(legacy)).toBe('Promote · Double Tap 2.4.1 (upload 11)');
expect(campaignTitle(deployments.find((row) => row.id === 'config')!)).toBe(
'Promote · Server settings v4'
);
const { GET: campaignDetail } = await import('../../src/routes/api/campaigns/[id]/+server.js');
const { GET: deploymentDetail } =
await import('../../src/routes/api/deployments/[id]/+server.js');
for (const detail of [campaignDetail, deploymentDetail]) {
const response = await (await detail({ params: { id: 'aggregate-script' } })).json();
expect(response.scripts).toEqual([expect.objectContaining(versions)]);
}
});
it('keeps API status filters and active/history partitions consistent with displayed outcomes', async () => { it('keeps API status filters and active/history partitions consistent with displayed outcomes', async () => {
const { handle, get, add } = await setup(); const { handle, get, add } = await setup();
add('complete', ['done']); add('complete', ['done']);
@@ -15,6 +15,95 @@ afterEach(async () => {
}); });
describe('persisted launch recipe summaries', () => { describe('persisted launch recipe summaries', () => {
it.each([false, true])(
'restores exact historical release labels with cached summary=%s without rewriting the recipe',
async (cached) => {
const { db } = await import('../../src/lib/server/db/index.js');
const { createScript, uploadNewVersion } =
await import('../../src/lib/server/fleet/scripts.js');
const {
captureRecipe,
summarizeRecipe,
recipeSummaryForDeployment,
recipeForDeployment
} = await import('../../src/lib/server/fleet/launch-recipes.js');
const saved = createScript({
name: 'Historical script',
scopeType: 'game',
scopeValue: 't5',
targetRelPath: 'history.gsc',
content: Buffer.from('historical bytes'),
releaseVersion: '2.3.4'
});
db()
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
.run(saved.version.id, saved.script.id);
const recipe = captureRecipe('t5', 'theater');
const original = recipe.files[0];
delete original.releaseVersion;
const unversioned = createScript({
name: 'Legacy script',
targetRelPath: 'legacy.gsc',
content: Buffer.from('historical bytes'),
releaseVersion: '1.0.0'
});
db()
.prepare('UPDATE script_versions SET release_version = NULL WHERE id = ?')
.run(unversioned.version.id);
recipe.files.push(
{ ...original, path: 'recorded.gsc', releaseVersion: '1.8.0' },
{ ...original, path: 'legacy.gsc', versionId: unversioned.version.id },
{ ...original, path: 'missing.gsc', versionId: 9999 },
{ ...original, path: 'mismatched.gsc', sha256: 'different' }
);
const latest = uploadNewVersion(
saved.script.id,
Buffer.from('newer bytes'),
undefined,
'9.0.0'
);
db()
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
.run(latest.id, saved.script.id);
const encoded = JSON.stringify(recipe);
const summary = summarizeRecipe(recipe);
db()
.exec(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by)
VALUES('historical','retired-server','create','done','Complete','2025-01-01','fixture')`);
db()
.prepare(
'INSERT INTO server_launch_recipes(deployment_id,recipe,recipe_summary) VALUES(?,?,?)'
)
.run('historical', encoded, cached ? JSON.stringify(summary) : null);
const restored = recipeSummaryForDeployment('historical')!.recipe;
expect(restored.fingerprint).toBe(summary.fingerprint);
expect(restored.files.find((file) => file.path === 'history.gsc')).toMatchObject({
version: 1,
releaseVersion: '2.3.4'
});
expect(
restored.files.find((file) => file.path === 'recorded.gsc')!.releaseVersion
).toBe('1.8.0');
expect(
restored.files.find((file) => file.path === 'legacy.gsc')!.releaseVersion
).toBeNull();
for (const path of ['missing.gsc', 'mismatched.gsc'])
expect(restored.files.find((file) => file.path === path)).not.toHaveProperty(
'releaseVersion'
);
expect(recipeSummaryForDeployment('historical')!.recipe).toEqual(restored);
expect(recipeForDeployment('historical')!.recipe).toEqual(recipe);
const stored = db()
.prepare(
'SELECT recipe,recipe_summary FROM server_launch_recipes WHERE deployment_id = ?'
)
.get('historical') as { recipe: string; recipe_summary: string };
expect(stored.recipe).toBe(encoded);
expect(JSON.parse(stored.recipe_summary)).toEqual(restored);
}
);
it('lazily upgrades legacy snapshots once without changing fingerprints or retained bytes', async () => { it('lazily upgrades legacy snapshots once without changing fingerprints or retained bytes', async () => {
const recipe: LaunchRecipe = { const recipe: LaunchRecipe = {
format: 1, format: 1,
+50
View File
@@ -82,12 +82,62 @@ describe('launch recipes and previews', () => {
expect(second.name).toBe(first.name); expect(second.name).toBe(first.name);
expect(second.port).toBe(first.port); expect(second.port).toBe(first.port);
expect(first.recipe.files).toHaveLength(3); expect(first.recipe.files).toHaveLength(3);
expect(first.recipe.files[0]).toMatchObject({ version: 1, releaseVersion: '1.0.0' });
expect(first.recipe.files[0]).not.toHaveProperty('content'); expect(first.recipe.files[0]).not.toHaveProperty('content');
expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]); expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]);
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]); expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]);
expect(JSON.stringify(first)).not.toContain('private-'); expect(JSON.stringify(first)).not.toContain('private-');
}); });
it('retains the selected script and plugin release in both planned and captured files', async () => {
const { script } = await seedArtifacts();
const { createScript, uploadNewVersion } =
await import('../../src/lib/server/fleet/scripts.js');
const { captureRecipe, captureRecipePlan } =
await import('../../src/lib/server/fleet/launch-recipes.js');
const { db } = await import('../../src/lib/server/db/index.js');
const selected = uploadNewVersion(
script.script.id,
Buffer.from('updated perks'),
undefined,
'2.3.4'
);
const plugin = createScript({
name: 'Native perks',
kind: 'plugin',
scopeType: 'game',
scopeValue: 't5',
targetRelPath: 'perks.dll',
content: Buffer.from('plugin'),
releaseVersion: 'v4.5.6'
});
for (const [id, versionId] of [
[script.script.id, selected.id],
[plugin.script.id, plugin.version.id]
])
db()
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
.run(versionId, id);
for (const capture of [captureRecipePlan, captureRecipe]) {
const recipe = capture('t5', 'theater');
expect(recipe.files).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'perks.gsc',
version: 2,
releaseVersion: '2.3.4'
}),
expect.objectContaining({
path: 'perks.dll',
version: 1,
releaseVersion: 'v4.5.6'
}),
expect.objectContaining({ kind: 'config', version: null, releaseVersion: null })
])
);
}
});
it('returns the same operation for repeated submission, even after expiry and restart', async () => { it('returns the same operation for repeated submission, even after expiry and restart', async () => {
const { previewCreation, queueCreateServer } = const { previewCreation, queueCreateServer } =
await import('../../src/lib/server/fleet/create.js'); await import('../../src/lib/server/fleet/create.js');
+92 -6
View File
@@ -174,7 +174,7 @@ async function moveTarget() {
} }
describe('rebalance instance migrations', () => { describe('rebalance instance migrations', () => {
it('preserves deployed configuration, patched file versions, identity and credentials on an active source', async () => { it.each(['current', 'legacy'])('preserves deployed files (%s)', async (recipeFormat) => {
const { config } = await import('../../src/lib/server/config.js'); const { config } = await import('../../src/lib/server/config.js');
Object.assign(config.rcon, { password: '' }); Object.assign(config.rcon, { password: '' });
const { createScript, uploadNewVersion } = const { createScript, uploadNewVersion } =
@@ -196,7 +196,16 @@ describe('rebalance instance migrations', () => {
scopeValue: 't5', scopeValue: 't5',
content: Buffer.from('original deployed script') content: Buffer.from('original deployed script')
}); });
for (const artifact of [baseline, script]) const plugin = createScript({
releaseVersion: 'v2.4.0',
name: 'Deployed plugin',
kind: 'plugin',
targetRelPath: 'preserved.dll',
scopeType: 'game',
scopeValue: 't5',
content: Buffer.from('deployed plugin')
});
for (const artifact of [baseline, script, plugin])
db() db()
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?') .prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
.run(artifact.version.id, artifact.script.id); .run(artifact.version.id, artifact.script.id);
@@ -243,10 +252,36 @@ describe('rebalance instance migrations', () => {
const original = nativeInstance(job.name)!; const original = nativeInstance(job.name)!;
const recipe = captureMoveRecipe(job.name); const recipe = captureMoveRecipe(job.name);
expect(recipe.files.every((file) => file.content === undefined)).toBe(true); expect(recipe.files.every((file) => file.content === undefined)).toBe(true);
expect(recipe.files.find((file) => file.path === 'preserved.gsc')).toMatchObject({
versionId: patched.id,
version: patched.version,
releaseVersion: '1.0.1'
});
expect(recipe.files.find((file) => file.path === 'preserved.dll')).toMatchObject({
versionId: plugin.version.id,
releaseVersion: 'v2.4.0'
});
const id = requestInstanceMove(job.name, target.id, { const id = requestInstanceMove(job.name, target.id, {
triggeredBy: 'rebalance:test', triggeredBy: 'rebalance:test',
recipe recipe
}); });
if (recipeFormat === 'legacy') {
const stored = db()
.prepare(
'SELECT recipe, recipe_summary FROM server_launch_recipes WHERE deployment_id=?'
)
.get(id) as { recipe: string; recipe_summary: string };
const legacy = (encoded: string) => {
const value = JSON.parse(encoded);
for (const file of value.files) delete file.releaseVersion;
return JSON.stringify(value);
};
db()
.prepare(
'UPDATE server_launch_recipes SET recipe=?,recipe_summary=? WHERE deployment_id=?'
)
.run(legacy(stored.recipe), legacy(stored.recipe_summary), id);
}
await advance(); await advance();
const removal = hostMigration(id)!.remove_operation_id!; const removal = hostMigration(id)!.remove_operation_id!;
expect(removal).toBeTruthy(); expect(removal).toBeTruthy();
@@ -304,7 +339,14 @@ describe('rebalance instance migrations', () => {
generation: 2 generation: 2
}); });
expect(getHost(hostId)?.mode).toBe('active'); expect(getHost(hostId)?.mode).toBe('active');
expect(JSON.stringify(getDeployment(id)?.recipe)).not.toContain(launch.rconPassword); const displayedRecipe = getDeployment(id)?.recipe;
expect(displayedRecipe?.files.find((file) => file.path === 'preserved.gsc')).toMatchObject({
releaseVersion: '1.0.1'
});
expect(displayedRecipe?.files.find((file) => file.path === 'preserved.dll')).toMatchObject({
releaseVersion: 'v2.4.0'
});
expect(JSON.stringify(displayedRecipe)).not.toContain(launch.rconPassword);
}); });
it('rechecks occupancy, target capacity and run admission before removing the source', async () => { it('rechecks occupancy, target capacity and run admission before removing the source', async () => {
@@ -1773,17 +1815,21 @@ describe('native instance provisioning', () => {
expect(getHost(hostId)?.mode).toBe('active'); expect(getHost(hostId)?.mode).toBe('active');
}); });
it('builds campaign replacements with requested plugins, configuration, additions and removals', async () => { it('builds campaign replacements with requested plugins, configuration, additions and removals', async () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { createScript, uploadNewVersion } =
await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
let replacementPluginId = 0;
for (const path of ['replace.dll', 'remove.dll']) { for (const path of ['replace.dll', 'remove.dll']) {
const file = createScript({ const file = createScript({
name: path, name: path,
kind: 'plugin', kind: 'plugin',
releaseVersion: 'v2.0.0',
targetRelPath: path, targetRelPath: path,
scopeType: 'game', scopeType: 'game',
scopeValue: 't5', scopeValue: 't5',
content: Buffer.from('promoted plugin') content: Buffer.from('promoted plugin')
}); });
if (path === 'replace.dll') replacementPluginId = file.script.id;
db() db()
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?') .prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
.run(file.version.id, file.script.id); .run(file.version.id, file.script.id);
@@ -1791,6 +1837,21 @@ describe('native instance provisioning', () => {
const job = await queue(); const job = await queue();
await advance(); await advance();
await acknowledge(job.deploymentId); await acknowledge(job.deploymentId);
const replacement = uploadNewVersion(
replacementPluginId,
Buffer.from('requested plugin'),
undefined,
'v2.1.0'
);
const addition = createScript({
name: 'New script',
kind: 'script',
releaseVersion: '3.2.1',
targetRelPath: 'new.gsc',
scopeType: 'game',
scopeValue: 't5',
content: Buffer.from('new script')
});
db() db()
.prepare( .prepare(
"INSERT INTO deployments(id,action,started_at) VALUES('files-rebuild','promote',?)" "INSERT INTO deployments(id,action,started_at) VALUES('files-rebuild','promote',?)"
@@ -1803,9 +1864,19 @@ describe('native instance provisioning', () => {
.run(job.name); .run(job.name);
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js'); const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
const changes = [ const changes = [
{ kind: 'plugin', relPath: 'replace.dll', content: Buffer.from('requested plugin') }, {
kind: 'plugin',
relPath: 'replace.dll',
content: Buffer.from('requested plugin'),
scriptVersionId: replacement.id
},
{ kind: 'plugin', relPath: 'remove.dll', content: null }, { kind: 'plugin', relPath: 'remove.dll', content: null },
{ kind: 'script', relPath: 'new.gsc', content: Buffer.from('new script') }, {
kind: 'script',
relPath: 'new.gsc',
content: Buffer.from('new script'),
scriptVersionId: addition.version.id
},
{ kind: 'config', relPath: 'custom.cfg', content: Buffer.from('set custom 1') } { kind: 'config', relPath: 'custom.cfg', content: Buffer.from('set custom 1') }
] as const; ] as const;
for (const change of changes) for (const change of changes)
@@ -1839,6 +1910,21 @@ describe('native instance provisioning', () => {
if (change.content === null) expect(actual).toBeUndefined(); if (change.content === null) expect(actual).toBeUndefined();
else expect(Buffer.from(actual!.content!, 'base64')).toEqual(change.content); else expect(Buffer.from(actual!.content!, 'base64')).toEqual(change.content);
} }
expect(recipe.files.find((file) => file.path === 'replace.dll')).toMatchObject({
versionId: replacement.id,
version: replacement.version,
releaseVersion: 'v2.1.0'
});
expect(recipe.files.find((file) => file.path === 'new.gsc')).toMatchObject({
versionId: addition.version.id,
version: addition.version.version,
releaseVersion: '3.2.1'
});
expect(recipe.files.find((file) => file.path === 'custom.cfg')).toMatchObject({
versionId: null,
version: null,
releaseVersion: null
});
const files = getHostOperation(staged.stage_operation_id)!.payload.files as Array<{ const files = getHostOperation(staged.stage_operation_id)!.payload.files as Array<{
id: number; id: number;
path: string; path: string;
+46 -2
View File
@@ -15,13 +15,25 @@ it('names campaigns with their script or beta and gives maintenance a readable t
rolled_back_by: null, rolled_back_by: null,
script_name: null, script_name: null,
script_version: null, script_version: null,
script_release_version: null,
script_kind: null,
scripts: [], scripts: [],
servers: [] servers: []
}; };
expect(campaignTitle(campaign)).toBe('Promote · Beta #74'); expect(campaignTitle(campaign)).toBe('Promote · Beta #74');
expect( expect(
campaignTitle({ ...campaign, scripts: [{ script_name: 'Double Tap', script_version: 2 }] }) campaignTitle({
).toBe('Promote · Double Tap v2'); ...campaign,
scripts: [
{
script_name: 'Double Tap',
script_version: 2,
script_release_version: '1.3.0',
script_kind: 'script'
}
]
})
).toBe('Promote · Double Tap 1.3.0 (upload 2)');
expect( expect(
campaignTitle({ campaignTitle({
...campaign, ...campaign,
@@ -32,6 +44,38 @@ it('names campaigns with their script or beta and gives maintenance a readable t
expect(campaignTitle({ ...campaign, action: 'drift' })).toBe('Maintenance repair'); expect(campaignTitle({ ...campaign, action: 'drift' })).toBe('Maintenance repair');
}); });
it('uses release labels for legacy campaigns and preserves config version labels', () => {
const campaign = {
action: 'promote',
beta_id: null,
rolled_back_by: null,
script_name: 'Double Tap',
script_version: 11,
script_release_version: '2.4.1',
script_kind: 'script',
scripts: [],
servers: []
};
expect(campaignTitle(campaign)).toBe('Promote · Double Tap 2.4.1 (upload 11)');
expect(campaignTitle({ ...campaign, script_release_version: null })).toBe(
'Promote · Double Tap Unversioned (upload 11)'
);
expect(campaignTitle({ ...campaign, script_version: null })).toBe('Promote · Double Tap');
expect(
campaignTitle({
...campaign,
scripts: [
{
script_name: 'Server settings',
script_version: 4,
script_release_version: null,
script_kind: 'config'
}
]
})
).toBe('Promote · Server settings v4');
});
it('summarizes progress without letting deleted servers create false alarms or false completion', () => { it('summarizes progress without letting deleted servers create false alarms or false completion', () => {
const campaign = { const campaign = {
rolled_back_by: null, rolled_back_by: null,
+50
View File
@@ -0,0 +1,50 @@
import { expect, it } from 'vitest';
import { render } from 'svelte/server';
import LaunchRecipe from '../../src/lib/ui/components/LaunchRecipe.svelte';
import type { LaunchFileSummary, LaunchRecipeSummary } from '../../src/lib/deployment-plan.js';
const file = (overrides: Partial<LaunchFileSummary>): LaunchFileSummary => ({
name: 'Script',
kind: 'script',
path: 'script.gsc',
versionId: 20,
version: 11,
sha256: 'a'.repeat(64),
size: 1200,
...overrides
});
const recipe: LaunchRecipeSummary = {
game: 't5',
mapSlug: 'theater',
mapName: 'Kino der Toten',
expectedMap: 'zombie_theater',
capturedAt: '2026-10-17T12:00:00Z',
image: 'runtime:latest',
configFile: 'dedicated_sp.cfg',
configSource: 'Production',
fingerprint: 'b'.repeat(64),
files: [
file({ releaseVersion: '1.2.3' }),
file({ kind: 'plugin', releaseVersion: 'v3.1.0' }),
file({ version: 2 }),
file({ kind: 'config', version: 3 }),
file({ kind: 'config', versionId: null, version: null })
]
};
it.each([false, true])(
'shows release versions and preserves legacy/generated labels in historical=%s launch files',
(historical) => {
const body = render(LaunchRecipe, { props: { recipe, historical } }).body.replace(
/\s+/g,
' '
);
expect(body).toContain('1.2.3 (upload 11) · script');
expect(body).toContain('v3.1.0 (upload 11) · plugin');
expect(body).toContain('Unversioned (upload 2) · script');
expect(body).toContain('v3 · config');
expect(body).toContain('Generated · config');
expect(body).not.toContain('v11 ·');
expect(body).not.toContain('v2 ·');
}
);