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)
+11 -11
View File
@@ -197,17 +197,17 @@ type RunAutonomousLogSource struct {
} }
type RunAutonomousDLLExtension struct { type RunAutonomousDLLExtension struct {
Key string `json:"key"` Key string `json:"key"`
Version string `json:"version"` Version string `json:"version"`
ReleaseURL string `json:"releaseUrl"` ReleaseURL string `json:"releaseUrl"`
Checksum string `json:"checksum"` Checksum string `json:"checksum"`
SizeBytes int64 `json:"sizeBytes,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"`
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"`
} }
// RunAutonomousDataTarget is a package-local snapshot declaration. The source // RunAutonomousDataTarget is a package-local snapshot declaration. The source
+29 -60
View File
@@ -462,41 +462,41 @@ type RuntimeDataTarget struct {
} }
type RuntimeDLLExtensionProfile struct { type RuntimeDLLExtensionProfile struct {
Key string Key string
DisplayName string DisplayName string
Kind string Kind string
Activation string Activation string
Version string Version string
ReleaseState string ReleaseState string
ReleaseURL string ReleaseURL string
Checksum string Checksum string
SizeBytes int64 SizeBytes int64
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
RCONPort int RCONPort int
} }
type RuntimeDLLExtensionPlan struct { type RuntimeDLLExtensionPlan struct {
Key string Key string
Version string Version string
ReleaseURL string ReleaseURL string
Checksum string Checksum string
SizeBytes int64 SizeBytes int64
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)
+62 -113
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"`
@@ -151,77 +136,77 @@ type RuntimeConfigTemplateBody struct {
} }
type RuntimeDLLExtensionProfileBody struct { type RuntimeDLLExtensionProfileBody struct {
Key string `json:"key"` Key string `json:"key"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Kind string `json:"kind"` Kind string `json:"kind"`
Activation string `json:"activation"` Activation string `json:"activation"`
Version string `json:"version"` Version string `json:"version"`
ReleaseState string `json:"releaseState"` ReleaseState string `json:"releaseState"`
ReleaseURL string `json:"releaseUrl,omitempty"` ReleaseURL string `json:"releaseUrl,omitempty"`
ReleaseHost string `json:"releaseHost,omitempty"` ReleaseHost string `json:"releaseHost,omitempty"`
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"`
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"`
RCONPort int `json:"rconPort"` RCONPort int `json:"rconPort"`
} }
// RuntimeDLLExtensionProfileResponseBody is the browser-safe projection of a // RuntimeDLLExtensionProfileResponseBody is the browser-safe projection of a
// declared DLL extension. The immutable deployment path, RCON port, and full // declared DLL extension. The immutable deployment path, RCON port, and full
// release URL remain internal to the manifest/start-job contracts. // release URL remain internal to the manifest/start-job contracts.
type RuntimeDLLExtensionProfileResponseBody struct { type RuntimeDLLExtensionProfileResponseBody struct {
Key string `json:"key"` Key string `json:"key"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Kind string `json:"kind"` Kind string `json:"kind"`
Activation string `json:"activation"` Activation string `json:"activation"`
Version string `json:"version"` Version string `json:"version"`
ReleaseState string `json:"releaseState"` ReleaseState string `json:"releaseState"`
ReleaseHost string `json:"releaseHost,omitempty"` ReleaseHost string `json:"releaseHost,omitempty"`
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"`
} }
type RuntimeDLLExtensionPlanBody struct { type RuntimeDLLExtensionPlanBody struct {
Key string `json:"key"` Key string `json:"key"`
Version string `json:"version"` Version string `json:"version"`
ReleaseURL string `json:"releaseUrl"` ReleaseURL string `json:"releaseUrl"`
Checksum string `json:"checksum"` Checksum string `json:"checksum"`
SizeBytes int64 `json:"sizeBytes"` SizeBytes int64 `json:"sizeBytes"`
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"`
} }
type GamePluginRuntimeProfilesBody struct { type GamePluginRuntimeProfilesBody struct {
Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"` Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"`
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,
}}, }},
} }
} }
+20 -124
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")
@@ -409,22 +305,22 @@ func validateRuntimeDLLReleaseURL(field string, value string) []string {
func validateRuntimeDLLExtensionPlan(prefix string, plan domain.RuntimeDLLExtensionPlan) []string { func validateRuntimeDLLExtensionPlan(prefix string, plan domain.RuntimeDLLExtensionPlan) []string {
return validateRuntimeDLLExtensionProfile(prefix, domain.RuntimeDLLExtensionProfile{ return validateRuntimeDLLExtensionProfile(prefix, domain.RuntimeDLLExtensionProfile{
Key: plan.Key, Key: plan.Key,
Kind: "ue4ss-dll", Kind: "ue4ss-dll",
Activation: "server-start", Activation: "server-start",
Version: plan.Version, Version: plan.Version,
ReleaseState: "ready", ReleaseState: "ready",
ReleaseURL: plan.ReleaseURL, ReleaseURL: plan.ReleaseURL,
Checksum: plan.Checksum, Checksum: plan.Checksum,
SizeBytes: plan.SizeBytes, SizeBytes: plan.SizeBytes,
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,
RCONPort: plan.RCONPort, RCONPort: plan.RCONPort,
}) })
} }
@@ -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
+12 -8
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", () => {
@@ -184,23 +186,25 @@ describe("first-party console pages", () => {
expect(serversPageSource).not.toContain('instance.state === "running" || instance.state === "installing"'); expect(serversPageSource).not.toContain('instance.state === "running" || instance.state === "installing"');
expect(serverDetailPageSource).not.toContain("<ServerDeploymentWorkflow"); expect(serverDetailPageSource).not.toContain("<ServerDeploymentWorkflow");
expect(serverDetailPageSource).toContain("ServerDeploymentSection"); expect(serverDetailPageSource).toContain("ServerDeploymentSection");
expect(serverDeploymentWorkflowSource).toContain("基本信息"); expect(serverDeploymentWorkflowSource).toContain("基本信息");
expect(serverDeploymentWorkflowSource).toContain("部署方式"); expect(serverDeploymentWorkflowSource).toContain("部署方式");
expect(serverDeploymentWorkflowSource).toContain("相关配置"); expect(serverDeploymentWorkflowSource).toContain("相关配置");
expect(serverDeploymentWorkflowSource).toContain("自动上报心跳"); expect(serverDeploymentWorkflowSource).toContain("自动上报心跳");
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", () => {
+23 -23
View File
@@ -379,35 +379,35 @@ function ServerMetadataSection({ instance, session, operations, onChanged }: Ser
} }
interface ServerDeploymentSectionProps { interface ServerDeploymentSectionProps {
instance: ServerInstanceResponse; instance: ServerInstanceResponse;
deployment: LoadState<ServerDeploymentResponse>; deployment: LoadState<ServerDeploymentResponse>;
} }
function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSectionProps) { function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSectionProps) {
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />; if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
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>;
} }
function deploymentProjectionLabel(value?: string): string { function deploymentProjectionLabel(value?: string): string {
switch (value) { switch (value) {
case "queued": return "排队中"; case "queued": return "排队中";
case "running": return "执行中"; case "running": return "执行中";
case "passed": return "已通过"; case "passed": return "已通过";
case "applied": return "已写入"; case "applied": return "已写入";
case "unchanged": return "未变化"; case "unchanged": return "未变化";
case "failed": return "失败"; case "failed": return "失败";
case "skipped": return "已跳过"; case "skipped": return "已跳过";
default: return "待返回"; default: return "待返回";
} }
} }
function deploymentProgressLabel(progress: JobResponse["progress"]): string { function deploymentProgressLabel(progress: JobResponse["progress"]): string {
+52 -38
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,
@@ -21,8 +30,8 @@ import type { PageComponentProps } from "../contracts/page";
import { import {
canDeleteServer, canDeleteServer,
defaultServerCreateForm, defaultServerCreateForm,
pluginCreateInputDefaults, pluginCreateInputDefaults,
runtimeObservationFreshness, runtimeObservationFreshness,
type ServerCreateFormState type ServerCreateFormState
} from "../contracts/serverManagement"; } from "../contracts/serverManagement";
import { summarizeServerOperations } from "../contracts/operationsConsole"; import { summarizeServerOperations } from "../contracts/operationsConsole";
@@ -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([]);
@@ -300,40 +308,40 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
await requireQuickRuntimeActionAvailable(instance.id, action); await requireQuickRuntimeActionAvailable(instance.id, action);
let message: string; let message: string;
message = await runtimeTask.runTask({ message = await runtimeTask.runTask({
title: intent, title: intent,
description: quickRuntimeTaskDescription(instance, action), description: quickRuntimeTaskDescription(instance, action),
stages: quickRuntimeStages(action), stages: quickRuntimeStages(action),
executeStageIndex: quickRuntimeExecuteStageIndex(action), executeStageIndex: quickRuntimeExecuteStageIndex(action),
execute: async () => { execute: async () => {
if (action === "download-run") { if (action === "download-run") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id); const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
await downloadArtifactReference(reference, (artifactId) => platformApiClient.downloadArtifactContent(artifactId)); await downloadArtifactReference(reference, (artifactId) => platformApiClient.downloadArtifactContent(artifactId));
return `run 下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`; return `run 下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`;
} }
if (action === "push-run-update") { if (action === "push-run-update") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id); const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum)); const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
return `run 更新任务已排队,job ${update.jobId ?? update.id}`; return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
} }
if (action === "reset-run-key") { if (action === "reset-run-key") {
const key = await platformApiClient.resetRunKey(instance.id); const key = await platformApiClient.resetRunKey(instance.id);
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));
return `依赖检查任务已排队,job ${job.id}`;
}
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 = firstDependencyProbe(catalog);
const probe = catalog.probes.find((candidate) => candidate.key === defaults.probeKey); const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probe.key));
if (!plan || probe?.installPlanKey !== plan.key) throw new Error("Platform 未返回与当前 probe 匹配的审核安装计划"); return `依赖检查任务已排队,job ${job.id}`;
}
if (action === "dependencies-install") {
const catalog = await platformApiClient.getDependencyCatalog(instance.id);
const { probe, plan } = firstInstallableDependency(catalog);
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}`;
}
throw new Error("该运行操作已下线");
} }
}); throw new Error("该运行操作已下线");
}
});
operations.succeed(operationId, message); operations.succeed(operationId, message);
runtimeTask.succeedTask(message); runtimeTask.succeedTask(message);
await refresh(); await refresh();
@@ -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"] }
} }
] ]
}, },
+6 -23
View File
@@ -320,26 +320,9 @@ 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"