feat: 完整游戏运维功能
This commit is contained in:
@@ -6,6 +6,7 @@ import { Ajv2020, type AnySchema, type ErrorObject } from "ajv/dist/2020.js";
|
||||
|
||||
const rootDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
const manifestSchemaPath = path.join(rootDir, "manifests", "game-plugin.manifest.schema.json");
|
||||
const lifecycleActionSchemaPath = path.join(rootDir, "manifests", "lifecycle-action.schema.json");
|
||||
const createFormSchemaPath = path.join(rootDir, "schemas", "create-form.schema.json");
|
||||
|
||||
function readJson(filePath: string): unknown {
|
||||
@@ -114,6 +115,179 @@ function isSafeRelativeJsonRef(value: string): boolean {
|
||||
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
|
||||
}
|
||||
|
||||
export function validateLifecycleActionFile(actionPath: string, expectedAction?: string): string[] {
|
||||
const action = readJson(path.resolve(rootDir, actionPath));
|
||||
const ajv = new Ajv2020({ allErrors: true });
|
||||
ajv.addSchema(readJson(manifestSchemaPath) as AnySchema);
|
||||
const validateAction = ajv.compile(readJson(lifecycleActionSchemaPath) as AnySchema);
|
||||
const errors = validateAction(action) ? [] : formatErrors("lifecycleAction", validateAction.errors);
|
||||
errors.push(...scanUnsafeValues(action, "lifecycleAction"));
|
||||
if (typeof action !== "object" || action === null) {
|
||||
return errors;
|
||||
}
|
||||
const declaration = action as { action?: string; mode?: string; executableKey?: string };
|
||||
if (expectedAction && declaration.action !== expectedAction) {
|
||||
errors.push(`lifecycleAction.action: expected ${expectedAction}`);
|
||||
}
|
||||
if ((declaration.action === "install" || declaration.action === "start") && !declaration.executableKey) {
|
||||
errors.push("lifecycleAction.executableKey: required for install/start");
|
||||
}
|
||||
if (declaration.action === "start" && declaration.mode !== "supervised") {
|
||||
errors.push("lifecycleAction.mode: start must be supervised");
|
||||
}
|
||||
if ((declaration.action === "stop" || declaration.action === "status") && declaration.mode !== "control") {
|
||||
errors.push(`lifecycleAction.mode: ${declaration.action} must be control`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function referencedLifecycleActions(manifest: unknown): Array<{ action: string; ref: string }> {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
const record = manifest as {
|
||||
actions?: Record<string, string>;
|
||||
runtimeProfiles?: { lifecycleProfiles?: Array<{ actionRefs?: Record<string, string> }> };
|
||||
};
|
||||
const refs = new Map<string, { action: string; ref: string }>();
|
||||
for (const [action, ref] of Object.entries(record.actions ?? {})) {
|
||||
refs.set(`${action}:${ref}`, { action, ref });
|
||||
}
|
||||
for (const profile of record.runtimeProfiles?.lifecycleProfiles ?? []) {
|
||||
for (const [action, ref] of Object.entries(profile.actionRefs ?? {})) {
|
||||
refs.set(`${action}:${ref}`, { action, ref });
|
||||
}
|
||||
}
|
||||
return [...refs.values()];
|
||||
}
|
||||
|
||||
function validateDependencyPlans(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
const plans = (manifest as { runtimeProfiles?: { installPlans?: Array<{ key?: string; steps?: Array<{ type?: string; downloadRef?: string }> }> } }).runtimeProfiles?.installPlans ?? [];
|
||||
const errors: string[] = [];
|
||||
for (const [planIndex, plan] of plans.entries()) {
|
||||
for (const [stepIndex, step] of (plan.steps ?? []).entries()) {
|
||||
if (step.type !== "verified-download" || !step.downloadRef) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(step.downloadRef);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const privateIPv4 = /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host);
|
||||
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash || host === "localhost" || host.endsWith(".localhost") || privateIPv4 || host === "::1") {
|
||||
errors.push(`manifest.runtimeProfiles.installPlans[${planIndex}].steps[${stepIndex}].downloadRef: host is not approved for dependency download`);
|
||||
}
|
||||
} catch {
|
||||
errors.push(`manifest.runtimeProfiles.installPlans[${planIndex}].steps[${stepIndex}].downloadRef: URL is invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateClientManagerProfiles(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
type ClientManagerProfile = {
|
||||
key?: string;
|
||||
version?: string;
|
||||
repository?: { revisionPolicy?: string; branch?: string; tag?: string; revision?: string };
|
||||
outputArtifacts?: string[];
|
||||
deployment?: { executableRef?: string; requiredRunCapabilities?: string[] };
|
||||
lifecycle?: { actions?: string[]; startupTimeoutSeconds?: number; stopTimeoutSeconds?: number };
|
||||
health?: { mode?: string; intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number; requiredCapabilities?: string[] };
|
||||
compatibility?: { minimumVersion?: string; maximumVersion?: string };
|
||||
updatePolicy?: { healthConfirmationSeconds?: number };
|
||||
};
|
||||
const profiles = (manifest as { runtimeProfiles?: { clientManagers?: ClientManagerProfile[] } }).runtimeProfiles?.clientManagers ?? [];
|
||||
const errors: string[] = [];
|
||||
const parseVersion = (value: string | undefined): number[] | undefined => {
|
||||
const match = value?.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/);
|
||||
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
|
||||
};
|
||||
const compareVersions = (left: number[], right: number[]): number => {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return left[index] - right[index];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
for (const [profileIndex, profile] of profiles.entries()) {
|
||||
const location = `manifest.runtimeProfiles.clientManagers[${profileIndex}]`;
|
||||
const policy = profile.repository?.revisionPolicy;
|
||||
if (policy === "pinned" && !profile.repository?.revision) {
|
||||
errors.push(`${location}.repository.revision: required for pinned revision policy`);
|
||||
}
|
||||
if (policy === "branch" && !profile.repository?.branch) {
|
||||
errors.push(`${location}.repository.branch: required for branch revision policy`);
|
||||
}
|
||||
if (policy === "tag" && !profile.repository?.tag) {
|
||||
errors.push(`${location}.repository.tag: required for tag revision policy`);
|
||||
}
|
||||
if (!profile.deployment) {
|
||||
continue;
|
||||
}
|
||||
if (!profile.version || !parseVersion(profile.version)) {
|
||||
errors.push(`${location}.version: lifecycle deployment requires a semantic version`);
|
||||
}
|
||||
if (!profile.outputArtifacts?.includes(profile.deployment.executableRef ?? "")) {
|
||||
errors.push(`${location}.deployment.executableRef: must name one declared output artifact`);
|
||||
}
|
||||
const actions = new Set(profile.lifecycle?.actions ?? []);
|
||||
const runCapabilities = new Set(profile.deployment.requiredRunCapabilities ?? []);
|
||||
if (!runCapabilities.has("client-manager.deploy")) {
|
||||
errors.push(`${location}.deployment.requiredRunCapabilities: client-manager.deploy is required`);
|
||||
}
|
||||
if (["start", "stop", "restart", "status"].some((action) => actions.has(action)) && !runCapabilities.has("client-manager.control")) {
|
||||
errors.push(`${location}.deployment.requiredRunCapabilities: lifecycle control actions require client-manager.control`);
|
||||
}
|
||||
if (actions.has("update") && !runCapabilities.has("client-manager.update")) {
|
||||
errors.push(`${location}.deployment.requiredRunCapabilities: update requires client-manager.update`);
|
||||
}
|
||||
if (actions.has("rollback") && !runCapabilities.has("client-manager.rollback")) {
|
||||
errors.push(`${location}.deployment.requiredRunCapabilities: rollback requires client-manager.rollback`);
|
||||
}
|
||||
if (actions.has("uninstall") && !runCapabilities.has("client-manager.uninstall")) {
|
||||
errors.push(`${location}.deployment.requiredRunCapabilities: uninstall requires client-manager.uninstall`);
|
||||
}
|
||||
const interval = profile.health?.intervalSeconds ?? 0;
|
||||
const degraded = profile.health?.degradedAfterSeconds ?? 0;
|
||||
const offline = profile.health?.offlineAfterSeconds ?? 0;
|
||||
if (degraded < interval * 2 || offline <= degraded) {
|
||||
errors.push(`${location}.health: degraded threshold must allow two heartbeats and offline threshold must be later`);
|
||||
}
|
||||
if (profile.health?.mode === "component-heartbeat") {
|
||||
const required = new Set(profile.health.requiredCapabilities ?? []);
|
||||
for (const capability of ["component.register", "component.heartbeat", "component.health"]) {
|
||||
if (!required.has(capability)) {
|
||||
errors.push(`${location}.health.requiredCapabilities: ${capability} is required for component-heartbeat mode`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const version = parseVersion(profile.version);
|
||||
const minimum = parseVersion(profile.compatibility?.minimumVersion);
|
||||
const maximum = parseVersion(profile.compatibility?.maximumVersion);
|
||||
if (minimum && maximum && compareVersions(minimum, maximum) > 0) {
|
||||
errors.push(`${location}.compatibility: minimumVersion must not exceed maximumVersion`);
|
||||
}
|
||||
if (version && minimum && compareVersions(version, minimum) < 0) {
|
||||
errors.push(`${location}.compatibility: profile version is below minimumVersion`);
|
||||
}
|
||||
if (version && maximum && compareVersions(version, maximum) > 0) {
|
||||
errors.push(`${location}.compatibility: profile version exceeds maximumVersion`);
|
||||
}
|
||||
if ((profile.updatePolicy?.healthConfirmationSeconds ?? 0) < interval) {
|
||||
errors.push(`${location}.updatePolicy.healthConfirmationSeconds: must cover at least one health interval`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateManifestFile(manifestPath: string): string[] {
|
||||
const absoluteManifestPath = path.resolve(rootDir, manifestPath);
|
||||
const manifest = readJson(absoluteManifestPath);
|
||||
@@ -129,6 +303,21 @@ export function validateManifestFile(manifestPath: string): string[] {
|
||||
}
|
||||
|
||||
errors.push(...scanUnsafeValues(manifest, "manifest"));
|
||||
errors.push(...validateDependencyPlans(manifest));
|
||||
errors.push(...validateClientManagerProfiles(manifest));
|
||||
|
||||
for (const declaration of referencedLifecycleActions(manifest)) {
|
||||
if (!isSafeRelativeJsonRef(declaration.ref)) {
|
||||
errors.push(`lifecycleAction.${declaration.action}: unsafe file reference`);
|
||||
continue;
|
||||
}
|
||||
const actionPath = path.resolve(manifestDir, declaration.ref);
|
||||
if (!fs.existsSync(actionPath)) {
|
||||
errors.push(`lifecycleAction.${declaration.action}: missing file ${declaration.ref}`);
|
||||
continue;
|
||||
}
|
||||
errors.push(...validateLifecycleActionFile(path.relative(rootDir, actionPath), declaration.action));
|
||||
}
|
||||
|
||||
if (typeof manifest === "object" && manifest !== null && "server" in manifest) {
|
||||
const server = (manifest as { server?: { createFormSchema?: string } }).server;
|
||||
|
||||
Reference in New Issue
Block a user