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
+14
View File
@@ -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
+4
View File
@@ -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})
}
+12
View File
@@ -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})
}
+7 -2
View File
@@ -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 {
+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.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.
+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 == "" {
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...),
+1
View File
@@ -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}},
}}},
+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 {
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
}
@@ -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)
}
}
+12
View File
@@ -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))