feat: 完整游戏运维功能

This commit is contained in:
npc0-hue
2026-07-18 09:04:01 +08:00
parent f3b14b7945
commit 48b8ad8d6c
187 changed files with 16607 additions and 1140 deletions
+12
View File
@@ -32,4 +32,16 @@ describe("ai provider form schemas", () => {
});
expect(request.models).toEqual(["gpt-oss:20b"]);
});
it("keeps an existing configured secret opaque during edits", () => {
const request = aiProviderUpdateRequestFromForm({
...emptyAiProviderForm(),
id: "ai.openai",
apiKeyRef: "",
apiKeyConfigured: true
});
expect(request.apiKeyRef).toBe("");
expect(JSON.stringify(request)).not.toContain("secret://providers/openai");
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { parseSafeJobResponse } from "./jobs";
const retryingJob = {
id: "job-1",
runEndpointId: "run-local",
capability: "process.start",
idempotencyKey: "idem-1",
state: "retrying",
progress: { percent: 10, message: "Run acknowledgement deadline expired" },
retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 2, maxBackoffSeconds: 60 },
attempt: 1,
nextAttemptAt: "2026-07-18T12:00:02Z",
reconcileCount: 1,
reconcileOutcome: "missing from Run journal",
createdAt: "2026-07-18T12:00:00Z",
updatedAt: "2026-07-18T12:00:00Z"
};
describe("safe job projection schema", () => {
it("accepts retry and reconciliation metadata", () => {
expect(parseSafeJobResponse(retryingJob)).toMatchObject({ state: "retrying", attempt: 1, retryPolicy: { maxAttempts: 3 }, reconcileCount: 1 });
});
it.each(["leaseToken", "leaseTokenHash", "sessionToken", "secretRef", "hostPath", "socket"])("rejects forbidden %s fields", (field) => {
expect(() => parseSafeJobResponse({ ...retryingJob, [field]: "forbidden" })).toThrow(/forbidden field/);
});
it("accepts safe typed execution metadata without private content", () => {
const parsed = parseSafeJobResponse({
...retryingJob,
state: "succeeded",
executionResult: { kind: "file.write", version: 2, checksum: "sha256:" + "a".repeat(64), sizeBytes: 18, auditSummary: "atomic compare-and-swap file write" }
});
expect(parsed.executionResult).toMatchObject({ kind: "file.write", version: 2, sizeBytes: 18 });
expect(parsed.executionResult).not.toHaveProperty("content");
expect(() => parseSafeJobResponse({ ...retryingJob, executionResult: { content: "private" } })).toThrow(/forbidden field/);
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { JobResponse, JobState } from "../api/types";
const jobStates = new Set<JobState>(["queued", "accepted", "running", "retrying", "succeeded", "failed", "cancelled"]);
const forbiddenProjectionKeys = new Set(["leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket", "credential", "content"]);
export function parseSafeJobResponse(value: unknown): JobResponse {
if (!isRecord(value)) throw new Error("job projection must be an object");
rejectForbiddenKeys(value);
const state = requiredString(value, "state") as JobState;
if (!jobStates.has(state)) throw new Error("job state is invalid");
const progress = requiredRecord(value, "progress");
const retryPolicy = requiredRecord(value, "retryPolicy");
const parsed: JobResponse = {
id: requiredString(value, "id"),
serverInstanceId: optionalString(value, "serverInstanceId"),
runEndpointId: requiredString(value, "runEndpointId"),
capability: requiredString(value, "capability"),
targetKey: optionalString(value, "targetKey"),
inputRef: optionalString(value, "inputRef"),
idempotencyKey: requiredString(value, "idempotencyKey"),
state,
progress: { percent: requiredNumber(progress, "percent"), message: optionalString(progress, "message") },
resultRef: optionalString(value, "resultRef"),
executionResult: optionalExecutionResult(value, "executionResult"),
retryPolicy: {
maxAttempts: requiredNumber(retryPolicy, "maxAttempts"),
initialBackoffSeconds: requiredNumber(retryPolicy, "initialBackoffSeconds"),
maxBackoffSeconds: requiredNumber(retryPolicy, "maxBackoffSeconds")
},
attempt: requiredNumber(value, "attempt"),
nextAttemptAt: optionalString(value, "nextAttemptAt"),
ackDeadlineAt: optionalString(value, "ackDeadlineAt"),
leaseExpiresAt: optionalString(value, "leaseExpiresAt"),
cancelReason: optionalString(value, "cancelReason"),
cancelRequestedAt: optionalString(value, "cancelRequestedAt"),
cancelCompletedAt: optionalString(value, "cancelCompletedAt"),
terminalAt: optionalString(value, "terminalAt"),
lastReconciledAt: optionalString(value, "lastReconciledAt"),
reconcileCount: requiredNumber(value, "reconcileCount"),
reconcileOutcome: optionalString(value, "reconcileOutcome"),
createdAt: requiredString(value, "createdAt"),
updatedAt: requiredString(value, "updatedAt")
};
return parsed;
}
function optionalExecutionResult(value: Record<string, unknown>, key: string): JobResponse["executionResult"] {
const field = value[key];
if (field === undefined) return undefined;
if (!isRecord(field)) throw new Error(`${key} must be an object`);
rejectForbiddenKeys(field);
const result: NonNullable<JobResponse["executionResult"]> = {
kind: optionalString(field, "kind"),
processState: optionalString(field, "processState"),
exitClassification: optionalString(field, "exitClassification"),
exitCode: optionalSignedNumber(field, "exitCode"),
version: optionalNumber(field, "version"),
checksum: optionalString(field, "checksum"),
sizeBytes: optionalNumber(field, "sizeBytes"),
auditSummary: optionalString(field, "auditSummary")
};
return result;
}
function rejectForbiddenKeys(value: Record<string, unknown>): void {
for (const key of Object.keys(value)) {
if (forbiddenProjectionKeys.has(key)) throw new Error(`job projection contains forbidden field ${key}`);
}
}
function requiredRecord(value: Record<string, unknown>, key: string): Record<string, unknown> {
const field = value[key];
if (!isRecord(field)) throw new Error(`${key} must be an object`);
rejectForbiddenKeys(field);
return field;
}
function requiredString(value: Record<string, unknown>, key: string): string {
const field = value[key];
if (typeof field !== "string" || field.trim() === "") throw new Error(`${key} must be a non-empty string`);
return field;
}
function optionalString(value: Record<string, unknown>, key: string): string | undefined {
const field = value[key];
if (field === undefined) return undefined;
if (typeof field !== "string") throw new Error(`${key} must be a string`);
return field;
}
function optionalSignedNumber(value: Record<string, unknown>, key: string): number | undefined {
const field = value[key];
if (field === undefined) return undefined;
if (typeof field !== "number" || !Number.isFinite(field)) throw new Error(`${key} must be a number`);
return field;
}
function optionalNumber(value: Record<string, unknown>, key: string): number | undefined {
const field = optionalSignedNumber(value, key);
if (field !== undefined && field < 0) throw new Error(`${key} must be non-negative`);
return field;
}
function requiredNumber(value: Record<string, unknown>, key: string): number {
const field = value[key];
if (typeof field !== "number" || !Number.isFinite(field) || field < 0) throw new Error(`${key} must be a non-negative number`);
return field;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { parseSafeDependencyCatalog, parseSafeRunUpdateList } from "./runtimeUpdates";
const digest = `sha256:${"a".repeat(64)}`;
describe("safe dependency and Run update projections", () => {
it("accepts reviewable plans, safe evidence, checksums, and rollback phases", () => {
const catalog = parseSafeDependencyCatalog({
serverInstanceId: "server-1",
pluginId: "game.runtime",
pluginVersion: "1.0.0",
profileKey: "local",
targetOs: "linux",
targetArch: "amd64",
probes: [{ key: "java", kind: "java.version", required: true, state: "present", evidence: "OpenJDK 21", installPlanKey: "java-install" }],
plans: [{ key: "java-install", title: "Install Java", targetOs: "linux", targetArch: "amd64", digest, steps: [{ type: "package", targetKey: "java", packageManager: "apt", packageName: "openjdk-21-jre" }] }],
updatedAt: "2026-07-18T12:00:00Z"
});
expect(catalog.plans[0]).toMatchObject({ digest, targetOs: "linux" });
const updates = parseSafeRunUpdateList({
items: [{
id: "update-1",
serverInstanceId: "server-1",
runEndpointId: "run-1",
artifactId: "artifact-1",
checksum: digest,
targetOs: "linux",
targetArch: "amd64",
targetRelease: "release-2",
previousVersion: "release-1",
jobId: "job-1",
status: "failed",
phase: "rolled-back",
message: "previous executable restored",
rollback: true,
createdAt: "2026-07-18T12:00:00Z",
updatedAt: "2026-07-18T12:01:00Z"
}],
count: 1
});
expect(updates.items[0]).toMatchObject({ phase: "rolled-back", rollback: true, checksum: digest });
});
it.each(["leaseToken", "sessionToken", "secretRef", "hostPath", "socket", "credential", "pid", "payload", "bindings"])("rejects forbidden %s fields recursively", (field) => {
expect(() => parseSafeDependencyCatalog({
serverInstanceId: "server-1",
pluginId: "game.runtime",
pluginVersion: "1.0.0",
profileKey: "local",
targetOs: "linux",
targetArch: "amd64",
probes: [{ key: "java", kind: "java.version", required: true, state: "unknown", [field]: "private" }],
plans: [],
updatedAt: "2026-07-18T12:00:00Z"
})).toThrow(/forbidden field/);
});
it("rejects raw host paths or credentials hidden in safe-looking evidence", () => {
expect(() => parseSafeDependencyCatalog({
serverInstanceId: "server-1",
pluginId: "game.runtime",
pluginVersion: "1.0.0",
profileKey: "local",
targetOs: "linux",
targetArch: "amd64",
probes: [{ key: "java", kind: "java.version", required: true, state: "present", evidence: "/Users/operator/private" }],
plans: [],
updatedAt: "2026-07-18T12:00:00Z"
})).toThrow(/unsafe runtime details/);
});
});
+202
View File
@@ -0,0 +1,202 @@
import type {
DependencyCatalogResponse,
DependencyPlanStepViewResponse,
DependencyPlanViewResponse,
DependencyProbeViewResponse,
DependencyState,
RunUpdateJobListResponse,
RunUpdateJobResponse,
RunUpdatePhase
} from "../api/types";
const dependencyStates = new Set<DependencyState>(["unknown", "present", "missing", "installing", "failed"]);
const updatePhases = new Set<RunUpdatePhase>(["queued", "downloading", "staged", "restart-requested", "activating", "succeeded", "rolled-back", "failed"]);
const updateStatuses = new Set(["queued", "running", "succeeded", "failed", "denied"]);
const forbiddenProjectionKeys = new Set([
"leasetoken",
"leasetokenhash",
"leasesessiongeneration",
"sessiontoken",
"runtoken",
"secretref",
"hostpath",
"executablepath",
"stagingpath",
"backuppath",
"socket",
"credential",
"pid",
"content",
"payload",
"bindings",
"downloadref"
]);
const unsafeProjectionText = /(?:\/Users\/|\/home\/|\/var\/run\/|[A-Za-z]:\\|unix:\/\/|tcp:\/\/|Bearer\s+|password=|token=|sk-[A-Za-z0-9_-]+)/i;
export function parseSafeDependencyCatalog(value: unknown): DependencyCatalogResponse {
const record = requiredRecordValue(value, "dependency catalog");
rejectForbiddenProjection(record);
return {
serverInstanceId: requiredString(record, "serverInstanceId"),
pluginId: requiredString(record, "pluginId"),
pluginVersion: requiredString(record, "pluginVersion"),
profileKey: requiredString(record, "profileKey"),
targetOs: requiredString(record, "targetOs"),
targetArch: requiredString(record, "targetArch"),
probes: requiredArray(record, "probes").map(parseDependencyProbe),
plans: requiredArray(record, "plans").map(parseDependencyPlan),
updatedAt: requiredString(record, "updatedAt")
};
}
export function parseSafeRunUpdateList(value: unknown): RunUpdateJobListResponse {
const record = requiredRecordValue(value, "Run update list");
rejectForbiddenProjection(record);
const items = requiredArray(record, "items").map((item) => parseRunUpdate(requiredRecordValue(item, "Run update")));
const count = requiredNumber(record, "count");
if (count !== items.length) throw new Error("Run update count does not match items");
return { items, count };
}
export function parseSafeRunUpdate(value: unknown): RunUpdateJobResponse {
const record = requiredRecordValue(value, "Run update");
rejectForbiddenProjection(record);
return parseRunUpdate(record);
}
function parseDependencyProbe(value: unknown): DependencyProbeViewResponse {
const record = requiredRecordValue(value, "dependency probe");
const state = requiredString(record, "state") as DependencyState;
if (!dependencyStates.has(state)) throw new Error("dependency state is invalid");
return {
key: requiredString(record, "key"),
kind: requiredString(record, "kind"),
required: requiredBoolean(record, "required"),
minimumVersion: optionalSafeString(record, "minimumVersion"),
state,
evidence: optionalSafeString(record, "evidence"),
installPlanKey: optionalString(record, "installPlanKey")
};
}
function parseDependencyPlan(value: unknown): DependencyPlanViewResponse {
const record = requiredRecordValue(value, "dependency plan");
return {
key: requiredString(record, "key"),
title: requiredString(record, "title"),
targetOs: requiredString(record, "targetOs"),
targetArch: requiredString(record, "targetArch"),
digest: requiredChecksum(record, "digest"),
steps: requiredArray(record, "steps").map(parseDependencyStep)
};
}
function parseDependencyStep(value: unknown): DependencyPlanStepViewResponse {
const record = requiredRecordValue(value, "dependency plan step");
const downloadHost = optionalString(record, "downloadHost");
if (downloadHost && (downloadHost.includes("/") || downloadHost.includes("@") || downloadHost.includes(":"))) throw new Error("dependency download host is invalid");
return {
type: requiredString(record, "type"),
targetKey: requiredString(record, "targetKey"),
packageManager: optionalString(record, "packageManager"),
packageName: optionalString(record, "packageName"),
version: optionalString(record, "version"),
downloadHost,
sizeBytes: optionalNumber(record, "sizeBytes")
};
}
function parseRunUpdate(record: Record<string, unknown>): RunUpdateJobResponse {
const phase = requiredString(record, "phase") as RunUpdatePhase;
if (!updatePhases.has(phase)) throw new Error("Run update phase is invalid");
const status = requiredString(record, "status");
if (!updateStatuses.has(status)) throw new Error("Run update status is invalid");
return {
id: requiredString(record, "id"),
serverInstanceId: requiredString(record, "serverInstanceId"),
runEndpointId: requiredString(record, "runEndpointId"),
artifactId: requiredString(record, "artifactId"),
checksum: requiredChecksum(record, "checksum"),
targetOs: requiredString(record, "targetOs"),
targetArch: requiredString(record, "targetArch"),
targetRelease: optionalString(record, "targetRelease"),
previousVersion: optionalString(record, "previousVersion"),
jobId: optionalString(record, "jobId"),
idempotencyKey: optionalString(record, "idempotencyKey"),
status,
phase,
message: optionalSafeString(record, "message"),
rollback: requiredBoolean(record, "rollback"),
createdAt: requiredString(record, "createdAt"),
updatedAt: requiredString(record, "updatedAt")
};
}
function rejectForbiddenProjection(value: unknown): void {
if (Array.isArray(value)) {
value.forEach(rejectForbiddenProjection);
return;
}
if (!isRecord(value)) return;
for (const [key, field] of Object.entries(value)) {
if (forbiddenProjectionKeys.has(key.toLowerCase())) throw new Error(`runtime projection contains forbidden field ${key}`);
rejectForbiddenProjection(field);
}
}
function requiredRecordValue(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${label} must be an object`);
return value;
}
function requiredArray(value: Record<string, unknown>, key: string): unknown[] {
const field = value[key];
if (!Array.isArray(field)) throw new Error(`${key} must be an array`);
return field;
}
function requiredString(value: Record<string, unknown>, key: string): string {
const field = value[key];
if (typeof field !== "string" || field.trim() === "") throw new Error(`${key} must be a non-empty string`);
return field;
}
function optionalString(value: Record<string, unknown>, key: string): string | undefined {
const field = value[key];
if (field === undefined) return undefined;
if (typeof field !== "string") throw new Error(`${key} must be a string`);
return field;
}
function optionalSafeString(value: Record<string, unknown>, key: string): string | undefined {
const field = optionalString(value, key);
if (field && unsafeProjectionText.test(field)) throw new Error(`${key} contains unsafe runtime details`);
return field;
}
function requiredChecksum(value: Record<string, unknown>, key: string): string {
const field = requiredString(value, key);
if (!/^sha256:[a-f0-9]{64}$/.test(field)) throw new Error(`${key} must be a SHA-256 checksum`);
return field;
}
function requiredNumber(value: Record<string, unknown>, key: string): number {
const field = value[key];
if (typeof field !== "number" || !Number.isFinite(field) || field < 0) throw new Error(`${key} must be a non-negative number`);
return field;
}
function optionalNumber(value: Record<string, unknown>, key: string): number | undefined {
if (value[key] === undefined) return undefined;
return requiredNumber(value, key);
}
function requiredBoolean(value: Record<string, unknown>, key: string): boolean {
const field = value[key];
if (typeof field !== "boolean") throw new Error(`${key} must be a boolean`);
return field;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import type { GamePluginResponse } from "../api/types";
import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
import { serverCreateRequestFromForm } from "./serverManagement";
const plugin: GamePluginResponse = {
id: "game.runtime",
name: "Runtime Game",
version: "1.0.0",
serverType: "runtime",
manifestRef: "artifact://runtime-manifest",
createFormSchemaRef: "schemas/create.json",
requiredRunCapabilities: ["process.install"],
declaredPermissions: ["server.create"],
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false },
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
bridgeActions: [],
pages: [],
tags: [],
aiPurposes: [],
status: "installed",
runtimeProfiles: {
discovery: [{ key: "root-check", kind: "file.exists", targetKey: "server-root", required: true }],
dependencyProbes: [{ key: "java", kind: "java.version", targetKey: "java-runtime", required: false }],
installPlans: [{ key: "java-install", title: "Java", steps: [{ type: "package", targetKey: "package-source" }] }],
logSources: [{ key: "main-log", kind: "file.tail", targetKey: "log-source", streamKey: "main" }],
transportProfiles: [
{ key: "rcon", kind: "rcon", targetKey: "rcon.password", capabilities: ["remote.run.rcon.command"] },
{ key: "ftp", kind: "ftp", targetKey: "ftp.profile", capabilities: ["remote.ftp.read"] }
],
lifecycleProfiles: [
{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"], transportKeys: ["rcon"] },
{ key: "hosted", mode: "hosted-ftp-rcon", capabilities: ["remote.ftp.read"], transportKeys: ["ftp"] }
]
}
};
describe("runtime profile server creation contracts", () => {
it("derives logical binding fields from the selected profile", () => {
expect(runtimeBindingFields(plugin, "local")).toEqual([
{ key: "java-runtime", required: false, sensitive: false },
{ key: "log-source", required: true, sensitive: false },
{ key: "package-source", required: false, sensitive: false },
{ key: "rcon.password", required: true, sensitive: true },
{ key: "server-root", required: true, sensitive: false }
]);
expect(runtimeBindingFields(plugin, "local").some((field) => field.key === "ftp.profile")).toBe(false);
});
it("selects the plugin profile and submits real profile bindings", () => {
const form = defaultServerCreateForm([plugin], []);
expect(form.profileKey).toBe("local");
expect(
serverCreateRequestFromForm(
{
...form,
id: " server-1 ",
name: " Runtime Server ",
bindings: { "server-root": " runtime.server-root ", "rcon.password": " secret://runtime/server-1/rcon ", "java-runtime": " " }
},
17
)
).toEqual({
id: "server-1",
pluginId: "game.runtime",
runEndpointId: "",
name: "Runtime Server",
idempotencyKey: "web:create:server-1:17",
profileKey: "local",
bindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" }
});
});
});
+10 -6
View File
@@ -18,13 +18,16 @@ export function serverCreateRequestFromForm(form: ServerCreateFormState, sequenc
pluginId: form.pluginId.trim(),
runEndpointId: form.runEndpointId.trim(),
name: form.name.trim(),
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence)
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence),
profileKey: form.profileKey.trim(),
bindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== ""))
};
}
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "start" | "stop", sequence = Date.now()): ServerLifecycleCommandRequest {
return {
expectedConfigVersion: instance.configVersion,
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "start" | "stop" | "status", sequence = Date.now()): ServerLifecycleCommandRequest {
return {
expectedConfigVersion: instance.configVersion,
expectedChecksum: instance.configChecksum,
idempotencyKey: lifecycleIdempotencyKey(action, instance.id, sequence)
};
}
@@ -77,10 +80,11 @@ export function clientManagerBuildRequest(input: {
};
}
export function dependencyJobRequest(serverInstanceId: string, probeKey: string, installPlanKey = "", sequence = Date.now()): DependencyJobRequest {
export function dependencyJobRequest(serverInstanceId: string, probeKey: string, installPlanKey = "", planDigest = "", sequence = Date.now()): DependencyJobRequest {
return {
probeKey: probeKey.trim(),
installPlanKey: installPlanKey.trim() || undefined,
planDigest: planDigest.trim() || undefined,
idempotencyKey: runtimeIdempotencyKey(installPlanKey ? "dependencies.install" : "dependencies.check", serverInstanceId, sequence)
};
}
@@ -98,6 +102,6 @@ export function runtimeIdempotencyKey(action: string, serverInstanceId: string,
return `web:${action}:${serverInstanceId}:${sequence}`;
}
export function lifecycleIdempotencyKey(action: "create" | "start" | "stop", serverInstanceId: string, sequence: number): string {
export function lifecycleIdempotencyKey(action: "create" | "start" | "stop" | "status", serverInstanceId: string, sequence: number): string {
return `web:${action}:${serverInstanceId}:${sequence}`;
}