Implement platform management features
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
|
||||
function fail(message, details = undefined) {
|
||||
console.error(message);
|
||||
if (details !== undefined) console.error(JSON.stringify(details, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseEvents(file) {
|
||||
const body = fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
||||
const events = [];
|
||||
for (const block of body.split("\n\n")) {
|
||||
const lines = block.split("\n");
|
||||
let event = "message";
|
||||
let id = "";
|
||||
const data = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim();
|
||||
if (line.startsWith("id:")) id = line.slice(3).trim();
|
||||
if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
|
||||
}
|
||||
if (data.length === 0) continue;
|
||||
try {
|
||||
events.push({ event, id, data: JSON.parse(data.join("\n")) });
|
||||
} catch {
|
||||
// The final block may be incomplete while curl is still appending.
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function markerEvent(events, marker) {
|
||||
return events.find((item) => item.event === "log" && item.data?.entry?.line === marker);
|
||||
}
|
||||
|
||||
function requireMarker(events, marker) {
|
||||
const item = markerEvent(events, marker);
|
||||
if (!item) fail(`missing SSE log marker: ${marker}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
const [command, file, ...args] = process.argv.slice(2);
|
||||
if (!command || !file) {
|
||||
fail("usage: verify-current-log-session-sse.mjs <session-for-marker|verify> <sse-file> [...args]");
|
||||
}
|
||||
|
||||
const events = parseEvents(file);
|
||||
if (command === "session-for-marker") {
|
||||
const [marker] = args;
|
||||
const item = requireMarker(events, marker);
|
||||
if (!item.data.logSessionId) fail(`marker has no logSessionId: ${marker}`, item);
|
||||
process.stdout.write(item.data.logSessionId);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command !== "verify") fail(`unknown command: ${command}`);
|
||||
|
||||
const [serverID, prefix, expectedSessionA, forbiddenIDsText = ""] = args;
|
||||
if (!serverID || !prefix || !expectedSessionA) fail("verify requires server id, marker prefix, and generation A session id");
|
||||
|
||||
const markers = {
|
||||
aStdout: `${prefix}-A-START-STDOUT`,
|
||||
aStderr: `${prefix}-A-START-STDERR`,
|
||||
aResumeStdout: `${prefix}-A-RUN-RESUME-STDOUT`,
|
||||
aResumeStderr: `${prefix}-A-RUN-RESUME-STDERR`,
|
||||
bStdout: `${prefix}-B-START-STDOUT`,
|
||||
bStderr: `${prefix}-B-START-STDERR`
|
||||
};
|
||||
const markerItems = Object.fromEntries(Object.entries(markers).map(([key, marker]) => [key, requireMarker(events, marker)]));
|
||||
const eventIndex = (needle) => events.indexOf(needle);
|
||||
const sessionEvents = events.filter((item) => item.event === "session" && item.data?.logSessionId);
|
||||
const sessionIDs = sessionEvents.map((item) => item.data.logSessionId);
|
||||
|
||||
if (sessionIDs.length !== 2 || sessionIDs[0] !== expectedSessionA) {
|
||||
fail("expected exactly one generation A boundary followed by one generation B boundary", { sessionIDs, expectedSessionA });
|
||||
}
|
||||
const sessionB = sessionIDs[1];
|
||||
if (!sessionB || sessionB === expectedSessionA) fail("generation B did not receive a new session id", { sessionIDs });
|
||||
|
||||
for (const key of ["aStdout", "aStderr", "aResumeStdout", "aResumeStderr"]) {
|
||||
if (markerItems[key].data.logSessionId !== expectedSessionA) {
|
||||
fail(`generation A marker changed session at ${key}`, markerItems[key]);
|
||||
}
|
||||
}
|
||||
for (const key of ["bStdout", "bStderr"]) {
|
||||
if (markerItems[key].data.logSessionId !== sessionB) fail(`generation B marker has the wrong session at ${key}`, markerItems[key]);
|
||||
}
|
||||
|
||||
const boundaryBIndex = eventIndex(sessionEvents[1]);
|
||||
const firstBLogIndex = Math.min(eventIndex(markerItems.bStdout), eventIndex(markerItems.bStderr));
|
||||
if (boundaryBIndex < 0 || boundaryBIndex >= firstBLogIndex) {
|
||||
fail("generation B session boundary was not delivered before generation B output", { boundaryBIndex, firstBLogIndex });
|
||||
}
|
||||
if (eventIndex(markerItems.aResumeStdout) >= boundaryBIndex || eventIndex(markerItems.aResumeStderr) >= boundaryBIndex) {
|
||||
fail("Run-resume output arrived after the process-generation boundary");
|
||||
}
|
||||
|
||||
const allowedStreamKeys = new Set(["smoke.console.stdout", "smoke.console.stderr"]);
|
||||
const forbiddenIDs = new Set(forbiddenIDsText.split(",").filter(Boolean));
|
||||
const streamIDsBySession = new Map();
|
||||
for (let index = 0; index < events.length; index += 1) {
|
||||
const item = events[index];
|
||||
if (item.data?.serverInstanceId && item.data.serverInstanceId !== serverID) {
|
||||
fail("SSE feed included another server instance", item);
|
||||
}
|
||||
const streamID = item.event === "stream" ? item.data?.id : item.data?.streamId;
|
||||
if (streamID && forbiddenIDs.has(streamID)) fail("SSE feed included an explicitly historical stream", item);
|
||||
if (item.event !== "stream" && item.event !== "log") continue;
|
||||
const sessionID = item.data?.logSessionId;
|
||||
const source = item.data?.source;
|
||||
const streamKey = item.data?.streamKey;
|
||||
if (item.data?.entry?.line === `${prefix}-HISTORICAL-JOB-STDOUT` || item.data?.entry?.line === `${prefix}-HISTORICAL-JOB-STDERR`) {
|
||||
fail("live SSE included historical job output", item);
|
||||
}
|
||||
if (source !== "process" || !allowedStreamKeys.has(streamKey)) {
|
||||
fail("live SSE included a legacy, job, file-tail, or undeclared stream", item);
|
||||
}
|
||||
if (index < boundaryBIndex && sessionID !== expectedSessionA) {
|
||||
fail("generation A feed mixed output from another or sessionless stream", item);
|
||||
}
|
||||
if (index > boundaryBIndex && sessionID !== sessionB) {
|
||||
fail("post-switch SSE mixed output from another session", item);
|
||||
}
|
||||
if (index > boundaryBIndex && item.data?.entry?.line?.startsWith(`${prefix}-A-`)) {
|
||||
fail("post-switch SSE retained generation A output", item);
|
||||
}
|
||||
if (streamID && sessionID) {
|
||||
if (!streamIDsBySession.has(sessionID)) streamIDsBySession.set(sessionID, new Set());
|
||||
streamIDsBySession.get(sessionID).add(streamID);
|
||||
}
|
||||
}
|
||||
|
||||
const streamsA = streamIDsBySession.get(expectedSessionA) ?? new Set();
|
||||
const streamsB = streamIDsBySession.get(sessionB) ?? new Set();
|
||||
if (streamsA.size < 2 || streamsB.size < 2) fail("stdout/stderr stream metadata was incomplete", { streamsA: [...streamsA], streamsB: [...streamsB] });
|
||||
for (const streamID of streamsA) {
|
||||
if (streamsB.has(streamID)) fail("process generations reused a stream id", { streamID });
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
serverInstanceId: serverID,
|
||||
sessionA: expectedSessionA,
|
||||
sessionB,
|
||||
sessionBoundaries: sessionIDs,
|
||||
markerOrder: Object.fromEntries(Object.entries(markerItems).map(([key, item]) => [key, eventIndex(item)])),
|
||||
generationAStreamIds: [...streamsA].sort(),
|
||||
generationBStreamIds: [...streamsB].sort(),
|
||||
excludedHistoricalStreamIds: [...forbiddenIDs].sort(),
|
||||
eventCount: events.length
|
||||
}, null, 2)}\n`);
|
||||
Reference in New Issue
Block a user