Move SCUM lifecycle ownership to plugin
This commit is contained in:
@@ -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.
|
||||||
+42
@@ -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.
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 == ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
instance.DeploymentProjection = domain.ServerDeploymentProjection{}
|
||||||
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.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -359,12 +328,11 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
|||||||
IdempotencyKey: idempotencyKey,
|
IdempotencyKey: idempotencyKey,
|
||||||
Progress: lifecycleJobProgress(instance.Deployment),
|
Progress: lifecycleJobProgress(instance.Deployment),
|
||||||
ExecutionInput: domain.JobExecutionInput{
|
ExecutionInput: domain.JobExecutionInput{
|
||||||
WorkspaceScope: profileKey,
|
WorkspaceScope: profileKey,
|
||||||
PluginID: plugin.ID,
|
PluginID: plugin.ID,
|
||||||
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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
printf 'dev server started\n'
|
||||||
+3
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user