Add graceful SCUM stop, restart, and version update flow

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

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

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

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

platform_web:
- 服务器详情新增“重启”按钮和“SCUM 版本更新”面板;点“检查更新”查询公开分支版本,
  只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。
This commit is contained in:
npc0-hue
2026-09-15 13:30:15 +08:00
parent 4ea27bda6a
commit 05f5a97ba9
39 changed files with 1197 additions and 56 deletions
+2
View File
@@ -63,6 +63,8 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/workflows/create", h.serverInstanceCreateWorkflow)
mux.HandleFunc("/api/v1/server-instances/{id}/start", h.serverInstanceStart)
mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop)
mux.HandleFunc("/api/v1/server-instances/{id}/restart", h.serverInstanceRestart)
mux.HandleFunc("/api/v1/server-instances/{id}/update", h.serverInstanceUpdate)
mux.HandleFunc("/api/v1/server-instances/{id}/process/status", h.serverInstanceProcessStatus)
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding)
+3 -1
View File
@@ -134,6 +134,8 @@ Artifact bridge execution returns safe metadata and platform content routes only
- `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. Creation starts from `pluginId` and `name`, and may include the create-wizard deployment definition such as deployment mode, plugin create inputs, server root, or custom start command. The browser does not submit a deployment target, Run endpoint, lifecycle profile, or Run identity binding. Platform applies plugin defaults, creates the definition without waiting for a Run, and attaches the active Run when its authenticated heartbeat arrives. Unknown legacy binding fields are rejected by the strict JSON decoder.
- `POST /api/v1/server-instances/{id}/start`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.start` job using `ServerLifecycleResponse`.
- `POST /api/v1/server-instances/{id}/stop`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.stop` job using `ServerLifecycleResponse`.
- `POST /api/v1/server-instances/{id}/restart`: accept `ServerLifecycleCommandRequest`, validate the instance is `running` or `stopped` with a matching config version and run capability, and queue the plugin-declared stop action using `ServerLifecycleResponse`. When that stop job succeeds, Platform queues the plugin-declared start action for the same instance.
- `POST /api/v1/server-instances/{id}/update`: accept `ServerLifecycleCommandRequest`, validate the instance is `running`, `stopped`, `ready`, or `failed` with a matching config version and `process.install` run capability, and queue the plugin-declared install/update action using `ServerLifecycleResponse`. The plugin install action owns the graceful close of a running server; Platform queues the plugin-declared start action after the update succeeds.
Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies.
@@ -251,7 +253,7 @@ These route groups remain documented future work beyond the currently implemente
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
- Live AI provider connectivity tests and remote model discovery.
- Production Run distribution signing/KMS, fleet rollout rings, and real AI-provider integration.
- Server restart/delete routes beyond the currently implemented lifecycle, metadata update, and archive actions.
- Server delete/archive routes beyond the currently implemented lifecycle, metadata update, and archive actions.
## Core Service Boundary
+62
View File
@@ -160,6 +160,68 @@ func (h *coreHandlers) serverInstanceStop(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
}
// serverInstanceRestart godoc
// @Summary Restart server instance
// @Description Validates lifecycle state and config version, queues the plugin-declared stop action, and queues the plugin-declared start action after the graceful stop result lands.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request"
// @Success 202 {object} dto.ServerLifecycleResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/restart [post]
func (h *coreHandlers) serverInstanceRestart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.RestartServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result))
}
// serverInstanceUpdate godoc
// @Summary Update server game files
// @Description Validates lifecycle state and config version, queues the plugin-declared install/update action, and queues the plugin-declared start action after the update result lands.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request"
// @Success 202 {object} dto.ServerLifecycleResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/update [post]
func (h *coreHandlers) serverInstanceUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.UpdateServerGameForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result))
}
// serverInstanceProcessStatus godoc
// @Summary Query supervised server process status
// @Description Queues a typed process.status job for the selected server/profile.
+2
View File
@@ -167,6 +167,7 @@ type RunAutonomousDependencyProbe struct {
TargetKey string `json:"targetKey"`
Required bool `json:"required,omitempty"`
MinimumVersion string `json:"minimumVersion,omitempty"`
SteamAppID string `json:"steamAppId,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
@@ -258,6 +259,7 @@ type DependencyExecutionInput struct {
TargetOS string
TargetArch string
PlanDigest string
ServerRoot string
Probe RuntimeDependencyProbe
Plan RuntimeInstallPlan
Bindings map[string]string
+1
View File
@@ -411,6 +411,7 @@ type RuntimeDependencyProbe struct {
TargetKey string
Required bool
MinimumVersion string
SteamAppID string
Platforms []string
}
+9 -5
View File
@@ -3,10 +3,12 @@ package domain
type ServerLifecycleAction string
const (
ServerLifecycleActionCreate ServerLifecycleAction = "create"
ServerLifecycleActionStart ServerLifecycleAction = "start"
ServerLifecycleActionStop ServerLifecycleAction = "stop"
ServerLifecycleActionStatus ServerLifecycleAction = "status"
ServerLifecycleActionCreate ServerLifecycleAction = "create"
ServerLifecycleActionStart ServerLifecycleAction = "start"
ServerLifecycleActionStop ServerLifecycleAction = "stop"
ServerLifecycleActionRestart ServerLifecycleAction = "restart"
ServerLifecycleActionUpdate ServerLifecycleAction = "update"
ServerLifecycleActionStatus ServerLifecycleAction = "status"
)
const (
@@ -48,8 +50,10 @@ func LifecycleCapabilityForAction(action ServerLifecycleAction) string {
return LifecycleCapabilityInstall
case ServerLifecycleActionStart:
return LifecycleCapabilityStart
case ServerLifecycleActionStop:
case ServerLifecycleActionStop, ServerLifecycleActionRestart:
return LifecycleCapabilityStop
case ServerLifecycleActionUpdate:
return LifecycleCapabilityInstall
case ServerLifecycleActionStatus:
return LifecycleCapabilityStatus
default:
+2 -1
View File
@@ -248,6 +248,7 @@ type DependencyExecutionInputResponse struct {
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
PlanDigest string `json:"planDigest"`
ServerRoot string `json:"serverRoot,omitempty"`
Probe RuntimeDependencyProbeBody `json:"probe,omitempty"`
Plan RuntimeInstallPlanBody `json:"plan,omitempty"`
Bindings map[string]string `json:"bindings"`
@@ -579,7 +580,7 @@ func DependencyExecutionInputFromDomain(input domain.DependencyExecutionInput) D
for i, step := range input.Plan.Steps {
steps[i] = RuntimeInstallStepBody{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadRef: step.DownloadRef, Checksum: step.Checksum}
}
return DependencyExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, PluginID: input.PluginID, PluginVersion: input.PluginVersion, ProfileKey: input.ProfileKey, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PlanDigest: input.PlanDigest, Probe: RuntimeDependencyProbeBody{Key: input.Probe.Key, Kind: input.Probe.Kind, TargetKey: input.Probe.TargetKey, Required: input.Probe.Required, MinimumVersion: input.Probe.MinimumVersion, Platforms: input.Probe.Platforms}, Plan: RuntimeInstallPlanBody{Key: input.Plan.Key, Title: input.Plan.Title, Platforms: input.Plan.Platforms, Steps: steps}, Bindings: input.Bindings}
return DependencyExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, PluginID: input.PluginID, PluginVersion: input.PluginVersion, ProfileKey: input.ProfileKey, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PlanDigest: input.PlanDigest, ServerRoot: input.ServerRoot, Probe: RuntimeDependencyProbeBody{Key: input.Probe.Key, Kind: input.Probe.Kind, TargetKey: input.Probe.TargetKey, Required: input.Probe.Required, MinimumVersion: input.Probe.MinimumVersion, SteamAppID: input.Probe.SteamAppID, Platforms: input.Probe.Platforms}, Plan: RuntimeInstallPlanBody{Key: input.Plan.Key, Title: input.Plan.Title, Platforms: input.Plan.Platforms, Steps: steps}, Bindings: input.Bindings}
}
func SourceRCONExecutionInputFromDomain(input domain.SourceRCONExecutionInput) SourceRCONExecutionInputResponse {
+3 -2
View File
@@ -40,6 +40,7 @@ type RuntimeDependencyProbeBody struct {
TargetKey string `json:"targetKey"`
Required bool `json:"required,omitempty"`
MinimumVersion string `json:"minimumVersion,omitempty"`
SteamAppID string `json:"steamAppId,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
@@ -243,7 +244,7 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), DLLExtensionRefs: domain.CopyStringSlice(item.DLLExtensionRefs), Platforms: domain.CopyStringSlice(item.Platforms)})
}
for _, item := range body.DependencyProbes {
profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: domain.CopyStringSlice(item.Platforms)})
profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, SteamAppID: item.SteamAppID, Platforms: domain.CopyStringSlice(item.Platforms)})
}
for _, item := range body.InstallPlans {
plan := domain.RuntimeInstallPlan{Key: item.Key, Title: item.Title, Platforms: domain.CopyStringSlice(item.Platforms)}
@@ -281,7 +282,7 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
body.LifecycleProfiles = append(body.LifecycleProfiles, RuntimeLifecycleProfileBody{Key: item.Key, Mode: item.Mode, Capabilities: item.Capabilities, ActionRefs: lifecycleActionsFromDomain(item.ActionRefs), TransportKeys: item.TransportKeys, DLLExtensionRefs: item.DLLExtensionRefs, Platforms: item.Platforms})
}
for _, item := range profiles.DependencyProbes {
body.DependencyProbes = append(body.DependencyProbes, RuntimeDependencyProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: item.Platforms})
body.DependencyProbes = append(body.DependencyProbes, RuntimeDependencyProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, SteamAppID: item.SteamAppID, Platforms: item.Platforms})
}
for _, item := range profiles.InstallPlans {
plan := RuntimeInstallPlanBody{Key: item.Key, Title: item.Title, Platforms: item.Platforms}
+26 -3
View File
@@ -38,9 +38,9 @@ A server instance is created from one installed game management plugin and is la
- `create`: validate plugin and create the instance record without requiring a run endpoint, deployment target, or runtime profile.
- `start`: record/authorize operator intent and route bounded control to the bound Run when applicable; generated Run startup is driven by its package-local autonomous lifecycle plan.
- `stop`: record/authorize operator intent and route bounded control to the bound Run when applicable.
- `restart`: dispatch stop/start or plugin-defined restart job.
- `update`: dispatch server update job and record version/result.
- `stop`: record/authorize operator intent and route bounded control to the bound Run when applicable. Run runs the plugin-declared graceful stop step first and escalates to bounded termination only when that action declares `fallback: "terminate"`.
- `restart`: queues the plugin-declared stop action for a `running` or `stopped` instance and queues the plugin-declared start action after the terminal stop job succeeds. A failed or cancelled stop leaves the instance failed and never starts the server.
- `update`: queues the plugin-declared install/update action for a `running`, `stopped`, `ready`, or `failed` instance and queues the plugin-declared start action after the update job succeeds. The plugin owns the graceful close inside its install action, so an update never replaces server files under a live process.
- `delete`: stop server when needed, preserve or remove artifacts according to policy, mark deleted.
## Implemented Workflow Routes
@@ -51,6 +51,8 @@ A server instance is created from one installed game management plugin and is la
- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, server name, idempotency key, and plugin-declared create inputs when provided. It creates the instance without requiring a deployment target, run endpoint, or runtime profile. Generated Run packages carry the autonomous lifecycle plan that Run consumes on startup; registration confirms binding/auth and does not enqueue bootstrap lifecycle jobs.
- `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job.
- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job.
- `POST /api/v1/server-instances/{id}/restart` validates the instance is `running` or `stopped`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues the plugin-declared stop job recorded as a `restart` lifecycle operation.
- `POST /api/v1/server-instances/{id}/update` validates the instance is `running`, `stopped`, `ready`, or `failed`, checks the expected config version, verifies the plugin install action and run endpoint `process.install` capability, and queues the plugin-declared install/update job recorded as an `update` lifecycle operation.
- Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses reviewable AI config-diff approvals and typed dispatch metadata without returning raw config text to plugin pages.
- `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks.
- `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user.
@@ -73,3 +75,24 @@ Platform-visible lifecycle state is a projection from Run-reported facts. Termin
- `process.install`, `process.start`, or `process.stop` + `failed` or `cancelled` marks the instance `failed`.
Active start and stop jobs do not introduce separate `starting` or `stopping` states in this change. Operators can inspect pending job state through the job list while the instance remains in its last terminal server state.
A terminal succeeded `restart` or `update` job queues the plugin-declared start action for the same instance and config version with idempotency key `<restart|update key>:start`. The follow-up start runs through the same start validation as an explicit operator start. Active start, stop, restart, and update jobs do not introduce separate `starting`, `stopping`, `restarting`, or `updating` states; operators can inspect pending job state through the job list while the instance remains in its last terminal server state.
## Plugin-Declared Graceful Stop
Run performs no game-specific shutdown logic. A plugin stop action may declare a `gracefulStop` step in `actions/stop.json`:
```json
"gracefulStop": {
"executableKey": "bin/scum-stop.cmd",
"environment": { "SERVER_STOP_SHUTDOWN_COMMAND": "Quit" },
"timeoutMs": 300000,
"fallback": "terminate"
}
```
Run runs that declared script first, then reads the supervised process state. If the process has exited, the stop result reports a graceful stop. If it is still running, Run applies the declared fallback: `report` fails the job so the operator can intervene, and `terminate` escalates to the existing bounded termination path. The shutdown command, player notice, wait budget, and Steam app id stay in plugin-owned files and manifest declarations; Run and Platform only implement the generic mechanism.
## Plugin-Declared Build Comparison
A plugin may declare a `steam.update` dependency probe with `targetKey` (the SteamCMD executable binding), `steamAppId`, and an optional platform list. Run queries the published build for the app's public branch with SteamCMD and compares it with the installed build recorded in `steamapps/appmanifest_<appId>.acf` under the instance's declared server install root, falling back to the SteamCMD directory. The probe reports bounded evidence of the form `installed=<build> latest=<build> update=yes|no` (or `update=unknown`) so the management console can light up an update action without embedding game policy in the platform.
+1 -1
View File
@@ -172,7 +172,7 @@ func (svc *CoreService) GetDependencyExecutionInput(request domain.DependencyExe
if err != nil {
return domain.DependencyExecutionInput{}, err
}
return domain.CopyDependencyExecutionInput(domain.DependencyExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, PlanDigest: digest, Probe: probe, Plan: plan, Bindings: bindings}), nil
return domain.CopyDependencyExecutionInput(domain.DependencyExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, PlanDigest: digest, ServerRoot: strings.TrimSpace(resolution.instance.Deployment.ServerRoot), Probe: probe, Plan: plan, Bindings: bindings}), nil
}
func (svc *CoreService) GetRunUpdateInput(request domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) {
@@ -304,3 +304,31 @@ func dependencyUpdateHello(instance domain.ServerInstance) domain.RunControlHell
Capacity: domain.RunCapacity{MaxJobs: 2},
}
}
func TestDependencyInputCarriesDeclaredServerInstallRoot(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
instance.Deployment.ServerRoot = "C:/scumserver"
if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatalf("set deployment server root: %v", err)
}
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil {
t.Fatalf("dependency catalog: %v", err)
}
job, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-install-root"})
if err != nil {
t.Fatalf("queue dependency check: %v", err)
}
runSession := registerDependencyUpdateRun(t, svc, instance)
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesCheck}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID {
t.Fatalf("claim dependency check: claim=%+v err=%v", claim, err)
}
input, err := svc.GetDependencyExecutionInput(domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil {
t.Fatalf("dependency input: %v", err)
}
if input.ServerRoot != "C:/scumserver" {
t.Fatalf("expected dependency input to carry the declared server install root, got %q", input.ServerRoot)
}
}
@@ -323,7 +323,7 @@ func autonomousBootstrapLifecycleAction(plugin domain.GamePlugin, profile domain
}
func autonomousDependencyProbe(probe domain.RuntimeDependencyProbe) domain.RunAutonomousDependencyProbe {
return domain.RunAutonomousDependencyProbe{Key: probe.Key, Kind: probe.Kind, TargetKey: probe.TargetKey, Required: probe.Required, MinimumVersion: probe.MinimumVersion, Platforms: domain.CopyStringSlice(probe.Platforms)}
return domain.RunAutonomousDependencyProbe{Key: probe.Key, Kind: probe.Kind, TargetKey: probe.TargetKey, Required: probe.Required, MinimumVersion: probe.MinimumVersion, SteamAppID: probe.SteamAppID, Platforms: domain.CopyStringSlice(probe.Platforms)}
}
func autonomousInstallPlan(plan domain.RuntimeInstallPlan) domain.RunAutonomousInstallPlan {
+17 -1
View File
@@ -100,6 +100,8 @@ type Core interface {
StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
StopServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
RestartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
UpdateServerGameForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
QueryServerInstanceProcessForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
GetServerInstance(string) (domain.ServerInstance, error)
GetServerInstanceForSession(string, string) (domain.ServerInstance, error)
@@ -1194,7 +1196,7 @@ func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.P
return base
}
lifecycleAction := domain.ServerLifecycleAction(strings.TrimSpace(payload["lifecycleAction"]))
if lifecycleAction == domain.ServerLifecycleActionStart || lifecycleAction == domain.ServerLifecycleActionStop {
if lifecycleAction == domain.ServerLifecycleActionStart || lifecycleAction == domain.ServerLifecycleActionStop || lifecycleAction == domain.ServerLifecycleActionRestart || lifecycleAction == domain.ServerLifecycleActionUpdate {
expectedVersion, _ := strconv.Atoi(payload["expectedConfigVersion"])
command := domain.ServerLifecycleCommand{
ServerInstanceID: instance.ID,
@@ -1218,6 +1220,20 @@ func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.P
return base
}
result, err = svc.StopServerInstanceForSession(sessionID, command)
case domain.ServerLifecycleActionRestart:
if capability != domain.LifecycleCapabilityStop {
base.Status = "denied"
base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "lifecycle action must match requested capability"}
return base
}
result, err = svc.RestartServerInstanceForSession(sessionID, command)
case domain.ServerLifecycleActionUpdate:
if capability != domain.LifecycleCapabilityInstall {
base.Status = "denied"
base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "lifecycle action must match requested capability"}
return base
}
result, err = svc.UpdateServerGameForSession(sessionID, command)
}
if err != nil {
return bridgeExecutionError(base, err)
+38 -2
View File
@@ -212,6 +212,34 @@ func (svc *CoreService) StopServerInstanceForSession(sessionID string, command d
return svc.StopServerInstance(command)
}
// RestartServerInstanceForSession queues the plugin-declared graceful stop and
// records restart intent. Run executes the stop action; the terminal stop
// result dispatches the plugin-declared start action for the same instance.
func (svc *CoreService) RestartServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
return domain.ServerLifecycleResult{}, err
}
return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionRestart, []domain.ServerInstanceState{
domain.ServerInstanceStateRunning,
domain.ServerInstanceStateStopped,
})
}
// UpdateServerGameForSession queues the plugin-declared install/update action.
// The plugin script stops the running server gracefully before SteamCMD touches
// server files, and the terminal update result starts the server again.
func (svc *CoreService) UpdateServerGameForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
return domain.ServerLifecycleResult{}, err
}
return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionUpdate, []domain.ServerInstanceState{
domain.ServerInstanceStateRunning,
domain.ServerInstanceStateStopped,
domain.ServerInstanceStateReady,
domain.ServerInstanceStateFailed,
})
}
func (svc *CoreService) QueryServerInstanceProcessForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
return domain.ServerLifecycleResult{}, err
@@ -418,6 +446,10 @@ func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string {
return "start"
case domain.ServerLifecycleActionStop:
return "stop"
case domain.ServerLifecycleActionRestart:
return "restart"
case domain.ServerLifecycleActionUpdate:
return "update"
case domain.ServerLifecycleActionStatus:
return "status"
default:
@@ -431,8 +463,10 @@ func runtimeProfileActionRef(actions domain.PluginLifecycleActions, action domai
return actions.Install
case domain.ServerLifecycleActionStart:
return actions.Start
case domain.ServerLifecycleActionStop:
case domain.ServerLifecycleActionStop, domain.ServerLifecycleActionRestart:
return actions.Stop
case domain.ServerLifecycleActionUpdate:
return actions.Install
case domain.ServerLifecycleActionStatus:
return actions.Status
default:
@@ -502,8 +536,10 @@ func lifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleA
return plugin.LifecycleActions.Install
case domain.ServerLifecycleActionStart:
return plugin.LifecycleActions.Start
case domain.ServerLifecycleActionStop:
case domain.ServerLifecycleActionStop, domain.ServerLifecycleActionRestart:
return plugin.LifecycleActions.Stop
case domain.ServerLifecycleActionUpdate:
return plugin.LifecycleActions.Install
case domain.ServerLifecycleActionStatus:
return plugin.LifecycleActions.Status
default:
@@ -1,6 +1,7 @@
package service
import (
"log"
"strings"
"time"
@@ -113,9 +114,39 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
return err
}
svc.publishLogProcessState(instance)
svc.dispatchFollowUpLifecycle(job, instance)
return nil
}
// dispatchFollowUpLifecycle starts the server again after a declared graceful
// stop or a completed game update. A failed or cancelled stop/update leaves the
// instance in its projected state so the operator sees the failure before any
// start is attempted.
func (svc *CoreService) dispatchFollowUpLifecycle(job domain.Job, instance domain.ServerInstance) {
if job.State != domain.JobStateSucceeded {
return
}
operation := job.ExecutionInput.LifecycleOperation
if operation != string(domain.ServerLifecycleActionRestart) && operation != string(domain.ServerLifecycleActionUpdate) {
return
}
command := domain.ServerLifecycleCommand{
ServerInstanceID: instance.ID,
ExpectedConfigVersion: instance.ConfigVersion,
IdempotencyKey: job.IdempotencyKey + ":start",
}
result, err := svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStart, []domain.ServerInstanceState{
domain.ServerInstanceStateReady,
domain.ServerInstanceStateStopped,
domain.ServerInstanceStateFailed,
})
if err != nil {
log.Printf("PLATFORM phase=lifecycle status=follow_up_failed operation=%s server=%s error=%s", safeLogValue(operation), safeLogValue(instance.ID), safeLogValue(err.Error()))
return
}
log.Printf("PLATFORM phase=lifecycle status=follow_up_queued operation=%s server=%s job=%s", safeLogValue(operation), safeLogValue(instance.ID), safeLogValue(result.Job.ID))
}
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" {
return nil
+211
View File
@@ -534,3 +534,214 @@ func claimAndCompleteLifecycleJobForServer(t *testing.T, svc *CoreService, sessi
t.Fatalf("complete lifecycle job %s: %v", capability, err)
}
}
func TestCoreServiceRestartLifecycleQueuesDeclaredStopThenStart(t *testing.T) {
svc, runSession := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
operator := createServiceUserAndLogin(t, svc, domain.User{ID: "lifecycle-operator", DisplayName: "Lifecycle Operator", Email: "lifecycle-operator@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"})
instance := runningLifecycleInstanceForTest(t, svc, runSession, "server-restart")
restarted, err := svc.RestartServerInstanceForSession(operator, domain.ServerLifecycleCommand{
ServerInstanceID: instance.ID,
ExpectedConfigVersion: instance.ConfigVersion,
IdempotencyKey: "idem-restart",
})
if err != nil {
t.Fatalf("restart lifecycle workflow: %v", err)
}
if restarted.Action != domain.ServerLifecycleActionRestart || restarted.Job.Capability != domain.LifecycleCapabilityStop || restarted.Job.TargetKey != "actions/stop.json" {
t.Fatalf("expected restart to queue the plugin-declared stop action, got %+v", restarted)
}
if restarted.Job.ExecutionInput.LifecycleOperation != string(domain.ServerLifecycleActionRestart) {
t.Fatalf("expected restart intent on the queued job, got %+v", restarted.Job.ExecutionInput)
}
if _, ok := queuedLifecycleJob(t, svc, instance.ID, domain.LifecycleCapabilityStart); ok {
t.Fatal("restart must not queue a start job before the declared stop result lands")
}
claimAndCompleteLifecycleJobForServer(t, svc, runSession, instance.ID, domain.LifecycleCapabilityStop, domain.JobStateSucceeded)
stopped, err := svc.GetServerInstance(instance.ID)
if err != nil {
t.Fatalf("get stopped instance: %v", err)
}
if stopped.State != domain.ServerInstanceStateStopped {
t.Fatalf("expected graceful stop result to project stopped, got %+v", stopped)
}
startJob, ok := queuedLifecycleJob(t, svc, instance.ID, domain.LifecycleCapabilityStart)
if !ok {
t.Fatal("expected a follow-up start job after the graceful stop succeeded")
}
if startJob.TargetKey != "actions/start.json" || startJob.ExecutionInput.LifecycleOperation != string(domain.ServerLifecycleActionStart) || startJob.IdempotencyKey != "idem-restart:start" {
t.Fatalf("unexpected follow-up start job: %+v", startJob)
}
claimAndCompleteLifecycleJobForServer(t, svc, runSession, instance.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
running, err := svc.GetServerInstance(instance.ID)
if err != nil {
t.Fatalf("get restarted instance: %v", err)
}
if running.State != domain.ServerInstanceStateRunning {
t.Fatalf("expected restart to return the server to running, got %+v", running)
}
}
func TestCoreServiceUpdateLifecycleQueuesInstallThenStart(t *testing.T) {
svc, runSession := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
operator := createServiceUserAndLogin(t, svc, domain.User{ID: "update-operator", DisplayName: "Update Operator", Email: "update-operator@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"})
instance := runningLifecycleInstanceForTest(t, svc, runSession, "server-update")
updated, err := svc.UpdateServerGameForSession(operator, domain.ServerLifecycleCommand{
ServerInstanceID: instance.ID,
ExpectedConfigVersion: instance.ConfigVersion,
IdempotencyKey: "idem-update",
})
if err != nil {
t.Fatalf("update lifecycle workflow: %v", err)
}
if updated.Action != domain.ServerLifecycleActionUpdate || updated.Job.Capability != domain.LifecycleCapabilityInstall || updated.Job.TargetKey != "actions/install.json" {
t.Fatalf("expected update to queue the plugin-declared install action, got %+v", updated)
}
if updated.Job.ExecutionInput.LifecycleOperation != string(domain.ServerLifecycleActionUpdate) {
t.Fatalf("expected update intent on the queued job, got %+v", updated.Job.ExecutionInput)
}
claimAndCompleteLifecycleJobForServer(t, svc, runSession, instance.ID, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
startJob, ok := queuedLifecycleJob(t, svc, instance.ID, domain.LifecycleCapabilityStart)
if !ok {
t.Fatal("expected a follow-up start job after the game update succeeded")
}
if startJob.IdempotencyKey != "idem-update:start" {
t.Fatalf("unexpected follow-up start job: %+v", startJob)
}
claimAndCompleteLifecycleJobForServer(t, svc, runSession, instance.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
running, err := svc.GetServerInstance(instance.ID)
if err != nil {
t.Fatalf("get updated instance: %v", err)
}
if running.State != domain.ServerInstanceStateRunning {
t.Fatalf("expected update to start the server again, got %+v", running)
}
}
func TestCoreServiceRestartAndUpdateRespectLifecycleState(t *testing.T) {
svc, runSession := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
operator := createServiceUserAndLogin(t, svc, domain.User{ID: "state-operator", DisplayName: "State Operator", Email: "state-operator@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"})
if _, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ID: "server-installing", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM installing", IdempotencyKey: "installing-create", ProfileKey: "local"}); err != nil {
t.Fatalf("create installing instance: %v", err)
}
installing, err := svc.GetServerInstance("server-installing")
if err != nil {
t.Fatalf("get installing instance: %v", err)
}
if _, err := svc.RestartServerInstanceForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: installing.ID, ExpectedConfigVersion: installing.ConfigVersion, IdempotencyKey: "idem-restart-installing"}); err == nil || !strings.Contains(err.Error(), "cannot restart") {
t.Fatalf("expected restart state rejection, got %v", err)
}
if _, err := svc.UpdateServerGameForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: installing.ID, ExpectedConfigVersion: installing.ConfigVersion, IdempotencyKey: "idem-update-installing"}); err == nil || !strings.Contains(err.Error(), "cannot update") {
t.Fatalf("expected update state rejection, got %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, runSession, installing.ID, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
ready, err := svc.GetServerInstance(installing.ID)
if err != nil {
t.Fatalf("get ready instance: %v", err)
}
if _, err := svc.RestartServerInstanceForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: ready.ID, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-restart-ready"}); err == nil || !strings.Contains(err.Error(), "cannot restart") {
t.Fatalf("expected ready state restart rejection, got %v", err)
}
if _, err := svc.UpdateServerGameForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: ready.ID, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-update-ready"}); err != nil {
t.Fatalf("ready server should accept a game update dispatch: %v", err)
}
}
func TestCoreServiceFailedGracefulStopDoesNotStartServer(t *testing.T) {
svc, runSession := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
operator := createServiceUserAndLogin(t, svc, domain.User{ID: "failure-operator", DisplayName: "Failure Operator", Email: "failure-operator@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"})
instance := runningLifecycleInstanceForTest(t, svc, runSession, "server-restart-failure")
if _, err := svc.RestartServerInstanceForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "idem-restart-failure"}); err != nil {
t.Fatalf("restart lifecycle workflow: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, runSession, instance.ID, domain.LifecycleCapabilityStop, domain.JobStateFailed)
if _, ok := queuedLifecycleJob(t, svc, instance.ID, domain.LifecycleCapabilityStart); ok {
t.Fatal("a failed graceful stop must not queue a start job")
}
failed, err := svc.GetServerInstance(instance.ID)
if err != nil {
t.Fatalf("get failed instance: %v", err)
}
if failed.State != domain.ServerInstanceStateFailed {
t.Fatalf("expected failed stop to project failed, got %+v", failed)
}
}
func TestCoreServiceRestartAndUpdateRequireEndpointCapabilities(t *testing.T) {
svc, runSession := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
operator := createServiceUserAndLogin(t, svc, domain.User{ID: "capability-operator", DisplayName: "Capability Operator", Email: "capability-operator@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"})
instance := runningLifecycleInstanceForTest(t, svc, runSession, "server-capability")
endpoint, err := svc.store.RunEndpoints().Get("run-local")
if err != nil {
t.Fatalf("get lifecycle endpoint: %v", err)
}
remaining := make([]string, 0, len(endpoint.Capabilities))
for _, capability := range endpoint.Capabilities {
if capability != domain.LifecycleCapabilityStop && capability != domain.LifecycleCapabilityInstall {
remaining = append(remaining, capability)
}
}
endpoint.Capabilities = remaining
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("remove lifecycle capabilities: %v", err)
}
if _, err := svc.RestartServerInstanceForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "idem-restart-capability"}); err == nil || !strings.Contains(err.Error(), domain.LifecycleCapabilityStop) {
t.Fatalf("expected missing stop capability rejection, got %v", err)
}
if _, err := svc.UpdateServerGameForSession(operator, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "idem-update-capability"}); err == nil || !strings.Contains(err.Error(), domain.LifecycleCapabilityInstall) {
t.Fatalf("expected missing install capability rejection, got %v", err)
}
}
func runningLifecycleInstanceForTest(t *testing.T, svc *CoreService, runSession string, id string) domain.ServerInstance {
t.Helper()
if _, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ID: id, PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM " + id, IdempotencyKey: id + "-create", ProfileKey: "local"}); err != nil {
t.Fatalf("create lifecycle workflow: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, runSession, id, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
ready, err := svc.GetServerInstance(id)
if err != nil {
t.Fatalf("get ready instance: %v", err)
}
if _, err := svc.StartServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: id, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: id + "-start"}); err != nil {
t.Fatalf("start lifecycle workflow: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, runSession, id, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
running, err := svc.GetServerInstance(id)
if err != nil {
t.Fatalf("get running instance: %v", err)
}
if running.State != domain.ServerInstanceStateRunning {
t.Fatalf("expected running instance, got %+v", running)
}
return running
}
func queuedLifecycleJob(t *testing.T, svc *CoreService, serverInstanceID string, capability string) (domain.Job, bool) {
t.Helper()
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID, State: domain.JobStateQueued})
if err != nil {
t.Fatalf("list jobs: %v", err)
}
for _, job := range jobs {
if job.Capability == capability {
return job, true
}
}
return domain.Job{}, false
}
+9 -1
View File
@@ -14,6 +14,7 @@ import (
var (
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
steamAppIDPattern = regexp.MustCompile(`^[0-9]{1,10}$`)
)
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
@@ -74,10 +75,17 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
violations = append(violations, validateProfileKey(prefix+".key", probe.Key)...)
violations = append(violations, recordRuntimeProfileKey(dependencyKeys, prefix+".key", probe.Key)...)
violations = append(violations, validateProfileKey(prefix+".targetKey", probe.TargetKey)...)
if !oneOf(probe.Kind, "command.version", "service.exists", "port.available", "steam.app", "java.version", "docker.available", "package.installed", "file.exists") {
if !oneOf(probe.Kind, "command.version", "service.exists", "port.available", "steam.app", "steam.update", "java.version", "docker.available", "package.installed", "file.exists") {
violations = append(violations, prefix+".kind is invalid")
}
violations = append(violations, validateSafeRuntimeValue(prefix+".minimumVersion", probe.MinimumVersion)...)
if probe.Kind == "steam.update" {
if !steamAppIDPattern.MatchString(probe.SteamAppID) {
violations = append(violations, prefix+".steamAppId is invalid for a Steam build probe")
}
} else if probe.SteamAppID != "" {
violations = append(violations, prefix+".steamAppId is allowed only for steam.update probes")
}
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", probe.Platforms)...)
}
for i, plan := range profiles.InstallPlans {
@@ -24,3 +24,20 @@ func TestValidateGamePluginRuntimeProfilesRejectsManualStepExecutionFields(t *te
t.Fatalf("expected manual execution field rejection, got %v", err)
}
}
func TestValidateGamePluginRuntimeProfilesChecksSteamBuildProbeAppID(t *testing.T) {
valid := domain.GamePluginRuntimeProfiles{DependencyProbes: []domain.RuntimeDependencyProbe{{Key: "game-build", Kind: "steam.update", TargetKey: "steamcmd", SteamAppID: "3792580"}}}
if err := ValidateGamePluginRuntimeProfiles(valid); err != nil {
t.Fatalf("expected declared steam build probe to validate, got %v", err)
}
missing := domain.GamePluginRuntimeProfiles{DependencyProbes: []domain.RuntimeDependencyProbe{{Key: "game-build", Kind: "steam.update", TargetKey: "steamcmd"}}}
if err := ValidateGamePluginRuntimeProfiles(missing); err == nil || !strings.Contains(err.Error(), "steamAppId is invalid for a Steam build probe") {
t.Fatalf("expected missing steam app id rejection, got %v", err)
}
misplaced := domain.GamePluginRuntimeProfiles{DependencyProbes: []domain.RuntimeDependencyProbe{{Key: "java-21", Kind: "java.version", TargetKey: "java", SteamAppID: "3792580"}}}
if err := ValidateGamePluginRuntimeProfiles(misplaced); err == nil || !strings.Contains(err.Error(), "steamAppId is allowed only for steam.update probes") {
t.Fatalf("expected misplaced steam app id rejection, got %v", err)
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ func ValidateServerLifecycleCommand(command domain.ServerLifecycleCommand) error
func ValidateServerLifecycleAction(action domain.ServerLifecycleAction) error {
switch action {
case domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionStatus:
case domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionRestart, domain.ServerLifecycleActionUpdate, domain.ServerLifecycleActionStatus:
return nil
default:
return ValidationError{Violations: []string{fmt.Sprintf("action %q is invalid", action)}}