Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -3,5 +3,7 @@ import { parseActivityQuery } from '$lib/activity.js';
|
||||
import { activityHistory } from '$lib/server/activity.js';
|
||||
|
||||
export function GET({ url }: { url: URL }) {
|
||||
return json(activityHistory(parseActivityQuery(url.searchParams)), { headers: { 'Cache-Control': 'no-store' } });
|
||||
return json(activityHistory(parseActivityQuery(url.searchParams)), {
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,13 +5,20 @@ import { db } from '$lib/server/db/index.js';
|
||||
export function GET({ request, params }: { request: Request; params: { sha256: string } }) {
|
||||
const host = authenticateAgent(request);
|
||||
if (!/^[a-f0-9]{64}$/.test(params.sha256)) error(404, 'File not found.');
|
||||
const row = db().prepare(`SELECT b.content,b.size FROM file_blobs b WHERE b.sha256=? AND EXISTS (
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT b.content,b.size FROM file_blobs b WHERE b.sha256=? AND EXISTS (
|
||||
SELECT 1 FROM file_blob_references r JOIN ec_host_operations o ON o.id=r.owner_id
|
||||
WHERE r.sha256=b.sha256 AND r.owner_type='operation' AND o.host_id=?)`).get(params.sha256, host.id) as
|
||||
{ content: Buffer; size: number } | undefined;
|
||||
WHERE r.sha256=b.sha256 AND r.owner_type='operation' AND o.host_id=?)`
|
||||
)
|
||||
.get(params.sha256, host.id) as { content: Buffer; size: number } | undefined;
|
||||
if (!row) error(404, 'File not found.');
|
||||
return new Response(new Uint8Array(row.content), { headers: {
|
||||
'Content-Type': 'application/octet-stream', 'Content-Length': String(row.size),
|
||||
'Cache-Control': 'private, no-store', ETag: `"${params.sha256}"`
|
||||
} });
|
||||
return new Response(new Uint8Array(row.content), {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': String(row.size),
|
||||
'Cache-Control': 'private, no-store',
|
||||
ETag: `"${params.sha256}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ export async function POST({ request }: { request: Request }) {
|
||||
const host = authenticateAgent(request);
|
||||
try {
|
||||
const body = await objectBody(request);
|
||||
if (typeof body.credential !== 'string') throw new Error('Persist a randomly generated replacement credential before requesting rotation.');
|
||||
if (typeof body.credential !== 'string')
|
||||
throw new Error(
|
||||
'Persist a randomly generated replacement credential before requesting rotation.'
|
||||
);
|
||||
rotateCredential(host.id, body.credential);
|
||||
return json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ import { objectBody, requireAgentTransport } from '$lib/server/hosts/http.js';
|
||||
/** The uninstall worker retains only this narrowly scoped credential after removing the agent. */
|
||||
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
requireAgentTransport(request);
|
||||
const token = request.headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9_-]+)$/i)?.[1] ?? '';
|
||||
try { return json(receiveDecommissionReceipt(params.id, token, await objectBody(request, 8192)), { headers: { 'Cache-Control': 'no-store' } }); }
|
||||
catch (cause) {
|
||||
if (cause instanceof HostDeletionError) return json({ error: cause.message }, { status: cause.status });
|
||||
const token =
|
||||
request.headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9_-]+)$/i)?.[1] ?? '';
|
||||
try {
|
||||
return json(receiveDecommissionReceipt(params.id, token, await objectBody(request, 8192)), {
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
} catch (cause) {
|
||||
if (cause instanceof HostDeletionError)
|
||||
return json({ error: cause.message }, { status: cause.status });
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ import { enrollHost } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, objectBody, requireAgentTransport } from '$lib/server/hosts/http.js';
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
requireAgentTransport(request);
|
||||
try { const body = await objectBody(request); return json(enrollHost(body.token, body.inventory), { headers: { 'Cache-Control': 'no-store' } }); }
|
||||
catch (cause) { return hostApiError(cause); }
|
||||
try {
|
||||
const body = await objectBody(request);
|
||||
return json(enrollHost(body.token, body.inventory), {
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { heartbeat, pendingCreationCancellations, shouldDeferPlutoniumPreparation } from '$lib/server/hosts/index.js';
|
||||
import {
|
||||
heartbeat,
|
||||
pendingCreationCancellations,
|
||||
shouldDeferPlutoniumPreparation
|
||||
} from '$lib/server/hosts/index.js';
|
||||
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
|
||||
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
import { reconcileHostLibraryCleanup } from '$lib/server/game-files/index.js';
|
||||
@@ -16,8 +20,15 @@ export async function POST({ request }: { request: Request }) {
|
||||
}
|
||||
reconcileHostPreparation(host.id);
|
||||
reconcileHostLibraryCleanup(host.id);
|
||||
return json({ host, cancelledCreations: pendingCreationCancellations(host.id), heartbeatSeconds: 15,
|
||||
return json({
|
||||
host,
|
||||
cancelledCreations: pendingCreationCancellations(host.id),
|
||||
heartbeatSeconds: 15,
|
||||
deferPlutoniumPreparation: shouldDeferPlutoniumPreparation(host.id),
|
||||
observationKeepDays: fleetSettings().observationKeepDays, serverTime: new Date().toISOString() });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
observationKeepDays: fleetSettings().observationKeepDays,
|
||||
serverTime: new Date().toISOString()
|
||||
});
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,18 @@ import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { requireImportOwner, setImportManifest } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
|
||||
export function GET({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try { return json(requireImportOwner(authenticateAgent(request).id, params.id)); }
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
try {
|
||||
return json(requireImportOwner(authenticateAgent(request).id, params.id));
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
export async function PUT({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try {
|
||||
const host = authenticateAgent(request);
|
||||
const body = await readGameJson(request, 64 * 1024 * 1024);
|
||||
return json(setImportManifest(host.id, params.id, body.files));
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,17 @@ import { gameFileError } from '$lib/server/game-files/http.js';
|
||||
export function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try {
|
||||
const result = requestImportPublication(authenticateAgent(request).id, params.id);
|
||||
return json(result, { status: result.status === 'verifying' || result.status === 'pending' ? 202 : 200 });
|
||||
return json(result, {
|
||||
status: result.status === 'verifying' || result.status === 'pending' ? 202 : 200
|
||||
});
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
export function GET({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try { return json(importPublicationStatus(authenticateAgent(request).id, params.id)); }
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
try {
|
||||
return json(importPublicationStatus(authenticateAgent(request).id, params.id));
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,18 @@ import { json } from '@sveltejs/kit';
|
||||
import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { uploadImportFile } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError } from '$lib/server/game-files/http.js';
|
||||
export async function PUT({ request, params }: { request: Request; params: { id: string; path: string } }) {
|
||||
export async function PUT({
|
||||
request,
|
||||
params
|
||||
}: {
|
||||
request: Request;
|
||||
params: { id: string; path: string };
|
||||
}) {
|
||||
try {
|
||||
const host = authenticateAgent(request);
|
||||
await uploadImportFile(host.id, params.id, params.path, request.body);
|
||||
return json({ uploaded: true });
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
|
||||
export async function GET() { return new Response(await agentReleaseAsset('install.sh'), { headers: { 'Content-Type': 'text/x-shellscript', 'Cache-Control': 'no-cache' } }); }
|
||||
export async function GET() {
|
||||
return new Response(await agentReleaseAsset('install.sh'), {
|
||||
headers: { 'Content-Type': 'text/x-shellscript', 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { gameFileError, serveGameFile } from '$lib/server/game-files/http.js';
|
||||
export async function GET({ request, params }: { request: Request; params: { id: string; path: string } }) {
|
||||
try { authenticateAgent(request); return await serveGameFile(request, params.id, params.path); }
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
export async function GET({
|
||||
request,
|
||||
params
|
||||
}: {
|
||||
request: Request;
|
||||
params: { id: string; path: string };
|
||||
}) {
|
||||
try {
|
||||
authenticateAgent(request);
|
||||
return await serveGameFile(request, params.id, params.path);
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
export const HEAD = GET;
|
||||
|
||||
@@ -6,8 +6,18 @@ export function GET({ request, params }: { request: Request; params: { id: strin
|
||||
try {
|
||||
authenticateAgent(request);
|
||||
const version = getLibraryVersion(params.id);
|
||||
if (!version || version.state !== 'ready') return json({ error: 'Published library not found.' }, { status: 404 });
|
||||
if (version.filesRemoved || version.filesRemoving) return json({ error: 'These outdated game files have been removed.' }, { status: 410 });
|
||||
return json({ id: version.id, game: version.game, files: version.files, sizeBytes: version.sizeBytes, manifestSha256: version.manifestSha256 });
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
if (!version || version.state !== 'ready')
|
||||
return json({ error: 'Published library not found.' }, { status: 404 });
|
||||
if (version.filesRemoved || version.filesRemoving)
|
||||
return json({ error: 'These outdated game files have been removed.' }, { status: 410 });
|
||||
return json({
|
||||
id: version.id,
|
||||
game: version.game,
|
||||
files: version.files,
|
||||
sizeBytes: version.sizeBytes,
|
||||
manifestSha256: version.manifestSha256
|
||||
});
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ import { updateHostLibraryCaches } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
|
||||
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try {
|
||||
const host = authenticateAgent(request); const body = await readGameJson(request);
|
||||
const host = authenticateAgent(request);
|
||||
const body = await readGameJson(request);
|
||||
updateHostLibraryCaches(host.id, [{ ...body, libraryVersionId: params.id }]);
|
||||
return json({ ok: true });
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ import { hostOperationsResponse } from '$lib/server/hosts/delivery.js';
|
||||
export async function GET({ request }: { request: Request }) {
|
||||
const host = authenticateAgent(request);
|
||||
const params = new URL(request.url).searchParams;
|
||||
const lane = params.get('lane') === 'health' ? 'health' : params.get('lane') === 'read' ? 'read' : params.get('lane') === 'write' ? 'write' : 'all';
|
||||
const lane =
|
||||
params.get('lane') === 'health'
|
||||
? 'health'
|
||||
: params.get('lane') === 'read'
|
||||
? 'read'
|
||||
: params.get('lane') === 'write'
|
||||
? 'write'
|
||||
: 'all';
|
||||
let operations = pendingHostOperationRows(host.id, lane, false);
|
||||
if (!operations.length && params.get('wait') === '20') {
|
||||
await waitForHostWork(host.id, request.signal);
|
||||
|
||||
@@ -6,12 +6,22 @@ export async function POST({ request, params }: { request: Request; params: { id
|
||||
const host = authenticateAgent(request);
|
||||
try {
|
||||
const body = await objectBody(request, 128 * 1024 * 1024);
|
||||
const operation = reportHostOperationStatus(host.id, params.id, { state: body.state, result: body.result, error: body.error });
|
||||
if (['completed', 'failed', 'unknown'].includes(operation.state)
|
||||
&& (operation.kind.startsWith('instance.') && operation.kind !== 'instance.inspect' || operation.kind === 'files.stage')) {
|
||||
void kickProvisioning().catch(error => console.error('[agent] provisioning wake failed:', error));
|
||||
const operation = reportHostOperationStatus(host.id, params.id, {
|
||||
state: body.state,
|
||||
result: body.result,
|
||||
error: body.error
|
||||
});
|
||||
if (
|
||||
['completed', 'failed', 'unknown'].includes(operation.state) &&
|
||||
((operation.kind.startsWith('instance.') && operation.kind !== 'instance.inspect') ||
|
||||
operation.kind === 'files.stage')
|
||||
) {
|
||||
void kickProvisioning().catch((error) =>
|
||||
console.error('[agent] provisioning wake failed:', error)
|
||||
);
|
||||
}
|
||||
return json({ operation: { id: operation.id, state: operation.state } });
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { claimRemovalAuthorization } from '$lib/server/autoscaling/authorization
|
||||
|
||||
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const host = authenticateAgent(request);
|
||||
try { return json(claimRemovalAuthorization(host.id, params.id, await objectBody(request, 4096))); }
|
||||
catch (cause) { return hostApiError(cause); }
|
||||
try {
|
||||
return json(claimRemovalAuthorization(host.id, params.id, await objectBody(request, 4096)));
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
|
||||
export async function GET({ params }: { params: { file: string } }) {
|
||||
if (!['ec-agent.py','ec-agent-update.py','manifest.json'].includes(params.file)) error(404, 'Release asset not found.');
|
||||
return new Response(await agentReleaseAsset(params.file as 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'), { headers: { 'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain', 'Cache-Control': 'no-cache' } });
|
||||
if (!['ec-agent.py', 'ec-agent-update.py', 'manifest.json'].includes(params.file))
|
||||
error(404, 'Release asset not found.');
|
||||
return new Response(
|
||||
await agentReleaseAsset(
|
||||
params.file as 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js';
|
||||
import { authBody, authError, sessionCookie, sessionResponse } from '$lib/server/auth/http.js';
|
||||
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
|
||||
try {
|
||||
const result = await authenticatePassword(await authBody(request),getClientAddress());
|
||||
const result = await authenticatePassword(await authBody(request), getClientAddress());
|
||||
revokeSession(cookies.get(sessionCookie(url)));
|
||||
return sessionResponse(result,cookies,url);
|
||||
} catch (cause) { return authError(cause); }
|
||||
return sessionResponse(result, cookies, url);
|
||||
} catch (cause) {
|
||||
return authError(cause);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { recordAudit, revokeSession } from '$lib/server/auth/index.js';
|
||||
import { sessionCookie } from '$lib/server/auth/http.js';
|
||||
export const POST: RequestHandler = ({ cookies, url, locals }) => {
|
||||
revokeSession(cookies.get(sessionCookie(url)));
|
||||
cookies.delete(sessionCookie(url),{path:'/'});
|
||||
recordAudit(locals.user?.id??null,'logout',locals.user?.id??null);
|
||||
return json({ ok:true });
|
||||
cookies.delete(sessionCookie(url), { path: '/' });
|
||||
recordAudit(locals.user?.id ?? null, 'logout', locals.user?.id ?? null);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
export const GET: RequestHandler = ({ locals }) => json({ user:locals.user },{headers:{'Cache-Control':'no-store'}});
|
||||
export const GET: RequestHandler = ({ locals }) =>
|
||||
json({ user: locals.user }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
|
||||
@@ -4,8 +4,14 @@ import { authBody, authError, requireUser, sessionResponse } from '$lib/server/a
|
||||
export const POST: RequestHandler = async ({ request, cookies, locals, url, getClientAddress }) => {
|
||||
try {
|
||||
const user = requireUser(locals.user);
|
||||
consumeAuthAttempt(getClientAddress(),user.id,'password');
|
||||
consumeAuthAttempt(getClientAddress(), user.id, 'password');
|
||||
const body = await authBody(request);
|
||||
return sessionResponse(await changePassword(user.id,body.currentPassword,body.newPassword),cookies,url);
|
||||
} catch (cause) { return authError(cause); }
|
||||
return sessionResponse(
|
||||
await changePassword(user.id, body.currentPassword, body.newPassword),
|
||||
cookies,
|
||||
url
|
||||
);
|
||||
} catch (cause) {
|
||||
return authError(cause);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,13 @@ import type { RequestHandler } from './$types';
|
||||
import { bootstrapAdmin } from '$lib/server/auth/index.js';
|
||||
import { authBody, authError, sessionResponse } from '$lib/server/auth/http.js';
|
||||
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
|
||||
try { return sessionResponse(await bootstrapAdmin(await authBody(request),getClientAddress()),cookies,url); }
|
||||
catch (cause) { return authError(cause); }
|
||||
try {
|
||||
return sessionResponse(
|
||||
await bootstrapAdmin(await authBody(request), getClientAddress()),
|
||||
cookies,
|
||||
url
|
||||
);
|
||||
} catch (cause) {
|
||||
return authError(cause);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { saveScaling, deleteScaling, scalingReport, setScalingGlobalPause, ScalingError } from '$lib/server/autoscaling/index.js';
|
||||
import {
|
||||
saveScaling,
|
||||
deleteScaling,
|
||||
scalingReport,
|
||||
setScalingGlobalPause,
|
||||
ScalingError
|
||||
} from '$lib/server/autoscaling/index.js';
|
||||
|
||||
export function GET() { return json(scalingReport()); }
|
||||
export function GET() {
|
||||
return json(scalingReport());
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ScalingError('Provide a policy action.');
|
||||
if (body.id !== undefined && typeof body.id !== 'string') throw new ScalingError('Invalid policy identity.');
|
||||
if (body.action === 'save') return json({ policy: saveScaling(body.settings, body.id, body.revision) });
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new ScalingError('Provide a policy action.');
|
||||
if (body.id !== undefined && typeof body.id !== 'string')
|
||||
throw new ScalingError('Invalid policy identity.');
|
||||
if (body.action === 'save')
|
||||
return json({ policy: saveScaling(body.settings, body.id, body.revision) });
|
||||
if (body.action === 'delete') {
|
||||
if (typeof body.id !== 'string' || !body.id || !Number.isInteger(body.revision) || body.revision < 1) throw new ScalingError('Provide the policy identity and current revision.');
|
||||
if (
|
||||
typeof body.id !== 'string' ||
|
||||
!body.id ||
|
||||
!Number.isInteger(body.revision) ||
|
||||
body.revision < 1
|
||||
)
|
||||
throw new ScalingError('Provide the policy identity and current revision.');
|
||||
deleteScaling(body.id, body.revision);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (body.action === 'global-pause' && typeof body.paused === 'boolean') { setScalingGlobalPause(body.paused); return json({ ok: true }); }
|
||||
if (body.action === 'global-pause' && typeof body.paused === 'boolean') {
|
||||
setScalingGlobalPause(body.paused);
|
||||
return json({ ok: true });
|
||||
}
|
||||
throw new ScalingError('Choose a supported policy action.');
|
||||
} catch (error) { return json({ error: (error as Error).message }, { status: error instanceof ScalingError ? error.status : 400 }); }
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: error instanceof ScalingError ? error.status : 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
openBeta,
|
||||
BetaError
|
||||
} from '$lib/server/fleet/beta.js';
|
||||
import { openBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import type { BetaServerRow } from '$lib/server/fleet/beta.js';
|
||||
@@ -14,16 +11,29 @@ import { parseBetaInstanceCounts } from '$lib/server/fleet/beta-selection.js';
|
||||
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
const state = url.searchParams.get('state');
|
||||
if (state && !['running','ready-promote','promoted','rolled-back'].includes(state)) return json({ error: 'Invalid beta state.' }, { status: 400 });
|
||||
if (state && !['running', 'ready-promote', 'promoted', 'rolled-back'].includes(state))
|
||||
return json({ error: 'Invalid beta state.' }, { status: 400 });
|
||||
const { betas, ...pagination } = betaListPage(url.searchParams);
|
||||
const ids = JSON.stringify(betas.map(beta => beta.id));
|
||||
const bundles = betaScriptMetadata(betas.map(beta => beta.id));
|
||||
const plans = new Map((db().prepare(`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r
|
||||
const ids = JSON.stringify(betas.map((beta) => beta.id));
|
||||
const bundles = betaScriptMetadata(betas.map((beta) => beta.id));
|
||||
const plans = new Map(
|
||||
(
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r
|
||||
WHERE r.beta_id=p.beta_id ORDER BY revision DESC LIMIT 1), b.opened_at) started_at
|
||||
FROM beta_test_plans p JOIN betas b ON b.id=p.beta_id
|
||||
WHERE p.beta_id IN (SELECT value FROM json_each(?))`).all(ids) as Array<StoredPlan & { started_at: string }>).map(plan => [plan.beta_id, plan]));
|
||||
const members = db().prepare('SELECT * FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(?)) ORDER BY server').all(ids) as BetaServerRow[];
|
||||
const rows = betas.map(b => {
|
||||
WHERE p.beta_id IN (SELECT value FROM json_each(?))`
|
||||
)
|
||||
.all(ids) as Array<StoredPlan & { started_at: string }>
|
||||
).map((plan) => [plan.beta_id, plan])
|
||||
);
|
||||
const members = db()
|
||||
.prepare(
|
||||
'SELECT * FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(?)) ORDER BY server'
|
||||
)
|
||||
.all(ids) as BetaServerRow[];
|
||||
const rows = betas.map((b) => {
|
||||
const scripts = bundles.get(b.id) ?? [];
|
||||
const test = plans.get(b.id);
|
||||
const plan = test ? parseBetaPlan(JSON.parse(test.plan)) : null;
|
||||
@@ -31,9 +41,11 @@ export async function GET({ url }: { url: URL }) {
|
||||
...b,
|
||||
testOutcome: test?.outcome ?? null,
|
||||
testReason: test?.reason ?? null,
|
||||
deadlineAt: plan ? new Date(Date.parse(test!.started_at) + plan.timeoutHours * 3600000).toISOString() : null,
|
||||
deadlineAt: plan
|
||||
? new Date(Date.parse(test!.started_at) + plan.timeoutHours * 3600000).toISOString()
|
||||
: null,
|
||||
scripts,
|
||||
servers: members.filter(member => member.beta_id === b.id)
|
||||
servers: members.filter((member) => member.beta_id === b.id)
|
||||
};
|
||||
});
|
||||
return json({ betas: rows, ...pagination });
|
||||
@@ -54,15 +66,18 @@ export async function POST({ request }: { request: Request }) {
|
||||
notes?: string;
|
||||
testPlan?: BetaPlan;
|
||||
};
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ ok: false, error: 'Expected a JSON object' }, { status: 400 });
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
return json({ ok: false, error: 'Expected a JSON object' }, { status: 400 });
|
||||
|
||||
// Normalize into the openBeta shape. Both fields feed the same list.
|
||||
const rawEntries: Array<{ scriptId?: number; scriptVersionId?: number }> = [];
|
||||
if (Array.isArray(body.artifacts)) {
|
||||
for (const a of body.artifacts) rawEntries.push({ scriptId: a?.id, scriptVersionId: a?.versionId });
|
||||
for (const a of body.artifacts)
|
||||
rawEntries.push({ scriptId: a?.id, scriptVersionId: a?.versionId });
|
||||
}
|
||||
if (Array.isArray(body.scripts)) {
|
||||
for (const s of body.scripts) rawEntries.push({ scriptId: s?.scriptId, scriptVersionId: s?.scriptVersionId });
|
||||
for (const s of body.scripts)
|
||||
rawEntries.push({ scriptId: s?.scriptId, scriptVersionId: s?.scriptVersionId });
|
||||
}
|
||||
if (rawEntries.length === 0) {
|
||||
return json(
|
||||
@@ -71,17 +86,35 @@ export async function POST({ request }: { request: Request }) {
|
||||
);
|
||||
}
|
||||
if ((body.servers !== undefined) === (body.instancesPerGame !== undefined)) {
|
||||
return json({ ok: false, error: 'Provide either instancesPerGame or servers, exclusively' }, { status: 400 });
|
||||
return json(
|
||||
{ ok: false, error: 'Provide either instancesPerGame or servers, exclusively' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (body.instancesPerGame !== undefined) {
|
||||
try { body.instancesPerGame = parseBetaInstanceCounts(body.instancesPerGame); }
|
||||
catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
|
||||
} else if (!Array.isArray(body.servers) || !body.servers.length || !body.servers.every(name => typeof name === 'string' && name.trim())) {
|
||||
return json({ ok: false, error: 'servers must contain at least one server name' }, { status: 400 });
|
||||
try {
|
||||
body.instancesPerGame = parseBetaInstanceCounts(body.instancesPerGame);
|
||||
} catch (error) {
|
||||
return json({ ok: false, error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
} else if (
|
||||
!Array.isArray(body.servers) ||
|
||||
!body.servers.length ||
|
||||
!body.servers.every((name) => typeof name === 'string' && name.trim())
|
||||
) {
|
||||
return json(
|
||||
{ ok: false, error: 'servers must contain at least one server name' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const normalized: Array<{ scriptId: number; scriptVersionId: number }> = [];
|
||||
for (const s of rawEntries) {
|
||||
if (!Number.isSafeInteger(s.scriptId) || !Number.isSafeInteger(s.scriptVersionId) || s.scriptId! < 1 || s.scriptVersionId! < 1) {
|
||||
if (
|
||||
!Number.isSafeInteger(s.scriptId) ||
|
||||
!Number.isSafeInteger(s.scriptVersionId) ||
|
||||
s.scriptId! < 1 ||
|
||||
s.scriptVersionId! < 1
|
||||
) {
|
||||
return json(
|
||||
{ ok: false, error: 'each entry needs scriptId/id and scriptVersionId/versionId' },
|
||||
{ status: 400 }
|
||||
@@ -92,8 +125,11 @@ export async function POST({ request }: { request: Request }) {
|
||||
|
||||
try {
|
||||
let testPlan: BetaPlan | undefined;
|
||||
try { if (body.testPlan !== undefined) testPlan = parseBetaPlan(body.testPlan); }
|
||||
catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
|
||||
try {
|
||||
if (body.testPlan !== undefined) testPlan = parseBetaPlan(body.testPlan);
|
||||
} catch (error) {
|
||||
return json({ ok: false, error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
const result = await openBeta({
|
||||
testPlan,
|
||||
scripts: normalized,
|
||||
@@ -107,7 +143,10 @@ export async function POST({ request }: { request: Request }) {
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
return json(
|
||||
{ ok: false, error: err.message, kind: err.kind, details: err.details },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -21,12 +21,45 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
const servers = listBetaServers(id);
|
||||
|
||||
const { events, nextBefore: eventsNextBefore } = betaActivityPage(id);
|
||||
const campaign = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
|
||||
const promotion = campaign ? { ...campaign,
|
||||
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')").get(campaign.id) as { n: number }).n,
|
||||
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
|
||||
const rollback = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1")
|
||||
const campaign = db()
|
||||
.prepare(
|
||||
"SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1"
|
||||
)
|
||||
.get(id) as { id: string } | undefined;
|
||||
return json({ beta, scripts, servers, events, eventsNextBefore, test: testView(id), promotion, rollback: rollback ?? null,
|
||||
revision: currentBetaRevision(id), revisions: betaRevisionHistory(id) });
|
||||
const promotion = campaign
|
||||
? {
|
||||
...campaign,
|
||||
pending: (
|
||||
db()
|
||||
.prepare(
|
||||
"SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')"
|
||||
)
|
||||
.get(campaign.id) as { n: number }
|
||||
).n,
|
||||
failed: (
|
||||
db()
|
||||
.prepare(
|
||||
"SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'"
|
||||
)
|
||||
.get(campaign.id) as { n: number }
|
||||
).n
|
||||
}
|
||||
: null;
|
||||
const rollback = db()
|
||||
.prepare(
|
||||
"SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1"
|
||||
)
|
||||
.get(id) as { id: string } | undefined;
|
||||
return json({
|
||||
beta,
|
||||
scripts,
|
||||
servers,
|
||||
events,
|
||||
eventsNextBefore,
|
||||
test: testView(id),
|
||||
promotion,
|
||||
rollback: rollback ?? null,
|
||||
revision: currentBetaRevision(id),
|
||||
revisions: betaRevisionHistory(id)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { betaActivityPage } from '$lib/server/fleet/beta-pages.js';
|
||||
|
||||
export function GET({ params, url }: { params: { id: string }; url: URL }) {
|
||||
const id = Number(params.id);
|
||||
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid beta ID.' }, { status: 400 });
|
||||
if (!Number.isSafeInteger(id) || id < 1)
|
||||
return json({ error: 'Invalid beta ID.' }, { status: 400 });
|
||||
if (!getBeta(id)) return json({ error: 'Beta not found.' }, { status: 404 });
|
||||
return json(betaActivityPage(id, url.searchParams));
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ function parseId(raw: string): number | null {
|
||||
export async function GET({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'Invalid beta ID.' }, { status: 400 });
|
||||
try { return json({ preview: previewBetaPromotion(id) }, { headers: { 'Cache-Control': 'no-store' } }); }
|
||||
catch (err) {
|
||||
try {
|
||||
return json(
|
||||
{ preview: previewBetaPromotion(id) },
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) return json({ error: err.message }, { status: 409 });
|
||||
throw err;
|
||||
}
|
||||
@@ -20,13 +24,20 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const body = await request.json().catch(() => null);
|
||||
if (typeof body?.previewToken !== 'string') return json({ ok: false, error: 'Review promotion targets before confirming.' }, { status: 400 });
|
||||
if (typeof body?.previewToken !== 'string')
|
||||
return json(
|
||||
{ ok: false, error: 'Review promotion targets before confirming.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
try {
|
||||
const result = await promoteBeta(id, body.previewToken);
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
return json(
|
||||
{ ok: false, error: err.message, kind: err.kind, details: err.details },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -17,31 +17,67 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
const betaId = parseId(params.id);
|
||||
if (betaId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const beta = getBeta(betaId);
|
||||
if (!beta || !['running', 'ready-promote'].includes(beta.state)) return json({ ok: false, error: 'Beta is closed or missing' }, { status: 409 });
|
||||
if (storedPlan(betaId)) return json({ ok: false, error: 'Use the test plan retry action to collect fresh evidence' }, { status: 409 });
|
||||
if (!beta || !['running', 'ready-promote'].includes(beta.state))
|
||||
return json({ ok: false, error: 'Beta is closed or missing' }, { status: 409 });
|
||||
if (storedPlan(betaId))
|
||||
return json(
|
||||
{ ok: false, error: 'Use the test plan retry action to collect fresh evidence' },
|
||||
{ status: 409 }
|
||||
);
|
||||
const body = (await request.json().catch(() => ({}))) as { server?: string };
|
||||
if (!body.server) return json({ ok: false, error: 'server is required' }, { status: 400 });
|
||||
|
||||
const row = db()
|
||||
.prepare('SELECT stage, deployed_at, restarted_at, healthy_at, server_deleted_at FROM beta_servers WHERE beta_id = ? AND server = ?')
|
||||
.prepare(
|
||||
'SELECT stage, deployed_at, restarted_at, healthy_at, server_deleted_at FROM beta_servers WHERE beta_id = ? AND server = ?'
|
||||
)
|
||||
.get(betaId, body.server) as
|
||||
| { stage: string; deployed_at: string | null; restarted_at: string | null; healthy_at: string | null; server_deleted_at: string | null }
|
||||
| {
|
||||
stage: string;
|
||||
deployed_at: string | null;
|
||||
restarted_at: string | null;
|
||||
healthy_at: string | null;
|
||||
server_deleted_at: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return json({ ok: false, error: 'no such beta_server' }, { status: 404 });
|
||||
if (!isActiveBetaServer(betaId, body.server)) return json({ ok: false, error: 'This beta server was deleted; open a new beta for its replacement.' }, { status: 409 });
|
||||
if (row.stage !== 'failed') return json({ ok: false, error: `stage is ${row.stage}, not failed` }, { status: 409 });
|
||||
if (stagedFileChanges('beta', String(betaId), body.server).some(change => change.state === 'applying')) {
|
||||
return json({ ok: false, error: 'A file write was interrupted. Inspect the server and roll back before opening a fresh beta.' }, { status: 409 });
|
||||
if (!isActiveBetaServer(betaId, body.server))
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: 'This beta server was deleted; open a new beta for its replacement.'
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
if (row.stage !== 'failed')
|
||||
return json({ ok: false, error: `stage is ${row.stage}, not failed` }, { status: 409 });
|
||||
if (
|
||||
stagedFileChanges('beta', String(betaId), body.server).some(
|
||||
(change) => change.state === 'applying'
|
||||
)
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: 'A file write was interrupted. Inspect the server and roll back before opening a fresh beta.'
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Preparation may have failed before any bytes were queued (for example,
|
||||
// an overlay without a baseline). Retrying must prepare its payload first.
|
||||
if (!row.deployed_at && !row.restarted_at) {
|
||||
try { await deployBetaServers(betaId, [body.server]); }
|
||||
catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 409 }); }
|
||||
const prepared = db().prepare('SELECT stage, error FROM beta_servers WHERE beta_id = ? AND server = ?')
|
||||
try {
|
||||
await deployBetaServers(betaId, [body.server]);
|
||||
} catch (error) {
|
||||
return json({ ok: false, error: (error as Error).message }, { status: 409 });
|
||||
}
|
||||
const prepared = db()
|
||||
.prepare('SELECT stage, error FROM beta_servers WHERE beta_id = ? AND server = ?')
|
||||
.get(betaId, body.server) as { stage: string; error: string | null };
|
||||
if (prepared.stage === 'failed') return json({ ok: false, error: prepared.error }, { status: 409 });
|
||||
if (prepared.stage === 'failed')
|
||||
return json({ ok: false, error: prepared.error }, { status: 409 });
|
||||
}
|
||||
|
||||
// Best-effort resume point: if we already got past restart, go back to
|
||||
@@ -50,7 +86,9 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
if (!row.restarted_at) releaseRestartRequest(body.server, { kind: 'beta', id: String(betaId) });
|
||||
const now = new Date().toISOString();
|
||||
db()
|
||||
.prepare('UPDATE beta_servers SET stage = ?, error = NULL, updated_at = ? WHERE beta_id = ? AND server = ?')
|
||||
.prepare(
|
||||
'UPDATE beta_servers SET stage = ?, error = NULL, updated_at = ? WHERE beta_id = ? AND server = ?'
|
||||
)
|
||||
.run(nextStage, now, betaId, body.server);
|
||||
emit({ type: 'beta-server-stage', betaId, server: body.server, stage: nextStage, error: null });
|
||||
return json({ ok: true, stage: nextStage });
|
||||
|
||||
@@ -5,24 +5,39 @@ import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||
const id = Number(params.id);
|
||||
if (!Number.isSafeInteger(id) || id < 1) return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 });
|
||||
if (!Number.isSafeInteger(id) || id < 1)
|
||||
return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 });
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const expectedRevision = Number(form.get('expectedRevision'));
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) throw new Error('A reviewed revision is required.');
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)
|
||||
throw new Error('A reviewed revision is required.');
|
||||
const uploads: BetaRevisionUpload[] = [];
|
||||
for (const [key, value] of form) {
|
||||
if (!key.startsWith('file:')) continue;
|
||||
const scriptId = Number(key.slice(5));
|
||||
if (!Number.isSafeInteger(scriptId) || scriptId < 1 || !(value instanceof File)) throw new Error('Invalid replacement file.');
|
||||
uploads.push({ scriptId, content: Buffer.from(await value.arrayBuffer()),
|
||||
releaseVersion: form.has(`release:${scriptId}`) ? String(form.get(`release:${scriptId}`)) : undefined });
|
||||
if (!Number.isSafeInteger(scriptId) || scriptId < 1 || !(value instanceof File))
|
||||
throw new Error('Invalid replacement file.');
|
||||
uploads.push({
|
||||
scriptId,
|
||||
content: Buffer.from(await value.arrayBuffer()),
|
||||
releaseVersion: form.has(`release:${scriptId}`)
|
||||
? String(form.get(`release:${scriptId}`))
|
||||
: undefined
|
||||
});
|
||||
}
|
||||
const result = await reviseBeta(id, expectedRevision, uploads, String(form.get('notes') ?? ''));
|
||||
const result = await reviseBeta(
|
||||
id,
|
||||
expectedRevision,
|
||||
uploads,
|
||||
String(form.get('notes') ?? '')
|
||||
);
|
||||
void kick();
|
||||
return json({ ok: true, result });
|
||||
} catch (error) {
|
||||
return json({ ok: false, error: (error as Error).message },
|
||||
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 });
|
||||
return json(
|
||||
{ ok: false, error: (error as Error).message },
|
||||
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,35 @@ import { blobContent } from '$lib/server/db/file-blobs.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
/** Current files can be reviewed before the first revision is archived. */
|
||||
export async function GET({ params }: { params: { id: string; revision: string; scriptId: string } }) {
|
||||
export async function GET({
|
||||
params
|
||||
}: {
|
||||
params: { id: string; revision: string; scriptId: string };
|
||||
}) {
|
||||
const values = [params.id, params.revision, params.scriptId].map(Number);
|
||||
if (values.some(value => !Number.isSafeInteger(value) || value < 1)) return new Response('Invalid file', { status: 400 });
|
||||
if (values.some((value) => !Number.isSafeInteger(value) || value < 1))
|
||||
return new Response('Invalid file', { status: 400 });
|
||||
const [id, revision, scriptId] = values;
|
||||
const file = db().prepare(`SELECT ${blobContent('content_hash')} AS content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?`)
|
||||
.get(id, revision, scriptId) as { content: Buffer } | undefined
|
||||
?? (revision === 1 ? db().prepare(`SELECT ${blobContent('v.content_hash')} AS content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id
|
||||
WHERE bs.beta_id = ? AND bs.script_id = ? AND NOT EXISTS (SELECT 1 FROM beta_revisions WHERE beta_id = ?)`)
|
||||
.get(id, scriptId, id) as { content: Buffer } | undefined : undefined);
|
||||
const file =
|
||||
(db()
|
||||
.prepare(
|
||||
`SELECT ${blobContent('content_hash')} AS content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?`
|
||||
)
|
||||
.get(id, revision, scriptId) as { content: Buffer } | undefined) ??
|
||||
(revision === 1
|
||||
? (db()
|
||||
.prepare(
|
||||
`SELECT ${blobContent('v.content_hash')} AS content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id
|
||||
WHERE bs.beta_id = ? AND bs.script_id = ? AND NOT EXISTS (SELECT 1 FROM beta_revisions WHERE beta_id = ?)`
|
||||
)
|
||||
.get(id, scriptId, id) as { content: Buffer } | undefined)
|
||||
: undefined);
|
||||
if (!file?.content) return new Response('File not found', { status: 404 });
|
||||
return new Response(new Uint8Array(file.content), { headers: { 'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': 'attachment', 'Cache-Control': 'no-store' } });
|
||||
return new Response(new Uint8Array(file.content), {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': 'attachment',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ export async function POST({ params }: { params: { id: string } }) {
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
return json(
|
||||
{ ok: false, error: err.message, kind: err.kind, details: err.details },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -3,78 +3,151 @@ import { releaseRestartRequest } from '$lib/server/provisioning/index.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { deployBetaServers, getBeta } from '$lib/server/fleet/beta.js';
|
||||
import { stagedFileChanges } from '$lib/server/fleet/staged-files.js';
|
||||
import { activity, currentRun, evidence, readMetrics, saveMetrics, setOutcome, storedPlan } from '$lib/server/fleet/beta-tests.js';
|
||||
import {
|
||||
activity,
|
||||
currentRun,
|
||||
evidence,
|
||||
readMetrics,
|
||||
saveMetrics,
|
||||
setOutcome,
|
||||
storedPlan
|
||||
} from '$lib/server/fleet/beta-tests.js';
|
||||
import { betaState, plannedBetaTick } from '$lib/server/pipeline/beta-plan-runner.js';
|
||||
import { parseBetaPlan } from '$lib/beta-plan.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||
const id = Number(params.id);
|
||||
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid beta ID' }, { status: 400 });
|
||||
if (!Number.isSafeInteger(id) || id < 1)
|
||||
return json({ error: 'Invalid beta ID' }, { status: 400 });
|
||||
await plannedBetaTick();
|
||||
const beta = getBeta(id);
|
||||
const stored = storedPlan(id);
|
||||
if (!beta || !stored) return json({ error: 'Test plan not found' }, { status: 404 });
|
||||
if (!['running', 'ready-promote'].includes(beta.state)) return json({ error: 'Beta is closed' }, { status: 409 });
|
||||
if (!['running', 'ready-promote'].includes(beta.state))
|
||||
return json({ error: 'Beta is closed' }, { status: 409 });
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (db().prepare('SELECT 1 FROM beta_servers WHERE beta_id = ? AND server_deleted_at IS NOT NULL').get(id)) {
|
||||
return json({ ok: false, error: 'A beta server was deleted. Roll back this beta and open a new one to collect fresh evidence.' }, { status: 409 });
|
||||
if (
|
||||
db()
|
||||
.prepare(
|
||||
'SELECT 1 FROM beta_servers WHERE beta_id = ? AND server_deleted_at IS NOT NULL'
|
||||
)
|
||||
.get(id)
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: 'A beta server was deleted. Roll back this beta and open a new one to collect fresh evidence.'
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
const plan = parseBetaPlan(JSON.parse(stored.plan));
|
||||
if (body.action === 'sign-off') {
|
||||
if (!['active', 'passed'].includes(stored.outcome)) throw new Error('Resume or retry the test before signing off');
|
||||
const requirement = plan.requirements.find(r => r.id === body.requirementId && r.kind === 'manual');
|
||||
if (!['active', 'passed'].includes(stored.outcome))
|
||||
throw new Error('Resume or retry the test before signing off');
|
||||
const requirement = plan.requirements.find(
|
||||
(r) => r.id === body.requirementId && r.kind === 'manual'
|
||||
);
|
||||
const run = typeof body.server === 'string' ? currentRun(id, body.server) : undefined;
|
||||
if (!requirement || !run?.observing_at) throw new Error('Checklist item or active server run not found');
|
||||
if (typeof body.note !== 'string' || !body.note.trim() || body.note.length > 1000) throw new Error('Add a tester name and observation (up to 1000 characters)');
|
||||
if (!requirement || !run?.observing_at)
|
||||
throw new Error('Checklist item or active server run not found');
|
||||
if (typeof body.note !== 'string' || !body.note.trim() || body.note.length > 1000)
|
||||
throw new Error('Add a tester name and observation (up to 1000 characters)');
|
||||
const metrics = readMetrics(run);
|
||||
metrics.manual[requirement.id] = body.note.trim();
|
||||
saveMetrics(run, metrics);
|
||||
evidence(run, `manual:${requirement.id}:${Date.now()}`, 'manual-sign-off', { requirement: requirement.label, note: body.note.trim() });
|
||||
evidence(run, `manual:${requirement.id}:${Date.now()}`, 'manual-sign-off', {
|
||||
requirement: requirement.label,
|
||||
note: body.note.trim()
|
||||
});
|
||||
activity(id, `${body.server}: ${requirement.label} signed off: ${body.note.trim()}`);
|
||||
} else if (body.action === 'pause') {
|
||||
setOutcome(id, 'paused', 'Paused by operator; deployment and evidence evaluation held');
|
||||
betaState(id, false);
|
||||
} else if (body.action === 'resume') {
|
||||
if (stored.outcome !== 'paused') throw new Error('Only paused plans can resume; failed or expired plans need a fresh attempt');
|
||||
const runs = db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL').all(id) as Parameters<typeof readMetrics>[0][];
|
||||
if (stored.outcome !== 'paused')
|
||||
throw new Error(
|
||||
'Only paused plans can resume; failed or expired plans need a fresh attempt'
|
||||
);
|
||||
const runs = db()
|
||||
.prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL')
|
||||
.all(id) as Parameters<typeof readMetrics>[0][];
|
||||
for (const run of runs) {
|
||||
const metrics = readMetrics(run);
|
||||
delete metrics.match;
|
||||
delete metrics.lastSample;
|
||||
delete metrics.wasHealthy;
|
||||
metrics.streaks = {};
|
||||
for (const r of plan.requirements) if (r.kind === 'healthy-minutes') metrics.values[r.id] = 0;
|
||||
for (const r of plan.requirements)
|
||||
if (r.kind === 'healthy-minutes') metrics.values[r.id] = 0;
|
||||
saveMetrics(run, metrics);
|
||||
}
|
||||
setOutcome(id, 'active', null);
|
||||
} else if (body.action === 'retry') {
|
||||
// Fresh attempts keep history and the current revision's deadline.
|
||||
if (stored.outcome === 'inconclusive') throw new Error('The testing window ended. Update the files to start a new revision, or roll back and open a new beta with a longer deadline');
|
||||
if (stagedFileChanges('beta', String(id)).some(change => change.state === 'applying')) {
|
||||
throw new Error('A file write was interrupted. Inspect the server and roll back before opening a fresh beta');
|
||||
if (stored.outcome === 'inconclusive')
|
||||
throw new Error(
|
||||
'The testing window ended. Update the files to start a new revision, or roll back and open a new beta with a longer deadline'
|
||||
);
|
||||
if (
|
||||
stagedFileChanges('beta', String(id)).some((change) => change.state === 'applying')
|
||||
) {
|
||||
throw new Error(
|
||||
'A file write was interrupted. Inspect the server and roll back before opening a fresh beta'
|
||||
);
|
||||
}
|
||||
const prepare = db().prepare("SELECT server FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL AND restarted_at IS NULL")
|
||||
const prepare = db()
|
||||
.prepare(
|
||||
"SELECT server FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL AND restarted_at IS NULL"
|
||||
)
|
||||
.all(id) as { server: string }[];
|
||||
if (prepare.length) {
|
||||
await deployBetaServers(id, prepare.map(row => row.server));
|
||||
const failed = db().prepare("SELECT error FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL").get(id) as { error: string | null } | undefined;
|
||||
await deployBetaServers(
|
||||
id,
|
||||
prepare.map((row) => row.server)
|
||||
);
|
||||
const failed = db()
|
||||
.prepare(
|
||||
"SELECT error FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL"
|
||||
)
|
||||
.get(id) as { error: string | null } | undefined;
|
||||
if (failed) throw new Error(failed.error ?? 'Could not prepare beta files');
|
||||
}
|
||||
db().transaction(() => {
|
||||
const servers = db().prepare('SELECT server FROM beta_servers WHERE beta_id = ?').all(id) as { server: string }[];
|
||||
for (const { server } of servers) releaseRestartRequest(server, { kind: 'beta', id: String(id) });
|
||||
db().prepare('UPDATE beta_test_runs SET retired_at = ? WHERE beta_id = ? AND retired_at IS NULL').run(new Date().toISOString(), id);
|
||||
db().prepare("UPDATE beta_servers SET stage = 'awaiting-empty', error = NULL, restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error NOT LIKE 'deploy:%'").run(id);
|
||||
const servers = db()
|
||||
.prepare('SELECT server FROM beta_servers WHERE beta_id = ?')
|
||||
.all(id) as { server: string }[];
|
||||
for (const { server } of servers)
|
||||
releaseRestartRequest(server, { kind: 'beta', id: String(id) });
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE beta_test_runs SET retired_at = ? WHERE beta_id = ? AND retired_at IS NULL'
|
||||
)
|
||||
.run(new Date().toISOString(), id);
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE beta_servers SET stage = 'awaiting-empty', error = NULL, restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error NOT LIKE 'deploy:%'"
|
||||
)
|
||||
.run(id);
|
||||
// SQL NULL does not satisfy NOT LIKE.
|
||||
db().prepare("UPDATE beta_servers SET stage = 'awaiting-empty', restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error IS NULL").run(id);
|
||||
db()
|
||||
.prepare(
|
||||
"UPDATE beta_servers SET stage = 'awaiting-empty', restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error IS NULL"
|
||||
)
|
||||
.run(id);
|
||||
betaState(id, false);
|
||||
setOutcome(id, 'active', null);
|
||||
activity(id, 'Fresh test attempt requested; deployed servers will restart when empty');
|
||||
activity(
|
||||
id,
|
||||
'Fresh test attempt requested; deployed servers will restart when empty'
|
||||
);
|
||||
})();
|
||||
} else throw new Error('Unknown test action');
|
||||
void kick();
|
||||
return json({ ok: true });
|
||||
} catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
|
||||
} catch (error) {
|
||||
return json({ ok: false, error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,31 @@ import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { builtInPlans, parseBetaPlan } from '$lib/beta-plan.js';
|
||||
export function GET() {
|
||||
const saved = db().prepare('SELECT name, plan FROM beta_test_presets ORDER BY name').all() as Array<{ name: string; plan: string }>;
|
||||
return json({ presets: [...builtInPlans, ...saved.map(p => ({ name: p.name, plan: JSON.parse(p.plan) }))] });
|
||||
const saved = db()
|
||||
.prepare('SELECT name, plan FROM beta_test_presets ORDER BY name')
|
||||
.all() as Array<{ name: string; plan: string }>;
|
||||
return json({
|
||||
presets: [
|
||||
...builtInPlans,
|
||||
...saved.map((p) => ({ name: p.name, plan: JSON.parse(p.plan) }))
|
||||
]
|
||||
});
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80) throw new Error('Preset name must be 1–80 characters');
|
||||
if (builtInPlans.some(p => p.name === body.name.trim())) throw new Error('Choose a name other than a built-in preset');
|
||||
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80)
|
||||
throw new Error('Preset name must be 1–80 characters');
|
||||
if (builtInPlans.some((p) => p.name === body.name.trim()))
|
||||
throw new Error('Choose a name other than a built-in preset');
|
||||
const plan = parseBetaPlan(body.plan);
|
||||
db().prepare('INSERT INTO beta_test_presets (name, plan) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET plan = excluded.plan')
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO beta_test_presets (name, plan) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET plan = excluded.plan'
|
||||
)
|
||||
.run(body.name.trim(), JSON.stringify(plan));
|
||||
return json({ ok: true });
|
||||
} catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
|
||||
} catch (error) {
|
||||
return json({ ok: false, error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listBlueprints, saveBlueprint } from '$lib/server/fleet/launch-recipes.js';
|
||||
|
||||
export function GET() { return json({ blueprints: listBlueprints() }); }
|
||||
export function GET() {
|
||||
return json({ blueprints: listBlueprints() });
|
||||
}
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection name and a launch recipe.');
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new Error('Provide a collection name and a launch recipe.');
|
||||
return json({ blueprint: saveBlueprint(body) }, { status: 201 });
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
|
||||
@@ -2,13 +2,18 @@ import { json } from '@sveltejs/kit';
|
||||
import { deleteBlueprint, updateBlueprint } from '$lib/server/fleet/launch-recipes.js';
|
||||
|
||||
export function DELETE({ params }: { params: { id: string } }) {
|
||||
return deleteBlueprint(params.id) ? json({ ok: true }) : json({ error: 'Collection not found.' }, { status: 404 });
|
||||
return deleteBlueprint(params.id)
|
||||
? json({ ok: true })
|
||||
: json({ error: 'Collection not found.' }, { status: 404 });
|
||||
}
|
||||
|
||||
export async function PUT({ params, request }: { params: { id: string }; request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection name and map selections.');
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new Error('Provide a collection name and map selections.');
|
||||
return json({ blueprint: updateBlueprint(params.id, body) });
|
||||
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,14 @@ export async function GET({ url }: { url: URL }) {
|
||||
const query = (url.searchParams.get('q') ?? '').slice(0, 100).trim().toLowerCase();
|
||||
const action = url.searchParams.get('action');
|
||||
const status = url.searchParams.get('status');
|
||||
const limit = Math.min(500, Math.max(1, Math.floor(Number(url.searchParams.get('limit')) || 25)));
|
||||
const offset = Math.min(1_000_000, Math.max(0, Math.floor(Number(url.searchParams.get('offset')) || 0)));
|
||||
const limit = Math.min(
|
||||
500,
|
||||
Math.max(1, Math.floor(Number(url.searchParams.get('limit')) || 25))
|
||||
);
|
||||
const offset = Math.min(
|
||||
1_000_000,
|
||||
Math.max(0, Math.floor(Number(url.searchParams.get('offset')) || 0))
|
||||
);
|
||||
const attention = `EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.server_deleted_at IS NULL AND a.restart_state IN ('failed','held','manual'))`;
|
||||
const pending = `EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.server_deleted_at IS NULL AND a.restart_state='pending')`;
|
||||
const active = `(${attention} OR ${pending})`;
|
||||
@@ -20,14 +26,32 @@ export async function GET({ url }: { url: URL }) {
|
||||
WHEN EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.restart_state != 'done') THEN 'complete'
|
||||
ELSE 'inactive' END`;
|
||||
const names = query ? (db().prepare('SELECT DISTINCT server FROM deployment_servers').all() as { server: string }[])
|
||||
.map(row => row.server).filter(name => `${name} ${serverTitle(name)}`.toLowerCase().includes(query)) : [];
|
||||
const conditions = ['(? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server=?))'];
|
||||
const names = query
|
||||
? (
|
||||
db().prepare('SELECT DISTINCT server FROM deployment_servers').all() as {
|
||||
server: string;
|
||||
}[]
|
||||
)
|
||||
.map((row) => row.server)
|
||||
.filter((name) => `${name} ${serverTitle(name)}`.toLowerCase().includes(query))
|
||||
: [];
|
||||
const conditions = [
|
||||
'(? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server=?))'
|
||||
];
|
||||
const parameters: (string | null)[] = [server, server];
|
||||
if (activeOnly) conditions.push(active);
|
||||
else if (historyOnly) conditions.push(`NOT ${active}`);
|
||||
if (action && ['promote', 'rollback', 'drift'].includes(action)) { conditions.push('d.action=?'); parameters.push(action); }
|
||||
if (status && ['attention', 'progress', 'complete', 'rolled-back', 'inactive'].includes(status)) { conditions.push(`(${outcome})=?`); parameters.push(status); }
|
||||
if (action && ['promote', 'rollback', 'drift'].includes(action)) {
|
||||
conditions.push('d.action=?');
|
||||
parameters.push(action);
|
||||
}
|
||||
if (
|
||||
status &&
|
||||
['attention', 'progress', 'complete', 'rolled-back', 'inactive'].includes(status)
|
||||
) {
|
||||
conditions.push(`(${outcome})=?`);
|
||||
parameters.push(status);
|
||||
}
|
||||
if (query) {
|
||||
conditions.push(`(instr(lower(d.id), ?) > 0
|
||||
OR instr(lower(CASE WHEN d.action='drift' THEN 'maintenance repair' ELSE d.action END), ?) > 0
|
||||
@@ -38,7 +62,9 @@ export async function GET({ url }: { url: URL }) {
|
||||
parameters.push(query, query, query, query, query, ...names);
|
||||
}
|
||||
const where = conditions.join(' AND ');
|
||||
const { total } = db().prepare(`SELECT COUNT(*) AS total FROM deployments d WHERE ${where}`).get(...parameters) as { total: number };
|
||||
const { total } = db()
|
||||
.prepare(`SELECT COUNT(*) AS total FROM deployments d WHERE ${where}`)
|
||||
.get(...parameters) as { total: number };
|
||||
|
||||
const deployments = db()
|
||||
.prepare(
|
||||
@@ -70,7 +96,7 @@ export async function GET({ url }: { url: URL }) {
|
||||
prior_version: number | null;
|
||||
}>;
|
||||
|
||||
const ids = deployments.map(deployment => deployment.id);
|
||||
const ids = deployments.map((deployment) => deployment.id);
|
||||
if (!ids.length) return json({ deployments: [], total, limit, offset });
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const serversByDep = db()
|
||||
@@ -122,12 +148,11 @@ export async function GET({ url }: { url: URL }) {
|
||||
scriptsByDep.set(s.deployment_id, arr);
|
||||
}
|
||||
|
||||
const rows = deployments
|
||||
.map((d) => ({
|
||||
...d,
|
||||
servers: grouped.get(d.id) ?? [],
|
||||
scripts: scriptsByDep.get(d.id) ?? []
|
||||
}));
|
||||
const rows = deployments.map((d) => ({
|
||||
...d,
|
||||
servers: grouped.get(d.id) ?? [],
|
||||
scripts: scriptsByDep.get(d.id) ?? []
|
||||
}));
|
||||
|
||||
return json({ deployments: rows, total, limit, offset });
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export async function GET({ params }: { params: { id: string } }) {
|
||||
const dep = db()
|
||||
.prepare('SELECT * FROM deployments WHERE id = ?')
|
||||
.get(params.id) as Record<string, unknown> | undefined;
|
||||
const dep = db().prepare('SELECT * FROM deployments WHERE id = ?').get(params.id) as
|
||||
Record<string, unknown> | undefined;
|
||||
if (!dep) throw error(404, 'deployment not found');
|
||||
|
||||
const servers = db()
|
||||
@@ -40,8 +39,26 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
)
|
||||
.all(params.id);
|
||||
|
||||
const events = db().prepare('SELECT id, created_at, message FROM campaign_events WHERE deployment_id = ? ORDER BY id').all(params.id);
|
||||
const watchdog = db().prepare('SELECT incident_id FROM watchdog_actions WHERE campaign_id = ? LIMIT 1').get(params.id) as { incident_id: string } | undefined;
|
||||
const fileContentsExpired = !!db().prepare("SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?").get(params.id);
|
||||
return json({ fileContentsExpired, deployment: dep, servers, files, scripts, events, watchdogIncidentId: watchdog?.incident_id ?? null });
|
||||
const events = db()
|
||||
.prepare(
|
||||
'SELECT id, created_at, message FROM campaign_events WHERE deployment_id = ? ORDER BY id'
|
||||
)
|
||||
.all(params.id);
|
||||
const watchdog = db()
|
||||
.prepare('SELECT incident_id FROM watchdog_actions WHERE campaign_id = ? LIMIT 1')
|
||||
.get(params.id) as { incident_id: string } | undefined;
|
||||
const fileContentsExpired = !!db()
|
||||
.prepare(
|
||||
"SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?"
|
||||
)
|
||||
.get(params.id);
|
||||
return json({
|
||||
fileContentsExpired,
|
||||
deployment: dep,
|
||||
servers,
|
||||
files,
|
||||
scripts,
|
||||
events,
|
||||
watchdogIncidentId: watchdog?.incident_id ?? null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,8 +6,17 @@ import { db } from '$lib/server/db/index.js';
|
||||
// so the DiffViewer can render a unified diff without a second query.
|
||||
|
||||
export async function GET({ params }: { params: { id: string; fileId: string } }) {
|
||||
if (db().prepare("SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?").get(params.id)) {
|
||||
return json({ error: 'File contents expired under the retention policy.' }, { status: 410 });
|
||||
if (
|
||||
db()
|
||||
.prepare(
|
||||
"SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?"
|
||||
)
|
||||
.get(params.id)
|
||||
) {
|
||||
return json(
|
||||
{ error: 'File contents expired under the retention policy.' },
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
const row = db()
|
||||
.prepare(
|
||||
@@ -27,7 +36,13 @@ export async function GET({ params }: { params: { id: string; fileId: string } }
|
||||
}
|
||||
| undefined;
|
||||
if (!row) throw error(404, 'file not found');
|
||||
if (row.snapshot_pending) return json({ error: 'The rollback snapshot is not available yet. It is captured immediately before file activation.' }, { status: 409 });
|
||||
if (row.snapshot_pending)
|
||||
return json(
|
||||
{
|
||||
error: 'The rollback snapshot is not available yet. It is captured immediately before file activation.'
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
|
||||
return json({
|
||||
verb: row.verb,
|
||||
|
||||
@@ -5,21 +5,30 @@ import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export function POST({ params }: { params: { id: string } }) {
|
||||
const changed = db().transaction(() => {
|
||||
const rows = db().prepare(`SELECT ds.server, d.state FROM deployment_servers ds
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT ds.server, d.state FROM deployment_servers ds
|
||||
LEFT JOIN server_deployments d ON d.id = ds.provisioning_id
|
||||
WHERE ds.deployment_id = ? AND ds.server_deleted_at IS NULL AND ds.restart_state IN ('held','manual')`)
|
||||
WHERE ds.deployment_id = ? AND ds.server_deleted_at IS NULL AND ds.restart_state IN ('held','manual')`
|
||||
)
|
||||
.all(params.id) as { server: string; state: string | null }[];
|
||||
for (const row of rows) {
|
||||
if (row.state && ['failed','interrupted','cancelled'].includes(row.state)) {
|
||||
if (row.state && ['failed', 'interrupted', 'cancelled'].includes(row.state)) {
|
||||
releaseRestartRequest(row.server, { kind: 'campaign', id: params.id });
|
||||
}
|
||||
}
|
||||
const result = db().prepare(`UPDATE deployment_servers SET restart_state = 'pending', error = NULL
|
||||
WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state IN ('held','manual')`).run(params.id);
|
||||
if (result.changes) db().prepare('UPDATE deployments SET finished_at = NULL WHERE id = ?').run(params.id);
|
||||
const result = db()
|
||||
.prepare(
|
||||
`UPDATE deployment_servers SET restart_state = 'pending', error = NULL
|
||||
WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state IN ('held','manual')`
|
||||
)
|
||||
.run(params.id);
|
||||
if (result.changes)
|
||||
db().prepare('UPDATE deployments SET finished_at = NULL WHERE id = ?').run(params.id);
|
||||
return result.changes;
|
||||
})();
|
||||
if (!changed) return json({ error: 'This campaign has no held changes to resume.' }, { status: 409 });
|
||||
void kick().catch(err => console.error('[campaign] activation kick failed:', err));
|
||||
if (!changed)
|
||||
return json({ error: 'This campaign has no held changes to resume.' }, { status: 409 });
|
||||
void kick().catch((err) => console.error('[campaign] activation kick failed:', err));
|
||||
return json({ ok: true }, { status: 202 });
|
||||
}
|
||||
|
||||
@@ -3,5 +3,7 @@ import { parseCapacityQuery } from '$lib/capacity.js';
|
||||
import { capacityPage } from '$lib/server/hosts/capacity-page.js';
|
||||
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
return json(await capacityPage(parseCapacityQuery(url.searchParams)), { headers: { 'cache-control': 'no-store' } });
|
||||
return json(await capacityPage(parseCapacityQuery(url.searchParams)), {
|
||||
headers: { 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,11 +4,21 @@ import { getScript, getVersion, getLatestVersion } from '$lib/server/fleet/scrip
|
||||
|
||||
export async function GET({ params, url }: { params: { id: string }; url: URL }) {
|
||||
const id = Number(params.id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return json({ error: 'Invalid config ID.' }, { status: 400 });
|
||||
if (!Number.isSafeInteger(id) || id <= 0)
|
||||
return json({ error: 'Invalid config ID.' }, { status: 400 });
|
||||
const script = getScript(id);
|
||||
if (!script || !['config', 'baseline'].includes(script.kind)) return json({ error: 'Config not found.' }, { status: 404 });
|
||||
if (!script || !['config', 'baseline'].includes(script.kind))
|
||||
return json({ error: 'Config not found.' }, { status: 404 });
|
||||
const requested = url.searchParams.get('version');
|
||||
const version = requested === null ? getLatestVersion(id) : getVersion(Number(requested));
|
||||
if (!version || version.script_id !== id) return json({ error: 'Config version not found.' }, { status: 404 });
|
||||
return json({ text: getVersionContent(version.id).toString('utf8'), versionId: version.id, version: version.version }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
if (!version || version.script_id !== id)
|
||||
return json({ error: 'Config version not found.' }, { status: 404 });
|
||||
return json(
|
||||
{
|
||||
text: getVersionContent(version.id).toString('utf8'),
|
||||
versionId: version.id,
|
||||
version: version.version
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,87 @@
|
||||
import { getVersionContent } from '$lib/server/fleet/scripts.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServer, listServers } from '$lib/server/fleet/servers.js';
|
||||
import { getBaselineForScope, getScript, getVersion, listPromotedOverlaysForScope, type ScriptRow } from '$lib/server/fleet/scripts.js';
|
||||
import {
|
||||
getBaselineForScope,
|
||||
getScript,
|
||||
getVersion,
|
||||
listPromotedOverlaysForScope,
|
||||
type ScriptRow
|
||||
} from '$lib/server/fleet/scripts.js';
|
||||
import { inspectConfig, type ConfigLayer } from '$lib/server/fleet/config-inspect.js';
|
||||
import type { ConfigInspection } from '$lib/config-workbench.js';
|
||||
|
||||
function inspect(serverName: string, draft?: { artifactId: number; text: string }): ConfigInspection {
|
||||
function inspect(
|
||||
serverName: string,
|
||||
draft?: { artifactId: number; text: string }
|
||||
): ConfigInspection {
|
||||
const server = getServer(serverName);
|
||||
if (!server || server.destroyed_at) throw new Error('Server not found.');
|
||||
const artifact = draft ? getScript(draft.artifactId) : undefined;
|
||||
if (draft && (!artifact || !['baseline', 'config'].includes(artifact.kind))) throw new Error('Config not found.');
|
||||
if (artifact && (artifact.scope_type === null || artifact.scope_value !== server[artifact.scope_type])) {
|
||||
if (draft && (!artifact || !['baseline', 'config'].includes(artifact.kind)))
|
||||
throw new Error('Config not found.');
|
||||
if (
|
||||
artifact &&
|
||||
(artifact.scope_type === null || artifact.scope_value !== server[artifact.scope_type])
|
||||
) {
|
||||
throw new Error('This config does not apply to the selected server.');
|
||||
}
|
||||
if (artifact?.kind === 'baseline' && artifact.scope_type !== 'game') throw new Error('Server composition requires a game baseline.');
|
||||
const baseline = artifact?.kind === 'baseline' ? artifact : server.game ? getBaselineForScope('game', server.game) : undefined;
|
||||
if (artifact?.kind === 'baseline' && artifact.scope_type !== 'game')
|
||||
throw new Error('Server composition requires a game baseline.');
|
||||
const baseline =
|
||||
artifact?.kind === 'baseline'
|
||||
? artifact
|
||||
: server.game
|
||||
? getBaselineForScope('game', server.game)
|
||||
: undefined;
|
||||
function layer(row: ScriptRow): ConfigLayer | null {
|
||||
const isDraft = row.id === draft?.artifactId;
|
||||
const version = row.current_version_id ? getVersion(row.current_version_id) : undefined;
|
||||
if (!isDraft && !version) return null;
|
||||
return {
|
||||
source: { id: row.id, name: row.name, version: isDraft ? null : version!.version, kind: row.kind as 'baseline' | 'config', draft: isDraft },
|
||||
source: {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
version: isDraft ? null : version!.version,
|
||||
kind: row.kind as 'baseline' | 'config',
|
||||
draft: isDraft
|
||||
},
|
||||
text: isDraft ? draft!.text : getVersionContent(version!.id).toString('utf8')
|
||||
};
|
||||
}
|
||||
const base = baseline ? layer(baseline) : null;
|
||||
if (!base) return { text: '', sources: [], settings: [], warnings: [], reason: 'No promoted baseline for this game. Promote a baseline through a beta to inspect the managed configuration.' };
|
||||
if (!base)
|
||||
return {
|
||||
text: '',
|
||||
sources: [],
|
||||
settings: [],
|
||||
warnings: [],
|
||||
reason: 'No promoted baseline for this game. Promote a baseline through a beta to inspect the managed configuration.'
|
||||
};
|
||||
const overlays = [
|
||||
...(server.game ? listPromotedOverlaysForScope('game', server.game) : []),
|
||||
...(server.map ? listPromotedOverlaysForScope('map', server.map) : [])
|
||||
].filter(row => row.id !== artifact?.id);
|
||||
].filter((row) => row.id !== artifact?.id);
|
||||
if (artifact?.kind === 'config') overlays.push(artifact);
|
||||
return inspectConfig(base, overlays.map(layer).filter((l): l is ConfigLayer => l !== null), server.game, server.map, server.map_slug);
|
||||
return inspectConfig(
|
||||
base,
|
||||
overlays.map(layer).filter((l): l is ConfigLayer => l !== null),
|
||||
server.game,
|
||||
server.map,
|
||||
server.map_slug
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
const servers = listServers().filter(s => !s.destroyed_at).map(({ name, game, map }) => ({ name, game, map }));
|
||||
const servers = listServers()
|
||||
.filter((s) => !s.destroyed_at)
|
||||
.map(({ name, game, map }) => ({ name, game, map }));
|
||||
try {
|
||||
const server = url.searchParams.get('server');
|
||||
return json({ servers, inspection: server ? inspect(server) : null }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
return json(
|
||||
{ servers, inspection: server ? inspect(server) : null },
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
@@ -46,12 +89,24 @@ export async function GET({ url }: { url: URL }) {
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
let body;
|
||||
try { body = await request.json(); } catch { return json({ error: 'Invalid preview request.' }, { status: 400 }); }
|
||||
if (!body || typeof body.server !== 'string' || !Number.isSafeInteger(body.artifactId) || typeof body.text !== 'string') {
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid preview request.' }, { status: 400 });
|
||||
}
|
||||
if (
|
||||
!body ||
|
||||
typeof body.server !== 'string' ||
|
||||
!Number.isSafeInteger(body.artifactId) ||
|
||||
typeof body.text !== 'string'
|
||||
) {
|
||||
return json({ error: 'Server, config ID and text are required.' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return json({ inspection: inspect(body.server, body), promoted: inspect(body.server) }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
return json(
|
||||
{ inspection: inspect(body.server, body), promoted: inspect(body.server) },
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -2,18 +2,26 @@ import { json } from '@sveltejs/kit';
|
||||
import { listCollections, queueCollection } from '$lib/server/fleet/collections.js';
|
||||
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export function GET() { return json({ collections: listCollections() }); }
|
||||
export function GET() {
|
||||
return json({ collections: listCollections() });
|
||||
}
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection preview and its server keys.');
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new Error('Provide a collection preview and its server keys.');
|
||||
const result = queueCollection(body);
|
||||
// The durable queue is committed. Let the response and pending I/O run
|
||||
// before provisioning loads launch files or starts checking removals.
|
||||
if (result.collectionId) setImmediate(() => {
|
||||
void kickProvisioning().catch(err => console.error('[collection] worker kick failed:', err));
|
||||
});
|
||||
if (result.collectionId)
|
||||
setImmediate(() => {
|
||||
void kickProvisioning().catch((err) =>
|
||||
console.error('[collection] worker kick failed:', err)
|
||||
);
|
||||
});
|
||||
return json(result, { status: result.collectionId ? 202 : 200 });
|
||||
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,12 @@ export function POST({ params }: { params: { id: string } }) {
|
||||
return json({ error: 'Collection not found.' }, { status: 404 });
|
||||
}
|
||||
if (!cancelCollection(params.id)) {
|
||||
return json({ error: 'This collection has no work that can still be cancelled. Refresh to see its current state.' }, { status: 409 });
|
||||
return json(
|
||||
{
|
||||
error: 'This collection has no work that can still be cancelled. Refresh to see its current state.'
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
void kickProvisioning();
|
||||
return json({ ok: true });
|
||||
|
||||
@@ -5,5 +5,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
return json(previewCollection(body?.blueprintId, body?.mode, body));
|
||||
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { db } from '$lib/server/db/index.js';
|
||||
import { getDeployment } from '$lib/server/provisioning/index.js';
|
||||
|
||||
export async function GET({ params, url }: { params: { id: string }; url?: URL }) {
|
||||
const lifecycle = getDeployment(params.id, { events: url?.searchParams.get('events') === 'recent' ? 'recent' : 'all' });
|
||||
const lifecycle = getDeployment(params.id, {
|
||||
events: url?.searchParams.get('events') === 'recent' ? 'recent' : 'all'
|
||||
});
|
||||
if (lifecycle) return json(lifecycle);
|
||||
const dep = db()
|
||||
.prepare('SELECT * FROM deployments WHERE id = ?')
|
||||
.get(params.id) as Record<string, unknown> | undefined;
|
||||
const dep = db().prepare('SELECT * FROM deployments WHERE id = ?').get(params.id) as
|
||||
Record<string, unknown> | undefined;
|
||||
if (!dep) throw error(404, 'deployment not found');
|
||||
|
||||
const servers = db()
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { cancelProvisioningJob, rebalanceCancellationBlocker } from '$lib/server/provisioning/cancellation.js';
|
||||
import {
|
||||
cancelProvisioningJob,
|
||||
rebalanceCancellationBlocker
|
||||
} from '$lib/server/provisioning/cancellation.js';
|
||||
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export function POST({ params }: { params: { id: string } }) {
|
||||
if (!cancelProvisioningJob(params.id)) return json({ error: rebalanceCancellationBlocker(params.id)
|
||||
?? 'This operation can no longer be cancelled. Refresh to see its current state.' }, { status: 409 });
|
||||
if (!cancelProvisioningJob(params.id))
|
||||
return json(
|
||||
{
|
||||
error:
|
||||
rebalanceCancellationBlocker(params.id) ??
|
||||
'This operation can no longer be cancelled. Refresh to see its current state.'
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
void kickProvisioning();
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { resumeVerification, resumeCleanup, resumeRebuild } from '$lib/server/provisioning/index.js';
|
||||
import {
|
||||
resumeVerification,
|
||||
resumeCleanup,
|
||||
resumeRebuild
|
||||
} from '$lib/server/provisioning/index.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export function POST({ params }: { params: { id: string } }) {
|
||||
if (!resumeVerification(params.id) && !resumeCleanup(params.id) && !resumeRebuild(params.id)) return json({ error: 'This operation cannot resume. Refresh and inspect its server.' }, { status: 409 });
|
||||
void kick().catch(err => console.error('[deployment] verification kick failed:', err));
|
||||
if (!resumeVerification(params.id) && !resumeCleanup(params.id) && !resumeRebuild(params.id))
|
||||
return json(
|
||||
{ error: 'This operation cannot resume. Refresh and inspect its server.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
void kick().catch((err) => console.error('[deployment] verification kick failed:', err));
|
||||
return json({ ok: true }, { status: 202 });
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ export function GET() {
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!Array.isArray(body?.servers) || !body.servers.every((name: unknown) => typeof name === 'string')) {
|
||||
if (
|
||||
!Array.isArray(body?.servers) ||
|
||||
!body.servers.every((name: unknown) => typeof name === 'string')
|
||||
) {
|
||||
return json({ error: 'A reviewed server list is required.' }, { status: 400 });
|
||||
}
|
||||
let result: ReturnType<typeof queueHouseParty>;
|
||||
@@ -18,6 +21,6 @@ export async function POST({ request }: { request: Request }) {
|
||||
return json({ ok: false, error: (err as Error).message }, { status: 409 });
|
||||
}
|
||||
if (!result.ok) return json(result, { status: 409 });
|
||||
void kick().catch(err => console.error('[house-party] worker kick failed:', err));
|
||||
void kick().catch((err) => console.error('[house-party] worker kick failed:', err));
|
||||
return json(result, { status: 202 });
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ import { previewCreation } from '$lib/server/provisioning/index.js';
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a launch selection.');
|
||||
if (body.blueprintId !== undefined) throw new Error('Collections use target counts. Use /api/deployment-collections/preview to preview only missing servers.');
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new Error('Provide a launch selection.');
|
||||
if (body.blueprintId !== undefined)
|
||||
throw new Error(
|
||||
'Collections use target counts. Use /api/deployment-collections/preview to preview only missing servers.'
|
||||
);
|
||||
return json(previewCreation(body));
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
|
||||
@@ -4,7 +4,11 @@ import { previewDrift } from '$lib/server/fleet/drift.js';
|
||||
// GET returns the current drift plan for the whole fleet without touching disk.
|
||||
export async function GET() {
|
||||
const plan = await previewDrift();
|
||||
const entries = plan.files.map(file => ({ ...file, size: file.afterSize ?? file.beforeSize ?? 0, sha256: file.afterSha256 ?? file.beforeSha256 ?? '' }));
|
||||
const entries = plan.files.map((file) => ({
|
||||
...file,
|
||||
size: file.afterSize ?? file.beforeSize ?? 0,
|
||||
sha256: file.afterSha256 ?? file.beforeSha256 ?? ''
|
||||
}));
|
||||
const byServer = new Map<
|
||||
string,
|
||||
Array<{ relPath: string; size: number; sha256: string; kind: string; driftKind: string }>
|
||||
@@ -21,7 +25,8 @@ export async function GET() {
|
||||
byServer.set(e.server, arr);
|
||||
}
|
||||
return json({
|
||||
totalDrift: entries.length, scannedAt: plan.scannedAt,
|
||||
totalDrift: entries.length,
|
||||
scannedAt: plan.scannedAt,
|
||||
servers: [...byServer.entries()].map(([server, files]) => ({ server, files }))
|
||||
});
|
||||
}
|
||||
@@ -29,12 +34,24 @@ export async function GET() {
|
||||
// Legacy previews remain readable. Watchdog owns all new reconciliation requests.
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || (body.servers !== undefined && (!Array.isArray(body.servers) || !body.servers.every((server: unknown) => typeof server === 'string')))) {
|
||||
if (
|
||||
!body ||
|
||||
(body.servers !== undefined &&
|
||||
(!Array.isArray(body.servers) ||
|
||||
!body.servers.every((server: unknown) => typeof server === 'string')))
|
||||
) {
|
||||
return json({ ok: false, error: 'Provide the server IDs to review.' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
if (body.dryRun === true) return json({ ok: true, plan: await previewDrift(body.servers) });
|
||||
return json({ ok: false, error: 'Drift reconciliation has moved to Watchdog. Scan there to queue a rebuild and campaign.', watchdogUrl: '/watchdog' }, { status: 410 });
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: 'Drift reconciliation has moved to Watchdog. Scan there to queue a rebuild and campaign.',
|
||||
watchdogUrl: '/watchdog'
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
} catch (error) {
|
||||
return json({ ok: false, error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -6,9 +6,14 @@ const encoder = new TextEncoder();
|
||||
|
||||
export async function GET() {
|
||||
return eventStreamResponse({
|
||||
accepts: event => event.type === 'server-state' || event.type === 'deployment-opened' ||
|
||||
event.type === 'server-changed' || event.type === 'sweep-tick' ||
|
||||
event.type === 'fleet-invalidated' || event.type === 'beta-changed',
|
||||
encode: event => encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
|
||||
accepts: (event) =>
|
||||
event.type === 'server-state' ||
|
||||
event.type === 'deployment-opened' ||
|
||||
event.type === 'server-changed' ||
|
||||
event.type === 'sweep-tick' ||
|
||||
event.type === 'fleet-invalidated' ||
|
||||
event.type === 'beta-changed',
|
||||
encode: (event) =>
|
||||
encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,21 +14,28 @@ const frames = new WeakMap<PipelineEvent, Uint8Array>();
|
||||
export async function GET() {
|
||||
const observation = observationReader();
|
||||
const capacity = playerCapacityReader();
|
||||
const observe = (server: ReturnType<typeof listServers>[number]) => statusEntry(server, observation, capacity(server.name));
|
||||
const observe = (server: ReturnType<typeof listServers>[number]) =>
|
||||
statusEntry(server, observation, capacity(server.name));
|
||||
|
||||
return eventStreamResponse({
|
||||
accepts: event => event.type === 'server-status' || event.type === 'server-telemetry',
|
||||
accepts: (event) => event.type === 'server-status' || event.type === 'server-telemetry',
|
||||
encode(event) {
|
||||
let frame = frames.get(event);
|
||||
if (!frame) {
|
||||
frame = encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify({ ...event, serverNow: Date.now() })}\n\n`);
|
||||
frame = encoder.encode(
|
||||
`event: ${event.type}\ndata: ${JSON.stringify({ ...event, serverNow: Date.now() })}\n\n`
|
||||
);
|
||||
frames.set(event, frame);
|
||||
}
|
||||
return frame;
|
||||
},
|
||||
initialFrames: [
|
||||
encoder.encode(`event: server-status\ndata: ${JSON.stringify({ entries: listServers().map(server => observe(server)), snapshot: true, serverNow: Date.now() })}\n\n`),
|
||||
encoder.encode(`event: server-telemetry\ndata: ${JSON.stringify({ entries: listTelemetry(), snapshot: true, serverNow: Date.now() })}\n\n`)
|
||||
encoder.encode(
|
||||
`event: server-status\ndata: ${JSON.stringify({ entries: listServers().map((server) => observe(server)), snapshot: true, serverNow: Date.now() })}\n\n`
|
||||
),
|
||||
encoder.encode(
|
||||
`event: server-telemetry\ndata: ${JSON.stringify({ entries: listTelemetry(), snapshot: true, serverNow: Date.now() })}\n\n`
|
||||
)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,8 +3,17 @@ import { createLibraryUpload, libraryOverview } from '$lib/server/game-files/ind
|
||||
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
|
||||
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
|
||||
export function GET() { return json(libraryOverview()); }
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try { requireManagementOrigin(request); return json({ version: createLibraryUpload(await readGameJson(request, 32 * 1024 * 1024)) }, { status: 202 }); }
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
export function GET() {
|
||||
return json(libraryOverview());
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
requireManagementOrigin(request);
|
||||
return json(
|
||||
{ version: createLibraryUpload(await readGameJson(request, 32 * 1024 * 1024)) },
|
||||
{ status: 202 }
|
||||
);
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { getLibraryVersion } from '$lib/server/game-files/index.js';
|
||||
export function GET({ params }: { params: { id: string } }) {
|
||||
const version = getLibraryVersion(params.id);
|
||||
return version ? json({ version }) : json({ error: 'Library version not found.' }, { status: 404 });
|
||||
return version
|
||||
? json({ version })
|
||||
: json({ error: 'Library version not found.' }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -8,5 +8,7 @@ export async function POST({ request, params }: { request: Request; params: { id
|
||||
const body = await readGameJson(request);
|
||||
if (typeof body.hostId !== 'string') throw new Error('Choose a target host.');
|
||||
return json({ operation: queueLibraryDownload(body.hostId, params.id) }, { status: 202 });
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import { browserUploadStatus } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError } from '$lib/server/game-files/http.js';
|
||||
|
||||
export async function GET({ params }: { params: { id: string } }) {
|
||||
try { return json(await browserUploadStatus(params.id), { headers: { 'Cache-Control': 'no-store' } }); }
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
try {
|
||||
return json(await browserUploadStatus(params.id), {
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,7 @@ export async function POST({ request, params }: { request: Request; params: { id
|
||||
try {
|
||||
requireManagementOrigin(request);
|
||||
return json(await requestBrowserPublication(params.id), { status: 202 });
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,26 @@ import { requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
|
||||
function integerHeader(request: Request, name: string): number {
|
||||
const value = request.headers.get(name);
|
||||
if (value === null || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) throw new Error(`Provide a valid ${name} header.`);
|
||||
if (value === null || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
|
||||
throw new Error(`Provide a valid ${name} header.`);
|
||||
return Number(value);
|
||||
}
|
||||
export async function PUT({ request, params }: { request: Request; params: { id: string; path: string } }) {
|
||||
export async function PUT({
|
||||
request,
|
||||
params
|
||||
}: {
|
||||
request: Request;
|
||||
params: { id: string; path: string };
|
||||
}) {
|
||||
try {
|
||||
requireManagementOrigin(request);
|
||||
const chunk = { offset: integerHeader(request, 'X-Upload-Offset'), size: integerHeader(request, 'X-Upload-Length'), sha256: request.headers.get('X-Content-SHA256') ?? '' };
|
||||
const chunk = {
|
||||
offset: integerHeader(request, 'X-Upload-Offset'),
|
||||
size: integerHeader(request, 'X-Upload-Length'),
|
||||
sha256: request.headers.get('X-Content-SHA256') ?? ''
|
||||
};
|
||||
return json(await uploadBrowserFile(params.id, params.path, chunk, request.body));
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
} catch (cause) {
|
||||
return gameFileError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createEnrollment, createHost } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
import {
|
||||
hostApiError,
|
||||
installerCommand,
|
||||
objectBody,
|
||||
requireManagementOrigin
|
||||
} from '$lib/server/hosts/http.js';
|
||||
import { hostOverview } from '$lib/server/hosts/overview.js';
|
||||
import { config } from '$lib/server/config.js';
|
||||
|
||||
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: hostOverview(), serverNow: Date.now(), defaultRuntimeImage: config.defaultRuntimeImage }, { headers: { 'Cache-Control': 'no-store' } }); }
|
||||
export function GET({ request }: { request: Request }) {
|
||||
requireManagementOrigin(request);
|
||||
return json(
|
||||
{
|
||||
hosts: hostOverview(),
|
||||
serverNow: Date.now(),
|
||||
defaultRuntimeImage: config.defaultRuntimeImage
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
requireManagementOrigin(request);
|
||||
try {
|
||||
const host = createHost(await objectBody(request));
|
||||
const enrollment = createEnrollment(host.id);
|
||||
return json({ host, enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { status: 201, headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
return json(
|
||||
{
|
||||
host,
|
||||
enrollment: {
|
||||
...enrollment,
|
||||
command: installerCommand(request.url, enrollment.token)
|
||||
}
|
||||
},
|
||||
{ status: 201, headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { capacityCalibration } from '$lib/server/hosts/calibration.js';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, updateHost } from '$lib/server/hosts/index.js';
|
||||
import {
|
||||
deleteHost,
|
||||
getHost,
|
||||
hostDeletionStatus,
|
||||
HostDeletionError,
|
||||
updateHost
|
||||
} from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
import { config } from '$lib/server/config.js';
|
||||
@@ -9,26 +15,47 @@ import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
|
||||
type Event = { request: Request; params: { id: string } };
|
||||
export function GET({ request, params }: Event) {
|
||||
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
|
||||
return json({ host: presentHostPreparation(host), instances: hostInstances(host.id), serverNow: Date.now(), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.defaultRuntimeImage, deletion: hostDeletionStatus(host.id) });
|
||||
requireManagementOrigin(request);
|
||||
const host = getHost(params.id);
|
||||
if (!host) error(404, 'Host not found.');
|
||||
return json({
|
||||
host: presentHostPreparation(host),
|
||||
instances: hostInstances(host.id),
|
||||
serverNow: Date.now(),
|
||||
calibration: capacityCalibration(host.id),
|
||||
defaultRuntimeImage: config.defaultRuntimeImage,
|
||||
deletion: hostDeletionStatus(host.id)
|
||||
});
|
||||
}
|
||||
export async function PATCH({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
try {
|
||||
const body = await objectBody(request);
|
||||
if (body.mode !== undefined && body.mode !== 'active' && body.mode !== 'draining') throw new Error('Choose Enable or Drain host. Hosts disable automatically after draining.');
|
||||
if (body.mode !== undefined && body.mode !== 'active' && body.mode !== 'draining')
|
||||
throw new Error(
|
||||
'Choose Enable or Drain host. Hosts disable automatically after draining.'
|
||||
);
|
||||
updateHost(params.id, body);
|
||||
if (body.mode === 'active') reconcileHostPreparation(params.id);
|
||||
if (body.mode === 'draining') void kickProvisioning().catch(cause => console.error('[host-drain]', cause));
|
||||
if (body.mode === 'draining')
|
||||
void kickProvisioning().catch((cause) => console.error('[host-drain]', cause));
|
||||
return json({ host: presentHostPreparation(getHost(params.id)!) });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
export function DELETE({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
try { const result = deleteHost(params.id); return json(result, { status: result.deleted ? 200 : 202 }); }
|
||||
catch (cause) {
|
||||
if (cause instanceof HostDeletionError) return json({ error: cause.message, blockers: cause.blockers }, { status: cause.status });
|
||||
try {
|
||||
const result = deleteHost(params.id);
|
||||
return json(result, { status: result.deleted ? 200 : 202 });
|
||||
} catch (cause) {
|
||||
if (cause instanceof HostDeletionError)
|
||||
return json(
|
||||
{ error: cause.message, blockers: cause.blockers },
|
||||
{ status: cause.status }
|
||||
);
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,19 @@ import { hostApiError, requireManagementOrigin } from '$lib/server/hosts/http.js
|
||||
type Event = { request: Request; params: { id: string } };
|
||||
export function DELETE({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
try { revokeCredentials(params.id); return json({ ok: true }); } catch (cause) { return hostApiError(cause); }
|
||||
try {
|
||||
revokeCredentials(params.id);
|
||||
return json({ ok: true });
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
export function POST({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
try { const operation = queueHostOperation(params.id, 'credentials.rotate', {}); return json({ operationId: operation.id }, { status: 202 }); } catch (cause) { return hostApiError(cause); }
|
||||
try {
|
||||
const operation = queueHostOperation(params.id, 'credentials.rotate', {});
|
||||
return json({ operationId: operation.id }, { status: 202 });
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,18 @@ import { createEnrollment } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, installerCommand, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
export function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
requireManagementOrigin(request);
|
||||
try { const enrollment = createEnrollment(params.id); return json({ enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { headers: { 'Cache-Control': 'no-store' } }); }
|
||||
catch (cause) { return hostApiError(cause); }
|
||||
try {
|
||||
const enrollment = createEnrollment(params.id);
|
||||
return json(
|
||||
{
|
||||
enrollment: {
|
||||
...enrollment,
|
||||
command: installerCommand(request.url, enrollment.token)
|
||||
}
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,15 @@ export async function POST({ request, params }: { request: Request; params: { id
|
||||
requireManagementOrigin(request);
|
||||
try {
|
||||
const body = await objectBody(request);
|
||||
if (body.kind !== 'runtime.pull' || typeof body.image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,511}$/.test(body.image)) throw new Error('Provide a runtime.pull request and a valid container image.');
|
||||
if (
|
||||
body.kind !== 'runtime.pull' ||
|
||||
typeof body.image !== 'string' ||
|
||||
!/^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,511}$/.test(body.image)
|
||||
)
|
||||
throw new Error('Provide a runtime.pull request and a valid container image.');
|
||||
const operation = queueHostOperation(params.id, 'runtime.pull', { image: body.image });
|
||||
return json({ operationId: operation.id }, { status: 202 });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
} catch (cause) {
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ export function GET({ request, params }: { request: Request; params: { id: strin
|
||||
const phase = search.get('phase') ?? 'all';
|
||||
const page = Number(search.get('page') ?? '1');
|
||||
if (scope !== 'host' && scope !== 'fleet') error(400, 'Invalid sample scope.');
|
||||
if (phase !== 'all' && phase !== 'startup' && phase !== 'running') error(400, 'Invalid sample phase.');
|
||||
if (phase !== 'all' && phase !== 'startup' && phase !== 'running')
|
||||
error(400, 'Invalid sample phase.');
|
||||
if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid sample page.');
|
||||
return json(capacitySamples(params.id, { scope, phase, page }), { headers: { 'Cache-Control': 'no-store' } });
|
||||
return json(capacitySamples(params.id, { scope, phase, page }), {
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ export function GET({ request }: { request: Request }) {
|
||||
if (!isHostOperationFilter(filter)) error(400, 'Invalid operation history filter.');
|
||||
const scope = hostOperationScope(params);
|
||||
for (const key of ['host', 'server', 'operation'] as const) {
|
||||
if (params.has(key) && params.get(key) !== scope[key]) error(400, 'Invalid operation history scope.');
|
||||
if (params.has(key) && params.get(key) !== scope[key])
|
||||
error(400, 'Invalid operation history scope.');
|
||||
}
|
||||
return json(listHostOperationHistory(page, filter, scope), { headers: { 'Cache-Control': 'no-store' } });
|
||||
return json(listHostOperationHistory(page, filter, scope), {
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,20 +12,45 @@ export async function GET({ url }: { url: URL }) {
|
||||
const players = params.get('players');
|
||||
const rawLimit = params.get('limit');
|
||||
const rawOffset = params.get('offset');
|
||||
const integer = (value: string | null, fallback: number) => value === null ? fallback
|
||||
: /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : NaN;
|
||||
const integer = (value: string | null, fallback: number) =>
|
||||
value === null
|
||||
? fallback
|
||||
: /^\d+$/.test(value) && Number.isSafeInteger(Number(value))
|
||||
? Number(value)
|
||||
: NaN;
|
||||
const limit = integer(rawLimit, 50);
|
||||
const offset = integer(rawOffset, 0);
|
||||
if ((game !== null && game !== 't5' && game !== 't6')
|
||||
|| (map !== null && map.length > 1024) || (q !== null && q.length > 100)
|
||||
|| (metric !== null && !isLeaderboardMetric(metric))
|
||||
|| (players !== null && !/^[1-4]$/.test(players))
|
||||
|| !Number.isFinite(limit) || limit < 1 || limit > 100
|
||||
|| !Number.isFinite(offset) || offset < 0 || offset > MAX_LEADERBOARD_OFFSET) {
|
||||
return json({ ok: false, kind: 'bad-request', error: 'Use game t5 or t6, a supported metric, players 1–4, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.' }, { status: 400 });
|
||||
if (
|
||||
(game !== null && game !== 't5' && game !== 't6') ||
|
||||
(map !== null && map.length > 1024) ||
|
||||
(q !== null && q.length > 100) ||
|
||||
(metric !== null && !isLeaderboardMetric(metric)) ||
|
||||
(players !== null && !/^[1-4]$/.test(players)) ||
|
||||
!Number.isFinite(limit) ||
|
||||
limit < 1 ||
|
||||
limit > 100 ||
|
||||
!Number.isFinite(offset) ||
|
||||
offset < 0 ||
|
||||
offset > MAX_LEADERBOARD_OFFSET
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
kind: 'bad-request',
|
||||
error: 'Use game t5 or t6, a supported metric, players 1–4, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return json(await leaderboardReport({ game: game ?? undefined, map: map || undefined,
|
||||
metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined,
|
||||
players: players === null ? undefined : Number(players) as LeaderboardPlayerCount,
|
||||
q: q ?? undefined, limit, offset }));
|
||||
return json(
|
||||
await leaderboardReport({
|
||||
game: game ?? undefined,
|
||||
map: map || undefined,
|
||||
metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined,
|
||||
players: players === null ? undefined : (Number(players) as LeaderboardPlayerCount),
|
||||
q: q ?? undefined,
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,50 +2,92 @@ import { json } from '@sveltejs/kit';
|
||||
import { maintenanceOverview } from '$lib/server/maintenance/index.js';
|
||||
import { pauseMaintenance, setMaintenancePolicy } from '$lib/server/maintenance/policies.js';
|
||||
import { watchdogTick } from '$lib/server/maintenance/recovery.js';
|
||||
import { cancelMaintenance, checkPlutonium, queueUpdates, retryMaintenance, saveMaintenanceSchedule } from '$lib/server/maintenance/runtime.js';
|
||||
import {
|
||||
cancelMaintenance,
|
||||
checkPlutonium,
|
||||
queueUpdates,
|
||||
retryMaintenance,
|
||||
saveMaintenanceSchedule
|
||||
} from '$lib/server/maintenance/runtime.js';
|
||||
import { validTimeZone } from '$lib/time-zone.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export function GET(event?: { url: URL }) {
|
||||
const job = event?.url.searchParams.get('job');
|
||||
if (job !== undefined && job !== null && (!/^[1-9]\d*$/.test(job) || !Number.isSafeInteger(Number(job)))) return json({ error: 'Choose a valid maintenance request.' }, { status: 400 });
|
||||
if (
|
||||
job !== undefined &&
|
||||
job !== null &&
|
||||
(!/^[1-9]\d*$/.test(job) || !Number.isSafeInteger(Number(job)))
|
||||
)
|
||||
return json({ error: 'Choose a valid maintenance request.' }, { status: 400 });
|
||||
return json(maintenanceOverview(job ? Number(job) : null));
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ error: 'Invalid request.' }, { status: 400 });
|
||||
let notice = '';
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
return json({ error: 'Invalid request.' }, { status: 400 });
|
||||
let notice: string;
|
||||
try {
|
||||
switch (body.action) {
|
||||
case 'pause':
|
||||
if (typeof body.paused !== 'boolean') throw new Error('Provide a paused boolean.');
|
||||
pauseMaintenance(body.paused);
|
||||
notice = body.paused ? 'Maintenance paused. Submitted operations continue recovery.' : 'Maintenance resumed.';
|
||||
notice = body.paused
|
||||
? 'Maintenance paused. Submitted operations continue recovery.'
|
||||
: 'Maintenance resumed.';
|
||||
break;
|
||||
case 'policy':
|
||||
if (!['health', 'drift', 'updates'].includes(body.policy) || typeof body.enabled !== 'boolean') throw new Error('Choose a policy and an enabled boolean.');
|
||||
setMaintenancePolicy(body.policy, body.enabled); notice = 'Policy saved.';
|
||||
if (
|
||||
!['health', 'drift', 'updates'].includes(body.policy) ||
|
||||
typeof body.enabled !== 'boolean'
|
||||
)
|
||||
throw new Error('Choose a policy and an enabled boolean.');
|
||||
setMaintenancePolicy(body.policy, body.enabled);
|
||||
notice = 'Policy saved.';
|
||||
break;
|
||||
case 'scan':
|
||||
await Promise.all([watchdogTick(true), checkPlutonium()]); notice = 'Checks finished.';
|
||||
await Promise.all([watchdogTick(true), checkPlutonium()]);
|
||||
notice = 'Checks finished.';
|
||||
break;
|
||||
case 'update':
|
||||
if (body.servers !== undefined && (!Array.isArray(body.servers) || !body.servers.length || body.servers.some((v: unknown) => typeof v !== 'string'))) throw new Error('Choose existing fleet servers.');
|
||||
if (
|
||||
body.servers !== undefined &&
|
||||
(!Array.isArray(body.servers) ||
|
||||
!body.servers.length ||
|
||||
body.servers.some((v: unknown) => typeof v !== 'string'))
|
||||
)
|
||||
throw new Error('Choose existing fleet servers.');
|
||||
notice = `${queueUpdates(body.servers)} update restart(s) queued. Occupied servers wait until empty.`;
|
||||
break;
|
||||
case 'schedule':
|
||||
if (typeof body.enabled !== 'boolean' || typeof body.time !== 'string' || !validTimeZone(body.timeZone)) throw new Error('Provide enabled, a time, and a valid timezone.');
|
||||
saveMaintenanceSchedule(body.enabled, body.time, body.timeZone); notice = 'Schedule saved. Existing requests are retained.';
|
||||
if (
|
||||
typeof body.enabled !== 'boolean' ||
|
||||
typeof body.time !== 'string' ||
|
||||
!validTimeZone(body.timeZone)
|
||||
)
|
||||
throw new Error('Provide enabled, a time, and a valid timezone.');
|
||||
saveMaintenanceSchedule(body.enabled, body.time, body.timeZone);
|
||||
notice = 'Schedule saved. Existing requests are retained.';
|
||||
break;
|
||||
case 'cancel':
|
||||
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id)) return json({ error: 'Only unsubmitted restarts can be cancelled.' }, { status: 409 });
|
||||
notice = 'Queued restart cancelled.'; break;
|
||||
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id))
|
||||
return json(
|
||||
{ error: 'Only unsubmitted restarts can be cancelled.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
notice = 'Queued restart cancelled.';
|
||||
break;
|
||||
case 'retry':
|
||||
if (!Number.isSafeInteger(body.id)) throw new Error('Choose a maintenance activity.');
|
||||
notice = `${retryMaintenance(body.id)} restart(s) queued for verification.`; break;
|
||||
default: throw new Error('Unknown maintenance action.');
|
||||
if (!Number.isSafeInteger(body.id))
|
||||
throw new Error('Choose a maintenance activity.');
|
||||
notice = `${retryMaintenance(body.id)} restart(s) queued for verification.`;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown maintenance action.');
|
||||
}
|
||||
} catch (error) { return json({ error: (error as Error).message }, { status: 400 }); }
|
||||
void kick().catch(error => console.error('[maintenance] worker kick failed:', error));
|
||||
} catch (error) {
|
||||
return json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
void kick().catch((error) => console.error('[maintenance] worker kick failed:', error));
|
||||
return json({ ...maintenanceOverview(), notice });
|
||||
}
|
||||
|
||||
@@ -4,13 +4,16 @@ import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export function GET({ params }: { params: { id: string } }) {
|
||||
const detail = watchdogDetail(params.id);
|
||||
return detail ? json(detail) : json({ error: 'Maintenance finding not found.' }, { status: 404 });
|
||||
return detail
|
||||
? json(detail)
|
||||
: json({ error: 'Maintenance finding not found.' }, { status: 404 });
|
||||
}
|
||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || !['retry','dismiss'].includes(body.action)) return json({ error: 'Choose retry or dismiss.' }, { status: 400 });
|
||||
if (!body || !['retry', 'dismiss'].includes(body.action))
|
||||
return json({ error: 'Choose retry or dismiss.' }, { status: 400 });
|
||||
const result = updateWatchdogIncident(params.id, body.action);
|
||||
if (!result.ok) return json({ error: result.error }, { status: result.status });
|
||||
void kick().catch(err => console.error('[maintenance] worker kick failed:', err));
|
||||
void kick().catch((err) => console.error('[maintenance] worker kick failed:', err));
|
||||
return json(watchdogDetail(params.id));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ export function GET({ url }: { url: URL }) {
|
||||
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
||||
}
|
||||
let query;
|
||||
try { query = readMatchQuery(url.searchParams); }
|
||||
catch { return json({ error: 'Invalid match history filters.' }, { status: 400 }); }
|
||||
try {
|
||||
query = readMatchQuery(url.searchParams);
|
||||
} catch {
|
||||
return json({ error: 'Invalid match history filters.' }, { status: 400 });
|
||||
}
|
||||
return json(serverMatchHistory(undefined, limit, query));
|
||||
}
|
||||
|
||||
@@ -5,15 +5,24 @@ import { ScriptError } from '$lib/server/fleet/scripts.js';
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
let input;
|
||||
try { input = await request.json(); } catch { return json({ error: 'Invalid JSON body.' }, { status: 400 }); }
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'Import details are required.' }, { status: 400 });
|
||||
if ((input.scopeType != null && input.scopeType !== 'game' && input.scopeType !== 'map') ||
|
||||
(input.scopeValue != null && typeof input.scopeValue !== 'string')) {
|
||||
try {
|
||||
input = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON body.' }, { status: 400 });
|
||||
}
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input))
|
||||
return json({ error: 'Import details are required.' }, { status: 400 });
|
||||
if (
|
||||
(input.scopeType != null && input.scopeType !== 'game' && input.scopeType !== 'map') ||
|
||||
(input.scopeValue != null && typeof input.scopeValue !== 'string')
|
||||
) {
|
||||
return json({ error: 'Invalid plugin scope.' }, { status: 400 });
|
||||
}
|
||||
try { return json({ ok: true, ...await importPluginRelease(input) }); }
|
||||
catch (error) {
|
||||
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
|
||||
try {
|
||||
return json({ ok: true, ...(await importPluginRelease(input)) });
|
||||
} catch (error) {
|
||||
if (error instanceof GithubReleaseError)
|
||||
return json({ error: error.message }, { status: error.status });
|
||||
if (error instanceof ScriptError) return json({ error: error.message }, { status: 409 });
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { json } from '@sveltejs/kit';
|
||||
import { getGithubRelease, GithubReleaseError } from '$lib/server/integrations/github-releases.js';
|
||||
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
try { return json({ release: await getGithubRelease(url.searchParams.get('url') ?? '') }); }
|
||||
catch (error) {
|
||||
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
|
||||
try {
|
||||
return json({ release: await getGithubRelease(url.searchParams.get('url') ?? '') });
|
||||
} catch (error) {
|
||||
if (error instanceof GithubReleaseError)
|
||||
return json({ error: error.message }, { status: error.status });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,13 @@ export async function GET({ url }: { url: URL }) {
|
||||
const raw = url.searchParams.get('pluginId');
|
||||
if (raw !== null) {
|
||||
const id = Number(raw);
|
||||
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid plugin id.' }, { status: 400 });
|
||||
try { return json({ updates: [await checkPluginUpdate(id)] }); }
|
||||
catch (error) {
|
||||
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
|
||||
if (!Number.isSafeInteger(id) || id < 1)
|
||||
return json({ error: 'Invalid plugin id.' }, { status: 400 });
|
||||
try {
|
||||
return json({ updates: [await checkPluginUpdate(id)] });
|
||||
} catch (error) {
|
||||
if (error instanceof GithubReleaseError)
|
||||
return json({ error: error.message }, { status: error.status });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +22,9 @@ export async function GET({ url }: { url: URL }) {
|
||||
const updates: PluginUpdate[] = [];
|
||||
// Bound outbound concurrency for large fleets; repository requests share a five-minute cache.
|
||||
for (let i = 0; i < plugins.length; i += 4) {
|
||||
updates.push(...await Promise.all(plugins.slice(i, i + 4).map(p => checkPluginUpdate(p.id))));
|
||||
updates.push(
|
||||
...(await Promise.all(plugins.slice(i, i + 4).map((p) => checkPluginUpdate(p.id))))
|
||||
);
|
||||
}
|
||||
return json({ updates });
|
||||
}
|
||||
|
||||
@@ -1,28 +1,57 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { validTimeZone } from '$lib/time-zone.js';
|
||||
import { cancelMaintenance, checkPlutonium, plutoniumStatus, queueMaintenance, saveMaintenanceSchedule } from '$lib/server/fleet/plutonium.js';
|
||||
import {
|
||||
cancelMaintenance,
|
||||
checkPlutonium,
|
||||
plutoniumStatus,
|
||||
queueMaintenance,
|
||||
saveMaintenanceSchedule
|
||||
} from '$lib/server/fleet/plutonium.js';
|
||||
|
||||
export function GET() { return json(plutoniumStatus()); }
|
||||
export function GET() {
|
||||
return json(plutoniumStatus());
|
||||
}
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
let body;
|
||||
try { body = await request.json(); } catch { return json({ error: 'Invalid JSON.' }, { status: 400 }); }
|
||||
if (!body || typeof body !== 'object') return json({ error: 'Invalid request.' }, { status: 400 });
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON.' }, { status: 400 });
|
||||
}
|
||||
if (!body || typeof body !== 'object')
|
||||
return json({ error: 'Invalid request.' }, { status: 400 });
|
||||
if (body.action === 'check') {
|
||||
await checkPlutonium();
|
||||
} else if (body.action === 'schedule') {
|
||||
if (typeof body.enabled !== 'boolean' || typeof body.time !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(body.time) || !validTimeZone(body.timeZone)) {
|
||||
return json({ error: 'Provide enabled, a valid time (HH:mm), and a valid timezone.' }, { status: 400 });
|
||||
if (
|
||||
typeof body.enabled !== 'boolean' ||
|
||||
typeof body.time !== 'string' ||
|
||||
!/^([01]\d|2[0-3]):[0-5]\d$/.test(body.time) ||
|
||||
!validTimeZone(body.timeZone)
|
||||
) {
|
||||
return json(
|
||||
{ error: 'Provide enabled, a valid time (HH:mm), and a valid timezone.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
saveMaintenanceSchedule(body.enabled, body.time, body.timeZone);
|
||||
} else if (body.action === 'restart') {
|
||||
if (!Array.isArray(body.servers) || !body.servers.length || body.servers.some((s: unknown) => typeof s !== 'string')) {
|
||||
if (
|
||||
!Array.isArray(body.servers) ||
|
||||
!body.servers.length ||
|
||||
body.servers.some((s: unknown) => typeof s !== 'string')
|
||||
) {
|
||||
return json({ error: 'Choose servers to restart.' }, { status: 400 });
|
||||
}
|
||||
try { queueMaintenance(body.servers); }
|
||||
catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
|
||||
try {
|
||||
queueMaintenance(body.servers);
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
} else if (body.action === 'cancel') {
|
||||
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id)) return json({ error: 'Only queued restarts can be cancelled.' }, { status: 409 });
|
||||
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id))
|
||||
return json({ error: 'Only queued restarts can be cancelled.' }, { status: 409 });
|
||||
} else return json({ error: 'Unknown action.' }, { status: 400 });
|
||||
return json(plutoniumStatus());
|
||||
}
|
||||
|
||||
@@ -1,37 +1,59 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { deploymentListPage } from '$lib/server/provisioning/history.js';
|
||||
import { requestLifecycle } from '$lib/server/provisioning/index.js';
|
||||
import { provisioningActions, runtimeActions, type ProvisioningAction, type RuntimeAction } from '$lib/provisioning.js';
|
||||
import {
|
||||
provisioningActions,
|
||||
runtimeActions,
|
||||
type ProvisioningAction,
|
||||
type RuntimeAction
|
||||
} from '$lib/provisioning.js';
|
||||
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
|
||||
import { provisioningListSources, provisioningListStatuses } from '$lib/provisioning-list.js';
|
||||
|
||||
export function GET({ url }: { url: URL }) {
|
||||
const params = url.searchParams;
|
||||
const action = params.get('action');
|
||||
const status = params.get('status') as typeof provisioningListStatuses[number];
|
||||
const source = params.get('source') as typeof provisioningListSources[number];
|
||||
return json(deploymentListPage({
|
||||
view: params.get('view') === 'history' ? 'history' : 'active',
|
||||
action: provisioningActions.includes(action as ProvisioningAction) ? action as ProvisioningAction : undefined,
|
||||
status: provisioningListStatuses.includes(status) ? status : undefined,
|
||||
source: provisioningListSources.includes(source) ? source : undefined,
|
||||
server: params.get('server') ?? '',
|
||||
limit: Number(params.get('limit') ?? 25),
|
||||
offset: Number(params.get('offset') ?? 0)
|
||||
}), { headers: { 'Cache-Control': 'no-store' } });
|
||||
const status = params.get('status') as (typeof provisioningListStatuses)[number];
|
||||
const source = params.get('source') as (typeof provisioningListSources)[number];
|
||||
return json(
|
||||
deploymentListPage({
|
||||
view: params.get('view') === 'history' ? 'history' : 'active',
|
||||
action: provisioningActions.includes(action as ProvisioningAction)
|
||||
? (action as ProvisioningAction)
|
||||
: undefined,
|
||||
status: provisioningListStatuses.includes(status) ? status : undefined,
|
||||
source: provisioningListSources.includes(source) ? source : undefined,
|
||||
server: params.get('server') ?? '',
|
||||
limit: Number(params.get('limit') ?? 25),
|
||||
offset: Number(params.get('offset') ?? 0)
|
||||
}),
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || typeof body.server !== 'string' || !body.server || !runtimeActions.includes(body.action)) {
|
||||
return json({ error: 'server and a valid lifecycle action are required.' }, { status: 400 });
|
||||
if (
|
||||
!body ||
|
||||
typeof body.server !== 'string' ||
|
||||
!body.server ||
|
||||
!runtimeActions.includes(body.action)
|
||||
) {
|
||||
return json(
|
||||
{ error: 'server and a valid lifecycle action are required.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = requestLifecycle(body.server, body.action as RuntimeAction);
|
||||
if (!result.ok) return json({ error: result.error }, { status: result.status });
|
||||
setImmediate(() => {
|
||||
void kickProvisioning().catch(err => console.error('[provisioning] worker kick failed:', err));
|
||||
void kickProvisioning().catch((err) =>
|
||||
console.error('[provisioning] worker kick failed:', err)
|
||||
);
|
||||
});
|
||||
return json(result, { status: 202 });
|
||||
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import { kickProvisioning } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export function POST({ params }: { params: { id: string } }) {
|
||||
const cleanupId = cancelCreation(params.id);
|
||||
if (!cleanupId) return json({ error: 'This creation can no longer be removed. Refresh its current state.' }, { status: 409 });
|
||||
if (!cleanupId)
|
||||
return json(
|
||||
{ error: 'This creation can no longer be removed. Refresh its current state.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
void kickProvisioning();
|
||||
return json({ ok: true, cleanupId });
|
||||
}
|
||||
|
||||
@@ -12,13 +12,22 @@ function integer(params: URLSearchParams, key: string): number | undefined {
|
||||
}
|
||||
|
||||
export function GET({ params, url }: { params: { id: string }; url: URL }) {
|
||||
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ?').get(params.id)) error(404, 'Provisioning operation not found.');
|
||||
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ?').get(params.id))
|
||||
error(404, 'Provisioning operation not found.');
|
||||
const order = url.searchParams.get('order') ?? 'newest';
|
||||
if (order !== 'newest' && order !== 'oldest') error(400, 'Choose newest or oldest activity order.');
|
||||
if (order !== 'newest' && order !== 'oldest')
|
||||
error(400, 'Choose newest or oldest activity order.');
|
||||
const query = url.searchParams.get('query') ?? '';
|
||||
if (query.length > 500) error(400, 'Activity search must be 500 characters or fewer.');
|
||||
const limit = integer(url.searchParams, 'limit');
|
||||
if (limit === 0) error(400, 'Activity limit must be at least 1.');
|
||||
return json(deploymentActivity(params.id, { order, query, limit,
|
||||
cursor: integer(url.searchParams, 'cursor'), watermark: integer(url.searchParams, 'watermark') }));
|
||||
return json(
|
||||
deploymentActivity(params.id, {
|
||||
order,
|
||||
query,
|
||||
limit,
|
||||
cursor: integer(url.searchParams, 'cursor'),
|
||||
watermark: integer(url.searchParams, 'watermark')
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,16 @@ import { kickProvisioning } from '$lib/server/pipeline/worker.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') return json({ error: 'Administrator access is required.' }, { status: 403 });
|
||||
if (!locals.user || locals.user.role !== 'admin')
|
||||
return json({ error: 'Administrator access is required.' }, { status: 403 });
|
||||
let body: Record<string, unknown>;
|
||||
try { body = await authBody(request); } catch (error) { return authError(error); }
|
||||
if (body.confirmed !== true) return json({ error: 'Confirm the admin override before continuing.' }, { status: 400 });
|
||||
try {
|
||||
body = await authBody(request);
|
||||
} catch (error) {
|
||||
return authError(error);
|
||||
}
|
||||
if (body.confirmed !== true)
|
||||
return json({ error: 'Confirm the admin override before continuing.' }, { status: 400 });
|
||||
const result = enableSafetyOverride(params.id, locals.user);
|
||||
if (!result.ok) return json({ error: result.error }, { status: 409 });
|
||||
void kickProvisioning();
|
||||
|
||||
@@ -5,6 +5,8 @@ import { kickProvisioning } from '$lib/server/pipeline/worker.js';
|
||||
export function POST({ params }: { params: { id: string } }) {
|
||||
const result = requestRetryWithRebuild(params.id);
|
||||
if (!result.ok) return json({ error: result.error }, { status: result.status });
|
||||
void kickProvisioning().catch(err => console.error('[provisioning] rebuild retry kick failed:', err));
|
||||
void kickProvisioning().catch((err) =>
|
||||
console.error('[provisioning] rebuild retry kick failed:', err)
|
||||
);
|
||||
return json(result, { status: 202 });
|
||||
}
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { RebalanceError, previewRebalance, rebalanceReport, startRebalance, stopRebalance } from '$lib/server/rebalancing/index.js';
|
||||
import {
|
||||
RebalanceError,
|
||||
previewRebalance,
|
||||
rebalanceReport,
|
||||
startRebalance,
|
||||
stopRebalance
|
||||
} from '$lib/server/rebalancing/index.js';
|
||||
|
||||
export function GET() { return json(rebalanceReport()); }
|
||||
export function GET() {
|
||||
return json(rebalanceReport());
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new RebalanceError('Provide a rebalance action.');
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new RebalanceError('Provide a rebalance action.');
|
||||
if (body.action === 'preview') return json({ preview: previewRebalance(body.settings) });
|
||||
if (body.action === 'start' && typeof body.previewId === 'string' && body.previewId.length <= 100) return json({ run: startRebalance(body.previewId) });
|
||||
if (body.action === 'stop' && typeof body.id === 'string' && body.id.length <= 100) return json({ run: stopRebalance(body.id) });
|
||||
if (
|
||||
body.action === 'start' &&
|
||||
typeof body.previewId === 'string' &&
|
||||
body.previewId.length <= 100
|
||||
)
|
||||
return json({ run: startRebalance(body.previewId) });
|
||||
if (body.action === 'stop' && typeof body.id === 'string' && body.id.length <= 100)
|
||||
return json({ run: stopRebalance(body.id) });
|
||||
throw new RebalanceError('Choose a supported rebalance action.');
|
||||
} catch (error) { return json({ error: (error as Error).message }, { status: error instanceof RebalanceError ? error.status : 400 }); }
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: error instanceof RebalanceError ? error.status : 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ function scopeTypeOrNull(v: FormDataEntryValue | null): ScopeType | null {
|
||||
throw new ScriptError(`invalid scope_type: ${s}`, 'invalid-scope');
|
||||
}
|
||||
|
||||
function kindOrDefault(v: FormDataEntryValue | string | null, fallback: ArtifactKind = 'script'): ArtifactKind {
|
||||
function kindOrDefault(
|
||||
v: FormDataEntryValue | string | null,
|
||||
fallback: ArtifactKind = 'script'
|
||||
): ArtifactKind {
|
||||
if (v === null || v === undefined || v === '') return fallback;
|
||||
const s = String(v);
|
||||
if (!isArtifactKind(s)) throw new ScriptError(`invalid kind: ${s}`, 'invalid-kind');
|
||||
@@ -66,9 +69,8 @@ export async function POST({ request, url }: { request: Request; url: URL }) {
|
||||
|
||||
// Target rel path: for script/plugin, default to the upload filename.
|
||||
// For config/baseline, createScript() defaults to config.configTargetFile.
|
||||
const targetRelPath = kind === 'script' || kind === 'plugin'
|
||||
? uploadedFilename ?? undefined
|
||||
: undefined;
|
||||
const targetRelPath =
|
||||
kind === 'script' || kind === 'plugin' ? (uploadedFilename ?? undefined) : undefined;
|
||||
|
||||
try {
|
||||
const { script, version } = createScript({
|
||||
@@ -78,7 +80,9 @@ export async function POST({ request, url }: { request: Request; url: URL }) {
|
||||
scopeValue,
|
||||
content,
|
||||
notes,
|
||||
releaseVersion: form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined,
|
||||
releaseVersion: form.has('releaseVersion')
|
||||
? String(form.get('releaseVersion'))
|
||||
: undefined,
|
||||
kind
|
||||
});
|
||||
return json({ ok: true, script, versionId: version.id });
|
||||
|
||||
@@ -29,21 +29,31 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
sha256: v.sha256,
|
||||
size: v.size,
|
||||
notes: v.notes,
|
||||
source: script.kind === 'plugin'
|
||||
? db().prepare('SELECT * FROM plugin_imports WHERE version_id = ?').get(v.id) as PluginSource | undefined ?? null
|
||||
: null,
|
||||
source:
|
||||
script.kind === 'plugin'
|
||||
? ((db().prepare('SELECT * FROM plugin_imports WHERE version_id = ?').get(v.id) as
|
||||
PluginSource | undefined) ?? null)
|
||||
: null,
|
||||
state: v.state,
|
||||
uploaded_at: v.uploaded_at
|
||||
}));
|
||||
const matched = script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type as ScopeType, script.scope_value)
|
||||
: [];
|
||||
const matched =
|
||||
script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type as ScopeType, script.scope_value)
|
||||
: [];
|
||||
const runningBeta = findRunningBetaForScript(id);
|
||||
|
||||
return json({
|
||||
script,
|
||||
versions,
|
||||
installedVersions: script.kind === 'script' || script.kind === 'plugin' ? db().prepare(`SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`).all(id, script.target_rel_path) : [],
|
||||
installedVersions:
|
||||
script.kind === 'script' || script.kind === 'plugin'
|
||||
? db()
|
||||
.prepare(
|
||||
`SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`
|
||||
)
|
||||
.all(id, script.target_rel_path)
|
||||
: [],
|
||||
matchedServers: matched,
|
||||
runningBetaId: runningBeta?.id ?? null
|
||||
});
|
||||
|
||||
@@ -1,23 +1,52 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getScript, setScope, serversMatchingScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
|
||||
import {
|
||||
getScript,
|
||||
setScope,
|
||||
serversMatchingScope,
|
||||
ScriptError,
|
||||
type ScopeType
|
||||
} from '$lib/server/fleet/scripts.js';
|
||||
import { unsetScopeAndDetach } from '$lib/server/fleet/beta.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export async function GET({ params, url }: { params: { id: string }; url: URL }) {
|
||||
const id = Number(params.id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return json({ error: 'Invalid file ID.' }, { status: 400 });
|
||||
if (!Number.isSafeInteger(id) || id <= 0)
|
||||
return json({ error: 'Invalid file ID.' }, { status: 400 });
|
||||
const script = getScript(id);
|
||||
if (!script) return json({ error: 'File not found.' }, { status: 404 });
|
||||
const type = url.searchParams.get('type');
|
||||
const value = url.searchParams.get('value');
|
||||
if (type !== 'none' && type !== 'game' && type !== 'map' || type !== 'none' && !value) return json({ error: 'Choose a complete scope.' }, { status: 400 });
|
||||
if ((type !== 'none' && type !== 'game' && type !== 'map') || (type !== 'none' && !value))
|
||||
return json({ error: 'Choose a complete scope.' }, { status: 400 });
|
||||
const before = serversMatchingScope(script.scope_type, script.scope_value);
|
||||
const after = type === 'none' ? [] : serversMatchingScope(type as ScopeType, value);
|
||||
const composed = script.kind === 'config' || script.kind === 'baseline';
|
||||
const tracked = composed ? [] : (db().prepare(`SELECT DISTINCT server FROM server_scripts WHERE rel_path = ? AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?) ORDER BY server`).all(script.target_rel_path, script.id) as Array<{ server: string }>).map(row => row.server);
|
||||
return json({ before, after, added: after.filter(name => !before.includes(name)), removed: before.filter(name => !after.includes(name)),
|
||||
affected: composed ? [...new Set([...before, ...after])].sort() : type === 'none' ? tracked : [...new Set([...before, ...after])].sort(),
|
||||
composed, detached: type === 'none' ? tracked : [] }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
const tracked = composed
|
||||
? []
|
||||
: (
|
||||
db()
|
||||
.prepare(
|
||||
`SELECT DISTINCT server FROM server_scripts WHERE rel_path = ? AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?) ORDER BY server`
|
||||
)
|
||||
.all(script.target_rel_path, script.id) as Array<{ server: string }>
|
||||
).map((row) => row.server);
|
||||
return json(
|
||||
{
|
||||
before,
|
||||
after,
|
||||
added: after.filter((name) => !before.includes(name)),
|
||||
removed: before.filter((name) => !after.includes(name)),
|
||||
affected: composed
|
||||
? [...new Set([...before, ...after])].sort()
|
||||
: type === 'none'
|
||||
? tracked
|
||||
: [...new Set([...before, ...after])].sort(),
|
||||
composed,
|
||||
detached: type === 'none' ? tracked : []
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
}
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
|
||||
@@ -18,27 +18,44 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
const script = getScript(scriptId);
|
||||
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
if ((script.kind === 'config' || script.kind === 'baseline') && findRunningBetaForScript(scriptId)) {
|
||||
return json({ ok: false, error: 'Finish the active beta before saving a config version.' }, { status: 409 });
|
||||
if (
|
||||
(script.kind === 'config' || script.kind === 'baseline') &&
|
||||
findRunningBetaForScript(scriptId)
|
||||
) {
|
||||
return json(
|
||||
{ ok: false, error: 'Finish the active beta before saving a config version.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const notes = form.get('notes') ? String(form.get('notes')) : undefined;
|
||||
const file = form.get('content');
|
||||
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
|
||||
if (!(file instanceof File))
|
||||
return json({ ok: false, error: 'content file is required' }, { status: 400 });
|
||||
|
||||
const content = Buffer.from(await file.arrayBuffer());
|
||||
if (script.kind === 'script' && form.get('reuseExisting') === 'true') {
|
||||
if (!content.length) return json({ ok: false, error: 'Choose a non-empty script file.' }, { status: 400 });
|
||||
if (!content.length)
|
||||
return json({ ok: false, error: 'Choose a non-empty script file.' }, { status: 400 });
|
||||
if (findRunningBetaForScript(scriptId)) {
|
||||
return json({ ok: false, error: 'Finish the active beta before uploading a version.' }, { status: 409 });
|
||||
return json(
|
||||
{ ok: false, error: 'Finish the active beta before uploading a version.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
const sha = createHash('sha256').update(content).digest('hex');
|
||||
const existing = db().prepare('SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1').get(scriptId, sha);
|
||||
const existing = db()
|
||||
.prepare(
|
||||
'SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1'
|
||||
)
|
||||
.get(scriptId, sha);
|
||||
if (existing) return json({ ok: true, version: existing, reused: true });
|
||||
}
|
||||
try {
|
||||
const releaseVersion = form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined;
|
||||
const releaseVersion = form.has('releaseVersion')
|
||||
? String(form.get('releaseVersion'))
|
||||
: undefined;
|
||||
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
|
||||
return json({
|
||||
ok: true,
|
||||
|
||||
@@ -9,9 +9,15 @@ export async function POST({ request }: { request: Request }) {
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return json({ error: 'Choose a non-empty script file.' }, { status: 400 });
|
||||
}
|
||||
const sha256 = createHash('sha256').update(Buffer.from(await file.arrayBuffer())).digest('hex');
|
||||
const duplicates = db().prepare(`SELECT v.script_id AS scriptId, v.id AS versionId, v.version
|
||||
const sha256 = createHash('sha256')
|
||||
.update(Buffer.from(await file.arrayBuffer()))
|
||||
.digest('hex');
|
||||
const duplicates = db()
|
||||
.prepare(
|
||||
`SELECT v.script_id AS scriptId, v.id AS versionId, v.version
|
||||
FROM script_versions v JOIN scripts s ON s.id = v.script_id
|
||||
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`).all(sha256);
|
||||
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`
|
||||
)
|
||||
.all(sha256);
|
||||
return json({ sha256, duplicates });
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ export async function PUT({ request }: { request: Request }) {
|
||||
|
||||
export async function DELETE({ request }: { request: Request }) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (typeof body?.id !== 'string' || !body.id.trim() || body.id.length > 128) return json({ error: 'Invalid key ID' }, { status: 400 });
|
||||
if (typeof body?.id !== 'string' || !body.id.trim() || body.id.length > 128)
|
||||
return json({ error: 'Invalid key ID' }, { status: 400 });
|
||||
try {
|
||||
return json({ ok: removeServerKey(body.id) });
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,11 +4,18 @@ import { json } from '@sveltejs/kit';
|
||||
import { queueCreateServer } from '$lib/server/provisioning/index.js';
|
||||
import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
|
||||
|
||||
export function GET() { return json(fleetSnapshot()); }
|
||||
export function GET() {
|
||||
return json(fleetSnapshot());
|
||||
}
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
hostId?: string; libraryId?: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number;
|
||||
hostId?: string;
|
||||
libraryId?: string;
|
||||
image?: string;
|
||||
bindAddress?: string;
|
||||
publicAddress?: string;
|
||||
publicPort?: number;
|
||||
game?: string;
|
||||
gameMode?: string;
|
||||
mapSlug?: string;
|
||||
@@ -16,9 +23,21 @@ export async function POST({ request }: { request: Request }) {
|
||||
expectedName?: string;
|
||||
previewId?: string;
|
||||
};
|
||||
if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string') || (body.previewId !== undefined && (typeof body.previewId !== 'string' || !body.previewId))) {
|
||||
if (
|
||||
!body ||
|
||||
(body.game !== undefined && body.game !== 't5' && body.game !== 't6') ||
|
||||
typeof body.gameMode !== 'string' ||
|
||||
typeof body.mapSlug !== 'string' ||
|
||||
(body.plutoKey !== undefined && typeof body.plutoKey !== 'string') ||
|
||||
(body.expectedName !== undefined && typeof body.expectedName !== 'string') ||
|
||||
(body.previewId !== undefined && (typeof body.previewId !== 'string' || !body.previewId))
|
||||
) {
|
||||
return json(
|
||||
{ ok: false, error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' },
|
||||
{
|
||||
ok: false,
|
||||
error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings',
|
||||
kind: 'bad-request'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -29,17 +48,34 @@ export async function POST({ request }: { request: Request }) {
|
||||
plutoKey: body.plutoKey,
|
||||
expectedName: body.expectedName,
|
||||
previewId: body.previewId,
|
||||
createdBy: 'ui', hostId: body.hostId, libraryId: body.libraryId, image: body.image, bindAddress: body.bindAddress, publicAddress: body.publicAddress, publicPort: body.publicPort
|
||||
createdBy: 'ui',
|
||||
hostId: body.hostId,
|
||||
libraryId: body.libraryId,
|
||||
image: body.image,
|
||||
bindAddress: body.bindAddress,
|
||||
publicAddress: body.publicAddress,
|
||||
publicPort: body.publicPort
|
||||
});
|
||||
if (!result.ok) {
|
||||
const status = result.kind === 'bad-request' ? 400 : result.kind === 'unconfigured' ? 503
|
||||
: result.kind === 'conflict' || result.kind === 'unknown-map' || result.kind === 'no-slot' || result.kind === 'no-port' ? 409
|
||||
: 500;
|
||||
const status =
|
||||
result.kind === 'bad-request'
|
||||
? 400
|
||||
: result.kind === 'unconfigured'
|
||||
? 503
|
||||
: result.kind === 'conflict' ||
|
||||
result.kind === 'unknown-map' ||
|
||||
result.kind === 'no-slot' ||
|
||||
result.kind === 'no-port'
|
||||
? 409
|
||||
: 500;
|
||||
return json({ ok: false, error: result.message, kind: result.kind }, { status });
|
||||
}
|
||||
// Return the durable job ID before the worker's synchronous admission work.
|
||||
setImmediate(() => {
|
||||
void kickProvisioning().catch((err) => console.error('[create] worker kick failed:', err));
|
||||
});
|
||||
return json({ ok: true, name: result.name, port: result.port, deploymentId: result.deploymentId }, { status: 202 });
|
||||
return json(
|
||||
{ ok: true, name: result.name, port: result.port, deploymentId: result.deploymentId },
|
||||
{ status: 202 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,18 @@ export function GET({ params }: { params: { name: string } }) {
|
||||
export async function DELETE({ params }: { params: { name: string } }) {
|
||||
const name = params.name;
|
||||
if (!name) {
|
||||
return json({ ok: false, error: 'server name required', kind: 'bad-request' }, { status: 400 });
|
||||
return json(
|
||||
{ ok: false, error: 'server name required', kind: 'bad-request' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = await destroyServer(name);
|
||||
if (!result.ok) {
|
||||
const status = result.kind === 'not-found' ? 404 : result.kind === 'conflict' ? 409 : 500;
|
||||
return json({ ok: false, error: result.message, kind: result.kind, warnings: result.warnings }, { status });
|
||||
return json(
|
||||
{ ok: false, error: result.message, kind: result.kind, warnings: result.warnings },
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
setImmediate(() => {
|
||||
void kickProvisioning().catch((err) => console.error('[destroy] worker kick failed:', err));
|
||||
|
||||
@@ -10,10 +10,11 @@ export async function GET({ params }: { params: { name: string } }) {
|
||||
return json({ ok: false, reason: 'Server not found' }, { status: 404, headers });
|
||||
}
|
||||
const path = resolveLogPath(server.name, gameLogKey(server.name));
|
||||
if (!path) return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers });
|
||||
if (!path)
|
||||
return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers });
|
||||
|
||||
try {
|
||||
return json({ ok: true, ...await collectServerChat(server) }, { headers });
|
||||
return json({ ok: true, ...(await collectServerChat(server)) }, { headers });
|
||||
} catch (error) {
|
||||
console.error('Could not read server chat', error);
|
||||
return json({ ok: false, reason: 'Could not read server chat' }, { status: 500, headers });
|
||||
|
||||
@@ -8,7 +8,10 @@ export function GET({ params, url }: { params: { name: string }; url: URL }) {
|
||||
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
||||
}
|
||||
let query;
|
||||
try { query = readMatchQuery(url.searchParams); }
|
||||
catch { return json({ error: 'Invalid match history filters.' }, { status: 400 }); }
|
||||
try {
|
||||
query = readMatchQuery(url.searchParams);
|
||||
} catch {
|
||||
return json({ error: 'Invalid match history filters.' }, { status: 400 });
|
||||
}
|
||||
return json(serverMatchHistory(params.name, limit, query));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,12 @@ export const POST: RequestHandler = async ({ params }) => {
|
||||
// Keep the diagnostic response, but never persist old-process evidence
|
||||
// across a lifecycle request or replacement registration.
|
||||
const current = getServer(name);
|
||||
if (!current || current.created_at !== row.created_at || epoch !== latestGameSubmission(name) || isProvisioning(name)) {
|
||||
if (
|
||||
!current ||
|
||||
current.created_at !== row.created_at ||
|
||||
epoch !== latestGameSubmission(name) ||
|
||||
isProvisioning(name)
|
||||
) {
|
||||
return json(result);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -6,6 +6,13 @@ import { redactRconAddresses } from '$lib/server/integrations/rcon-diagnostics.j
|
||||
export function GET({ params }: { params: { name: string } }) {
|
||||
const server = getServer(params.name);
|
||||
if (!server || server.destroyed_at) return json({ error: 'No such server.' }, { status: 404 });
|
||||
return json({ replySnippet: server.last_error && server.last_reply_snippet
|
||||
? redactRconAddresses(server.last_reply_snippet) : null }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
return json(
|
||||
{
|
||||
replySnippet:
|
||||
server.last_error && server.last_reply_snippet
|
||||
? redactRconAddresses(server.last_reply_snippet)
|
||||
: null
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,34 @@ import { db } from '$lib/server/db/index.js';
|
||||
export async function GET({ params }: { params: { name: string } }) {
|
||||
const server = getServer(params.name);
|
||||
if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 });
|
||||
const metadata = db().prepare(`SELECT i.rel_path, s.kind, s.id AS artifactId, v.version, v.release_version AS releaseVersion FROM server_scripts i
|
||||
const metadata = db()
|
||||
.prepare(
|
||||
`SELECT i.rel_path, s.kind, s.id AS artifactId, v.version, v.release_version AS releaseVersion FROM server_scripts i
|
||||
JOIN script_versions v ON v.id = i.script_version_id JOIN scripts s ON s.id = v.script_id
|
||||
WHERE i.server = ? AND i.sha256 = v.sha256`).all(params.name) as Array<{ rel_path: string; kind: string; artifactId: number; version: number; releaseVersion: string | null }>;
|
||||
const versions = new Map(metadata.map(row => [row.rel_path, row]));
|
||||
WHERE i.server = ? AND i.sha256 = v.sha256`
|
||||
)
|
||||
.all(params.name) as Array<{
|
||||
rel_path: string;
|
||||
kind: string;
|
||||
artifactId: number;
|
||||
version: number;
|
||||
releaseVersion: string | null;
|
||||
}>;
|
||||
const versions = new Map(metadata.map((row) => [row.rel_path, row]));
|
||||
const scripts = listServerScripts(params.name).map((r) => ({
|
||||
relPath: r.rel_path,
|
||||
artifactId: versions.get(r.rel_path)?.artifactId ?? null,
|
||||
version: versions.get(r.rel_path)?.version ?? null,
|
||||
releaseVersion: versions.get(r.rel_path)?.releaseVersion ?? null,
|
||||
kind: versions.get(r.rel_path)?.kind ?? (/\.cfg$/i.test(r.rel_path) ? 'config' : /\.dll$/i.test(r.rel_path) ? 'plugin' : /\.gsc$/i.test(r.rel_path) ? 'script' : 'file'),
|
||||
kind:
|
||||
versions.get(r.rel_path)?.kind ??
|
||||
(/\.cfg$/i.test(r.rel_path)
|
||||
? 'config'
|
||||
: /\.dll$/i.test(r.rel_path)
|
||||
? 'plugin'
|
||||
: /\.gsc$/i.test(r.rel_path)
|
||||
? 'script'
|
||||
: 'file'),
|
||||
scriptVersionId: r.script_version_id,
|
||||
sha256: r.sha256,
|
||||
size: r.size,
|
||||
|
||||
@@ -3,8 +3,12 @@ import { getServer } from '$lib/server/fleet/servers.js';
|
||||
import { listTelemetryRequests, telemetryRetention } from '$lib/server/fleet/telemetry-requests.js';
|
||||
|
||||
export async function GET({ params }: { params: { name: string } }) {
|
||||
if (!getServer(params.name)) return json({ ok: false, error: 'no such server' }, { status: 404 });
|
||||
return json({ ok: true, requests: listTelemetryRequests(params.name), retention: telemetryRetention() }, {
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
if (!getServer(params.name))
|
||||
return json({ ok: false, error: 'no such server' }, { status: 404 });
|
||||
return json(
|
||||
{ ok: true, requests: listTelemetryRequests(params.name), retention: telemetryRetention() },
|
||||
{
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,22 +2,41 @@ import { json } from '@sveltejs/kit';
|
||||
import { validateFleetSettings, concurrencyKeys, retentionKeys } from '$lib/fleet-settings.js';
|
||||
import { fleetSettings, saveFleetSettings } from '$lib/server/settings.js';
|
||||
|
||||
export function GET() { return json(fleetSettings()); }
|
||||
export function GET() {
|
||||
return json(fleetSettings());
|
||||
}
|
||||
|
||||
export async function PUT({ request }: { request: Request }) {
|
||||
let settings;
|
||||
try { settings = validateFleetSettings(await request.json()); }
|
||||
catch (error) { return json({ error: (error as Error).message }, { status: 400 }); }
|
||||
try {
|
||||
settings = validateFleetSettings(await request.json());
|
||||
} catch (error) {
|
||||
return json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
return json(saveFleetSettings(settings));
|
||||
}
|
||||
|
||||
export async function PATCH({ request }: { request: Request }) {
|
||||
try {
|
||||
const { section, values } = await request.json();
|
||||
const keys = section === 'Concurrency' ? concurrencyKeys : section === 'Retention' ? retentionKeys : null;
|
||||
if (!keys || !values || typeof values !== 'object' || Array.isArray(values)
|
||||
|| Object.keys(values).length !== keys.length || keys.some(key => !Object.hasOwn(values, key))
|
||||
|| Object.keys(values).some(key => !keys.includes(key as typeof keys[number]))) throw new Error('Provide all settings for one section.');
|
||||
const keys =
|
||||
section === 'Concurrency'
|
||||
? concurrencyKeys
|
||||
: section === 'Retention'
|
||||
? retentionKeys
|
||||
: null;
|
||||
if (
|
||||
!keys ||
|
||||
!values ||
|
||||
typeof values !== 'object' ||
|
||||
Array.isArray(values) ||
|
||||
Object.keys(values).length !== keys.length ||
|
||||
keys.some((key) => !Object.hasOwn(values, key)) ||
|
||||
Object.keys(values).some((key) => !keys.includes(key as (typeof keys)[number]))
|
||||
)
|
||||
throw new Error('Provide all settings for one section.');
|
||||
return json(saveFleetSettings({ ...fleetSettings(), ...values }));
|
||||
} catch (error) { return json({ error: (error as Error).message }, { status: 400 }); }
|
||||
} catch (error) {
|
||||
return json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { retentionPreview, retentionControls, sampleRetentionPreview, setRetentionControl } from '$lib/server/retention.js';
|
||||
import {
|
||||
retentionPreview,
|
||||
retentionControls,
|
||||
sampleRetentionPreview,
|
||||
setRetentionControl
|
||||
} from '$lib/server/retention.js';
|
||||
import { gameFileRetentionPreview } from '$lib/server/game-files/index.js';
|
||||
|
||||
export function GET() {
|
||||
return json({ policies: [...retentionPreview(),...sampleRetentionPreview(),...gameFileRetentionPreview()], controls: retentionControls() }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
return json(
|
||||
{
|
||||
policies: [
|
||||
...retentionPreview(),
|
||||
...sampleRetentionPreview(),
|
||||
...gameFileRetentionPreview()
|
||||
],
|
||||
controls: retentionControls()
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try { setRetentionControl(await request.json()); return json({ ok: true }); }
|
||||
catch (error) { return json({ error: (error as Error).message }, { status: 400 }); }
|
||||
try {
|
||||
setRetentionControl(await request.json());
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
return json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { validateFleetSettings } from '$lib/fleet-settings.js';
|
||||
import { retentionPreview,retentionControls,sampleRetentionPreview } from '$lib/server/retention.js';
|
||||
import {
|
||||
retentionPreview,
|
||||
retentionControls,
|
||||
sampleRetentionPreview
|
||||
} from '$lib/server/retention.js';
|
||||
import { gameFileRetentionPreview } from '$lib/server/game-files/index.js';
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const settings = validateFleetSettings(await request.json());
|
||||
return json({ policies:[...retentionPreview(settings),...sampleRetentionPreview(settings),...gameFileRetentionPreview(settings)],controls:retentionControls() },{ headers:{'Cache-Control':'no-store'} });
|
||||
} catch (error) { return json({error:(error as Error).message},{status:400}); }
|
||||
return json(
|
||||
{
|
||||
policies: [
|
||||
...retentionPreview(settings),
|
||||
...sampleRetentionPreview(settings),
|
||||
...gameFileRetentionPreview(settings)
|
||||
],
|
||||
controls: retentionControls()
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
} catch (error) {
|
||||
return json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,22 +9,95 @@ import { listLibraries } from '$lib/server/game-files/index.js';
|
||||
|
||||
export async function GET() {
|
||||
const servers = listServers();
|
||||
const readyServers = servers.filter(server => serverObservation(server).health === 'online').length;
|
||||
const rconServers = servers.filter(server => !server.last_error && freshStatusTimestamp(server.last_seen_at, Date.now()) && server.container_state === 'running').length;
|
||||
const readyServers = servers.filter(
|
||||
(server) => serverObservation(server).health === 'online'
|
||||
).length;
|
||||
const rconServers = servers.filter(
|
||||
(server) =>
|
||||
!server.last_error &&
|
||||
freshStatusTimestamp(server.last_seen_at, Date.now()) &&
|
||||
server.container_state === 'running'
|
||||
).length;
|
||||
const savedKeys = listServerKeys().length;
|
||||
const hosts = listHosts();
|
||||
const readyHosts = hosts.filter(host => host.readiness === 'ready');
|
||||
const connected = hosts.filter(host => host.connected);
|
||||
const versions = listLibraries().flatMap(game => game.versions.filter(version => version.id === game.latestVersionId));
|
||||
const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId)));
|
||||
const runtimes = connected.filter(host => host.inventory.runtimeImages?.length);
|
||||
const readyHosts = hosts.filter((host) => host.readiness === 'ready');
|
||||
const connected = hosts.filter((host) => host.connected);
|
||||
const versions = listLibraries().flatMap((game) =>
|
||||
game.versions.filter((version) => version.id === game.latestVersionId)
|
||||
);
|
||||
const cached = versions.filter((version) =>
|
||||
version.caches.some(
|
||||
(cache) =>
|
||||
cache.status === 'ready' && readyHosts.some((host) => host.id === cache.hostId)
|
||||
)
|
||||
);
|
||||
const runtimes = connected.filter((host) => host.inventory.runtimeImages?.length);
|
||||
const checks: SetupCheck[] = [
|
||||
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
|
||||
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Hosts prepare the configured Plutainer runtime automatically.', href: '/hosts', action: 'View host setup' },
|
||||
{ id: 'game-files', title: 'Game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' },
|
||||
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
|
||||
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
|
||||
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a host to launch your first server with the latest game files.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
|
||||
{
|
||||
id: 'hosts',
|
||||
title: 'Host agents',
|
||||
state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured',
|
||||
detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`,
|
||||
href: '/hosts',
|
||||
action: hosts.length ? 'Review hosts' : 'Add host'
|
||||
},
|
||||
{
|
||||
id: 'runtime',
|
||||
title: 'Plutainer runtime',
|
||||
state: runtimes.length ? 'ready' : 'attention',
|
||||
detail: runtimes.length
|
||||
? `${runtimes.length} connected hosts report cached runtime images.`
|
||||
: 'Hosts prepare the configured Plutainer runtime automatically.',
|
||||
href: '/hosts',
|
||||
action: 'View host setup'
|
||||
},
|
||||
{
|
||||
id: 'game-files',
|
||||
title: 'Game files',
|
||||
state: cached.length ? 'ready' : 'attention',
|
||||
detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`,
|
||||
href: '/game-files',
|
||||
action: 'Manage game files'
|
||||
},
|
||||
{
|
||||
id: 'rcon',
|
||||
title: 'Agent-local game connection',
|
||||
state: rconServers ? 'ready' : 'unverified',
|
||||
detail: rconServers
|
||||
? `${rconServers} servers have fresh RCON responses.`
|
||||
: 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.',
|
||||
href: servers.length ? '/' : '/hosts',
|
||||
action: servers.length ? 'Review fleet' : 'Review hosts'
|
||||
},
|
||||
{
|
||||
id: 'keys',
|
||||
title: 'Saved server keys',
|
||||
state: savedKeys ? 'ready' : 'attention',
|
||||
detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`,
|
||||
href: '/server-keys',
|
||||
action: 'Manage keys'
|
||||
},
|
||||
{
|
||||
id: 'first-server',
|
||||
title: 'First server ready',
|
||||
state: readyServers ? 'ready' : 'attention',
|
||||
detail: readyServers
|
||||
? `${readyServers} of ${servers.length} servers are responding.`
|
||||
: servers.length
|
||||
? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.'
|
||||
: 'Choose a host to launch your first server with the latest game files.',
|
||||
href: servers.length ? '/' : '/provisioning',
|
||||
action: servers.length ? 'Review fleet' : 'Create a server'
|
||||
}
|
||||
];
|
||||
return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } });
|
||||
return json(
|
||||
{
|
||||
checkedAt: new Date().toISOString(),
|
||||
checks,
|
||||
readyServers,
|
||||
totalServers: servers.length,
|
||||
savedKeys
|
||||
} satisfies SystemStatus,
|
||||
{ headers: { 'cache-control': 'no-store' } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,21 +13,28 @@ export async function POST({ request }: { request: Request }) {
|
||||
if (!authentication.ok) {
|
||||
// Unauthenticated bodies are never parsed, attributed to a server, or
|
||||
// retained in telemetry diagnostics; they cannot affect game evidence.
|
||||
return json({ ok: false, error: authentication.error }, {
|
||||
status: authentication.status,
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
return json(
|
||||
{ ok: false, error: authentication.error },
|
||||
{
|
||||
status: authentication.status,
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
}
|
||||
);
|
||||
}
|
||||
const receivedAt = new Date().toISOString();
|
||||
const contentType = request.headers.get('content-type');
|
||||
let value: unknown;
|
||||
let malformed = false;
|
||||
try { value = await request.json(); }
|
||||
catch { malformed = true; }
|
||||
try {
|
||||
value = await request.json();
|
||||
} catch {
|
||||
malformed = true;
|
||||
}
|
||||
|
||||
function log(status: number, error: string | null): void {
|
||||
try { recordTelemetryRequest({ value, receivedAt, status, error, contentType }); }
|
||||
catch (error) {
|
||||
try {
|
||||
recordTelemetryRequest({ value, receivedAt, status, error, contentType });
|
||||
} catch (error) {
|
||||
// Diagnostics must not turn a saved report into a failed request.
|
||||
console.error('Could not record telemetry request diagnostics', error);
|
||||
}
|
||||
@@ -42,11 +49,15 @@ export async function POST({ request }: { request: Request }) {
|
||||
}
|
||||
if (malformed) return reject(400, 'Malformed JSON');
|
||||
let telemetry: GameTelemetry;
|
||||
try { telemetry = { ...parseGameEvent(value), receivedAt }; }
|
||||
catch (error) { return reject(400, (error as Error).message); }
|
||||
try {
|
||||
telemetry = { ...parseGameEvent(value), receivedAt };
|
||||
} catch (error) {
|
||||
return reject(400, (error as Error).message);
|
||||
}
|
||||
try {
|
||||
const server = getServer(telemetry.server);
|
||||
if (!server || server.destroyed_at) return reject(404, 'Server is not registered or has been destroyed');
|
||||
if (!server || server.destroyed_at)
|
||||
return reject(404, 'Server is not registered or has been destroyed');
|
||||
db().transaction(() => {
|
||||
telemetry = saveTelemetry(telemetry);
|
||||
recordGameEvent(telemetry);
|
||||
@@ -54,7 +65,10 @@ export async function POST({ request }: { request: Request }) {
|
||||
emit({ type: 'server-telemetry', entries: [telemetry] });
|
||||
} catch (error) {
|
||||
console.error('Could not process telemetry', error);
|
||||
log(500, `Could not process telemetry: ${error instanceof Error ? error.message : String(error)}`);
|
||||
log(
|
||||
500,
|
||||
`Could not process telemetry: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return json({ ok: false, error: 'Could not process telemetry' }, { status: 500 });
|
||||
}
|
||||
log(200, null);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { createUser, listUsers } from '$lib/server/auth/index.js';
|
||||
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
|
||||
export const GET: RequestHandler = () => json({ users:listUsers() });
|
||||
export const GET: RequestHandler = () => json({ users: listUsers() });
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
try { return json({ user:await createUser(requireUser(locals.user).id,await authBody(request)) },{status:201}); }
|
||||
catch (cause) { return authError(cause); }
|
||||
try {
|
||||
return json(
|
||||
{ user: await createUser(requireUser(locals.user).id, await authBody(request)) },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (cause) {
|
||||
return authError(cause);
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user