feat: 完整游戏运维功能
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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" }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user