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:
@@ -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/workflows/create", h.serverInstanceCreateWorkflow)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/start", h.serverInstanceStart)
|
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}/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}/process/status", h.serverInstanceProcessStatus)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
|
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding)
|
mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding)
|
||||||
|
|||||||
@@ -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/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}/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}/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.
|
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.
|
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
|
||||||
- Live AI provider connectivity tests and remote model discovery.
|
- Live AI provider connectivity tests and remote model discovery.
|
||||||
- Production Run distribution signing/KMS, fleet rollout rings, and real AI-provider integration.
|
- 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
|
## Core Service Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,68 @@ func (h *coreHandlers) serverInstanceStop(w http.ResponseWriter, r *http.Request
|
|||||||
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
|
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
|
// serverInstanceProcessStatus godoc
|
||||||
// @Summary Query supervised server process status
|
// @Summary Query supervised server process status
|
||||||
// @Description Queues a typed process.status job for the selected server/profile.
|
// @Description Queues a typed process.status job for the selected server/profile.
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ type RunAutonomousDependencyProbe struct {
|
|||||||
TargetKey string `json:"targetKey"`
|
TargetKey string `json:"targetKey"`
|
||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
MinimumVersion string `json:"minimumVersion,omitempty"`
|
MinimumVersion string `json:"minimumVersion,omitempty"`
|
||||||
|
SteamAppID string `json:"steamAppId,omitempty"`
|
||||||
Platforms []string `json:"platforms,omitempty"`
|
Platforms []string `json:"platforms,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +259,7 @@ type DependencyExecutionInput struct {
|
|||||||
TargetOS string
|
TargetOS string
|
||||||
TargetArch string
|
TargetArch string
|
||||||
PlanDigest string
|
PlanDigest string
|
||||||
|
ServerRoot string
|
||||||
Probe RuntimeDependencyProbe
|
Probe RuntimeDependencyProbe
|
||||||
Plan RuntimeInstallPlan
|
Plan RuntimeInstallPlan
|
||||||
Bindings map[string]string
|
Bindings map[string]string
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ type RuntimeDependencyProbe struct {
|
|||||||
TargetKey string
|
TargetKey string
|
||||||
Required bool
|
Required bool
|
||||||
MinimumVersion string
|
MinimumVersion string
|
||||||
|
SteamAppID string
|
||||||
Platforms []string
|
Platforms []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const (
|
|||||||
ServerLifecycleActionCreate ServerLifecycleAction = "create"
|
ServerLifecycleActionCreate ServerLifecycleAction = "create"
|
||||||
ServerLifecycleActionStart ServerLifecycleAction = "start"
|
ServerLifecycleActionStart ServerLifecycleAction = "start"
|
||||||
ServerLifecycleActionStop ServerLifecycleAction = "stop"
|
ServerLifecycleActionStop ServerLifecycleAction = "stop"
|
||||||
|
ServerLifecycleActionRestart ServerLifecycleAction = "restart"
|
||||||
|
ServerLifecycleActionUpdate ServerLifecycleAction = "update"
|
||||||
ServerLifecycleActionStatus ServerLifecycleAction = "status"
|
ServerLifecycleActionStatus ServerLifecycleAction = "status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,8 +50,10 @@ func LifecycleCapabilityForAction(action ServerLifecycleAction) string {
|
|||||||
return LifecycleCapabilityInstall
|
return LifecycleCapabilityInstall
|
||||||
case ServerLifecycleActionStart:
|
case ServerLifecycleActionStart:
|
||||||
return LifecycleCapabilityStart
|
return LifecycleCapabilityStart
|
||||||
case ServerLifecycleActionStop:
|
case ServerLifecycleActionStop, ServerLifecycleActionRestart:
|
||||||
return LifecycleCapabilityStop
|
return LifecycleCapabilityStop
|
||||||
|
case ServerLifecycleActionUpdate:
|
||||||
|
return LifecycleCapabilityInstall
|
||||||
case ServerLifecycleActionStatus:
|
case ServerLifecycleActionStatus:
|
||||||
return LifecycleCapabilityStatus
|
return LifecycleCapabilityStatus
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ type DependencyExecutionInputResponse struct {
|
|||||||
TargetOS string `json:"targetOs"`
|
TargetOS string `json:"targetOs"`
|
||||||
TargetArch string `json:"targetArch"`
|
TargetArch string `json:"targetArch"`
|
||||||
PlanDigest string `json:"planDigest"`
|
PlanDigest string `json:"planDigest"`
|
||||||
|
ServerRoot string `json:"serverRoot,omitempty"`
|
||||||
Probe RuntimeDependencyProbeBody `json:"probe,omitempty"`
|
Probe RuntimeDependencyProbeBody `json:"probe,omitempty"`
|
||||||
Plan RuntimeInstallPlanBody `json:"plan,omitempty"`
|
Plan RuntimeInstallPlanBody `json:"plan,omitempty"`
|
||||||
Bindings map[string]string `json:"bindings"`
|
Bindings map[string]string `json:"bindings"`
|
||||||
@@ -579,7 +580,7 @@ func DependencyExecutionInputFromDomain(input domain.DependencyExecutionInput) D
|
|||||||
for i, step := range input.Plan.Steps {
|
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}
|
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 {
|
func SourceRCONExecutionInputFromDomain(input domain.SourceRCONExecutionInput) SourceRCONExecutionInputResponse {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type RuntimeDependencyProbeBody struct {
|
|||||||
TargetKey string `json:"targetKey"`
|
TargetKey string `json:"targetKey"`
|
||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
MinimumVersion string `json:"minimumVersion,omitempty"`
|
MinimumVersion string `json:"minimumVersion,omitempty"`
|
||||||
|
SteamAppID string `json:"steamAppId,omitempty"`
|
||||||
Platforms []string `json:"platforms,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)})
|
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 {
|
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 {
|
for _, item := range body.InstallPlans {
|
||||||
plan := domain.RuntimeInstallPlan{Key: item.Key, Title: item.Title, Platforms: domain.CopyStringSlice(item.Platforms)}
|
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})
|
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 {
|
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 {
|
for _, item := range profiles.InstallPlans {
|
||||||
plan := RuntimeInstallPlanBody{Key: item.Key, Title: item.Title, Platforms: item.Platforms}
|
plan := RuntimeInstallPlanBody{Key: item.Key, Title: item.Title, Platforms: item.Platforms}
|
||||||
|
|||||||
@@ -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.
|
- `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.
|
- `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.
|
- `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`: dispatch stop/start or plugin-defined restart job.
|
- `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`: dispatch server update job and record version/result.
|
- `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.
|
- `delete`: stop server when needed, preserve or remove artifacts according to policy, mark deleted.
|
||||||
|
|
||||||
## Implemented Workflow Routes
|
## 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/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}/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}/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.
|
- 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.
|
- `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.
|
- `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`.
|
- `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.
|
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.
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ func (svc *CoreService) GetDependencyExecutionInput(request domain.DependencyExe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DependencyExecutionInput{}, err
|
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) {
|
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},
|
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 {
|
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 {
|
func autonomousInstallPlan(plan domain.RuntimeInstallPlan) domain.RunAutonomousInstallPlan {
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ type Core interface {
|
|||||||
StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
||||||
StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
||||||
StopServerInstanceForSession(string, 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)
|
QueryServerInstanceProcessForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
||||||
GetServerInstance(string) (domain.ServerInstance, error)
|
GetServerInstance(string) (domain.ServerInstance, error)
|
||||||
GetServerInstanceForSession(string, 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
|
return base
|
||||||
}
|
}
|
||||||
lifecycleAction := domain.ServerLifecycleAction(strings.TrimSpace(payload["lifecycleAction"]))
|
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"])
|
expectedVersion, _ := strconv.Atoi(payload["expectedConfigVersion"])
|
||||||
command := domain.ServerLifecycleCommand{
|
command := domain.ServerLifecycleCommand{
|
||||||
ServerInstanceID: instance.ID,
|
ServerInstanceID: instance.ID,
|
||||||
@@ -1218,6 +1220,20 @@ func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.P
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
result, err = svc.StopServerInstanceForSession(sessionID, command)
|
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 {
|
if err != nil {
|
||||||
return bridgeExecutionError(base, err)
|
return bridgeExecutionError(base, err)
|
||||||
|
|||||||
@@ -212,6 +212,34 @@ func (svc *CoreService) StopServerInstanceForSession(sessionID string, command d
|
|||||||
return svc.StopServerInstance(command)
|
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) {
|
func (svc *CoreService) QueryServerInstanceProcessForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
|
||||||
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
|
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
|
||||||
return domain.ServerLifecycleResult{}, err
|
return domain.ServerLifecycleResult{}, err
|
||||||
@@ -418,6 +446,10 @@ func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string {
|
|||||||
return "start"
|
return "start"
|
||||||
case domain.ServerLifecycleActionStop:
|
case domain.ServerLifecycleActionStop:
|
||||||
return "stop"
|
return "stop"
|
||||||
|
case domain.ServerLifecycleActionRestart:
|
||||||
|
return "restart"
|
||||||
|
case domain.ServerLifecycleActionUpdate:
|
||||||
|
return "update"
|
||||||
case domain.ServerLifecycleActionStatus:
|
case domain.ServerLifecycleActionStatus:
|
||||||
return "status"
|
return "status"
|
||||||
default:
|
default:
|
||||||
@@ -431,8 +463,10 @@ func runtimeProfileActionRef(actions domain.PluginLifecycleActions, action domai
|
|||||||
return actions.Install
|
return actions.Install
|
||||||
case domain.ServerLifecycleActionStart:
|
case domain.ServerLifecycleActionStart:
|
||||||
return actions.Start
|
return actions.Start
|
||||||
case domain.ServerLifecycleActionStop:
|
case domain.ServerLifecycleActionStop, domain.ServerLifecycleActionRestart:
|
||||||
return actions.Stop
|
return actions.Stop
|
||||||
|
case domain.ServerLifecycleActionUpdate:
|
||||||
|
return actions.Install
|
||||||
case domain.ServerLifecycleActionStatus:
|
case domain.ServerLifecycleActionStatus:
|
||||||
return actions.Status
|
return actions.Status
|
||||||
default:
|
default:
|
||||||
@@ -502,8 +536,10 @@ func lifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleA
|
|||||||
return plugin.LifecycleActions.Install
|
return plugin.LifecycleActions.Install
|
||||||
case domain.ServerLifecycleActionStart:
|
case domain.ServerLifecycleActionStart:
|
||||||
return plugin.LifecycleActions.Start
|
return plugin.LifecycleActions.Start
|
||||||
case domain.ServerLifecycleActionStop:
|
case domain.ServerLifecycleActionStop, domain.ServerLifecycleActionRestart:
|
||||||
return plugin.LifecycleActions.Stop
|
return plugin.LifecycleActions.Stop
|
||||||
|
case domain.ServerLifecycleActionUpdate:
|
||||||
|
return plugin.LifecycleActions.Install
|
||||||
case domain.ServerLifecycleActionStatus:
|
case domain.ServerLifecycleActionStatus:
|
||||||
return plugin.LifecycleActions.Status
|
return plugin.LifecycleActions.Status
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -113,9 +114,39 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
svc.publishLogProcessState(instance)
|
svc.publishLogProcessState(instance)
|
||||||
|
svc.dispatchFollowUpLifecycle(job, instance)
|
||||||
return nil
|
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 {
|
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
|
||||||
if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" {
|
if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -534,3 +534,214 @@ func claimAndCompleteLifecycleJobForServer(t *testing.T, svc *CoreService, sessi
|
|||||||
t.Fatalf("complete lifecycle job %s: %v", capability, err)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
|
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
|
||||||
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
|
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
|
||||||
|
steamAppIDPattern = regexp.MustCompile(`^[0-9]{1,10}$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
|
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, validateProfileKey(prefix+".key", probe.Key)...)
|
||||||
violations = append(violations, recordRuntimeProfileKey(dependencyKeys, prefix+".key", probe.Key)...)
|
violations = append(violations, recordRuntimeProfileKey(dependencyKeys, prefix+".key", probe.Key)...)
|
||||||
violations = append(violations, validateProfileKey(prefix+".targetKey", probe.TargetKey)...)
|
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, prefix+".kind is invalid")
|
||||||
}
|
}
|
||||||
violations = append(violations, validateSafeRuntimeValue(prefix+".minimumVersion", probe.MinimumVersion)...)
|
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)...)
|
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", probe.Platforms)...)
|
||||||
}
|
}
|
||||||
for i, plan := range profiles.InstallPlans {
|
for i, plan := range profiles.InstallPlans {
|
||||||
|
|||||||
@@ -24,3 +24,20 @@ func TestValidateGamePluginRuntimeProfilesRejectsManualStepExecutionFields(t *te
|
|||||||
t.Fatalf("expected manual execution field rejection, got %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func ValidateServerLifecycleCommand(command domain.ServerLifecycleCommand) error
|
|||||||
|
|
||||||
func ValidateServerLifecycleAction(action domain.ServerLifecycleAction) error {
|
func ValidateServerLifecycleAction(action domain.ServerLifecycleAction) error {
|
||||||
switch action {
|
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
|
return nil
|
||||||
default:
|
default:
|
||||||
return ValidationError{Violations: []string{fmt.Sprintf("action %q is invalid", action)}}
|
return ValidationError{Violations: []string{fmt.Sprintf("action %q is invalid", action)}}
|
||||||
|
|||||||
@@ -385,6 +385,14 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
if (url.endsWith("/api/v1/server-instances/server-1/stop") && init?.method === "POST") {
|
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" } });
|
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") {
|
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" } } });
|
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.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.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.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.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 });
|
||||||
await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] });
|
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" })
|
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" } });
|
).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 () => {
|
it("normalizes server file workspace null arrays from older platform responses", async () => {
|
||||||
|
|||||||
@@ -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> {
|
async queryServerProcessStatus(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
|
||||||
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/process/status`, {
|
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/process/status`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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.
|
- `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.
|
- `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.
|
- `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.
|
- 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.
|
- `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;
|
||||||
|
}
|
||||||
@@ -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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { canStartServer, canStopServer, runtimeObservationFreshness } from "./serverManagement";
|
import { canRestartServer, canStartServer, canStopServer, canUpdateServerGame, gameUpdateStatusFromProbe, gameUpdateStatusLabel, steamUpdateProbe, runtimeObservationFreshness } from "./serverManagement";
|
||||||
import type { RunEndpointResponse, ServerInstanceResponse, ServerInstanceState } from "../api/types";
|
import type { DependencyCatalogResponse, DependencyProbeViewResponse, RunEndpointResponse, ServerInstanceResponse, ServerInstanceState } from "../api/types";
|
||||||
|
|
||||||
describe("server management lifecycle contracts", () => {
|
describe("server management lifecycle contracts", () => {
|
||||||
it("allows explicit starts from recoverable non-running states", () => {
|
it("allows explicit starts from recoverable non-running states", () => {
|
||||||
@@ -17,6 +17,41 @@ describe("server management lifecycle contracts", () => {
|
|||||||
expect(canStopServer("failed")).toBe(false);
|
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", () => {
|
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 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;
|
const endpoint = { id: "run-1", status: "online", lastHeartbeatAt: "2026-08-07T10:00:00Z" } as RunEndpointResponse;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
DependencyCatalogResponse,
|
||||||
|
DependencyProbeViewResponse,
|
||||||
GamePluginResponse,
|
GamePluginResponse,
|
||||||
JobResponse,
|
JobResponse,
|
||||||
RunEndpointResponse,
|
RunEndpointResponse,
|
||||||
@@ -120,6 +122,61 @@ export function canStopServer(state: ServerInstanceState): boolean {
|
|||||||
return state === "running";
|
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 {
|
export function isPendingJobState(state: JobResponse["state"]): boolean {
|
||||||
return state === "queued" || state === "accepted" || state === "running" || state === "retrying";
|
return state === "queued" || state === "accepted" || state === "running" || state === "retrying";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { configDiffViewFromPreview } from "./ServerDetailPage";
|
|||||||
import serversPageSource from "./ServersPage.tsx?raw";
|
import serversPageSource from "./ServersPage.tsx?raw";
|
||||||
import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw";
|
import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw";
|
||||||
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
||||||
|
import serverGameUpdatePanelSource from "../components/ServerGameUpdatePanel.tsx?raw";
|
||||||
import type { ServerConfigDiffPreviewResponse } from "../api/types";
|
import type { ServerConfigDiffPreviewResponse } from "../api/types";
|
||||||
|
|
||||||
const preview: ServerConfigDiffPreviewResponse = {
|
const preview: ServerConfigDiffPreviewResponse = {
|
||||||
@@ -170,12 +171,27 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
expect(serverDetailPageSource).not.toContain("插件控制");
|
expect(serverDetailPageSource).not.toContain("插件控制");
|
||||||
expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(current.id");
|
expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(current.id");
|
||||||
expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(current.id");
|
expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(current.id");
|
||||||
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, \"start\")");
|
expect(serverDetailPageSource).toContain("platformApiClient.restartServerInstance(current.id");
|
||||||
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, \"stop\")");
|
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, action)");
|
||||||
expect(serverDetailPageSource).not.toContain('capability: "process.start"');
|
expect(serverDetailPageSource).not.toContain('capability: "process.start"');
|
||||||
expect(serverDetailPageSource).not.toContain('capability: "process.stop"');
|
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", () => {
|
it("leaves guided deployment to the generated Run heartbeat workflow", () => {
|
||||||
expect(serverDetailPageSource).not.toContain("canDeployServer(instance.data.state)");
|
expect(serverDetailPageSource).not.toContain("canDeployServer(instance.data.state)");
|
||||||
expect(serverDetailPageSource).not.toContain("requestDeployment(instance.data)");
|
expect(serverDetailPageSource).not.toContain("requestDeployment(instance.data)");
|
||||||
|
|||||||
@@ -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 { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
@@ -19,9 +19,10 @@ import type {
|
|||||||
import { ConfirmDialog } from "../components/OperationControls";
|
import { ConfirmDialog } from "../components/OperationControls";
|
||||||
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
|
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
|
||||||
import { ServerConfigEditor } from "../components/ServerConfigEditor";
|
import { ServerConfigEditor } from "../components/ServerConfigEditor";
|
||||||
|
import { ServerGameUpdatePanel } from "../components/ServerGameUpdatePanel";
|
||||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
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 {
|
import {
|
||||||
serverDetailSections,
|
serverDetailSections,
|
||||||
type ConfigDiffView,
|
type ConfigDiffView,
|
||||||
@@ -129,26 +130,32 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
setSection(`plugin:${defaultPluginPage.key}`);
|
setSection(`plugin:${defaultPluginPage.key}`);
|
||||||
}, [defaultPluginPage, params.routeKey, section]);
|
}, [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({
|
setConfirm({
|
||||||
title: action === "start" ? "启动服务器" : "停止服务器",
|
title: action === "start" ? "启动服务器" : action === "stop" ? "停止服务器" : "重启服务器",
|
||||||
description:
|
description:
|
||||||
action === "start"
|
action === "start"
|
||||||
? `确认启动服务器 ${current.name}(${current.id})?`
|
? `确认启动服务器 ${current.name}(${current.id})?`
|
||||||
: `停止服务器 ${current.name}(${current.id})会断开所有在线玩家,确认继续?`,
|
: action === "stop"
|
||||||
|
? `停止服务器 ${current.name}(${current.id})会断开所有在线玩家,确认继续?`
|
||||||
|
: `重启服务器 ${current.name}(${current.id})会先按插件声明优雅关闭 SCUM 服务,停止成功后自动重新启动;在线玩家会被断开。`,
|
||||||
danger: action === "stop",
|
danger: action === "stop",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const operationId = operations.begin({
|
const operationId = operations.begin({
|
||||||
intent: action === "start" ? "启动服务器" : "停止服务器",
|
intent,
|
||||||
targetKind: "server",
|
targetKind: "server",
|
||||||
targetId: current.id,
|
targetId: current.id,
|
||||||
requester: session.displayName
|
requester: session.displayName
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
const request = serverLifecycleCommandRequest(current, action);
|
||||||
const result =
|
const result =
|
||||||
action === "start"
|
action === "start"
|
||||||
? await platformApiClient.startServerInstance(current.id, serverLifecycleCommandRequest(current, "start"))
|
? await platformApiClient.startServerInstance(current.id, request)
|
||||||
: await platformApiClient.stopServerInstance(current.id, serverLifecycleCommandRequest(current, "stop"));
|
: 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);
|
operations.succeed(operationId, `任务 ${result.job.id}(${result.job.capability})已派发`, result.job);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -231,6 +238,15 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
<Square size={15} />
|
<Square size={15} />
|
||||||
<span>停止</span>
|
<span>停止</span>
|
||||||
</button>
|
</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>
|
<span className={cx("status-pill", detailFreshness === "fresh" ? statusClass(instance.data.state) : "status-disabled")}>{detailStateText}</span>
|
||||||
</div>
|
</div>
|
||||||
</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" && <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 === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
|
||||||
{section === "llm" && <LlmSection serverId={serverId} 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)} />
|
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setTerminalOpen(false)} />
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function serverInstanceIdFromName(name: string, sequence = Date.now()): s
|
|||||||
return normalized ? `server-${normalized}-${suffix}` : `server-${suffix}`;
|
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 {
|
return {
|
||||||
expectedConfigVersion: instance.configVersion,
|
expectedConfigVersion: instance.configVersion,
|
||||||
idempotencyKey: lifecycleIdempotencyKey(action, instance.id, sequence)
|
idempotencyKey: lifecycleIdempotencyKey(action, instance.id, sequence)
|
||||||
@@ -113,6 +113,6 @@ export function runtimeIdempotencyKey(action: string, serverInstanceId: string,
|
|||||||
return `web:${action}:${serverInstanceId}:${sequence}`;
|
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}`;
|
return `web:${action}:${serverInstanceId}:${sequence}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"SERVER_TEMPLATE": "scum-server",
|
"SERVER_TEMPLATE": "scum-server",
|
||||||
"SERVER_STEAM_APP_ID": "3792580",
|
"SERVER_STEAM_APP_ID": "3792580",
|
||||||
"SERVER_STEAMCMD_INSTALL_DIR_ARG": "+force_install_dir",
|
"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"
|
"SERVER_EXECUTABLE_REF": "SCUM/Binaries/Win64/SCUMServer.exe"
|
||||||
},
|
},
|
||||||
"timeoutMs": 7200000
|
"timeoutMs": 7200000
|
||||||
|
|||||||
@@ -2,9 +2,22 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"action": "stop",
|
"action": "stop",
|
||||||
"mode": "control",
|
"mode": "control",
|
||||||
|
"environment": {
|
||||||
|
"GAME_ID": "scum",
|
||||||
|
"SERVER_ACTION": "stop",
|
||||||
|
"SERVER_STOP_SHUTDOWN_COMMAND": "Quit",
|
||||||
|
"SERVER_STOP_NOTICE_SECONDS": "15",
|
||||||
|
"SERVER_STOP_TIMEOUT_SECONDS": "120"
|
||||||
|
},
|
||||||
|
"gracefulStop": {
|
||||||
|
"executableKey": "bin/scum-stop.cmd",
|
||||||
|
"arguments": [],
|
||||||
"environment": {
|
"environment": {
|
||||||
"GAME_ID": "scum",
|
"GAME_ID": "scum",
|
||||||
"SERVER_ACTION": "stop"
|
"SERVER_ACTION": "stop"
|
||||||
},
|
},
|
||||||
"stopTimeoutMs": 30000
|
"timeoutMs": 300000,
|
||||||
|
"fallback": "terminate"
|
||||||
|
},
|
||||||
|
"stopTimeoutMs": 60000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ if "%SERVER_ROOT%"=="" set "SERVER_ROOT=C:\scumserver"
|
|||||||
set "SERVER_ROOT_WINDOWS=%SERVER_ROOT:/=\%"
|
set "SERVER_ROOT_WINDOWS=%SERVER_ROOT:/=\%"
|
||||||
if "%SERVER_STEAM_APP_ID%"=="" set "SERVER_STEAM_APP_ID=3792580"
|
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_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_EXECUTABLE_REF%"=="" set "SERVER_EXECUTABLE_REF=SCUM\Binaries\Win64\SCUMServer.exe"
|
||||||
if "%SERVER_INSTALL_DIR%"=="" set "SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%"
|
if "%SERVER_INSTALL_DIR%"=="" set "SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%"
|
||||||
set "SERVER_INSTALL_DIR_WINDOWS=%SERVER_INSTALL_DIR:/=\%"
|
set "SERVER_INSTALL_DIR_WINDOWS=%SERVER_INSTALL_DIR:/=\%"
|
||||||
@@ -29,8 +29,7 @@ if not exist "%STEAMCMD_EXE%" (
|
|||||||
if errorlevel 1 exit /b 1
|
if errorlevel 1 exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
call :resolve_scum_exe
|
call :stop_running_scum
|
||||||
if exist "%SCUM_EXE%" call :stop_matching_scum
|
|
||||||
if errorlevel 1 exit /b 1
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
pushd "%STEAMCMD_DIR%"
|
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:/=\%"
|
set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%"
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
:stop_matching_scum
|
:stop_running_scum
|
||||||
if "%SCUM_EXE%"=="" exit /b 0
|
rem SteamCMD must never replace server files underneath a running process.
|
||||||
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 }"
|
rem The plugin-owned stop script asks the running SCUM server to shut down and
|
||||||
exit /b %ERRORLEVEL%
|
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
|
:default_steamcmd_dir
|
||||||
for %%I in ("%SERVER_ROOT_WINDOWS%") do set "SERVER_ROOT_DRIVE=%%~dI"
|
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",
|
"id": "game.scum",
|
||||||
"name": "SCUM Server",
|
"name": "SCUM Server",
|
||||||
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and plugin-owned RCON data flows.",
|
"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",
|
"kind": "game-plugin",
|
||||||
"tags": [
|
"tags": [
|
||||||
"scum",
|
"scum",
|
||||||
@@ -778,6 +778,14 @@
|
|||||||
"path": "bin/scum-start.cmd",
|
"path": "bin/scum-start.cmd",
|
||||||
"mode": 448
|
"mode": 448
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "bin/scum-stop.cmd",
|
||||||
|
"mode": 448
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "bin/scum-rcon.ps1",
|
||||||
|
"mode": 384
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "assets/map/scum-map-overview.jpg",
|
"path": "assets/map/scum-map-overview.jpg",
|
||||||
"mode": 384
|
"mode": 384
|
||||||
@@ -1037,6 +1045,16 @@
|
|||||||
"windows",
|
"windows",
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-server-build",
|
||||||
|
"kind": "steam.update",
|
||||||
|
"targetKey": "steamcmd",
|
||||||
|
"steamAppId": "3792580",
|
||||||
|
"required": false,
|
||||||
|
"platforms": [
|
||||||
|
"windows"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"installPlans": [
|
"installPlans": [
|
||||||
|
|||||||
@@ -539,10 +539,11 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"key": { "$ref": "#/$defs/logicalKey" },
|
"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" },
|
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||||
"required": { "type": "boolean" },
|
"required": { "type": "boolean" },
|
||||||
"minimumVersion": { "type": "string", "maxLength": 80 },
|
"minimumVersion": { "type": "string", "maxLength": 80 },
|
||||||
|
"steamAppId": { "type": "string", "pattern": "^[0-9]{1,10}$" },
|
||||||
"platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true }
|
"platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,27 @@
|
|||||||
},
|
},
|
||||||
"outputMode": { "enum": ["pipes", "console"] },
|
"outputMode": { "enum": ["pipes", "console"] },
|
||||||
"timeoutMs": { "type": "integer", "minimum": 1, "maximum": 7200000 },
|
"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"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,15 @@ export function validateLifecycleActionFile(actionPath: string, expectedAction?:
|
|||||||
if ((declaration.action === "stop" || declaration.action === "status") && declaration.mode !== "control") {
|
if ((declaration.action === "stop" || declaration.action === "status") && declaration.mode !== "control") {
|
||||||
errors.push(`lifecycleAction.mode: ${declaration.action} must be 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;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,6 +763,11 @@ export function validateManifestFile(manifestPath: string): string[] {
|
|||||||
if (typeof executableKey === "string" && !assetValidation.declared.has(executableKey)) {
|
if (typeof executableKey === "string" && !assetValidation.declared.has(executableKey)) {
|
||||||
errors.push(`lifecycleAction.${declaration.action}.executableKey: ${executableKey} must be declared in manifest.assetFiles`);
|
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) {
|
if (typeof manifest === "object" && manifest !== null && "server" in manifest) {
|
||||||
|
|||||||
@@ -161,18 +161,40 @@ describe("plugin manifest validation", () => {
|
|||||||
const assetPaths = manifest.assetFiles.map((file: { path: string }) => file.path);
|
const assetPaths = manifest.assetFiles.map((file: { path: string }) => file.path);
|
||||||
const installAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.install), "utf8"));
|
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 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 installScript = fs.readFileSync(path.join(pluginDir, installAction.executableKey), "utf8");
|
||||||
const startScript = fs.readFileSync(path.join(pluginDir, startAction.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(manifest.runtimeProfiles.serverDeployments).toBeUndefined();
|
||||||
expect(JSON.stringify(manifest.runtimeProfiles.installPlans ?? [])).not.toContain("steamcmd-app");
|
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(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 +quit" } });
|
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(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(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).not.toContain("taskkill");
|
||||||
expect(installScript).toContain("call :stop_matching_scum");
|
expect(installScript).toContain("call :stop_running_scum");
|
||||||
expect(installScript).toContain("Get-CimInstance Win32_Process");
|
expect(installScript).toContain("scum-stop.cmd");
|
||||||
expect(installScript).toContain("[IO.Path]::GetFullPath($_.ExecutablePath) -ieq $target");
|
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_ROOT_WINDOWS=%SERVER_ROOT:/=\\%");
|
||||||
expect(installScript).toContain("SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%");
|
expect(installScript).toContain("SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%");
|
||||||
expect(installScript).toContain("SERVER_STEAMCMD_DIR=%SERVER_ROOT_DRIVE%\\steamcmd");
|
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%");
|
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", () => {
|
it("rejects lifecycle executable assets missing from the manifest seed declaration", () => {
|
||||||
const errors = validateTemporaryScumManifest((manifest) => {
|
const errors = validateTemporaryScumManifest((manifest) => {
|
||||||
manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "bin/scum-start.cmd");
|
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);
|
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", () => {
|
it("rejects unsupported or unsafe inline create-field declarations", () => {
|
||||||
const malformed = validateTemporaryScumManifest((manifest) => {
|
const malformed = validateTemporaryScumManifest((manifest) => {
|
||||||
manifest.server.createFields[0].type = "path";
|
manifest.server.createFields[0].type = "path";
|
||||||
@@ -269,7 +313,7 @@ describe("plugin manifest validation", () => {
|
|||||||
const serialized = JSON.stringify(manifest).toLowerCase();
|
const serialized = JSON.stringify(manifest).toLowerCase();
|
||||||
|
|
||||||
expect(serialized).not.toContain("local-proof");
|
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(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.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([
|
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
|
||||||
|
|||||||
Reference in New Issue
Block a user