diff --git a/platform/.env.example b/platform/.env.example index b9632d0..43009e1 100644 --- a/platform/.env.example +++ b/platform/.env.example @@ -37,5 +37,5 @@ PLATFORM_BUILDER_SOURCE_REPOSITORY=git@git.npc0.com:admin343/run.git PLATFORM_BUILDER_SOURCE_REVISION=main PLATFORM_BUILDER_WORKSPACE_DIR=.platform-data/distribution-builds PLATFORM_BUILDER_TIMEOUT_SECONDS=1800 -# URL embedded into generated Run and client-manager packages. -PLATFORM_RUN_RELEASE_URL=https://scum.npc0.com +# URL embedded into generated Run packages; override for tunnel or production access. +PLATFORM_RUN_RELEASE_URL=http://127.0.0.1:8080 diff --git a/platform/README.md b/platform/README.md index 0b8c23f..daa658a 100644 --- a/platform/README.md +++ b/platform/README.md @@ -62,7 +62,7 @@ Runtime configuration: - `PLATFORM_BUILDER_WORKSPACE_DIR`: private per-plugin/per-job build workspace, default `/distribution-builds`. - `PLATFORM_BUILDER_CACHE_DIR`: persistent Go build/module cache, default `/distribution-build-cache`; it contains no job inputs or component keys. - `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: diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index e9dfd37..50cbc58 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -912,6 +912,10 @@ func (h *coreHandlers) gamePlugins(w http.ResponseWriter, r *http.Request) { writeDecodeError(w, err) return } + if violations := request.RuntimeProfiles.UnsupportedLegacyProfileViolations("runtimeProfiles"); len(violations) > 0 { + writeServiceError(w, validator.ValidationError{Violations: violations}) + return + } plugin, err := h.core.CreateGamePlugin(request.ToDomain()) if err != nil { writeServiceError(w, err) @@ -944,7 +948,7 @@ func (h *coreHandlers) gamePluginManifestRegistration(w http.ResponseWriter, r * writeDecodeError(w, err) 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}) return } diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index e7cc1b5..20d094b 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -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) { router := newTestRouter() 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", 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", - 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) diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index e9609b8..031b30d 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -197,17 +197,17 @@ type RunAutonomousLogSource struct { } type RunAutonomousDLLExtension struct { - Key string `json:"key"` - Version string `json:"version"` - ReleaseURL string `json:"releaseUrl"` - Checksum string `json:"checksum"` - SizeBytes int64 `json:"sizeBytes,omitempty"` - TargetKey string `json:"targetKey"` - ModKey string `json:"modKey"` - DLLRef string `json:"dllRef"` - SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"` - UE4SSABI string `json:"ue4ssAbi,omitempty"` - RCONPort int `json:"rconPort,omitempty"` + Key string `json:"key"` + Version string `json:"version"` + ReleaseURL string `json:"releaseUrl"` + Checksum string `json:"checksum"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + TargetKey string `json:"targetKey"` + ModKey string `json:"modKey"` + DLLRef string `json:"dllRef"` + TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"` + UE4SSABI string `json:"ue4ssAbi,omitempty"` + RCONPort int `json:"rconPort,omitempty"` } // RunAutonomousDataTarget is a package-local snapshot declaration. The source diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 7896137..e815fa2 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -462,41 +462,41 @@ type RuntimeDataTarget struct { } type RuntimeDLLExtensionProfile struct { - Key string - DisplayName string - Kind string - Activation string - Version string - ReleaseState string - ReleaseURL string - Checksum string - SizeBytes int64 - TargetKey string - ModKey string - DLLRef string - SCUMExecutableChecksum string - UE4SSABI string - SupportedTargets []RuntimeTarget - UpdateOnStart bool - RCONPort int + Key string + DisplayName string + Kind string + Activation string + Version string + ReleaseState string + ReleaseURL string + Checksum string + SizeBytes int64 + TargetKey string + ModKey string + DLLRef string + TargetExecutableChecksum string + UE4SSABI string + SupportedTargets []RuntimeTarget + UpdateOnStart bool + RCONPort int } type RuntimeDLLExtensionPlan struct { - Key string - Version string - ReleaseURL string - Checksum string - SizeBytes int64 - TargetKey string - ModKey string - DLLRef string - SCUMExecutableChecksum string - UE4SSABI string - RCONPort int + Key string + Version string + ReleaseURL string + Checksum string + SizeBytes int64 + TargetKey string + ModKey string + DLLRef string + TargetExecutableChecksum string + UE4SSABI string + RCONPort int } // 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 { Protocol string ExtensionKey string @@ -543,29 +543,11 @@ type RuntimeServerPrerequisite struct { 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 { Discovery []RuntimeDiscoveryProbe LifecycleProfiles []RuntimeLifecycleProfile DependencyProbes []RuntimeDependencyProbe InstallPlans []RuntimeInstallPlan - ServerDeployments []RuntimeServerDeploymentProfile LogSources []RuntimeLogSource TransportProfiles []RuntimeTransportProfile DataTargets []RuntimeDataTarget @@ -1826,10 +1808,6 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi profiles.InstallPlans[i].Platforms = CopyStringSlice(profiles.InstallPlans[i].Platforms) 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.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...) for i := range profiles.TransportProfiles { @@ -1895,15 +1873,6 @@ func CopyServerInstance(instance ServerInstance) ServerInstance { 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 { projection.DiscoveredFacts = CopyStringMap(projection.DiscoveredFacts) projection.MappingResults = CopyStringMap(projection.MappingResults) diff --git a/platform/dto/runtime_profiles.go b/platform/dto/runtime_profiles.go index bf5a498..be637e6 100644 --- a/platform/dto/runtime_profiles.go +++ b/platform/dto/runtime_profiles.go @@ -82,21 +82,6 @@ type RuntimeServerVerificationCheckBody struct { 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 { Key string `json:"key"` Kind string `json:"kind"` @@ -151,77 +136,77 @@ type RuntimeConfigTemplateBody struct { } type RuntimeDLLExtensionProfileBody struct { - Key string `json:"key"` - DisplayName string `json:"displayName"` - Kind string `json:"kind"` - Activation string `json:"activation"` - Version string `json:"version"` - ReleaseState string `json:"releaseState"` - ReleaseURL string `json:"releaseUrl,omitempty"` - ReleaseHost string `json:"releaseHost,omitempty"` - ReleaseFilename string `json:"releaseFilename,omitempty"` - Checksum string `json:"checksum,omitempty"` - SizeBytes int64 `json:"sizeBytes,omitempty"` - TargetKey string `json:"targetKey"` - ModKey string `json:"modKey"` - DLLRef string `json:"dllRef"` - SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"` - UE4SSABI string `json:"ue4ssAbi,omitempty"` - SupportedTargets []RuntimeTargetBody `json:"supportedTargets"` - UpdateOnStart bool `json:"updateOnStart"` - RCONPort int `json:"rconPort"` + Key string `json:"key"` + DisplayName string `json:"displayName"` + Kind string `json:"kind"` + Activation string `json:"activation"` + Version string `json:"version"` + ReleaseState string `json:"releaseState"` + ReleaseURL string `json:"releaseUrl,omitempty"` + ReleaseHost string `json:"releaseHost,omitempty"` + ReleaseFilename string `json:"releaseFilename,omitempty"` + Checksum string `json:"checksum,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + TargetKey string `json:"targetKey"` + ModKey string `json:"modKey"` + DLLRef string `json:"dllRef"` + TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"` + UE4SSABI string `json:"ue4ssAbi,omitempty"` + SupportedTargets []RuntimeTargetBody `json:"supportedTargets"` + UpdateOnStart bool `json:"updateOnStart"` + RCONPort int `json:"rconPort"` } // RuntimeDLLExtensionProfileResponseBody is the browser-safe projection of a // declared DLL extension. The immutable deployment path, RCON port, and full // release URL remain internal to the manifest/start-job contracts. type RuntimeDLLExtensionProfileResponseBody struct { - Key string `json:"key"` - DisplayName string `json:"displayName"` - Kind string `json:"kind"` - Activation string `json:"activation"` - Version string `json:"version"` - ReleaseState string `json:"releaseState"` - ReleaseHost string `json:"releaseHost,omitempty"` - ReleaseFilename string `json:"releaseFilename,omitempty"` - Checksum string `json:"checksum,omitempty"` - SizeBytes int64 `json:"sizeBytes,omitempty"` - SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"` - UE4SSABI string `json:"ue4ssAbi,omitempty"` - SupportedTargets []RuntimeTargetBody `json:"supportedTargets"` - UpdateOnStart bool `json:"updateOnStart"` + Key string `json:"key"` + DisplayName string `json:"displayName"` + Kind string `json:"kind"` + Activation string `json:"activation"` + Version string `json:"version"` + ReleaseState string `json:"releaseState"` + ReleaseHost string `json:"releaseHost,omitempty"` + ReleaseFilename string `json:"releaseFilename,omitempty"` + Checksum string `json:"checksum,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"` + UE4SSABI string `json:"ue4ssAbi,omitempty"` + SupportedTargets []RuntimeTargetBody `json:"supportedTargets"` + UpdateOnStart bool `json:"updateOnStart"` } type RuntimeDLLExtensionPlanBody struct { - Key string `json:"key"` - Version string `json:"version"` - ReleaseURL string `json:"releaseUrl"` - Checksum string `json:"checksum"` - SizeBytes int64 `json:"sizeBytes"` - TargetKey string `json:"targetKey"` - ModKey string `json:"modKey"` - DLLRef string `json:"dllRef"` - SCUMExecutableChecksum string `json:"scumExecutableChecksum"` - UE4SSABI string `json:"ue4ssAbi"` - RCONPort int `json:"rconPort"` + Key string `json:"key"` + Version string `json:"version"` + ReleaseURL string `json:"releaseUrl"` + Checksum string `json:"checksum"` + SizeBytes int64 `json:"sizeBytes"` + TargetKey string `json:"targetKey"` + ModKey string `json:"modKey"` + DLLRef string `json:"dllRef"` + TargetExecutableChecksum string `json:"targetExecutableChecksum"` + UE4SSABI string `json:"ue4ssAbi"` + RCONPort int `json:"rconPort"` } type GamePluginRuntimeProfilesBody struct { - Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"` - LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"` - DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"` - InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"` - ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"` - LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` - TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"` - DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"` - ClientManagers *[]json.RawMessage `json:"clientManagers,omitempty"` - DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"` + Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"` + LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"` + DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"` + InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"` + ServerDeployments json.RawMessage `json:"serverDeployments,omitempty"` + LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` + TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"` + DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"` + ClientManagers json.RawMessage `json:"clientManagers,omitempty"` + DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"` } -func (body GamePluginRuntimeProfilesBody) UnsupportedLegacyClientManagerViolations(prefix string) []string { +func (body GamePluginRuntimeProfilesBody) UnsupportedLegacyProfileViolations(prefix string) []string { var violations []string - if body.ClientManagers != nil { + if len(body.ClientManagers) > 0 { violations = append(violations, prefix+".clientManagers is no longer supported") } 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)) } } + if len(body.ServerDeployments) > 0 { + violations = append(violations, prefix+".serverDeployments is no longer supported") + } return violations } @@ -240,7 +228,6 @@ type GamePluginRuntimeProfilesResponseBody struct { LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"` DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"` InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"` - ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"` LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"` DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"` @@ -265,25 +252,6 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro } 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 { 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)}) } 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 { 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) } - 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 { 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 { 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 { 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 { items := make([]RuntimeDLLExtensionPlanBody, 0, len(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 } diff --git a/platform/dto/runtime_profiles_test.go b/platform/dto/runtime_profiles_test.go new file mode 100644 index 0000000..041ca41 --- /dev/null +++ b/platform/dto/runtime_profiles_test.go @@ -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) + } +} diff --git a/platform/protocol/dependency-update-contracts.md b/platform/protocol/dependency-update-contracts.md index 62abd9d..7391871 100644 --- a/platform/protocol/dependency-update-contracts.md +++ b/platform/protocol/dependency-update-contracts.md @@ -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. 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. -4. Terminal evidence is typed and redacted. Platform verifies probe key, plan digest, result checksum, and job attempt before updating `DependencyStatus`. +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 dependency status, not log parsing. Platform verifies probe key, plan digest, result checksum, and job attempt before updating `DependencyStatus`. ## Self-update flow diff --git a/platform/protocol/server-deployment.md b/platform/protocol/server-deployment.md index 0221b4f..e93547d 100644 --- a/platform/protocol/server-deployment.md +++ b/platform/protocol/server-deployment.md @@ -26,11 +26,9 @@ from command text. Empty `shell` means argv-oriented execution. Game plugins own concrete game policy: install/update commands, app ids, executable refs, default launch flags, stop-before-update behavior, and startup -argument construction. For SCUM, the plugin action assets own the SteamCMD flow: -stop `SCUMServer.exe` when updating, keep SteamCMD outside the server install -root, run -`steamcmd.exe +force_install_dir +login anonymous +app_update 3792580 +quit`, -and start `\\SCUM\\Binaries\\Win64\\SCUMServer.exe -port= -MaxPlayers= -log`. +argument construction. For SCUM, those values live in the SCUM plugin action +assets and scripts; Platform and Run only pass the bounded deployment context to +the declared lifecycle action and execute it through generic action handling. Generated Run distributions carry validated plugin lifecycle assets into the server-scoped workspace. Run materializes those assets at startup and executes diff --git a/platform/service/dependency_updates.go b/platform/service/dependency_updates.go index c62dbcd..d134d06 100644 --- a/platform/service/dependency_updates.go +++ b/platform/service/dependency_updates.go @@ -208,13 +208,6 @@ func (svc *CoreService) ReadRunUpdateChunk(request domain.RunUpdateChunkRequest) if err != nil { 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 { return domain.RunUpdateChunk{}, validationError("offset must be inside update artifact") } @@ -224,7 +217,11 @@ func (svc *CoreService) ReadRunUpdateChunk(request domain.RunUpdateChunkRequest) length = int(remaining) } 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) { diff --git a/platform/service/dependency_updates_test.go b/platform/service/dependency_updates_test.go index c9d9ea9..6a54321 100644 --- a/platform/service/dependency_updates_test.go +++ b/platform/service/dependency_updates_test.go @@ -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)) { 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 { t.Fatal("expected stale update chunk attempt rejection") } diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index 397ee5f..ccd741d 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -339,7 +339,7 @@ func autonomousRunLogSourceKind(kind string) bool { } 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 { diff --git a/platform/service/distributions.go b/platform/service/distributions.go index 2c9d7dc..59566de 100644 --- a/platform/service/distributions.go +++ b/platform/service/distributions.go @@ -782,7 +782,7 @@ func runReleasePlatformURL() string { if value := strings.TrimSpace(os.Getenv("PLATFORM_RUN_RELEASE_URL")); value != "" { return value } - return "https://scum.npc0.com/" + return "http://127.0.0.1:8080/" } func distributionID(prefix string, parts ...interface{}) string { diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 57877af..003efd8 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -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) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) diff --git a/platform/service/server_files.go b/platform/service/server_files.go index 664d45d..0ad3a80 100644 --- a/platform/service/server_files.go +++ b/platform/service/server_files.go @@ -392,13 +392,6 @@ func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRe if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable { 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 { 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 { length = int(remaining) } - end := int(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} + payload, err := svc.artifactStore.ReadPayloadRange(artifact.ID, request.Offset, length) + 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 } diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index 260362c..f84fd76 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -461,7 +461,7 @@ func lifecycleDLLExtensionPlans(profiles domain.GamePluginRuntimeProfiles, profi if !runtimeDLLExtensionSupportsTarget(extension, endpoint.Platform, endpoint.Architecture) { 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 } diff --git a/platform/service/server_lifecycle_test.go b/platform/service/server_lifecycle_test.go index 7a8d6a6..cee0759 100644 --- a/platform/service/server_lifecycle_test.go +++ b/platform/service/server_lifecycle_test.go @@ -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", 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", - 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 { t.Fatalf("attach ready DLL extension: %v", err) diff --git a/platform/service/source_rcon_test.go b/platform/service/source_rcon_test.go index 6fd0660..1379c0f 100644 --- a/platform/service/source_rcon_test.go +++ b/platform/service/source_rcon_test.go @@ -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", 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", - 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, }}, }, }) diff --git a/platform/validator/runtime_dll_extensions_test.go b/platform/validator/runtime_dll_extensions_test.go index 146db80..d4eed13 100644 --- a/platform/validator/runtime_dll_extensions_test.go +++ b/platform/validator/runtime_dll_extensions_test.go @@ -52,7 +52,7 @@ func TestValidateGamePluginRuntimeProfilesRejectsUnsafeOrUnpublishedDLLExtension func TestValidateJobRejectsDLLPlanOutsideProcessStart(t *testing.T) { profiles := validRuntimeDLLExtensionProfiles() 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") { 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", 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", - 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, }}, } } diff --git a/platform/validator/runtime_profiles.go b/platform/validator/runtime_profiles.go index 17e720e..09a5e31 100644 --- a/platform/validator/runtime_profiles.go +++ b/platform/validator/runtime_profiles.go @@ -29,7 +29,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles discoveryKeys := map[string]struct{}{} dependencyKeys := map[string]struct{}{} installPlanKeys := map[string]struct{}{} - serverDeploymentKeys := map[string]struct{}{} logSourceKeys := map[string]struct{}{} for i, probe := range profiles.Discovery { @@ -95,7 +94,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles } for j, step := range plan.Steps { 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, validateProfileKey(stepPrefix+".targetKey", step.TargetKey)...) @@ -133,100 +132,14 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles if step.DownloadRef == "" || step.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": - 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, 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 { prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i) violations = append(violations, validateProfileKey(prefix+".key", source.Key)...) @@ -329,23 +242,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles 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 { var violations []string if extension.Kind != "ue4ss-dll" || extension.Activation != "server-start" { @@ -381,8 +277,8 @@ func validateRuntimeDLLExtensionProfile(prefix string, extension domain.RuntimeD if extension.ReleaseURL == "" { violations = append(violations, prefix+".releaseUrl is required for a ready release") } - if !validSHA256Checksum(extension.Checksum) || !validSHA256Checksum(extension.SCUMExecutableChecksum) { - violations = append(violations, prefix+".checksum and scumExecutableChecksum must be SHA-256") + if !validSHA256Checksum(extension.Checksum) || !validSHA256Checksum(extension.TargetExecutableChecksum) { + violations = append(violations, prefix+".checksum and targetExecutableChecksum must be SHA-256") } if extension.SizeBytes < 1 || extension.SizeBytes > 128*1024*1024 { 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 { return validateRuntimeDLLExtensionProfile(prefix, domain.RuntimeDLLExtensionProfile{ - Key: plan.Key, - Kind: "ue4ss-dll", - Activation: "server-start", - Version: plan.Version, - ReleaseState: "ready", - ReleaseURL: plan.ReleaseURL, - Checksum: plan.Checksum, - SizeBytes: plan.SizeBytes, - TargetKey: plan.TargetKey, - ModKey: plan.ModKey, - DLLRef: plan.DLLRef, - SCUMExecutableChecksum: plan.SCUMExecutableChecksum, - UE4SSABI: plan.UE4SSABI, - SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, - UpdateOnStart: true, - RCONPort: plan.RCONPort, + Key: plan.Key, + Kind: "ue4ss-dll", + Activation: "server-start", + Version: plan.Version, + ReleaseState: "ready", + ReleaseURL: plan.ReleaseURL, + Checksum: plan.Checksum, + SizeBytes: plan.SizeBytes, + TargetKey: plan.TargetKey, + ModKey: plan.ModKey, + DLLRef: plan.DLLRef, + TargetExecutableChecksum: plan.TargetExecutableChecksum, + UE4SSABI: plan.UE4SSABI, + SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, + UpdateOnStart: true, + RCONPort: plan.RCONPort, }) } diff --git a/platform/validator/runtime_profiles_test.go b/platform/validator/runtime_profiles_test.go new file mode 100644 index 0000000..ac6cf47 --- /dev/null +++ b/platform/validator/runtime_profiles_test.go @@ -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) + } +} diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index cd50906..69e62ac 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -265,20 +265,6 @@ export interface RuntimeInstallPlanResponse { 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 { key: string; kind: string; @@ -311,7 +297,7 @@ export interface RuntimeDLLExtensionProfileResponse { releaseFilename?: string; checksum?: string; sizeBytes?: number; - scumExecutableChecksum?: string; + targetExecutableChecksum?: string; ue4ssAbi?: string; supportedTargets: Array<{ os: string; arch: string }>; updateOnStart: boolean; @@ -322,7 +308,6 @@ export interface GamePluginRuntimeProfilesResponse { lifecycleProfiles?: RuntimeLifecycleProfileResponse[]; dependencyProbes?: RuntimeDependencyProbeResponse[]; installPlans?: RuntimeInstallPlanResponse[]; - serverDeployments?: RuntimeServerDeploymentProfileResponse[]; logSources?: RuntimeLogSourceResponse[]; transportProfiles?: RuntimeTransportProfileResponse[]; dllExtensions?: RuntimeDLLExtensionProfileResponse[]; diff --git a/platform_web/components/RuntimeDLLExtensionsPanel.tsx b/platform_web/components/RuntimeDLLExtensionsPanel.tsx index a05a2d4..9f840fb 100644 --- a/platform_web/components/RuntimeDLLExtensionsPanel.tsx +++ b/platform_web/components/RuntimeDLLExtensionsPanel.tsx @@ -21,7 +21,7 @@ function RuntimeDLLExtensionsContent({ extensions }: { extensions: RuntimeDLLExt

UE4SS DLL 扩展

声明式、固定版本、启动前同步 -

Run 不直接执行 DLL;已安装的 UE4SS 会在 SCUM 正常启动时加载它。

+

Run 不直接执行 DLL;已安装的 UE4SS 会在目标服务端正常启动时加载它。

{extensions.map((extension) => (
@@ -43,8 +43,8 @@ function RuntimeDLLExtensionsContent({ extensions }: { extensions: RuntimeDLLExt
{extension.checksum || "待发布后固定"}
-
SCUM 兼容
-
{extension.scumExecutableChecksum || "待发布后固定"}
+
目标可执行文件兼容
+
{extension.targetExecutableChecksum || "待发布后固定"}
UE4SS ABI
@@ -53,7 +53,7 @@ function RuntimeDLLExtensionsContent({ extensions }: { extensions: RuntimeDLLExt
启动前自动校验和更新 - 运行:SCUM 服务器受监管启动 + 运行:目标服务端受监管启动 由 UE4SS 正常加载 {supportedTargets(extension)} Linux 启动前拒绝 diff --git a/platform_web/components/ServerDeploymentWorkflow.tsx b/platform_web/components/ServerDeploymentWorkflow.tsx index 21ddbfc..d2d6ef3 100644 --- a/platform_web/components/ServerDeploymentWorkflow.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.tsx @@ -27,7 +27,6 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep const [revealError, setRevealError] = useState(""); const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]); const pluginFields = selectedPlugin?.createFields ?? []; - const isScum = selectedPlugin?.id === "game.scum"; const workflowSteps = kind === "create" ? [{ label: "基本信息", icon: Compass }, { label: "部署方式", icon: ServerCog }, { 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 === modeStep) return Boolean(form.deploymentMode); 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 === "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())); @@ -111,14 +109,14 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
创建基础信息插件决定下一步显示哪些部署方式和游戏参数。
配置启动项新建安装、接管已有和自定义启动分别填写自己的字段。
平台构建 Run 包平台在自有构建器中打包,Run 启动后自动上报心跳。
} - {step === modeStep &&

选择这台服务器的创建方式;下一步只显示该方式需要的启动项。

{isScum &&
SCUM 自动部署链路Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。
}
+ {step === modeStep &&

选择这台服务器的创建方式;下一步只显示该方式需要的启动项。

插件生命周期链路Run 会按插件声明执行预检、安装或扫描、配置映射与健康验证;目录本身不代表安装完成。
setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} /> setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} /> setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
} {step === configurationStep &&
{kind === "edit" && onReveal &&
已读取受保护配置{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}{revealError && <>{revealError}}
}
{kind === "edit" && } - {form.deploymentMode === "guided-install" && } + {form.deploymentMode === "guided-install" && } {form.deploymentMode === "existing-server" && } {form.deploymentMode === "custom-command" && } {form.deploymentMode === "guided-install" && pluginFields.map((field) => ( @@ -129,11 +127,11 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep )} ))}
- {form.deploymentMode === "guided-install" && } - {form.deploymentMode === "existing-server" && } + {form.deploymentMode === "guided-install" && } + {form.deploymentMode === "existing-server" && } {form.deploymentMode === "custom-command" &&
高级启动设置

只有自定义启动器需要这些设置。执行目录留空时,Run 以服务器目录执行。

}
} - {step === reviewStep &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
{kind === "create" &&
服务器名称{form.name.trim() || "未填写"}
}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
{kind === "create" ? "本次保存创建向导配置" : "本次只保存部署设置"}{kind === "create" ? "Run 会自动识别并上报心跳;部署执行按这里的方式和启动项进行。" : form.deploymentMode === "existing-server" ? "Run 将自动识别并预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}
} + {step === reviewStep &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
{kind === "create" &&
服务器名称{form.name.trim() || "未填写"}
}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}
完成条件按插件声明完成预检、执行与验证
{kind === "create" ? "本次保存创建向导配置" : "本次只保存部署设置"}{kind === "create" ? "Run 会自动识别并上报心跳;部署执行按这里的方式和启动项进行。" : form.deploymentMode === "existing-server" ? "Run 将自动识别并预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}
}
{step < reviewStep ? : }
; @@ -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 ; } -function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) { - const steps = isScum ? [ - { icon: ScanSearch, title: "预检目录与端口", copy: "确认安装目录可用、Run 环境兼容且端口可用。" }, - { icon: Download, title: "下载 SCUM Server", copy: "通过 SteamCMD 安装 App 3792580 到该目录。" }, - { icon: SlidersHorizontal, title: "写入游戏配置", copy: "把本页的名称、端口与人数写入 ServerSettings.ini。" }, - { icon: HeartPulse, title: "启动并健康验证", copy: "检查可执行文件、版本、配置、端口和服务进程。" } - ] : [ +function GuidedInstallPlan({ pluginName }: { pluginName: string }) { + const steps = [ { icon: ScanSearch, title: "预检目录与 Run", copy: "确认安装目录、权限、端口与 Run 环境可用。" }, { icon: Download, title: "安装游戏服务端", copy: "按插件声明的推荐方案安装到该目录。" }, { icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给自动部署流程。" }, { icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" } ]; - return
确认后,{pluginName} 会这样安装“安装目录”就是游戏服务端、数据和配置将落地的位置;它不是命令执行目录,也不会在日志中回显。
{isScum ? "全部 4 步通过才算安装成功" : "Run 按插件契约执行"}
    {steps.map(({ icon: Icon, title, copy }, index) =>
  1. {index + 1}. {title}{copy}
  2. )}

不会做:{isScum ? "不会跳过验证就标记成功;失败时不会暴露你的目录、命令或凭据。" : "不会把受保护的路径、命令或凭据回显给浏览器。"}

; + return
确认后,{pluginName} 会这样安装“安装目录”就是游戏服务端、数据和配置将落地的位置;平台普通响应不展示受保护路径,插件日志按插件输出原样显示。
Run 按插件契约执行
    {steps.map(({ icon: Icon, title, copy }, index) =>
  1. {index + 1}. {title}{copy}
  2. )}

不会做:不会把受保护的路径、命令或凭据作为平台普通响应回显给浏览器。

; } -function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) { - const steps = isScum ? [ - { 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: "确认端口、进程与配置可读后,才标记为接管成功。" } - ] : [ +function ExistingServerAdoptionPlan({ pluginName }: { pluginName: string }) { + const steps = [ { icon: FolderCog, title: "定位服务端根目录", copy: "填写已有服务端文件、数据与配置所在的主目录。" }, { icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、插件识别和端口是否可用。" }, { icon: SlidersHorizontal, title: "只读扫描配置", copy: "读取插件需要的现有状态,不把新建默认值写进服务器。" }, @@ -172,5 +159,5 @@ function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string { icon: HeartPulse, title: "健康验证", copy: "验证通过后才标记为接管成功。" } ]; - return
确认后,{pluginName} 会这样接管目录只会交给 Run 在本机使用;平台、浏览器和日志都不会显示原始路径。
先扫描,后自动识别
    {steps.map(({ icon: Icon, title, copy }, index) =>
  1. {index + 1}. {title}{copy}
  2. )}
{isScum ?

SCUM 与 SteamCMD:接管只需要服务端根目录,不需要填写 SteamCMD 目录。Run 可能按本机策略检查 SteamCMD 是否可用,但它不是接管输入。
升级:接管不会升级游戏;当前平台尚未提供 SCUM 服务端的自动升级任务,不能承诺自动升级。升级能力需要单独的 SteamCMD 更新任务与备份/健康验证流程。

:

不会做:不会重新安装、覆盖已有游戏配置,或把受保护路径回显给浏览器。

}
; + return
确认后,{pluginName} 会这样接管目录只会交给 Run 在本机使用;平台普通响应不展示原始路径,插件日志按插件输出原样显示。
先扫描,后自动识别
    {steps.map(({ icon: Icon, title, copy }, index) =>
  1. {index + 1}. {title}{copy}
  2. )}

不会做:不会重新安装、覆盖已有游戏配置,或把受保护路径作为平台普通响应回显给浏览器。

; } diff --git a/platform_web/contracts/plugin-page-bridge.md b/platform_web/contracts/plugin-page-bridge.md index e598cf4..844786e 100644 --- a/platform_web/contracts/plugin-page-bridge.md +++ b/platform_web/contracts/plugin-page-bridge.md @@ -24,7 +24,7 @@ Plugin page runs with safe platform context. - `plugin-lifecycle.request`: declared plugin lifecycle request through Platform. - `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 diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index 945c829..af816a7 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -67,6 +67,8 @@ describe("first-party console pages", () => { expect(runPlatformOptions(plugin, "darwin")).toEqual(["windows", "linux"]); 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", () => { @@ -184,23 +186,25 @@ describe("first-party console pages", () => { expect(serversPageSource).not.toContain('instance.state === "running" || instance.state === "installing"'); expect(serverDetailPageSource).not.toContain(" { expect(html).toContain("停用"); expect(html).toContain("SCUM Simple RCON UE4SS DLL"); expect(html).toContain("启动前自动校验和更新"); - expect(html).toContain("运行:SCUM 服务器受监管启动"); + expect(html).toContain("运行:目标服务端受监管启动"); expect(html).toContain("Linux 启动前拒绝"); expect(html).toContain('role="dialog"'); expect(html).not.toContain("billing"); diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index b85fa51..72a53ba 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -138,6 +138,10 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain("tcp://"); expect(serverDetailPageSource).not.toContain("mysql://"); 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", () => { diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 28e89ed..582889a 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -379,35 +379,35 @@ function ServerMetadataSection({ instance, session, operations, onChanged }: Ser } interface ServerDeploymentSectionProps { - instance: ServerInstanceResponse; - deployment: LoadState; + instance: ServerInstanceResponse; + deployment: LoadState; } function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSectionProps) { - if (deployment.status === "loading") return ; - if (deployment.status === "error") return ; - const view = deployment.data; - const projection = view.projection; - const isScumTemplate = (instance.pluginId === "game.scum" && (view.mode === "guided-install" || view.mode === "existing-server")) || projection?.templateKey?.startsWith("scum-"); - return
-

部署定义

{view.mode || "未配置"} · 修订 {view.revision}
-

服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。

-
服务器目录{view.serverRootConfigured ? "已配置" : "未配置"}
高级执行目录{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}
启动设置{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}
{view.latestDispatch &&
最近 Run 调度{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}
}{view.latestDispatch?.runConfirmed &&
Run 执行确认已按 r{view.latestDispatch.deploymentRevision} 确认执行
}
- {isScumTemplate &&
SCUM 部署模板{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}
预检 / 扫描{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}
配置映射 / 健康验证{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}
{projection?.failureCode &&
失败原因{projection.failureCode}
}
} -
; + if (deployment.status === "loading") return ; + if (deployment.status === "error") return ; + const view = deployment.data; + const projection = view.projection; + const hasProjection = Boolean(projection?.templateKey || projection?.templateVersion || projection?.preflightState || projection?.discoveryState || projection?.mappingState || projection?.verificationState || projection?.failureCode); + return
+

部署定义

{view.mode || "未配置"} · 修订 {view.revision}
+

服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,平台普通响应不会回显。

+
服务器目录{view.serverRootConfigured ? "已配置" : "未配置"}
高级执行目录{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}
启动设置{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}
{view.latestDispatch &&
最近 Run 调度{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}
}{view.latestDispatch?.runConfirmed &&
Run 执行确认已按 r{view.latestDispatch.deploymentRevision} 确认执行
}
+ {hasProjection &&
部署验证模板{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : projection?.templateKey ?? "等待 Run 预检"}
预检 / 扫描{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}
配置映射 / 健康验证{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}
{projection?.failureCode &&
失败原因{projection.failureCode}
}
} +
; } function deploymentProjectionLabel(value?: string): string { - switch (value) { - case "queued": return "排队中"; - case "running": return "执行中"; - case "passed": return "已通过"; - case "applied": return "已写入"; - case "unchanged": return "未变化"; - case "failed": return "失败"; - case "skipped": return "已跳过"; - default: return "待返回"; - } + switch (value) { + case "queued": return "排队中"; + case "running": return "执行中"; + case "passed": return "已通过"; + case "applied": return "已写入"; + case "unchanged": return "未变化"; + case "failed": return "失败"; + case "skipped": return "已跳过"; + default: return "待返回"; + } } function deploymentProgressLabel(progress: JobResponse["progress"]): string { diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index f62e149..3249e0d 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -3,7 +3,16 @@ import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, us import { createPortal } from "react-dom"; 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 { RuntimeTaskProgressDialog, type RuntimeTaskDialogAction, @@ -21,8 +30,8 @@ import type { PageComponentProps } from "../contracts/page"; import { canDeleteServer, defaultServerCreateForm, - pluginCreateInputDefaults, - runtimeObservationFreshness, + pluginCreateInputDefaults, + runtimeObservationFreshness, type ServerCreateFormState } from "../contracts/serverManagement"; import { summarizeServerOperations } from "../contracts/operationsConsole"; @@ -223,11 +232,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr } } - 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([]); - setRunTargetSelection({ instance, targetOs: defaults.runOs, targetArch: "amd64" }); + setRunTargetSelection({ instance, targetOs, targetArch: "amd64" }); } async function handleRunTargetSubmit(event: FormEvent) { @@ -292,7 +301,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr return; } } - const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId); const intent = quickRuntimeActionLabel(action); const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName }); setRuntimeTaskActions([]); @@ -300,40 +308,40 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr await requireQuickRuntimeActionAvailable(instance.id, action); let message: string; message = await runtimeTask.runTask({ - title: intent, - description: quickRuntimeTaskDescription(instance, action), - stages: quickRuntimeStages(action), - executeStageIndex: quickRuntimeExecuteStageIndex(action), - execute: async () => { - if (action === "download-run") { + title: intent, + description: quickRuntimeTaskDescription(instance, action), + stages: quickRuntimeStages(action), + executeStageIndex: quickRuntimeExecuteStageIndex(action), + execute: async () => { + if (action === "download-run") { const reference = await platformApiClient.downloadLatestRunDistribution(instance.id); await downloadArtifactReference(reference, (artifactId) => platformApiClient.downloadArtifactContent(artifactId)); 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 update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum)); return `run 更新任务已排队,job ${update.jobId ?? update.id}`; - } - if (action === "reset-run-key") { + } + if (action === "reset-run-key") { const key = await platformApiClient.resetRunKey(instance.id); return `run 密钥已重置到第 ${key.generation} 代,旧 run 会话已失效,请重新生成并部署 run`; - } - if (action === "dependencies-check") { - const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey)); - return `依赖检查任务已排队,job ${job.id}`; - } - if (action === "dependencies-install") { + } + if (action === "dependencies-check") { const catalog = await platformApiClient.getDependencyCatalog(instance.id); - const plan = catalog.plans.find((candidate) => candidate.key === defaults.installPlanKey); - const probe = catalog.probes.find((candidate) => candidate.key === defaults.probeKey); - if (!plan || probe?.installPlanKey !== plan.key) throw new Error("Platform 未返回与当前 probe 匹配的审核安装计划"); + const probe = firstDependencyProbe(catalog); + const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probe.key)); + 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)); return `依赖安装任务已排队,job ${job.id}`; - } - throw new Error("该运行操作已下线"); } - }); + throw new Error("该运行操作已下线"); + } + }); operations.succeed(operationId, message); runtimeTask.succeedTask(message); await refresh(); @@ -905,15 +913,6 @@ function quickRuntimeTaskDescription(instance: ServerInstanceResponse, action: S 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[] { const options = new Set(); const add = (value: string | undefined) => { @@ -932,6 +931,21 @@ export function runPlatformOptions(plugin: GamePluginResponse | undefined, fallb 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 { switch (platform) { case "linux": diff --git a/platform_web/utils/artifactTransfer.ts b/platform_web/utils/artifactTransfer.ts index 3c0ba79..79d912c 100644 --- a/platform_web/utils/artifactTransfer.ts +++ b/platform_web/utils/artifactTransfer.ts @@ -99,8 +99,3 @@ function copyStreamChunk(value: Uint8Array): ArrayBuffer { copy.set(value); 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]"); -} diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 39d97e2..305eedc 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -1243,21 +1243,6 @@ } ], "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", "title": "Install SteamCMD", @@ -1416,7 +1401,7 @@ "targetKey": "ue4ss/scum-simple-rcon", "modKey": "scum_simple_rcon", "dllRef": "ue4ss/Mods/scum_simple_rcon/dlls/main.dll", - "scumExecutableChecksum": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "targetExecutableChecksum": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "ue4ssAbi": "ue4ss-3.0", "supportedTargets": [ { diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 77e508e..490b240 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -109,12 +109,6 @@ "items": { "$ref": "#/$defs/runtimeInstallPlan" }, "uniqueItems": true }, - "serverDeployments": { - "type": "array", - "items": { "$ref": "#/$defs/runtimeServerDeploymentProfile" }, - "uniqueItems": true, - "maxItems": 8 - }, "logSources": { "type": "array", "items": { "$ref": "#/$defs/runtimeLogSource" }, @@ -557,9 +551,9 @@ "required": ["type", "targetKey"], "additionalProperties": false, "properties": { - "type": { "enum": ["package", "verified-download", "steamcmd-app", "manual"] }, + "type": { "enum": ["package", "verified-download", "manual"] }, "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 }, "version": { "type": "string", "maxLength": 80 }, "downloadRef": { "type": "string", "pattern": "^https://[a-zA-Z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+$", "maxLength": 240 }, @@ -576,16 +570,6 @@ { "if": { "properties": { "type": { "const": "verified-download" } }, "required": ["type"] }, "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 } } }, - "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": { "type": "object", "required": ["key", "kind", "streamKey"], @@ -724,7 +646,7 @@ "targetKey": { "$ref": "#/$defs/logicalKey" }, "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 }, - "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}$" }, "supportedTargets": { "type": "array", "items": { "$ref": "#/$defs/runtimeTarget" }, "minItems": 1, "maxItems": 1, "uniqueItems": true }, "updateOnStart": { "const": true }, @@ -733,7 +655,7 @@ "allOf": [ { "if": { "properties": { "releaseState": { "const": "ready" } }, "required": ["releaseState"] }, - "then": { "required": ["releaseUrl", "checksum", "sizeBytes", "scumExecutableChecksum", "ue4ssAbi"] } + "then": { "required": ["releaseUrl", "checksum", "sizeBytes", "targetExecutableChecksum", "ue4ssAbi"] } } ] }, diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 69c3065..5e099d2 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -320,26 +320,9 @@ function validateDependencyPlans(manifest: unknown): string[] { } function validateServerDeploymentProfiles(manifest: unknown): string[] { - if (typeof manifest !== "object" || manifest === null || !("server" in manifest)) return []; - const record = manifest as { server?: { createFields?: Array<{ key?: string }> }; runtimeProfiles?: { serverDeployments?: Array } }; - const declaredFields = new Set((record.server?.createFields ?? []).map((field) => field.key).filter((key): key is string => Boolean(key))); - const errors: string[] = []; - for (const [index, profile] of (record.runtimeProfiles?.serverDeployments ?? []).entries()) { - const location = `manifest.runtimeProfiles.serverDeployments[${index}]`; - const mappingKeys = new Set(); - 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; + if (typeof manifest !== "object" || manifest === null || !("server" in manifest)) return []; + const record = manifest as { runtimeProfiles?: { serverDeployments?: unknown } }; + return record.runtimeProfiles?.serverDeployments === undefined ? [] : ["manifest.runtimeProfiles.serverDeployments: legacy server deployment profiles are no longer supported"]; } function validateUnsupportedLegacyClientManagerDeclarations(manifest: unknown): string[] { @@ -380,7 +363,7 @@ function validateDLLExtensionProfiles(manifest: unknown): string[] { targetKey?: string; modKey?: string; dllRef?: string; - scumExecutableChecksum?: string; + targetExecutableChecksum?: string; ue4ssAbi?: string; supportedTargets?: Array<{ os?: string; arch?: string }>; updateOnStart?: boolean; @@ -415,8 +398,8 @@ function validateDLLExtensionProfiles(manifest: unknown): string[] { errors.push(`${location}.releaseState: must be ready or unpublished`); } if (profile.releaseState === "ready") { - if (!checksumPattern.test(profile.checksum ?? "") || !checksumPattern.test(profile.scumExecutableChecksum ?? "")) { - errors.push(`${location}: release and SCUM executable SHA-256 checksums are required`); + if (!checksumPattern.test(profile.checksum ?? "") || !checksumPattern.test(profile.targetExecutableChecksum ?? "")) { + 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) { errors.push(`${location}.sizeBytes: must be a bounded DLL size`); diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index 8930dae..c1d2385 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -387,9 +387,9 @@ export interface RuntimeDependencyProbe { } export interface RuntimeInstallStep { - type: "package" | "verified-download" | "steamcmd-app" | "manual"; + type: "package" | "verified-download" | "manual"; 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; version?: string; downloadRef?: string; @@ -444,7 +444,7 @@ export interface RuntimeDLLExtensionProfile { targetKey: string; modKey: string; dllRef: string; - scumExecutableChecksum?: `sha256:${string}`; + targetExecutableChecksum?: `sha256:${string}`; ue4ssAbi?: string; supportedTargets: [{ os: "windows"; arch: "amd64" }]; updateOnStart: true; diff --git a/plugins/tests/fixtures/unsafe-runtime-profile-manifest.json b/plugins/tests/fixtures/unsafe-runtime-profile-manifest.json index 03d444a..0454878 100644 --- a/plugins/tests/fixtures/unsafe-runtime-profile-manifest.json +++ b/plugins/tests/fixtures/unsafe-runtime-profile-manifest.json @@ -20,6 +20,9 @@ {"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"}]} ], + "serverDeployments": [ + {"key": "legacy", "version": "1.0.0"} + ], "clientManagers": [ { "key": "unsafe-client", diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index ded57bd..61634a0 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -165,6 +165,7 @@ describe("plugin manifest validation", () => { const installScript = fs.readFileSync(path.join(pluginDir, installAction.executableKey), "utf8"); const startScript = fs.readFileSync(path.join(pluginDir, startAction.executableKey), "utf8"); 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(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); @@ -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("arbitrary shell"))).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); }); diff --git a/scripts/local-debug/env.sh b/scripts/local-debug/env.sh index a6d955f..26b1acd 100755 --- a/scripts/local-debug/env.sh +++ b/scripts/local-debug/env.sh @@ -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_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_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_REPO_DIR="$RUN_SOURCE_DIR"