Files
npc0-hue 05f5a97ba9 Add graceful SCUM stop, restart, and version update flow
SCUM 停止/重启/更新以前只有“结束进程”这一条路,插件没有声明任何优雅关闭方式,
平台也没有把停止后重新启动串起来。现在插件声明自己的关闭脚本,run 先执行它,
平台在停止或更新成功后再自动拉起服务。

run:
- lifecycle stop 支持插件声明的 gracefulStop(可执行文件、参数、环境、超时、
  fallback=report|terminate);关闭命令超时且声明 report 时任务失败,不再默默杀进程。
- 新增 steam.update 依赖探针:调用 steamcmd +app_info_print 获取公开分支 buildid,
  与本地 steamapps/appmanifest_<appid>.acf 的 buildid 比较,输出
  installed/latest/update=yes|no|unknown。

platform:
- 新增 POST /api/v1/server-instances/{id}/restart 与 /update。
- restart 派发插件 stop 动作(走优雅关闭),终态成功后入队 start 作业。
- update 派发插件 install 动作;插件在更新前必须先优雅关闭 SCUM,关闭失败直接拒绝
  SteamCMD 更新,成功后平台再拉起服务。
- 依赖检查输入带上插件声明的服务器安装根目录,供 steam.update 读取 appmanifest。

plugin (SCUM server plugin 0.1.16):
- bin/scum-stop.cmd:解析已声明的可执行文件路径,定位同路径正在运行的 SCUMServer.exe,
  通过本地 RCON 公告并发送关闭命令,等待进程自行退出;不再使用 taskkill。
- bin/scum-rcon.ps1:插件自有的 Source RCON 客户端,从 UE4SS mod config.ini 读取
  密码/端口,密钥不离开本机。
- actions/stop.json 声明 gracefulStop;actions/install.json 更新前先执行同一关闭脚本。

platform_web:
- 服务器详情新增“重启”按钮和“SCUM 版本更新”面板;点“检查更新”查询公开分支版本,
  只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。
2026-09-15 13:30:15 +08:00

119 lines
4.6 KiB
TypeScript

import type {
DependencyJobRequest,
LogBackfillRequest,
RunDistributionGenerateRequest,
RunUpdateRequest,
ServerInstanceUpdateRequest,
ServerLifecycleCommandRequest,
ServerLifecycleCreateRequest,
ServerInstanceResponse
} from "../api/types";
import type { ServerCreateFormState, ServerMetadataFormState, ServerRemovalConfirmationState } from "../contracts/serverManagement";
export function minimalServerCreateRequestFromForm(form: ServerCreateFormState, sequence = Date.now()): ServerLifecycleCreateRequest {
const id = form.id.trim() || serverInstanceIdFromName(form.name, sequence);
return {
id,
pluginId: form.pluginId.trim(),
name: form.name.trim(),
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence)
};
}
export function serverCreateRequestFromForm(form: ServerCreateFormState, sequence = Date.now()): ServerLifecycleCreateRequest {
const id = form.id.trim() || serverInstanceIdFromName(form.name, sequence);
return {
id,
pluginId: form.pluginId.trim(),
name: form.name.trim(),
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence),
deployment: {
mode: form.deploymentMode,
createInputs: Object.fromEntries(Object.entries(form.createInputs).map(([key, value]) => [key, value.trim()])),
serverRoot: form.serverRoot.trim() || undefined,
workingDirectory: form.workingDirectory.trim() || undefined,
installCommand: form.installCommand.trim() || undefined,
startCommand: form.startCommand.trim() || undefined,
stopCommand: form.stopCommand.trim() || undefined,
statusCommand: form.statusCommand.trim() || undefined,
shell: form.shell || undefined
}
};
}
export function serverInstanceIdFromName(name: string, sequence = Date.now()): string {
const normalized = name
.trim()
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48)
.replace(/-+$/g, "");
const suffix = String(Math.max(0, Math.floor(Number.isFinite(sequence) ? sequence : Date.now())));
return normalized ? `server-${normalized}-${suffix}` : `server-${suffix}`;
}
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "deploy" | "start" | "stop" | "restart" | "update" | "status", sequence = Date.now()): ServerLifecycleCommandRequest {
return {
expectedConfigVersion: instance.configVersion,
idempotencyKey: lifecycleIdempotencyKey(action, instance.id, sequence)
};
}
export function serverMetadataUpdateRequestFromForm(form: ServerMetadataFormState): ServerInstanceUpdateRequest {
return { name: form.name.trim() };
}
export function serverDeleteConfirmation(instance: ServerInstanceResponse): ServerRemovalConfirmationState {
return {
action: "delete",
serverInstanceId: instance.id,
name: instance.name,
state: instance.state
};
}
export function runDistributionGenerateRequest(serverInstanceId: string, targetOs: string, targetArch: string, sequence = Date.now()): RunDistributionGenerateRequest {
return {
targetOs,
targetArch,
idempotencyKey: runtimeIdempotencyKey("run.generate", serverInstanceId, sequence)
};
}
export function runUpdateRequest(serverInstanceId: string, artifactId: string, checksum?: string, sequence = Date.now()): RunUpdateRequest {
return {
artifactId,
checksum,
idempotencyKey: runtimeIdempotencyKey("run.update", serverInstanceId, sequence)
};
}
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)
};
}
export function logBackfillRequest(serverInstanceId: string, sourceKey: string, checkpointRef = "", limit = 200, sequence = Date.now()): LogBackfillRequest {
return {
sourceKey: sourceKey.trim(),
checkpointRef: checkpointRef.trim() || undefined,
limit,
idempotencyKey: runtimeIdempotencyKey("logs.backfill", serverInstanceId, sequence)
};
}
export function runtimeIdempotencyKey(action: string, serverInstanceId: string, sequence: number): string {
return `web:${action}:${serverInstanceId}:${sequence}`;
}
export function lifecycleIdempotencyKey(action: "create" | "deploy" | "start" | "stop" | "restart" | "update" | "status", serverInstanceId: string, sequence: number): string {
return `web:${action}:${serverInstanceId}:${sequence}`;
}