Add graceful SCUM stop, restart, and version update flow

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

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

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

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

platform_web:
- 服务器详情新增“重启”按钮和“SCUM 版本更新”面板;点“检查更新”查询公开分支版本,
  只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。
This commit is contained in:
npc0-hue
2026-09-15 13:30:15 +08:00
parent 4ea27bda6a
commit 05f5a97ba9
39 changed files with 1197 additions and 56 deletions
+2
View File
@@ -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)
+3 -1
View File
@@ -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
+62
View File
@@ -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.
+2
View File
@@ -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
+1
View File
@@ -411,6 +411,7 @@ type RuntimeDependencyProbe struct {
TargetKey string
Required bool
MinimumVersion string
SteamAppID string
Platforms []string
}
+9 -5
View File
@@ -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:
+2 -1
View File
@@ -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 {
+3 -2
View File
@@ -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}
+26 -3
View File
@@ -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 `<restart|update 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_<appId>.acf` under the instance's declared server install root, falling back to the SteamCMD directory. The probe reports bounded evidence of the form `installed=<build> latest=<build> update=yes|no` (or `update=unknown`) so the management console can light up an update action without embedding game policy in the platform.
+1 -1
View File
@@ -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) {
@@ -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)
}
}
@@ -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 {
+17 -1
View File
@@ -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)
+38 -2
View File
@@ -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:
@@ -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
+211
View File
@@ -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
}
+9 -1
View File
@@ -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 {
@@ -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)
}
}
+1 -1
View File
@@ -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)}}
+11 -1
View File
@@ -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 () => {
+14
View File
@@ -237,6 +237,20 @@ export class PlatformApiClient {
});
}
async restartServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/restart`, {
method: "POST",
body: request
});
}
async updateServerGame(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/update`, {
method: "POST",
body: request
});
}
async queryServerProcessStatus(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/process/status`, {
method: "POST",
+1 -1
View File
@@ -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.
@@ -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<DependencyProbeViewResponse | null>(null);
const [supported, setSupported] = useState(false);
const [busy, setBusy] = useState<"check" | "update" | null>(null);
const [note, setNote] = useState<string | null>(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 (
<article className="console-panel console-module server-game-update-panel" aria-label="game version update">
<div className="panel-header">
<h2>
<RefreshCw size={16} style={{ verticalAlign: "-2px" }} />
</h2>
{result && <ResultBadge status={result.status} label={result.label} />}
</div>
<p className="section-copy"> Steam SCUM SCUM </p>
<dl className="console-stat-strip">
<div>
<dt></dt>
<dd>{status?.installed ?? "未记录"}</dd>
</div>
<div>
<dt></dt>
<dd>{status?.latest ?? "未记录"}</dd>
</div>
<div>
<dt></dt>
<dd>{gameUpdateStatusLabel(status)}</dd>
</div>
</dl>
<p className="console-note">{note ?? gameUpdateCheckNote(status)}</p>
<div className="console-row-actions">
<button type="button" className="icon-command" disabled={!canManage || busy !== null} title={canManage ? "向 SteamCMD 查询最新 SCUM 版本号" : "当前账号没有管理权限"} onClick={() => void checkForUpdate()}>
<RefreshCw size={15} />
<span></span>
</button>
<button
type="button"
className={updateAvailable ? "primary-command" : "icon-command"}
disabled={!canManage || !updateAllowed || busy !== null}
title={updateTitle(canManage, updateAvailable, updateAllowed, instance.state)}
onClick={() => setConfirmUpdate(true)}
>
<Download size={15} />
<span></span>
</button>
</div>
<ConfirmDialog
open={confirmUpdate}
title="更新 SCUM 服务端"
description={`确认更新 ${instance.name}${instance.id})?平台会先让插件优雅关闭 SCUM 服务(RCON 通知玩家并等待进程退出),关闭失败则中止更新;SteamCMD 校验更新完成后会自动重新启动服务。`}
confirmLabel="关闭并更新"
busy={busy === "update"}
onCancel={() => setConfirmUpdate(false)}
onConfirm={() => {
setConfirmUpdate(false);
void submitUpdate();
}}
/>
</article>
);
}
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<JobResponse | null> {
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;
}
+1 -1
View File
@@ -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.
## 插件市场
@@ -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;
@@ -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";
}
+18 -2
View File
@@ -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)");
+33 -8
View File
@@ -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) {
<Square size={15} />
<span></span>
</button>
<button
type="button"
className="icon-command"
disabled={!canManageServers || !canRestartServer(instance.data.state) || operations.isPending(instance.data.id, "重启服务器")}
onClick={() => requestLifecycle(instance.data, "restart")}
>
<RotateCcw size={15} />
<span></span>
</button>
<span className={cx("status-pill", detailFreshness === "fresh" ? statusClass(instance.data.state) : "status-disabled")}>{detailStateText}</span>
</div>
</div>
@@ -249,6 +265,15 @@ export function ServerDetailPage(props: PageComponentProps) {
/>
)}
{section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "manage" && (
<ServerGameUpdatePanel
instance={instance.data}
session={session}
operations={operations}
canManage={canManageServers}
onChanged={() => void refreshOperationalState()}
/>
)}
{section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setTerminalOpen(false)} />
+2 -2
View File
@@ -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}`;
}
@@ -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
@@ -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
}
@@ -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"
@@ -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()
}
@@ -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
@@ -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": [
@@ -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 }
}
},
+22 -1
View File
@@ -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"] }
}
}
}
}
+14
View File
@@ -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) {
+51 -7
View File
@@ -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([