From 05f5a97ba96f27b16e967e4157a55e028bcc9663 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Tue, 15 Sep 2026 13:30:15 +0800 Subject: [PATCH] Add graceful SCUM stop, restart, and version update flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCUM 停止/重启/更新以前只有“结束进程”这一条路,插件没有声明任何优雅关闭方式, 平台也没有把停止后重新启动串起来。现在插件声明自己的关闭脚本,run 先执行它, 平台在停止或更新成功后再自动拉起服务。 run: - lifecycle stop 支持插件声明的 gracefulStop(可执行文件、参数、环境、超时、 fallback=report|terminate);关闭命令超时且声明 report 时任务失败,不再默默杀进程。 - 新增 steam.update 依赖探针:调用 steamcmd +app_info_print 获取公开分支 buildid, 与本地 steamapps/appmanifest_.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 版本更新”面板;点“检查更新”查询公开分支版本, 只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。 --- platform/api/resource_handlers.go | 2 + platform/api/routes.md | 4 +- platform/api/server_lifecycle_handlers.go | 62 +++++ platform/domain/job_channel.go | 2 + platform/domain/resources.go | 1 + platform/domain/server_lifecycle.go | 14 +- platform/dto/job_channel.go | 3 +- platform/dto/runtime_profiles.go | 5 +- platform/protocol/server-lifecycle.md | 29 ++- platform/service/dependency_updates.go | 2 +- platform/service/dependency_updates_test.go | 28 +++ .../service/distribution_build_execution.go | 2 +- platform/service/resources.go | 18 +- platform/service/server_lifecycle.go | 40 +++- .../service/server_lifecycle_projection.go | 31 +++ platform/service/server_lifecycle_test.go | 211 ++++++++++++++++++ platform/validator/runtime_profiles.go | 10 +- platform/validator/runtime_profiles_test.go | 17 ++ platform/validator/server_lifecycle.go | 2 +- platform_web/api/client.test.ts | 12 +- platform_web/api/client.ts | 14 ++ platform_web/api/contracts.md | 2 +- .../components/ServerGameUpdatePanel.tsx | 208 +++++++++++++++++ platform_web/contracts/pages.md | 2 +- .../contracts/serverManagement.test.ts | 39 +++- platform_web/contracts/serverManagement.ts | 57 +++++ platform_web/pages/ServerDetailPage.test.tsx | 20 +- platform_web/pages/ServerDetailPage.tsx | 41 +++- platform_web/schemas/serverManagement.ts | 4 +- .../scum-server-plugin/actions/install.json | 2 +- .../scum-server-plugin/actions/stop.json | 17 +- .../bin/scum-install-update.cmd | 24 +- .../scum-server-plugin/bin/scum-rcon.ps1 | 111 +++++++++ .../scum-server-plugin/bin/scum-stop.cmd | 99 ++++++++ .../examples/scum-server-plugin/manifest.json | 20 +- .../game-plugin.manifest.schema.json | 3 +- .../manifests/lifecycle-action.schema.json | 23 +- plugins/scripts/validate-manifest.ts | 14 ++ plugins/tests/manifest-validation.test.ts | 58 ++++- 39 files changed, 1197 insertions(+), 56 deletions(-) create mode 100644 platform_web/components/ServerGameUpdatePanel.tsx create mode 100644 plugins/examples/scum-server-plugin/bin/scum-rcon.ps1 create mode 100644 plugins/examples/scum-server-plugin/bin/scum-stop.cmd diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index b6e7de6..bbb8482 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -63,6 +63,8 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/server-instances/workflows/create", h.serverInstanceCreateWorkflow) mux.HandleFunc("/api/v1/server-instances/{id}/start", h.serverInstanceStart) mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop) + mux.HandleFunc("/api/v1/server-instances/{id}/restart", h.serverInstanceRestart) + mux.HandleFunc("/api/v1/server-instances/{id}/update", h.serverInstanceUpdate) mux.HandleFunc("/api/v1/server-instances/{id}/process/status", h.serverInstanceProcessStatus) mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions) mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding) diff --git a/platform/api/routes.md b/platform/api/routes.md index 0ba352e..b022ee0 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -134,6 +134,8 @@ Artifact bridge execution returns safe metadata and platform content routes only - `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. Creation starts from `pluginId` and `name`, and may include the create-wizard deployment definition such as deployment mode, plugin create inputs, server root, or custom start command. The browser does not submit a deployment target, Run endpoint, lifecycle profile, or Run identity binding. Platform applies plugin defaults, creates the definition without waiting for a Run, and attaches the active Run when its authenticated heartbeat arrives. Unknown legacy binding fields are rejected by the strict JSON decoder. - `POST /api/v1/server-instances/{id}/start`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.start` job using `ServerLifecycleResponse`. - `POST /api/v1/server-instances/{id}/stop`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.stop` job using `ServerLifecycleResponse`. +- `POST /api/v1/server-instances/{id}/restart`: accept `ServerLifecycleCommandRequest`, validate the instance is `running` or `stopped` with a matching config version and run capability, and queue the plugin-declared stop action using `ServerLifecycleResponse`. When that stop job succeeds, Platform queues the plugin-declared start action for the same instance. +- `POST /api/v1/server-instances/{id}/update`: accept `ServerLifecycleCommandRequest`, validate the instance is `running`, `stopped`, `ready`, or `failed` with a matching config version and `process.install` run capability, and queue the plugin-declared install/update action using `ServerLifecycleResponse`. The plugin install action owns the graceful close of a running server; Platform queues the plugin-declared start action after the update succeeds. Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies. @@ -251,7 +253,7 @@ These route groups remain documented future work beyond the currently implemente - Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts. - Live AI provider connectivity tests and remote model discovery. - Production Run distribution signing/KMS, fleet rollout rings, and real AI-provider integration. -- Server restart/delete routes beyond the currently implemented lifecycle, metadata update, and archive actions. +- Server delete/archive routes beyond the currently implemented lifecycle, metadata update, and archive actions. ## Core Service Boundary diff --git a/platform/api/server_lifecycle_handlers.go b/platform/api/server_lifecycle_handlers.go index ec89a80..1b8763a 100644 --- a/platform/api/server_lifecycle_handlers.go +++ b/platform/api/server_lifecycle_handlers.go @@ -160,6 +160,68 @@ func (h *coreHandlers) serverInstanceStop(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result)) } +// serverInstanceRestart godoc +// @Summary Restart server instance +// @Description Validates lifecycle state and config version, queues the plugin-declared stop action, and queues the plugin-declared start action after the graceful stop result lands. +// @Tags server-instances +// @Accept json +// @Produce json +// @Param id path string true "Server instance ID" +// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request" +// @Success 202 {object} dto.ServerLifecycleResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/restart [post] +func (h *coreHandlers) serverInstanceRestart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.RestartServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result)) +} + +// serverInstanceUpdate godoc +// @Summary Update server game files +// @Description Validates lifecycle state and config version, queues the plugin-declared install/update action, and queues the plugin-declared start action after the update result lands. +// @Tags server-instances +// @Accept json +// @Produce json +// @Param id path string true "Server instance ID" +// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request" +// @Success 202 {object} dto.ServerLifecycleResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/update [post] +func (h *coreHandlers) serverInstanceUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.UpdateServerGameForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result)) +} + // serverInstanceProcessStatus godoc // @Summary Query supervised server process status // @Description Queues a typed process.status job for the selected server/profile. diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index 031b30d..88bf942 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -167,6 +167,7 @@ type RunAutonomousDependencyProbe struct { TargetKey string `json:"targetKey"` Required bool `json:"required,omitempty"` MinimumVersion string `json:"minimumVersion,omitempty"` + SteamAppID string `json:"steamAppId,omitempty"` Platforms []string `json:"platforms,omitempty"` } @@ -258,6 +259,7 @@ type DependencyExecutionInput struct { TargetOS string TargetArch string PlanDigest string + ServerRoot string Probe RuntimeDependencyProbe Plan RuntimeInstallPlan Bindings map[string]string diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 20442a3..ccd810b 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -411,6 +411,7 @@ type RuntimeDependencyProbe struct { TargetKey string Required bool MinimumVersion string + SteamAppID string Platforms []string } diff --git a/platform/domain/server_lifecycle.go b/platform/domain/server_lifecycle.go index ee79e3c..9be20ab 100644 --- a/platform/domain/server_lifecycle.go +++ b/platform/domain/server_lifecycle.go @@ -3,10 +3,12 @@ package domain type ServerLifecycleAction string const ( - ServerLifecycleActionCreate ServerLifecycleAction = "create" - ServerLifecycleActionStart ServerLifecycleAction = "start" - ServerLifecycleActionStop ServerLifecycleAction = "stop" - ServerLifecycleActionStatus ServerLifecycleAction = "status" + ServerLifecycleActionCreate ServerLifecycleAction = "create" + ServerLifecycleActionStart ServerLifecycleAction = "start" + ServerLifecycleActionStop ServerLifecycleAction = "stop" + ServerLifecycleActionRestart ServerLifecycleAction = "restart" + ServerLifecycleActionUpdate ServerLifecycleAction = "update" + ServerLifecycleActionStatus ServerLifecycleAction = "status" ) const ( @@ -48,8 +50,10 @@ func LifecycleCapabilityForAction(action ServerLifecycleAction) string { return LifecycleCapabilityInstall case ServerLifecycleActionStart: return LifecycleCapabilityStart - case ServerLifecycleActionStop: + case ServerLifecycleActionStop, ServerLifecycleActionRestart: return LifecycleCapabilityStop + case ServerLifecycleActionUpdate: + return LifecycleCapabilityInstall case ServerLifecycleActionStatus: return LifecycleCapabilityStatus default: diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 46d40bd..ad17d7d 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -248,6 +248,7 @@ type DependencyExecutionInputResponse struct { TargetOS string `json:"targetOs"` TargetArch string `json:"targetArch"` PlanDigest string `json:"planDigest"` + ServerRoot string `json:"serverRoot,omitempty"` Probe RuntimeDependencyProbeBody `json:"probe,omitempty"` Plan RuntimeInstallPlanBody `json:"plan,omitempty"` Bindings map[string]string `json:"bindings"` @@ -579,7 +580,7 @@ func DependencyExecutionInputFromDomain(input domain.DependencyExecutionInput) D for i, step := range input.Plan.Steps { steps[i] = RuntimeInstallStepBody{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadRef: step.DownloadRef, Checksum: step.Checksum} } - return DependencyExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, PluginID: input.PluginID, PluginVersion: input.PluginVersion, ProfileKey: input.ProfileKey, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PlanDigest: input.PlanDigest, Probe: RuntimeDependencyProbeBody{Key: input.Probe.Key, Kind: input.Probe.Kind, TargetKey: input.Probe.TargetKey, Required: input.Probe.Required, MinimumVersion: input.Probe.MinimumVersion, Platforms: input.Probe.Platforms}, Plan: RuntimeInstallPlanBody{Key: input.Plan.Key, Title: input.Plan.Title, Platforms: input.Plan.Platforms, Steps: steps}, Bindings: input.Bindings} + return DependencyExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, PluginID: input.PluginID, PluginVersion: input.PluginVersion, ProfileKey: input.ProfileKey, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PlanDigest: input.PlanDigest, ServerRoot: input.ServerRoot, Probe: RuntimeDependencyProbeBody{Key: input.Probe.Key, Kind: input.Probe.Kind, TargetKey: input.Probe.TargetKey, Required: input.Probe.Required, MinimumVersion: input.Probe.MinimumVersion, SteamAppID: input.Probe.SteamAppID, Platforms: input.Probe.Platforms}, Plan: RuntimeInstallPlanBody{Key: input.Plan.Key, Title: input.Plan.Title, Platforms: input.Plan.Platforms, Steps: steps}, Bindings: input.Bindings} } func SourceRCONExecutionInputFromDomain(input domain.SourceRCONExecutionInput) SourceRCONExecutionInputResponse { diff --git a/platform/dto/runtime_profiles.go b/platform/dto/runtime_profiles.go index be637e6..6ddff96 100644 --- a/platform/dto/runtime_profiles.go +++ b/platform/dto/runtime_profiles.go @@ -40,6 +40,7 @@ type RuntimeDependencyProbeBody struct { TargetKey string `json:"targetKey"` Required bool `json:"required,omitempty"` MinimumVersion string `json:"minimumVersion,omitempty"` + SteamAppID string `json:"steamAppId,omitempty"` Platforms []string `json:"platforms,omitempty"` } @@ -243,7 +244,7 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), DLLExtensionRefs: domain.CopyStringSlice(item.DLLExtensionRefs), Platforms: domain.CopyStringSlice(item.Platforms)}) } for _, item := range body.DependencyProbes { - profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: domain.CopyStringSlice(item.Platforms)}) + profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, SteamAppID: item.SteamAppID, Platforms: domain.CopyStringSlice(item.Platforms)}) } for _, item := range body.InstallPlans { plan := domain.RuntimeInstallPlan{Key: item.Key, Title: item.Title, Platforms: domain.CopyStringSlice(item.Platforms)} @@ -281,7 +282,7 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl body.LifecycleProfiles = append(body.LifecycleProfiles, RuntimeLifecycleProfileBody{Key: item.Key, Mode: item.Mode, Capabilities: item.Capabilities, ActionRefs: lifecycleActionsFromDomain(item.ActionRefs), TransportKeys: item.TransportKeys, DLLExtensionRefs: item.DLLExtensionRefs, Platforms: item.Platforms}) } for _, item := range profiles.DependencyProbes { - body.DependencyProbes = append(body.DependencyProbes, RuntimeDependencyProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: item.Platforms}) + body.DependencyProbes = append(body.DependencyProbes, RuntimeDependencyProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, SteamAppID: item.SteamAppID, Platforms: item.Platforms}) } for _, item := range profiles.InstallPlans { plan := RuntimeInstallPlanBody{Key: item.Key, Title: item.Title, Platforms: item.Platforms} diff --git a/platform/protocol/server-lifecycle.md b/platform/protocol/server-lifecycle.md index f565d94..a478ca1 100644 --- a/platform/protocol/server-lifecycle.md +++ b/platform/protocol/server-lifecycle.md @@ -38,9 +38,9 @@ A server instance is created from one installed game management plugin and is la - `create`: validate plugin and create the instance record without requiring a run endpoint, deployment target, or runtime profile. - `start`: record/authorize operator intent and route bounded control to the bound Run when applicable; generated Run startup is driven by its package-local autonomous lifecycle plan. -- `stop`: record/authorize operator intent and route bounded control to the bound Run when applicable. -- `restart`: dispatch stop/start or plugin-defined restart job. -- `update`: dispatch server update job and record version/result. +- `stop`: record/authorize operator intent and route bounded control to the bound Run when applicable. Run runs the plugin-declared graceful stop step first and escalates to bounded termination only when that action declares `fallback: "terminate"`. +- `restart`: queues the plugin-declared stop action for a `running` or `stopped` instance and queues the plugin-declared start action after the terminal stop job succeeds. A failed or cancelled stop leaves the instance failed and never starts the server. +- `update`: queues the plugin-declared install/update action for a `running`, `stopped`, `ready`, or `failed` instance and queues the plugin-declared start action after the update job succeeds. The plugin owns the graceful close inside its install action, so an update never replaces server files under a live process. - `delete`: stop server when needed, preserve or remove artifacts according to policy, mark deleted. ## Implemented Workflow Routes @@ -51,6 +51,8 @@ A server instance is created from one installed game management plugin and is la - `POST /api/v1/server-instances/workflows/create` validates an installed plugin, server name, idempotency key, and plugin-declared create inputs when provided. It creates the instance without requiring a deployment target, run endpoint, or runtime profile. Generated Run packages carry the autonomous lifecycle plan that Run consumes on startup; registration confirms binding/auth and does not enqueue bootstrap lifecycle jobs. - `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job. - `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job. +- `POST /api/v1/server-instances/{id}/restart` validates the instance is `running` or `stopped`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues the plugin-declared stop job recorded as a `restart` lifecycle operation. +- `POST /api/v1/server-instances/{id}/update` validates the instance is `running`, `stopped`, `ready`, or `failed`, checks the expected config version, verifies the plugin install action and run endpoint `process.install` capability, and queues the plugin-declared install/update job recorded as an `update` lifecycle operation. - Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses reviewable AI config-diff approvals and typed dispatch metadata without returning raw config text to plugin pages. - `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks. - `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user. @@ -73,3 +75,24 @@ Platform-visible lifecycle state is a projection from Run-reported facts. Termin - `process.install`, `process.start`, or `process.stop` + `failed` or `cancelled` marks the instance `failed`. Active start and stop jobs do not introduce separate `starting` or `stopping` states in this change. Operators can inspect pending job state through the job list while the instance remains in its last terminal server state. + +A terminal succeeded `restart` or `update` job queues the plugin-declared start action for the same instance and config version with idempotency key `:start`. The follow-up start runs through the same start validation as an explicit operator start. Active start, stop, restart, and update jobs do not introduce separate `starting`, `stopping`, `restarting`, or `updating` states; operators can inspect pending job state through the job list while the instance remains in its last terminal server state. + +## Plugin-Declared Graceful Stop + +Run performs no game-specific shutdown logic. A plugin stop action may declare a `gracefulStop` step in `actions/stop.json`: + +```json +"gracefulStop": { + "executableKey": "bin/scum-stop.cmd", + "environment": { "SERVER_STOP_SHUTDOWN_COMMAND": "Quit" }, + "timeoutMs": 300000, + "fallback": "terminate" +} +``` + +Run runs that declared script first, then reads the supervised process state. If the process has exited, the stop result reports a graceful stop. If it is still running, Run applies the declared fallback: `report` fails the job so the operator can intervene, and `terminate` escalates to the existing bounded termination path. The shutdown command, player notice, wait budget, and Steam app id stay in plugin-owned files and manifest declarations; Run and Platform only implement the generic mechanism. + +## Plugin-Declared Build Comparison + +A plugin may declare a `steam.update` dependency probe with `targetKey` (the SteamCMD executable binding), `steamAppId`, and an optional platform list. Run queries the published build for the app's public branch with SteamCMD and compares it with the installed build recorded in `steamapps/appmanifest_.acf` under the instance's declared server install root, falling back to the SteamCMD directory. The probe reports bounded evidence of the form `installed= latest= update=yes|no` (or `update=unknown`) so the management console can light up an update action without embedding game policy in the platform. diff --git a/platform/service/dependency_updates.go b/platform/service/dependency_updates.go index d134d06..82ed279 100644 --- a/platform/service/dependency_updates.go +++ b/platform/service/dependency_updates.go @@ -172,7 +172,7 @@ func (svc *CoreService) GetDependencyExecutionInput(request domain.DependencyExe if err != nil { return domain.DependencyExecutionInput{}, err } - return domain.CopyDependencyExecutionInput(domain.DependencyExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, PlanDigest: digest, Probe: probe, Plan: plan, Bindings: bindings}), nil + return domain.CopyDependencyExecutionInput(domain.DependencyExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, PlanDigest: digest, ServerRoot: strings.TrimSpace(resolution.instance.Deployment.ServerRoot), Probe: probe, Plan: plan, Bindings: bindings}), nil } func (svc *CoreService) GetRunUpdateInput(request domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) { diff --git a/platform/service/dependency_updates_test.go b/platform/service/dependency_updates_test.go index 6a54321..29912d3 100644 --- a/platform/service/dependency_updates_test.go +++ b/platform/service/dependency_updates_test.go @@ -304,3 +304,31 @@ func dependencyUpdateHello(instance domain.ServerInstance) domain.RunControlHell Capacity: domain.RunCapacity{MaxJobs: 2}, } } + +func TestDependencyInputCarriesDeclaredServerInstallRoot(t *testing.T) { + svc, session, instance := newDistributionTestFixture(t) + instance.Deployment.ServerRoot = "C:/scumserver" + if err := svc.store.ServerInstances().Update(instance); err != nil { + t.Fatalf("set deployment server root: %v", err) + } + catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID) + if err != nil { + t.Fatalf("dependency catalog: %v", err) + } + job, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-install-root"}) + if err != nil { + t.Fatalf("queue dependency check: %v", err) + } + runSession := registerDependencyUpdateRun(t, svc, instance) + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesCheck}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job.JobID != job.ID { + t.Fatalf("claim dependency check: claim=%+v err=%v", claim, err) + } + input, err := svc.GetDependencyExecutionInput(domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}) + if err != nil { + t.Fatalf("dependency input: %v", err) + } + if input.ServerRoot != "C:/scumserver" { + t.Fatalf("expected dependency input to carry the declared server install root, got %q", input.ServerRoot) + } +} diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index 0db1025..fa399ad 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -323,7 +323,7 @@ func autonomousBootstrapLifecycleAction(plugin domain.GamePlugin, profile domain } func autonomousDependencyProbe(probe domain.RuntimeDependencyProbe) domain.RunAutonomousDependencyProbe { - return domain.RunAutonomousDependencyProbe{Key: probe.Key, Kind: probe.Kind, TargetKey: probe.TargetKey, Required: probe.Required, MinimumVersion: probe.MinimumVersion, Platforms: domain.CopyStringSlice(probe.Platforms)} + return domain.RunAutonomousDependencyProbe{Key: probe.Key, Kind: probe.Kind, TargetKey: probe.TargetKey, Required: probe.Required, MinimumVersion: probe.MinimumVersion, SteamAppID: probe.SteamAppID, Platforms: domain.CopyStringSlice(probe.Platforms)} } func autonomousInstallPlan(plan domain.RuntimeInstallPlan) domain.RunAutonomousInstallPlan { diff --git a/platform/service/resources.go b/platform/service/resources.go index 96f2fd6..4408c0b 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -100,6 +100,8 @@ type Core interface { StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StopServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) + RestartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) + UpdateServerGameForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) QueryServerInstanceProcessForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) GetServerInstance(string) (domain.ServerInstance, error) GetServerInstanceForSession(string, string) (domain.ServerInstance, error) @@ -1194,7 +1196,7 @@ func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.P return base } lifecycleAction := domain.ServerLifecycleAction(strings.TrimSpace(payload["lifecycleAction"])) - if lifecycleAction == domain.ServerLifecycleActionStart || lifecycleAction == domain.ServerLifecycleActionStop { + if lifecycleAction == domain.ServerLifecycleActionStart || lifecycleAction == domain.ServerLifecycleActionStop || lifecycleAction == domain.ServerLifecycleActionRestart || lifecycleAction == domain.ServerLifecycleActionUpdate { expectedVersion, _ := strconv.Atoi(payload["expectedConfigVersion"]) command := domain.ServerLifecycleCommand{ ServerInstanceID: instance.ID, @@ -1218,6 +1220,20 @@ func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.P return base } result, err = svc.StopServerInstanceForSession(sessionID, command) + case domain.ServerLifecycleActionRestart: + if capability != domain.LifecycleCapabilityStop { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "lifecycle action must match requested capability"} + return base + } + result, err = svc.RestartServerInstanceForSession(sessionID, command) + case domain.ServerLifecycleActionUpdate: + if capability != domain.LifecycleCapabilityInstall { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "lifecycle action must match requested capability"} + return base + } + result, err = svc.UpdateServerGameForSession(sessionID, command) } if err != nil { return bridgeExecutionError(base, err) diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index f84fd76..52a2acb 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -212,6 +212,34 @@ func (svc *CoreService) StopServerInstanceForSession(sessionID string, command d return svc.StopServerInstance(command) } +// RestartServerInstanceForSession queues the plugin-declared graceful stop and +// records restart intent. Run executes the stop action; the terminal stop +// result dispatches the plugin-declared start action for the same instance. +func (svc *CoreService) RestartServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { + if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil { + return domain.ServerLifecycleResult{}, err + } + return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionRestart, []domain.ServerInstanceState{ + domain.ServerInstanceStateRunning, + domain.ServerInstanceStateStopped, + }) +} + +// UpdateServerGameForSession queues the plugin-declared install/update action. +// The plugin script stops the running server gracefully before SteamCMD touches +// server files, and the terminal update result starts the server again. +func (svc *CoreService) UpdateServerGameForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { + if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil { + return domain.ServerLifecycleResult{}, err + } + return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionUpdate, []domain.ServerInstanceState{ + domain.ServerInstanceStateRunning, + domain.ServerInstanceStateStopped, + domain.ServerInstanceStateReady, + domain.ServerInstanceStateFailed, + }) +} + func (svc *CoreService) QueryServerInstanceProcessForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil { return domain.ServerLifecycleResult{}, err @@ -418,6 +446,10 @@ func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string { return "start" case domain.ServerLifecycleActionStop: return "stop" + case domain.ServerLifecycleActionRestart: + return "restart" + case domain.ServerLifecycleActionUpdate: + return "update" case domain.ServerLifecycleActionStatus: return "status" default: @@ -431,8 +463,10 @@ func runtimeProfileActionRef(actions domain.PluginLifecycleActions, action domai return actions.Install case domain.ServerLifecycleActionStart: return actions.Start - case domain.ServerLifecycleActionStop: + case domain.ServerLifecycleActionStop, domain.ServerLifecycleActionRestart: return actions.Stop + case domain.ServerLifecycleActionUpdate: + return actions.Install case domain.ServerLifecycleActionStatus: return actions.Status default: @@ -502,8 +536,10 @@ func lifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleA return plugin.LifecycleActions.Install case domain.ServerLifecycleActionStart: return plugin.LifecycleActions.Start - case domain.ServerLifecycleActionStop: + case domain.ServerLifecycleActionStop, domain.ServerLifecycleActionRestart: return plugin.LifecycleActions.Stop + case domain.ServerLifecycleActionUpdate: + return plugin.LifecycleActions.Install case domain.ServerLifecycleActionStatus: return plugin.LifecycleActions.Status default: diff --git a/platform/service/server_lifecycle_projection.go b/platform/service/server_lifecycle_projection.go index a8ee4d1..d2dcdb7 100644 --- a/platform/service/server_lifecycle_projection.go +++ b/platform/service/server_lifecycle_projection.go @@ -1,6 +1,7 @@ package service import ( + "log" "strings" "time" @@ -113,9 +114,39 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim return err } svc.publishLogProcessState(instance) + svc.dispatchFollowUpLifecycle(job, instance) return nil } +// dispatchFollowUpLifecycle starts the server again after a declared graceful +// stop or a completed game update. A failed or cancelled stop/update leaves the +// instance in its projected state so the operator sees the failure before any +// start is attempted. +func (svc *CoreService) dispatchFollowUpLifecycle(job domain.Job, instance domain.ServerInstance) { + if job.State != domain.JobStateSucceeded { + return + } + operation := job.ExecutionInput.LifecycleOperation + if operation != string(domain.ServerLifecycleActionRestart) && operation != string(domain.ServerLifecycleActionUpdate) { + return + } + command := domain.ServerLifecycleCommand{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion, + IdempotencyKey: job.IdempotencyKey + ":start", + } + result, err := svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStart, []domain.ServerInstanceState{ + domain.ServerInstanceStateReady, + domain.ServerInstanceStateStopped, + domain.ServerInstanceStateFailed, + }) + if err != nil { + log.Printf("PLATFORM phase=lifecycle status=follow_up_failed operation=%s server=%s error=%s", safeLogValue(operation), safeLogValue(instance.ID), safeLogValue(err.Error())) + return + } + log.Printf("PLATFORM phase=lifecycle status=follow_up_queued operation=%s server=%s job=%s", safeLogValue(operation), safeLogValue(instance.ID), safeLogValue(result.Job.ID)) +} + func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error { if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" { return nil diff --git a/platform/service/server_lifecycle_test.go b/platform/service/server_lifecycle_test.go index cee0759..77767df 100644 --- a/platform/service/server_lifecycle_test.go +++ b/platform/service/server_lifecycle_test.go @@ -534,3 +534,214 @@ func claimAndCompleteLifecycleJobForServer(t *testing.T, svc *CoreService, sessi t.Fatalf("complete lifecycle job %s: %v", capability, err) } } + +func TestCoreServiceRestartLifecycleQueuesDeclaredStopThenStart(t *testing.T) { + svc, runSession := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + operator := createServiceUserAndLogin(t, svc, domain.User{ID: "lifecycle-operator", DisplayName: "Lifecycle Operator", Email: "lifecycle-operator@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"}) + instance := runningLifecycleInstanceForTest(t, svc, runSession, "server-restart") + + restarted, err := svc.RestartServerInstanceForSession(operator, domain.ServerLifecycleCommand{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion, + IdempotencyKey: "idem-restart", + }) + if err != nil { + t.Fatalf("restart lifecycle workflow: %v", err) + } + if restarted.Action != domain.ServerLifecycleActionRestart || restarted.Job.Capability != domain.LifecycleCapabilityStop || restarted.Job.TargetKey != "actions/stop.json" { + t.Fatalf("expected restart to queue the plugin-declared stop action, got %+v", restarted) + } + if restarted.Job.ExecutionInput.LifecycleOperation != string(domain.ServerLifecycleActionRestart) { + t.Fatalf("expected restart intent on the queued job, got %+v", restarted.Job.ExecutionInput) + } + if _, ok := queuedLifecycleJob(t, svc, instance.ID, domain.LifecycleCapabilityStart); ok { + t.Fatal("restart must not queue a start job before the declared stop result lands") + } + + claimAndCompleteLifecycleJobForServer(t, svc, runSession, instance.ID, domain.LifecycleCapabilityStop, domain.JobStateSucceeded) + stopped, err := svc.GetServerInstance(instance.ID) + if err != nil { + t.Fatalf("get stopped instance: %v", err) + } + if stopped.State != domain.ServerInstanceStateStopped { + t.Fatalf("expected graceful stop result to project stopped, got %+v", stopped) + } + + startJob, ok := queuedLifecycleJob(t, svc, instance.ID, domain.LifecycleCapabilityStart) + if !ok { + t.Fatal("expected a follow-up start job after the graceful stop succeeded") + } + if startJob.TargetKey != "actions/start.json" || startJob.ExecutionInput.LifecycleOperation != string(domain.ServerLifecycleActionStart) || startJob.IdempotencyKey != "idem-restart:start" { + t.Fatalf("unexpected follow-up start job: %+v", startJob) + } + + claimAndCompleteLifecycleJobForServer(t, svc, runSession, instance.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded) + running, err := svc.GetServerInstance(instance.ID) + if err != nil { + t.Fatalf("get restarted instance: %v", err) + } + if running.State != domain.ServerInstanceStateRunning { + t.Fatalf("expected restart to return the server to running, got %+v", running) + } +} + +func TestCoreServiceUpdateLifecycleQueuesInstallThenStart(t *testing.T) { + svc, runSession := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + operator := createServiceUserAndLogin(t, svc, domain.User{ID: "update-operator", DisplayName: "Update Operator", Email: "update-operator@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"}) + instance := runningLifecycleInstanceForTest(t, svc, runSession, "server-update") + + updated, err := svc.UpdateServerGameForSession(operator, domain.ServerLifecycleCommand{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion, + IdempotencyKey: "idem-update", + }) + if err != nil { + t.Fatalf("update lifecycle workflow: %v", err) + } + if updated.Action != domain.ServerLifecycleActionUpdate || updated.Job.Capability != domain.LifecycleCapabilityInstall || updated.Job.TargetKey != "actions/install.json" { + t.Fatalf("expected update to queue the plugin-declared install action, got %+v", updated) + } + if updated.Job.ExecutionInput.LifecycleOperation != string(domain.ServerLifecycleActionUpdate) { + t.Fatalf("expected update intent on the queued job, got %+v", updated.Job.ExecutionInput) + } + + claimAndCompleteLifecycleJobForServer(t, svc, runSession, instance.ID, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded) + startJob, ok := queuedLifecycleJob(t, svc, instance.ID, domain.LifecycleCapabilityStart) + if !ok { + t.Fatal("expected a follow-up start job after the game update succeeded") + } + if startJob.IdempotencyKey != "idem-update:start" { + t.Fatalf("unexpected follow-up start job: %+v", startJob) + } + + claimAndCompleteLifecycleJobForServer(t, svc, runSession, instance.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded) + running, err := svc.GetServerInstance(instance.ID) + if err != nil { + t.Fatalf("get updated instance: %v", err) + } + if running.State != domain.ServerInstanceStateRunning { + t.Fatalf("expected update to start the server again, got %+v", running) + } +} + +func TestCoreServiceRestartAndUpdateRespectLifecycleState(t *testing.T) { + svc, runSession := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + operator := createServiceUserAndLogin(t, svc, domain.User{ID: "state-operator", DisplayName: "State Operator", Email: "state-operator@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"}) + if _, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ID: "server-installing", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM installing", IdempotencyKey: "installing-create", ProfileKey: "local"}); err != nil { + t.Fatalf("create installing instance: %v", err) + } + installing, err := svc.GetServerInstance("server-installing") + if err != nil { + t.Fatalf("get installing instance: %v", err) + } + if _, err := svc.RestartServerInstanceForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: installing.ID, ExpectedConfigVersion: installing.ConfigVersion, IdempotencyKey: "idem-restart-installing"}); err == nil || !strings.Contains(err.Error(), "cannot restart") { + t.Fatalf("expected restart state rejection, got %v", err) + } + if _, err := svc.UpdateServerGameForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: installing.ID, ExpectedConfigVersion: installing.ConfigVersion, IdempotencyKey: "idem-update-installing"}); err == nil || !strings.Contains(err.Error(), "cannot update") { + t.Fatalf("expected update state rejection, got %v", err) + } + + claimAndCompleteLifecycleJobForServer(t, svc, runSession, installing.ID, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded) + ready, err := svc.GetServerInstance(installing.ID) + if err != nil { + t.Fatalf("get ready instance: %v", err) + } + if _, err := svc.RestartServerInstanceForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: ready.ID, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-restart-ready"}); err == nil || !strings.Contains(err.Error(), "cannot restart") { + t.Fatalf("expected ready state restart rejection, got %v", err) + } + if _, err := svc.UpdateServerGameForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: ready.ID, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-update-ready"}); err != nil { + t.Fatalf("ready server should accept a game update dispatch: %v", err) + } +} + +func TestCoreServiceFailedGracefulStopDoesNotStartServer(t *testing.T) { + svc, runSession := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + operator := createServiceUserAndLogin(t, svc, domain.User{ID: "failure-operator", DisplayName: "Failure Operator", Email: "failure-operator@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"}) + instance := runningLifecycleInstanceForTest(t, svc, runSession, "server-restart-failure") + + if _, err := svc.RestartServerInstanceForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "idem-restart-failure"}); err != nil { + t.Fatalf("restart lifecycle workflow: %v", err) + } + claimAndCompleteLifecycleJobForServer(t, svc, runSession, instance.ID, domain.LifecycleCapabilityStop, domain.JobStateFailed) + if _, ok := queuedLifecycleJob(t, svc, instance.ID, domain.LifecycleCapabilityStart); ok { + t.Fatal("a failed graceful stop must not queue a start job") + } + failed, err := svc.GetServerInstance(instance.ID) + if err != nil { + t.Fatalf("get failed instance: %v", err) + } + if failed.State != domain.ServerInstanceStateFailed { + t.Fatalf("expected failed stop to project failed, got %+v", failed) + } +} + +func TestCoreServiceRestartAndUpdateRequireEndpointCapabilities(t *testing.T) { + svc, runSession := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + operator := createServiceUserAndLogin(t, svc, domain.User{ID: "capability-operator", DisplayName: "Capability Operator", Email: "capability-operator@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"}) + instance := runningLifecycleInstanceForTest(t, svc, runSession, "server-capability") + + endpoint, err := svc.store.RunEndpoints().Get("run-local") + if err != nil { + t.Fatalf("get lifecycle endpoint: %v", err) + } + remaining := make([]string, 0, len(endpoint.Capabilities)) + for _, capability := range endpoint.Capabilities { + if capability != domain.LifecycleCapabilityStop && capability != domain.LifecycleCapabilityInstall { + remaining = append(remaining, capability) + } + } + endpoint.Capabilities = remaining + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatalf("remove lifecycle capabilities: %v", err) + } + + if _, err := svc.RestartServerInstanceForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "idem-restart-capability"}); err == nil || !strings.Contains(err.Error(), domain.LifecycleCapabilityStop) { + t.Fatalf("expected missing stop capability rejection, got %v", err) + } + if _, err := svc.UpdateServerGameForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "idem-update-capability"}); err == nil || !strings.Contains(err.Error(), domain.LifecycleCapabilityInstall) { + t.Fatalf("expected missing install capability rejection, got %v", err) + } +} + +func runningLifecycleInstanceForTest(t *testing.T, svc *CoreService, runSession string, id string) domain.ServerInstance { + t.Helper() + if _, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ID: id, PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM " + id, IdempotencyKey: id + "-create", ProfileKey: "local"}); err != nil { + t.Fatalf("create lifecycle workflow: %v", err) + } + claimAndCompleteLifecycleJobForServer(t, svc, runSession, id, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded) + ready, err := svc.GetServerInstance(id) + if err != nil { + t.Fatalf("get ready instance: %v", err) + } + if _, err := svc.StartServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: id, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: id + "-start"}); err != nil { + t.Fatalf("start lifecycle workflow: %v", err) + } + claimAndCompleteLifecycleJobForServer(t, svc, runSession, id, domain.LifecycleCapabilityStart, domain.JobStateSucceeded) + running, err := svc.GetServerInstance(id) + if err != nil { + t.Fatalf("get running instance: %v", err) + } + if running.State != domain.ServerInstanceStateRunning { + t.Fatalf("expected running instance, got %+v", running) + } + return running +} + +func queuedLifecycleJob(t *testing.T, svc *CoreService, serverInstanceID string, capability string) (domain.Job, bool) { + t.Helper() + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID, State: domain.JobStateQueued}) + if err != nil { + t.Fatalf("list jobs: %v", err) + } + for _, job := range jobs { + if job.Capability == capability { + return job, true + } + } + return domain.Job{}, false +} diff --git a/platform/validator/runtime_profiles.go b/platform/validator/runtime_profiles.go index 09a5e31..14e9da7 100644 --- a/platform/validator/runtime_profiles.go +++ b/platform/validator/runtime_profiles.go @@ -14,6 +14,7 @@ import ( var ( runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`) runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`) + steamAppIDPattern = regexp.MustCompile(`^[0-9]{1,10}$`) ) func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error { @@ -74,10 +75,17 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles violations = append(violations, validateProfileKey(prefix+".key", probe.Key)...) violations = append(violations, recordRuntimeProfileKey(dependencyKeys, prefix+".key", probe.Key)...) violations = append(violations, validateProfileKey(prefix+".targetKey", probe.TargetKey)...) - if !oneOf(probe.Kind, "command.version", "service.exists", "port.available", "steam.app", "java.version", "docker.available", "package.installed", "file.exists") { + if !oneOf(probe.Kind, "command.version", "service.exists", "port.available", "steam.app", "steam.update", "java.version", "docker.available", "package.installed", "file.exists") { violations = append(violations, prefix+".kind is invalid") } violations = append(violations, validateSafeRuntimeValue(prefix+".minimumVersion", probe.MinimumVersion)...) + if probe.Kind == "steam.update" { + if !steamAppIDPattern.MatchString(probe.SteamAppID) { + violations = append(violations, prefix+".steamAppId is invalid for a Steam build probe") + } + } else if probe.SteamAppID != "" { + violations = append(violations, prefix+".steamAppId is allowed only for steam.update probes") + } violations = append(violations, validateRuntimePlatforms(prefix+".platforms", probe.Platforms)...) } for i, plan := range profiles.InstallPlans { diff --git a/platform/validator/runtime_profiles_test.go b/platform/validator/runtime_profiles_test.go index ac6cf47..045a3a6 100644 --- a/platform/validator/runtime_profiles_test.go +++ b/platform/validator/runtime_profiles_test.go @@ -24,3 +24,20 @@ func TestValidateGamePluginRuntimeProfilesRejectsManualStepExecutionFields(t *te t.Fatalf("expected manual execution field rejection, got %v", err) } } + +func TestValidateGamePluginRuntimeProfilesChecksSteamBuildProbeAppID(t *testing.T) { + valid := domain.GamePluginRuntimeProfiles{DependencyProbes: []domain.RuntimeDependencyProbe{{Key: "game-build", Kind: "steam.update", TargetKey: "steamcmd", SteamAppID: "3792580"}}} + if err := ValidateGamePluginRuntimeProfiles(valid); err != nil { + t.Fatalf("expected declared steam build probe to validate, got %v", err) + } + + missing := domain.GamePluginRuntimeProfiles{DependencyProbes: []domain.RuntimeDependencyProbe{{Key: "game-build", Kind: "steam.update", TargetKey: "steamcmd"}}} + if err := ValidateGamePluginRuntimeProfiles(missing); err == nil || !strings.Contains(err.Error(), "steamAppId is invalid for a Steam build probe") { + t.Fatalf("expected missing steam app id rejection, got %v", err) + } + + misplaced := domain.GamePluginRuntimeProfiles{DependencyProbes: []domain.RuntimeDependencyProbe{{Key: "java-21", Kind: "java.version", TargetKey: "java", SteamAppID: "3792580"}}} + if err := ValidateGamePluginRuntimeProfiles(misplaced); err == nil || !strings.Contains(err.Error(), "steamAppId is allowed only for steam.update probes") { + t.Fatalf("expected misplaced steam app id rejection, got %v", err) + } +} diff --git a/platform/validator/server_lifecycle.go b/platform/validator/server_lifecycle.go index 83b1a9f..150acdd 100644 --- a/platform/validator/server_lifecycle.go +++ b/platform/validator/server_lifecycle.go @@ -36,7 +36,7 @@ func ValidateServerLifecycleCommand(command domain.ServerLifecycleCommand) error func ValidateServerLifecycleAction(action domain.ServerLifecycleAction) error { switch action { - case domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionStatus: + case domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionRestart, domain.ServerLifecycleActionUpdate, domain.ServerLifecycleActionStatus: return nil default: return ValidationError{Violations: []string{fmt.Sprintf("action %q is invalid", action)}} diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 3efe49e..5db1cf0 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -385,6 +385,14 @@ describe("PlatformApiClient AI providers", () => { if (url.endsWith("/api/v1/server-instances/server-1/stop") && init?.method === "POST") { return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } }); } + if (url.endsWith("/api/v1/server-instances/server-1/restart") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ expectedConfigVersion: 1, idempotencyKey: "idem-restart" }); + return jsonResponse({ accepted: true, action: "restart", instance: server, job: { ...job, capability: "process.stop" } }); + } + if (url.endsWith("/api/v1/server-instances/server-1/update") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ expectedConfigVersion: 1, idempotencyKey: "idem-update" }); + return jsonResponse({ accepted: true, action: "update", instance: server, job: { ...job, capability: "process.install" } }); + } if (url.endsWith("/api/v1/server-instances/server-1/process/status") && init?.method === "POST") { return jsonResponse({ accepted: true, action: "status", instance: server, job: { ...job, capability: "process.status", executionResult: { kind: "process", processState: "running", summary: "private supervised process identity" } } }); } @@ -604,6 +612,8 @@ describe("PlatformApiClient AI providers", () => { }); await expect(client.startServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-start" })).resolves.toMatchObject({ action: "start" }); await expect(client.stopServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-stop" })).resolves.toMatchObject({ action: "stop" }); + await expect(client.restartServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-restart" })).resolves.toMatchObject({ action: "restart", job: { capability: "process.stop" } }); + await expect(client.updateServerGame(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-update" })).resolves.toMatchObject({ action: "update", job: { capability: "process.install" } }); await expect(client.queryServerProcessStatus(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-status" })).resolves.toMatchObject({ action: "status", job: { executionResult: { processState: "running" } } }); await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 }); await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] }); @@ -637,7 +647,7 @@ describe("PlatformApiClient AI providers", () => { client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" }) ).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } }); - expect(fetchMock).toHaveBeenCalledTimes(49); + expect(fetchMock).toHaveBeenCalledTimes(51); }); it("normalizes server file workspace null arrays from older platform responses", async () => { diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index c12867c..41be671 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -237,6 +237,20 @@ export class PlatformApiClient { }); } + async restartServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/restart`, { + method: "POST", + body: request + }); + } + + async updateServerGame(id: string, request: ServerLifecycleCommandRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/update`, { + method: "POST", + body: request + }); + } + async queryServerProcessStatus(id: string, request: ServerLifecycleCommandRequest): Promise { return this.request(`/server-instances/${encodeURIComponent(id)}/process/status`, { method: "POST", diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index cf539e4..a08b26b 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -24,7 +24,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia - `createServerWorkflow` posts `ServerLifecycleCreateRequest` with the create-wizard deployment definition to `/server-instances/workflows/create`, including deployment mode, plugin create inputs, and custom startup fields when provided. It never includes a deployment target, Run endpoint, lifecycle profile, or Run identity binding. The generated Run package uses plugin defaults and Platform observes the active Run from its authenticated heartbeat. - `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs for internal/advanced logical transports. Server detail must not expose a manual runtime-binding tab or require these fields before normal start/stop when plugin-declared deployment/lifecycle data is sufficient. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values. -- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. +- `startServerInstance`, `stopServerInstance`, `restartServerInstance`, and `updateServerGame` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. `restart` queues the plugin-declared stop action before the matching start action, and `update` queues the plugin-declared install/update action, which closes a running server gracefully before the game files change. - `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators. - Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients. - `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for file work. diff --git a/platform_web/components/ServerGameUpdatePanel.tsx b/platform_web/components/ServerGameUpdatePanel.tsx new file mode 100644 index 0000000..3f77ee6 --- /dev/null +++ b/platform_web/components/ServerGameUpdatePanel.tsx @@ -0,0 +1,208 @@ +import { Download, RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { platformApiClient } from "../api/client"; +import type { DependencyProbeViewResponse, JobResponse, ServerInstanceResponse } from "../api/types"; +import type { PageComponentProps } from "../contracts/page"; +import { + canUpdateServerGame, + gameUpdateStatusFromProbe, + gameUpdateStatusLabel, + steamUpdateProbe, + type GameUpdateStatus +} from "../contracts/serverManagement"; +import { dependencyJobRequest, serverLifecycleCommandRequest } from "../schemas/serverManagement"; +import { ConfirmDialog } from "./OperationControls"; +import { ResultBadge } from "./StateViews"; + +const dependencyCheckPollAttempts = 45; +const dependencyCheckPollMs = 2000; + +export function gameUpdateCheckNote(status?: GameUpdateStatus): string { + if (!status) { + return "尚未检查过 SCUM 公开分支版本。"; + } + if (status.availability === "available") { + const installed = status.installed ? `本地 ${status.installed}` : "本地版本未知"; + const latest = status.latest ? `公开分支 ${status.latest}` : "公开分支版本未知"; + return `${installed},${latest},可以执行更新。`; + } + if (status.availability === "up-to-date") { + return status.installed ? `本地版本 ${status.installed} 已与公开分支一致。` : "本地版本已与公开分支一致。"; + } + return "SteamCMD 未返回可比较的版本号,请稍后重新检查或在服务器上确认 SteamCMD 可用。"; +} + +interface ServerGameUpdatePanelProps { + instance: ServerInstanceResponse; + session: PageComponentProps["session"]; + operations: PageComponentProps["operations"]; + canManage: boolean; + onChanged: () => void; +} + +export function ServerGameUpdatePanel({ instance, session, operations, canManage, onChanged }: ServerGameUpdatePanelProps) { + const [probe, setProbe] = useState(null); + const [supported, setSupported] = useState(false); + const [busy, setBusy] = useState<"check" | "update" | null>(null); + const [note, setNote] = useState(null); + const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null); + const [confirmUpdate, setConfirmUpdate] = useState(false); + const status = useMemo(() => gameUpdateStatusFromProbe(probe ?? undefined), [probe]); + const updateAvailable = status?.availability === "available"; + const updateAllowed = updateAvailable && canUpdateServerGame(instance.state); + + const loadCatalog = useCallback(async () => { + try { + const catalog = await platformApiClient.getDependencyCatalog(instance.id); + const candidate = steamUpdateProbe(catalog) ?? null; + setProbe(candidate); + setSupported(candidate !== null); + } catch { + setProbe(null); + setSupported(false); + } + }, [instance.id]); + + useEffect(() => { + void loadCatalog(); + }, [loadCatalog]); + + async function checkForUpdate() { + if (!probe) { + return; + } + const operationId = operations.begin({ intent: "检查游戏更新", targetKind: "server", targetId: instance.id, requester: session.displayName }); + setBusy("check"); + setNote("正在向 SteamCMD 查询 SCUM 公开分支版本…"); + setResult({ status: "pending", label: "正在检查版本" }); + try { + const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probe.key)); + const finalJob = await waitForDependencyCheckJob(job.id); + if (!finalJob) { + throw new Error("版本检查任务还没有返回结果,可稍后在运维控制台查看该任务。"); + } + if (finalJob.state !== "succeeded") { + throw new Error(finalJob.progress.message || finalJob.executionResult?.summary || "版本检查失败"); + } + const catalog = await platformApiClient.getDependencyCatalog(instance.id); + const candidate = steamUpdateProbe(catalog) ?? null; + setProbe(candidate); + const label = gameUpdateStatusLabel(gameUpdateStatusFromProbe(candidate ?? undefined)); + const summary = gameUpdateCheckNote(gameUpdateStatusFromProbe(candidate ?? undefined)); + setNote(summary); + setResult({ status: "succeeded", label }); + operations.succeed(operationId, `游戏版本检查完成:${label}。${summary}`, finalJob); + } catch (error) { + const reason = error instanceof Error ? error.message : "版本检查失败"; + setNote(reason); + setResult({ status: "failed", label: reason }); + operations.fail(operationId, reason, operationId); + } finally { + setBusy(null); + } + } + + async function submitUpdate() { + const operationId = operations.begin({ intent: "更新游戏版本", targetKind: "server", targetId: instance.id, requester: session.displayName }); + setBusy("update"); + try { + const response = await platformApiClient.updateServerGame(instance.id, serverLifecycleCommandRequest(instance, "update")); + const message = `更新任务 ${response.job.id} 已派发:先按插件声明优雅关闭 SCUM 服务,再执行 SteamCMD 更新,成功后自动重新启动。`; + setNote(message); + setResult({ status: "pending", label: "更新任务已派发" }); + operations.succeed(operationId, message, response.job); + onChanged(); + } catch (error) { + const reason = error instanceof Error ? error.message : "更新派发失败"; + setNote(reason); + setResult({ status: "failed", label: reason }); + operations.fail(operationId, reason, operationId); + } finally { + setBusy(null); + } + } + + if (!supported || !probe) { + return null; + } + + return ( +
+
+

+ 游戏版本更新 +

+ {result && } +
+

检查 Steam 公开分支的 SCUM 版本号,发现新版本后更新按钮会点亮。更新会先通过插件声明的优雅关机流程停止 SCUM 服务,关闭失败时中止更新,不会直接结束进程。

+
+
+
本地版本
+
{status?.installed ?? "未记录"}
+
+
+
公开分支版本
+
{status?.latest ?? "未记录"}
+
+
+
更新状态
+
{gameUpdateStatusLabel(status)}
+
+
+

{note ?? gameUpdateCheckNote(status)}

+
+ + +
+ setConfirmUpdate(false)} + onConfirm={() => { + setConfirmUpdate(false); + void submitUpdate(); + }} + /> +
+ ); +} + +function updateTitle(canManage: boolean, updateAvailable: boolean, updateAllowed: boolean, state: ServerInstanceResponse["state"]): string { + if (!canManage) { + return "当前账号没有管理权限"; + } + if (!updateAvailable) { + return "先检查更新;检测到新版本后该按钮会点亮"; + } + if (!updateAllowed) { + return `当前状态 ${state} 不能执行游戏更新`; + } + return "优雅关闭 SCUM 服务后执行 SteamCMD 更新,成功后自动启动"; +} + +async function waitForDependencyCheckJob(jobId: string): Promise { + for (let attempt = 0; attempt < dependencyCheckPollAttempts; attempt += 1) { + const current = await platformApiClient.getJob(jobId); + if (current.state === "succeeded" || current.state === "failed" || current.state === "cancelled") { + return current; + } + await new Promise((resolve) => window.setTimeout(resolve, dependencyCheckPollMs)); + } + return null; +} diff --git a/platform_web/contracts/pages.md b/platform_web/contracts/pages.md index a9859b6..b81caaa 100644 --- a/platform_web/contracts/pages.md +++ b/platform_web/contracts/pages.md @@ -23,7 +23,7 @@ Default landing page for server owners and server administrators. Shows searchab ## 服务器详情 -Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start/stop lifecycle actions. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM user and vehicle pages read platform-maintained SCUM tables while plugin-owned squads, map settings, gifts, and workflows stay in scoped plugin records. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail. +Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start, stop, restart, and plugin-declared graceful-update lifecycle actions, plus a game-version module that checks the plugin-declared Steam build probe and only lights up the update action when a newer public build is reported. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM user and vehicle pages read platform-maintained SCUM tables while plugin-owned squads, map settings, gifts, and workflows stay in scoped plugin records. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail. ## 插件市场 diff --git a/platform_web/contracts/serverManagement.test.ts b/platform_web/contracts/serverManagement.test.ts index 037aea5..31d1a72 100644 --- a/platform_web/contracts/serverManagement.test.ts +++ b/platform_web/contracts/serverManagement.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { canStartServer, canStopServer, runtimeObservationFreshness } from "./serverManagement"; -import type { RunEndpointResponse, ServerInstanceResponse, ServerInstanceState } from "../api/types"; +import { canRestartServer, canStartServer, canStopServer, canUpdateServerGame, gameUpdateStatusFromProbe, gameUpdateStatusLabel, steamUpdateProbe, runtimeObservationFreshness } from "./serverManagement"; +import type { DependencyCatalogResponse, DependencyProbeViewResponse, RunEndpointResponse, ServerInstanceResponse, ServerInstanceState } from "../api/types"; describe("server management lifecycle contracts", () => { it("allows explicit starts from recoverable non-running states", () => { @@ -17,6 +17,41 @@ describe("server management lifecycle contracts", () => { expect(canStopServer("failed")).toBe(false); }); + it("keeps restart and update dispatch inside the states the platform accepts", () => { + const restartable: ServerInstanceState[] = ["running", "stopped"]; + const updatable: ServerInstanceState[] = ["running", "stopped", "ready", "failed"]; + expect(restartable.every(canRestartServer)).toBe(true); + expect((["draft", "installing", "ready", "failed", "deleted"] as ServerInstanceState[]).some(canRestartServer)).toBe(false); + expect(updatable.every(canUpdateServerGame)).toBe(true); + expect((["draft", "installing", "deleted"] as ServerInstanceState[]).some(canUpdateServerGame)).toBe(false); + }); + + it("reads the Steam build probe evidence into an update decision", () => { + const catalog = { + serverInstanceId: "server-1", + pluginId: "game.scum", + pluginVersion: "0.1.16", + profileKey: "run-local", + targetOs: "windows", + targetArch: "amd64", + updatedAt: "2026-09-15T00:00:00Z", + plans: [], + probes: [{ key: "scum-server-build", kind: "steam.update", required: false, state: "present", evidence: "installed=100 latest=200 update=yes" }] + } as DependencyCatalogResponse; + const probe = steamUpdateProbe(catalog); + expect(probe?.key).toBe("scum-server-build"); + const status = gameUpdateStatusFromProbe(probe); + expect(status).toEqual({ availability: "available", installed: "100", latest: "200" }); + expect(gameUpdateStatusLabel(status)).toBe("发现新版本"); + + const current = gameUpdateStatusFromProbe({ ...probe, evidence: "installed=200 latest=200 update=no" } as DependencyProbeViewResponse); + expect(current?.availability).toBe("up-to-date"); + const unknown = gameUpdateStatusFromProbe({ ...probe, evidence: "installed=none latest=200 update=yes" } as DependencyProbeViewResponse); + expect(unknown).toEqual({ availability: "available", installed: "none", latest: "200" }); + const missing = gameUpdateStatusFromProbe({ ...probe, kind: "java.version" } as DependencyProbeViewResponse); + expect(missing).toBeUndefined(); + }); + it("distinguishes a fresh Run observation from an unverified historical lifecycle state", () => { const instance = { id: "server-1", state: "running", runEndpointId: "run-1" } as ServerInstanceResponse; const endpoint = { id: "run-1", status: "online", lastHeartbeatAt: "2026-08-07T10:00:00Z" } as RunEndpointResponse; diff --git a/platform_web/contracts/serverManagement.ts b/platform_web/contracts/serverManagement.ts index 082f617..36027cf 100644 --- a/platform_web/contracts/serverManagement.ts +++ b/platform_web/contracts/serverManagement.ts @@ -1,4 +1,6 @@ import type { + DependencyCatalogResponse, + DependencyProbeViewResponse, GamePluginResponse, JobResponse, RunEndpointResponse, @@ -120,6 +122,61 @@ export function canStopServer(state: ServerInstanceState): boolean { return state === "running"; } +export function canRestartServer(state: ServerInstanceState): boolean { + return state === "running" || state === "stopped"; +} + +export function canUpdateServerGame(state: ServerInstanceState): boolean { + return state === "running" || state === "stopped" || state === "ready" || state === "failed"; +} + +export type GameUpdateAvailability = "unknown" | "available" | "up-to-date"; + +export interface GameUpdateStatus { + availability: GameUpdateAvailability; + installed?: string; + latest?: string; +} + +export function steamUpdateProbe(catalog?: DependencyCatalogResponse): DependencyProbeViewResponse | undefined { + return catalog?.probes.find((probe) => probe.kind === "steam.update"); +} + +export function gameUpdateStatusFromProbe(probe?: DependencyProbeViewResponse): GameUpdateStatus | undefined { + if (!probe || probe.kind !== "steam.update") { + return undefined; + } + const update = dependencyEvidenceValue(probe.evidence, "update"); + return { + availability: update === "yes" ? "available" : update === "no" ? "up-to-date" : "unknown", + installed: dependencyEvidenceValue(probe.evidence, "installed"), + latest: dependencyEvidenceValue(probe.evidence, "latest") + }; +} + +export function gameUpdateStatusLabel(status?: GameUpdateStatus): string { + switch (status?.availability) { + case "available": return "发现新版本"; + case "up-to-date": return "已是最新版本"; + case "unknown": return "版本未知"; + default: return "尚未检查"; + } +} + +function dependencyEvidenceValue(evidence: string | undefined, key: string): string | undefined { + if (!evidence) { + return undefined; + } + for (const token of evidence.split(/\s+/)) { + const separator = token.indexOf("="); + if (separator <= 0 || token.slice(0, separator) !== key) { + continue; + } + return token.slice(separator + 1) || undefined; + } + return undefined; +} + export function isPendingJobState(state: JobResponse["state"]): boolean { return state === "queued" || state === "accepted" || state === "running" || state === "retrying"; } diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index d3b4101..83244b8 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -4,6 +4,7 @@ import { configDiffViewFromPreview } from "./ServerDetailPage"; import serversPageSource from "./ServersPage.tsx?raw"; import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw"; import serverDetailPageSource from "./ServerDetailPage.tsx?raw"; +import serverGameUpdatePanelSource from "../components/ServerGameUpdatePanel.tsx?raw"; import type { ServerConfigDiffPreviewResponse } from "../api/types"; const preview: ServerConfigDiffPreviewResponse = { @@ -170,12 +171,27 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain("插件控制"); expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(current.id"); expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(current.id"); - expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, \"start\")"); - expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, \"stop\")"); + expect(serverDetailPageSource).toContain("platformApiClient.restartServerInstance(current.id"); + expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, action)"); expect(serverDetailPageSource).not.toContain('capability: "process.start"'); expect(serverDetailPageSource).not.toContain('capability: "process.stop"'); }); + it("lights the SCUM update action only after the plugin-declared build check reports a new version", () => { + expect(serverDetailPageSource).toContain("ServerGameUpdatePanel"); + expect(serverDetailPageSource).toContain("canRestartServer(instance.data.state)"); + expect(serverGameUpdatePanelSource).toContain("platformApiClient.getDependencyCatalog"); + expect(serverGameUpdatePanelSource).toContain("platformApiClient.checkDependencies"); + expect(serverGameUpdatePanelSource).toContain("platformApiClient.updateServerGame"); + expect(serverGameUpdatePanelSource).toContain("steamUpdateProbe"); + expect(serverGameUpdatePanelSource).toContain("gameUpdateStatusFromProbe"); + expect(serverGameUpdatePanelSource).toContain('serverLifecycleCommandRequest(instance, "update")'); + expect(serverGameUpdatePanelSource).toContain("优雅关机"); + expect(serverGameUpdatePanelSource).not.toContain("taskkill"); + expect(serverGameUpdatePanelSource).not.toContain('capability: "process.stop"'); + expect(serverGameUpdatePanelSource).not.toContain('capability: "process.install"'); + }); + it("leaves guided deployment to the generated Run heartbeat workflow", () => { expect(serverDetailPageSource).not.toContain("canDeployServer(instance.data.state)"); expect(serverDetailPageSource).not.toContain("requestDeployment(instance.data)"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 3b93317..d8407f3 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -1,4 +1,4 @@ -import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, Settings2, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react"; +import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, RotateCcw, Save, Search, Settings2, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react"; import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { platformApiClient } from "../api/client"; @@ -19,9 +19,10 @@ import type { import { ConfirmDialog } from "../components/OperationControls"; import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer"; import { ServerConfigEditor } from "../components/ServerConfigEditor"; +import { ServerGameUpdatePanel } from "../components/ServerGameUpdatePanel"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; -import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; +import { canRestartServer, canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; import { serverDetailSections, type ConfigDiffView, @@ -129,26 +130,32 @@ export function ServerDetailPage(props: PageComponentProps) { setSection(`plugin:${defaultPluginPage.key}`); }, [defaultPluginPage, params.routeKey, section]); - function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") { + function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop" | "restart") { + const intent = action === "start" ? "启动服务器" : action === "stop" ? "停止服务器" : "重启服务器"; setConfirm({ - title: action === "start" ? "启动服务器" : "停止服务器", + title: action === "start" ? "启动服务器" : action === "stop" ? "停止服务器" : "重启服务器", description: action === "start" ? `确认启动服务器 ${current.name}(${current.id})?` - : `停止服务器 ${current.name}(${current.id})会断开所有在线玩家,确认继续?`, + : action === "stop" + ? `停止服务器 ${current.name}(${current.id})会断开所有在线玩家,确认继续?` + : `重启服务器 ${current.name}(${current.id})会先按插件声明优雅关闭 SCUM 服务,停止成功后自动重新启动;在线玩家会被断开。`, danger: action === "stop", run: async () => { const operationId = operations.begin({ - intent: action === "start" ? "启动服务器" : "停止服务器", + intent, targetKind: "server", targetId: current.id, requester: session.displayName }); try { + const request = serverLifecycleCommandRequest(current, action); const result = action === "start" - ? await platformApiClient.startServerInstance(current.id, serverLifecycleCommandRequest(current, "start")) - : await platformApiClient.stopServerInstance(current.id, serverLifecycleCommandRequest(current, "stop")); + ? await platformApiClient.startServerInstance(current.id, request) + : action === "stop" + ? await platformApiClient.stopServerInstance(current.id, request) + : await platformApiClient.restartServerInstance(current.id, request); operations.succeed(operationId, `任务 ${result.job.id}(${result.job.capability})已派发`, result.job); await refresh(); } catch (error) { @@ -231,6 +238,15 @@ export function ServerDetailPage(props: PageComponentProps) { 停止 + {detailStateText} @@ -249,6 +265,15 @@ export function ServerDetailPage(props: PageComponentProps) { /> )} {section === "manage" && setInstance({ status: "ready", data: next })} />} + {section === "manage" && ( + void refreshOperationalState()} + /> + )} {section === "files" && } {section === "llm" && } setTerminalOpen(false)} /> diff --git a/platform_web/schemas/serverManagement.ts b/platform_web/schemas/serverManagement.ts index 9eb32c8..de8433f 100644 --- a/platform_web/schemas/serverManagement.ts +++ b/platform_web/schemas/serverManagement.ts @@ -55,7 +55,7 @@ export function serverInstanceIdFromName(name: string, sequence = Date.now()): s return normalized ? `server-${normalized}-${suffix}` : `server-${suffix}`; } -export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "deploy" | "start" | "stop" | "status", sequence = Date.now()): ServerLifecycleCommandRequest { +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) @@ -113,6 +113,6 @@ export function runtimeIdempotencyKey(action: string, serverInstanceId: string, return `web:${action}:${serverInstanceId}:${sequence}`; } -export function lifecycleIdempotencyKey(action: "create" | "deploy" | "start" | "stop" | "status", serverInstanceId: string, sequence: number): string { +export function lifecycleIdempotencyKey(action: "create" | "deploy" | "start" | "stop" | "restart" | "update" | "status", serverInstanceId: string, sequence: number): string { return `web:${action}:${serverInstanceId}:${sequence}`; } diff --git a/plugins/examples/scum-server-plugin/actions/install.json b/plugins/examples/scum-server-plugin/actions/install.json index 287a2a5..d3d99bc 100644 --- a/plugins/examples/scum-server-plugin/actions/install.json +++ b/plugins/examples/scum-server-plugin/actions/install.json @@ -9,7 +9,7 @@ "SERVER_TEMPLATE": "scum-server", "SERVER_STEAM_APP_ID": "3792580", "SERVER_STEAMCMD_INSTALL_DIR_ARG": "+force_install_dir", - "SERVER_STEAMCMD_UPDATE_ARGS": "+login anonymous +app_update 3792580 +quit", + "SERVER_STEAMCMD_UPDATE_ARGS": "+login anonymous +app_update 3792580 validate +quit", "SERVER_EXECUTABLE_REF": "SCUM/Binaries/Win64/SCUMServer.exe" }, "timeoutMs": 7200000 diff --git a/plugins/examples/scum-server-plugin/actions/stop.json b/plugins/examples/scum-server-plugin/actions/stop.json index ea2b5aa..d7db8f5 100644 --- a/plugins/examples/scum-server-plugin/actions/stop.json +++ b/plugins/examples/scum-server-plugin/actions/stop.json @@ -4,7 +4,20 @@ "mode": "control", "environment": { "GAME_ID": "scum", - "SERVER_ACTION": "stop" + "SERVER_ACTION": "stop", + "SERVER_STOP_SHUTDOWN_COMMAND": "Quit", + "SERVER_STOP_NOTICE_SECONDS": "15", + "SERVER_STOP_TIMEOUT_SECONDS": "120" }, - "stopTimeoutMs": 30000 + "gracefulStop": { + "executableKey": "bin/scum-stop.cmd", + "arguments": [], + "environment": { + "GAME_ID": "scum", + "SERVER_ACTION": "stop" + }, + "timeoutMs": 300000, + "fallback": "terminate" + }, + "stopTimeoutMs": 60000 } diff --git a/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd b/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd index 3aa4e6f..ae65108 100755 --- a/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd +++ b/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd @@ -5,7 +5,7 @@ if "%SERVER_ROOT%"=="" set "SERVER_ROOT=C:\scumserver" set "SERVER_ROOT_WINDOWS=%SERVER_ROOT:/=\%" if "%SERVER_STEAM_APP_ID%"=="" set "SERVER_STEAM_APP_ID=3792580" if "%SERVER_STEAMCMD_INSTALL_DIR_ARG%"=="" set "SERVER_STEAMCMD_INSTALL_DIR_ARG=+force_install_dir" -if "%SERVER_STEAMCMD_UPDATE_ARGS%"=="" set "SERVER_STEAMCMD_UPDATE_ARGS=+login anonymous +app_update 3792580 +quit" +if "%SERVER_STEAMCMD_UPDATE_ARGS%"=="" set "SERVER_STEAMCMD_UPDATE_ARGS=+login anonymous +app_update 3792580 validate +quit" if "%SERVER_EXECUTABLE_REF%"=="" set "SERVER_EXECUTABLE_REF=SCUM\Binaries\Win64\SCUMServer.exe" if "%SERVER_INSTALL_DIR%"=="" set "SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%" set "SERVER_INSTALL_DIR_WINDOWS=%SERVER_INSTALL_DIR:/=\%" @@ -29,8 +29,7 @@ if not exist "%STEAMCMD_EXE%" ( if errorlevel 1 exit /b 1 ) -call :resolve_scum_exe -if exist "%SCUM_EXE%" call :stop_matching_scum +call :stop_running_scum if errorlevel 1 exit /b 1 pushd "%STEAMCMD_DIR%" @@ -60,10 +59,21 @@ if exist "%SCUM_EXE%" exit /b 0 set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%" exit /b 0 -:stop_matching_scum -if "%SCUM_EXE%"=="" exit /b 0 -powershell -NoProfile -ExecutionPolicy Bypass -Command "$target=[IO.Path]::GetFullPath($env:SCUM_EXE); Get-CimInstance Win32_Process -Filter \"name='SCUMServer.exe'\" | Where-Object { $_.ExecutablePath -and ([IO.Path]::GetFullPath($_.ExecutablePath) -ieq $target) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }" -exit /b %ERRORLEVEL% +:stop_running_scum +rem SteamCMD must never replace server files underneath a running process. +rem The plugin-owned stop script asks the running SCUM server to shut down and +rem waits for it to exit, so an update cannot silently kill a live server. +set "SCUM_STOP_SCRIPT=%~dp0scum-stop.cmd" +if not exist "%SCUM_STOP_SCRIPT%" ( + echo [scum-install-update] Missing plugin stop script: %SCUM_STOP_SCRIPT% + exit /b 4 +) +call "%SCUM_STOP_SCRIPT%" +if errorlevel 1 ( + echo [scum-install-update] SCUM server did not shut down gracefully; refusing to update server files. + exit /b 5 +) +exit /b 0 :default_steamcmd_dir for %%I in ("%SERVER_ROOT_WINDOWS%") do set "SERVER_ROOT_DRIVE=%%~dI" diff --git a/plugins/examples/scum-server-plugin/bin/scum-rcon.ps1 b/plugins/examples/scum-server-plugin/bin/scum-rcon.ps1 new file mode 100644 index 0000000..3a4a958 --- /dev/null +++ b/plugins/examples/scum-server-plugin/bin/scum-rcon.ps1 @@ -0,0 +1,111 @@ +# Minimal plugin-owned Source RCON client for the local SCUM "Simple RCON" +# listener declared by this plugin. It is used by plugin lifecycle scripts that +# must speak to the running server (announce, save, shutdown). The listener +# password stays on this machine: callers either pass -ConfigPath so the script +# reads it from the local UE4SS mod config, or pass -Password directly. +param( + [string]$ConfigPath = "", + [string]$Password = "", + [int]$Port = 0, + [string]$BindAddress = "", + [string]$Command = "", + [int]$TimeoutMs = 10000 +) + +$ErrorActionPreference = "Stop" + +function Read-RconConfig { + param([string]$Path) + $values = @{} + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $values } + $section = "" + foreach ($line in Get-Content -LiteralPath $Path) { + $trimmed = $line.Trim() + if (-not $trimmed -or $trimmed.StartsWith(";") -or $trimmed.StartsWith("#")) { continue } + if ($trimmed.StartsWith("[") -and $trimmed.EndsWith("]")) { + $section = $trimmed.Substring(1, $trimmed.Length - 2).Trim() + continue + } + $separator = $trimmed.IndexOf("=") + if ($separator -lt 1) { continue } + $key = $trimmed.Substring(0, $separator).Trim() + $value = $trimmed.Substring($separator + 1).Trim().Trim('"') + $values[($section + "." + $key).ToLowerInvariant()] = $value + } + return $values +} + +function Send-RconPacket { + param([System.Net.Sockets.NetworkStream]$Stream, [int]$Id, [int]$Type, [string]$Body) + $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($Body) + $size = 4 + 4 + $bodyBytes.Length + 2 + $buffer = New-Object byte[] (4 + $size) + [System.BitConverter]::GetBytes([int]$size).CopyTo($buffer, 0) + [System.BitConverter]::GetBytes([int]$Id).CopyTo($buffer, 4) + [System.BitConverter]::GetBytes([int]$Type).CopyTo($buffer, 8) + $bodyBytes.CopyTo($buffer, 12) + $Stream.Write($buffer, 0, $buffer.Length) + $Stream.Flush() +} + +function Receive-RconPacket { + param([System.Net.Sockets.NetworkStream]$Stream) + $header = New-Object byte[] 4 + $read = 0 + while ($read -lt 4) { + $chunk = $Stream.Read($header, $read, 4 - $read) + if ($chunk -le 0) { throw "RCON connection closed before a response header arrived" } + $read += $chunk + } + $size = [System.BitConverter]::ToInt32($header, 0) + if ($size -lt 10 -or $size -gt 65536) { throw "RCON response size is invalid" } + $body = New-Object byte[] $size + $read = 0 + while ($read -lt $size) { + $chunk = $Stream.Read($body, $read, $size - $read) + if ($chunk -le 0) { throw "RCON connection closed mid response" } + $read += $chunk + } + $id = [System.BitConverter]::ToInt32($body, 0) + $type = [System.BitConverter]::ToInt32($body, 4) + $text = [System.Text.Encoding]::UTF8.GetString($body, 8, $size - 10) + return @{ Id = $id; Type = $type; Body = $text } +} + +$config = Read-RconConfig -Path $ConfigPath +if (-not $Password) { $Password = $config["rcon.password"] } +if (-not $Port -or $Port -le 0) { + $configuredPort = 0 + if ([int]::TryParse([string]$config["rcon.port"], [ref]$configuredPort) -and $configuredPort -ge 1 -and $configuredPort -le 65535) { + $Port = $configuredPort + } +} +if (-not $Port -or $Port -le 0) { $Port = 27015 } +if (-not $BindAddress) { $BindAddress = $config["rcon.bind_address"] } +if (-not $BindAddress) { $BindAddress = "127.0.0.1" } +if (-not $Password) { Write-Error "SCUM RCON password is not available for the local listener"; exit 3 } +if (-not $Command) { Write-Error "SCUM RCON command text is required"; exit 4 } + +$client = New-Object System.Net.Sockets.TcpClient +try { + $connect = $client.BeginConnect($BindAddress, $Port, $null, $null) + if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMs)) { Write-Error "SCUM RCON listener is not reachable on port $Port"; exit 5 } + $client.EndConnect($connect) + $client.ReceiveTimeout = $TimeoutMs + $client.SendTimeout = $TimeoutMs + $stream = $client.GetStream() + + Send-RconPacket -Stream $stream -Id 1 -Type 3 -Body $Password + $auth = Receive-RconPacket -Stream $stream + if ($auth.Id -ne 1) { Write-Error "SCUM RCON authentication response did not match the request"; exit 6 } + + Send-RconPacket -Stream $stream -Id 2 -Type 2 -Body $Command + $response = Receive-RconPacket -Stream $stream + if ($response.Body) { Write-Output $response.Body } + exit 0 +} catch { + Write-Error ("SCUM RCON command failed: " + $_.Exception.Message) + exit 7 +} finally { + $client.Close() +} diff --git a/plugins/examples/scum-server-plugin/bin/scum-stop.cmd b/plugins/examples/scum-server-plugin/bin/scum-stop.cmd new file mode 100644 index 0000000..2f12bbd --- /dev/null +++ b/plugins/examples/scum-server-plugin/bin/scum-stop.cmd @@ -0,0 +1,99 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +rem Plugin-owned graceful stop for the SCUM dedicated server. +rem It announces the shutdown to players, asks the running server to shut down +rem through the local RCON listener (port and password come from the UE4SS mod +rem config), and waits for SCUMServer.exe to exit on its own. Run only +rem terminates the process afterwards when the lifecycle action declares that +rem fallback. + +if "%SERVER_ROOT%"=="" set "SERVER_ROOT=C:\scumserver" +set "SERVER_ROOT_WINDOWS=%SERVER_ROOT:/=\%" +if "%SERVER_EXECUTABLE_REF%"=="" set "SERVER_EXECUTABLE_REF=SCUM\Binaries\Win64\SCUMServer.exe" +if "%SERVER_INSTALL_DIR%"=="" set "SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%" +set "SERVER_INSTALL_DIR_WINDOWS=%SERVER_INSTALL_DIR:/=\%" +set "SCUM_RCON_PORT_ARG=" +if not "%SERVER_STOP_RCON_PORT%"=="" set "SCUM_RCON_PORT_ARG=-Port %SERVER_STOP_RCON_PORT%" +if "%SERVER_STOP_SHUTDOWN_COMMAND%"=="" set "SERVER_STOP_SHUTDOWN_COMMAND=Quit" +if "%SERVER_STOP_ANNOUNCE%"=="" set "SERVER_STOP_ANNOUNCE=Server shutdown requested by the operator. Save your progress and disconnect." +if "%SERVER_STOP_NOTICE_SECONDS%"=="" set "SERVER_STOP_NOTICE_SECONDS=15" +if "%SERVER_STOP_TIMEOUT_SECONDS%"=="" set "SERVER_STOP_TIMEOUT_SECONDS=120" +if "%SERVER_STOP_RCON_TIMEOUT_MS%"=="" set "SERVER_STOP_RCON_TIMEOUT_MS=10000" + +set "SCUM_RCON_SCRIPT=%~dp0scum-rcon.ps1" +set "SCUM_RCON_CONFIG=%SERVER_INSTALL_DIR_WINDOWS%\ue4ss\Mods\scum_simple_rcon\config.ini" +if not exist "%SCUM_RCON_CONFIG%" set "SCUM_RCON_CONFIG=%SERVER_ROOT_WINDOWS%\ue4ss\Mods\scum_simple_rcon\config.ini" + +call :resolve_scum_exe +call :find_running_scum +if "%SCUM_RUNNING_PID%"=="" ( + echo [scum-stop] SCUM server is not running; nothing to stop. + exit /b 0 +) + +echo [scum-stop] Requesting graceful shutdown of SCUM server pid %SCUM_RUNNING_PID%. +if not exist "%SCUM_RCON_SCRIPT%" ( + echo [scum-stop] Missing plugin RCON client: %SCUM_RCON_SCRIPT% + exit /b 3 +) +if not exist "%SCUM_RCON_CONFIG%" ( + echo [scum-stop] Missing SCUM RCON configuration: %SCUM_RCON_CONFIG% + exit /b 4 +) + +if not "%SERVER_STOP_NOTICE_SECONDS%"=="0" ( + call :send_rcon "Announce %SERVER_STOP_ANNOUNCE%" + if errorlevel 1 echo [scum-stop] Player announcement was not delivered; continuing with the shutdown request. + timeout /T %SERVER_STOP_NOTICE_SECONDS% /NOBREAK >nul +) + +call :send_rcon "%SERVER_STOP_SHUTDOWN_COMMAND%" +if errorlevel 1 echo [scum-stop] Declared shutdown command was not acknowledged; waiting for the server to exit anyway. + +call :wait_for_exit +if not "%SCUM_EXITED%"=="1" ( + echo [scum-stop] SCUM server pid %SCUM_RUNNING_PID% did not exit within %SERVER_STOP_TIMEOUT_SECONDS% seconds of the declared shutdown request. + exit /b 2 +) +echo [scum-stop] SCUM server shut down gracefully. +exit /b 0 + +:send_rcon +if "%~1"=="" exit /b 1 +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCUM_RCON_SCRIPT%" -ConfigPath "%SCUM_RCON_CONFIG%" %SCUM_RCON_PORT_ARG% -TimeoutMs %SERVER_STOP_RCON_TIMEOUT_MS% -Command "%~1" +exit /b %ERRORLEVEL% + +:wait_for_exit +set "SCUM_EXITED=0" +set /A SCUM_WAIT_TICKS=%SERVER_STOP_TIMEOUT_SECONDS%*2 +if %SCUM_WAIT_TICKS% LSS 1 set "SCUM_WAIT_TICKS=1" +for /L %%A in (1,1,%SCUM_WAIT_TICKS%) do ( + call :find_running_scum + if "!SCUM_RUNNING_PID!"=="" ( + set "SCUM_EXITED=1" + goto :wait_done + ) + timeout /T 1 /NOBREAK >nul +) +:wait_done +exit /b 0 + +:find_running_scum +set "SCUM_RUNNING_PID=" +for /f "usebackq delims=" %%P in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$target=$env:SCUM_EXE; if (-not $target) { exit 0 }; $full=[IO.Path]::GetFullPath($target); Get-CimInstance Win32_Process -Filter \"name='SCUMServer.exe'\" | Where-Object { $_.ExecutablePath -and ([IO.Path]::GetFullPath($_.ExecutablePath) -ieq $full) } | Select-Object -First 1 -ExpandProperty ProcessId"`) do set "SCUM_RUNNING_PID=%%P" +exit /b 0 + +:resolve_scum_exe +set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%" +set "SCUM_EXE_MARKER=%SERVER_ROOT_WINDOWS%\.scum-exe-path" +if exist "%SCUM_EXE_MARKER%" set /p SCUM_EXE=<"%SCUM_EXE_MARKER%" +if exist "%SCUM_EXE%" exit /b 0 +set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%" +if exist "%SCUM_EXE%" exit /b 0 +set "SCUM_EXE=%SERVER_INSTALL_DIR_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%" +if exist "%SCUM_EXE%" exit /b 0 +set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\steamcmd\steamapps\common\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%" +if exist "%SCUM_EXE%" exit /b 0 +set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%" +exit /b 0 diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 05c34d0..cbe588c 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -3,7 +3,7 @@ "id": "game.scum", "name": "SCUM Server", "description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and plugin-owned RCON data flows.", - "version": "0.1.15", + "version": "0.1.16", "kind": "game-plugin", "tags": [ "scum", @@ -778,6 +778,14 @@ "path": "bin/scum-start.cmd", "mode": 448 }, + { + "path": "bin/scum-stop.cmd", + "mode": 448 + }, + { + "path": "bin/scum-rcon.ps1", + "mode": 384 + }, { "path": "assets/map/scum-map-overview.jpg", "mode": 384 @@ -1037,6 +1045,16 @@ "windows", "linux" ] + }, + { + "key": "scum-server-build", + "kind": "steam.update", + "targetKey": "steamcmd", + "steamAppId": "3792580", + "required": false, + "platforms": [ + "windows" + ] } ], "installPlans": [ diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 490b240..f67fe2e 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -539,10 +539,11 @@ "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, - "kind": { "enum": ["command.version", "service.exists", "port.available", "steam.app", "java.version", "docker.available", "package.installed", "file.exists"] }, + "kind": { "enum": ["command.version", "service.exists", "port.available", "steam.app", "steam.update", "java.version", "docker.available", "package.installed", "file.exists"] }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "required": { "type": "boolean" }, "minimumVersion": { "type": "string", "maxLength": 80 }, + "steamAppId": { "type": "string", "pattern": "^[0-9]{1,10}$" }, "platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true } } }, diff --git a/plugins/manifests/lifecycle-action.schema.json b/plugins/manifests/lifecycle-action.schema.json index 6aeb075..c9dc461 100644 --- a/plugins/manifests/lifecycle-action.schema.json +++ b/plugins/manifests/lifecycle-action.schema.json @@ -24,6 +24,27 @@ }, "outputMode": { "enum": ["pipes", "console"] }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 7200000 }, - "stopTimeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 } + "stopTimeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 }, + "gracefulStop": { + "type": "object", + "required": ["executableKey"], + "additionalProperties": false, + "properties": { + "executableKey": { "$ref": "game-plugin.manifest.schema.json#/$defs/relativePathRef" }, + "arguments": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 240 }, + "maxItems": 64 + }, + "environment": { + "type": "object", + "propertyNames": { "pattern": "^(GAME|SERVER|RUN)_[A-Z0-9_]{1,59}$" }, + "additionalProperties": { "type": "string", "maxLength": 512 }, + "maxProperties": 32 + }, + "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 1800000 }, + "fallback": { "enum": ["report", "terminate"] } + } + } } } diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 5e099d2..2f10dc0 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -223,6 +223,15 @@ export function validateLifecycleActionFile(actionPath: string, expectedAction?: if ((declaration.action === "stop" || declaration.action === "status") && declaration.mode !== "control") { errors.push(`lifecycleAction.mode: ${declaration.action} must be control`); } + const gracefulStop = (action as { gracefulStop?: { executableKey?: unknown } }).gracefulStop; + if (gracefulStop !== undefined) { + if (declaration.action !== "stop") { + errors.push("lifecycleAction.gracefulStop: only the stop action may declare a graceful stop step"); + } + if (typeof gracefulStop.executableKey !== "string") { + errors.push("lifecycleAction.gracefulStop.executableKey: required for a declared graceful stop step"); + } + } return errors; } @@ -754,6 +763,11 @@ export function validateManifestFile(manifestPath: string): string[] { if (typeof executableKey === "string" && !assetValidation.declared.has(executableKey)) { errors.push(`lifecycleAction.${declaration.action}.executableKey: ${executableKey} must be declared in manifest.assetFiles`); } + const gracefulStop = typeof action === "object" && action !== null ? (action as { gracefulStop?: { executableKey?: unknown } }).gracefulStop : undefined; + const gracefulExecutableKey = gracefulStop && typeof gracefulStop.executableKey === "string" ? gracefulStop.executableKey : undefined; + if (gracefulExecutableKey && !assetValidation.declared.has(gracefulExecutableKey)) { + errors.push(`lifecycleAction.${declaration.action}.gracefulStop.executableKey: ${gracefulExecutableKey} must be declared in manifest.assetFiles`); + } } if (typeof manifest === "object" && manifest !== null && "server" in manifest) { diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 49e7b0f..3adfa4f 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -161,18 +161,40 @@ describe("plugin manifest validation", () => { const assetPaths = manifest.assetFiles.map((file: { path: string }) => file.path); const installAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.install), "utf8")); const startAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.start), "utf8")); + const stopAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.stop), "utf8")); const installScript = fs.readFileSync(path.join(pluginDir, installAction.executableKey), "utf8"); const startScript = fs.readFileSync(path.join(pluginDir, startAction.executableKey), "utf8"); + const stopScript = fs.readFileSync(path.join(pluginDir, stopAction.gracefulStop.executableKey), "utf8"); + const rconScript = fs.readFileSync(path.join(pluginDir, "bin/scum-rcon.ps1"), "utf8"); expect(manifest.runtimeProfiles.serverDeployments).toBeUndefined(); expect(JSON.stringify(manifest.runtimeProfiles.installPlans ?? [])).not.toContain("steamcmd-app"); - expect(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd", "assets/map/scum-map-overview.jpg"])); - expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 +quit" } }); + expect(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "actions/stop.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd", "bin/scum-stop.cmd", "bin/scum-rcon.ps1", "assets/map/scum-map-overview.jpg"])); + expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 validate +quit" } }); expect(installAction.timeoutMs).toBe(7200000); expect(startAction).toMatchObject({ executableKey: "bin/scum-start.cmd", targetExecutableKey: "SCUM/Binaries/Win64/SCUMServer.exe", outputMode: "pipes", environment: { SERVER_LOG_FLAG: "-log -stdout -FullStdOutLogOutput" } }); - expect(installScript).not.toContain("taskkill /IM SCUMServer.exe /F"); - expect(installScript).toContain("call :stop_matching_scum"); - expect(installScript).toContain("Get-CimInstance Win32_Process"); - expect(installScript).toContain("[IO.Path]::GetFullPath($_.ExecutablePath) -ieq $target"); + expect(installScript).not.toContain("taskkill"); + expect(installScript).toContain("call :stop_running_scum"); + expect(installScript).toContain("scum-stop.cmd"); + expect(installScript).toContain("SCUM server did not shut down gracefully; refusing to update server files."); + expect(stopAction).toMatchObject({ + mode: "control", + environment: { SERVER_STOP_SHUTDOWN_COMMAND: "Quit", SERVER_STOP_NOTICE_SECONDS: "15", SERVER_STOP_TIMEOUT_SECONDS: "120" }, + gracefulStop: { executableKey: "bin/scum-stop.cmd", fallback: "terminate", timeoutMs: 300000 } + }); + expect(stopScript).not.toContain("taskkill"); + expect(stopScript).toContain("Get-CimInstance Win32_Process"); + expect(stopScript).toContain("[IO.Path]::GetFullPath($_.ExecutablePath) -ieq $full"); + expect(stopScript).toContain("scum-rcon.ps1"); + expect(stopScript).toContain("ue4ss\\Mods\\scum_simple_rcon\\config.ini"); + expect(stopScript).toContain('call :send_rcon "Announce %SERVER_STOP_ANNOUNCE%"'); + expect(stopScript).toContain('call :send_rcon "%SERVER_STOP_SHUTDOWN_COMMAND%"'); + expect(stopScript).toContain("exit /b 2"); + expect(rconScript).toContain("Read-RconConfig"); + expect(rconScript).toContain('$config["rcon.password"]'); + expect(rconScript).toContain('$config["rcon.port"]'); + expect(rconScript).toContain("Send-RconPacket"); + expect(rconScript).toContain("Receive-RconPacket"); + expect(stopScript).toContain('set "SCUM_RCON_PORT_ARG=-Port %SERVER_STOP_RCON_PORT%"'); expect(installScript).toContain("SERVER_ROOT_WINDOWS=%SERVER_ROOT:/=\\%"); expect(installScript).toContain("SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%"); expect(installScript).toContain("SERVER_STEAMCMD_DIR=%SERVER_ROOT_DRIVE%\\steamcmd"); @@ -195,6 +217,16 @@ describe("plugin manifest validation", () => { expect(startScript).toContain("-port=%SERVER_CREATE_GAMEPORT% -QueryPort=%SERVER_CREATE_QUERYPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG%"); }); + it("declares a plugin-owned SCUM build probe that the update button can check", () => { + const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { + runtimeProfiles: { dependencyProbes: Array<{ key: string; kind: string; targetKey: string; required?: boolean; steamAppId?: string; platforms?: string[] }> }; + }; + const probe = manifest.runtimeProfiles.dependencyProbes.find((candidate) => candidate.key === "scum-server-build"); + expect(probe).toMatchObject({ kind: "steam.update", targetKey: "steamcmd", steamAppId: "3792580", required: false, platforms: ["windows"] }); + expect(manifest.runtimeProfiles.dependencyProbes.filter((candidate) => candidate.kind === "steam.update")).toEqual([probe]); + }); + it("rejects lifecycle executable assets missing from the manifest seed declaration", () => { const errors = validateTemporaryScumManifest((manifest) => { manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "bin/scum-start.cmd"); @@ -202,6 +234,18 @@ describe("plugin manifest validation", () => { expect(errors.some((error) => error.includes("lifecycleAction.start.executableKey") && error.includes("manifest.assetFiles"))).toBe(true); }); + it("rejects graceful stop scripts missing from the manifest seed declaration", () => { + const missingAsset = validateTemporaryScumManifest((manifest) => { + manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "bin/scum-stop.cmd"); + }); + expect(missingAsset.some((error) => error.includes("lifecycleAction.stop.gracefulStop.executableKey") && error.includes("manifest.assetFiles"))).toBe(true); + + const wrongAction = validateTemporaryScumManifest((manifest) => { + manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "actions/install.json"); + }); + expect(wrongAction.some((error) => error.includes("lifecycleAction.install: action file must be declared"))).toBe(true); + }); + it("rejects unsupported or unsafe inline create-field declarations", () => { const malformed = validateTemporaryScumManifest((manifest) => { manifest.server.createFields[0].type = "path"; @@ -269,7 +313,7 @@ describe("plugin manifest validation", () => { const serialized = JSON.stringify(manifest).toLowerCase(); expect(serialized).not.toContain("local-proof"); - expect(manifest.version).toBe("0.1.15"); + expect(manifest.version).toBe("0.1.16"); expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server"); expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"])); expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([