feat: declare and execute SCUM guided installs

This commit is contained in:
npc0-hue
2026-07-27 20:09:14 +08:00
parent 08fa5488f2
commit 2ae27bb746
21 changed files with 185 additions and 9 deletions
@@ -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. 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 ## 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. - [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. - [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. - [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. - [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 ## Migration Plan
@@ -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. - 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. - 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. - 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 ## 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. - 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. - 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. - 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.
@@ -32,3 +32,18 @@ Platform SHALL preserve a deployment value when an update omits it and SHALL cle
#### Scenario: Optional command is cleared #### Scenario: Optional command is cleared
- **WHEN** an editor submits `stopCommand` in its explicit clear list - **WHEN** an editor submits `stopCommand` in its explicit clear list
- **THEN** Platform SHALL store no stop command for the next deployment revision - **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
@@ -8,7 +8,8 @@
- [x] 2.1 Add typed deployment execution version/receipt contracts and validation for frozen lifecycle jobs. - [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. - [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 ## 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.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.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. - [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 ## 5. Verification and delivery
- [x] 5.1 Run browser Platform/backend/frontend validation and strict OpenSpec validation. - [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.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.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.
+14
View File
@@ -545,6 +545,13 @@ type RuntimeServerVerificationCheck struct {
Required bool 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 // RuntimeServerDeploymentProfile is a frozen, game-specific deployment
// template. It contains logical references only; Run resolves them locally. // template. It contains logical references only; Run resolves them locally.
type RuntimeServerDeploymentProfile struct { type RuntimeServerDeploymentProfile struct {
@@ -556,6 +563,7 @@ type RuntimeServerDeploymentProfile struct {
InstallRootKey string InstallRootKey string
ConfigKey string ConfigKey string
ConfigFormat string ConfigFormat string
Prerequisites []RuntimeServerPrerequisite
ConfigMappings []RuntimeServerConfigMapping ConfigMappings []RuntimeServerConfigMapping
DiscoveryMarkers []RuntimeServerDiscoveryMarker DiscoveryMarkers []RuntimeServerDiscoveryMarker
VerificationChecks []RuntimeServerVerificationCheck VerificationChecks []RuntimeServerVerificationCheck
@@ -847,6 +855,8 @@ type ServerDeploymentUpdate struct {
StopCommand string StopCommand string
StatusCommand string StatusCommand string
Shell ServerCommandShell Shell ServerCommandShell
ShellSet bool
ClearFields []string
} }
// ServerDeploymentView is the intentionally redacted read model. // ServerDeploymentView is the intentionally redacted read model.
@@ -1068,6 +1078,7 @@ type ServerDeploymentPlan struct {
InstallRootKey string InstallRootKey string
ConfigKey string ConfigKey string
ConfigFormat string ConfigFormat string
Prerequisites []RuntimeServerPrerequisite
ConfigMappings []RuntimeServerConfigMapping ConfigMappings []RuntimeServerConfigMapping
DiscoveryMarkers []RuntimeServerDiscoveryMarker DiscoveryMarkers []RuntimeServerDiscoveryMarker
VerificationChecks []RuntimeServerVerificationCheck VerificationChecks []RuntimeServerVerificationCheck
@@ -1773,6 +1784,7 @@ func CopyServerInstance(instance ServerInstance) ServerInstance {
} }
func CopyRuntimeServerDeploymentProfile(profile RuntimeServerDeploymentProfile) RuntimeServerDeploymentProfile { func CopyRuntimeServerDeploymentProfile(profile RuntimeServerDeploymentProfile) RuntimeServerDeploymentProfile {
profile.Prerequisites = append([]RuntimeServerPrerequisite(nil), profile.Prerequisites...)
profile.SupportedTargets = append([]RuntimeTarget(nil), profile.SupportedTargets...) profile.SupportedTargets = append([]RuntimeTarget(nil), profile.SupportedTargets...)
profile.ConfigMappings = append([]RuntimeServerConfigMapping(nil), profile.ConfigMappings...) profile.ConfigMappings = append([]RuntimeServerConfigMapping(nil), profile.ConfigMappings...)
profile.DiscoveryMarkers = append([]RuntimeServerDiscoveryMarker(nil), profile.DiscoveryMarkers...) profile.DiscoveryMarkers = append([]RuntimeServerDiscoveryMarker(nil), profile.DiscoveryMarkers...)
@@ -1792,6 +1804,7 @@ func CopyServerDeploymentPlan(plan *ServerDeploymentPlan) *ServerDeploymentPlan
return nil return nil
} }
copy := *plan copy := *plan
copy.Prerequisites = append([]RuntimeServerPrerequisite(nil), plan.Prerequisites...)
copy.ConfigMappings = append([]RuntimeServerConfigMapping(nil), plan.ConfigMappings...) copy.ConfigMappings = append([]RuntimeServerConfigMapping(nil), plan.ConfigMappings...)
copy.DiscoveryMarkers = append([]RuntimeServerDiscoveryMarker(nil), plan.DiscoveryMarkers...) copy.DiscoveryMarkers = append([]RuntimeServerDiscoveryMarker(nil), plan.DiscoveryMarkers...)
copy.VerificationChecks = append([]RuntimeServerVerificationCheck(nil), plan.VerificationChecks...) copy.VerificationChecks = append([]RuntimeServerVerificationCheck(nil), plan.VerificationChecks...)
@@ -1824,6 +1837,7 @@ func CopyServerDeploymentDefinition(definition ServerDeploymentDefinition) Serve
} }
func CopyServerDeploymentUpdate(update ServerDeploymentUpdate) ServerDeploymentUpdate { func CopyServerDeploymentUpdate(update ServerDeploymentUpdate) ServerDeploymentUpdate {
update.ClearFields = CopyStringSlice(update.ClearFields)
update.RuntimeBindings = CopyStringMap(update.RuntimeBindings) update.RuntimeBindings = CopyStringMap(update.RuntimeBindings)
update.CreateInputs = CopyStringMap(update.CreateInputs) update.CreateInputs = CopyStringMap(update.CreateInputs)
return update return update
+4
View File
@@ -121,6 +121,7 @@ type ServerDeploymentPlanBody struct {
InstallRootKey string `json:"installRootKey"` InstallRootKey string `json:"installRootKey"`
ConfigKey string `json:"configKey"` ConfigKey string `json:"configKey"`
ConfigFormat string `json:"configFormat"` ConfigFormat string `json:"configFormat"`
Prerequisites []RuntimeServerPrerequisiteBody `json:"prerequisites,omitempty"`
ConfigMappings []RuntimeServerConfigMappingBody `json:"configMappings"` ConfigMappings []RuntimeServerConfigMappingBody `json:"configMappings"`
DiscoveryMarkers []RuntimeServerDiscoveryMarkerBody `json:"discoveryMarkers"` DiscoveryMarkers []RuntimeServerDiscoveryMarkerBody `json:"discoveryMarkers"`
VerificationChecks []RuntimeServerVerificationCheckBody `json:"verificationChecks"` VerificationChecks []RuntimeServerVerificationCheckBody `json:"verificationChecks"`
@@ -643,6 +644,9 @@ func serverDeploymentPlanFromDomain(plan *domain.ServerDeploymentPlan) *ServerDe
return nil 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} 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 { for _, mapping := range plan.ConfigMappings {
body.ConfigMappings = append(body.ConfigMappings, RuntimeServerConfigMappingBody{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required}) body.ConfigMappings = append(body.ConfigMappings, RuntimeServerConfigMappingBody{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required})
} }
+12
View File
@@ -89,11 +89,17 @@ type RuntimeServerDeploymentProfileBody struct {
InstallRootKey string `json:"installRootKey"` InstallRootKey string `json:"installRootKey"`
ConfigKey string `json:"configKey"` ConfigKey string `json:"configKey"`
ConfigFormat string `json:"configFormat"` ConfigFormat string `json:"configFormat"`
Prerequisites []RuntimeServerPrerequisiteBody `json:"prerequisites,omitempty"`
ConfigMappings []RuntimeServerConfigMappingBody `json:"configMappings"` ConfigMappings []RuntimeServerConfigMappingBody `json:"configMappings"`
DiscoveryMarkers []RuntimeServerDiscoveryMarkerBody `json:"discoveryMarkers"` DiscoveryMarkers []RuntimeServerDiscoveryMarkerBody `json:"discoveryMarkers"`
VerificationChecks []RuntimeServerVerificationCheckBody `json:"verificationChecks"` VerificationChecks []RuntimeServerVerificationCheckBody `json:"verificationChecks"`
} }
type RuntimeServerPrerequisiteBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
}
type RuntimeLogSourceBody struct { type RuntimeLogSourceBody struct {
Key string `json:"key"` Key string `json:"key"`
Kind string `json:"kind"` Kind string `json:"kind"`
@@ -297,6 +303,9 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
} }
for _, item := range body.ServerDeployments { 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} 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 { for _, target := range item.SupportedTargets {
profile.SupportedTargets = append(profile.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch}) 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 { 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} 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 { for _, target := range item.SupportedTargets {
bodyProfile.SupportedTargets = append(bodyProfile.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch}) bodyProfile.SupportedTargets = append(bodyProfile.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch})
} }
+7 -2
View File
@@ -18,7 +18,8 @@ type ServerDeploymentRequest struct {
StartCommand string `json:"startCommand,omitempty"` StartCommand string `json:"startCommand,omitempty"`
StopCommand string `json:"stopCommand,omitempty"` StopCommand string `json:"stopCommand,omitempty"`
StatusCommand string `json:"statusCommand,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 { type ServerDeploymentResponse struct {
@@ -114,7 +115,11 @@ func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCre
} }
func (request ServerDeploymentRequest) ToDomain() domain.ServerDeploymentUpdate { 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 { func (request ServerDeploymentRequest) deploymentDefinition() domain.ServerDeploymentDefinition {
+9
View File
@@ -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.completed` and `scum.adopt.completed`; failed result kinds are
`scum.install.failed` and `scum.adopt.failed`. `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 An `existing-server` plan may omit installation. A `custom-command` plan
requires a start command. Guided templates remain plugin recommendations; requires a start command. Guided templates remain plugin recommendations;
Run owns their local resolution and execution. Run owns their local resolution and execution.
+10
View File
@@ -42,6 +42,15 @@ func scumDeploymentPlan(plugin domain.GamePlugin, definition domain.ServerDeploy
if strings.TrimSpace(profile.Key) == "" || strings.TrimSpace(profile.Version) == "" || profile.SteamAppID == "" { if strings.TrimSpace(profile.Key) == "" || strings.TrimSpace(profile.Version) == "" || profile.SteamAppID == "" {
return nil, validationError("SCUM deployment template is incomplete") 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)) fieldKeys := make(map[string]struct{}, len(plugin.CreateFields))
for _, field := range plugin.CreateFields { for _, field := range plugin.CreateFields {
fieldKeys[field.Key] = struct{}{} fieldKeys[field.Key] = struct{}{}
@@ -70,6 +79,7 @@ func scumDeploymentPlan(plugin domain.GamePlugin, definition domain.ServerDeploy
InstallRootKey: profile.InstallRootKey, InstallRootKey: profile.InstallRootKey,
ConfigKey: profile.ConfigKey, ConfigKey: profile.ConfigKey,
ConfigFormat: profile.ConfigFormat, ConfigFormat: profile.ConfigFormat,
Prerequisites: append([]domain.RuntimeServerPrerequisite(nil), profile.Prerequisites...),
ConfigMappings: append([]domain.RuntimeServerConfigMapping(nil), profile.ConfigMappings...), ConfigMappings: append([]domain.RuntimeServerConfigMapping(nil), profile.ConfigMappings...),
DiscoveryMarkers: append([]domain.RuntimeServerDiscoveryMarker(nil), profile.DiscoveryMarkers...), DiscoveryMarkers: append([]domain.RuntimeServerDiscoveryMarker(nil), profile.DiscoveryMarkers...),
VerificationChecks: append([]domain.RuntimeServerVerificationCheck(nil), profile.VerificationChecks...), VerificationChecks: append([]domain.RuntimeServerVerificationCheck(nil), profile.VerificationChecks...),
+1
View File
@@ -17,6 +17,7 @@ func scumDeploymentTestPlugin() domain.GamePlugin {
}, },
RuntimeProfiles: domain.GamePluginRuntimeProfiles{ServerDeployments: []domain.RuntimeServerDeploymentProfile{{ 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", 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}}, 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}}, 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}},
}}}, }}},
+28 -1
View File
@@ -38,6 +38,13 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
if instance.State == domain.ServerInstanceStateInstalling || instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateDeleted { 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") 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 := mergeDeploymentDefinition(instance.Deployment, update)
definition.Revision = instance.Deployment.Revision + 1 definition.Revision = instance.Deployment.Revision + 1
definition.UpdatedAt = svc.now() definition.UpdatedAt = svc.now()
@@ -194,6 +201,24 @@ func deploymentShellCapability(shell domain.ServerCommandShell) string {
func mergeDeploymentDefinition(current domain.ServerDeploymentDefinition, update domain.ServerDeploymentUpdate) domain.ServerDeploymentDefinition { func mergeDeploymentDefinition(current domain.ServerDeploymentDefinition, update domain.ServerDeploymentUpdate) domain.ServerDeploymentDefinition {
definition := domain.CopyServerDeploymentDefinition(current) 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 definition.Mode = update.Mode
if update.ProfileKey != "" { if update.ProfileKey != "" {
definition.ProfileKey = update.ProfileKey definition.ProfileKey = update.ProfileKey
@@ -222,7 +247,9 @@ func mergeDeploymentDefinition(current domain.ServerDeploymentDefinition, update
if update.StatusCommand != "" { if update.StatusCommand != "" {
definition.StatusCommand = update.StatusCommand definition.StatusCommand = update.StatusCommand
} }
definition.Shell = update.Shell if update.ShellSet {
definition.Shell = update.Shell
}
return definition return definition
} }
@@ -64,3 +64,15 @@ func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibl
t.Fatalf("expected safe dispatch evidence, view=%+v err=%v", view, err) 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)
}
}
+12
View File
@@ -170,6 +170,18 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
if len(profile.SupportedTargets) == 0 { if len(profile.SupportedTargets) == 0 {
violations = append(violations, prefix+".supportedTargets must not be empty") 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 { for j, target := range profile.SupportedTargets {
if !validPluginSupportedOS(target.OS) || !oneOf(target.Arch, "amd64", "arm64") { if !validPluginSupportedOS(target.OS) || !oneOf(target.Arch, "amd64", "arm64") {
violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is invalid", prefix, j)) violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is invalid", prefix, j))
@@ -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("安装运行依赖");
});
});
+11
View File
@@ -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;
}
}
+3 -2
View File
@@ -28,6 +28,7 @@ import { UsageMeter } from "../components/OperationControls";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole"; import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole";
import { jobCapabilityLabel } from "../contracts/jobPresentation";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import type { GameTypeDistributionEntry, PlatformOverviewSignal } from "../contracts/workspace"; import type { GameTypeDistributionEntry, PlatformOverviewSignal } from "../contracts/workspace";
import { serverIsOnline } 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")} 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)} onClick={() => onNavigate(job.serverInstanceId ? "serverDetail" : "maintenance", job.serverInstanceId ? { serverId: job.serverInstanceId } : undefined)}
> >
<span><strong>{job.capability}</strong><small>{job.serverInstanceId ? `服务器 ${job.serverInstanceId}` : "平台任务"}</small></span> <span><strong>{jobCapabilityLabel(job.capability)}</strong><small>{job.serverInstanceId ? `服务器 ${job.serverInstanceId}` : "平台任务"}</small></span>
<span>{job.state === "retrying" ? `重试 ${job.attempt}/${job.retryPolicy.maxAttempts}` : jobStateLabel(job.state)}</span> <span>{job.state === "retrying" ? `重试 ${job.attempt}/${job.retryPolicy.maxAttempts}` : jobStateLabel(job.state)}</span>
<span>{Math.round(job.progress.percent)}%</span> <span>{Math.round(job.progress.percent)}%</span>
</button> </button>
@@ -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 }); 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)) { 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") { if (providers.status === "ready") {
+2 -1
View File
@@ -5,6 +5,7 @@ import { platformApiClient } from "../api/client";
import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types"; import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { jobCapabilityLabel } from "../contracts/jobPresentation";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { cx } from "../utils/classes"; import { cx } from "../utils/classes";
@@ -227,7 +228,7 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
return ( return (
<div key={job.id} className="console-record"> <div key={job.id} className="console-record">
<div className="console-record-head"> <div className="console-record-head">
<strong>{job.capability}</strong> <strong>{jobCapabilityLabel(job.capability)}</strong>
<span className="status-pill status-error">{jobStateLabel(job.state)}</span> <span className="status-pill status-error">{jobStateLabel(job.state)}</span>
</div> </div>
<div className="console-record-meta"> <div className="console-record-meta">
+2 -1
View File
@@ -48,6 +48,7 @@ import {
} from "../components/RuntimeTaskProgress"; } from "../components/RuntimeTaskProgress";
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { jobCapabilityLabel } from "../contracts/jobPresentation";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge"; import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
import { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement"; import { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement";
import { import {
@@ -2348,7 +2349,7 @@ function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHis
{jobs.slice(0, 20).map((job) => ( {jobs.slice(0, 20).map((job) => (
<div key={job.id} className="console-record"> <div key={job.id} className="console-record">
<div className="console-record-head"> <div className="console-record-head">
<strong>{job.capability}</strong> <strong>{jobCapabilityLabel(job.capability)}</strong>
<span className={cx("status-pill", job.state === "succeeded" ? "status-active" : job.state === "failed" ? "status-error" : "status-disabled")}> <span className={cx("status-pill", job.state === "succeeded" ? "status-active" : job.state === "failed" ? "status-error" : "status-disabled")}>
{job.state} {job.state}
</span> </span>
@@ -585,6 +585,16 @@
"installRootKey": "server/install-root", "installRootKey": "server/install-root",
"configKey": "scum/server-settings", "configKey": "scum/server-settings",
"configFormat": "ini", "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": [ "configMappings": [
{ "fieldKey": "serverName", "configKey": "server-settings.server-name", "valueType": "text", "required": true }, { "fieldKey": "serverName", "configKey": "server-settings.server-name", "valueType": "text", "required": true },
{ "fieldKey": "gamePort", "configKey": "server-settings.game-port", "valueType": "port", "required": true }, { "fieldKey": "gamePort", "configKey": "server-settings.game-port", "valueType": "port", "required": true },
@@ -573,11 +573,21 @@
"installRootKey": { "$ref": "#/$defs/logicalKey" }, "installRootKey": { "$ref": "#/$defs/logicalKey" },
"configKey": { "$ref": "#/$defs/logicalKey" }, "configKey": { "$ref": "#/$defs/logicalKey" },
"configFormat": { "enum": ["ini", "json", "yaml", "properties"] }, "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 }, "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 }, "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 } "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": { "runtimeLogSource": {
"type": "object", "type": "object",
"required": ["key", "kind", "streamKey"], "required": ["key", "kind", "streamKey"],