Files
browser/platform/service/dependency_updates_test.go
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

335 lines
20 KiB
Go

package service
import (
"encoding/json"
"errors"
"strings"
"testing"
"browser.local/platform/domain"
)
func TestDependencyCatalogRequiresCurrentReviewedDigest(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "dependency-other-owner", DisplayName: "Other Owner", Email: "dependency-other@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
if _, err := svc.GetDependencyCatalogForSession(otherSession, instance.ID); !errors.Is(err, ErrForbidden) {
t.Fatalf("expected cross-owner dependency catalog denial, got %v", err)
}
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil {
t.Fatalf("get dependency catalog: %v", err)
}
if catalog.TargetOS != "linux" || catalog.TargetArch != "amd64" || len(catalog.Probes) != 1 || len(catalog.Plans) != 1 || !strings.HasPrefix(catalog.Plans[0].Digest, "sha256:") {
t.Fatalf("unexpected dependency catalog: %+v", catalog)
}
request := domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, Install: true, InstallPlanKey: catalog.Plans[0].Key, PlanDigest: "sha256:" + strings.Repeat("f", 64), IdempotencyKey: "dependency-stale-digest"}
if _, err := svc.QueueDependencyJobForSession(session, request); err == nil || !strings.Contains(err.Error(), "planDigest") {
t.Fatalf("expected stale digest rejection, got %v", err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil {
t.Fatalf("list jobs: %v", err)
}
for _, job := range jobs {
if job.IdempotencyKey == request.IdempotencyKey {
t.Fatalf("stale digest created a job: %+v", job)
}
}
request.PlanDigest = catalog.Plans[0].Digest
request.IdempotencyKey = "dependency-current-digest"
job, err := svc.QueueDependencyJobForSession(session, request)
if err != nil {
t.Fatalf("queue reviewed dependency plan: %v", err)
}
if job.Capability != domain.JobCapabilityDependenciesInstall || job.TargetKey != "dependencies/install/"+catalog.Plans[0].Key {
t.Fatalf("unexpected dependency install job: %+v", job)
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
plugin.RuntimeProfiles.InstallPlans[0].Steps[0].PackageName = "openjdk-22-jre"
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("mutate plugin declaration: %v", err)
}
runSession := registerDependencyUpdateRun(t, svc, instance)
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob {
t.Fatalf("claim dependency install: claim=%+v err=%v", claim, err)
}
_, 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 || !strings.Contains(err.Error(), "changed after dispatch") {
t.Fatalf("expected changed declaration rejection, got %v", err)
}
}
func TestDependencyCatalogNamesMissingPluginPermission(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
permissions := plugin.DeclaredPermissions[:0]
for _, permission := range plugin.DeclaredPermissions {
if permission != "server.dependencies.manage" {
permissions = append(permissions, permission)
}
}
plugin.DeclaredPermissions = permissions
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin: %v", err)
}
_, err = svc.GetDependencyCatalogForSession(session, instance.ID)
if !errors.Is(err, ErrForbidden) || !strings.Contains(err.Error(), "server.dependencies.manage") {
t.Fatalf("expected named dependency permission denial, got %v", err)
}
}
func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil {
t.Fatalf("catalog: %v", err)
}
job, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-check-fencing"})
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)
}
base := domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
input, err := svc.GetDependencyExecutionInput(base)
if err != nil || input.PlanDigest == "" || input.Bindings["java"] == "" {
t.Fatalf("get fenced dependency input: input=%+v err=%v", input, err)
}
wrongSession := base
wrongSession.SessionToken = "stale-session"
if _, err := svc.GetDependencyExecutionInput(wrongSession); err == nil {
t.Fatal("expected wrong session rejection")
}
wrongAttempt := base
wrongAttempt.Attempt++
if _, err := svc.GetDependencyExecutionInput(wrongAttempt); err == nil {
t.Fatal("expected wrong attempt rejection")
}
wrongLease := base
wrongLease.LeaseToken = "stale-lease"
if _, err := svc.GetDependencyExecutionInput(wrongLease); err == nil {
t.Fatal("expected wrong lease rejection")
}
evidence, _ := json.Marshal(domain.DependencyExecutionEvidence{ProbeKey: input.Probe.Key, PlanDigest: input.PlanDigest, State: string(domain.DependencyStatePresent), Evidence: "OpenJDK 21"})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "dependency probe completed"}, ResultRef: "artifact://jobs/dependency-check/result", Message: "dependency probe completed", ExecutionResult: domain.JobExecutionResult{Kind: "dependency.check", Checksum: input.PlanDigest, Summary: "dependency probe completed", Content: string(evidence)}}); err != nil {
t.Fatalf("complete dependency result: %v", err)
}
projected, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil || projected.Probes[0].State != domain.DependencyStatePresent || projected.Probes[0].Evidence != "OpenJDK 21" {
t.Fatalf("unexpected dependency projection: catalog=%+v err=%v", projected, err)
}
cancelJob, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-check-cancel"})
if err != nil {
t.Fatalf("queue cancellable dependency check: %v", err)
}
claim, err = svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesCheck}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || claim.Job.JobID != cancelJob.ID {
t.Fatalf("claim cancellable dependency check: claim=%+v err=%v", claim, err)
}
if _, err := svc.RequestRunJobCancelForSession(session, domain.RunJobCancelRequest{JobID: cancelJob.ID, Reason: "operator cancelled"}); err != nil {
t.Fatalf("request cancel: %v", err)
}
if _, err := svc.GetDependencyExecutionInput(domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}); err == nil || !strings.Contains(err.Error(), "cancelled") {
t.Fatalf("expected cancelled input rejection, got %v", err)
}
}
func TestPluginBridgeDependencyInstallUsesReviewedPlanDigest(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{Key: "runtime", Title: "Runtime", Path: "/runtime", Permissions: []string{"server.dependencies.manage"}, BridgeActions: []string{string(domain.PluginBridgeActionDependenciesRequest)}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("add dependency bridge page: %v", err)
}
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil {
t.Fatalf("get dependency catalog: %v", err)
}
request := domain.PluginBridgeExecuteRequest{RequestID: "bridge-dependency-install", PluginID: plugin.ID, RouteKey: "runtime", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionDependenciesRequest, Payload: map[string]string{"operation": "install", "probeKey": catalog.Probes[0].Key, "planKey": catalog.Plans[0].Key, "idempotencyKey": "bridge-dependency-install"}}
denied, err := svc.ExecutePluginBridgeAction(session, request)
if err != nil {
t.Fatalf("execute bridge without digest: %v", err)
}
if denied.Status == "queued" || denied.Error == nil {
t.Fatalf("bridge install without reviewed digest must be denied: %+v", denied)
}
request.Payload["planDigest"] = catalog.Plans[0].Digest
request.Payload["idempotencyKey"] = "bridge-dependency-install-approved"
approved, err := svc.ExecutePluginBridgeAction(session, request)
if err != nil {
t.Fatalf("execute reviewed bridge install: %v", err)
}
if approved.Status != "queued" || approved.Result["capability"] != domain.JobCapabilityDependenciesInstall {
t.Fatalf("expected reviewed bridge dependency job, got %+v", approved)
}
}
func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
payload := []byte("compiled target-matched run archive")
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "run-update-build"})
if err != nil {
t.Fatalf("generate update distribution: %v", err)
}
distribution = completeDistributionBuild(t, svc, distribution, payload)
otherInstance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-update-other", PluginID: instance.PluginID, RunEndpointID: instance.RunEndpointID, Name: "Other Update Server", State: domain.ServerInstanceStateReady})
if err != nil {
t.Fatalf("create other update server: %v", err)
}
createCompleteRuntimeBinding(t, svc, otherInstance, "local")
if _, err := svc.PushRunUpdateForSession(session, domain.RunUpdateRequest{ServerInstanceID: otherInstance.ID, ArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, IdempotencyKey: "run-update-cross-server"}); err == nil {
t.Fatal("expected cross-server update artifact rejection")
}
endpoint, _ := svc.store.RunEndpoints().Get(instance.RunEndpointID)
endpoint.Architecture = "arm64"
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("change endpoint target: %v", err)
}
request := domain.RunUpdateRequest{ServerInstanceID: instance.ID, ArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, IdempotencyKey: "run-update-target-check"}
if _, err := svc.PushRunUpdateForSession(session, request); err == nil || !strings.Contains(err.Error(), "target-matched") {
t.Fatalf("expected cross-target update rejection, got %v", err)
}
endpoint.Architecture = "amd64"
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("restore endpoint target: %v", err)
}
request.IdempotencyKey = "run-update-fenced"
update, err := svc.PushRunUpdateForSession(session, request)
if err != nil {
t.Fatalf("push target-matched update: %v", err)
}
runSession := registerDependencyUpdateRun(t, svc, instance)
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRunSelfUpdate}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.JobID != update.JobID {
t.Fatalf("claim Run update: claim=%+v err=%v", claim, err)
}
inputRequest := domain.RunUpdateInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
input, err := svc.GetRunUpdateInput(inputRequest)
if err != nil || input.TargetRelease != update.TargetRelease || input.Checksum != distribution.Checksum {
t.Fatalf("get Run update input: input=%+v err=%v", input, err)
}
chunk, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 0, Length: 8})
if err != nil || string(chunk.Payload) != string(payload[:8]) || chunk.Offset != 0 || chunk.TotalBytes != int64(len(payload)) {
t.Fatalf("read bounded update chunk: chunk=%+v err=%v", chunk, err)
}
secondChunk, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 8, Length: 5})
if err != nil || string(secondChunk.Payload) != string(payload[8:13]) || secondChunk.Offset != 8 || secondChunk.Complete {
t.Fatalf("read offset update chunk: chunk=%+v err=%v", secondChunk, err)
}
if _, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt + 1, Offset: 0, Length: 8}); err == nil {
t.Fatal("expected stale update chunk attempt rejection")
}
evidence, _ := json.Marshal(domain.RunUpdateExecutionEvidence{TargetRelease: update.TargetRelease, Phase: "staged"})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "Run update verified and staged"}, ResultRef: "artifact://jobs/run-update/staged", Message: "Run update verified and staged", ExecutionResult: domain.JobExecutionResult{Kind: "run.update.staged", Checksum: update.Checksum, SizeBytes: int64(len(payload)), Summary: "verified update staged", Content: string(evidence)}}); err != nil {
t.Fatalf("complete staged Run update: %v", err)
}
updates, err := svc.ListRunUpdateJobsForSession(session, instance.ID)
if err != nil || len(updates) != 1 || updates[0].Phase != domain.RunUpdatePhaseRestartRequested {
t.Fatalf("expected restart-requested projection, updates=%+v err=%v", updates, err)
}
newHello := dependencyUpdateHello(instance)
newHello.Version = update.TargetRelease
newRegistration, err := svc.RegisterRunHello(newHello)
if err != nil {
t.Fatalf("register updated Run: %v", err)
}
health := domain.RunUpdateHealthReport{RunEndpointID: instance.RunEndpointID, SessionToken: newRegistration.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Outcome: "succeeded", Version: update.TargetRelease}
if _, err := svc.ReportRunUpdateHealth(domain.RunUpdateHealthReport{RunEndpointID: health.RunEndpointID, SessionToken: health.SessionToken, JobID: health.JobID, LeaseToken: "stale-lease", Attempt: health.Attempt, Outcome: health.Outcome, Version: health.Version}); err == nil {
t.Fatal("expected stale health lease rejection")
}
result, err := svc.ReportRunUpdateHealth(health)
if err != nil || !result.Accepted || result.Phase != domain.RunUpdatePhaseSucceeded {
t.Fatalf("report updated Run health: result=%+v err=%v", result, err)
}
rollbackHello := dependencyUpdateHello(instance)
rollbackHello.Version = update.PreviousVersion
rollbackRegistration, err := svc.RegisterRunHello(rollbackHello)
if err != nil {
t.Fatalf("register rolled-back Run: %v", err)
}
health.SessionToken = rollbackRegistration.SessionToken
health.Outcome = "rolled-back"
health.Version = update.PreviousVersion
result, err = svc.ReportRunUpdateHealth(health)
if err != nil || result.Phase != domain.RunUpdatePhaseRolledBack {
t.Fatalf("report rollback: result=%+v err=%v", result, err)
}
updates, _ = svc.ListRunUpdateJobsForSession(session, instance.ID)
if !updates[0].Rollback || updates[0].Status != domain.DistributionJobStatusFailed || updates[0].Phase != domain.RunUpdatePhaseRolledBack {
t.Fatalf("unexpected rollback projection: %+v", updates[0])
}
}
func registerDependencyUpdateRun(t *testing.T, svc *CoreService, instance domain.ServerInstance) string {
t.Helper()
result, err := svc.RegisterRunHello(dependencyUpdateHello(instance))
if err != nil || !result.Accepted {
t.Fatalf("register dependency/update Run: result=%+v err=%v", result, err)
}
return result.SessionToken
}
func dependencyUpdateHello(instance domain.ServerInstance) domain.RunControlHello {
return domain.RunControlHello{
RegistrationToken: "registration-token",
RunEndpointID: instance.RunEndpointID,
DisplayName: "Dependency Update Run",
Version: "0.1.0",
Status: domain.RunEndpointStatusOnline,
Platform: "linux",
Architecture: "amd64",
CapabilityReport: domain.RunCapabilityReport{Capabilities: []string{domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate}, Fingerprint: "dependency-update-v1"},
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)
}
}