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

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

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

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

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

225 lines
8.1 KiB
Go

package service
import (
"log"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/validator"
)
func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (domain.RunLifecycleReportResult, error) {
report = domain.CopyRunLifecycleReport(report)
if err := validator.ValidateRunLifecycleReport(report); err != nil {
return domain.RunLifecycleReportResult{}, err
}
if _, err := svc.validatedRunSession(report.RunEndpointID, report.SessionToken); err != nil {
return domain.RunLifecycleReportResult{}, err
}
instance, err := svc.store.ServerInstances().Get(report.ServerInstanceID)
if err != nil {
return domain.RunLifecycleReportResult{}, err
}
if instance.RunEndpointID != report.RunEndpointID {
return domain.RunLifecycleReportResult{}, validationError("runEndpointId must match server instance")
}
if instance.State == domain.ServerInstanceStateDeleted {
return domain.RunLifecycleReportResult{}, validationError("server instance must not be deleted")
}
stamp := svc.now()
nextState, projected := lifecycleProjectedState(report.Capability, report.State, report.ExecutionResult)
staleObservation := lifecycleObservationIsStale(instance, report)
if !projected || staleObservation {
projected = false
nextState = instance.State
}
if projected {
instance.State = nextState
if report.ManagedProcessID != "" {
instance.LifecycleProcessID = report.ManagedProcessID
instance.LifecycleObservationSeq = report.ObservationSeq
instance.LifecycleObservedAt = report.ObservedAt
if instance.LifecycleObservedAt.IsZero() {
instance.LifecycleObservedAt = stamp
}
}
instance.UpdatedAt = stamp
if err := validator.ValidateServerInstance(instance); err != nil {
return domain.RunLifecycleReportResult{}, err
}
if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.RunLifecycleReportResult{}, err
}
svc.publishLogProcessState(instance)
}
return domain.CopyRunLifecycleReportResult(domain.RunLifecycleReportResult{Accepted: true, RunEndpointID: report.RunEndpointID, ServerInstanceID: report.ServerInstanceID, ProjectedState: nextState, ServerTime: stamp}), nil
}
func lifecycleObservationIsStale(instance domain.ServerInstance, report domain.RunLifecycleReport) bool {
if report.ManagedProcessID == "" || instance.LifecycleProcessID == "" {
return false
}
if report.ManagedProcessID == instance.LifecycleProcessID {
return report.ObservationSeq <= instance.LifecycleObservationSeq
}
return !report.ObservedAt.IsZero() && !instance.LifecycleObservedAt.IsZero() && report.ObservedAt.Before(instance.LifecycleObservedAt)
}
func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time.Time) error {
if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) {
return nil
}
return nil
}
func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error {
if job.Capability == domain.JobCapabilityConfigWrite {
if job.State != domain.JobStateSucceeded {
return nil
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil {
return err
}
instance.ConfigKey = job.TargetKey
instance.ConfigContent = job.ExecutionInput.Content
instance.ConfigChecksum = job.ExecutionResult.Checksum
instance.ConfigVersion = job.ExecutionResult.Version
instance.ConfigUpdatedAt = stamp
instance.UpdatedAt = stamp
if err := validator.ValidateServerInstance(instance); err != nil {
return err
}
if err := svc.store.ServerInstances().Update(instance); err != nil {
return err
}
return nil
}
nextState, ok := lifecycleProjectedState(job.Capability, job.State, job.ExecutionResult)
if !ok || job.ServerInstanceID == "" {
return nil
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil {
return err
}
instance.State = nextState
instance.UpdatedAt = stamp
if err := validator.ValidateServerInstance(instance); err != nil {
return err
}
if err := svc.store.ServerInstances().Update(instance); err != nil {
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
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil {
return err
}
projection := domain.CopyServerDeploymentProjection(instance.DeploymentProjection)
projection.State = "running"
projection.Operation = job.ExecutionInput.LifecycleOperation
switch job.Progress.Phase {
case "preflight":
projection.PreflightState = "running"
case "scan", "discover", "discovery":
projection.DiscoveryState = "running"
case "install", "configure", "mapping":
projection.MappingState = "running"
case "start", "health":
projection.VerificationState = "running"
}
projection.UpdatedAt = stamp
instance.DeploymentProjection = projection
instance.UpdatedAt = stamp
return svc.store.ServerInstances().Update(instance)
}
func lifecycleProjectedState(capability string, jobState domain.JobState, result domain.JobExecutionResult) (domain.ServerInstanceState, bool) {
if capability != domain.LifecycleCapabilityInstall && capability != domain.LifecycleCapabilityStart && capability != domain.LifecycleCapabilityStop && capability != domain.LifecycleCapabilityStatus {
return "", false
}
if capability == domain.LifecycleCapabilityStatus {
if jobState != domain.JobStateSucceeded {
return "", false
}
return lifecycleStateFromProcessResult(result)
}
if jobState == domain.JobStateFailed || jobState == domain.JobStateCancelled {
return domain.ServerInstanceStateFailed, true
}
if jobState != domain.JobStateSucceeded {
return "", false
}
switch capability {
case domain.LifecycleCapabilityInstall:
return domain.ServerInstanceStateReady, true
case domain.LifecycleCapabilityStart:
return lifecycleStateFromProcessResult(result)
case domain.LifecycleCapabilityStop:
if state, ok := lifecycleStateFromProcessResult(result); ok {
return state, true
}
return domain.ServerInstanceStateStopped, true
default:
return "", false
}
}
func lifecycleStateFromProcessResult(result domain.JobExecutionResult) (domain.ServerInstanceState, bool) {
switch strings.TrimSpace(result.ProcessState) {
case "running":
return domain.ServerInstanceStateRunning, true
case "stopped", "not-started":
return domain.ServerInstanceStateStopped, true
case "exited":
switch strings.TrimSpace(result.ExitClassification) {
case "requested-stop", "forced-stop", "already-stopped":
return domain.ServerInstanceStateStopped, true
default:
return domain.ServerInstanceStateFailed, true
}
default:
return "", false
}
}