Move SCUM lifecycle ownership to plugin

This commit is contained in:
npc0-hue
2026-08-01 09:36:12 +08:00
parent bca4f935ba
commit d1249fca85
49 changed files with 795 additions and 566 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-31
@@ -0,0 +1,37 @@
## Design
The lifecycle boundary becomes:
- Platform validates the selected plugin, resolves the plugin lifecycle action reference, packages plugin action assets into generated run workspaces, and dispatches a generic lifecycle job with typed inputs.
- The SCUM plugin owns Windows scripts/action specs that check whether `SCUMServer.exe` exists, install or update through SteamCMD, stop before update, write plugin-declared config values, and start with plugin-declared `port`/`MaxPlayers` plus the plugin default `-log`.
- Run executes the referenced action through generic primitives. It never branches on `game.scum`, `SCUMServer.exe`, or app `3792580`.
### SCUM Plugin Action Model
The SCUM install action is the authoritative setup action. It runs before first start and may also be reused as a pre-start update action. The action checks for SteamCMD, downloads it if missing, stops the SCUM process when an update is needed, runs:
```powershell
./steamcmd.exe +force_install_dir C:/scumserver +login anonymous +app_update 3792580 validate +quit
```
Then the start action runs:
```powershell
C:/scumserver\SCUM\Binaries\Win64\SCUMServer.exe -port=<gamePort> -MaxPlayers=<maxPlayers> -log
```
The concrete path may come from server deployment inputs, but the SCUM executable relative path, app id, SteamCMD arguments, and `-log` default are plugin assets, not platform/run code.
### Platform Changes
Platform no longer freezes a `ServerDeploymentPlan` for SCUM or requires `deployment.scum.v1`. It keeps generic deployment definitions and dispatches plugin action refs. Deployment projections can still show queued/running/failed lifecycle state from generic receipts, but detailed SCUM evidence is plugin-generated output/log/artifact data, not a platform-owned SCUM evidence schema.
### Run Changes
Run removes the SCUM deployment executor and SCUM capability advertisement. Assignments carrying `serverDeploymentPlan` are rejected as legacy unsupported input. Generic lifecycle template execution remains, including managed process start/stop/status, file operations, logs, and artifacts.
## Risks / Trade-Offs
- Existing tests expecting SCUM evidence need to shift to plugin-action dispatch assertions.
- The first plugin script implementation must be careful about Windows quoting and idempotency.
- If future games need richer setup flows, add generic action primitives or structured lifecycle DSL features without moving game policy into run.
@@ -0,0 +1,28 @@
## Why
The current SCUM deployment flow hardcodes SteamCMD, SCUM executable paths, app id `3792580`, config writes, and start arguments inside platform/run code. That violates the intended boundary: the SCUM plugin should own game lifecycle policy while platform dispatches and run executes generic, bounded actions.
## What Changes
- **BREAKING**: Retire the SCUM-specific `deployment.scum.v1` run capability and the platform-to-run `serverDeploymentPlan` path for SCUM installs/adoptions.
- Move SCUM install/update/start command ownership into the SCUM plugin action bundle.
- Use plugin lifecycle actions for idempotent "install if missing, update if present, then start" behavior.
- Keep platform limited to manifest/action validation, distribution packaging, lifecycle job dispatch, and generic result projection.
- Keep run limited to generic action execution, scoped file/process operations, logs, artifacts, and capability enforcement.
## Capabilities
### New Capabilities
- `plugin-owned-game-lifecycle`: Game plugins own game-specific lifecycle commands, default launch flags, app IDs, executable paths, install/update policies, and pre-start checks.
### Modified Capabilities
- `platform-side-distribution-builds`: Generated run packages must carry plugin-owned lifecycle action assets without requiring run to advertise game-specific deployment capabilities.
## Impact
- Affects `plugins/examples/scum-server-plugin` action assets and manifest declarations.
- Affects `platform/service`, `platform/domain`, `platform/dto`, `platform/validator`, and protocol docs by removing SCUM-specific deployment plan dispatch/gating.
- Affects the independent `run` repository by removing SCUM-specific runtime execution and capability advertisement.
- Requires tests proving SCUM lifecycle jobs are plugin action jobs and run no longer contains a SCUM deployment executor.
@@ -0,0 +1,42 @@
# plugin-owned-game-lifecycle Specification
## Purpose
Ensure game-specific server lifecycle behavior lives in game plugins while platform and run remain generic.
## ADDED Requirements
### Requirement: Game plugins own game-specific lifecycle policy
The system SHALL keep game-specific install, update, pre-start, start argument, stop, status, executable path, app id, and default launch flag policy in plugin-owned manifests, action specs, templates, or scripts.
#### Scenario: SCUM lifecycle declares concrete commands in plugin assets
- **WHEN** SCUM requires SteamCMD installation/update or server start
- **THEN** the Steam app id `3792580`, `SCUMServer.exe` relative path, `+app_update 3792580 validate`, `-port`, `-MaxPlayers`, and `-log` are provided by SCUM plugin assets or plugin startup fields
- **AND** platform and run do not hardcode those values to make SCUM lifecycle work
### Requirement: Platform dispatches plugin lifecycle actions generically
The platform SHALL dispatch lifecycle jobs using plugin-declared action references and generic lifecycle/deployment inputs, and SHALL NOT require a game-specific run capability for SCUM deployment.
#### Scenario: SCUM install job is queued
- **WHEN** a SCUM server create/install lifecycle job is created
- **THEN** the job target key references the SCUM plugin install action
- **AND** the job execution input does not include a SCUM-specific server deployment plan
- **AND** required run capabilities contain only generic lifecycle/deployment capabilities
### Requirement: Run executes generic actions only
Run SHALL execute lifecycle action templates and scoped process/file operations generically, and SHALL NOT branch on a game id or contain per-game deployment executors.
#### Scenario: Legacy SCUM deployment plan reaches run
- **WHEN** a run assignment includes a legacy `serverDeploymentPlan`
- **THEN** run rejects it as unsupported legacy input instead of executing game-specific deployment logic
#### Scenario: Run capability report
- **WHEN** run reports supported capabilities
- **THEN** the report does not include `deployment.scum.v1`
@@ -0,0 +1,25 @@
## 1. OpenSpec Artifacts
- [x] 1.1 Create proposal, design, and spec delta for plugin-owned SCUM lifecycle ownership.
- [x] 1.2 Validate the OpenSpec change strictly before completion.
## 2. Plugin Lifecycle Assets
- [x] 2.1 Add SCUM-owned Windows install/update and start scripts/action specs with SteamCMD app update and startup arguments owned by the plugin.
- [x] 2.2 Update plugin manifest/tests so SCUM lifecycle no longer depends on `deployment.scum.v1`.
## 3. Platform Dispatch Boundary
- [x] 3.1 Remove SCUM-specific deployment plan creation, capability gating, and evidence validation from platform lifecycle dispatch.
- [x] 3.2 Update platform tests/protocol docs to assert generic plugin action dispatch.
## 4. Run Executor Boundary
- [x] 4.1 Remove the SCUM-specific run executor/capability path and reject legacy `serverDeploymentPlan` inputs generically.
- [x] 4.2 Update run tests to prove generated run capabilities exclude `deployment.scum.v1` and generic plugin actions still execute.
## 5. Verification And Delivery
- [x] 5.1 Run focused plugin, platform, and run tests.
- [x] 5.2 Run `scripts/check-structure.sh` and final repository status checks.
- [ ] 5.3 Commit and push main-repo and run-repo changes separately.
+1
View File
@@ -127,6 +127,7 @@ type DistributionBuildInput struct {
SecretRef string SecretRef string
KeyGeneration int KeyGeneration int
AuthKey string AuthKey string
WorkspaceSeed string
} }
type DependencyExecutionInputRequest struct { type DependencyExecutionInputRequest struct {
+23 -3
View File
@@ -582,8 +582,8 @@ type RuntimeServerPrerequisite struct {
Kind string Kind string
} }
// RuntimeServerDeploymentProfile is a frozen, game-specific deployment // RuntimeServerDeploymentProfile is a legacy game-specific deployment template
// template. It contains logical references only; Run resolves them locally. // declaration kept for backward-compatible manifest decoding.
type RuntimeServerDeploymentProfile struct { type RuntimeServerDeploymentProfile struct {
Key string Key string
Version string Version string
@@ -624,6 +624,7 @@ type GamePluginManifest struct {
Capabilities []string Capabilities []string
Permissions []string Permissions []string
Actions PluginLifecycleActions Actions PluginLifecycleActions
AssetFiles []PluginAssetFile
Pages []GamePluginPage Pages []GamePluginPage
FileWorkspace PluginFileWorkspace FileWorkspace PluginFileWorkspace
AI GamePluginManifestAI AI GamePluginManifestAI
@@ -637,6 +638,13 @@ type GamePluginManifest struct {
type GamePluginManifestRegistration struct { type GamePluginManifestRegistration struct {
ManifestRef string ManifestRef string
Manifest GamePluginManifest Manifest GamePluginManifest
AssetFiles []PluginAssetFile
}
type PluginAssetFile struct {
Path string
Content string
Mode int
} }
type GamePlugin struct { type GamePlugin struct {
@@ -654,6 +662,7 @@ type GamePlugin struct {
DeclaredPermissions []string DeclaredPermissions []string
Permissions PluginPermissions Permissions PluginPermissions
LifecycleActions PluginLifecycleActions LifecycleActions PluginLifecycleActions
LifecycleAssets []PluginAssetFile
BridgeActions []string BridgeActions []string
Pages []GamePluginPage Pages []GamePluginPage
FileWorkspace PluginFileWorkspace FileWorkspace PluginFileWorkspace
@@ -1057,7 +1066,6 @@ const (
// JobCapabilityDeploymentPlan gates Run implementations that understand // JobCapabilityDeploymentPlan gates Run implementations that understand
// protected deployment definitions, absolute paths, and custom commands. // protected deployment definitions, absolute paths, and custom commands.
JobCapabilityDeploymentPlan = "deployment.plan.v1" JobCapabilityDeploymentPlan = "deployment.plan.v1"
JobCapabilitySCUMDeploymentPlan = "deployment.scum.v1"
JobCapabilityDeploymentShellPosix = "deployment.shell.posix-sh" JobCapabilityDeploymentShellPosix = "deployment.shell.posix-sh"
JobCapabilityDeploymentShellPowerShell = "deployment.shell.powershell" JobCapabilityDeploymentShellPowerShell = "deployment.shell.powershell"
JobCapabilityDeploymentShellCmd = "deployment.shell.cmd" JobCapabilityDeploymentShellCmd = "deployment.shell.cmd"
@@ -1648,6 +1656,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions) plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions)
plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS) plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS)
plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields) plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields)
plugin.LifecycleAssets = CopyPluginAssetFiles(plugin.LifecycleAssets)
plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions) plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions)
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages) plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
plugin.FileWorkspace = CopyPluginFileWorkspace(plugin.FileWorkspace) plugin.FileWorkspace = CopyPluginFileWorkspace(plugin.FileWorkspace)
@@ -1699,9 +1708,19 @@ func CopyPluginMarketplacePluginSlice(plugins []PluginMarketplacePlugin) []Plugi
func CopyGamePluginManifestRegistration(registration GamePluginManifestRegistration) GamePluginManifestRegistration { func CopyGamePluginManifestRegistration(registration GamePluginManifestRegistration) GamePluginManifestRegistration {
registration.Manifest = CopyGamePluginManifest(registration.Manifest) registration.Manifest = CopyGamePluginManifest(registration.Manifest)
registration.AssetFiles = CopyPluginAssetFiles(registration.AssetFiles)
return registration return registration
} }
func CopyPluginAssetFiles(files []PluginAssetFile) []PluginAssetFile {
if files == nil {
return nil
}
out := make([]PluginAssetFile, len(files))
copy(out, files)
return out
}
func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest { func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
manifest.Tags = CopyStringSlice(manifest.Tags) manifest.Tags = CopyStringSlice(manifest.Tags)
manifest.Server.SupportedOS = CopyStringSlice(manifest.Server.SupportedOS) manifest.Server.SupportedOS = CopyStringSlice(manifest.Server.SupportedOS)
@@ -1709,6 +1728,7 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
manifest.Bridge.Actions = CopyStringSlice(manifest.Bridge.Actions) manifest.Bridge.Actions = CopyStringSlice(manifest.Bridge.Actions)
manifest.Capabilities = CopyStringSlice(manifest.Capabilities) manifest.Capabilities = CopyStringSlice(manifest.Capabilities)
manifest.Permissions = CopyStringSlice(manifest.Permissions) manifest.Permissions = CopyStringSlice(manifest.Permissions)
manifest.AssetFiles = CopyPluginAssetFiles(manifest.AssetFiles)
manifest.Pages = CopyGamePluginPageSlice(manifest.Pages) manifest.Pages = CopyGamePluginPageSlice(manifest.Pages)
manifest.FileWorkspace = CopyPluginFileWorkspace(manifest.FileWorkspace) manifest.FileWorkspace = CopyPluginFileWorkspace(manifest.FileWorkspace)
manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes) manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes)
+7 -6
View File
@@ -111,12 +111,13 @@ Run sessions persist only a token hash, generation, status, expiry, capability f
Installed `GamePlugin` records persist the validated manifest `runtimeProfiles` contract, including discovery, lifecycle, dependency/install, log, transport, and client-manager declarations. One server binding selects one declared lifecycle profile. Platform derives allowed and required logical keys; clients cannot assert `missingKeys` or `status`. Installed `GamePlugin` records persist the validated manifest `runtimeProfiles` contract, including discovery, lifecycle, dependency/install, log, transport, and client-manager declarations. One server binding selects one declared lifecycle profile. Platform derives allowed and required logical keys; clients cannot assert `missingKeys` or `status`.
SCUM plugins may additionally declare `runtimeProfiles.serverDeployments`. These Plugin lifecycle assets are registered as manifest-declared files plus a
profiles are versioned, freeze into a leased Run assignment, and contain only content-bearing registration payload. Platform packages those assets into
logical executable/config markers, field mappings, discovery markers, and generated Run workspaces so plugin action refs such as `actions/install.json`
verification checks. A server's `DeploymentProjection` stores bounded evidence and script refs such as `bin/scum-start.cmd` are available before the first
from Run for operator views; protected paths, commands, credentials, process install/start job. Game-specific install/update/start policy, including SCUM
ids, and sockets never enter that projection. SteamCMD app IDs and launch flags, stays in the plugin asset bundle rather than
in platform services or Run executors.
Bindings are used for action gating and future run-side profile resolution. File and MySQL metadata snapshots include them so a platform restart does not make a configured server appear complete or lose its selected profile. API responses expose only logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never expose stored binding values, raw host paths, direct sockets, FTP/RCON passwords, SQL DSNs, component auth keys, or internal secret locations. Bindings are used for action gating and future run-side profile resolution. File and MySQL metadata snapshots include them so a platform restart does not make a configured server appear complete or lose its selected profile. API responses expose only logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never expose stored binding values, raw host paths, direct sockets, FTP/RCON passwords, SQL DSNs, component auth keys, or internal secret locations.
+2
View File
@@ -224,6 +224,7 @@ type DistributionBuildInputResponse struct {
SecretRef string `json:"secretRef"` SecretRef string `json:"secretRef"`
KeyGeneration int `json:"keyGeneration"` KeyGeneration int `json:"keyGeneration"`
AuthKey string `json:"authKey"` AuthKey string `json:"authKey"`
WorkspaceSeed string `json:"workspaceSeed,omitempty"`
} }
type DependencyExecutionInputRequest struct { type DependencyExecutionInputRequest struct {
@@ -568,6 +569,7 @@ func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) Distr
SecretRef: input.SecretRef, SecretRef: input.SecretRef,
KeyGeneration: input.KeyGeneration, KeyGeneration: input.KeyGeneration,
AuthKey: input.AuthKey, AuthKey: input.AuthKey,
WorkspaceSeed: input.WorkspaceSeed,
} }
} }
+21
View File
@@ -371,6 +371,7 @@ type GamePluginManifestBody struct {
Capabilities []string `json:"capabilities"` Capabilities []string `json:"capabilities"`
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
Actions PluginLifecycleActionsBody `json:"actions"` Actions PluginLifecycleActionsBody `json:"actions"`
AssetFiles []PluginAssetFileBody `json:"assetFiles,omitempty"`
Pages []GamePluginPageBody `json:"pages,omitempty"` Pages []GamePluginPageBody `json:"pages,omitempty"`
FileWorkspace PluginFileWorkspaceBody `json:"fileWorkspace,omitempty"` FileWorkspace PluginFileWorkspaceBody `json:"fileWorkspace,omitempty"`
AI GamePluginManifestAIBody `json:"ai,omitempty"` AI GamePluginManifestAIBody `json:"ai,omitempty"`
@@ -384,6 +385,13 @@ type GamePluginManifestBody struct {
type GamePluginManifestRegistrationRequest struct { type GamePluginManifestRegistrationRequest struct {
ManifestRef string `json:"manifestRef"` ManifestRef string `json:"manifestRef"`
Manifest GamePluginManifestBody `json:"manifest"` Manifest GamePluginManifestBody `json:"manifest"`
AssetFiles []PluginAssetFileBody `json:"assetFiles,omitempty"`
}
type PluginAssetFileBody struct {
Path string `json:"path"`
Content string `json:"content,omitempty"`
Mode int `json:"mode,omitempty"`
} }
type GamePluginCreateRequest struct { type GamePluginCreateRequest struct {
@@ -996,6 +1004,7 @@ func (request AIProviderUpdateRequest) ToDomain(id string, status domain.AIProvi
func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePluginManifestRegistration { func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePluginManifestRegistration {
return domain.GamePluginManifestRegistration{ return domain.GamePluginManifestRegistration{
ManifestRef: request.ManifestRef, ManifestRef: request.ManifestRef,
AssetFiles: pluginAssetFilesToDomain(request.AssetFiles),
Manifest: domain.GamePluginManifest{ Manifest: domain.GamePluginManifest{
ID: request.Manifest.ID, ID: request.Manifest.ID,
Name: request.Manifest.Name, Name: request.Manifest.Name,
@@ -1008,6 +1017,7 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
Capabilities: domain.CopyStringSlice(request.Manifest.Capabilities), Capabilities: domain.CopyStringSlice(request.Manifest.Capabilities),
Permissions: domain.CopyStringSlice(request.Manifest.Permissions), Permissions: domain.CopyStringSlice(request.Manifest.Permissions),
Actions: request.Manifest.Actions.ToDomain(), Actions: request.Manifest.Actions.ToDomain(),
AssetFiles: pluginAssetFilesToDomain(request.Manifest.AssetFiles),
Pages: pagesToDomain(request.Manifest.Pages), Pages: pagesToDomain(request.Manifest.Pages),
FileWorkspace: fileWorkspaceToDomain(request.Manifest.FileWorkspace), FileWorkspace: fileWorkspaceToDomain(request.Manifest.FileWorkspace),
AI: request.Manifest.AI.ToDomain(), AI: request.Manifest.AI.ToDomain(),
@@ -1020,6 +1030,17 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
} }
} }
func pluginAssetFilesToDomain(files []PluginAssetFileBody) []domain.PluginAssetFile {
if files == nil {
return nil
}
out := make([]domain.PluginAssetFile, len(files))
for i, file := range files {
out[i] = domain.PluginAssetFile{Path: file.Path, Content: file.Content, Mode: file.Mode}
}
return out
}
func mapTrajectoryDeclarationToDomain(value *GameMapTrajectoryDeclarationBody) *domain.GameMapTrajectoryDeclaration { func mapTrajectoryDeclarationToDomain(value *GameMapTrajectoryDeclarationBody) *domain.GameMapTrajectoryDeclaration {
if value == nil { if value == nil {
return nil return nil
+9 -1
View File
@@ -77,20 +77,28 @@ func TestGamePluginManifestRegistrationToDomainCopiesSlices(t *testing.T) {
CreateFormSchema: "schemas/create-form.schema.json", CreateFormSchema: "schemas/create-form.schema.json",
}, },
Actions: PluginLifecycleActionsBody{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"}, Actions: PluginLifecycleActionsBody{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"},
AssetFiles: []PluginAssetFileBody{
{Path: "actions/install.json", Mode: 0o600},
},
Pages: []GamePluginPageBody{ Pages: []GamePluginPageBody{
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}}, {Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
}, },
AI: GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}}, AI: GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}},
}, },
AssetFiles: []PluginAssetFileBody{
{Path: "actions/install.json", Content: "{}", Mode: 0o600},
},
} }
domainRegistration := request.ToDomain() domainRegistration := request.ToDomain()
domainRegistration.Manifest.Tags[0] = "mutated" domainRegistration.Manifest.Tags[0] = "mutated"
domainRegistration.Manifest.Server.SupportedOS[0] = "darwin" domainRegistration.Manifest.Server.SupportedOS[0] = "darwin"
domainRegistration.Manifest.AssetFiles[0].Path = "actions/mutated.json"
domainRegistration.AssetFiles[0].Content = "mutated"
domainRegistration.Manifest.Pages[0].Permissions[0] = "ai.invoke" domainRegistration.Manifest.Pages[0].Permissions[0] = "ai.invoke"
domainRegistration.Manifest.AI.Purposes[0] = "config.suggest" domainRegistration.Manifest.AI.Purposes[0] = "config.suggest"
if request.Manifest.Tags[0] != "example" || request.Manifest.Server.SupportedOS[0] != "linux" || request.Manifest.Pages[0].Permissions[0] != "server.logs.read" || request.Manifest.AI.Purposes[0] != "logs.diagnose" { if request.Manifest.Tags[0] != "example" || request.Manifest.Server.SupportedOS[0] != "linux" || request.Manifest.AssetFiles[0].Path != "actions/install.json" || request.AssetFiles[0].Content != "{}" || request.Manifest.Pages[0].Permissions[0] != "server.logs.read" || request.Manifest.AI.Purposes[0] != "logs.diagnose" {
t.Fatalf("expected manifest request slices to be copied, got %+v", request) t.Fatalf("expected manifest request slices to be copied, got %+v", request)
} }
} }
+4
View File
@@ -175,6 +175,8 @@ type GamePlugin struct {
Permissions PluginPermissions `json:"permissions" db:"permissions"` Permissions PluginPermissions `json:"permissions" db:"permissions"`
// LifecycleActions stores manifest lifecycle action references. // LifecycleActions stores manifest lifecycle action references.
LifecycleActions PluginLifecycleActions `json:"lifecycleActions" db:"lifecycle_actions"` LifecycleActions PluginLifecycleActions `json:"lifecycleActions" db:"lifecycle_actions"`
// LifecycleAssets stores plugin-owned lifecycle files packaged into generated Run workspaces.
LifecycleAssets []domain.PluginAssetFile `json:"lifecycleAssets,omitempty" db:"lifecycle_assets"`
// Pages stores plugin-local page metadata. // Pages stores plugin-local page metadata.
Pages []GamePluginPage `json:"pages" db:"pages"` Pages []GamePluginPage `json:"pages" db:"pages"`
// Tags stores bounded catalog tags. // Tags stores bounded catalog tags.
@@ -597,6 +599,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePlugin {
DeclaredPermissions: plugin.DeclaredPermissions, DeclaredPermissions: plugin.DeclaredPermissions,
Permissions: permissionsFromDomain(plugin.Permissions), Permissions: permissionsFromDomain(plugin.Permissions),
LifecycleActions: lifecycleActionsFromDomain(plugin.LifecycleActions), LifecycleActions: lifecycleActionsFromDomain(plugin.LifecycleActions),
LifecycleAssets: domain.CopyPluginAssetFiles(plugin.LifecycleAssets),
Pages: pagesFromDomain(plugin.Pages), Pages: pagesFromDomain(plugin.Pages),
Tags: plugin.Tags, Tags: plugin.Tags,
AIPurposes: plugin.AIPurposes, AIPurposes: plugin.AIPurposes,
@@ -623,6 +626,7 @@ func (plugin GamePlugin) ToDomain() domain.GamePlugin {
DeclaredPermissions: domain.CopyStringSlice(plugin.DeclaredPermissions), DeclaredPermissions: domain.CopyStringSlice(plugin.DeclaredPermissions),
Permissions: plugin.Permissions.ToDomain(), Permissions: plugin.Permissions.ToDomain(),
LifecycleActions: plugin.LifecycleActions.ToDomain(), LifecycleActions: plugin.LifecycleActions.ToDomain(),
LifecycleAssets: domain.CopyPluginAssetFiles(plugin.LifecycleAssets),
Pages: pagesToDomain(plugin.Pages), Pages: pagesToDomain(plugin.Pages),
Tags: domain.CopyStringSlice(plugin.Tags), Tags: domain.CopyStringSlice(plugin.Tags),
AIPurposes: domain.CopyStringSlice(plugin.AIPurposes), AIPurposes: domain.CopyStringSlice(plugin.AIPurposes),
+43 -53
View File
@@ -1,74 +1,64 @@
# Server deployment plan v1 # Server deployment lifecycle
`deployment.plan.v1` is the capability gate for Run implementations that can `deployment.plan.v1` gates only generic custom-command deployment execution:
execute a protected server deployment plan. Platform only sends the plan in a Platform sends a protected `RunJobAssignmentResponse.executionInput.deployment`
leased `RunJobAssignmentResponse.executionInput.deployment`; it never appears when a server uses `custom-command` mode and Run executes the operator-reviewed
in public server, job, audit, log, or plugin-bridge responses. argv-oriented command inside its local policy.
SCUM controlled deployments additionally require `deployment.scum.v1`. The Guided or existing-server game setup is plugin-owned. Platform dispatches the
leased assignment includes `executionInput.serverDeploymentPlan` with plugin-declared lifecycle action reference, such as `actions/install.json`, and
`schemaVersion: "1"`, an operation of `install` or `adopt`, the frozen template may include generic deployment context (`mode`, `profileKey`, `serverRoot`, and
key/version, Steam app id, logical executable/config references, explicit field `createInputs`) so the plugin action can resolve its own behavior. Platform does
mappings, discovery markers, and required verification checks. The plan is not create a game-specific `serverDeploymentPlan`, and SCUM no longer requires a
secret-free and contains logical keys only; the protected deployment body still `deployment.scum.v1` capability.
holds the operator's paths/commands and is resolved only by Run.
## Capability and policy ## Capability and policy
Run advertises `deployment.plan.v1` along with its normal lifecycle Run advertises normal lifecycle capabilities (`process.install`,
capabilities. A Run that supports shell commands additionally advertises its `process.start`, `process.stop`, `process.status`) and generic primitives such as
local policy for `posix-sh`, `powershell`, or `cmd` out of band with its scoped files, logs, artifacts, dependency helpers, and custom deployment plan
operator configuration. Platform must not infer shell support from command execution. A Run that supports shell commands advertises its local shell policy
text. Empty `shell` means argv-oriented execution. out of band with operator configuration. Platform must not infer shell support
from command text. Empty `shell` means argv-oriented execution.
## Plugin-owned game lifecycle
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, run
`steamcmd.exe +force_install_dir <serverRoot> +login anonymous +app_update 3792580 validate +quit`,
and start `<serverRoot>\\SCUM\\Binaries\\Win64\\SCUMServer.exe -port=<gamePort> -MaxPlayers=<maxPlayers> -log`.
Generated Run distributions carry validated plugin lifecycle assets into the
server-scoped workspace. Run materializes those assets at startup and executes
them through generic action template handling; it does not branch on game ids,
Steam app ids, SCUM executable paths, or launch flags.
## Required local preflight ## Required local preflight
Before a write, install, or process action, Run validates the selected plan: Before a custom-command action, Run validates the selected protected deployment
body:
- absolute server root and working directory are allowed anywhere permitted by - absolute server root and working directory are allowed only where permitted by
the local Run policy; they are not required to be adjacent to the Run binary; local Run policy;
- the effective directory, executable, permissions, timeout, plugin version,
and requested ports are locally valid;
- selected shell kind and custom-command policy are enabled; - selected shell kind and custom-command policy are enabled;
- no raw command, path, secret, socket address, or credential is emitted in a - no raw command, path, secret, socket address, or credential is emitted in a
result, diagnostic, log batch, or artifact name. result, diagnostic, log batch, or artifact name.
For an SCUM `install`, Run performs SteamCMD app installation followed by For plugin-owned actions, Run validates scoped action template paths, executable
configuration materialization and health checks. For `adopt`, Run performs a asset refs, bounded environment variables, and lifecycle action/capability
scan first and must not reinstall or overwrite existing configuration. A matches. Deployment create inputs may be exposed to the action as bounded
controlled SCUM install or adoption requires an explicit protected server root; environment variables such as `SERVER_CREATE_GAMEPORT`; protected command text is
the root is never exposed in browser projections. Adoption does not require not exposed unless the server explicitly uses custom-command mode.
new-install create inputs because its mapping phase is read-only unless a
separate approved write is dispatched. A
terminal SCUM result must include bounded `serverDeploymentEvidence` with
preflight, discovery, mapping, and verification states. Required mapping
results are `applied` or `unchanged`; required verification results are
`passed` (adoption may report mapping as `skipped`). Failed results include a stable `failureCode` and never include the
resolved path or command text. Successful result kinds are
`scum.install.completed` and `scum.adopt.completed`; failed result kinds are
`scum.install.failed` and `scum.adopt.failed`.
The first-party Windows SCUM template declares `steamcmd`, Visual C++ 2012,
2013, and 2015-2022 (x86 and x64), plus DirectX runtime prerequisites. Run
checks their Windows markers before installation and uses only its fixed
Microsoft installer catalog with silent arguments when one is absent. The
template builds SteamCMD as separate arguments: `+force_install_dir`, the
protected root, anonymous login, App `3792580`, `validate`, and `+quit`. The
original root and constructed command are eligible only for opt-in local Run
diagnostics; they never enter progress or result payloads.
An `existing-server` plan may omit installation. A `custom-command` plan
requires a start command. Guided templates remain plugin recommendations;
Run owns their local resolution and execution.
## Safe progress reports ## Safe progress reports
Run reports bounded progress with `percent`, `phase`, and a safe message. The Run reports bounded progress with `percent`, `phase`, and a safe message. The
allowed phase vocabulary is `queued`, `claimed`, `preflight`, `scan`, `install`, allowed phase vocabulary is `queued`, `claimed`, `preflight`, `scan`, `install`,
`configure`, `mapping`, `start`, and `health`. On failure it reports a stable safe error `configure`, `mapping`, `start`, and `health`. On failure it reports a stable
code and summary such as `working-directory-unavailable`, never the supplied safe error code and summary, never the supplied path or command text.
path or command text.
Platform treats preflight as authoritative. It does not open a direct shell, Platform treats Run preflight as authoritative. It does not open a direct shell,
SSH connection, raw socket, or host filesystem to compensate for a failed SSH connection, raw socket, or host filesystem to compensate for a failed
preflight. preflight.
+5 -7
View File
@@ -351,7 +351,6 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStop,
"process.restart", "process.restart",
domain.LifecycleCapabilityStatus, domain.LifecycleCapabilityStatus,
domain.JobCapabilitySCUMDeploymentPlan,
"files.list", "files.list",
domain.JobCapabilityFilesRead, domain.JobCapabilityFilesRead,
"files.patch", "files.patch",
@@ -374,7 +373,6 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
Platforms: []string{"windows"}, Platforms: []string{"windows"},
}} }}
requireManualSCUMRuntimeBindings(&plugin, "run-local") requireManualSCUMRuntimeBindings(&plugin, "run-local")
plugin.RuntimeProfiles.ServerDeployments[0].SupportedTargets = []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}
if err := svc.store.GamePlugins().Create(plugin); err != nil { if err := svc.store.GamePlugins().Create(plugin); err != nil {
t.Fatalf("create SCUM plugin fixture: %v", err) t.Fatalf("create SCUM plugin fixture: %v", err)
} }
@@ -418,7 +416,7 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
hello.KeyGeneration = key.Generation hello.KeyGeneration = key.Generation
hello.Platform = "windows" hello.Platform = "windows"
hello.Architecture = "amd64" hello.Architecture = "amd64"
hello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus, domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan} hello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}
registered, err := svc.RegisterRunHello(hello) registered, err := svc.RegisterRunHello(hello)
if err != nil || !registered.Accepted { if err != nil || !registered.Accepted {
t.Fatalf("register generated SCUM Run: result=%+v err=%v", registered, err) t.Fatalf("register generated SCUM Run: result=%+v err=%v", registered, err)
@@ -433,15 +431,15 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
t.Fatalf("expected one SCUM install job, jobs=%+v err=%v", jobs, err) t.Fatalf("expected one SCUM install job, jobs=%+v err=%v", jobs, err)
} }
job := jobs[0] job := jobs[0]
if job.Capability != domain.LifecycleCapabilityInstall || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan == nil { if job.Capability != domain.LifecycleCapabilityInstall || job.TargetKey != "actions/install.json" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil {
t.Fatalf("expected SCUM install job with protected deployment plan, job=%+v", job) t.Fatalf("expected SCUM install job with plugin action and generic deployment inputs, job=%+v", job)
} }
if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" { if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
t.Fatalf("SCUM install job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs) t.Fatalf("SCUM install job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs)
} }
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}}) claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.ExecutionInput.ServerDeploymentPlan == nil { if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
t.Fatalf("generated SCUM Run should claim install job with frozen plan, claim=%+v err=%v", claim, err) t.Fatalf("generated SCUM Run should claim plugin-owned install action, claim=%+v err=%v", claim, err)
} }
} }
@@ -1,6 +1,8 @@
package service package service
import ( import (
"encoding/base64"
"encoding/json"
"errors" "errors"
"strings" "strings"
@@ -122,12 +124,17 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
if err != nil { if err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
} }
profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(distribution)
if err != nil {
return domain.DistributionBuildInput{}, err
}
return domain.DistributionBuildInput{ return domain.DistributionBuildInput{
JobID: job.ID, JobID: job.ID,
ComponentKind: domain.DistributionComponentRun, ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: distribution.ServerInstanceID, ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID, PluginID: distribution.PluginID,
RunEndpointID: distribution.RunEndpointID, RunEndpointID: distribution.RunEndpointID,
ProfileKey: profileKey,
TargetOS: distribution.TargetOS, TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch, TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID, TargetRelease: distribution.ID,
@@ -138,6 +145,7 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
SecretRef: distribution.SecretRef, SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration, KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey, AuthKey: plainKey,
WorkspaceSeed: workspaceSeed,
}, nil }, nil
} }
@@ -183,6 +191,48 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
return domain.DistributionBuildInput{}, repo.ErrNotFound return domain.DistributionBuildInput{}, repo.ErrNotFound
} }
func (svc *CoreService) runDistributionWorkspaceSeed(distribution domain.RunDistribution) (string, string, error) {
instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID)
if err != nil {
return "", "", err
}
plugin, err := svc.store.GamePlugins().Get(distribution.PluginID)
if err != nil {
return "", "", err
}
seed, err := encodePluginWorkspaceSeed(plugin.LifecycleAssets)
if err != nil {
return "", "", err
}
profileKey := instance.Deployment.ProfileKey
if binding, bindingErr := svc.runtimeBindingForServer(distribution.ServerInstanceID); bindingErr == nil {
profileKey = binding.ProfileKey
} else if !errors.Is(bindingErr, repo.ErrNotFound) {
return "", "", bindingErr
}
return profileKey, seed, nil
}
func encodePluginWorkspaceSeed(files []domain.PluginAssetFile) (string, error) {
if len(files) == 0 {
return "", nil
}
type seedFile struct {
Path string `json:"path"`
Content string `json:"content"`
Mode int `json:"mode,omitempty"`
}
seed := make([]seedFile, len(files))
for i, file := range files {
seed[i] = seedFile{Path: file.Path, Content: file.Content, Mode: file.Mode}
}
body, err := json.Marshal(seed)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(body), nil
}
// storeDistributionBuildArtifact records the built package under job ownership // storeDistributionBuildArtifact records the built package under job ownership
// so the existing ArtifactOwnerKindJob scope assertions keep guarding it. // so the existing ArtifactOwnerKindJob scope assertions keep guarding it.
func (svc *CoreService) storeDistributionBuildArtifact(artifactID string, jobID string, payload []byte) error { func (svc *CoreService) storeDistributionBuildArtifact(artifactID string, jobID string, payload []byte) error {
@@ -1,6 +1,8 @@
package service package service
import ( import (
"encoding/base64"
"encoding/json"
"errors" "errors"
"strings" "strings"
"sync" "sync"
@@ -31,6 +33,17 @@ func (builder captureDistributionBuilder) Build(input domain.DistributionBuildIn
func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin fixture: %v", err)
}
plugin.LifecycleAssets = []domain.PluginAssetFile{
{Path: "actions/install.json", Content: `{"version":1,"action":"install","mode":"oneshot"}`, Mode: 0o600},
{Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700},
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("seed plugin lifecycle assets: %v", err)
}
inputs := make(chan domain.DistributionBuildInput, 1) inputs := make(chan domain.DistributionBuildInput, 1)
release := make(chan struct{}) release := make(chan struct{})
var releaseOnce sync.Once var releaseOnce sync.Once
@@ -55,6 +68,17 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
if platformInput.AuthKey == "" || platformInput.JobID != distribution.BuildJobID || platformInput.RunEndpointID != instance.RunEndpointID { if platformInput.AuthKey == "" || platformInput.JobID != distribution.BuildJobID || platformInput.RunEndpointID != instance.RunEndpointID {
t.Fatalf("platform builder received incomplete internal input: %+v", platformInput) t.Fatalf("platform builder received incomplete internal input: %+v", platformInput)
} }
decodedSeed, err := base64.StdEncoding.DecodeString(platformInput.WorkspaceSeed)
if err != nil {
t.Fatalf("decode workspace seed: %v", err)
}
var seedFiles []domain.PluginAssetFile
if err := json.Unmarshal(decodedSeed, &seedFiles); err != nil {
t.Fatalf("unmarshal workspace seed: %v", err)
}
if platformInput.ProfileKey != "local" || len(seedFiles) != 2 || seedFiles[1].Path != "bin/install-server" || seedFiles[1].Content == "" {
t.Fatalf("platform builder received incomplete plugin workspace seed: profile=%q seed=%+v", platformInput.ProfileKey, seedFiles)
}
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{ auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun, ComponentKind: domain.DistributionComponentRun,
@@ -51,12 +51,17 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
if err != nil { if err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
} }
profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(distribution)
if err != nil {
return domain.DistributionBuildInput{}, err
}
return domain.DistributionBuildInput{ return domain.DistributionBuildInput{
JobID: job.ID, JobID: job.ID,
ComponentKind: domain.DistributionComponentRun, ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: distribution.ServerInstanceID, ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID, PluginID: distribution.PluginID,
RunEndpointID: distribution.RunEndpointID, RunEndpointID: distribution.RunEndpointID,
ProfileKey: profileKey,
TargetOS: distribution.TargetOS, TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch, TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID, TargetRelease: distribution.ID,
@@ -67,6 +72,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
SecretRef: distribution.SecretRef, SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration, KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey, AuthKey: plainKey,
WorkspaceSeed: workspaceSeed,
}, nil }, nil
} }
+22 -1
View File
@@ -6,6 +6,7 @@ import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"context" "context"
"encoding/base64"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@@ -158,6 +159,17 @@ func (builder *DockerDistributionBuilder) Build(input domain.DistributionBuildIn
if err := os.WriteFile(filepath.Join(inputDir, "auth-key"), []byte(input.AuthKey), 0o600); err != nil { if err := os.WriteFile(filepath.Join(inputDir, "auth-key"), []byte(input.AuthKey), 0o600); err != nil {
return nil, err return nil, err
} }
seedPayload := []byte("[]")
if strings.TrimSpace(input.WorkspaceSeed) != "" {
decoded, err := base64.StdEncoding.DecodeString(input.WorkspaceSeed)
if err != nil {
return nil, validationError("distribution build input has an invalid workspace seed")
}
seedPayload = decoded
}
if err := os.WriteFile(filepath.Join(inputDir, "workspace-seed.json"), seedPayload, 0o600); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil { if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil {
return nil, err return nil, err
} }
@@ -197,7 +209,16 @@ set -eu
auth_key="$(cat /workspace/input/auth-key)" auth_key="$(cat /workspace/input/auth-key)"
if [ "$COMPONENT_KIND" = "run" ]; then if [ "$COMPONENT_KIND" = "run" ]; then
cd /workspace/source rm -rf /workspace/build/run-source
mkdir -p /workspace/build/run-source
cp -R /workspace/source/. /workspace/build/run-source/
seed_b64="$(base64 /workspace/input/workspace-seed.json | tr -d '\n')"
cat > /workspace/build/run-source/config/workspace_seed_generated.go <<EOF
package config
func init() { BuildWorkspaceSeed = "$seed_b64" }
EOF
cd /workspace/build/run-source
ldflags="-s -w" ldflags="-s -w"
ldflags="$ldflags -X browser.local/run/config.BuildMode=worker" ldflags="$ldflags -X browser.local/run/config.BuildMode=worker"
ldflags="$ldflags -X browser.local/run/config.BuildPlatformURL=$PLATFORM_URL" ldflags="$ldflags -X browser.local/run/config.BuildPlatformURL=$PLATFORM_URL"
-1
View File
@@ -132,7 +132,6 @@ func TestCoreServiceBuildsSCUMGuidedRunWithoutCompleteRuntimeBinding(t *testing.
plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"} plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}, ActionRefs: domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}, Platforms: []string{"windows"}}} plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}, ActionRefs: domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}, Platforms: []string{"windows"}}}
requireManualSCUMRuntimeBindings(&plugin, "run-local") requireManualSCUMRuntimeBindings(&plugin, "run-local")
plugin.RuntimeProfiles.ServerDeployments[0].SupportedTargets = []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}
if err := svc.store.GamePlugins().Create(plugin); err != nil { if err := svc.store.GamePlugins().Create(plugin); err != nil {
t.Fatalf("create SCUM plugin: %v", err) t.Fatalf("create SCUM plugin: %v", err)
} }
-3
View File
@@ -294,9 +294,6 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
return validationError("deployment execution receipt does not match leased definition") return validationError("deployment execution receipt does not match leased definition")
} }
} }
if job.ExecutionInput.ServerDeploymentPlan != nil {
return validateSCUMDeploymentEvidence(job.ExecutionInput.ServerDeploymentPlan, result)
}
if result.ExecutionResult.Kind == "" { if result.ExecutionResult.Kind == "" {
return nil return nil
} }
+23
View File
@@ -0,0 +1,23 @@
package service
import "browser.local/platform/domain"
func applyPluginCreateDefaults(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition) domain.ServerDeploymentDefinition {
definition = domain.CopyServerDeploymentDefinition(definition)
if definition.Mode != domain.ServerDeploymentModeGuided {
return definition
}
if definition.CreateInputs == nil {
definition.CreateInputs = map[string]string{}
}
for _, field := range plugin.CreateFields {
if _, present := definition.CreateInputs[field.Key]; !present && field.DefaultValue != "" {
definition.CreateInputs[field.Key] = field.DefaultValue
}
}
return definition
}
func deploymentNeedsCompleteRuntimeBinding(_ domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool {
return definition.Mode == ""
}
+20
View File
@@ -748,6 +748,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
DeclaredPermissions: manifest.Permissions, DeclaredPermissions: manifest.Permissions,
Permissions: pluginPermissionsFromManifest(manifest.Permissions), Permissions: pluginPermissionsFromManifest(manifest.Permissions),
LifecycleActions: manifest.Actions, LifecycleActions: manifest.Actions,
LifecycleAssets: lifecycleAssetsFromManifestRegistration(registration),
BridgeActions: manifest.Bridge.Actions, BridgeActions: manifest.Bridge.Actions,
Pages: manifest.Pages, Pages: manifest.Pages,
FileWorkspace: manifest.FileWorkspace, FileWorkspace: manifest.FileWorkspace,
@@ -762,6 +763,25 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
} }
} }
func lifecycleAssetsFromManifestRegistration(registration domain.GamePluginManifestRegistration) []domain.PluginAssetFile {
assets := domain.CopyPluginAssetFiles(registration.AssetFiles)
if len(registration.Manifest.AssetFiles) == 0 {
return assets
}
modeByPath := map[string]int{}
for _, file := range registration.Manifest.AssetFiles {
if file.Mode != 0 {
modeByPath[file.Path] = file.Mode
}
}
for i := range assets {
if assets[i].Mode == 0 {
assets[i].Mode = modeByPath[assets[i].Path]
}
}
return assets
}
func (svc *CoreService) AuthorizePluginBridgeAction(request domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) { func (svc *CoreService) AuthorizePluginBridgeAction(request domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) {
if err := validator.ValidatePluginBridgeAuthorizeRequest(request); err != nil { if err := validator.ValidatePluginBridgeAuthorizeRequest(request); err != nil {
return domain.PluginBridgeAuthorization{}, err return domain.PluginBridgeAuthorization{}, err
+7 -1
View File
@@ -872,7 +872,10 @@ func TestCoreServiceManagesAIProviderMetadata(t *testing.T) {
func TestCoreServiceRegistersGamePluginManifest(t *testing.T) { func TestCoreServiceRegistersGamePluginManifest(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
plugin, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration()) registration := validPluginManifestRegistration()
registration.Manifest.AssetFiles = []domain.PluginAssetFile{{Path: "actions/install.json", Mode: 0o600}, {Path: "bin/install-server", Mode: 0o700}}
registration.AssetFiles = []domain.PluginAssetFile{{Path: "actions/install.json", Content: "{}", Mode: 0o600}, {Path: "bin/install-server", Content: "#!/usr/bin/env sh\n"}}
plugin, err := svc.RegisterGamePluginManifest(registration)
if err != nil { if err != nil {
t.Fatalf("register manifest: %v", err) t.Fatalf("register manifest: %v", err)
} }
@@ -894,6 +897,9 @@ func TestCoreServiceRegistersGamePluginManifest(t *testing.T) {
if len(plugin.BridgeActions) != 4 || plugin.BridgeActions[0] != string(domain.PluginBridgeActionServerInstancesRead) { if len(plugin.BridgeActions) != 4 || plugin.BridgeActions[0] != string(domain.PluginBridgeActionServerInstancesRead) {
t.Fatalf("expected bridge actions, got %+v", plugin.BridgeActions) t.Fatalf("expected bridge actions, got %+v", plugin.BridgeActions)
} }
if len(plugin.LifecycleAssets) != 2 || plugin.LifecycleAssets[1].Path != "bin/install-server" || plugin.LifecycleAssets[1].Mode != 0o700 {
t.Fatalf("expected declared lifecycle assets with manifest mode defaults, got %+v", plugin.LifecycleAssets)
}
listed, err := svc.ListGamePlugins(domain.GamePluginFilter{ServerType: "example", Status: domain.GamePluginStatusInstalled}) listed, err := svc.ListGamePlugins(domain.GamePluginFilter{ServerType: "example", Status: domain.GamePluginStatusInstalled})
if err != nil || len(listed) != 1 { if err != nil || len(listed) != 1 {
-239
View File
@@ -1,239 +0,0 @@
package service
import (
"fmt"
"regexp"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/validator"
)
const scumPluginID = "game.scum"
func applyPluginCreateDefaults(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition) domain.ServerDeploymentDefinition {
definition = domain.CopyServerDeploymentDefinition(definition)
if definition.Mode != domain.ServerDeploymentModeGuided {
return definition
}
if definition.CreateInputs == nil {
definition.CreateInputs = map[string]string{}
}
for _, field := range plugin.CreateFields {
if _, present := definition.CreateInputs[field.Key]; !present && field.DefaultValue != "" {
definition.CreateInputs[field.Key] = field.DefaultValue
}
}
return definition
}
func scumDeploymentPlan(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition, operation string) (*domain.ServerDeploymentPlan, error) {
if plugin.ID != scumPluginID || (definition.Mode != domain.ServerDeploymentModeGuided && definition.Mode != domain.ServerDeploymentModeExisting) {
return nil, nil
}
if operation != "install" && operation != "adopt" {
return nil, validationError("SCUM deployment operation is invalid")
}
if len(plugin.RuntimeProfiles.ServerDeployments) == 0 {
return nil, validationError("SCUM deployment template is not registered")
}
profile := plugin.RuntimeProfiles.ServerDeployments[0]
if strings.TrimSpace(profile.Key) == "" || strings.TrimSpace(profile.Version) == "" || profile.SteamAppID == "" {
return nil, validationError("SCUM deployment template is incomplete")
}
expectedPrerequisites := map[string]string{"steamcmd": "steamcmd", "vcredist-2012-x86": "windows-vcredist", "vcredist-2012-x64": "windows-vcredist", "vcredist-2013-x86": "windows-vcredist", "vcredist-2013-x64": "windows-vcredist", "vcredist-2015-2022-x86": "windows-vcredist", "vcredist-2015-2022-x64": "windows-vcredist", "directx-jun2010": "windows-directx"}
if len(profile.Prerequisites) != len(expectedPrerequisites) {
return nil, validationError("SCUM deployment template prerequisite list is incomplete")
}
for _, prerequisite := range profile.Prerequisites {
if expectedPrerequisites[prerequisite.Key] != prerequisite.Kind {
return nil, validationError("SCUM deployment template prerequisite is invalid")
}
}
fieldKeys := make(map[string]struct{}, len(plugin.CreateFields))
for _, field := range plugin.CreateFields {
fieldKeys[field.Key] = struct{}{}
}
for _, mapping := range profile.ConfigMappings {
if _, ok := fieldKeys[mapping.FieldKey]; !ok {
return nil, validationError("SCUM deployment template maps an undeclared create field")
}
if strings.TrimSpace(mapping.ConfigKey) == "" || strings.TrimSpace(mapping.ValueType) == "" {
return nil, validationError("SCUM deployment template contains an incomplete config mapping")
}
}
for _, check := range profile.VerificationChecks {
if check.Required && (strings.TrimSpace(check.Key) == "" || strings.TrimSpace(check.Kind) == "") {
return nil, validationError("SCUM deployment template contains an incomplete verification check")
}
}
return &domain.ServerDeploymentPlan{
SchemaVersion: "1",
Operation: operation,
PluginID: plugin.ID,
TemplateKey: profile.Key,
TemplateVersion: profile.Version,
SteamAppID: profile.SteamAppID,
ExecutableKey: profile.ExecutableKey,
InstallRootKey: profile.InstallRootKey,
ConfigKey: profile.ConfigKey,
ConfigFormat: profile.ConfigFormat,
Prerequisites: append([]domain.RuntimeServerPrerequisite(nil), profile.Prerequisites...),
ConfigMappings: append([]domain.RuntimeServerConfigMapping(nil), profile.ConfigMappings...),
DiscoveryMarkers: append([]domain.RuntimeServerDiscoveryMarker(nil), profile.DiscoveryMarkers...),
VerificationChecks: append([]domain.RuntimeServerVerificationCheck(nil), profile.VerificationChecks...),
}, nil
}
func scumDeploymentProjection(plan *domain.ServerDeploymentPlan, operation string, stamp time.Time) domain.ServerDeploymentProjection {
projection := domain.ServerDeploymentProjection{State: "draft", Operation: operation, UpdatedAt: stamp}
if plan != nil {
projection.TemplateKey = plan.TemplateKey
projection.TemplateVersion = plan.TemplateVersion
}
return projection
}
func scumDeploymentCapabilityRequired(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool {
return plugin.ID == scumPluginID && (definition.Mode == domain.ServerDeploymentModeGuided || definition.Mode == domain.ServerDeploymentModeExisting)
}
func deploymentNeedsCompleteRuntimeBinding(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool {
return !scumDeploymentCapabilityRequired(plugin, definition)
}
func validateSCUMDeploymentTarget(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition, endpoint domain.RunEndpoint) error {
if !scumDeploymentCapabilityRequired(plugin, definition) {
return nil
}
plan, err := scumDeploymentPlan(plugin, definition, "install")
if err != nil {
return err
}
for _, profile := range plugin.RuntimeProfiles.ServerDeployments {
if profile.Key != plan.TemplateKey {
continue
}
for _, target := range profile.SupportedTargets {
if target.OS == endpoint.Platform && target.Arch == endpoint.Architecture {
return nil
}
}
}
return validationError("SCUM deployment template is incompatible with the selected Run target")
}
func validateScumDeploymentInputs(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition) error {
plan, err := scumDeploymentPlan(plugin, definition, "install")
if err != nil || plan == nil {
return err
}
if definition.Mode == domain.ServerDeploymentModeExisting {
if strings.TrimSpace(definition.ServerRoot) == "" {
return validationError("SCUM adoption requires an existing server directory")
}
return nil
}
if strings.TrimSpace(definition.ServerRoot) == "" {
return validationError("SCUM installation requires an install directory")
}
for _, mapping := range plan.ConfigMappings {
if mapping.Required && strings.TrimSpace(definition.CreateInputs[mapping.FieldKey]) == "" {
field, found := findPluginCreateField(plugin.CreateFields, mapping.FieldKey)
if !found || field.DefaultValue == "" {
return validator.ValidationError{Violations: []string{"createInputs." + mapping.FieldKey + " is required by the SCUM deployment template"}}
}
}
}
return nil
}
func findPluginCreateField(fields []domain.PluginCreateField, key string) (domain.PluginCreateField, bool) {
for _, field := range fields {
if field.Key == key {
return field, true
}
}
return domain.PluginCreateField{}, false
}
func validateSCUMDeploymentEvidence(plan *domain.ServerDeploymentPlan, result domain.RunJobResult) error {
if plan == nil {
return nil
}
evidence := result.ExecutionResult.ServerDeploymentEvidence
if evidence == nil {
return validationError("SCUM deployment result must include deployment evidence")
}
if evidence.TemplateKey != plan.TemplateKey || evidence.TemplateVersion != plan.TemplateVersion {
return validationError("SCUM deployment result template fence is invalid")
}
for name, values := range map[string]map[string]string{"discoveredFacts": evidence.DiscoveredFacts, "mappingResults": evidence.MappingResults, "verificationResults": evidence.VerificationResults} {
if len(values) > 64 {
return validationError("SCUM deployment evidence contains too many " + name)
}
for key, value := range values {
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$`).MatchString(key) || len(value) > 160 || strings.TrimSpace(value) != value || unsafeSCUMEvidenceValue(value) {
return validationError(fmt.Sprintf("SCUM deployment evidence contains an unsafe %s value", name))
}
}
}
if len(evidence.FailureCode) > 80 || (evidence.FailureCode != "" && !regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,79}$`).MatchString(evidence.FailureCode)) {
return validationError("SCUM deployment failure code is invalid")
}
if result.State == domain.JobStateFailed {
if result.ExecutionResult.Kind != "scum.install.failed" && result.ExecutionResult.Kind != "scum.adopt.failed" {
return validationError("SCUM deployment failure result type is invalid")
}
if strings.TrimSpace(evidence.FailureCode) == "" {
return validationError("SCUM deployment failure must include a stable failure code")
}
return nil
}
if result.State != domain.JobStateSucceeded {
return nil
}
expectedKind := "scum.install.completed"
if plan.Operation == "adopt" {
expectedKind = "scum.adopt.completed"
}
if result.ExecutionResult.Kind != expectedKind {
return validationError("SCUM deployment result type is invalid")
}
if evidence.PreflightState != "passed" || evidence.DiscoveryState != "passed" || evidence.VerificationState != "passed" {
return validationError("SCUM deployment result is missing successful preflight, discovery, or verification evidence")
}
validMappingState := evidence.MappingState == "passed" || evidence.MappingState == "applied" || evidence.MappingState == "unchanged"
if plan.Operation == "adopt" && evidence.MappingState == "skipped" {
validMappingState = true
}
if !validMappingState {
return validationError("SCUM deployment result is missing a successful config mapping state")
}
for _, mapping := range plan.ConfigMappings {
mappingResult := evidence.MappingResults[mapping.FieldKey]
if plan.Operation == "adopt" && mappingResult == "" {
mappingResult = "skipped"
}
if mapping.Required && mappingResult != "applied" && mappingResult != "unchanged" && !(plan.Operation == "adopt" && mappingResult == "skipped") {
return validationError("SCUM deployment result is missing a required config mapping")
}
}
for _, check := range plan.VerificationChecks {
if check.Required && evidence.VerificationResults[check.Key] != "passed" {
return validationError("SCUM deployment result is missing a required verification check")
}
}
return nil
}
func unsafeSCUMEvidenceValue(value string) bool {
lower := strings.ToLower(value)
for _, token := range []string{"password", "secret", "credential", "token", "private key", "powershell", "cmd.exe", "bash -c", "ssh://", "tcp://", "udp://"} {
if strings.Contains(lower, token) {
return true
}
}
return strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\\`) || regexp.MustCompile(`^[A-Za-z]:[\\/]`).MatchString(value)
}
+12 -78
View File
@@ -11,16 +11,10 @@ func scumDeploymentTestPlugin() domain.GamePlugin {
ID: "game.scum", ID: "game.scum",
CreateFields: []domain.PluginCreateField{ CreateFields: []domain.PluginCreateField{
{Key: "serverName", Type: "text", DefaultValue: "SCUM Test"}, {Key: "serverName", Type: "text", DefaultValue: "SCUM Test"},
{Key: "gamePort", Type: "port", DefaultValue: "7777"}, {Key: "gamePort", Type: "port", DefaultValue: "7779"},
{Key: "queryPort", Type: "port", DefaultValue: "27015"}, {Key: "queryPort", Type: "port", DefaultValue: "27015"},
{Key: "maxPlayers", Type: "number", DefaultValue: "64"}, {Key: "maxPlayers", Type: "number", DefaultValue: "128"},
}, },
RuntimeProfiles: domain.GamePluginRuntimeProfiles{ServerDeployments: []domain.RuntimeServerDeploymentProfile{{
Key: "scum-steamcmd-windows", Version: "1.0.0", SteamAppID: "3792580", ExecutableKey: "scum/server-executable", InstallRootKey: "server/install-root", ConfigKey: "scum/server-settings", ConfigFormat: "ini",
Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "steamcmd"}, {Key: "vcredist-2012-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2012-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x64", Kind: "windows-vcredist"}, {Key: "directx-jun2010", Kind: "windows-directx"}},
ConfigMappings: []domain.RuntimeServerConfigMapping{{FieldKey: "serverName", ConfigKey: "server-settings.server-name", ValueType: "text", Required: true}, {FieldKey: "gamePort", ConfigKey: "server-settings.game-port", ValueType: "port", Required: true}, {FieldKey: "queryPort", ConfigKey: "server-settings.query-port", ValueType: "port", Required: true}, {FieldKey: "maxPlayers", ConfigKey: "server-settings.max-players", ValueType: "integer", Required: true}},
VerificationChecks: []domain.RuntimeServerVerificationCheck{{Key: "executable", Kind: "executable.present", TargetKey: "scum/server-executable", Required: true}, {Key: "game-port", Kind: "port.bound", TargetKey: "game-port", Required: true}, {Key: "config", Kind: "config.readable", TargetKey: "scum/server-settings", Required: true}, {Key: "process", Kind: "process.healthy", TargetKey: "scum/server-executable", Required: true}},
}}},
} }
} }
@@ -42,81 +36,21 @@ func requireManualSCUMRuntimeBindings(plugin *domain.GamePlugin, profileKey stri
} }
} }
func TestSCUMDeploymentPlanSeparatesInstallAndAdopt(t *testing.T) { func TestApplyPluginCreateDefaultsUsesPluginFields(t *testing.T) {
plugin := scumDeploymentTestPlugin() plugin := scumDeploymentTestPlugin()
definition := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, CreateInputs: map[string]string{"serverName": "Alpha", "gamePort": "7777", "queryPort": "27015", "maxPlayers": "64"}} definition := applyPluginCreateDefaults(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, CreateInputs: map[string]string{"serverName": "Moon"}})
plan, err := scumDeploymentPlan(plugin, definition, "install")
if err != nil || plan == nil || plan.Operation != "install" || plan.SteamAppID != "3792580" { if definition.CreateInputs["serverName"] != "Moon" || definition.CreateInputs["gamePort"] != "7779" || definition.CreateInputs["maxPlayers"] != "128" {
t.Fatalf("unexpected install plan: %+v, %v", plan, err) t.Fatalf("expected plugin defaults to fill missing guided inputs: %+v", definition.CreateInputs)
}
definition.Mode = domain.ServerDeploymentModeExisting
plan, err = scumDeploymentPlan(plugin, definition, "adopt")
if err != nil || plan == nil || plan.Operation != "adopt" {
t.Fatalf("unexpected adopt plan: %+v, %v", plan, err)
} }
} }
func TestSCUMDeploymentEvidenceRequiresAllRequiredChecks(t *testing.T) { func TestDeploymentDefinitionsUsePluginOwnedLifecycleWithoutRuntimeBinding(t *testing.T) {
plugin := scumDeploymentTestPlugin() plugin := scumDeploymentTestPlugin()
plan, err := scumDeploymentPlan(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided}, "install") if deploymentNeedsCompleteRuntimeBinding(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided}) {
if err != nil { t.Fatal("guided plugin-owned lifecycle should not require a complete runtime binding")
t.Fatal(err)
} }
result := domain.RunJobResult{State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: "scum.install.completed", ServerDeploymentEvidence: &domain.ServerDeploymentEvidence{TemplateKey: plan.TemplateKey, TemplateVersion: plan.TemplateVersion, PreflightState: "passed", DiscoveryState: "passed", MappingState: "applied", VerificationState: "passed", MappingResults: map[string]string{"serverName": "applied", "gamePort": "applied", "queryPort": "applied", "maxPlayers": "applied"}, VerificationResults: map[string]string{"executable": "passed", "version": "passed", "game-port": "passed", "config": "passed", "process": "passed"}}}} if !deploymentNeedsCompleteRuntimeBinding(plugin, domain.ServerDeploymentDefinition{}) {
if err := validateSCUMDeploymentEvidence(plan, result); err != nil { t.Fatal("non-deployment runtime lifecycle still requires a complete runtime binding")
t.Fatalf("valid evidence rejected: %v", err)
}
delete(result.ExecutionResult.ServerDeploymentEvidence.VerificationResults, "process")
if err := validateSCUMDeploymentEvidence(plan, result); err == nil {
t.Fatal("missing required process check was accepted")
}
}
func TestSCUMAdoptionMaySkipConfigurationMappingAfterDiscovery(t *testing.T) {
plugin := scumDeploymentTestPlugin()
plan, err := scumDeploymentPlan(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: `C:\\scum`}, "adopt")
if err != nil {
t.Fatal(err)
}
result := domain.RunJobResult{State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: "scum.adopt.completed", ServerDeploymentEvidence: &domain.ServerDeploymentEvidence{
TemplateKey: plan.TemplateKey, TemplateVersion: plan.TemplateVersion, PreflightState: "passed", DiscoveryState: "passed", MappingState: "skipped", VerificationState: "passed",
VerificationResults: map[string]string{"executable": "passed", "version": "passed", "game-port": "passed", "config": "passed", "process": "passed"},
}}}
if err := validateSCUMDeploymentEvidence(plan, result); err != nil {
t.Fatalf("valid adoption evidence rejected: %v", err)
}
}
func TestSCUMInstallRequiresDirectoryButAdoptionDoesNotRequireCreateInputs(t *testing.T) {
plugin := scumDeploymentTestPlugin()
install := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, CreateInputs: map[string]string{"serverName": "Alpha"}}
if err := validateScumDeploymentInputs(plugin, install); err == nil {
t.Fatal("SCUM install without a directory was accepted")
}
install.ServerRoot = `C:\\scum`
if err := validateScumDeploymentInputs(plugin, install); err != nil {
t.Fatalf("SCUM install with defaults was rejected: %v", err)
}
adopt := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: `C:\\scum`}
if err := validateScumDeploymentInputs(plugin, adopt); err != nil {
t.Fatalf("SCUM adoption without create inputs was rejected: %v", err)
}
}
func TestSCUMDeploymentFailureRequiresStableCode(t *testing.T) {
plugin := scumDeploymentTestPlugin()
plan, _ := scumDeploymentPlan(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting}, "adopt")
result := domain.RunJobResult{State: domain.JobStateFailed, ExecutionResult: domain.JobExecutionResult{Kind: "scum.adopt.failed", ServerDeploymentEvidence: &domain.ServerDeploymentEvidence{TemplateKey: plan.TemplateKey, TemplateVersion: plan.TemplateVersion}}}
if err := validateSCUMDeploymentEvidence(plan, result); err == nil {
t.Fatal("failed deployment without failure code was accepted")
}
}
func TestSCUMDeploymentTargetMustMatchTemplate(t *testing.T) {
plugin := scumDeploymentTestPlugin()
plugin.RuntimeProfiles.ServerDeployments[0].SupportedTargets = []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}
definition := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided}
if err := validateSCUMDeploymentTarget(plugin, definition, domain.RunEndpoint{Platform: "linux", Architecture: "amd64"}); err == nil {
t.Fatal("linux Run target was accepted for the Windows-only SCUM template")
} }
} }
-24
View File
@@ -59,9 +59,6 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, definition.CreateInputs); err != nil { if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, definition.CreateInputs); err != nil {
return domain.ServerDeploymentView{}, err return domain.ServerDeploymentView{}, err
} }
if err := validateScumDeploymentInputs(plugin, definition); err != nil {
return domain.ServerDeploymentView{}, err
}
if update.RunEndpointID != "" { if update.RunEndpointID != "" {
if _, err := svc.store.RunEndpoints().Get(update.RunEndpointID); err != nil { if _, err := svc.store.RunEndpoints().Get(update.RunEndpointID); err != nil {
return domain.ServerDeploymentView{}, err return domain.ServerDeploymentView{}, err
@@ -69,17 +66,7 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
instance.RunEndpointID = update.RunEndpointID instance.RunEndpointID = update.RunEndpointID
} }
instance.Deployment = definition instance.Deployment = definition
operation := "install"
if definition.Mode == domain.ServerDeploymentModeExisting {
operation = "adopt"
}
if plan, planErr := scumDeploymentPlan(plugin, definition, operation); planErr != nil {
return domain.ServerDeploymentView{}, planErr
} else if plan != nil {
instance.DeploymentProjection = scumDeploymentProjection(plan, operation, definition.UpdatedAt)
} else {
instance.DeploymentProjection = domain.ServerDeploymentProjection{} instance.DeploymentProjection = domain.ServerDeploymentProjection{}
}
instance.UpdatedAt = definition.UpdatedAt instance.UpdatedAt = definition.UpdatedAt
if err := validator.ValidateServerInstance(instance); err != nil { if err := validator.ValidateServerInstance(instance); err != nil {
return domain.ServerDeploymentView{}, err return domain.ServerDeploymentView{}, err
@@ -131,18 +118,12 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment) instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment)
if err := validateSCUMDeploymentTarget(plugin, instance.Deployment, endpoint); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil { if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil { if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if err := validateScumDeploymentInputs(plugin, instance.Deployment); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityInstall); err != nil { if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityInstall); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
@@ -169,11 +150,6 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
} }
instance.State = domain.ServerInstanceStateInstalling instance.State = domain.ServerInstanceStateInstalling
instance.UpdatedAt = svc.now() instance.UpdatedAt = svc.now()
if instance.DeploymentProjection.TemplateKey != "" {
instance.DeploymentProjection.State = "queued"
instance.DeploymentProjection.PreflightState = "queued"
instance.DeploymentProjection.UpdatedAt = instance.UpdatedAt
}
if err := svc.store.ServerInstances().Update(instance); err != nil { if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
+9 -7
View File
@@ -65,13 +65,13 @@ func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibl
} }
} }
func TestCoreServiceSCUMGuidedDeployUsesScopedCapabilitiesAndDispatchesFrozenPlan(t *testing.T) { func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
runHello := validRunControlHello() runHello := validRunControlHello()
runHello.RunEndpointID = "run-scum-guided" runHello.RunEndpointID = "run-scum-guided"
runHello.Platform = "windows" runHello.Platform = "windows"
runHello.Architecture = "amd64" runHello.Architecture = "amd64"
runHello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan} runHello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}
runHello.CapabilityReport.Fingerprint = "cap-scum-guided" runHello.CapabilityReport.Fingerprint = "cap-scum-guided"
session, err := svc.RegisterRunHello(runHello) session, err := svc.RegisterRunHello(runHello)
if err != nil { if err != nil {
@@ -86,7 +86,6 @@ func TestCoreServiceSCUMGuidedDeployUsesScopedCapabilitiesAndDispatchesFrozenPla
plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"} plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"}
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}} plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}}
requireManualSCUMRuntimeBindings(&plugin, "local") requireManualSCUMRuntimeBindings(&plugin, "local")
plugin.RuntimeProfiles.ServerDeployments[0].SupportedTargets = []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}
if err := svc.store.GamePlugins().Create(plugin); err != nil { if err := svc.store.GamePlugins().Create(plugin); err != nil {
t.Fatalf("create SCUM plugin fixture: %v", err) t.Fatalf("create SCUM plugin fixture: %v", err)
} }
@@ -99,12 +98,15 @@ func TestCoreServiceSCUMGuidedDeployUsesScopedCapabilitiesAndDispatchesFrozenPla
if err != nil { if err != nil {
t.Fatalf("SCUM guided deployment should not require unrelated manifest capabilities: %v", err) t.Fatalf("SCUM guided deployment should not require unrelated manifest capabilities: %v", err)
} }
if created.Job.ExecutionInput.ServerDeploymentPlan == nil || created.Job.ExecutionInput.ServerDeploymentPlan.SteamAppID != "3792580" { if created.Job.TargetKey != "actions/install.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil {
t.Fatalf("expected frozen SCUM deployment plan on queued job, got %+v", created.Job.ExecutionInput) t.Fatalf("expected plugin-owned install action without SCUM deployment plan, job=%+v", created.Job)
}
if created.Job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || created.Job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
t.Fatalf("SCUM install job lost create inputs: %+v", created.Job.ExecutionInput.Deployment.CreateInputs)
} }
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.ExecutionInput.ServerDeploymentPlan == nil { if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
t.Fatalf("claimed SCUM install must include frozen deployment plan, claim=%+v err=%v", claim, err) t.Fatalf("claimed SCUM install must use plugin action without SCUM plan, claim=%+v err=%v", claim, err)
} }
} }
+1 -36
View File
@@ -33,9 +33,6 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, create.Deployment.CreateInputs); err != nil { if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, create.Deployment.CreateInputs); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if err := validateScumDeploymentInputs(plugin, create.Deployment); err != nil {
return domain.ServerLifecycleResult{}, err
}
} }
stamp := svc.now() stamp := svc.now()
@@ -63,15 +60,6 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
instance.Deployment.RuntimeBindings = domain.CopyStringMap(create.Bindings) instance.Deployment.RuntimeBindings = domain.CopyStringMap(create.Bindings)
instance.Deployment.Revision = maxInt(1, instance.Deployment.Revision) instance.Deployment.Revision = maxInt(1, instance.Deployment.Revision)
instance.Deployment.UpdatedAt = stamp instance.Deployment.UpdatedAt = stamp
operation := "install"
if instance.Deployment.Mode == domain.ServerDeploymentModeExisting {
operation = "adopt"
}
if plan, planErr := scumDeploymentPlan(plugin, instance.Deployment, operation); planErr != nil {
return domain.ServerLifecycleResult{}, planErr
} else if plan != nil {
instance.DeploymentProjection = scumDeploymentProjection(plan, operation, stamp)
}
} }
instance.ConfigContent = buildLogicalServerConfig(instance) instance.ConfigContent = buildLogicalServerConfig(instance)
instance.ConfigChecksum = validator.BytesChecksum([]byte(instance.ConfigContent)) instance.ConfigChecksum = validator.BytesChecksum([]byte(instance.ConfigContent))
@@ -127,14 +115,6 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if instance.DeploymentProjection.TemplateKey != "" {
instance.DeploymentProjection.State = "queued"
instance.DeploymentProjection.PreflightState = "queued"
instance.DeploymentProjection.UpdatedAt = stamp
}
if err := validateSCUMDeploymentTarget(plugin, instance.Deployment, endpoint); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
@@ -339,17 +319,6 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
return domain.Job{}, err return domain.Job{}, err
} }
} }
var serverDeploymentPlan *domain.ServerDeploymentPlan
if action == domain.ServerLifecycleActionCreate {
operation := "install"
if instance.Deployment.Mode == domain.ServerDeploymentModeExisting {
operation = "adopt"
}
serverDeploymentPlan, err = scumDeploymentPlan(plugin, instance.Deployment, operation)
if err != nil {
return domain.Job{}, err
}
}
job, err := svc.CreateJob(domain.Job{ job, err := svc.CreateJob(domain.Job{
ID: lifecycleJobID(instance.ID, action, idempotencyKey), ID: lifecycleJobID(instance.ID, action, idempotencyKey),
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
@@ -364,7 +333,6 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
LifecycleOperation: lifecycleExecutionOperation(action), LifecycleOperation: lifecycleExecutionOperation(action),
DLLExtensions: dllExtensions, DLLExtensions: dllExtensions,
Deployment: deploymentPlanForDispatch(instance.Deployment), Deployment: deploymentPlanForDispatch(instance.Deployment),
ServerDeploymentPlan: serverDeploymentPlan,
}, },
}) })
if err != nil { if err != nil {
@@ -520,11 +488,8 @@ func validateServerInstanceLifecycleDependencies(instance domain.ServerInstance,
func lifecycleRequiredRunCapabilities(instance domain.ServerInstance, plugin domain.GamePlugin, action domain.ServerLifecycleAction) []string { func lifecycleRequiredRunCapabilities(instance domain.ServerInstance, plugin domain.GamePlugin, action domain.ServerLifecycleAction) []string {
required := append([]string(nil), domain.LifecycleCapabilityForAction(action)) required := append([]string(nil), domain.LifecycleCapabilityForAction(action))
if instance.Deployment.Mode != "" { if instance.Deployment.Mode == domain.ServerDeploymentModeCustom {
required = append(required, domain.JobCapabilityDeploymentPlan) required = append(required, domain.JobCapabilityDeploymentPlan)
if scumDeploymentCapabilityRequired(plugin, instance.Deployment) {
required = append(required, domain.JobCapabilitySCUMDeploymentPlan)
}
} }
return compactUniqueStrings(required) return compactUniqueStrings(required)
} }
@@ -58,31 +58,6 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
if err != nil { if err != nil {
return err return err
} }
if plan := job.ExecutionInput.ServerDeploymentPlan; plan != nil {
projection := domain.CopyServerDeploymentProjection(instance.DeploymentProjection)
projection.Operation = plan.Operation
projection.TemplateKey = plan.TemplateKey
projection.TemplateVersion = plan.TemplateVersion
projection.UpdatedAt = stamp
if evidence := job.ExecutionResult.ServerDeploymentEvidence; evidence != nil {
projection.State = "verified"
projection.PreflightState = evidence.PreflightState
projection.DiscoveryState = evidence.DiscoveryState
projection.MappingState = evidence.MappingState
projection.VerificationState = evidence.VerificationState
projection.DiscoveredFacts = domain.CopyStringMap(evidence.DiscoveredFacts)
projection.MappingResults = domain.CopyStringMap(evidence.MappingResults)
projection.VerificationResults = domain.CopyStringMap(evidence.VerificationResults)
projection.FailureCode = evidence.FailureCode
}
if job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled {
projection.State = "failed"
if projection.FailureCode == "" && job.State == domain.JobStateCancelled {
projection.FailureCode = "cancelled"
}
}
instance.DeploymentProjection = projection
}
instance.State = nextState instance.State = nextState
instance.UpdatedAt = stamp instance.UpdatedAt = stamp
if err := validator.ValidateServerInstance(instance); err != nil { if err := validator.ValidateServerInstance(instance); err != nil {
@@ -99,8 +74,7 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
} }
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error { func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
plan := job.ExecutionInput.ServerDeploymentPlan if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" {
if plan == nil || job.ServerInstanceID == "" {
return nil return nil
} }
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
@@ -109,9 +83,7 @@ func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp ti
} }
projection := domain.CopyServerDeploymentProjection(instance.DeploymentProjection) projection := domain.CopyServerDeploymentProjection(instance.DeploymentProjection)
projection.State = "running" projection.State = "running"
projection.Operation = plan.Operation projection.Operation = job.ExecutionInput.LifecycleOperation
projection.TemplateKey = plan.TemplateKey
projection.TemplateVersion = plan.TemplateVersion
switch job.Progress.Phase { switch job.Progress.Phase {
case "preflight": case "preflight":
projection.PreflightState = "running" projection.PreflightState = "running"
+85 -1
View File
@@ -159,6 +159,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...) violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
violations = append(violations, validateMapTrajectoryDeclaration("mapTrajectories", plugin.MapTrajectories)...) violations = append(violations, validateMapTrajectoryDeclaration("mapTrajectories", plugin.MapTrajectories)...)
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...) violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...) violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
return finish(violations) return finish(violations)
} }
@@ -229,10 +230,87 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...) violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...) violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...)
violations = append(violations, validateMapTrajectoryDeclaration("manifest.mapTrajectories", manifest.MapTrajectories)...) violations = append(violations, validateMapTrajectoryDeclaration("manifest.mapTrajectories", manifest.MapTrajectories)...)
violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
violations = append(violations, validateRegistrationAssetCoverage(registration.Manifest.AssetFiles, registration.AssetFiles)...)
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...) violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
return finish(violations) return finish(violations)
} }
func validatePluginAssetFileDeclarations(prefix string, files []domain.PluginAssetFile) []string {
if len(files) > 64 {
return []string{prefix + " has too many files"}
}
var violations []string
seen := map[string]struct{}{}
for i, file := range files {
field := fmt.Sprintf("%s[%d]", prefix, i)
if !validLogicalFileKey(file.Path) {
violations = append(violations, field+".path is unsafe")
}
if _, exists := seen[file.Path]; exists {
violations = append(violations, field+".path is duplicated")
}
seen[file.Path] = struct{}{}
if file.Content != "" {
violations = append(violations, field+".content must be supplied only in registration assetFiles")
}
if file.Mode != 0 && file.Mode != 0o600 && file.Mode != 0o700 {
violations = append(violations, field+".mode is unsafe")
}
}
return violations
}
func validatePluginAssetFiles(prefix string, files []domain.PluginAssetFile) []string {
if len(files) > 64 {
return []string{prefix + " has too many files"}
}
var violations []string
seen := map[string]struct{}{}
for i, file := range files {
field := fmt.Sprintf("%s[%d]", prefix, i)
if !validLogicalFileKey(file.Path) {
violations = append(violations, field+".path is unsafe")
}
if _, exists := seen[file.Path]; exists {
violations = append(violations, field+".path is duplicated")
}
seen[file.Path] = struct{}{}
if len([]byte(file.Content)) > 64*1024 || strings.ContainsRune(file.Content, '\x00') || containsUnsafeRuntimeSecret(file.Content) {
violations = append(violations, field+".content is unsafe")
}
if file.Mode != 0 && (file.Mode < 0o400 || file.Mode > 0o700 || file.Mode&0o022 != 0) {
violations = append(violations, field+".mode is unsafe")
}
}
return violations
}
func validateRegistrationAssetCoverage(declared []domain.PluginAssetFile, payload []domain.PluginAssetFile) []string {
if len(declared) == 0 {
return nil
}
allowed := map[string]struct{}{}
for _, file := range declared {
allowed[file.Path] = struct{}{}
}
provided := map[string]struct{}{}
for _, file := range payload {
provided[file.Path] = struct{}{}
if _, ok := allowed[file.Path]; !ok {
return []string{"assetFiles contains undeclared plugin asset " + file.Path}
}
}
var violations []string
for _, file := range declared {
if _, ok := provided[file.Path]; !ok {
violations = append(violations, "assetFiles is missing declared plugin asset "+file.Path)
}
}
return violations
}
func validateMapTrajectoryDeclaration(prefix string, value *domain.GameMapTrajectoryDeclaration) []string { func validateMapTrajectoryDeclaration(prefix string, value *domain.GameMapTrajectoryDeclaration) []string {
if value == nil { if value == nil {
return nil return nil
@@ -1711,6 +1789,9 @@ func pluginSafeStrings(plugin domain.GamePlugin) []fieldString {
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods) values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities) values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", plugin.RemoteAccess.DatabaseEngines) values = appendStringSliceFields(values, "remoteAccess.databaseEngines", plugin.RemoteAccess.DatabaseEngines)
for i, file := range plugin.LifecycleAssets {
values = append(values, fieldString{field: fmt.Sprintf("lifecycleAssets[%d].path", i), value: file.Path})
}
for i, page := range plugin.Pages { for i, page := range plugin.Pages {
prefix := fmt.Sprintf("pages[%d]", i) prefix := fmt.Sprintf("pages[%d]", i)
values = append(values, values = append(values,
@@ -1753,6 +1834,9 @@ func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []f
values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods) values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities) values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines) values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines)
for i, file := range manifest.AssetFiles {
values = append(values, fieldString{field: fmt.Sprintf("assetFiles[%d].path", i), value: file.Path})
}
for i, page := range manifest.Pages { for i, page := range manifest.Pages {
prefix := fmt.Sprintf("pages[%d]", i) prefix := fmt.Sprintf("pages[%d]", i)
values = append(values, values = append(values,
@@ -1953,7 +2037,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall,
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write", "artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
+33
View File
@@ -74,6 +74,39 @@ func TestValidateGamePluginManifestRegistrationRejectsUnsafeRequests(t *testing.
} }
} }
func TestValidateGamePluginManifestRegistrationValidatesAssetFileCoverage(t *testing.T) {
registration := validGamePluginManifestRegistration()
registration.Manifest.AssetFiles = []domain.PluginAssetFile{
{Path: "actions/install.json", Mode: 0o600},
{Path: "bin/install-server", Mode: 0o700},
}
registration.AssetFiles = []domain.PluginAssetFile{
{Path: "actions/install.json", Content: "{}", Mode: 0o600},
{Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700},
}
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
t.Fatalf("expected declared asset files with matching content to validate, got %v", err)
}
missing := domain.CopyGamePluginManifestRegistration(registration)
missing.AssetFiles = missing.AssetFiles[:1]
if err := ValidateGamePluginManifestRegistration(missing); err == nil || !strings.Contains(err.Error(), "missing declared plugin asset bin/install-server") {
t.Fatalf("expected missing asset rejection, got %v", err)
}
extra := domain.CopyGamePluginManifestRegistration(registration)
extra.AssetFiles = append(extra.AssetFiles, domain.PluginAssetFile{Path: "bin/unregistered", Content: "unused"})
if err := ValidateGamePluginManifestRegistration(extra); err == nil || !strings.Contains(err.Error(), "undeclared plugin asset bin/unregistered") {
t.Fatalf("expected undeclared asset rejection, got %v", err)
}
inlineContent := domain.CopyGamePluginManifestRegistration(registration)
inlineContent.Manifest.AssetFiles[0].Content = "{}"
if err := ValidateGamePluginManifestRegistration(inlineContent); err == nil || !strings.Contains(err.Error(), "content must be supplied only") {
t.Fatalf("expected manifest inline content rejection, got %v", err)
}
}
func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testing.T) { func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testing.T) {
t.Run("unsafe runtime value", func(t *testing.T) { t.Run("unsafe runtime value", func(t *testing.T) {
registration := validGamePluginManifestRegistration() registration := validGamePluginManifestRegistration()
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
set -eu
printf 'dev server started\n'
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
set -eu
printf 'dev install complete\n'
@@ -56,6 +56,15 @@
"restart": "actions/restart.json", "restart": "actions/restart.json",
"status": "actions/status.json" "status": "actions/status.json"
}, },
"assetFiles": [
{ "path": "actions/install.json", "mode": 384 },
{ "path": "actions/start.json", "mode": 384 },
{ "path": "actions/stop.json", "mode": 384 },
{ "path": "actions/restart.json", "mode": 384 },
{ "path": "actions/status.json", "mode": 384 },
{ "path": "bin/install-server", "mode": 448 },
{ "path": "bin/game-server", "mode": 448 }
],
"productionLifecycle": { "productionLifecycle": {
"operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], "operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"],
"dependencyPolicy": "optional", "dependencyPolicy": "optional",
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
set -eu
printf 'minecraft server started\n'
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
set -eu
printf 'minecraft install complete\n'
@@ -98,6 +98,15 @@
"restart": "actions/restart.json", "restart": "actions/restart.json",
"status": "actions/status.json" "status": "actions/status.json"
}, },
"assetFiles": [
{ "path": "actions/install.json", "mode": 384 },
{ "path": "actions/start.json", "mode": 384 },
{ "path": "actions/stop.json", "mode": 384 },
{ "path": "actions/restart.json", "mode": 384 },
{ "path": "actions/status.json", "mode": 384 },
{ "path": "bin/install-server", "mode": 448 },
{ "path": "bin/game-server", "mode": 448 }
],
"productionLifecycle": { "productionLifecycle": {
"operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], "operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"],
"dependencyPolicy": "required", "dependencyPolicy": "required",
@@ -2,11 +2,15 @@
"version": 1, "version": 1,
"action": "install", "action": "install",
"mode": "oneshot", "mode": "oneshot",
"executableKey": "bin/install-server", "executableKey": "bin/scum-install-update.cmd",
"arguments": [], "arguments": [],
"environment": { "environment": {
"GAME_ID": "scum", "GAME_ID": "scum",
"SERVER_TEMPLATE": "scum-server" "SERVER_TEMPLATE": "scum-server",
"SERVER_STEAM_APP_ID": "3792580",
"SERVER_STEAMCMD_INSTALL_DIR_ARG": "+force_install_dir",
"SERVER_STEAMCMD_UPDATE_ARGS": "+login anonymous +app_update 3792580 validate +quit",
"SERVER_EXECUTABLE_REF": "SCUM/Binaries/Win64/SCUMServer.exe"
}, },
"timeoutMs": 30000 "timeoutMs": 300000
} }
@@ -2,11 +2,15 @@
"version": 1, "version": 1,
"action": "start", "action": "start",
"mode": "supervised", "mode": "supervised",
"executableKey": "bin/game-server", "executableKey": "bin/scum-start.cmd",
"arguments": ["--foreground"], "arguments": [],
"environment": { "environment": {
"GAME_ID": "scum", "GAME_ID": "scum",
"SERVER_ACTION": "start" "SERVER_ACTION": "start",
"SERVER_EXECUTABLE_REF": "SCUM/Binaries/Win64/SCUMServer.exe",
"SERVER_PORT_FIELD": "gamePort",
"SERVER_MAX_PLAYERS_FIELD": "maxPlayers",
"SERVER_LOG_FLAG": "-log"
}, },
"timeoutMs": 30000 "timeoutMs": 30000
} }
@@ -0,0 +1,31 @@
@echo off
setlocal EnableExtensions
if "%SERVER_ROOT%"=="" set "SERVER_ROOT=C:\scumserver"
if "%SERVER_STEAM_APP_ID%"=="" set "SERVER_STEAM_APP_ID=3792580"
if "%SERVER_STEAMCMD_INSTALL_DIR_ARG%"=="" set "SERVER_STEAMCMD_INSTALL_DIR_ARG=+force_install_dir"
if "%SERVER_STEAMCMD_UPDATE_ARGS%"=="" set "SERVER_STEAMCMD_UPDATE_ARGS=+login anonymous +app_update 3792580 validate +quit"
if "%SERVER_EXECUTABLE_REF%"=="" set "SERVER_EXECUTABLE_REF=SCUM\Binaries\Win64\SCUMServer.exe"
set "STEAMCMD_DIR=%SERVER_ROOT%\steamcmd"
set "STEAMCMD_EXE=%STEAMCMD_DIR%\steamcmd.exe"
set "SCUM_EXE=%SERVER_ROOT%\%SERVER_EXECUTABLE_REF:/=\%"
if not exist "%SERVER_ROOT%" mkdir "%SERVER_ROOT%"
if not exist "%STEAMCMD_DIR%" mkdir "%STEAMCMD_DIR%"
if not exist "%STEAMCMD_EXE%" (
powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; $zip=Join-Path $env:STEAMCMD_DIR 'steamcmd.zip'; Invoke-WebRequest -Uri 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip' -OutFile $zip; Expand-Archive -Path $zip -DestinationPath $env:STEAMCMD_DIR -Force; Remove-Item $zip -Force"
if errorlevel 1 exit /b 1
)
taskkill /IM SCUMServer.exe /F >nul 2>nul
pushd "%STEAMCMD_DIR%"
"%STEAMCMD_EXE%" %SERVER_STEAMCMD_INSTALL_DIR_ARG% "%SERVER_ROOT%" %SERVER_STEAMCMD_UPDATE_ARGS%
set "SCUM_STEAMCMD_RESULT=%ERRORLEVEL%"
popd
if not "%SCUM_STEAMCMD_RESULT%"=="0" exit /b %SCUM_STEAMCMD_RESULT%
if not exist "%SCUM_EXE%" exit /b 2
exit /b 0
+17
View File
@@ -0,0 +1,17 @@
@echo off
setlocal EnableExtensions
if "%SERVER_ROOT%"=="" set "SERVER_ROOT=C:\scumserver"
if "%SERVER_EXECUTABLE_REF%"=="" set "SERVER_EXECUTABLE_REF=SCUM\Binaries\Win64\SCUMServer.exe"
if "%SERVER_CREATE_GAMEPORT%"=="" set "SERVER_CREATE_GAMEPORT=7779"
if "%SERVER_CREATE_MAXPLAYERS%"=="" set "SERVER_CREATE_MAXPLAYERS=128"
if "%SERVER_LOG_FLAG%"=="" set "SERVER_LOG_FLAG=-log"
set "SCUM_EXE=%SERVER_ROOT%\%SERVER_EXECUTABLE_REF:/=\%"
if not exist "%SCUM_EXE%" exit /b 2
pushd "%SERVER_ROOT%"
"%SCUM_EXE%" -port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG%
set "SCUM_START_RESULT=%ERRORLEVEL%"
popd
exit /b %SCUM_START_RESULT%
@@ -4,9 +4,9 @@ import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMSta
// per server, but a game version never enables or disables a feature. // per server, but a game version never enables or disables a feature.
export const configurationCatalog: readonly SCUMConfigField[] = [ export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "server-name", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" }, { key: "server-name", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" },
{ key: "game-port", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7777", restartImpact: "restart-required" }, { key: "game-port", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" },
{ key: "query-port", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" }, { key: "query-port", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" },
{ key: "max-players", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "64", restartImpact: "restart-required" }, { key: "max-players", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" },
{ key: "welcome-message", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" } { key: "welcome-message", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
]; ];
export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }]; export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }];
@@ -22,9 +22,9 @@
"createFormSchema": "schemas/create-form.schema.json", "createFormSchema": "schemas/create-form.schema.json",
"createFields": [ "createFields": [
{ "key": "serverName", "label": "SCUM 服务器名称", "type": "text", "required": true, "configKey": "serverName" }, { "key": "serverName", "label": "SCUM 服务器名称", "type": "text", "required": true, "configKey": "serverName" },
{ "key": "gamePort", "label": "游戏端口", "type": "port", "required": true, "defaultValue": "7777", "configKey": "gamePort" }, { "key": "gamePort", "label": "游戏端口", "type": "port", "required": true, "defaultValue": "7779", "configKey": "gamePort" },
{ "key": "queryPort", "label": "查询端口", "type": "port", "required": true, "defaultValue": "27015", "configKey": "queryPort" }, { "key": "queryPort", "label": "查询端口", "type": "port", "required": true, "defaultValue": "27015", "configKey": "queryPort" },
{ "key": "maxPlayers", "label": "最大玩家数", "type": "number", "required": true, "defaultValue": "64", "configKey": "maxPlayers" } { "key": "maxPlayers", "label": "最大玩家数", "type": "number", "required": true, "defaultValue": "128", "configKey": "maxPlayers" }
] ]
}, },
"capabilities": [ "capabilities": [
@@ -33,7 +33,6 @@
"process.stop", "process.stop",
"process.restart", "process.restart",
"process.status", "process.status",
"deployment.scum.v1",
"files.list", "files.list",
"files.read", "files.read",
"files.patch", "files.patch",
@@ -360,6 +359,15 @@
"restart": "actions/restart.json", "restart": "actions/restart.json",
"status": "actions/status.json" "status": "actions/status.json"
}, },
"assetFiles": [
{ "path": "actions/install.json", "mode": 384 },
{ "path": "actions/start.json", "mode": 384 },
{ "path": "actions/stop.json", "mode": 384 },
{ "path": "actions/restart.json", "mode": 384 },
{ "path": "actions/status.json", "mode": 384 },
{ "path": "bin/scum-install-update.cmd", "mode": 448 },
{ "path": "bin/scum-start.cmd", "mode": 448 }
],
"productionLifecycle": { "productionLifecycle": {
"operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], "operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"],
"dependencyPolicy": "required", "dependencyPolicy": "required",
@@ -370,7 +378,7 @@
"defaultDirectoryKey": "scum-config", "defaultDirectoryKey": "scum-config",
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], "directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
"files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "SCUM Server.log", "kind": "log", "streamKey": "scum.server" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "SCUM Chat.log", "kind": "log", "streamKey": "scum.chat" }], "files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "SCUM Server.log", "kind": "log", "streamKey": "scum.server" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "SCUM Chat.log", "kind": "log", "streamKey": "scum.chat" }],
"configFields": [{ "key": "server-name", "fileKey": "scum-server-settings", "configKey": "ServerName", "label": "服务器名称", "description": "显示在服务器浏览器与玩家连接界面。", "control": "text", "defaultValue": "SCUM Server", "restartImpact": "restart-required" }, { "key": "game-port", "fileKey": "scum-server-settings", "configKey": "GamePort", "label": "游戏端口", "description": "玩家连接所使用的游戏端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "7777", "restartImpact": "restart-required" }, { "key": "query-port", "fileKey": "scum-server-settings", "configKey": "QueryPort", "label": "查询端口", "description": "服务器查询和状态发现所使用的端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "27015", "restartImpact": "restart-required" }, { "key": "max-players", "fileKey": "scum-server-settings", "configKey": "MaxPlayers", "label": "最大玩家数", "description": "允许同时进入服务器的玩家上限。", "control": "number", "minimum": 1, "maximum": 128, "defaultValue": "64", "restartImpact": "restart-required" }, { "key": "welcome-message", "fileKey": "scum-server-settings", "configKey": "WelcomeMessage", "label": "欢迎消息", "description": "登录成功后由已声明的服务器扩展显示给玩家。", "control": "text", "defaultValue": "", "restartImpact": "none" }] "configFields": [{ "key": "server-name", "fileKey": "scum-server-settings", "configKey": "ServerName", "label": "服务器名称", "description": "显示在服务器浏览器与玩家连接界面。", "control": "text", "defaultValue": "SCUM Server", "restartImpact": "restart-required" }, { "key": "game-port", "fileKey": "scum-server-settings", "configKey": "GamePort", "label": "游戏端口", "description": "玩家连接所使用的游戏端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "7779", "restartImpact": "restart-required" }, { "key": "query-port", "fileKey": "scum-server-settings", "configKey": "QueryPort", "label": "查询端口", "description": "服务器查询和状态发现所使用的端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "27015", "restartImpact": "restart-required" }, { "key": "max-players", "fileKey": "scum-server-settings", "configKey": "MaxPlayers", "label": "最大玩家数", "description": "允许同时进入服务器的玩家上限。", "control": "number", "minimum": 1, "maximum": 128, "defaultValue": "128", "restartImpact": "restart-required" }, { "key": "welcome-message", "fileKey": "scum-server-settings", "configKey": "WelcomeMessage", "label": "欢迎消息", "description": "登录成功后由已声明的服务器扩展显示给玩家。", "control": "text", "defaultValue": "", "restartImpact": "none" }]
}, },
"ai": { "ai": {
"purposes": [ "purposes": [
@@ -494,49 +502,6 @@
] ]
} }
], ],
"serverDeployments": [
{
"key": "scum-steamcmd-windows",
"version": "1.0.0",
"supportedTargets": [
{ "os": "windows", "arch": "amd64" }
],
"steamAppId": "3792580",
"executableKey": "scum/server-executable",
"installRootKey": "server/install-root",
"configKey": "scum/server-settings",
"configFormat": "ini",
"prerequisites": [
{ "key": "steamcmd", "kind": "steamcmd" },
{ "key": "vcredist-2012-x86", "kind": "windows-vcredist" },
{ "key": "vcredist-2012-x64", "kind": "windows-vcredist" },
{ "key": "vcredist-2013-x86", "kind": "windows-vcredist" },
{ "key": "vcredist-2013-x64", "kind": "windows-vcredist" },
{ "key": "vcredist-2015-2022-x86", "kind": "windows-vcredist" },
{ "key": "vcredist-2015-2022-x64", "kind": "windows-vcredist" },
{ "key": "directx-jun2010", "kind": "windows-directx" }
],
"configMappings": [
{ "fieldKey": "serverName", "configKey": "server-settings.server-name", "valueType": "text", "required": true },
{ "fieldKey": "gamePort", "configKey": "server-settings.game-port", "valueType": "port", "required": true },
{ "fieldKey": "queryPort", "configKey": "server-settings.query-port", "valueType": "port", "required": true },
{ "fieldKey": "maxPlayers", "configKey": "server-settings.max-players", "valueType": "integer", "required": true }
],
"discoveryMarkers": [
{ "key": "scum-executable", "kind": "file.exists", "targetKey": "scum/server-executable", "required": true },
{ "key": "scum-steam-app", "kind": "steam.app", "targetKey": "server/install-root", "expected": "3792580", "required": true },
{ "key": "scum-config", "kind": "file.exists", "targetKey": "scum/server-settings", "expected": "ServerSettings.ini", "required": true },
{ "key": "scum-game-port", "kind": "port.open", "targetKey": "game-port", "required": true },
{ "key": "scum-query-port", "kind": "port.open", "targetKey": "query-port", "required": true }
],
"verificationChecks": [
{ "key": "executable", "kind": "executable.present", "targetKey": "scum/server-executable", "required": true },
{ "key": "game-port", "kind": "port.bound", "targetKey": "game-port", "required": true },
{ "key": "config", "kind": "config.readable", "targetKey": "scum/server-settings", "required": true },
{ "key": "process", "kind": "process.healthy", "targetKey": "scum/server-executable", "required": true }
]
}
],
"logSources": [ "logSources": [
{ {
"key": "scum-console-stdout", "key": "scum-console-stdout",
@@ -11,7 +11,7 @@
"label": "游戏端口", "label": "游戏端口",
"type": "port", "type": "port",
"required": true, "required": true,
"default": 7777 "default": 7779
}, },
{ {
"key": "queryPort", "key": "queryPort",
@@ -25,7 +25,7 @@
"label": "最大玩家数", "label": "最大玩家数",
"type": "number", "type": "number",
"required": true, "required": true,
"default": 64 "default": 128
} }
] ]
} }
@@ -157,6 +157,12 @@
"status": { "$ref": "#/$defs/relativeJsonRef" } "status": { "$ref": "#/$defs/relativeJsonRef" }
} }
}, },
"assetFiles": {
"type": "array",
"items": { "$ref": "#/$defs/pluginAssetFile" },
"uniqueItems": true,
"maxItems": 64
},
"productionLifecycle": { "productionLifecycle": {
"type": "object", "type": "object",
"required": ["operations", "dependencyPolicy", "approvalRequired"], "required": ["operations", "dependencyPolicy", "approvalRequired"],
@@ -222,6 +228,15 @@
"pluginLogicalDirectory": { "type": "object", "required": ["key", "label", "scope"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 60 }, "scope": { "enum": ["config", "logs"] } } }, "pluginLogicalDirectory": { "type": "object", "required": ["key", "label", "scope"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 60 }, "scope": { "enum": ["config", "logs"] } } },
"pluginLogicalFile": { "type": "object", "required": ["key", "directoryKey", "label", "kind"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "directoryKey": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "kind": { "enum": ["config", "log"] }, "streamKey": { "$ref": "#/$defs/logicalKey" }, "editable": { "type": "boolean" } } }, "pluginLogicalFile": { "type": "object", "required": ["key", "directoryKey", "label", "kind"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "directoryKey": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "kind": { "enum": ["config", "log"] }, "streamKey": { "$ref": "#/$defs/logicalKey" }, "editable": { "type": "boolean" } } },
"pluginConfigField": { "type": "object", "required": ["key", "fileKey", "configKey", "label", "description", "control", "restartImpact"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "fileKey": { "$ref": "#/$defs/logicalKey" }, "configKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$", "maxLength": 120 }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "description": { "type": "string", "minLength": 1, "maxLength": 240 }, "control": { "enum": ["text", "number", "boolean", "port"] }, "minimum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "maximum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "defaultValue": { "type": "string", "maxLength": 120 }, "restartImpact": { "enum": ["none", "restart-required"] } } }, "pluginConfigField": { "type": "object", "required": ["key", "fileKey", "configKey", "label", "description", "control", "restartImpact"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "fileKey": { "$ref": "#/$defs/logicalKey" }, "configKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$", "maxLength": 120 }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "description": { "type": "string", "minLength": 1, "maxLength": 240 }, "control": { "enum": ["text", "number", "boolean", "port"] }, "minimum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "maximum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "defaultValue": { "type": "string", "maxLength": 120 }, "restartImpact": { "enum": ["none", "restart-required"] } } },
"pluginAssetFile": {
"type": "object",
"required": ["path"],
"additionalProperties": false,
"properties": {
"path": { "$ref": "#/$defs/relativePathRef" },
"mode": { "type": "integer", "enum": [384, 448] }
}
},
"pluginCreateField": { "pluginCreateField": {
"type": "object", "type": "object",
"required": ["key", "label", "type"], "required": ["key", "label", "type"],
@@ -378,7 +393,6 @@
"process.restart", "process.restart",
"process.status", "process.status",
"deployment.plan.v1", "deployment.plan.v1",
"deployment.scum.v1",
"config.write", "config.write",
"files.list", "files.list",
"files.read", "files.read",
+65
View File
@@ -134,6 +134,10 @@ function isSafeRelativeJsonRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value); return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
} }
function isSafeRelativePathRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+$/.test(value);
}
function identifierTokens(value: string): string[] { function identifierTokens(value: string): string[] {
return value return value
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
@@ -353,6 +357,57 @@ function referencedLifecycleActions(manifest: unknown): Array<{ action: string;
return [...refs.values()]; return [...refs.values()];
} }
type PluginAssetFileDeclaration = { path?: unknown; mode?: unknown };
function declaredAssetFiles(manifest: unknown): PluginAssetFileDeclaration[] {
if (typeof manifest !== "object" || manifest === null) {
return [];
}
const assetFiles = (manifest as { assetFiles?: unknown }).assetFiles;
return Array.isArray(assetFiles) ? (assetFiles as PluginAssetFileDeclaration[]) : [];
}
function validateManifestAssetFiles(manifest: unknown, manifestDir: string): { errors: string[]; declared: Set<string> } {
const errors: string[] = [];
const declared = new Set<string>();
const root = path.resolve(manifestDir);
for (const [index, file] of declaredAssetFiles(manifest).entries()) {
const location = `manifest.assetFiles[${index}]`;
if (typeof file.path !== "string" || !isSafeRelativePathRef(file.path)) {
errors.push(`${location}.path: unsafe file reference`);
continue;
}
if (declared.has(file.path)) {
errors.push(`${location}.path: duplicate asset file`);
continue;
}
declared.add(file.path);
if (file.mode !== undefined && file.mode !== 0o600 && file.mode !== 0o700) {
errors.push(`${location}.mode: unsafe file mode`);
}
const target = path.resolve(manifestDir, file.path);
const relative = path.relative(root, target);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
errors.push(`${location}.path: file escapes plugin directory`);
continue;
}
if (!fs.existsSync(target)) {
errors.push(`${location}.path: missing file ${file.path}`);
continue;
}
const stat = fs.statSync(target);
if (!stat.isFile() || stat.size > 64 * 1024) {
errors.push(`${location}.path: asset file must be a regular file under 64KiB`);
continue;
}
const body = fs.readFileSync(target);
if (body.includes(0)) {
errors.push(`${location}.path: asset file contains NUL bytes`);
}
}
return { errors, declared };
}
function validateDependencyPlans(manifest: unknown): string[] { function validateDependencyPlans(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) { if (typeof manifest !== "object" || manifest === null) {
return []; return [];
@@ -1187,18 +1242,28 @@ export function validateManifestFile(manifestPath: string): string[] {
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir)); errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
errors.push(...validateRuntimeLogEventCatalog(manifest)); errors.push(...validateRuntimeLogEventCatalog(manifest));
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir)); errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
errors.push(...assetValidation.errors);
for (const declaration of referencedLifecycleActions(manifest)) { for (const declaration of referencedLifecycleActions(manifest)) {
if (!isSafeRelativeJsonRef(declaration.ref)) { if (!isSafeRelativeJsonRef(declaration.ref)) {
errors.push(`lifecycleAction.${declaration.action}: unsafe file reference`); errors.push(`lifecycleAction.${declaration.action}: unsafe file reference`);
continue; continue;
} }
if (!assetValidation.declared.has(declaration.ref)) {
errors.push(`lifecycleAction.${declaration.action}: action file must be declared in manifest.assetFiles`);
}
const actionPath = path.resolve(manifestDir, declaration.ref); const actionPath = path.resolve(manifestDir, declaration.ref);
if (!fs.existsSync(actionPath)) { if (!fs.existsSync(actionPath)) {
errors.push(`lifecycleAction.${declaration.action}: missing file ${declaration.ref}`); errors.push(`lifecycleAction.${declaration.action}: missing file ${declaration.ref}`);
continue; continue;
} }
errors.push(...validateLifecycleActionFile(path.relative(rootDir, actionPath), declaration.action)); errors.push(...validateLifecycleActionFile(path.relative(rootDir, actionPath), declaration.action));
const action = readJson(actionPath);
const executableKey = typeof action === "object" && action !== null ? (action as { executableKey?: unknown }).executableKey : undefined;
if (typeof executableKey === "string" && !assetValidation.declared.has(executableKey)) {
errors.push(`lifecycleAction.${declaration.action}.executableKey: ${executableKey} must be declared in manifest.assetFiles`);
}
} }
if (typeof manifest === "object" && manifest !== null && "server" in manifest) { if (typeof manifest === "object" && manifest !== null && "server" in manifest) {
+16
View File
@@ -22,11 +22,18 @@ export type RunCapability =
| "process.stop" | "process.stop"
| "process.restart" | "process.restart"
| "process.status" | "process.status"
| "deployment.plan.v1"
| "config.write"
| "files.list" | "files.list"
| "files.read" | "files.read"
| "files.write" | "files.write"
| "files.patch" | "files.patch"
| "logs.read" | "logs.read"
| "run.self-update"
| "distribution.build"
| "dependencies.check"
| "dependencies.install"
| "logs.backfill"
| "remote.ftp.read" | "remote.ftp.read"
| "remote.ftp.write" | "remote.ftp.write"
| "remote.rsync.read" | "remote.rsync.read"
@@ -39,6 +46,9 @@ export type RunCapability =
| "remote.run.db.sqlite.query" | "remote.run.db.sqlite.query"
| "remote.run.logs.transfer" | "remote.run.logs.transfer"
| "remote.run.rcon.command" | "remote.run.rcon.command"
| "remote.run.protected.sql"
| "remote.run.protected.rcon"
| "remote.run.program.command"
| "client-manager.deploy" | "client-manager.deploy"
| "client-manager.control" | "client-manager.control"
| "client-manager.update" | "client-manager.update"
@@ -646,6 +656,11 @@ export interface GamePluginBridge {
actions: PluginBridgeAction[]; actions: PluginBridgeAction[];
} }
export interface PluginAssetFile {
path: string;
mode?: 384 | 448;
}
export interface GamePluginManifest { export interface GamePluginManifest {
id: `game.${string}`; id: `game.${string}`;
name: string; name: string;
@@ -666,6 +681,7 @@ export interface GamePluginManifest {
runtimeProfiles?: GamePluginRuntimeProfiles; runtimeProfiles?: GamePluginRuntimeProfiles;
gameClientBridge?: GameClientBridgeManifest; gameClientBridge?: GameClientBridgeManifest;
actions?: GamePluginActions; actions?: GamePluginActions;
assetFiles?: PluginAssetFile[];
productionLifecycle: { productionLifecycle: {
operations: ProductionPluginLifecycleOperation[]; operations: ProductionPluginLifecycleOperation[];
dependencyPolicy: "required" | "optional"; dependencyPolicy: "required" | "optional";
+18 -9
View File
@@ -189,19 +189,28 @@ describe("plugin manifest validation", () => {
} }
}); });
it("declares a frozen SCUM install/adopt template with explicit mapping and verification checks", () => { it("declares SCUM install/update and start lifecycle through plugin assets", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any; const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const template = manifest.runtimeProfiles.serverDeployments[0]; const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any;
expect(template).toMatchObject({ key: "scum-steamcmd-windows", version: "1.0.0", steamAppId: "3792580", configFormat: "ini" }); const assetPaths = manifest.assetFiles.map((file: { path: string }) => file.path);
expect(template.configMappings.map((mapping: any) => mapping.fieldKey)).toEqual(["serverName", "gamePort", "queryPort", "maxPlayers"]); const installAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.install), "utf8"));
expect(template.verificationChecks.filter((check: any) => check.required)).toHaveLength(4); const startAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.start), "utf8"));
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(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd"]));
expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 validate +quit" } });
expect(startAction).toMatchObject({ executableKey: "bin/scum-start.cmd", environment: { SERVER_LOG_FLAG: "-log" } });
expect(installScript).toContain("taskkill /IM SCUMServer.exe /F");
expect(installScript).toContain("%SERVER_STEAMCMD_INSTALL_DIR_ARG% \"%SERVER_ROOT%\" %SERVER_STEAMCMD_UPDATE_ARGS%");
expect(startScript).toContain("-port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG%");
}); });
it("rejects an SCUM template mapping an undeclared field", () => { it("rejects lifecycle executable assets missing from the manifest seed declaration", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => { const errors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.runtimeProfiles.serverDeployments[0].configMappings[0].fieldKey = "hostCommand"; manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "bin/scum-start.cmd");
}); });
expect(errors.some((error) => error.includes("configMappings") && error.includes("fieldKey"))).toBe(true); expect(errors.some((error) => error.includes("lifecycleAction.start.executableKey") && error.includes("manifest.assetFiles"))).toBe(true);
}); });
it("rejects unsupported or unsafe inline create-field declarations", () => { it("rejects unsupported or unsafe inline create-field declarations", () => {
+24 -2
View File
@@ -635,9 +635,18 @@ printf 'validating plugin manifests\n'
node - "$ROOT_DIR/plugins/examples/dev-game-plugin/manifest.json" "$WORK_DIR/register-plugin.request.json" <<'NODE' node - "$ROOT_DIR/plugins/examples/dev-game-plugin/manifest.json" "$WORK_DIR/register-plugin.request.json" <<'NODE'
const fs = require("fs"); const fs = require("fs");
const path = require("path");
const manifestPath = process.argv[2]; const manifestPath = process.argv[2];
const outputPath = process.argv[3]; const outputPath = process.argv[3];
const source = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const source = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifestDir = path.dirname(manifestPath);
function readAssetFiles(manifest) {
return (manifest.assetFiles ?? []).map((file) => ({
path: file.path,
mode: file.mode,
content: fs.readFileSync(path.join(manifestDir, file.path), "utf8")
}));
}
const manifest = { const manifest = {
id: source.id, id: source.id,
name: source.name, name: source.name,
@@ -659,6 +668,7 @@ const manifest = {
stop: source.actions.stop, stop: source.actions.stop,
restart: source.actions.restart restart: source.actions.restart
}, },
assetFiles: source.assetFiles,
pages: [ pages: [
{ {
key: "logs", key: "logs",
@@ -686,7 +696,8 @@ const manifest = {
}; };
fs.writeFileSync(outputPath, JSON.stringify({ fs.writeFileSync(outputPath, JSON.stringify({
manifestRef: "artifact://manifests/game.example/0.1.0", manifestRef: "artifact://manifests/game.example/0.1.0",
manifest manifest,
assetFiles: readAssetFiles(source)
}, null, 2)); }, null, 2));
NODE NODE
@@ -696,9 +707,18 @@ reject_forbidden_fragments "$WORK_DIR/register-plugin.response.json"
node - "$ROOT_DIR/plugins/examples/scum-server-plugin/manifest.json" "$WORK_DIR/register-scum-plugin.request.json" <<'NODE' node - "$ROOT_DIR/plugins/examples/scum-server-plugin/manifest.json" "$WORK_DIR/register-scum-plugin.request.json" <<'NODE'
const fs = require("fs"); const fs = require("fs");
const path = require("path");
const manifestPath = process.argv[2]; const manifestPath = process.argv[2];
const outputPath = process.argv[3]; const outputPath = process.argv[3];
const source = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const source = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifestDir = path.dirname(manifestPath);
function readAssetFiles(manifest) {
return (manifest.assetFiles ?? []).map((file) => ({
path: file.path,
mode: file.mode,
content: fs.readFileSync(path.join(manifestDir, file.path), "utf8")
}));
}
const localRunCapabilities = [ const localRunCapabilities = [
"process.install", "process.install",
"process.start", "process.start",
@@ -742,6 +762,7 @@ const manifest = {
capabilities: localRunCapabilities, capabilities: localRunCapabilities,
permissions: source.permissions, permissions: source.permissions,
actions: source.actions, actions: source.actions,
assetFiles: source.assetFiles,
pages: source.pages, pages: source.pages,
bridge: source.bridge, bridge: source.bridge,
ai: source.ai, ai: source.ai,
@@ -752,7 +773,8 @@ const manifest = {
}; };
fs.writeFileSync(outputPath, JSON.stringify({ fs.writeFileSync(outputPath, JSON.stringify({
manifestRef: "artifact://manifests/game.scum/0.1.0", manifestRef: "artifact://manifests/game.scum/0.1.0",
manifest manifest,
assetFiles: readAssetFiles(source)
}, null, 2)); }, null, 2));
NODE NODE