From 2ae27bb74609f704fe52442253f71c6712a7b375 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 27 Jul 2026 20:09:14 +0800 Subject: [PATCH] feat: declare and execute SCUM guided installs --- .../design.md | 5 ++++ .../proposal.md | 3 ++ .../run-deployment-execution-receipts/spec.md | 15 ++++++++++ .../tasks.md | 7 +++-- platform/domain/resources.go | 14 +++++++++ platform/dto/job_channel.go | 4 +++ platform/dto/runtime_profiles.go | 12 ++++++++ platform/dto/server_lifecycle.go | 9 ++++-- platform/protocol/server-deployment.md | 9 ++++++ platform/service/scum_deployment.go | 10 +++++++ platform/service/scum_deployment_test.go | 1 + platform/service/server_deployment.go | 29 ++++++++++++++++++- platform/service/server_deployment_test.go | 12 ++++++++ platform/validator/runtime_profiles.go | 12 ++++++++ .../contracts/jobPresentation.test.ts | 10 +++++++ platform_web/contracts/jobPresentation.ts | 11 +++++++ platform_web/pages/HomePage.tsx | 5 ++-- platform_web/pages/MaintenancePage.tsx | 3 +- platform_web/pages/ServerDetailPage.tsx | 3 +- .../examples/scum-server-plugin/manifest.json | 10 +++++++ .../game-plugin.manifest.schema.json | 10 +++++++ 21 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 platform_web/contracts/jobPresentation.test.ts create mode 100644 platform_web/contracts/jobPresentation.ts diff --git a/openspec/changes/synchronize-run-deployment-execution/design.md b/openspec/changes/synchronize-run-deployment-execution/design.md index e5a3b62..a139191 100644 --- a/openspec/changes/synchronize-run-deployment-execution/design.md +++ b/openspec/changes/synchronize-run-deployment-execution/design.md @@ -31,12 +31,17 @@ Raw host directories and command text are normally protected. The operator has e 5. **Auto-reveal only within the authorized editor route.** The editor calls the existing owner-scoped reveal endpoint when opened, stores values only in component state, signals that protected values are visible, and clears state on close. Ordinary deployment reads remain redacted. +6. **SCUM guided installation is a typed template, not a custom command.** The first-party SCUM template is the authority for SteamCMD App `3792580`, SCUM file/config markers, Microsoft Visual C++ prerequisites, DirectX runtime, and verification. Run receives the frozen template plus the protected root and game fields, creates a controlled SteamCMD argument vector including `+force_install_dir`, and never interprets an operator or plugin supplied shell snippet for the guided path. Microsoft prerequisite artifacts use official Microsoft endpoints and silent switches under a fixed Run catalog; an internal mirror is not selected unless it is explicitly checksum-equivalent to that catalog. Run checks runtime markers before each install and verifies the marker again afterward. + +7. **Keep transport identifiers internal and present safe operation text.** `process.install` remains the Run capability because it is part of the leased job protocol. The Platform Web task card derives its title from the frozen plugin/action and deployment phase (for example, `安装 SCUM 服务端`), rather than rendering the protocol capability as user-facing copy. + ## Risks / Trade-offs - [Raw local diagnostics can be copied from the host console] → Diagnostics are opt-in, local-only, action-scoped, and clearly warn operators; no credentials or environment are included. - [Mixed Platform/Run releases can lose execution confirmation] → Version/capability negotiation rejects deployment-aware jobs until the Run supports the protocol, rather than silently ignoring input. - [An editor request can reveal operational values to an authorized browser] → It remains owner-authorized, uses no shared cache, and clears on dialog close. - [Existing clients cannot express clearing] → The additive `clearFields` field preserves existing requests while enabling explicit removal. +- [A download mirror may be stale or tampered with] → Prefer the official Microsoft catalog and a fixed checksum/signature policy. Mirrors are acceleration only after equivalence verification; they are never the trust root. ## Migration Plan diff --git a/openspec/changes/synchronize-run-deployment-execution/proposal.md b/openspec/changes/synchronize-run-deployment-execution/proposal.md index 6500f79..9479771 100644 --- a/openspec/changes/synchronize-run-deployment-execution/proposal.md +++ b/openspec/changes/synchronize-run-deployment-execution/proposal.md @@ -12,6 +12,8 @@ This change makes the editor load the authorized saved definition directly and m - Return a non-sensitive receipt containing the deployment revision and execution mode so Platform can distinguish dispatch evidence from Run execution confirmation. - Automatically fetch and prefill the authorized saved deployment inputs when opening the edit workflow, then clear them when the dialog closes. - Replace ambiguous empty-string update handling with explicit preserve/set/clear semantics and prevent an omitted shell from resetting the saved shell. +- Make first-party SCUM guided installation fully plugin-owned: Run must consume the frozen SCUM template, ensure SteamCMD plus declared Microsoft prerequisites, install App `3792580` into the selected root, materialize configuration, and verify the resulting server. Operators supply the target directory and game settings, never lifecycle commands or prerequisite installers. +- Replace raw lifecycle capability text such as `process.install` in operator task surfaces with the plugin-aware operation name and phase, while retaining the capability as the internal routing identifier. ## Capabilities @@ -29,3 +31,4 @@ This change makes the editor load the authorized saved definition directly and m - Platform job-channel DTOs, domain contracts, validation, lifecycle dispatch/result projection, and deployment editor API/client/view code. - Independent `run.git` protocol, lifecycle executor, worker diagnostics, tests, and release compatibility metadata. No Run source is added to this repository. - Operator workflow: opening an authorized stopped-server editor reads protected deployment values; normal list/detail/job/log/audit responses remain redacted. +- SCUM guided deployment: first-party manifest declaration, Platform's frozen assignment, and the independent Run executor. diff --git a/openspec/changes/synchronize-run-deployment-execution/specs/run-deployment-execution-receipts/spec.md b/openspec/changes/synchronize-run-deployment-execution/specs/run-deployment-execution-receipts/spec.md index 4906ec0..ce7c5d8 100644 --- a/openspec/changes/synchronize-run-deployment-execution/specs/run-deployment-execution-receipts/spec.md +++ b/openspec/changes/synchronize-run-deployment-execution/specs/run-deployment-execution-receipts/spec.md @@ -32,3 +32,18 @@ Platform SHALL preserve a deployment value when an update omits it and SHALL cle #### Scenario: Optional command is cleared - **WHEN** an editor submits `stopCommand` in its explicit clear list - **THEN** Platform SHALL store no stop command for the next deployment revision + +### Requirement: SCUM guided deployment is template-owned +For a frozen SCUM guided-install assignment, Run SHALL execute the declared SCUM installation template rather than requiring an operator-provided install or start command. The operator supplies only the protected target root and plugin create inputs. + +#### Scenario: Missing runtime prerequisites are installed silently +- **WHEN** a Windows SCUM guided-install detects a required declared Microsoft runtime is absent +- **THEN** Run SHALL obtain the approved Microsoft artifact, invoke only its fixed silent installer arguments, verify the runtime marker, and continue only when verification passes + +#### Scenario: SteamCMD installs into the selected root +- **WHEN** a valid SCUM guided-install is executed for root `C:\\scumserver` +- **THEN** Run SHALL invoke SteamCMD with `+force_install_dir C:\\scumserver`, anonymous login, App `3792580`, validation, and quit as separate argument values + +#### Scenario: Guided execution does not disclose raw values remotely +- **WHEN** Run completes or fails a SCUM guided-install +- **THEN** its progress, result, evidence, artifact names, and uploaded logs SHALL contain no raw root, command line, credential, or installer URL diff --git a/openspec/changes/synchronize-run-deployment-execution/tasks.md b/openspec/changes/synchronize-run-deployment-execution/tasks.md index ac6e8fc..cb8296d 100644 --- a/openspec/changes/synchronize-run-deployment-execution/tasks.md +++ b/openspec/changes/synchronize-run-deployment-execution/tasks.md @@ -8,7 +8,8 @@ - [x] 2.1 Add typed deployment execution version/receipt contracts and validation for frozen lifecycle jobs. - [x] 2.2 Require compatible Run capability before lifecycle dispatch and project validated Run confirmation in deployment views. -- [ ] 2.3 Add explicit deployment-field clearing and preserve omitted shell values; cover service/API behavior with tests. +- [x] 2.3 Add explicit deployment-field clearing and preserve omitted shell values; cover service/API behavior with tests. +- [x] 2.4 Freeze SCUM prerequisite/template execution data into the leased deployment plan and validate it as a first-party Windows-only guided installation contract. ## 3. Platform editor workflow @@ -20,10 +21,12 @@ - [x] 4.1 Extend independent `run.git` protocol and validation with the typed deployment envelope and safe execution receipt. - [x] 4.2 Execute deployment-aware lifecycle actions using the frozen root, working directory, shell, and commands. - [x] 4.3 Add opt-in local-only raw startup diagnostics that bypass the upload log sink and exclude credentials/environment values. -- [ ] 4.4 Add Run protocol/runtime tests covering execution, receipt fences, enabled diagnostics, and remote-channel redaction. +- [x] 4.4 Add Run protocol/runtime tests covering execution, receipt fences, enabled diagnostics, and remote-channel redaction. +- [x] 4.5 Implement the SCUM guided executor: dependency probes and silent Microsoft prerequisite installation, SteamCMD bootstrap, `+force_install_dir` installation, configuration materialization, verification evidence, and local-only diagnostic output. ## 5. Verification and delivery - [x] 5.1 Run browser Platform/backend/frontend validation and strict OpenSpec validation. - [x] 5.2 Run independent Run test suite and inspect both worktrees for scoped changes only. - [x] 5.3 Stage, commit, and push scoped changes on `main` in both repositories. +- [x] 5.4 Replace raw lifecycle capability labels in the server-management task views and run focused UI tests. diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 98d645f..f413bd6 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -545,6 +545,13 @@ type RuntimeServerVerificationCheck struct { Required bool } +// RuntimeServerPrerequisite is a plugin-declared named prerequisite resolved +// only through Run's fixed, platform-specific installer catalog. +type RuntimeServerPrerequisite struct { + Key string + Kind string +} + // RuntimeServerDeploymentProfile is a frozen, game-specific deployment // template. It contains logical references only; Run resolves them locally. type RuntimeServerDeploymentProfile struct { @@ -556,6 +563,7 @@ type RuntimeServerDeploymentProfile struct { InstallRootKey string ConfigKey string ConfigFormat string + Prerequisites []RuntimeServerPrerequisite ConfigMappings []RuntimeServerConfigMapping DiscoveryMarkers []RuntimeServerDiscoveryMarker VerificationChecks []RuntimeServerVerificationCheck @@ -847,6 +855,8 @@ type ServerDeploymentUpdate struct { StopCommand string StatusCommand string Shell ServerCommandShell + ShellSet bool + ClearFields []string } // ServerDeploymentView is the intentionally redacted read model. @@ -1068,6 +1078,7 @@ type ServerDeploymentPlan struct { InstallRootKey string ConfigKey string ConfigFormat string + Prerequisites []RuntimeServerPrerequisite ConfigMappings []RuntimeServerConfigMapping DiscoveryMarkers []RuntimeServerDiscoveryMarker VerificationChecks []RuntimeServerVerificationCheck @@ -1773,6 +1784,7 @@ func CopyServerInstance(instance ServerInstance) ServerInstance { } func CopyRuntimeServerDeploymentProfile(profile RuntimeServerDeploymentProfile) RuntimeServerDeploymentProfile { + profile.Prerequisites = append([]RuntimeServerPrerequisite(nil), profile.Prerequisites...) profile.SupportedTargets = append([]RuntimeTarget(nil), profile.SupportedTargets...) profile.ConfigMappings = append([]RuntimeServerConfigMapping(nil), profile.ConfigMappings...) profile.DiscoveryMarkers = append([]RuntimeServerDiscoveryMarker(nil), profile.DiscoveryMarkers...) @@ -1792,6 +1804,7 @@ func CopyServerDeploymentPlan(plan *ServerDeploymentPlan) *ServerDeploymentPlan return nil } copy := *plan + copy.Prerequisites = append([]RuntimeServerPrerequisite(nil), plan.Prerequisites...) copy.ConfigMappings = append([]RuntimeServerConfigMapping(nil), plan.ConfigMappings...) copy.DiscoveryMarkers = append([]RuntimeServerDiscoveryMarker(nil), plan.DiscoveryMarkers...) copy.VerificationChecks = append([]RuntimeServerVerificationCheck(nil), plan.VerificationChecks...) @@ -1824,6 +1837,7 @@ func CopyServerDeploymentDefinition(definition ServerDeploymentDefinition) Serve } func CopyServerDeploymentUpdate(update ServerDeploymentUpdate) ServerDeploymentUpdate { + update.ClearFields = CopyStringSlice(update.ClearFields) update.RuntimeBindings = CopyStringMap(update.RuntimeBindings) update.CreateInputs = CopyStringMap(update.CreateInputs) return update diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 7ff1f2c..06a2908 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -121,6 +121,7 @@ type ServerDeploymentPlanBody struct { InstallRootKey string `json:"installRootKey"` ConfigKey string `json:"configKey"` ConfigFormat string `json:"configFormat"` + Prerequisites []RuntimeServerPrerequisiteBody `json:"prerequisites,omitempty"` ConfigMappings []RuntimeServerConfigMappingBody `json:"configMappings"` DiscoveryMarkers []RuntimeServerDiscoveryMarkerBody `json:"discoveryMarkers"` VerificationChecks []RuntimeServerVerificationCheckBody `json:"verificationChecks"` @@ -643,6 +644,9 @@ func serverDeploymentPlanFromDomain(plan *domain.ServerDeploymentPlan) *ServerDe return nil } body := &ServerDeploymentPlanBody{SchemaVersion: plan.SchemaVersion, Operation: plan.Operation, PluginID: plan.PluginID, TemplateKey: plan.TemplateKey, TemplateVersion: plan.TemplateVersion, SteamAppID: plan.SteamAppID, ExecutableKey: plan.ExecutableKey, InstallRootKey: plan.InstallRootKey, ConfigKey: plan.ConfigKey, ConfigFormat: plan.ConfigFormat} + for _, prerequisite := range plan.Prerequisites { + body.Prerequisites = append(body.Prerequisites, RuntimeServerPrerequisiteBody{Key: prerequisite.Key, Kind: prerequisite.Kind}) + } for _, mapping := range plan.ConfigMappings { body.ConfigMappings = append(body.ConfigMappings, RuntimeServerConfigMappingBody{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required}) } diff --git a/platform/dto/runtime_profiles.go b/platform/dto/runtime_profiles.go index 8ba3156..83c5546 100644 --- a/platform/dto/runtime_profiles.go +++ b/platform/dto/runtime_profiles.go @@ -89,11 +89,17 @@ type RuntimeServerDeploymentProfileBody struct { InstallRootKey string `json:"installRootKey"` ConfigKey string `json:"configKey"` ConfigFormat string `json:"configFormat"` + Prerequisites []RuntimeServerPrerequisiteBody `json:"prerequisites,omitempty"` ConfigMappings []RuntimeServerConfigMappingBody `json:"configMappings"` DiscoveryMarkers []RuntimeServerDiscoveryMarkerBody `json:"discoveryMarkers"` VerificationChecks []RuntimeServerVerificationCheckBody `json:"verificationChecks"` } +type RuntimeServerPrerequisiteBody struct { + Key string `json:"key"` + Kind string `json:"kind"` +} + type RuntimeLogSourceBody struct { Key string `json:"key"` Kind string `json:"kind"` @@ -297,6 +303,9 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro } for _, item := range body.ServerDeployments { profile := domain.RuntimeServerDeploymentProfile{Key: item.Key, Version: item.Version, SteamAppID: item.SteamAppID, ExecutableKey: item.ExecutableKey, InstallRootKey: item.InstallRootKey, ConfigKey: item.ConfigKey, ConfigFormat: item.ConfigFormat} + for _, prerequisite := range item.Prerequisites { + profile.Prerequisites = append(profile.Prerequisites, domain.RuntimeServerPrerequisite{Key: prerequisite.Key, Kind: prerequisite.Kind}) + } for _, target := range item.SupportedTargets { profile.SupportedTargets = append(profile.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch}) } @@ -370,6 +379,9 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl } for _, item := range profiles.ServerDeployments { bodyProfile := RuntimeServerDeploymentProfileBody{Key: item.Key, Version: item.Version, SteamAppID: item.SteamAppID, ExecutableKey: item.ExecutableKey, InstallRootKey: item.InstallRootKey, ConfigKey: item.ConfigKey, ConfigFormat: item.ConfigFormat} + for _, prerequisite := range item.Prerequisites { + bodyProfile.Prerequisites = append(bodyProfile.Prerequisites, RuntimeServerPrerequisiteBody{Key: prerequisite.Key, Kind: prerequisite.Kind}) + } for _, target := range item.SupportedTargets { bodyProfile.SupportedTargets = append(bodyProfile.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch}) } diff --git a/platform/dto/server_lifecycle.go b/platform/dto/server_lifecycle.go index b3ce74b..d8a0ee2 100644 --- a/platform/dto/server_lifecycle.go +++ b/platform/dto/server_lifecycle.go @@ -18,7 +18,8 @@ type ServerDeploymentRequest struct { StartCommand string `json:"startCommand,omitempty"` StopCommand string `json:"stopCommand,omitempty"` StatusCommand string `json:"statusCommand,omitempty"` - Shell domain.ServerCommandShell `json:"shell,omitempty"` + Shell *domain.ServerCommandShell `json:"shell,omitempty"` + ClearFields []string `json:"clearFields,omitempty"` } type ServerDeploymentResponse struct { @@ -114,7 +115,11 @@ func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCre } func (request ServerDeploymentRequest) ToDomain() domain.ServerDeploymentUpdate { - return domain.ServerDeploymentUpdate{RunEndpointID: request.RunEndpointID, Mode: request.Mode, ProfileKey: request.ProfileKey, RuntimeBindings: domain.CopyStringMap(request.RuntimeBindings), CreateInputs: domain.CopyStringMap(request.CreateInputs), ServerRoot: request.ServerRoot, WorkingDirectory: request.WorkingDirectory, InstallCommand: request.InstallCommand, StartCommand: request.StartCommand, StopCommand: request.StopCommand, StatusCommand: request.StatusCommand, Shell: request.Shell} + update := domain.ServerDeploymentUpdate{RunEndpointID: request.RunEndpointID, Mode: request.Mode, ProfileKey: request.ProfileKey, RuntimeBindings: domain.CopyStringMap(request.RuntimeBindings), CreateInputs: domain.CopyStringMap(request.CreateInputs), ServerRoot: request.ServerRoot, WorkingDirectory: request.WorkingDirectory, InstallCommand: request.InstallCommand, StartCommand: request.StartCommand, StopCommand: request.StopCommand, StatusCommand: request.StatusCommand, ClearFields: domain.CopyStringSlice(request.ClearFields)} + if request.Shell != nil { + update.Shell, update.ShellSet = *request.Shell, true + } + return update } func (request ServerDeploymentRequest) deploymentDefinition() domain.ServerDeploymentDefinition { diff --git a/platform/protocol/server-deployment.md b/platform/protocol/server-deployment.md index d5fb817..eb1bc4f 100644 --- a/platform/protocol/server-deployment.md +++ b/platform/protocol/server-deployment.md @@ -48,6 +48,15 @@ resolved path or command text. Successful result kinds are `scum.install.completed` and `scum.adopt.completed`; failed result kinds are `scum.install.failed` and `scum.adopt.failed`. +The first-party Windows SCUM template declares `steamcmd`, Visual C++ 2012, +2013, and 2015-2022 (x86 and x64), plus DirectX runtime prerequisites. Run +checks their Windows markers before installation and uses only its fixed +Microsoft installer catalog with silent arguments when one is absent. The +template builds SteamCMD as separate arguments: `+force_install_dir`, the +protected root, anonymous login, App `3792580`, `validate`, and `+quit`. The +original root and constructed command are eligible only for opt-in local Run +diagnostics; they never enter progress or result payloads. + An `existing-server` plan may omit installation. A `custom-command` plan requires a start command. Guided templates remain plugin recommendations; Run owns their local resolution and execution. diff --git a/platform/service/scum_deployment.go b/platform/service/scum_deployment.go index 729342b..e89669a 100644 --- a/platform/service/scum_deployment.go +++ b/platform/service/scum_deployment.go @@ -42,6 +42,15 @@ func scumDeploymentPlan(plugin domain.GamePlugin, definition domain.ServerDeploy if strings.TrimSpace(profile.Key) == "" || strings.TrimSpace(profile.Version) == "" || profile.SteamAppID == "" { return nil, validationError("SCUM deployment template is incomplete") } + expectedPrerequisites := map[string]string{"steamcmd": "steamcmd", "vcredist-2012-x86": "windows-vcredist", "vcredist-2012-x64": "windows-vcredist", "vcredist-2013-x86": "windows-vcredist", "vcredist-2013-x64": "windows-vcredist", "vcredist-2015-2022-x86": "windows-vcredist", "vcredist-2015-2022-x64": "windows-vcredist", "directx-jun2010": "windows-directx"} + if len(profile.Prerequisites) != len(expectedPrerequisites) { + return nil, validationError("SCUM deployment template prerequisite list is incomplete") + } + for _, prerequisite := range profile.Prerequisites { + if expectedPrerequisites[prerequisite.Key] != prerequisite.Kind { + return nil, validationError("SCUM deployment template prerequisite is invalid") + } + } fieldKeys := make(map[string]struct{}, len(plugin.CreateFields)) for _, field := range plugin.CreateFields { fieldKeys[field.Key] = struct{}{} @@ -70,6 +79,7 @@ func scumDeploymentPlan(plugin domain.GamePlugin, definition domain.ServerDeploy InstallRootKey: profile.InstallRootKey, ConfigKey: profile.ConfigKey, ConfigFormat: profile.ConfigFormat, + Prerequisites: append([]domain.RuntimeServerPrerequisite(nil), profile.Prerequisites...), ConfigMappings: append([]domain.RuntimeServerConfigMapping(nil), profile.ConfigMappings...), DiscoveryMarkers: append([]domain.RuntimeServerDiscoveryMarker(nil), profile.DiscoveryMarkers...), VerificationChecks: append([]domain.RuntimeServerVerificationCheck(nil), profile.VerificationChecks...), diff --git a/platform/service/scum_deployment_test.go b/platform/service/scum_deployment_test.go index 23c31ee..53d1213 100644 --- a/platform/service/scum_deployment_test.go +++ b/platform/service/scum_deployment_test.go @@ -17,6 +17,7 @@ func scumDeploymentTestPlugin() domain.GamePlugin { }, RuntimeProfiles: domain.GamePluginRuntimeProfiles{ServerDeployments: []domain.RuntimeServerDeploymentProfile{{ Key: "scum-steamcmd-windows", Version: "1.0.0", SteamAppID: "3792580", ExecutableKey: "scum/server-executable", InstallRootKey: "server/install-root", ConfigKey: "scum/server-settings", ConfigFormat: "ini", + Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "steamcmd"}, {Key: "vcredist-2012-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2012-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x64", Kind: "windows-vcredist"}, {Key: "directx-jun2010", Kind: "windows-directx"}}, ConfigMappings: []domain.RuntimeServerConfigMapping{{FieldKey: "serverName", ConfigKey: "server-settings.server-name", ValueType: "text", Required: true}, {FieldKey: "gamePort", ConfigKey: "server-settings.game-port", ValueType: "port", Required: true}, {FieldKey: "queryPort", ConfigKey: "server-settings.query-port", ValueType: "port", Required: true}, {FieldKey: "maxPlayers", ConfigKey: "server-settings.max-players", ValueType: "integer", Required: true}}, VerificationChecks: []domain.RuntimeServerVerificationCheck{{Key: "executable", Kind: "executable.present", TargetKey: "scum/server-executable", Required: true}, {Key: "version", Kind: "version.matches", TargetKey: "scum/server-executable", Required: true}, {Key: "game-port", Kind: "port.bound", TargetKey: "game-port", Required: true}, {Key: "config", Kind: "config.readable", TargetKey: "scum/server-settings", Required: true}, {Key: "process", Kind: "process.healthy", TargetKey: "scum/server-executable", Required: true}}, }}}, diff --git a/platform/service/server_deployment.go b/platform/service/server_deployment.go index b70e4f5..9689344 100644 --- a/platform/service/server_deployment.go +++ b/platform/service/server_deployment.go @@ -38,6 +38,13 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan if instance.State == domain.ServerInstanceStateInstalling || instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateDeleted { return domain.ServerDeploymentView{}, validationError("deployment definition cannot be changed while the server is active") } + seenClearFields := map[string]bool{} + for _, field := range update.ClearFields { + if seenClearFields[field] || field != "serverRoot" && field != "workingDirectory" && field != "installCommand" && field != "startCommand" && field != "stopCommand" && field != "statusCommand" && field != "shell" { + return domain.ServerDeploymentView{}, validationError("deployment clearFields contains an invalid field") + } + seenClearFields[field] = true + } definition := mergeDeploymentDefinition(instance.Deployment, update) definition.Revision = instance.Deployment.Revision + 1 definition.UpdatedAt = svc.now() @@ -194,6 +201,24 @@ func deploymentShellCapability(shell domain.ServerCommandShell) string { func mergeDeploymentDefinition(current domain.ServerDeploymentDefinition, update domain.ServerDeploymentUpdate) domain.ServerDeploymentDefinition { definition := domain.CopyServerDeploymentDefinition(current) + for _, field := range update.ClearFields { + switch field { + case "serverRoot": + definition.ServerRoot = "" + case "workingDirectory": + definition.WorkingDirectory = "" + case "installCommand": + definition.InstallCommand = "" + case "startCommand": + definition.StartCommand = "" + case "stopCommand": + definition.StopCommand = "" + case "statusCommand": + definition.StatusCommand = "" + case "shell": + definition.Shell = domain.ServerCommandShellNone + } + } definition.Mode = update.Mode if update.ProfileKey != "" { definition.ProfileKey = update.ProfileKey @@ -222,7 +247,9 @@ func mergeDeploymentDefinition(current domain.ServerDeploymentDefinition, update if update.StatusCommand != "" { definition.StatusCommand = update.StatusCommand } - definition.Shell = update.Shell + if update.ShellSet { + definition.Shell = update.Shell + } return definition } diff --git a/platform/service/server_deployment_test.go b/platform/service/server_deployment_test.go index 3a37dd7..895ca72 100644 --- a/platform/service/server_deployment_test.go +++ b/platform/service/server_deployment_test.go @@ -64,3 +64,15 @@ func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibl t.Fatalf("expected safe dispatch evidence, view=%+v err=%v", view, err) } } + +func TestMergeDeploymentPreservesOmittedShellAndClearsOnlyExplicitFields(t *testing.T) { + current := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/game", StartCommand: "./start", StopCommand: "./stop", Shell: domain.ServerCommandShellCmd} + preserved := mergeDeploymentDefinition(current, domain.ServerDeploymentUpdate{Mode: domain.ServerDeploymentModeCustom}) + if preserved.Shell != domain.ServerCommandShellCmd || preserved.StopCommand != "./stop" { + t.Fatalf("omitted protected fields must be preserved: %+v", preserved) + } + cleared := mergeDeploymentDefinition(current, domain.ServerDeploymentUpdate{Mode: domain.ServerDeploymentModeCustom, ClearFields: []string{"stopCommand", "shell"}}) + if cleared.StopCommand != "" || cleared.Shell != domain.ServerCommandShellNone || cleared.StartCommand != "./start" { + t.Fatalf("only explicitly cleared fields may be removed: %+v", cleared) + } +} diff --git a/platform/validator/runtime_profiles.go b/platform/validator/runtime_profiles.go index 7206fb6..8445d46 100644 --- a/platform/validator/runtime_profiles.go +++ b/platform/validator/runtime_profiles.go @@ -170,6 +170,18 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles if len(profile.SupportedTargets) == 0 { violations = append(violations, prefix+".supportedTargets must not be empty") } + prerequisiteKeys := map[string]struct{}{} + for j, prerequisite := range profile.Prerequisites { + prerequisitePrefix := fmt.Sprintf("%s.prerequisites[%d]", prefix, j) + violations = append(violations, validateProfileKey(prerequisitePrefix+".key", prerequisite.Key)...) + if _, exists := prerequisiteKeys[prerequisite.Key]; exists { + violations = append(violations, prerequisitePrefix+".key duplicates another prerequisite") + } + prerequisiteKeys[prerequisite.Key] = struct{}{} + if !oneOf(prerequisite.Kind, "steamcmd", "windows-vcredist", "windows-directx") { + violations = append(violations, prerequisitePrefix+".kind is invalid") + } + } for j, target := range profile.SupportedTargets { if !validPluginSupportedOS(target.OS) || !oneOf(target.Arch, "amd64", "arm64") { violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is invalid", prefix, j)) diff --git a/platform_web/contracts/jobPresentation.test.ts b/platform_web/contracts/jobPresentation.test.ts new file mode 100644 index 0000000..529c017 --- /dev/null +++ b/platform_web/contracts/jobPresentation.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import { jobCapabilityLabel } from "./jobPresentation"; + +describe("jobCapabilityLabel", () => { + it("keeps Run routing identifiers out of operator task titles", () => { + expect(jobCapabilityLabel("process.install")).toBe("安装服务端"); + expect(jobCapabilityLabel("dependencies.install")).toBe("安装运行依赖"); + }); +}); diff --git a/platform_web/contracts/jobPresentation.ts b/platform_web/contracts/jobPresentation.ts new file mode 100644 index 0000000..4e6d7be --- /dev/null +++ b/platform_web/contracts/jobPresentation.ts @@ -0,0 +1,11 @@ +export function jobCapabilityLabel(capability: string): string { + switch (capability) { + case "process.install": return "安装服务端"; + case "process.start": return "启动服务器"; + case "process.stop": return "停止服务器"; + case "process.status": return "检查服务器状态"; + case "dependencies.check": return "检查运行依赖"; + case "dependencies.install": return "安装运行依赖"; + default: return capability; + } +} diff --git a/platform_web/pages/HomePage.tsx b/platform_web/pages/HomePage.tsx index 1074f6d..c1f88d5 100644 --- a/platform_web/pages/HomePage.tsx +++ b/platform_web/pages/HomePage.tsx @@ -28,6 +28,7 @@ import { UsageMeter } from "../components/OperationControls"; import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; import { EmptyState, ErrorState, LoadingState } from "../components/StateViews"; import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole"; +import { jobCapabilityLabel } from "../contracts/jobPresentation"; import type { PageComponentProps } from "../contracts/page"; import type { GameTypeDistributionEntry, PlatformOverviewSignal } from "../contracts/workspace"; import { serverIsOnline } from "../contracts/workspace"; @@ -249,7 +250,7 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) { className={cx("console-row console-row-button", job.state === "failed" && "console-row-danger")} onClick={() => onNavigate(job.serverInstanceId ? "serverDetail" : "maintenance", job.serverInstanceId ? { serverId: job.serverInstanceId } : undefined)} > - {job.capability}{job.serverInstanceId ? `服务器 ${job.serverInstanceId}` : "平台任务"} + {jobCapabilityLabel(job.capability)}{job.serverInstanceId ? `服务器 ${job.serverInstanceId}` : "平台任务"} {job.state === "retrying" ? `重试 ${job.attempt}/${job.retryPolicy.maxAttempts}` : jobStateLabel(job.state)} {Math.round(job.progress.percent)}% @@ -382,7 +383,7 @@ function buildOverviewSignals( collected.push({ id: `fault-${instance.id}`, kind: "fault", summary: `服务器 ${instance.name} 处于异常状态`, detail: `实例 ${instance.id} 需要排查`, targetPage: "servers", targetId: instance.id, tone: "error", at: instance.updatedAt }); } for (const job of jobBuckets(core.data.jobs).failed.slice(0, 5)) { - collected.push({ id: `job-${job.id}`, kind: "job", summary: `任务 ${job.capability} 失败`, detail: job.serverInstanceId ? `服务器 ${job.serverInstanceId} 的任务执行失败` : "平台任务执行失败", targetPage: job.serverInstanceId ? "servers" : "maintenance", targetId: job.serverInstanceId, tone: "error", at: job.updatedAt }); + collected.push({ id: `job-${job.id}`, kind: "job", summary: `任务 ${jobCapabilityLabel(job.capability)} 失败`, detail: job.serverInstanceId ? `服务器 ${job.serverInstanceId} 的任务执行失败` : "平台任务执行失败", targetPage: job.serverInstanceId ? "servers" : "maintenance", targetId: job.serverInstanceId, tone: "error", at: job.updatedAt }); } } if (providers.status === "ready") { diff --git a/platform_web/pages/MaintenancePage.tsx b/platform_web/pages/MaintenancePage.tsx index 1efe75e..0e87bc7 100644 --- a/platform_web/pages/MaintenancePage.tsx +++ b/platform_web/pages/MaintenancePage.tsx @@ -5,6 +5,7 @@ import { platformApiClient } from "../api/client"; import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; +import { jobCapabilityLabel } from "../contracts/jobPresentation"; import type { PageComponentProps } from "../contracts/page"; import { cx } from "../utils/classes"; @@ -227,7 +228,7 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone return (
- {job.capability} + {jobCapabilityLabel(job.capability)} {jobStateLabel(job.state)}
diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 76ef08b..851783d 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -48,6 +48,7 @@ import { } from "../components/RuntimeTaskProgress"; import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; +import { jobCapabilityLabel } from "../contracts/jobPresentation"; import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge"; import { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement"; import { @@ -2348,7 +2349,7 @@ function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHis {jobs.slice(0, 20).map((job) => (
- {job.capability} + {jobCapabilityLabel(job.capability)} {job.state} diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 5877cea..eba06ef 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -585,6 +585,16 @@ "installRootKey": "server/install-root", "configKey": "scum/server-settings", "configFormat": "ini", + "prerequisites": [ + { "key": "steamcmd", "kind": "steamcmd" }, + { "key": "vcredist-2012-x86", "kind": "windows-vcredist" }, + { "key": "vcredist-2012-x64", "kind": "windows-vcredist" }, + { "key": "vcredist-2013-x86", "kind": "windows-vcredist" }, + { "key": "vcredist-2013-x64", "kind": "windows-vcredist" }, + { "key": "vcredist-2015-2022-x86", "kind": "windows-vcredist" }, + { "key": "vcredist-2015-2022-x64", "kind": "windows-vcredist" }, + { "key": "directx-jun2010", "kind": "windows-directx" } + ], "configMappings": [ { "fieldKey": "serverName", "configKey": "server-settings.server-name", "valueType": "text", "required": true }, { "fieldKey": "gamePort", "configKey": "server-settings.game-port", "valueType": "port", "required": true }, diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 8467e0b..01c6945 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -573,11 +573,21 @@ "installRootKey": { "$ref": "#/$defs/logicalKey" }, "configKey": { "$ref": "#/$defs/logicalKey" }, "configFormat": { "enum": ["ini", "json", "yaml", "properties"] }, + "prerequisites": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerPrerequisite" }, "maxItems": 16 }, "configMappings": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerConfigMapping" }, "minItems": 1, "maxItems": 32, "uniqueItems": true }, "discoveryMarkers": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerDiscoveryMarker" }, "minItems": 1, "maxItems": 32, "uniqueItems": true }, "verificationChecks": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerVerificationCheck" }, "minItems": 5, "maxItems": 16, "uniqueItems": true } } }, + "runtimeServerPrerequisite": { + "type": "object", + "required": ["key", "kind"], + "additionalProperties": false, + "properties": { + "key": { "$ref": "#/$defs/logicalKey" }, + "kind": { "enum": ["steamcmd", "windows-vcredist", "windows-directx"] } + } + }, "runtimeLogSource": { "type": "object", "required": ["key", "kind", "streamKey"],