Remove legacy runtime deployment paths

This commit is contained in:
npc0-hue
2026-09-04 12:36:21 +08:00
parent 14cbc63e61
commit 3cfb98ed47
39 changed files with 407 additions and 589 deletions
+2 -2
View File
@@ -37,5 +37,5 @@ PLATFORM_BUILDER_SOURCE_REPOSITORY=git@git.npc0.com:admin343/run.git
PLATFORM_BUILDER_SOURCE_REVISION=main PLATFORM_BUILDER_SOURCE_REVISION=main
PLATFORM_BUILDER_WORKSPACE_DIR=.platform-data/distribution-builds PLATFORM_BUILDER_WORKSPACE_DIR=.platform-data/distribution-builds
PLATFORM_BUILDER_TIMEOUT_SECONDS=1800 PLATFORM_BUILDER_TIMEOUT_SECONDS=1800
# URL embedded into generated Run and client-manager packages. # URL embedded into generated Run packages; override for tunnel or production access.
PLATFORM_RUN_RELEASE_URL=https://scum.npc0.com PLATFORM_RUN_RELEASE_URL=http://127.0.0.1:8080
+1 -1
View File
@@ -62,7 +62,7 @@ Runtime configuration:
- `PLATFORM_BUILDER_WORKSPACE_DIR`: private per-plugin/per-job build workspace, default `<PLATFORM_DATA_DIR>/distribution-builds`. - `PLATFORM_BUILDER_WORKSPACE_DIR`: private per-plugin/per-job build workspace, default `<PLATFORM_DATA_DIR>/distribution-builds`.
- `PLATFORM_BUILDER_CACHE_DIR`: persistent Go build/module cache, default `<PLATFORM_DATA_DIR>/distribution-build-cache`; it contains no job inputs or component keys. - `PLATFORM_BUILDER_CACHE_DIR`: persistent Go build/module cache, default `<PLATFORM_DATA_DIR>/distribution-build-cache`; it contains no job inputs or component keys.
- `PLATFORM_BUILDER_TIMEOUT_SECONDS`: positive build deadline, default `1800`. - `PLATFORM_BUILDER_TIMEOUT_SECONDS`: positive build deadline, default `1800`.
- `PLATFORM_RUN_RELEASE_URL`: public platform URL embedded into generated components, default `https://scum.npc0.com/`. - `PLATFORM_RUN_RELEASE_URL`: public platform URL embedded into generated Run packages, default `http://127.0.0.1:8080/`; set this explicitly for tunnel or production access.
Build the dedicated toolchain image before enabling distribution generation: Build the dedicated toolchain image before enabling distribution generation:
+5 -1
View File
@@ -912,6 +912,10 @@ func (h *coreHandlers) gamePlugins(w http.ResponseWriter, r *http.Request) {
writeDecodeError(w, err) writeDecodeError(w, err)
return return
} }
if violations := request.RuntimeProfiles.UnsupportedLegacyProfileViolations("runtimeProfiles"); len(violations) > 0 {
writeServiceError(w, validator.ValidationError{Violations: violations})
return
}
plugin, err := h.core.CreateGamePlugin(request.ToDomain()) plugin, err := h.core.CreateGamePlugin(request.ToDomain())
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
@@ -944,7 +948,7 @@ func (h *coreHandlers) gamePluginManifestRegistration(w http.ResponseWriter, r *
writeDecodeError(w, err) writeDecodeError(w, err)
return return
} }
if violations := request.Manifest.RuntimeProfiles.UnsupportedLegacyClientManagerViolations("manifest.runtimeProfiles"); len(violations) > 0 { if violations := request.Manifest.RuntimeProfiles.UnsupportedLegacyProfileViolations("manifest.runtimeProfiles"); len(violations) > 0 {
writeServiceError(w, validator.ValidationError{Violations: violations}) writeServiceError(w, validator.ValidationError{Violations: violations})
return return
} }
+19 -1
View File
@@ -129,6 +129,24 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
} }
func TestCoreAPIGamePluginCreateRejectsLegacyServerDeployments(t *testing.T) {
router := newTestRouter()
request := validGamePluginRequest()
request.RuntimeProfiles.ServerDeployments = json.RawMessage(`[{"key":"legacy"}]`)
response := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins", request)
assertErrorResponse(t, response, http.StatusBadRequest, errorCodeValidation)
}
func TestCoreAPIGamePluginManifestRejectsLegacyServerDeployments(t *testing.T) {
router := newTestRouter()
registration := validGamePluginManifestRegistrationRequest()
registration.Manifest.RuntimeProfiles.ServerDeployments = json.RawMessage(`[{"key":"legacy"}]`)
response := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration)
assertErrorResponse(t, response, http.StatusBadRequest, errorCodeValidation)
}
func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) { func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
router := newTestRouter() router := newTestRouter()
adminSession := createAdminSession(t, router) adminSession := createAdminSession(t, router)
@@ -1519,7 +1537,7 @@ func TestGamePluginManifestAPISafelyProjectsDLLReleaseDeclaration(t *testing.T)
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready", Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024, ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024,
TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll", TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015, TargetExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
}}} }}}
recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration) recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration)
+1 -1
View File
@@ -205,7 +205,7 @@ type RunAutonomousDLLExtension struct {
TargetKey string `json:"targetKey"` TargetKey string `json:"targetKey"`
ModKey string `json:"modKey"` ModKey string `json:"modKey"`
DLLRef string `json:"dllRef"` DLLRef string `json:"dllRef"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"` TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"`
UE4SSABI string `json:"ue4ssAbi,omitempty"` UE4SSABI string `json:"ue4ssAbi,omitempty"`
RCONPort int `json:"rconPort,omitempty"` RCONPort int `json:"rconPort,omitempty"`
} }
+3 -34
View File
@@ -474,7 +474,7 @@ type RuntimeDLLExtensionProfile struct {
TargetKey string TargetKey string
ModKey string ModKey string
DLLRef string DLLRef string
SCUMExecutableChecksum string TargetExecutableChecksum string
UE4SSABI string UE4SSABI string
SupportedTargets []RuntimeTarget SupportedTargets []RuntimeTarget
UpdateOnStart bool UpdateOnStart bool
@@ -490,13 +490,13 @@ type RuntimeDLLExtensionPlan struct {
TargetKey string TargetKey string
ModKey string ModKey string
DLLRef string DLLRef string
SCUMExecutableChecksum string TargetExecutableChecksum string
UE4SSABI string UE4SSABI string
RCONPort int RCONPort int
} }
// RuntimeSourceRCONPlan is a frozen, secret-free loopback connection plan for // RuntimeSourceRCONPlan is a frozen, secret-free loopback connection plan for
// a ready SCUM UE4SS extension. The generated local config remains Run-owned. // a ready UE4SS extension. The generated local config remains Run-owned.
type RuntimeSourceRCONPlan struct { type RuntimeSourceRCONPlan struct {
Protocol string Protocol string
ExtensionKey string ExtensionKey string
@@ -543,29 +543,11 @@ type RuntimeServerPrerequisite struct {
Kind string Kind string
} }
// RuntimeServerDeploymentProfile is a legacy game-specific deployment template
// declaration kept for backward-compatible manifest decoding.
type RuntimeServerDeploymentProfile struct {
Key string
Version string
SupportedTargets []RuntimeTarget
SteamAppID string
ExecutableKey string
InstallRootKey string
ConfigKey string
ConfigFormat string
Prerequisites []RuntimeServerPrerequisite
ConfigMappings []RuntimeServerConfigMapping
DiscoveryMarkers []RuntimeServerDiscoveryMarker
VerificationChecks []RuntimeServerVerificationCheck
}
type GamePluginRuntimeProfiles struct { type GamePluginRuntimeProfiles struct {
Discovery []RuntimeDiscoveryProbe Discovery []RuntimeDiscoveryProbe
LifecycleProfiles []RuntimeLifecycleProfile LifecycleProfiles []RuntimeLifecycleProfile
DependencyProbes []RuntimeDependencyProbe DependencyProbes []RuntimeDependencyProbe
InstallPlans []RuntimeInstallPlan InstallPlans []RuntimeInstallPlan
ServerDeployments []RuntimeServerDeploymentProfile
LogSources []RuntimeLogSource LogSources []RuntimeLogSource
TransportProfiles []RuntimeTransportProfile TransportProfiles []RuntimeTransportProfile
DataTargets []RuntimeDataTarget DataTargets []RuntimeDataTarget
@@ -1826,10 +1808,6 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
profiles.InstallPlans[i].Platforms = CopyStringSlice(profiles.InstallPlans[i].Platforms) profiles.InstallPlans[i].Platforms = CopyStringSlice(profiles.InstallPlans[i].Platforms)
profiles.InstallPlans[i].Steps = append([]RuntimeInstallStep(nil), profiles.InstallPlans[i].Steps...) profiles.InstallPlans[i].Steps = append([]RuntimeInstallStep(nil), profiles.InstallPlans[i].Steps...)
} }
profiles.ServerDeployments = append([]RuntimeServerDeploymentProfile(nil), profiles.ServerDeployments...)
for i := range profiles.ServerDeployments {
profiles.ServerDeployments[i] = CopyRuntimeServerDeploymentProfile(profiles.ServerDeployments[i])
}
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...) profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...) profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...)
for i := range profiles.TransportProfiles { for i := range profiles.TransportProfiles {
@@ -1895,15 +1873,6 @@ func CopyServerInstance(instance ServerInstance) ServerInstance {
return instance return instance
} }
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...)
profile.VerificationChecks = append([]RuntimeServerVerificationCheck(nil), profile.VerificationChecks...)
return profile
}
func CopyServerDeploymentProjection(projection ServerDeploymentProjection) ServerDeploymentProjection { func CopyServerDeploymentProjection(projection ServerDeploymentProjection) ServerDeploymentProjection {
projection.DiscoveredFacts = CopyStringMap(projection.DiscoveredFacts) projection.DiscoveredFacts = CopyStringMap(projection.DiscoveredFacts)
projection.MappingResults = CopyStringMap(projection.MappingResults) projection.MappingResults = CopyStringMap(projection.MappingResults)
+13 -64
View File
@@ -82,21 +82,6 @@ type RuntimeServerVerificationCheckBody struct {
Required bool `json:"required,omitempty"` Required bool `json:"required,omitempty"`
} }
type RuntimeServerDeploymentProfileBody struct {
Key string `json:"key"`
Version string `json:"version"`
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
SteamAppID string `json:"steamAppId"`
ExecutableKey string `json:"executableKey"`
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 { type RuntimeServerPrerequisiteBody struct {
Key string `json:"key"` Key string `json:"key"`
Kind string `json:"kind"` Kind string `json:"kind"`
@@ -165,7 +150,7 @@ type RuntimeDLLExtensionProfileBody struct {
TargetKey string `json:"targetKey"` TargetKey string `json:"targetKey"`
ModKey string `json:"modKey"` ModKey string `json:"modKey"`
DLLRef string `json:"dllRef"` DLLRef string `json:"dllRef"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"` TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"`
UE4SSABI string `json:"ue4ssAbi,omitempty"` UE4SSABI string `json:"ue4ssAbi,omitempty"`
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"` SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
UpdateOnStart bool `json:"updateOnStart"` UpdateOnStart bool `json:"updateOnStart"`
@@ -186,7 +171,7 @@ type RuntimeDLLExtensionProfileResponseBody struct {
ReleaseFilename string `json:"releaseFilename,omitempty"` ReleaseFilename string `json:"releaseFilename,omitempty"`
Checksum string `json:"checksum,omitempty"` Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"` TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"`
UE4SSABI string `json:"ue4ssAbi,omitempty"` UE4SSABI string `json:"ue4ssAbi,omitempty"`
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"` SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
UpdateOnStart bool `json:"updateOnStart"` UpdateOnStart bool `json:"updateOnStart"`
@@ -201,7 +186,7 @@ type RuntimeDLLExtensionPlanBody struct {
TargetKey string `json:"targetKey"` TargetKey string `json:"targetKey"`
ModKey string `json:"modKey"` ModKey string `json:"modKey"`
DLLRef string `json:"dllRef"` DLLRef string `json:"dllRef"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum"` TargetExecutableChecksum string `json:"targetExecutableChecksum"`
UE4SSABI string `json:"ue4ssAbi"` UE4SSABI string `json:"ue4ssAbi"`
RCONPort int `json:"rconPort"` RCONPort int `json:"rconPort"`
} }
@@ -211,17 +196,17 @@ type GamePluginRuntimeProfilesBody struct {
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"` LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"` DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"` InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"` ServerDeployments json.RawMessage `json:"serverDeployments,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"` TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"` DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"`
ClientManagers *[]json.RawMessage `json:"clientManagers,omitempty"` ClientManagers json.RawMessage `json:"clientManagers,omitempty"`
DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"` DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"`
} }
func (body GamePluginRuntimeProfilesBody) UnsupportedLegacyClientManagerViolations(prefix string) []string { func (body GamePluginRuntimeProfilesBody) UnsupportedLegacyProfileViolations(prefix string) []string {
var violations []string var violations []string
if body.ClientManagers != nil { if len(body.ClientManagers) > 0 {
violations = append(violations, prefix+".clientManagers is no longer supported") violations = append(violations, prefix+".clientManagers is no longer supported")
} }
for i, profile := range body.LifecycleProfiles { for i, profile := range body.LifecycleProfiles {
@@ -229,6 +214,9 @@ func (body GamePluginRuntimeProfilesBody) UnsupportedLegacyClientManagerViolatio
violations = append(violations, fmt.Sprintf("%s.lifecycleProfiles[%d].clientManagerRef is no longer supported", prefix, i)) violations = append(violations, fmt.Sprintf("%s.lifecycleProfiles[%d].clientManagerRef is no longer supported", prefix, i))
} }
} }
if len(body.ServerDeployments) > 0 {
violations = append(violations, prefix+".serverDeployments is no longer supported")
}
return violations return violations
} }
@@ -240,7 +228,6 @@ type GamePluginRuntimeProfilesResponseBody struct {
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"` LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"` DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"` InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"` TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"` DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"`
@@ -265,25 +252,6 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
} }
profiles.InstallPlans = append(profiles.InstallPlans, plan) profiles.InstallPlans = append(profiles.InstallPlans, plan)
} }
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})
}
for _, mapping := range item.ConfigMappings {
profile.ConfigMappings = append(profile.ConfigMappings, domain.RuntimeServerConfigMapping{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required})
}
for _, marker := range item.DiscoveryMarkers {
profile.DiscoveryMarkers = append(profile.DiscoveryMarkers, domain.RuntimeServerDiscoveryMarker{Key: marker.Key, Kind: marker.Kind, TargetKey: marker.TargetKey, Expected: marker.Expected, Required: marker.Required})
}
for _, check := range item.VerificationChecks {
profile.VerificationChecks = append(profile.VerificationChecks, domain.RuntimeServerVerificationCheck{Key: check.Key, Kind: check.Kind, TargetKey: check.TargetKey, Required: check.Required})
}
profiles.ServerDeployments = append(profiles.ServerDeployments, profile)
}
for _, item := range body.LogSources { for _, item := range body.LogSources {
profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays}) profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
} }
@@ -294,7 +262,7 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
profiles.DataTargets = append(profiles.DataTargets, domain.RuntimeDataTarget{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: domain.CopyStringSlice(item.Platforms)}) profiles.DataTargets = append(profiles.DataTargets, domain.RuntimeDataTarget{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: domain.CopyStringSlice(item.Platforms)})
} }
for _, item := range body.DLLExtensions { for _, item := range body.DLLExtensions {
extension := domain.RuntimeDLLExtensionProfile{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, SCUMExecutableChecksum: item.SCUMExecutableChecksum, UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart, RCONPort: item.RCONPort} extension := domain.RuntimeDLLExtensionProfile{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, TargetExecutableChecksum: item.TargetExecutableChecksum, UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart, RCONPort: item.RCONPort}
for _, target := range item.SupportedTargets { for _, target := range item.SupportedTargets {
extension.SupportedTargets = append(extension.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch}) extension.SupportedTargets = append(extension.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch})
} }
@@ -322,25 +290,6 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
} }
body.InstallPlans = append(body.InstallPlans, plan) body.InstallPlans = append(body.InstallPlans, plan)
} }
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})
}
for _, mapping := range item.ConfigMappings {
bodyProfile.ConfigMappings = append(bodyProfile.ConfigMappings, RuntimeServerConfigMappingBody{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required})
}
for _, marker := range item.DiscoveryMarkers {
bodyProfile.DiscoveryMarkers = append(bodyProfile.DiscoveryMarkers, RuntimeServerDiscoveryMarkerBody{Key: marker.Key, Kind: marker.Kind, TargetKey: marker.TargetKey, Expected: marker.Expected, Required: marker.Required})
}
for _, check := range item.VerificationChecks {
bodyProfile.VerificationChecks = append(bodyProfile.VerificationChecks, RuntimeServerVerificationCheckBody{Key: check.Key, Kind: check.Kind, TargetKey: check.TargetKey, Required: check.Required})
}
body.ServerDeployments = append(body.ServerDeployments, bodyProfile)
}
for _, item := range profiles.LogSources { for _, item := range profiles.LogSources {
body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays}) body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
} }
@@ -352,7 +301,7 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
} }
for _, item := range profiles.DLLExtensions { for _, item := range profiles.DLLExtensions {
host, filename := safeDLLReleaseLocation(item.ReleaseURL) host, filename := safeDLLReleaseLocation(item.ReleaseURL)
extension := RuntimeDLLExtensionProfileResponseBody{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseHost: host, ReleaseFilename: filename, Checksum: safeDLLChecksumPrefix(item.Checksum), SizeBytes: item.SizeBytes, SCUMExecutableChecksum: safeDLLChecksumPrefix(item.SCUMExecutableChecksum), UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart} extension := RuntimeDLLExtensionProfileResponseBody{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseHost: host, ReleaseFilename: filename, Checksum: safeDLLChecksumPrefix(item.Checksum), SizeBytes: item.SizeBytes, TargetExecutableChecksum: safeDLLChecksumPrefix(item.TargetExecutableChecksum), UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart}
for _, target := range item.SupportedTargets { for _, target := range item.SupportedTargets {
extension.SupportedTargets = append(extension.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch}) extension.SupportedTargets = append(extension.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch})
} }
@@ -364,7 +313,7 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
func dllExtensionPlansFromDomain(plans []domain.RuntimeDLLExtensionPlan) []RuntimeDLLExtensionPlanBody { func dllExtensionPlansFromDomain(plans []domain.RuntimeDLLExtensionPlan) []RuntimeDLLExtensionPlanBody {
items := make([]RuntimeDLLExtensionPlanBody, 0, len(plans)) items := make([]RuntimeDLLExtensionPlanBody, 0, len(plans))
for _, item := range plans { for _, item := range plans {
items = append(items, RuntimeDLLExtensionPlanBody{Key: item.Key, Version: item.Version, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, SCUMExecutableChecksum: item.SCUMExecutableChecksum, UE4SSABI: item.UE4SSABI, RCONPort: item.RCONPort}) items = append(items, RuntimeDLLExtensionPlanBody{Key: item.Key, Version: item.Version, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, TargetExecutableChecksum: item.TargetExecutableChecksum, UE4SSABI: item.UE4SSABI, RCONPort: item.RCONPort})
} }
return items return items
} }
+37
View File
@@ -0,0 +1,37 @@
package dto
import (
"encoding/json"
"strings"
"testing"
)
func TestGamePluginRuntimeProfilesBodyRejectsLegacyServerDeployments(t *testing.T) {
body := GamePluginRuntimeProfilesBody{ServerDeployments: json.RawMessage(`[{"key":"legacy"}]`)}
violations := body.UnsupportedLegacyProfileViolations("runtimeProfiles")
if len(violations) != 1 || !strings.Contains(violations[0], "runtimeProfiles.serverDeployments is no longer supported") {
t.Fatalf("expected legacy serverDeployments violation, got %v", violations)
}
}
func TestGamePluginRuntimeProfilesBodyRejectsLegacyClientManagers(t *testing.T) {
body := GamePluginRuntimeProfilesBody{ClientManagers: json.RawMessage(`[{"key":"legacy"}]`), LifecycleProfiles: []RuntimeLifecycleProfileBody{{Key: "local", ClientManagerRef: json.RawMessage(`{"key":"legacy"}`)}}}
violations := body.UnsupportedLegacyProfileViolations("manifest.runtimeProfiles")
if len(violations) != 2 || !strings.Contains(strings.Join(violations, "; "), "clientManagers is no longer supported") || !strings.Contains(strings.Join(violations, "; "), "clientManagerRef is no longer supported") {
t.Fatalf("expected legacy client manager violations, got %v", violations)
}
}
func TestGamePluginRuntimeProfilesBodyRejectsLegacyClientManagersWhenNull(t *testing.T) {
var body GamePluginRuntimeProfilesBody
if err := json.Unmarshal([]byte(`{"clientManagers":null}`), &body); err != nil {
t.Fatalf("decode runtime profile body: %v", err)
}
violations := body.UnsupportedLegacyProfileViolations("runtimeProfiles")
if len(violations) != 1 || !strings.Contains(violations[0], "runtimeProfiles.clientManagers is no longer supported") {
t.Fatalf("expected legacy clientManagers null violation, got %v", violations)
}
}
@@ -6,8 +6,8 @@ Platform owns the reviewable dependency catalog, immutable plan digest, selected
1. `GET /api/v1/server-instances/{id}/dependencies` resolves the installed plugin version, complete runtime binding, online Run endpoint OS/architecture, target-matched probes/plans, and canonical SHA-256 digest. 1. `GET /api/v1/server-instances/{id}/dependencies` resolves the installed plugin version, complete runtime binding, online Run endpoint OS/architecture, target-matched probes/plans, and canonical SHA-256 digest.
2. An install request must submit that exact digest. Platform re-resolves the declaration before creating `dependencies.install`; missing or stale plan evidence is denied and reported. 2. An install request must submit that exact digest. Platform re-resolves the declaration before creating `dependencies.install`; missing or stale plan evidence is denied and reported.
3. Run retrieves private input through signed `POST /api/v1/run/jobs/dependency-input` only for the active endpoint/session/attempt/lease and non-cancelled job. It executes closed command-version, Java, Docker, package, service, Steam, file, package-manager, verified HTTPS download, and SteamCMD adapters with bounded output/timeouts and a durable step journal. 3. Run retrieves private input through signed `POST /api/v1/run/jobs/dependency-input` only for the active endpoint/session/attempt/lease and non-cancelled job. It executes closed command-version, Java, Docker, package, service, file, package-manager, and verified HTTPS download adapters with bounded output/timeouts and a durable step journal.
4. Terminal evidence is typed and redacted. Platform verifies probe key, plan digest, result checksum, and job attempt before updating `DependencyStatus`. 4. Terminal evidence is typed dependency status, not log parsing. Platform verifies probe key, plan digest, result checksum, and job attempt before updating `DependencyStatus`.
## Self-update flow ## Self-update flow
+3 -5
View File
@@ -26,11 +26,9 @@ from command text. Empty `shell` means argv-oriented execution.
Game plugins own concrete game policy: install/update commands, app ids, Game plugins own concrete game policy: install/update commands, app ids,
executable refs, default launch flags, stop-before-update behavior, and startup executable refs, default launch flags, stop-before-update behavior, and startup
argument construction. For SCUM, the plugin action assets own the SteamCMD flow: argument construction. For SCUM, those values live in the SCUM plugin action
stop `SCUMServer.exe` when updating, keep SteamCMD outside the server install assets and scripts; Platform and Run only pass the bounded deployment context to
root, run the declared lifecycle action and execute it through generic action handling.
`steamcmd.exe +force_install_dir <serverRoot> +login anonymous +app_update 3792580 +quit`,
and start `<serverRoot>\\SCUM\\Binaries\\Win64\\SCUMServer.exe -port=<gamePort> -MaxPlayers=<maxPlayers> -log`.
Generated Run distributions carry validated plugin lifecycle assets into the Generated Run distributions carry validated plugin lifecycle assets into the
server-scoped workspace. Run materializes those assets at startup and executes server-scoped workspace. Run materializes those assets at startup and executes
+5 -8
View File
@@ -208,13 +208,6 @@ func (svc *CoreService) ReadRunUpdateChunk(request domain.RunUpdateChunkRequest)
if err != nil { if err != nil {
return domain.RunUpdateChunk{}, err return domain.RunUpdateChunk{}, err
} }
payload, err := svc.artifactPayload(artifact.ID)
if err != nil {
return domain.RunUpdateChunk{}, err
}
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
return domain.RunUpdateChunk{}, validationError("update artifact content does not match metadata")
}
if request.Offset >= artifact.SizeBytes { if request.Offset >= artifact.SizeBytes {
return domain.RunUpdateChunk{}, validationError("offset must be inside update artifact") return domain.RunUpdateChunk{}, validationError("offset must be inside update artifact")
} }
@@ -224,7 +217,11 @@ func (svc *CoreService) ReadRunUpdateChunk(request domain.RunUpdateChunkRequest)
length = int(remaining) length = int(remaining)
} }
end := request.Offset + int64(length) end := request.Offset + int64(length)
return domain.CopyRunUpdateChunk(domain.RunUpdateChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):int(end)], Complete: end == artifact.SizeBytes}), nil payload, err := svc.artifactStore.ReadPayloadRange(artifact.ID, request.Offset, length)
if err != nil {
return domain.RunUpdateChunk{}, err
}
return domain.CopyRunUpdateChunk(domain.RunUpdateChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload, Complete: end == artifact.SizeBytes}), nil
} }
func (svc *CoreService) activeFencedInputJob(endpointID, sessionToken, jobID, leaseToken string, attempt int) (domain.Job, error) { func (svc *CoreService) activeFencedInputJob(endpointID, sessionToken, jobID, leaseToken string, attempt int) (domain.Job, error) {
@@ -231,6 +231,10 @@ func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.T) {
if err != nil || string(chunk.Payload) != string(payload[:8]) || chunk.Offset != 0 || chunk.TotalBytes != int64(len(payload)) { if err != nil || string(chunk.Payload) != string(payload[:8]) || chunk.Offset != 0 || chunk.TotalBytes != int64(len(payload)) {
t.Fatalf("read bounded update chunk: chunk=%+v err=%v", chunk, err) t.Fatalf("read bounded update chunk: chunk=%+v err=%v", chunk, err)
} }
secondChunk, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 8, Length: 5})
if err != nil || string(secondChunk.Payload) != string(payload[8:13]) || secondChunk.Offset != 8 || secondChunk.Complete {
t.Fatalf("read offset update chunk: chunk=%+v err=%v", secondChunk, err)
}
if _, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt + 1, Offset: 0, Length: 8}); err == nil { if _, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt + 1, Offset: 0, Length: 8}); err == nil {
t.Fatal("expected stale update chunk attempt rejection") t.Fatal("expected stale update chunk attempt rejection")
} }
@@ -339,7 +339,7 @@ func autonomousRunLogSourceKind(kind string) bool {
} }
func autonomousDLLExtension(extension domain.RuntimeDLLExtensionPlan) domain.RunAutonomousDLLExtension { func autonomousDLLExtension(extension domain.RuntimeDLLExtensionPlan) domain.RunAutonomousDLLExtension {
return domain.RunAutonomousDLLExtension{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort} return domain.RunAutonomousDLLExtension{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, TargetExecutableChecksum: extension.TargetExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort}
} }
func autonomousDataTarget(target domain.RuntimeDataTarget) domain.RunAutonomousDataTarget { func autonomousDataTarget(target domain.RuntimeDataTarget) domain.RunAutonomousDataTarget {
+1 -1
View File
@@ -782,7 +782,7 @@ func runReleasePlatformURL() string {
if value := strings.TrimSpace(os.Getenv("PLATFORM_RUN_RELEASE_URL")); value != "" { if value := strings.TrimSpace(os.Getenv("PLATFORM_RUN_RELEASE_URL")); value != "" {
return value return value
} }
return "https://scum.npc0.com/" return "http://127.0.0.1:8080/"
} }
func distributionID(prefix string, parts ...interface{}) string { func distributionID(prefix string, parts ...interface{}) string {
+39
View File
@@ -973,6 +973,45 @@ func TestPluginFileWorkspaceDoesNotConstrainServerFileDispatch(t *testing.T) {
} }
} }
func TestReadRunFileInputChunkReadsRequestedRange(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.FileWorkspace = scumTestFileWorkspace()
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin workspace: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-chunk", DisplayName: "File Chunk Owner", Email: "file-chunk@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-chunk", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Chunk Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
createCompleteRuntimeBinding(t, svc, instance, "local")
payload := []byte("0123456789abcdef")
upload, err := svc.UploadServerFileForSession(ownerSession, domain.ServerFileUploadRequest{ServerInstanceID: instance.ID, DirectoryKey: "scum-logs", Filename: "chunked.log", Payload: payload, Checksum: validator.BytesChecksum(payload), IdempotencyKey: "file-chunk-upload"})
if err != nil {
t.Fatalf("upload server file: %v", err)
}
hello := validRunControlHello()
hello.RunEndpointID = endpoint.ID
hello.CapabilityReport.Capabilities = []string{domain.JobCapabilityFilesWrite}
registered, err := svc.RegisterRunHello(hello)
if err != nil {
t.Fatalf("register run: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, Capabilities: []string{domain.JobCapabilityFilesWrite}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.JobID != upload.Job.ID {
t.Fatalf("claim file write: claim=%+v err=%v", claim, err)
}
chunk, err := svc.ReadRunFileInputChunk(domain.RunFileInputChunkRequest{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 4, Length: 6})
if err != nil || string(chunk.Payload) != string(payload[4:10]) || chunk.Offset != 4 || chunk.Complete {
t.Fatalf("read offset file input chunk: chunk=%+v err=%v", chunk, err)
}
finalChunk, err := svc.ReadRunFileInputChunk(domain.RunFileInputChunkRequest{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 14, Length: 8})
if err != nil || string(finalChunk.Payload) != string(payload[14:]) || !finalChunk.Complete {
t.Fatalf("read final file input chunk: chunk=%+v err=%v", finalChunk, err)
}
}
func TestServerFileListReportsFailedRuntimeRefresh(t *testing.T) { func TestServerFileListReportsFailedRuntimeRefresh(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc) plugin, endpoint := createPluginAndRunEndpoint(t, svc)
+6 -9
View File
@@ -392,13 +392,6 @@ func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRe
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable { if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable {
return domain.RunFileInputChunk{}, ErrForbidden return domain.RunFileInputChunk{}, ErrForbidden
} }
payload, err := svc.artifactPayload(artifact.ID)
if err != nil {
return domain.RunFileInputChunk{}, err
}
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
return domain.RunFileInputChunk{}, validationError("file input artifact checksum mismatch")
}
if request.Offset >= artifact.SizeBytes { if request.Offset >= artifact.SizeBytes {
return domain.RunFileInputChunk{}, validationError("offset must be inside artifact content") return domain.RunFileInputChunk{}, validationError("offset must be inside artifact content")
} }
@@ -407,8 +400,12 @@ func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRe
if int64(length) > remaining { if int64(length) > remaining {
length = int(remaining) length = int(remaining)
} }
end := int(request.Offset) + length payload, err := svc.artifactStore.ReadPayloadRange(artifact.ID, request.Offset, length)
chunk := domain.RunFileInputChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):end], Complete: int64(end) == artifact.SizeBytes} if err != nil {
return domain.RunFileInputChunk{}, err
}
end := request.Offset + int64(length)
chunk := domain.RunFileInputChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload, Complete: end == artifact.SizeBytes}
return domain.CopyRunFileInputChunk(chunk), nil return domain.CopyRunFileInputChunk(chunk), nil
} }
+1 -1
View File
@@ -461,7 +461,7 @@ func lifecycleDLLExtensionPlans(profiles domain.GamePluginRuntimeProfiles, profi
if !runtimeDLLExtensionSupportsTarget(extension, endpoint.Platform, endpoint.Architecture) { if !runtimeDLLExtensionSupportsTarget(extension, endpoint.Platform, endpoint.Architecture) {
return nil, validationError("unsupported_extension_platform: UE4SS DLL requires windows/amd64") return nil, validationError("unsupported_extension_platform: UE4SS DLL requires windows/amd64")
} }
plans = append(plans, domain.RuntimeDLLExtensionPlan{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort}) plans = append(plans, domain.RuntimeDLLExtensionPlan{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, TargetExecutableChecksum: extension.TargetExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort})
} }
return plans, nil return plans, nil
} }
+1 -1
View File
@@ -466,7 +466,7 @@ func attachReadyLifecycleDLLExtension(t *testing.T, svc *CoreService, plugin *do
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready", Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024, ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024,
TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll", TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015, TargetExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
}} }}
if err := svc.store.GamePlugins().Update(*plugin); err != nil { if err := svc.store.GamePlugins().Update(*plugin); err != nil {
t.Fatalf("attach ready DLL extension: %v", err) t.Fatalf("attach ready DLL extension: %v", err)
+1 -1
View File
@@ -280,7 +280,7 @@ func newSourceRCONFixtureWithRuntimeBinding(t *testing.T, createBinding bool) (*
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready", Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024, ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024,
TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll", TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015, TargetExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
}}, }},
}, },
}) })
@@ -52,7 +52,7 @@ func TestValidateGamePluginRuntimeProfilesRejectsUnsafeOrUnpublishedDLLExtension
func TestValidateJobRejectsDLLPlanOutsideProcessStart(t *testing.T) { func TestValidateJobRejectsDLLPlanOutsideProcessStart(t *testing.T) {
profiles := validRuntimeDLLExtensionProfiles() profiles := validRuntimeDLLExtensionProfiles()
extension := profiles.DLLExtensions[0] extension := profiles.DLLExtensions[0]
job := domain.Job{ID: "dll-job", ServerInstanceID: "server-1", RunEndpointID: "run-1", Capability: domain.LifecycleCapabilityStop, IdempotencyKey: "dll-stop", State: domain.JobStateQueued, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{LifecycleOperation: "stop", DLLExtensions: []domain.RuntimeDLLExtensionPlan{{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort}}}} job := domain.Job{ID: "dll-job", ServerInstanceID: "server-1", RunEndpointID: "run-1", Capability: domain.LifecycleCapabilityStop, IdempotencyKey: "dll-stop", State: domain.JobStateQueued, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{LifecycleOperation: "stop", DLLExtensions: []domain.RuntimeDLLExtensionPlan{{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, TargetExecutableChecksum: extension.TargetExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort}}}}
if err := ValidateJob(job); err == nil || !strings.Contains(err.Error(), "process.start") { if err := ValidateJob(job); err == nil || !strings.Contains(err.Error(), "process.start") {
t.Fatalf("expected process.start plan restriction, got %v", err) t.Fatalf("expected process.start plan restriction, got %v", err)
} }
@@ -65,7 +65,7 @@ func validRuntimeDLLExtensionProfiles() domain.GamePluginRuntimeProfiles {
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready", Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024, ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024,
TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll", TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015, TargetExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
}}, }},
} }
} }
+5 -109
View File
@@ -29,7 +29,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
discoveryKeys := map[string]struct{}{} discoveryKeys := map[string]struct{}{}
dependencyKeys := map[string]struct{}{} dependencyKeys := map[string]struct{}{}
installPlanKeys := map[string]struct{}{} installPlanKeys := map[string]struct{}{}
serverDeploymentKeys := map[string]struct{}{}
logSourceKeys := map[string]struct{}{} logSourceKeys := map[string]struct{}{}
for i, probe := range profiles.Discovery { for i, probe := range profiles.Discovery {
@@ -95,7 +94,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
} }
for j, step := range plan.Steps { for j, step := range plan.Steps {
stepPrefix := fmt.Sprintf("%s.steps[%d]", prefix, j) stepPrefix := fmt.Sprintf("%s.steps[%d]", prefix, j)
if !oneOf(step.Type, "package", "verified-download", "steamcmd-app", "manual") { if !oneOf(step.Type, "package", "verified-download", "manual") {
violations = append(violations, stepPrefix+".type is invalid") violations = append(violations, stepPrefix+".type is invalid")
} }
violations = append(violations, validateProfileKey(stepPrefix+".targetKey", step.TargetKey)...) violations = append(violations, validateProfileKey(stepPrefix+".targetKey", step.TargetKey)...)
@@ -133,100 +132,14 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
if step.DownloadRef == "" || step.Checksum == "" { if step.DownloadRef == "" || step.Checksum == "" {
violations = append(violations, stepPrefix+" requires downloadRef and checksum") violations = append(violations, stepPrefix+" requires downloadRef and checksum")
} }
case "steamcmd-app":
if step.PackageManager != "" && step.PackageManager != "steamcmd" || !regexp.MustCompile(`^[0-9]{1,12}$`).MatchString(step.PackageName) {
violations = append(violations, stepPrefix+" requires a numeric Steam app and steamcmd adapter")
}
case "manual": case "manual":
if step.DownloadRef != "" || step.Checksum != "" || step.PackageName != "" { if step.DownloadRef != "" || step.Checksum != "" || step.PackageManager != "" || step.PackageName != "" || step.Version != "" {
violations = append(violations, stepPrefix+" manual step cannot contain machine execution fields") violations = append(violations, stepPrefix+" manual step cannot contain machine execution fields")
} }
} }
} }
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", plan.Platforms)...) violations = append(violations, validateRuntimePlatforms(prefix+".platforms", plan.Platforms)...)
} }
for i, profile := range profiles.ServerDeployments {
prefix := fmt.Sprintf("runtimeProfiles.serverDeployments[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", profile.Key)...)
violations = append(violations, recordRuntimeProfileKey(serverDeploymentKeys, prefix+".key", profile.Key)...)
if !validSemanticVersion(profile.Version) {
violations = append(violations, prefix+".version must be semantic")
}
if !regexp.MustCompile(`^[0-9]{1,12}$`).MatchString(profile.SteamAppID) {
violations = append(violations, prefix+".steamAppId must be numeric")
}
for field, value := range map[string]string{"executableKey": profile.ExecutableKey, "installRootKey": profile.InstallRootKey, "configKey": profile.ConfigKey} {
violations = append(violations, validateProfileKey(prefix+"."+field, value)...)
}
if profile.ConfigFormat != "ini" && profile.ConfigFormat != "json" && profile.ConfigFormat != "yaml" && profile.ConfigFormat != "properties" {
violations = append(violations, prefix+".configFormat is invalid")
}
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))
}
}
mappingKeys := map[string]struct{}{}
for j, mapping := range profile.ConfigMappings {
mappingPrefix := fmt.Sprintf("%s.configMappings[%d]", prefix, j)
if !regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._/-]{0,79}$`).MatchString(mapping.FieldKey) {
violations = append(violations, mappingPrefix+".fieldKey is invalid")
}
violations = append(violations, validateProfileKey(mappingPrefix+".configKey", mapping.ConfigKey)...)
if _, exists := mappingKeys[mapping.FieldKey]; exists {
violations = append(violations, mappingPrefix+".fieldKey duplicates another mapping")
}
mappingKeys[mapping.FieldKey] = struct{}{}
if !oneOf(mapping.ValueType, "text", "integer", "number", "boolean", "port") {
violations = append(violations, mappingPrefix+".valueType is invalid")
}
}
markerKeys := map[string]struct{}{}
for j, marker := range profile.DiscoveryMarkers {
markerPrefix := fmt.Sprintf("%s.discoveryMarkers[%d]", prefix, j)
violations = append(violations, validateProfileKey(markerPrefix+".key", marker.Key)...)
violations = append(violations, validateProfileKey(markerPrefix+".targetKey", marker.TargetKey)...)
if _, exists := markerKeys[marker.Key]; exists {
violations = append(violations, markerPrefix+".key duplicates another marker")
}
markerKeys[marker.Key] = struct{}{}
if !oneOf(marker.Kind, "file.exists", "command.version", "port.open", "steam.app") {
violations = append(violations, markerPrefix+".kind is invalid")
}
violations = append(violations, validateSafeRuntimeValue(markerPrefix+".expected", marker.Expected)...)
}
checkKeys := map[string]struct{}{}
for j, check := range profile.VerificationChecks {
checkPrefix := fmt.Sprintf("%s.verificationChecks[%d]", prefix, j)
violations = append(violations, validateProfileKey(checkPrefix+".key", check.Key)...)
violations = append(violations, validateProfileKey(checkPrefix+".targetKey", check.TargetKey)...)
if _, exists := checkKeys[check.Key]; exists {
violations = append(violations, checkPrefix+".key duplicates another check")
}
checkKeys[check.Key] = struct{}{}
if !oneOf(check.Kind, "executable.present", "version.matches", "port.bound", "config.readable", "process.healthy") {
violations = append(violations, checkPrefix+".kind is invalid")
}
}
if len(profile.VerificationChecks) == 0 || !containsRequiredVerification(profile.VerificationChecks) {
violations = append(violations, prefix+".verificationChecks must include executable, config, port, and process checks")
}
}
for i, source := range profiles.LogSources { for i, source := range profiles.LogSources {
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i) prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...) violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
@@ -329,23 +242,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
return finish(violations) return finish(violations)
} }
func containsRequiredVerification(checks []domain.RuntimeServerVerificationCheck) bool {
required := map[string]bool{"executable.present": false, "port.bound": false, "config.readable": false, "process.healthy": false}
for _, check := range checks {
if check.Required {
if _, ok := required[check.Kind]; ok {
required[check.Kind] = true
}
}
}
for _, present := range required {
if !present {
return false
}
}
return true
}
func validateRuntimeDLLExtensionProfile(prefix string, extension domain.RuntimeDLLExtensionProfile) []string { func validateRuntimeDLLExtensionProfile(prefix string, extension domain.RuntimeDLLExtensionProfile) []string {
var violations []string var violations []string
if extension.Kind != "ue4ss-dll" || extension.Activation != "server-start" { if extension.Kind != "ue4ss-dll" || extension.Activation != "server-start" {
@@ -381,8 +277,8 @@ func validateRuntimeDLLExtensionProfile(prefix string, extension domain.RuntimeD
if extension.ReleaseURL == "" { if extension.ReleaseURL == "" {
violations = append(violations, prefix+".releaseUrl is required for a ready release") violations = append(violations, prefix+".releaseUrl is required for a ready release")
} }
if !validSHA256Checksum(extension.Checksum) || !validSHA256Checksum(extension.SCUMExecutableChecksum) { if !validSHA256Checksum(extension.Checksum) || !validSHA256Checksum(extension.TargetExecutableChecksum) {
violations = append(violations, prefix+".checksum and scumExecutableChecksum must be SHA-256") violations = append(violations, prefix+".checksum and targetExecutableChecksum must be SHA-256")
} }
if extension.SizeBytes < 1 || extension.SizeBytes > 128*1024*1024 { if extension.SizeBytes < 1 || extension.SizeBytes > 128*1024*1024 {
violations = append(violations, prefix+".sizeBytes is out of bounds") violations = append(violations, prefix+".sizeBytes is out of bounds")
@@ -420,7 +316,7 @@ func validateRuntimeDLLExtensionPlan(prefix string, plan domain.RuntimeDLLExtens
TargetKey: plan.TargetKey, TargetKey: plan.TargetKey,
ModKey: plan.ModKey, ModKey: plan.ModKey,
DLLRef: plan.DLLRef, DLLRef: plan.DLLRef,
SCUMExecutableChecksum: plan.SCUMExecutableChecksum, TargetExecutableChecksum: plan.TargetExecutableChecksum,
UE4SSABI: plan.UE4SSABI, UE4SSABI: plan.UE4SSABI,
SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}},
UpdateOnStart: true, UpdateOnStart: true,
@@ -0,0 +1,26 @@
package validator
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func TestValidateGamePluginRuntimeProfilesRejectsLegacySteamCMDAppStep(t *testing.T) {
profiles := domain.GamePluginRuntimeProfiles{InstallPlans: []domain.RuntimeInstallPlan{{Key: "install-game", Title: "Install game", Steps: []domain.RuntimeInstallStep{{Type: "steamcmd-app", TargetKey: "server/install-root", PackageManager: "steamcmd", PackageName: "123456"}}}}}
err := ValidateGamePluginRuntimeProfiles(profiles)
if err == nil || !strings.Contains(err.Error(), "type is invalid") {
t.Fatalf("expected legacy steamcmd-app step rejection, got %v", err)
}
}
func TestValidateGamePluginRuntimeProfilesRejectsManualStepExecutionFields(t *testing.T) {
profiles := domain.GamePluginRuntimeProfiles{InstallPlans: []domain.RuntimeInstallPlan{{Key: "manual-plan", Title: "Manual plan", Steps: []domain.RuntimeInstallStep{{Type: "manual", TargetKey: "operator/manual", PackageManager: "steamcmd"}}}}}
err := ValidateGamePluginRuntimeProfiles(profiles)
if err == nil || !strings.Contains(err.Error(), "manual step cannot contain machine execution fields") {
t.Fatalf("expected manual execution field rejection, got %v", err)
}
}
+1 -16
View File
@@ -265,20 +265,6 @@ export interface RuntimeInstallPlanResponse {
steps: Array<{ type: string; targetKey: string; packageManager?: string; packageName?: string; version?: string; downloadRef?: string; checksum?: string }>; steps: Array<{ type: string; targetKey: string; packageManager?: string; packageName?: string; version?: string; downloadRef?: string; checksum?: string }>;
} }
export interface RuntimeServerDeploymentProfileResponse {
key: string;
version: string;
supportedTargets: Array<{ os: string; arch: string }>;
steamAppId: string;
executableKey: string;
installRootKey: string;
configKey: string;
configFormat: string;
configMappings: Array<{ fieldKey: string; configKey: string; valueType: string; required?: boolean }>;
discoveryMarkers: Array<{ key: string; kind: string; targetKey: string; expected?: string; required?: boolean }>;
verificationChecks: Array<{ key: string; kind: string; targetKey: string; required?: boolean }>;
}
export interface RuntimeLogSourceResponse { export interface RuntimeLogSourceResponse {
key: string; key: string;
kind: string; kind: string;
@@ -311,7 +297,7 @@ export interface RuntimeDLLExtensionProfileResponse {
releaseFilename?: string; releaseFilename?: string;
checksum?: string; checksum?: string;
sizeBytes?: number; sizeBytes?: number;
scumExecutableChecksum?: string; targetExecutableChecksum?: string;
ue4ssAbi?: string; ue4ssAbi?: string;
supportedTargets: Array<{ os: string; arch: string }>; supportedTargets: Array<{ os: string; arch: string }>;
updateOnStart: boolean; updateOnStart: boolean;
@@ -322,7 +308,6 @@ export interface GamePluginRuntimeProfilesResponse {
lifecycleProfiles?: RuntimeLifecycleProfileResponse[]; lifecycleProfiles?: RuntimeLifecycleProfileResponse[];
dependencyProbes?: RuntimeDependencyProbeResponse[]; dependencyProbes?: RuntimeDependencyProbeResponse[];
installPlans?: RuntimeInstallPlanResponse[]; installPlans?: RuntimeInstallPlanResponse[];
serverDeployments?: RuntimeServerDeploymentProfileResponse[];
logSources?: RuntimeLogSourceResponse[]; logSources?: RuntimeLogSourceResponse[];
transportProfiles?: RuntimeTransportProfileResponse[]; transportProfiles?: RuntimeTransportProfileResponse[];
dllExtensions?: RuntimeDLLExtensionProfileResponse[]; dllExtensions?: RuntimeDLLExtensionProfileResponse[];
@@ -21,7 +21,7 @@ function RuntimeDLLExtensionsContent({ extensions }: { extensions: RuntimeDLLExt
<h2>UE4SS DLL </h2> <h2>UE4SS DLL </h2>
<span className="page-status"></span> <span className="page-status"></span>
</div> </div>
<p className="page-status">Run DLL UE4SS SCUM </p> <p className="page-status">Run DLL UE4SS </p>
<div className="console-record-list"> <div className="console-record-list">
{extensions.map((extension) => ( {extensions.map((extension) => (
<article key={extension.key} className="console-record"> <article key={extension.key} className="console-record">
@@ -43,8 +43,8 @@ function RuntimeDLLExtensionsContent({ extensions }: { extensions: RuntimeDLLExt
<dd>{extension.checksum || "待发布后固定"}</dd> <dd>{extension.checksum || "待发布后固定"}</dd>
</div> </div>
<div> <div>
<dt>SCUM </dt> <dt></dt>
<dd>{extension.scumExecutableChecksum || "待发布后固定"}</dd> <dd>{extension.targetExecutableChecksum || "待发布后固定"}</dd>
</div> </div>
<div> <div>
<dt>UE4SS ABI</dt> <dt>UE4SS ABI</dt>
@@ -53,7 +53,7 @@ function RuntimeDLLExtensionsContent({ extensions }: { extensions: RuntimeDLLExt
</dl> </dl>
<div className="tag-list" aria-label={`${extension.displayName} activation policy`}> <div className="tag-list" aria-label={`${extension.displayName} activation policy`}>
<span></span> <span></span>
<span>SCUM </span> <span></span>
<span> UE4SS </span> <span> UE4SS </span>
<span>{supportedTargets(extension)}</span> <span>{supportedTargets(extension)}</span>
<span>Linux </span> <span>Linux </span>
@@ -27,7 +27,6 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
const [revealError, setRevealError] = useState(""); const [revealError, setRevealError] = useState("");
const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]); const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]);
const pluginFields = selectedPlugin?.createFields ?? []; const pluginFields = selectedPlugin?.createFields ?? [];
const isScum = selectedPlugin?.id === "game.scum";
const workflowSteps = kind === "create" const workflowSteps = kind === "create"
? [{ label: "基本信息", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }] ? [{ label: "基本信息", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]
: [{ label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]; : [{ label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }];
@@ -66,7 +65,6 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
if (step === pluginStep) return Boolean(form.pluginId) && Boolean(form.name.trim()); if (step === pluginStep) return Boolean(form.pluginId) && Boolean(form.name.trim());
if (step === modeStep) return Boolean(form.deploymentMode); if (step === modeStep) return Boolean(form.deploymentMode);
if (step === configurationStep) { if (step === configurationStep) {
if (isScum && form.deploymentMode === "guided-install" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false;
if (form.deploymentMode === "existing-server" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false; if (form.deploymentMode === "existing-server" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false;
if (form.deploymentMode === "custom-command" && !form.startCommand.trim() && !deployment?.startCommandConfigured) return false; if (form.deploymentMode === "custom-command" && !form.startCommand.trim() && !deployment?.startCommandConfigured) return false;
return form.deploymentMode !== "guided-install" || pluginFields.filter((field) => field.required).every((field) => Boolean(form.createInputs[field.key]?.trim())); return form.deploymentMode !== "guided-install" || pluginFields.filter((field) => field.required).every((field) => Boolean(form.createInputs[field.key]?.trim()));
@@ -111,14 +109,14 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
<div className="workflow-hint-grid"><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong> Run </strong><span>Run </span></div></div> <div className="workflow-hint-grid"><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong> Run </strong><span>Run </span></div></div>
<div className="form-grid"><label><select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label></div> <div className="form-grid"><label><select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label></div>
</div>} </div>}
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy"></p>{isScum && <div className="form-guidance"><strong>SCUM </strong><span>Run </span></div>}<div className="deployment-mode-grid"> {step === modeStep && <div className="deployment-workflow-body"><p className="section-copy"></p><div className="form-guidance"><strong></strong><span>Run </span></div><div className="deployment-mode-grid">
<ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} /> <ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} />
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} /> <ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由 Run 策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} /> <ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由 Run 策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
</div></div>} </div></div>}
{step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong></strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}></button></>}</div>}<div className="form-grid"> {step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong></strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}></button></>}</div>}<div className="form-grid">
{kind === "edit" && <label><select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install"></option><option value="existing-server"></option><option value="custom-command"></option></select><small className="field-help">Run </small></label>} {kind === "edit" && <label><select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install"></option><option value="existing-server"></option><option value="custom-command"></option></select><small className="field-help">Run </small></label>}
{form.deploymentMode === "guided-install" && <label>{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">SCUM </small></label>} {form.deploymentMode === "guided-install" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help"></small></label>}
{form.deploymentMode === "existing-server" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run </small></label>} {form.deploymentMode === "existing-server" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run </small></label>}
{form.deploymentMode === "custom-command" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help"></small></label>} {form.deploymentMode === "custom-command" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help"></small></label>}
{form.deploymentMode === "guided-install" && pluginFields.map((field) => ( {form.deploymentMode === "guided-install" && pluginFields.map((field) => (
@@ -129,11 +127,11 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
)}</label> )}</label>
))} ))}
</div> </div>
{form.deploymentMode === "guided-install" && <GuidedInstallPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />} {form.deploymentMode === "guided-install" && <GuidedInstallPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} />}
{form.deploymentMode === "existing-server" && <ExistingServerAdoptionPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />} {form.deploymentMode === "existing-server" && <ExistingServerAdoptionPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} />}
{form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help">Run </p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label><select name="shell" value={form.shell} onChange={updateForm}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>} {form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help">Run </p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label><select name="shell" value={form.shell} onChange={updateForm}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>}
</div>} </div>}
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div>{kind === "create" && <div><span></span><strong>{form.name.trim() || "未填写"}</strong></div>}<div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{kind === "create" ? "本次保存创建向导配置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "Run 会自动识别并上报心跳;部署执行按这里的方式和启动项进行。" : form.deploymentMode === "existing-server" ? "Run 将自动识别并预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}</span></div></div>} {step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div>{kind === "create" && <div><span></span><strong>{form.name.trim() || "未填写"}</strong></div>}<div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}<div><span></span><strong></strong></div></div><div className="form-guidance"><strong>{kind === "create" ? "本次保存创建向导配置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "Run 会自动识别并上报心跳;部署执行按这里的方式和启动项进行。" : form.deploymentMode === "existing-server" ? "Run 将自动识别并预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}</span></div></div>}
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? closeWorkflow() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div> <div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? closeWorkflow() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div>
</form> </form>
</ManagementDialog>; </ManagementDialog>;
@@ -141,30 +139,19 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
function ModeOption({ active, title, copy, onClick }: { active: boolean; title: string; copy: string; onClick: () => void }) { return <button type="button" className={cx("deployment-mode-option", active && "deployment-mode-option-active")} onClick={onClick}><strong>{title}</strong><span>{copy}</span></button>; } function ModeOption({ active, title, copy, onClick }: { active: boolean; title: string; copy: string; onClick: () => void }) { return <button type="button" className={cx("deployment-mode-option", active && "deployment-mode-option-active")} onClick={onClick}><strong>{title}</strong><span>{copy}</span></button>; }
function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) { function GuidedInstallPlan({ pluginName }: { pluginName: string }) {
const steps = isScum ? [ const steps = [
{ icon: ScanSearch, title: "预检目录与端口", copy: "确认安装目录可用、Run 环境兼容且端口可用。" },
{ icon: Download, title: "下载 SCUM Server", copy: "通过 SteamCMD 安装 App 3792580 到该目录。" },
{ icon: SlidersHorizontal, title: "写入游戏配置", copy: "把本页的名称、端口与人数写入 ServerSettings.ini。" },
{ icon: HeartPulse, title: "启动并健康验证", copy: "检查可执行文件、版本、配置、端口和服务进程。" }
] : [
{ icon: ScanSearch, title: "预检目录与 Run", copy: "确认安装目录、权限、端口与 Run 环境可用。" }, { icon: ScanSearch, title: "预检目录与 Run", copy: "确认安装目录、权限、端口与 Run 环境可用。" },
{ icon: Download, title: "安装游戏服务端", copy: "按插件声明的推荐方案安装到该目录。" }, { icon: Download, title: "安装游戏服务端", copy: "按插件声明的推荐方案安装到该目录。" },
{ icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给自动部署流程。" }, { icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给自动部署流程。" },
{ icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" } { icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" }
]; ];
return <section className="guided-install-plan" aria-label="新建并安装执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span></span></div><small>{isScum ? "全部 4 步通过才算安装成功" : "Run 按插件契约执行"}</small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol><p><strong></strong>{isScum ? "不会跳过验证就标记成功;失败时不会暴露你的目录、命令或凭据。" : "不会把受保护的路径、命令或凭据回显给浏览器。"}</p></section>; return <section className="guided-install-plan" aria-label="新建并安装执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span></span></div><small>Run </small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol><p><strong></strong></p></section>;
} }
function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) { function ExistingServerAdoptionPlan({ pluginName }: { pluginName: string }) {
const steps = isScum ? [ const steps = [
{ icon: FolderCog, title: "定位服务端根目录", copy: "填写包含 SCUM 服务端文件、数据与配置的目录,不是 Steam 库或 SteamCMD 目录。" },
{ icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、可执行文件、版本、Steam App 标记和所需端口。" },
{ icon: SlidersHorizontal, title: "只读扫描配置", copy: "识别 ServerSettings.ini 与现有参数;接管不会写入或覆盖它们。" },
{ icon: ServerCog, title: "建立生命周期", copy: "Run 自动识别这台实例,后续启动、停止和日志仍走平台通道。" },
{ icon: HeartPulse, title: "健康验证", copy: "确认端口、进程与配置可读后,才标记为接管成功。" }
] : [
{ icon: FolderCog, title: "定位服务端根目录", copy: "填写已有服务端文件、数据与配置所在的主目录。" }, { icon: FolderCog, title: "定位服务端根目录", copy: "填写已有服务端文件、数据与配置所在的主目录。" },
{ icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、插件识别和端口是否可用。" }, { icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、插件识别和端口是否可用。" },
{ icon: SlidersHorizontal, title: "只读扫描配置", copy: "读取插件需要的现有状态,不把新建默认值写进服务器。" }, { icon: SlidersHorizontal, title: "只读扫描配置", copy: "读取插件需要的现有状态,不把新建默认值写进服务器。" },
@@ -172,5 +159,5 @@ function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string
{ icon: HeartPulse, title: "健康验证", copy: "验证通过后才标记为接管成功。" } { icon: HeartPulse, title: "健康验证", copy: "验证通过后才标记为接管成功。" }
]; ];
return <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span> Run 使</span></div><small></small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol>{isScum ? <p><strong>SCUM SteamCMD</strong> SteamCMD Run SteamCMD <br /><strong></strong> SCUM SteamCMD /</p> : <p><strong></strong></p>}</section>; return <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span> Run 使</span></div><small></small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol><p><strong></strong></p></section>;
} }
+1 -1
View File
@@ -24,7 +24,7 @@ Plugin page runs with safe platform context.
- `plugin-lifecycle.request`: declared plugin lifecycle request through Platform. - `plugin-lifecycle.request`: declared plugin lifecycle request through Platform.
- `ai.invoke`: platform-mediated AI invocation. - `ai.invoke`: platform-mediated AI invocation.
The host intersects manifest-level and page-level permissions/actions before exposing context. The SCUM operations page additionally intersects its command, snapshot, and query-template keys with `gameClientBridge.pages.operations`; it does not synthesize undeclared SCUM semantics. The host intersects manifest-level and page-level permissions/actions before exposing context. A plugin page additionally intersects its command, snapshot, and query-template keys with the matching `gameClientBridge.pages.*` declaration; it does not synthesize undeclared game semantics.
## Forbidden ## Forbidden
+9 -5
View File
@@ -67,6 +67,8 @@ describe("first-party console pages", () => {
expect(runPlatformOptions(plugin, "darwin")).toEqual(["windows", "linux"]); expect(runPlatformOptions(plugin, "darwin")).toEqual(["windows", "linux"]);
expect(runPlatformOptions(undefined, "windows")).toEqual(["windows"]); expect(runPlatformOptions(undefined, "windows")).toEqual(["windows"]);
expect(serversPageSource).not.toContain("quickRuntimeDefaultsForPlugin");
expect(serversPageSource).not.toContain("pluginId.toLowerCase().includes");
}); });
it("renders the platform overview with first-screen health modules", () => { it("renders the platform overview with first-screen health modules", () => {
@@ -191,16 +193,18 @@ describe("first-party console pages", () => {
expect(serverDeploymentWorkflowSource).toContain("创建服务器"); expect(serverDeploymentWorkflowSource).toContain("创建服务器");
expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)"); expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)");
expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录"); expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录");
expect(serverDeploymentWorkflowSource).toContain("安装目录{isScum ? \"(必填)\" : \"(可选)\"}"); expect(serverDeploymentWorkflowSource).toContain("安装目录(可选)");
expect(serverDeploymentWorkflowSource).toContain("新建并安装执行流程"); expect(serverDeploymentWorkflowSource).toContain("新建并安装执行流程");
expect(serverDeploymentWorkflowSource).toContain("安装目录”就是游戏服务端、数据和配置将落地的位置"); expect(serverDeploymentWorkflowSource).toContain("安装目录”就是游戏服务端、数据和配置将落地的位置");
expect(serverDeploymentWorkflowSource).toContain("通过 SteamCMD 安装 App 3792580 到该目录"); expect(serverDeploymentWorkflowSource).toContain("插件日志按插件输出原样显示");
expect(serverDeploymentWorkflowSource).toContain("全部 4 步通过才算安装成功"); expect(serverDeploymentWorkflowSource).toContain("按插件声明的推荐方案安装到该目录");
expect(serverDeploymentWorkflowSource).not.toContain("SteamCMD 安装 App 3792580");
expect(serverDeploymentWorkflowSource).not.toContain("selectedPlugin?.id === \"game.scum\"");
expect(serverDeploymentWorkflowSource).toContain("已有服务器目录"); expect(serverDeploymentWorkflowSource).toContain("已有服务器目录");
expect(serverDeploymentWorkflowSource).toContain("不会重装或覆盖现有游戏配置"); expect(serverDeploymentWorkflowSource).toContain("不会重装或覆盖现有游戏配置");
expect(serverDeploymentWorkflowSource).toContain("接管已有服务器执行流程"); expect(serverDeploymentWorkflowSource).toContain("接管已有服务器执行流程");
expect(serverDeploymentWorkflowSource).toContain("不需要填写 SteamCMD 目录"); expect(serverDeploymentWorkflowSource).not.toContain("不需要填写 SteamCMD 目录");
expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的自动升级任务"); expect(serverDeploymentWorkflowSource).not.toContain("当前平台尚未提供 SCUM 服务端的自动升级任务");
expect(serverDeploymentWorkflowSource).toContain("Run 会按心跳自动识别服务器"); expect(serverDeploymentWorkflowSource).toContain("Run 会按心跳自动识别服务器");
expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })'); expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })');
expect(serverDetailPageSource).not.toContain("运行配置绑定"); expect(serverDetailPageSource).not.toContain("运行配置绑定");
+1 -1
View File
@@ -74,7 +74,7 @@ describe("PluginsPage", () => {
expect(html).toContain("停用"); expect(html).toContain("停用");
expect(html).toContain("SCUM Simple RCON UE4SS DLL"); expect(html).toContain("SCUM Simple RCON UE4SS DLL");
expect(html).toContain("启动前自动校验和更新"); expect(html).toContain("启动前自动校验和更新");
expect(html).toContain("运行:SCUM 服务受监管启动"); expect(html).toContain("运行:目标服务受监管启动");
expect(html).toContain("Linux 启动前拒绝"); expect(html).toContain("Linux 启动前拒绝");
expect(html).toContain('role="dialog"'); expect(html).toContain('role="dialog"');
expect(html).not.toContain("billing"); expect(html).not.toContain("billing");
@@ -138,6 +138,10 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("tcp://"); expect(serverDetailPageSource).not.toContain("tcp://");
expect(serverDetailPageSource).not.toContain("mysql://"); expect(serverDetailPageSource).not.toContain("mysql://");
expect(serverDetailPageSource).not.toContain("sqlite://"); expect(serverDetailPageSource).not.toContain("sqlite://");
expect(serverDetailPageSource).not.toContain("SCUM 部署模板");
expect(serverDetailPageSource).not.toContain("isScumTemplate");
expect(serverDetailPageSource).toContain("部署验证模板");
expect(serverDetailPageSource).toContain("平台普通响应不会回显");
}); });
it("does not expose manual runtime configuration surfaces", () => { it("does not expose manual runtime configuration surfaces", () => {
+3 -3
View File
@@ -388,12 +388,12 @@ function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSecti
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />; if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
const view = deployment.data; const view = deployment.data;
const projection = view.projection; const projection = view.projection;
const isScumTemplate = (instance.pluginId === "game.scum" && (view.mode === "guided-install" || view.mode === "existing-server")) || projection?.templateKey?.startsWith("scum-"); const hasProjection = Boolean(projection?.templateKey || projection?.templateVersion || projection?.preflightState || projection?.discoveryState || projection?.mappingState || projection?.verificationState || projection?.failureCode);
return <article className="console-panel" aria-label="server deployment"> return <article className="console-panel" aria-label="server deployment">
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> </h2><span className="page-status">{view.mode || "未配置"} · {view.revision}</span></div> <div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> </h2><span className="page-status">{view.mode || "未配置"} · {view.revision}</span></div>
<p className="section-copy"></p> <p className="section-copy"></p>
<div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span> Run </span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run </span><strong> r{view.latestDispatch.deploymentRevision} </strong></div>}</div> <div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span> Run </span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run </span><strong> r{view.latestDispatch.deploymentRevision} </strong></div>}</div>
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM </span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span></span><strong>{projection.failureCode}</strong></div>}</div>} {hasProjection && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span></span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : projection?.templateKey ?? "等待 Run 预检"}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span></span><strong>{projection.failureCode}</strong></div>}</div>}
</article>; </article>;
} }
+32 -18
View File
@@ -3,7 +3,16 @@ import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, us
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
import type { GamePluginResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types"; import type {
DependencyCatalogResponse,
DependencyPlanViewResponse,
DependencyProbeViewResponse,
GamePluginResponse,
JobResponse,
RunEndpointResponse,
ServerInstanceResponse,
ServerMetricsResponse
} from "../api/types";
import { import {
RuntimeTaskProgressDialog, RuntimeTaskProgressDialog,
type RuntimeTaskDialogAction, type RuntimeTaskDialogAction,
@@ -223,11 +232,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
} }
} }
function openRunTargetSelection(instance: ServerInstanceResponse) { function openRunTargetSelection(instance: ServerInstanceResponse) {
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId); const plugin = plugins.find((item) => item.id === instance.pluginId);
const targetOs = runPlatformOptions(plugin, "linux")[0] ?? "linux";
setRuntimeTaskActions([]); setRuntimeTaskActions([]);
setRunTargetSelection({ instance, targetOs: defaults.runOs, targetArch: "amd64" }); setRunTargetSelection({ instance, targetOs, targetArch: "amd64" });
} }
async function handleRunTargetSubmit(event: FormEvent<HTMLFormElement>) { async function handleRunTargetSubmit(event: FormEvent<HTMLFormElement>) {
@@ -292,7 +301,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
return; return;
} }
} }
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
const intent = quickRuntimeActionLabel(action); const intent = quickRuntimeActionLabel(action);
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName }); const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName });
setRuntimeTaskActions([]); setRuntimeTaskActions([]);
@@ -320,14 +328,14 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
return `run 密钥已重置到第 ${key.generation} 代,旧 run 会话已失效,请重新生成并部署 run`; return `run 密钥已重置到第 ${key.generation} 代,旧 run 会话已失效,请重新生成并部署 run`;
} }
if (action === "dependencies-check") { if (action === "dependencies-check") {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey)); const catalog = await platformApiClient.getDependencyCatalog(instance.id);
const probe = firstDependencyProbe(catalog);
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probe.key));
return `依赖检查任务已排队,job ${job.id}`; return `依赖检查任务已排队,job ${job.id}`;
} }
if (action === "dependencies-install") { if (action === "dependencies-install") {
const catalog = await platformApiClient.getDependencyCatalog(instance.id); const catalog = await platformApiClient.getDependencyCatalog(instance.id);
const plan = catalog.plans.find((candidate) => candidate.key === defaults.installPlanKey); const { probe, plan } = firstInstallableDependency(catalog);
const probe = catalog.probes.find((candidate) => candidate.key === defaults.probeKey);
if (!plan || probe?.installPlanKey !== plan.key) throw new Error("Platform 未返回与当前 probe 匹配的审核安装计划");
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest)); const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest));
return `依赖安装任务已排队,job ${job.id}`; return `依赖安装任务已排队,job ${job.id}`;
} }
@@ -905,15 +913,6 @@ function quickRuntimeTaskDescription(instance: ServerInstanceResponse, action: S
return `${instance.name}${instance.id}${label},通过平台 API 派发并保留可追踪进度。`; return `${instance.name}${instance.id}${label},通过平台 API 派发并保留可追踪进度。`;
} }
function quickRuntimeDefaultsForPlugin(pluginId: string) {
const isScum = pluginId.toLowerCase().includes("scum");
return {
runOs: isScum ? "windows" : "linux",
probeKey: isScum ? "steamcmd" : "java-21",
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux"
};
}
export function runPlatformOptions(plugin: GamePluginResponse | undefined, fallback: string): string[] { export function runPlatformOptions(plugin: GamePluginResponse | undefined, fallback: string): string[] {
const options = new Set<string>(); const options = new Set<string>();
const add = (value: string | undefined) => { const add = (value: string | undefined) => {
@@ -932,6 +931,21 @@ export function runPlatformOptions(plugin: GamePluginResponse | undefined, fallb
return [...options]; return [...options];
} }
function firstDependencyProbe(catalog: DependencyCatalogResponse): DependencyProbeViewResponse {
const probe = catalog.probes.find((candidate) => candidate.required) ?? catalog.probes[0];
if (!probe) throw new Error("插件未声明可检查的运行依赖");
return probe;
}
function firstInstallableDependency(catalog: DependencyCatalogResponse): { probe: DependencyProbeViewResponse; plan: DependencyPlanViewResponse } {
for (const probe of catalog.probes) {
if (!probe.installPlanKey) continue;
const plan = catalog.plans.find((candidate) => candidate.key === probe.installPlanKey);
if (plan) return { probe, plan };
}
throw new Error("插件未声明可安装的运行依赖计划");
}
function runPlatformLabel(platform: string): string { function runPlatformLabel(platform: string): string {
switch (platform) { switch (platform) {
case "linux": case "linux":
-5
View File
@@ -99,8 +99,3 @@ function copyStreamChunk(value: Uint8Array): ArrayBuffer {
copy.set(value); copy.set(value);
return copy.buffer; return copy.buffer;
} }
export function safeArtifactError(error: unknown): string {
const message = error instanceof Error ? error.message : "制品传输失败";
return message.replace(/\/Users\/[^\s]+/g, "[path]").replace(/Bearer\s+[^\s]+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]");
}
@@ -1243,21 +1243,6 @@
} }
], ],
"installPlans": [ "installPlans": [
{
"key": "install-scum-server",
"title": "Install SCUM Dedicated Server",
"platforms": [
"windows"
],
"steps": [
{
"type": "steamcmd-app",
"targetKey": "server/install-root",
"packageManager": "steamcmd",
"packageName": "3792580"
}
]
},
{ {
"key": "install-steamcmd-linux", "key": "install-steamcmd-linux",
"title": "Install SteamCMD", "title": "Install SteamCMD",
@@ -1416,7 +1401,7 @@
"targetKey": "ue4ss/scum-simple-rcon", "targetKey": "ue4ss/scum-simple-rcon",
"modKey": "scum_simple_rcon", "modKey": "scum_simple_rcon",
"dllRef": "ue4ss/Mods/scum_simple_rcon/dlls/main.dll", "dllRef": "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
"scumExecutableChecksum": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "targetExecutableChecksum": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"ue4ssAbi": "ue4ss-3.0", "ue4ssAbi": "ue4ss-3.0",
"supportedTargets": [ "supportedTargets": [
{ {
@@ -109,12 +109,6 @@
"items": { "$ref": "#/$defs/runtimeInstallPlan" }, "items": { "$ref": "#/$defs/runtimeInstallPlan" },
"uniqueItems": true "uniqueItems": true
}, },
"serverDeployments": {
"type": "array",
"items": { "$ref": "#/$defs/runtimeServerDeploymentProfile" },
"uniqueItems": true,
"maxItems": 8
},
"logSources": { "logSources": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/runtimeLogSource" }, "items": { "$ref": "#/$defs/runtimeLogSource" },
@@ -557,9 +551,9 @@
"required": ["type", "targetKey"], "required": ["type", "targetKey"],
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"type": { "enum": ["package", "verified-download", "steamcmd-app", "manual"] }, "type": { "enum": ["package", "verified-download", "manual"] },
"targetKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" },
"packageManager": { "enum": ["winget", "choco", "scoop", "apt", "yum", "dnf", "pacman", "zypper", "brew", "steamcmd", "manual"] }, "packageManager": { "enum": ["winget", "choco", "scoop", "apt", "yum", "dnf", "pacman", "zypper", "brew"] },
"packageName": { "type": "string", "pattern": "^[a-zA-Z0-9_.:+@/-]+$", "maxLength": 120 }, "packageName": { "type": "string", "pattern": "^[a-zA-Z0-9_.:+@/-]+$", "maxLength": 120 },
"version": { "type": "string", "maxLength": 80 }, "version": { "type": "string", "maxLength": 80 },
"downloadRef": { "type": "string", "pattern": "^https://[a-zA-Z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+$", "maxLength": 240 }, "downloadRef": { "type": "string", "pattern": "^https://[a-zA-Z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+$", "maxLength": 240 },
@@ -576,16 +570,6 @@
{ {
"if": { "properties": { "type": { "const": "verified-download" } }, "required": ["type"] }, "if": { "properties": { "type": { "const": "verified-download" } }, "required": ["type"] },
"then": { "required": ["downloadRef", "checksum"] } "then": { "required": ["downloadRef", "checksum"] }
},
{
"if": { "properties": { "type": { "const": "steamcmd-app" } }, "required": ["type"] },
"then": {
"required": ["packageName"],
"properties": {
"packageManager": { "const": "steamcmd" },
"packageName": { "type": "string", "pattern": "^[0-9]{1,12}$" }
}
}
} }
] ]
}, },
@@ -600,68 +584,6 @@
"steps": { "type": "array", "items": { "$ref": "#/$defs/runtimeInstallStep" }, "minItems": 1, "maxItems": 64 } "steps": { "type": "array", "items": { "$ref": "#/$defs/runtimeInstallStep" }, "minItems": 1, "maxItems": 64 }
} }
}, },
"runtimeServerConfigMapping": {
"type": "object",
"required": ["fieldKey", "configKey", "valueType"],
"additionalProperties": false,
"properties": {
"fieldKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._/-]*$", "maxLength": 80 },
"configKey": { "$ref": "#/$defs/logicalKey" },
"valueType": { "enum": ["text", "integer", "number", "boolean", "port"] },
"required": { "type": "boolean" }
}
},
"runtimeServerDiscoveryMarker": {
"type": "object",
"required": ["key", "kind", "targetKey"],
"additionalProperties": false,
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"kind": { "enum": ["file.exists", "command.version", "port.open", "steam.app"] },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"expected": { "type": "string", "maxLength": 120 },
"required": { "type": "boolean" }
}
},
"runtimeServerVerificationCheck": {
"type": "object",
"required": ["key", "kind", "targetKey"],
"additionalProperties": false,
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"kind": { "enum": ["executable.present", "version.matches", "port.bound", "config.readable", "process.healthy"] },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"required": { "type": "boolean" }
}
},
"runtimeServerDeploymentProfile": {
"type": "object",
"required": ["key", "version", "supportedTargets", "steamAppId", "executableKey", "installRootKey", "configKey", "configFormat", "configMappings", "discoveryMarkers", "verificationChecks"],
"additionalProperties": false,
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$" },
"supportedTargets": { "type": "array", "items": { "$ref": "#/$defs/runtimeTarget" }, "minItems": 1, "uniqueItems": true },
"steamAppId": { "type": "string", "pattern": "^[0-9]{1,12}$" },
"executableKey": { "$ref": "#/$defs/logicalKey" },
"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": 4, "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"],
@@ -724,7 +646,7 @@
"targetKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" },
"modKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]{0,79}$" }, "modKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]{0,79}$" },
"dllRef": { "type": "string", "pattern": "^ue4ss/Mods/[a-z0-9][a-z0-9_-]{0,79}/dlls/main\\.dll$", "maxLength": 160 }, "dllRef": { "type": "string", "pattern": "^ue4ss/Mods/[a-z0-9][a-z0-9_-]{0,79}/dlls/main\\.dll$", "maxLength": 160 },
"scumExecutableChecksum": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }, "targetExecutableChecksum": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" },
"ue4ssAbi": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,80}$" }, "ue4ssAbi": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,80}$" },
"supportedTargets": { "type": "array", "items": { "$ref": "#/$defs/runtimeTarget" }, "minItems": 1, "maxItems": 1, "uniqueItems": true }, "supportedTargets": { "type": "array", "items": { "$ref": "#/$defs/runtimeTarget" }, "minItems": 1, "maxItems": 1, "uniqueItems": true },
"updateOnStart": { "const": true }, "updateOnStart": { "const": true },
@@ -733,7 +655,7 @@
"allOf": [ "allOf": [
{ {
"if": { "properties": { "releaseState": { "const": "ready" } }, "required": ["releaseState"] }, "if": { "properties": { "releaseState": { "const": "ready" } }, "required": ["releaseState"] },
"then": { "required": ["releaseUrl", "checksum", "sizeBytes", "scumExecutableChecksum", "ue4ssAbi"] } "then": { "required": ["releaseUrl", "checksum", "sizeBytes", "targetExecutableChecksum", "ue4ssAbi"] }
} }
] ]
}, },
+5 -22
View File
@@ -321,25 +321,8 @@ function validateDependencyPlans(manifest: unknown): string[] {
function validateServerDeploymentProfiles(manifest: unknown): string[] { function validateServerDeploymentProfiles(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null || !("server" in manifest)) return []; if (typeof manifest !== "object" || manifest === null || !("server" in manifest)) return [];
const record = manifest as { server?: { createFields?: Array<{ key?: string }> }; runtimeProfiles?: { serverDeployments?: Array<any> } }; const record = manifest as { runtimeProfiles?: { serverDeployments?: unknown } };
const declaredFields = new Set((record.server?.createFields ?? []).map((field) => field.key).filter((key): key is string => Boolean(key))); return record.runtimeProfiles?.serverDeployments === undefined ? [] : ["manifest.runtimeProfiles.serverDeployments: legacy server deployment profiles are no longer supported"];
const errors: string[] = [];
for (const [index, profile] of (record.runtimeProfiles?.serverDeployments ?? []).entries()) {
const location = `manifest.runtimeProfiles.serverDeployments[${index}]`;
const mappingKeys = new Set<string>();
for (const [mappingIndex, mapping] of (profile.configMappings ?? []).entries()) {
const mappingLocation = `${location}.configMappings[${mappingIndex}]`;
if (!declaredFields.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: must reference a declared server.createFields key`);
if (mappingKeys.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: duplicate mapping`);
mappingKeys.add(mapping.fieldKey);
}
const requiredChecks = new Set(["executable.present", "port.bound", "config.readable", "process.healthy"]);
for (const check of profile.verificationChecks ?? []) {
if (check.required) requiredChecks.delete(check.kind);
}
for (const missing of requiredChecks) errors.push(`${location}.verificationChecks: required check ${missing} is missing`);
}
return errors;
} }
function validateUnsupportedLegacyClientManagerDeclarations(manifest: unknown): string[] { function validateUnsupportedLegacyClientManagerDeclarations(manifest: unknown): string[] {
@@ -380,7 +363,7 @@ function validateDLLExtensionProfiles(manifest: unknown): string[] {
targetKey?: string; targetKey?: string;
modKey?: string; modKey?: string;
dllRef?: string; dllRef?: string;
scumExecutableChecksum?: string; targetExecutableChecksum?: string;
ue4ssAbi?: string; ue4ssAbi?: string;
supportedTargets?: Array<{ os?: string; arch?: string }>; supportedTargets?: Array<{ os?: string; arch?: string }>;
updateOnStart?: boolean; updateOnStart?: boolean;
@@ -415,8 +398,8 @@ function validateDLLExtensionProfiles(manifest: unknown): string[] {
errors.push(`${location}.releaseState: must be ready or unpublished`); errors.push(`${location}.releaseState: must be ready or unpublished`);
} }
if (profile.releaseState === "ready") { if (profile.releaseState === "ready") {
if (!checksumPattern.test(profile.checksum ?? "") || !checksumPattern.test(profile.scumExecutableChecksum ?? "")) { if (!checksumPattern.test(profile.checksum ?? "") || !checksumPattern.test(profile.targetExecutableChecksum ?? "")) {
errors.push(`${location}: release and SCUM executable SHA-256 checksums are required`); errors.push(`${location}: release and target executable SHA-256 checksums are required`);
} }
if (!Number.isInteger(profile.sizeBytes) || (profile.sizeBytes ?? 0) < 1 || (profile.sizeBytes ?? 0) > 128 * 1024 * 1024) { if (!Number.isInteger(profile.sizeBytes) || (profile.sizeBytes ?? 0) < 1 || (profile.sizeBytes ?? 0) > 128 * 1024 * 1024) {
errors.push(`${location}.sizeBytes: must be a bounded DLL size`); errors.push(`${location}.sizeBytes: must be a bounded DLL size`);
+3 -3
View File
@@ -387,9 +387,9 @@ export interface RuntimeDependencyProbe {
} }
export interface RuntimeInstallStep { export interface RuntimeInstallStep {
type: "package" | "verified-download" | "steamcmd-app" | "manual"; type: "package" | "verified-download" | "manual";
targetKey: string; targetKey: string;
packageManager?: "winget" | "choco" | "scoop" | "apt" | "yum" | "dnf" | "pacman" | "zypper" | "brew" | "steamcmd" | "manual"; packageManager?: "winget" | "choco" | "scoop" | "apt" | "yum" | "dnf" | "pacman" | "zypper" | "brew";
packageName?: string; packageName?: string;
version?: string; version?: string;
downloadRef?: string; downloadRef?: string;
@@ -444,7 +444,7 @@ export interface RuntimeDLLExtensionProfile {
targetKey: string; targetKey: string;
modKey: string; modKey: string;
dllRef: string; dllRef: string;
scumExecutableChecksum?: `sha256:${string}`; targetExecutableChecksum?: `sha256:${string}`;
ue4ssAbi?: string; ue4ssAbi?: string;
supportedTargets: [{ os: "windows"; arch: "amd64" }]; supportedTargets: [{ os: "windows"; arch: "amd64" }];
updateOnStart: true; updateOnStart: true;
@@ -20,6 +20,9 @@
{"key": "unsafe-install", "title": "bash -c installer", "steps": [{"type": "manual", "targetKey": "manual"}]}, {"key": "unsafe-install", "title": "bash -c installer", "steps": [{"type": "manual", "targetKey": "manual"}]},
{"key": "unsafe-download", "title": "Unsafe dependency download", "steps": [{"type": "verified-download", "targetKey": "tool", "downloadRef": "https://127.0.0.1/tool", "checksum": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]} {"key": "unsafe-download", "title": "Unsafe dependency download", "steps": [{"type": "verified-download", "targetKey": "tool", "downloadRef": "https://127.0.0.1/tool", "checksum": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}
], ],
"serverDeployments": [
{"key": "legacy", "version": "1.0.0"}
],
"clientManagers": [ "clientManagers": [
{ {
"key": "unsafe-client", "key": "unsafe-client",
@@ -165,6 +165,7 @@ describe("plugin manifest validation", () => {
const installScript = fs.readFileSync(path.join(pluginDir, installAction.executableKey), "utf8"); const installScript = fs.readFileSync(path.join(pluginDir, installAction.executableKey), "utf8");
const startScript = fs.readFileSync(path.join(pluginDir, startAction.executableKey), "utf8"); const startScript = fs.readFileSync(path.join(pluginDir, startAction.executableKey), "utf8");
expect(manifest.runtimeProfiles.serverDeployments).toBeUndefined(); expect(manifest.runtimeProfiles.serverDeployments).toBeUndefined();
expect(JSON.stringify(manifest.runtimeProfiles.installPlans ?? [])).not.toContain("steamcmd-app");
expect(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd", "assets/map/scum-map-overview.jpg"])); expect(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd", "assets/map/scum-map-overview.jpg"]));
expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 +quit" } }); expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 +quit" } });
expect(installAction.timeoutMs).toBe(7200000); expect(installAction.timeoutMs).toBe(7200000);
@@ -709,6 +710,7 @@ describe("plugin manifest validation", () => {
expect(errors.some((error) => error.includes("raw host path"))).toBe(true); expect(errors.some((error) => error.includes("raw host path"))).toBe(true);
expect(errors.some((error) => error.includes("arbitrary shell"))).toBe(true); expect(errors.some((error) => error.includes("arbitrary shell"))).toBe(true);
expect(errors.some((error) => error.includes("not approved for dependency download"))).toBe(true); expect(errors.some((error) => error.includes("not approved for dependency download"))).toBe(true);
expect(errors.some((error) => error.includes("runtimeProfiles.serverDeployments") && error.includes("no longer supported"))).toBe(true);
expect(errors.some((error) => error.includes("runtimeProfiles.clientManagers") && error.includes("no longer supported"))).toBe(true); expect(errors.some((error) => error.includes("runtimeProfiles.clientManagers") && error.includes("no longer supported"))).toBe(true);
}); });
+1 -1
View File
@@ -25,7 +25,7 @@ export PLATFORM_BOOTSTRAP_ADMIN_EMAIL="${PLATFORM_BOOTSTRAP_ADMIN_EMAIL:-operato
export PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="${PLATFORM_BOOTSTRAP_ADMIN_PASSWORD:-operator-local}" export PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="${PLATFORM_BOOTSTRAP_ADMIN_PASSWORD:-operator-local}"
export PLATFORM_SECRET_ENVELOPE_KEY="${PLATFORM_SECRET_ENVELOPE_KEY:-local-debug-secret-envelope-key-change-me}" export PLATFORM_SECRET_ENVELOPE_KEY="${PLATFORM_SECRET_ENVELOPE_KEY:-local-debug-secret-envelope-key-change-me}"
export PLATFORM_AI_PROVIDER_MODE="${PLATFORM_AI_PROVIDER_MODE:-mock}" export PLATFORM_AI_PROVIDER_MODE="${PLATFORM_AI_PROVIDER_MODE:-mock}"
export PLATFORM_RUN_RELEASE_URL="${PLATFORM_RUN_RELEASE_URL:-https://scum.npc0.com/}" export PLATFORM_RUN_RELEASE_URL="${PLATFORM_RUN_RELEASE_URL:-http://127.0.0.1:$LOCAL_DEBUG_PLATFORM_PORT}"
export RUN_SOURCE_DIR="${RUN_SOURCE_DIR:-${RUN_REPO_DIR:-$LOCAL_DEBUG_ROOT_DIR/run}}" export RUN_SOURCE_DIR="${RUN_SOURCE_DIR:-${RUN_REPO_DIR:-$LOCAL_DEBUG_ROOT_DIR/run}}"
export RUN_REPO_DIR="$RUN_SOURCE_DIR" export RUN_REPO_DIR="$RUN_SOURCE_DIR"