Remove legacy runtime deployment paths

This commit is contained in:
npc0-hue
2026-09-04 12:36:21 +08:00
parent 14cbc63e61
commit 3cfb98ed47
39 changed files with 407 additions and 589 deletions
+2 -2
View File
@@ -37,5 +37,5 @@ PLATFORM_BUILDER_SOURCE_REPOSITORY=git@git.npc0.com:admin343/run.git
PLATFORM_BUILDER_SOURCE_REVISION=main
PLATFORM_BUILDER_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
+1 -1
View File
@@ -62,7 +62,7 @@ Runtime configuration:
- `PLATFORM_BUILDER_WORKSPACE_DIR`: private per-plugin/per-job build workspace, default `<PLATFORM_DATA_DIR>/distribution-builds`.
- `PLATFORM_BUILDER_CACHE_DIR`: persistent Go build/module cache, default `<PLATFORM_DATA_DIR>/distribution-build-cache`; it contains no job inputs or component keys.
- `PLATFORM_BUILDER_TIMEOUT_SECONDS`: positive build deadline, default `1800`.
- `PLATFORM_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:
+5 -1
View File
@@ -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
}
+19 -1
View File
@@ -129,6 +129,24 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
}
func TestCoreAPIGamePluginCreateRejectsLegacyServerDeployments(t *testing.T) {
router := newTestRouter()
request := validGamePluginRequest()
request.RuntimeProfiles.ServerDeployments = json.RawMessage(`[{"key":"legacy"}]`)
response := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins", request)
assertErrorResponse(t, response, http.StatusBadRequest, errorCodeValidation)
}
func TestCoreAPIGamePluginManifestRejectsLegacyServerDeployments(t *testing.T) {
router := newTestRouter()
registration := validGamePluginManifestRegistrationRequest()
registration.Manifest.RuntimeProfiles.ServerDeployments = json.RawMessage(`[{"key":"legacy"}]`)
response := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration)
assertErrorResponse(t, response, http.StatusBadRequest, errorCodeValidation)
}
func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
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)
+1 -1
View File
@@ -205,7 +205,7 @@ type RunAutonomousDLLExtension struct {
TargetKey string `json:"targetKey"`
ModKey string `json:"modKey"`
DLLRef string `json:"dllRef"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"`
TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"`
UE4SSABI string `json:"ue4ssAbi,omitempty"`
RCONPort int `json:"rconPort,omitempty"`
}
+3 -34
View File
@@ -474,7 +474,7 @@ type RuntimeDLLExtensionProfile struct {
TargetKey string
ModKey string
DLLRef string
SCUMExecutableChecksum string
TargetExecutableChecksum string
UE4SSABI string
SupportedTargets []RuntimeTarget
UpdateOnStart bool
@@ -490,13 +490,13 @@ type RuntimeDLLExtensionPlan struct {
TargetKey string
ModKey string
DLLRef string
SCUMExecutableChecksum 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)
+13 -64
View File
@@ -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"`
@@ -165,7 +150,7 @@ type RuntimeDLLExtensionProfileBody struct {
TargetKey string `json:"targetKey"`
ModKey string `json:"modKey"`
DLLRef string `json:"dllRef"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"`
TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"`
UE4SSABI string `json:"ue4ssAbi,omitempty"`
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
UpdateOnStart bool `json:"updateOnStart"`
@@ -186,7 +171,7 @@ type RuntimeDLLExtensionProfileResponseBody struct {
ReleaseFilename string `json:"releaseFilename,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"`
TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"`
UE4SSABI string `json:"ue4ssAbi,omitempty"`
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
UpdateOnStart bool `json:"updateOnStart"`
@@ -201,7 +186,7 @@ type RuntimeDLLExtensionPlanBody struct {
TargetKey string `json:"targetKey"`
ModKey string `json:"modKey"`
DLLRef string `json:"dllRef"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum"`
TargetExecutableChecksum string `json:"targetExecutableChecksum"`
UE4SSABI string `json:"ue4ssAbi"`
RCONPort int `json:"rconPort"`
}
@@ -211,17 +196,17 @@ type GamePluginRuntimeProfilesBody struct {
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,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"`
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
}
+37
View File
@@ -0,0 +1,37 @@
package dto
import (
"encoding/json"
"strings"
"testing"
)
func TestGamePluginRuntimeProfilesBodyRejectsLegacyServerDeployments(t *testing.T) {
body := GamePluginRuntimeProfilesBody{ServerDeployments: json.RawMessage(`[{"key":"legacy"}]`)}
violations := body.UnsupportedLegacyProfileViolations("runtimeProfiles")
if len(violations) != 1 || !strings.Contains(violations[0], "runtimeProfiles.serverDeployments is no longer supported") {
t.Fatalf("expected legacy serverDeployments violation, got %v", violations)
}
}
func TestGamePluginRuntimeProfilesBodyRejectsLegacyClientManagers(t *testing.T) {
body := GamePluginRuntimeProfilesBody{ClientManagers: json.RawMessage(`[{"key":"legacy"}]`), LifecycleProfiles: []RuntimeLifecycleProfileBody{{Key: "local", ClientManagerRef: json.RawMessage(`{"key":"legacy"}`)}}}
violations := body.UnsupportedLegacyProfileViolations("manifest.runtimeProfiles")
if len(violations) != 2 || !strings.Contains(strings.Join(violations, "; "), "clientManagers is no longer supported") || !strings.Contains(strings.Join(violations, "; "), "clientManagerRef is no longer supported") {
t.Fatalf("expected legacy client manager violations, got %v", violations)
}
}
func TestGamePluginRuntimeProfilesBodyRejectsLegacyClientManagersWhenNull(t *testing.T) {
var body GamePluginRuntimeProfilesBody
if err := json.Unmarshal([]byte(`{"clientManagers":null}`), &body); err != nil {
t.Fatalf("decode runtime profile body: %v", err)
}
violations := body.UnsupportedLegacyProfileViolations("runtimeProfiles")
if len(violations) != 1 || !strings.Contains(violations[0], "runtimeProfiles.clientManagers is no longer supported") {
t.Fatalf("expected legacy clientManagers null violation, got %v", violations)
}
}
@@ -6,8 +6,8 @@ Platform owns the reviewable dependency catalog, immutable plan digest, selected
1. `GET /api/v1/server-instances/{id}/dependencies` resolves the installed plugin version, complete runtime binding, online Run endpoint OS/architecture, target-matched probes/plans, and canonical SHA-256 digest.
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
+3 -5
View File
@@ -26,11 +26,9 @@ from command text. Empty `shell` means argv-oriented execution.
Game plugins own concrete game policy: install/update commands, app ids,
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 <serverRoot> +login anonymous +app_update 3792580 +quit`,
and start `<serverRoot>\\SCUM\\Binaries\\Win64\\SCUMServer.exe -port=<gamePort> -MaxPlayers=<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
+5 -8
View File
@@ -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) {
@@ -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")
}
@@ -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 {
+1 -1
View File
@@ -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 {
+39
View File
@@ -973,6 +973,45 @@ func TestPluginFileWorkspaceDoesNotConstrainServerFileDispatch(t *testing.T) {
}
}
func TestReadRunFileInputChunkReadsRequestedRange(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.FileWorkspace = scumTestFileWorkspace()
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin workspace: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-chunk", DisplayName: "File Chunk Owner", Email: "file-chunk@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-chunk", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Chunk Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
createCompleteRuntimeBinding(t, svc, instance, "local")
payload := []byte("0123456789abcdef")
upload, err := svc.UploadServerFileForSession(ownerSession, domain.ServerFileUploadRequest{ServerInstanceID: instance.ID, DirectoryKey: "scum-logs", Filename: "chunked.log", Payload: payload, Checksum: validator.BytesChecksum(payload), IdempotencyKey: "file-chunk-upload"})
if err != nil {
t.Fatalf("upload server file: %v", err)
}
hello := validRunControlHello()
hello.RunEndpointID = endpoint.ID
hello.CapabilityReport.Capabilities = []string{domain.JobCapabilityFilesWrite}
registered, err := svc.RegisterRunHello(hello)
if err != nil {
t.Fatalf("register run: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, Capabilities: []string{domain.JobCapabilityFilesWrite}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.JobID != upload.Job.ID {
t.Fatalf("claim file write: claim=%+v err=%v", claim, err)
}
chunk, err := svc.ReadRunFileInputChunk(domain.RunFileInputChunkRequest{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 4, Length: 6})
if err != nil || string(chunk.Payload) != string(payload[4:10]) || chunk.Offset != 4 || chunk.Complete {
t.Fatalf("read offset file input chunk: chunk=%+v err=%v", chunk, err)
}
finalChunk, err := svc.ReadRunFileInputChunk(domain.RunFileInputChunkRequest{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 14, Length: 8})
if err != nil || string(finalChunk.Payload) != string(payload[14:]) || !finalChunk.Complete {
t.Fatalf("read final file input chunk: chunk=%+v err=%v", finalChunk, err)
}
}
func TestServerFileListReportsFailedRuntimeRefresh(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
+6 -9
View File
@@ -392,13 +392,6 @@ func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRe
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable {
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
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -466,7 +466,7 @@ func attachReadyLifecycleDLLExtension(t *testing.T, svc *CoreService, plugin *do
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
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)
+1 -1
View File
@@ -280,7 +280,7 @@ func newSourceRCONFixtureWithRuntimeBinding(t *testing.T, createBinding bool) (*
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
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,
}},
},
})
@@ -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,
}},
}
}
+5 -109
View File
@@ -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")
@@ -420,7 +316,7 @@ func validateRuntimeDLLExtensionPlan(prefix string, plan domain.RuntimeDLLExtens
TargetKey: plan.TargetKey,
ModKey: plan.ModKey,
DLLRef: plan.DLLRef,
SCUMExecutableChecksum: plan.SCUMExecutableChecksum,
TargetExecutableChecksum: plan.TargetExecutableChecksum,
UE4SSABI: plan.UE4SSABI,
SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}},
UpdateOnStart: true,
@@ -0,0 +1,26 @@
package validator
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func TestValidateGamePluginRuntimeProfilesRejectsLegacySteamCMDAppStep(t *testing.T) {
profiles := domain.GamePluginRuntimeProfiles{InstallPlans: []domain.RuntimeInstallPlan{{Key: "install-game", Title: "Install game", Steps: []domain.RuntimeInstallStep{{Type: "steamcmd-app", TargetKey: "server/install-root", PackageManager: "steamcmd", PackageName: "123456"}}}}}
err := ValidateGamePluginRuntimeProfiles(profiles)
if err == nil || !strings.Contains(err.Error(), "type is invalid") {
t.Fatalf("expected legacy steamcmd-app step rejection, got %v", err)
}
}
func TestValidateGamePluginRuntimeProfilesRejectsManualStepExecutionFields(t *testing.T) {
profiles := domain.GamePluginRuntimeProfiles{InstallPlans: []domain.RuntimeInstallPlan{{Key: "manual-plan", Title: "Manual plan", Steps: []domain.RuntimeInstallStep{{Type: "manual", TargetKey: "operator/manual", PackageManager: "steamcmd"}}}}}
err := ValidateGamePluginRuntimeProfiles(profiles)
if err == nil || !strings.Contains(err.Error(), "manual step cannot contain machine execution fields") {
t.Fatalf("expected manual execution field rejection, got %v", err)
}
}
+1 -16
View File
@@ -265,20 +265,6 @@ export interface RuntimeInstallPlanResponse {
steps: Array<{ type: string; targetKey: string; packageManager?: string; packageName?: string; version?: string; downloadRef?: string; checksum?: string }>;
}
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[];
@@ -21,7 +21,7 @@ function RuntimeDLLExtensionsContent({ extensions }: { extensions: RuntimeDLLExt
<h2>UE4SS DLL </h2>
<span className="page-status"></span>
</div>
<p className="page-status">Run DLL UE4SS SCUM </p>
<p className="page-status">Run DLL UE4SS </p>
<div className="console-record-list">
{extensions.map((extension) => (
<article key={extension.key} className="console-record">
@@ -43,8 +43,8 @@ function RuntimeDLLExtensionsContent({ extensions }: { extensions: RuntimeDLLExt
<dd>{extension.checksum || "待发布后固定"}</dd>
</div>
<div>
<dt>SCUM </dt>
<dd>{extension.scumExecutableChecksum || "待发布后固定"}</dd>
<dt></dt>
<dd>{extension.targetExecutableChecksum || "待发布后固定"}</dd>
</div>
<div>
<dt>UE4SS ABI</dt>
@@ -53,7 +53,7 @@ function RuntimeDLLExtensionsContent({ extensions }: { extensions: RuntimeDLLExt
</dl>
<div className="tag-list" aria-label={`${extension.displayName} activation policy`}>
<span></span>
<span>SCUM </span>
<span></span>
<span> UE4SS </span>
<span>{supportedTargets(extension)}</span>
<span>Linux </span>
@@ -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
<div className="workflow-hint-grid"><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong> Run </strong><span>Run </span></div></div>
<div className="form-grid"><label><select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label></div>
</div>}
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy"></p>{isScum && <div className="form-guidance"><strong>SCUM </strong><span>Run </span></div>}<div className="deployment-mode-grid">
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy"></p><div className="form-guidance"><strong></strong><span>Run </span></div><div className="deployment-mode-grid">
<ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} />
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由 Run 策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
</div></div>}
{step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong></strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}></button></>}</div>}<div className="form-grid">
{kind === "edit" && <label><select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install"></option><option value="existing-server"></option><option value="custom-command"></option></select><small className="field-help">Run </small></label>}
{form.deploymentMode === "guided-install" && <label>{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">SCUM </small></label>}
{form.deploymentMode === "guided-install" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help"></small></label>}
{form.deploymentMode === "existing-server" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run </small></label>}
{form.deploymentMode === "custom-command" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help"></small></label>}
{form.deploymentMode === "guided-install" && pluginFields.map((field) => (
@@ -129,11 +127,11 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
)}</label>
))}
</div>
{form.deploymentMode === "guided-install" && <GuidedInstallPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />}
{form.deploymentMode === "existing-server" && <ExistingServerAdoptionPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />}
{form.deploymentMode === "guided-install" && <GuidedInstallPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} />}
{form.deploymentMode === "existing-server" && <ExistingServerAdoptionPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} />}
{form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help">Run </p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label><select name="shell" value={form.shell} onChange={updateForm}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>}
</div>}
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div>{kind === "create" && <div><span></span><strong>{form.name.trim() || "未填写"}</strong></div>}<div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{kind === "create" ? "本次保存创建向导配置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "Run 会自动识别并上报心跳;部署执行按这里的方式和启动项进行。" : form.deploymentMode === "existing-server" ? "Run 将自动识别并预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}</span></div></div>}
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div>{kind === "create" && <div><span></span><strong>{form.name.trim() || "未填写"}</strong></div>}<div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}<div><span></span><strong></strong></div></div><div className="form-guidance"><strong>{kind === "create" ? "本次保存创建向导配置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "Run 会自动识别并上报心跳;部署执行按这里的方式和启动项进行。" : form.deploymentMode === "existing-server" ? "Run 将自动识别并预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}</span></div></div>}
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? closeWorkflow() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div>
</form>
</ManagementDialog>;
@@ -141,30 +139,19 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
function ModeOption({ active, title, copy, onClick }: { active: boolean; title: string; copy: string; onClick: () => void }) { return <button type="button" className={cx("deployment-mode-option", active && "deployment-mode-option-active")} onClick={onClick}><strong>{title}</strong><span>{copy}</span></button>; }
function 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 <section className="guided-install-plan" aria-label="新建并安装执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span></span></div><small>{isScum ? "全部 4 步通过才算安装成功" : "Run 按插件契约执行"}</small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol><p><strong></strong>{isScum ? "不会跳过验证就标记成功;失败时不会暴露你的目录、命令或凭据。" : "不会把受保护的路径、命令或凭据回显给浏览器。"}</p></section>;
return <section className="guided-install-plan" aria-label="新建并安装执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span></span></div><small>Run </small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol><p><strong></strong></p></section>;
}
function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) {
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 <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span> Run 使</span></div><small></small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol>{isScum ? <p><strong>SCUM SteamCMD</strong> SteamCMD Run SteamCMD <br /><strong></strong> SCUM SteamCMD /</p> : <p><strong></strong></p>}</section>;
return <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>{pluginName} </strong><span> Run 使</span></div><small></small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol><p><strong></strong></p></section>;
}
+1 -1
View File
@@ -24,7 +24,7 @@ Plugin page runs with safe platform context.
- `plugin-lifecycle.request`: declared plugin lifecycle request through Platform.
- `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
+9 -5
View File
@@ -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", () => {
@@ -191,16 +193,18 @@ describe("first-party console pages", () => {
expect(serverDeploymentWorkflowSource).toContain("创建服务器");
expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)");
expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录");
expect(serverDeploymentWorkflowSource).toContain("安装目录{isScum ? \"(必填)\" : \"(可选)\"}");
expect(serverDeploymentWorkflowSource).toContain("安装目录(可选)");
expect(serverDeploymentWorkflowSource).toContain("新建并安装执行流程");
expect(serverDeploymentWorkflowSource).toContain("安装目录”就是游戏服务端、数据和配置将落地的位置");
expect(serverDeploymentWorkflowSource).toContain("通过 SteamCMD 安装 App 3792580 到该目录");
expect(serverDeploymentWorkflowSource).toContain("全部 4 步通过才算安装成功");
expect(serverDeploymentWorkflowSource).toContain("插件日志按插件输出原样显示");
expect(serverDeploymentWorkflowSource).toContain("按插件声明的推荐方案安装到该目录");
expect(serverDeploymentWorkflowSource).not.toContain("SteamCMD 安装 App 3792580");
expect(serverDeploymentWorkflowSource).not.toContain("selectedPlugin?.id === \"game.scum\"");
expect(serverDeploymentWorkflowSource).toContain("已有服务器目录");
expect(serverDeploymentWorkflowSource).toContain("不会重装或覆盖现有游戏配置");
expect(serverDeploymentWorkflowSource).toContain("接管已有服务器执行流程");
expect(serverDeploymentWorkflowSource).toContain("不需要填写 SteamCMD 目录");
expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的自动升级任务");
expect(serverDeploymentWorkflowSource).not.toContain("不需要填写 SteamCMD 目录");
expect(serverDeploymentWorkflowSource).not.toContain("当前平台尚未提供 SCUM 服务端的自动升级任务");
expect(serverDeploymentWorkflowSource).toContain("Run 会按心跳自动识别服务器");
expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })');
expect(serverDetailPageSource).not.toContain("运行配置绑定");
+1 -1
View File
@@ -74,7 +74,7 @@ describe("PluginsPage", () => {
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");
@@ -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", () => {
+3 -3
View File
@@ -388,12 +388,12 @@ function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSecti
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
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-");
const hasProjection = Boolean(projection?.templateKey || projection?.templateVersion || projection?.preflightState || projection?.discoveryState || projection?.mappingState || projection?.verificationState || projection?.failureCode);
return <article className="console-panel" aria-label="server deployment">
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> </h2><span className="page-status">{view.mode || "未配置"} · {view.revision}</span></div>
<p className="section-copy"></p>
<p className="section-copy"></p>
<div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span> Run </span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run </span><strong> r{view.latestDispatch.deploymentRevision} </strong></div>}</div>
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM </span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span></span><strong>{projection.failureCode}</strong></div>}</div>}
{hasProjection && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span></span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : projection?.templateKey ?? "等待 Run 预检"}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span></span><strong>{projection.failureCode}</strong></div>}</div>}
</article>;
}
+32 -18
View File
@@ -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,
@@ -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<HTMLFormElement>) {
@@ -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([]);
@@ -320,14 +328,14 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
return `run 密钥已重置到第 ${key.generation} 代,旧 run 会话已失效,请重新生成并部署 run`;
}
if (action === "dependencies-check") {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
const catalog = await platformApiClient.getDependencyCatalog(instance.id);
const probe = firstDependencyProbe(catalog);
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probe.key));
return `依赖检查任务已排队,job ${job.id}`;
}
if (action === "dependencies-install") {
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, plan } = firstInstallableDependency(catalog);
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest));
return `依赖安装任务已排队,job ${job.id}`;
}
@@ -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<string>();
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":
-5
View File
@@ -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]");
}
@@ -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": [
{
@@ -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"] }
}
]
},
+5 -22
View File
@@ -321,25 +321,8 @@ 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<any> } };
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<string>();
for (const [mappingIndex, mapping] of (profile.configMappings ?? []).entries()) {
const mappingLocation = `${location}.configMappings[${mappingIndex}]`;
if (!declaredFields.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: must reference a declared server.createFields key`);
if (mappingKeys.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: duplicate mapping`);
mappingKeys.add(mapping.fieldKey);
}
const requiredChecks = new Set(["executable.present", "port.bound", "config.readable", "process.healthy"]);
for (const check of profile.verificationChecks ?? []) {
if (check.required) requiredChecks.delete(check.kind);
}
for (const missing of requiredChecks) errors.push(`${location}.verificationChecks: required check ${missing} is missing`);
}
return errors;
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`);
+3 -3
View File
@@ -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;
@@ -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",
@@ -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);
});
+1 -1
View File
@@ -25,7 +25,7 @@ export PLATFORM_BOOTSTRAP_ADMIN_EMAIL="${PLATFORM_BOOTSTRAP_ADMIN_EMAIL:-operato
export PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="${PLATFORM_BOOTSTRAP_ADMIN_PASSWORD:-operator-local}"
export PLATFORM_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"