Complete SCUM controlled deployment lifecycle

This commit is contained in:
npc0-hue
2026-07-25 10:13:53 +08:00
parent 6cfd929f7f
commit 15789e15b4
28 changed files with 1349 additions and 123 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-25
@@ -0,0 +1,63 @@
## Context
`ServerDeploymentDefinition` currently carries generic mode, paths, commands, and create inputs. `dispatchLifecycleJob` forwards that definition, while `projectLifecycleJobResult` only projects a generic ready/running/stopped state. The SCUM manifest has a SteamCMD app id and create fields, but no contract tying those fields to a real SCUM config file or verification evidence. The Run executor is a separate repository, so Platform must define a complete, bounded wire contract without pretending to implement host execution locally.
## Goals / Non-Goals
**Goals:**
- Make install and adoption separate operations with authoritative Run preflight.
- Freeze a SCUM template into each leased job so plugin changes cannot mutate an in-flight deployment.
- Return safe, structured scan/config/verification projections that the future UI can render.
- Keep all raw paths, commands, credentials, and sockets inside Run; public Platform views expose only configured flags, logical keys, facts, and stable error codes.
- Preserve idempotency, retry, cancellation, and channel separation already used by lifecycle jobs.
**Non-Goals:**
- Implementing SteamCMD, SCUM binaries, Windows process control, or filesystem scanning in this repository.
- Allowing arbitrary plugin commands, arbitrary config paths, or direct browser-to-Run access.
- Replacing the existing generic lifecycle protocol for non-SCUM plugins.
## Decisions
### 1. Add a typed SCUM deployment template to runtime profiles
Add `serverDeployments` to `GamePluginRuntimeProfiles`. A profile contains a stable key/version, target OS/architecture, Steam app id, executable marker, install root logical key, config file logical key, field-to-config mappings, discovery markers, and verification checks. The SCUM manifest declares one Windows template for app `3792580` and explicit mappings for `serverName`, `gamePort`, `queryPort`, and `maxPlayers`.
This is preferred over teaching Run to infer SCUM behavior from `createInputs` or free-form action JSON. Inference would make the same directory mean different things in different versions and would be impossible to audit.
### 2. Freeze the template into the leased assignment
`JobExecutionInput` gains a `ServerDeploymentPlan` containing `schemaVersion`, operation (`install` or `adopt`), template identity, and bounded template data. The existing protected `ServerDeploymentDefinition` remains the source of operator paths/commands. DTO conversion exposes the plan only in `RunJobAssignmentResponse.executionInput`, never in browser-facing job or server responses.
### 3. Return bounded lifecycle evidence through the terminal result
`JobExecutionResult` gains an optional `ServerDeploymentEvidence` object. It records preflight checks, discovery facts (executable/version/ports/config/log markers as logical names), config mapping outcomes, verification checks, and a stable failure code. Values are bounded and path/command redacted. Platform persists a safe projection on `ServerInstance` and only projects `ready` when the required verification checks pass.
This uses the existing job result channel so control, logs, and artifacts remain independent. Large scan output or logs use existing artifact/log channels and are referenced, not inlined.
### 4. Use explicit install/adopt operation semantics
Guided install dispatches `install`: preflight → SteamCMD install → config materialization → launch/status health checks. Existing-server adoption dispatches `adopt`: preflight → scan → optional reviewed config mapping → status health checks. A failed scan never silently falls back to install, and a failed verification projects `failed` with an actionable code.
### 5. Persist safe projection, not host evidence
Add a `ServerDeploymentProjection` to `ServerInstance` with state, operation, template key/version, safe discovered facts, mapping status, verification status, failure code, and timestamps. No raw path, command, credential, process id, or socket appears in the projection or DTO.
## Risks / Trade-offs
- [Run is in an independent repository] -> Version the contract and add fixture-based Platform tests; mark real execution dependent on the coordinated Run implementation.
- [Existing installations have different config layouts] -> Discovery reports marker confidence and config mapping status; adoption requires explicit operator approval when mapping is incomplete.
- [Evidence can grow large] -> Enforce bounded counts/lengths and use artifact references for detailed reports.
- [Old Run versions cannot understand the plan] -> Gate dispatch on a new capability string and reject before changing server state.
## Migration Plan
1. Ship additive domain/DTO/protocol types and manifest declarations; old plugins continue using the legacy lifecycle path.
2. Gate SCUM controlled install/adopt on `deployment.scum.v1` capability. Existing generic `deployment.plan.v1` remains valid for other plugins.
3. Coordinate the independent Run implementation and enable the capability only after its contract tests pass.
4. Update the frontend to render the persisted projection and evidence; rollback leaves legacy deployments untouched.
## Open Questions
- The exact SCUM config filename can vary by distribution. The first template uses the known `ServerSettings.ini` logical marker and allows Run to report a discovered alternate marker without exposing its absolute path; a later template version can add mappings without changing the Platform projection.
@@ -0,0 +1,28 @@
## Why
The current SCUM workflow treats a directory and a few generic fields as if they were a complete installation. That cannot distinguish a new SteamCMD install from adoption of an existing server, cannot prove that SCUM configuration was materialized, and marks a job ready without a game-specific health check.
## What Changes
- Add a versioned SCUM deployment template that declares the Steam app, supported target, executable markers, config file mappings, discovery markers, and post-install verification checks.
- Extend the Platform-to-Run job contract with bounded preflight, install/scan projection, config mapping, and verification evidence; keep protected paths and commands Run-local.
- Persist a safe deployment projection for operators: preflight state, discovered runtime facts, config mapping status, verification state, and stable failure codes without raw host paths or command text.
- Make SCUM install and adopt operations explicit and idempotent. A new install must run install and configure phases; adoption must scan before any managed write.
- Add contract tests and manifest validation for the complete SCUM lifecycle. The UI will consume these projections after the backend contract is in place.
## Capabilities
### New Capabilities
- `scum-deployment-lifecycle`: Game-specific controlled install/adopt plans, scan projections, config mappings, and post-install verification.
### Modified Capabilities
- `run-job-channel`: Leased deployment jobs carry a validated SCUM template and return bounded lifecycle evidence.
## Impact
- Affects `platform/domain`, `platform/dto`, `platform/service`, `platform/validator`, and `platform/protocol`.
- Affects `plugins/manifests/game-plugin.manifest.schema.json`, SCUM manifest assets, and plugin validation tests.
- Requires a coordinated implementation in the independent Run repository before real machines can execute the new template; this repository provides the authoritative contract and safe projection.
- Does not add billing, cloud host sales, direct SSH, raw host paths to browser/plugin responses, or a Run source tree here.
@@ -0,0 +1,59 @@
## ADDED Requirements
### Requirement: SCUM deployments use a versioned game template
The Platform SHALL register a versioned SCUM server deployment template that declares the Steam app id, compatible target, executable marker, install root logical key, config file logical key, supported field mappings, discovery markers, and required verification checks. A leased install or adoption job MUST freeze the template version used for that job.
#### Scenario: Guided SCUM install template
- **WHEN** an operator creates a SCUM server with guided installation on a compatible Windows Run node
- **THEN** Platform dispatches an `install` job with the frozen SCUM template, Steam app `3792580`, and mappings for the declared SCUM fields
- **AND** the assignment contains no raw browser credential or direct socket
#### Scenario: Unsupported Run is rejected
- **WHEN** the selected Run node does not advertise `deployment.scum.v1`
- **THEN** Platform rejects the deployment before changing the instance to installing
- **AND** the response identifies the missing capability without exposing host details
### Requirement: New install and existing-server adoption are distinct
The Platform SHALL dispatch guided installation and existing-server adoption as different operations. Installation SHALL perform preflight, SteamCMD install, configuration materialization, and health verification. Adoption SHALL perform preflight and discovery first, and SHALL NOT reinstall or overwrite existing configuration without an explicit approved mapping request.
#### Scenario: Adoption discovers an existing server
- **WHEN** an operator chooses existing-server adoption
- **THEN** Run scans the selected logical server root and returns bounded executable, version, port, config-marker, and log-marker facts
- **AND** Platform persists those facts as a safe deployment projection
#### Scenario: Adoption scan fails
- **WHEN** the scan cannot identify a compatible SCUM executable or required marker
- **THEN** the instance remains failed or draft with a stable failure code
- **AND** Platform does not silently switch to install
### Requirement: SCUM configuration mappings are explicit and reviewable
The SCUM template SHALL map only declared create fields to known logical configuration keys. Run SHALL report each mapping as `applied`, `unchanged`, `skipped`, or `failed` with a bounded reason code. Platform SHALL require successful required mappings before reporting an install as ready.
#### Scenario: Materialize SCUM settings
- **WHEN** a new SCUM install completes SteamCMD setup with valid inputs
- **THEN** Run applies `serverName`, `gamePort`, `queryPort`, and `maxPlayers` through the frozen mappings
- **AND** the evidence reports the mapping outcomes without returning the absolute config path
#### Scenario: Unsupported field is submitted
- **WHEN** a create request includes a field not present in the template mapping
- **THEN** Platform rejects the request before dispatch
### Requirement: Installation completion requires verification evidence
The Platform SHALL accept SCUM installation as ready only when Run returns successful required checks for executable presence/version, configured ports, config readability, and process health. A terminal success without required evidence SHALL be rejected as an invalid result.
#### Scenario: Health verification succeeds
- **WHEN** Run reports all required SCUM checks as passed
- **THEN** Platform projects the instance to ready after install, or running after start
- **AND** the deployment projection records the verification timestamp and template version
#### Scenario: Verification fails
- **WHEN** any required check fails
- **THEN** Platform projects the job as failed with a stable error code and keeps the raw diagnostic local to Run
### Requirement: Deployment evidence is safe for browser projection
Public server, job, marketplace, and plugin bridge responses SHALL expose only bounded logical facts and configured/reviewable state. They MUST NOT expose raw host paths, command text, credentials, process ids, or direct sockets.
#### Scenario: Operator reads deployment status
- **WHEN** an operator opens a SCUM deployment status view
- **THEN** the response includes operation, template version, discovery/mapping/verification states, and safe failure code
- **AND** it omits the supplied server root, working directory, install/start/stop commands, and any secret material
@@ -0,0 +1,23 @@
## 1. Contract and domain model
- [x] 1.1 Add typed SCUM deployment templates, config mappings, discovery markers, verification checks, and safe deployment projections to `platform/domain`.
- [x] 1.2 Extend Run assignment/result DTOs and conversion with versioned deployment plans and bounded evidence; keep public projections redacted.
- [x] 1.3 Add validator rules and capability gating for `deployment.scum.v1`, bounded evidence, operation-specific requirements, and required verification checks.
## 2. Platform lifecycle behavior
- [x] 2.1 Freeze the selected SCUM template into install/adopt jobs and distinguish `install` from `adopt` dispatch semantics.
- [x] 2.2 Persist Run evidence into the safe server deployment projection and gate ready/running state on required verification.
- [x] 2.3 Add server deployment status DTO/API projection for preflight, scan, mapping, verification, and stable failure codes.
## 3. SCUM plugin assets
- [x] 3.1 Extend the manifest schema and domain conversion for `serverDeployments`.
- [x] 3.2 Declare the SCUM Windows SteamCMD template, executable/config markers, field mappings, adoption scan markers, and post-install checks in the first-party manifest.
- [x] 3.3 Add manifest validation and fixture tests for the SCUM template and unsafe-value rejection.
## 4. Verification and handoff
- [x] 4.1 Add Platform unit/contract tests covering install/adopt semantics, target/capability rejection, mapping validation, evidence projection, and redaction.
- [x] 4.2 Add protocol documentation and Run coordination notes for the independent executor implementation.
- [x] 4.3 Run Go tests, plugin typecheck/test/manifest validation, `scripts/check-structure.sh`, and `openspec validate complete-scum-deployment-lifecycle --strict`.
+135
View File
@@ -521,11 +521,52 @@ type RuntimeConfigTemplate struct {
OutputRef string OutputRef string
} }
// RuntimeServerConfigMapping maps one safe plugin create field to a logical
// game configuration key. Paths and file syntax remain Run-owned.
type RuntimeServerConfigMapping struct {
FieldKey string
ConfigKey string
ValueType string
Required bool
}
type RuntimeServerDiscoveryMarker struct {
Key string
Kind string
TargetKey string
Expected string
Required bool
}
type RuntimeServerVerificationCheck struct {
Key string
Kind string
TargetKey string
Required bool
}
// RuntimeServerDeploymentProfile is a frozen, game-specific deployment
// template. It contains logical references only; Run resolves them locally.
type RuntimeServerDeploymentProfile struct {
Key string
Version string
SupportedTargets []RuntimeTarget
SteamAppID string
ExecutableKey string
InstallRootKey string
ConfigKey string
ConfigFormat string
ConfigMappings []RuntimeServerConfigMapping
DiscoveryMarkers []RuntimeServerDiscoveryMarker
VerificationChecks []RuntimeServerVerificationCheck
}
type GamePluginRuntimeProfiles struct { type GamePluginRuntimeProfiles struct {
Discovery []RuntimeDiscoveryProbe Discovery []RuntimeDiscoveryProbe
LifecycleProfiles []RuntimeLifecycleProfile LifecycleProfiles []RuntimeLifecycleProfile
DependencyProbes []RuntimeDependencyProbe DependencyProbes []RuntimeDependencyProbe
InstallPlans []RuntimeInstallPlan InstallPlans []RuntimeInstallPlan
ServerDeployments []RuntimeServerDeploymentProfile
LogSources []RuntimeLogSource LogSources []RuntimeLogSource
LogEvents []RuntimeLogEvent LogEvents []RuntimeLogEvent
TransportProfiles []RuntimeTransportProfile TransportProfiles []RuntimeTransportProfile
@@ -695,6 +736,23 @@ type ServerInstance struct {
// Deployment stores operator-supplied deployment inputs. Its protected path // Deployment stores operator-supplied deployment inputs. Its protected path
// and command values are never included in normal platform read projections. // and command values are never included in normal platform read projections.
Deployment ServerDeploymentDefinition Deployment ServerDeploymentDefinition
DeploymentProjection ServerDeploymentProjection
}
type ServerDeploymentProjection struct {
State string
Operation string
TemplateKey string
TemplateVersion string
PreflightState string
DiscoveryState string
MappingState string
VerificationState string
DiscoveredFacts map[string]string
MappingResults map[string]string
VerificationResults map[string]string
FailureCode string
UpdatedAt time.Time
} }
type ServerInstanceUpdate struct { type ServerInstanceUpdate struct {
@@ -803,6 +861,7 @@ type ServerDeploymentView struct {
Shell ServerCommandShell Shell ServerCommandShell
Revision int Revision int
UpdatedAt time.Time UpdatedAt time.Time
Projection ServerDeploymentProjection
} }
type ConfigDiffLine struct { type ConfigDiffLine struct {
@@ -914,6 +973,7 @@ 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"
@@ -959,6 +1019,36 @@ type JobExecutionInput struct {
DLLExtensions []RuntimeDLLExtensionPlan DLLExtensions []RuntimeDLLExtensionPlan
SourceRCON *RuntimeSourceRCONPlan SourceRCON *RuntimeSourceRCONPlan
Deployment *ServerDeploymentDefinition Deployment *ServerDeploymentDefinition
ServerDeploymentPlan *ServerDeploymentPlan
}
type ServerDeploymentPlan struct {
SchemaVersion string
Operation string
PluginID string
TemplateKey string
TemplateVersion string
SteamAppID string
ExecutableKey string
InstallRootKey string
ConfigKey string
ConfigFormat string
ConfigMappings []RuntimeServerConfigMapping
DiscoveryMarkers []RuntimeServerDiscoveryMarker
VerificationChecks []RuntimeServerVerificationCheck
}
type ServerDeploymentEvidence struct {
TemplateKey string
TemplateVersion string
PreflightState string
DiscoveryState string
MappingState string
VerificationState string
DiscoveredFacts map[string]string
MappingResults map[string]string
VerificationResults map[string]string
FailureCode string
} }
type JobExecutionResult struct { type JobExecutionResult struct {
@@ -971,6 +1061,7 @@ type JobExecutionResult struct {
SizeBytes int64 SizeBytes int64
AuditSummary string AuditSummary string
Content string Content string
ServerDeploymentEvidence *ServerDeploymentEvidence
} }
type Job struct { type Job struct {
@@ -1570,6 +1661,10 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
profiles.InstallPlans[i].Platforms = CopyStringSlice(profiles.InstallPlans[i].Platforms) profiles.InstallPlans[i].Platforms = CopyStringSlice(profiles.InstallPlans[i].Platforms)
profiles.InstallPlans[i].Steps = append([]RuntimeInstallStep(nil), profiles.InstallPlans[i].Steps...) profiles.InstallPlans[i].Steps = append([]RuntimeInstallStep(nil), profiles.InstallPlans[i].Steps...)
} }
profiles.ServerDeployments = append([]RuntimeServerDeploymentProfile(nil), profiles.ServerDeployments...)
for i := range profiles.ServerDeployments {
profiles.ServerDeployments[i] = CopyRuntimeServerDeploymentProfile(profiles.ServerDeployments[i])
}
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...) profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
profiles.LogEvents = append([]RuntimeLogEvent(nil), profiles.LogEvents...) profiles.LogEvents = append([]RuntimeLogEvent(nil), profiles.LogEvents...)
profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...) profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...)
@@ -1637,9 +1732,47 @@ func CopyPluginBridgeExecuteResponse(response PluginBridgeExecuteResponse) Plugi
func CopyServerInstance(instance ServerInstance) ServerInstance { func CopyServerInstance(instance ServerInstance) ServerInstance {
instance.AdminUserIDs = CopyStringSlice(instance.AdminUserIDs) instance.AdminUserIDs = CopyStringSlice(instance.AdminUserIDs)
instance.Deployment = CopyServerDeploymentDefinition(instance.Deployment) instance.Deployment = CopyServerDeploymentDefinition(instance.Deployment)
instance.DeploymentProjection = CopyServerDeploymentProjection(instance.DeploymentProjection)
return instance return instance
} }
func CopyRuntimeServerDeploymentProfile(profile RuntimeServerDeploymentProfile) RuntimeServerDeploymentProfile {
profile.SupportedTargets = append([]RuntimeTarget(nil), profile.SupportedTargets...)
profile.ConfigMappings = append([]RuntimeServerConfigMapping(nil), profile.ConfigMappings...)
profile.DiscoveryMarkers = append([]RuntimeServerDiscoveryMarker(nil), profile.DiscoveryMarkers...)
profile.VerificationChecks = append([]RuntimeServerVerificationCheck(nil), profile.VerificationChecks...)
return profile
}
func CopyServerDeploymentProjection(projection ServerDeploymentProjection) ServerDeploymentProjection {
projection.DiscoveredFacts = CopyStringMap(projection.DiscoveredFacts)
projection.MappingResults = CopyStringMap(projection.MappingResults)
projection.VerificationResults = CopyStringMap(projection.VerificationResults)
return projection
}
func CopyServerDeploymentPlan(plan *ServerDeploymentPlan) *ServerDeploymentPlan {
if plan == nil {
return nil
}
copy := *plan
copy.ConfigMappings = append([]RuntimeServerConfigMapping(nil), plan.ConfigMappings...)
copy.DiscoveryMarkers = append([]RuntimeServerDiscoveryMarker(nil), plan.DiscoveryMarkers...)
copy.VerificationChecks = append([]RuntimeServerVerificationCheck(nil), plan.VerificationChecks...)
return &copy
}
func CopyServerDeploymentEvidence(evidence *ServerDeploymentEvidence) *ServerDeploymentEvidence {
if evidence == nil {
return nil
}
copy := *evidence
copy.DiscoveredFacts = CopyStringMap(evidence.DiscoveredFacts)
copy.MappingResults = CopyStringMap(evidence.MappingResults)
copy.VerificationResults = CopyStringMap(evidence.VerificationResults)
return &copy
}
func CopyServerDeploymentDefinition(definition ServerDeploymentDefinition) ServerDeploymentDefinition { func CopyServerDeploymentDefinition(definition ServerDeploymentDefinition) ServerDeploymentDefinition {
definition.RuntimeBindings = CopyStringMap(definition.RuntimeBindings) definition.RuntimeBindings = CopyStringMap(definition.RuntimeBindings)
definition.CreateInputs = CopyStringMap(definition.CreateInputs) definition.CreateInputs = CopyStringMap(definition.CreateInputs)
@@ -1713,6 +1846,8 @@ func CopyJob(job Job) Job {
job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs) job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs)
job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...) job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)
job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON) job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON)
job.ExecutionInput.ServerDeploymentPlan = CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)
job.ExecutionResult.ServerDeploymentEvidence = CopyServerDeploymentEvidence(job.ExecutionResult.ServerDeploymentEvidence)
if job.ExecutionInput.Deployment != nil { if job.ExecutionInput.Deployment != nil {
copy := CopyServerDeploymentDefinition(*job.ExecutionInput.Deployment) copy := CopyServerDeploymentDefinition(*job.ExecutionInput.Deployment)
job.ExecutionInput.Deployment = &copy job.ExecutionInput.Deployment = &copy
+7
View File
@@ -111,6 +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
profiles are versioned, freeze into a leased Run assignment, and contain only
logical executable/config markers, field mappings, discovery markers, and
verification checks. A server's `DeploymentProjection` stores bounded evidence
from Run for operator views; protected paths, commands, credentials, process
ids, and sockets never enter that projection.
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.
## Runtime Component Keys And Distributions ## Runtime Component Keys And Distributions
+64 -2
View File
@@ -107,6 +107,36 @@ type RunJobExecutionInputBody struct {
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"` DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"` SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"`
Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"` Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"`
ServerDeploymentPlan *ServerDeploymentPlanBody `json:"serverDeploymentPlan,omitempty"`
}
type ServerDeploymentPlanBody struct {
SchemaVersion string `json:"schemaVersion"`
Operation string `json:"operation"`
PluginID string `json:"pluginId"`
TemplateKey string `json:"templateKey"`
TemplateVersion string `json:"templateVersion"`
SteamAppID string `json:"steamAppId"`
ExecutableKey string `json:"executableKey"`
InstallRootKey string `json:"installRootKey"`
ConfigKey string `json:"configKey"`
ConfigFormat string `json:"configFormat"`
ConfigMappings []RuntimeServerConfigMappingBody `json:"configMappings"`
DiscoveryMarkers []RuntimeServerDiscoveryMarkerBody `json:"discoveryMarkers"`
VerificationChecks []RuntimeServerVerificationCheckBody `json:"verificationChecks"`
}
type ServerDeploymentEvidenceBody struct {
TemplateKey string `json:"templateKey,omitempty"`
TemplateVersion string `json:"templateVersion,omitempty"`
PreflightState string `json:"preflightState,omitempty"`
DiscoveryState string `json:"discoveryState,omitempty"`
MappingState string `json:"mappingState,omitempty"`
VerificationState string `json:"verificationState,omitempty"`
DiscoveredFacts map[string]string `json:"discoveredFacts,omitempty"`
MappingResults map[string]string `json:"mappingResults,omitempty"`
VerificationResults map[string]string `json:"verificationResults,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
} }
// ServerDeploymentExecutionBody is included only in a leased Run assignment. // ServerDeploymentExecutionBody is included only in a leased Run assignment.
@@ -145,6 +175,7 @@ type RunJobExecutionResultBody struct {
SizeBytes int64 `json:"sizeBytes,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"`
AuditSummary string `json:"auditSummary,omitempty"` AuditSummary string `json:"auditSummary,omitempty"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
} }
type RunJobResultResponse struct { type RunJobResultResponse struct {
@@ -377,7 +408,7 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
Message: request.Message, Message: request.Message,
ErrorCode: request.ErrorCode, ErrorCode: request.ErrorCode,
Retryable: request.Retryable, Retryable: request.Retryable,
ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content}, ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence)},
} }
} }
@@ -583,7 +614,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
State: assignment.State, State: assignment.State,
Progress: progressReportFromDomain(assignment.Progress), Progress: progressReportFromDomain(assignment.Progress),
ResultRef: assignment.ResultRef, ResultRef: assignment.ResultRef,
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment)}, ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)},
LeaseToken: assignment.LeaseToken, LeaseToken: assignment.LeaseToken,
Attempt: assignment.Attempt, Attempt: assignment.Attempt,
MaxAttempts: assignment.MaxAttempts, MaxAttempts: assignment.MaxAttempts,
@@ -596,6 +627,37 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
} }
} }
func serverDeploymentPlanFromDomain(plan *domain.ServerDeploymentPlan) *ServerDeploymentPlanBody {
if plan == nil {
return nil
}
body := &ServerDeploymentPlanBody{SchemaVersion: plan.SchemaVersion, Operation: plan.Operation, PluginID: plan.PluginID, TemplateKey: plan.TemplateKey, TemplateVersion: plan.TemplateVersion, SteamAppID: plan.SteamAppID, ExecutableKey: plan.ExecutableKey, InstallRootKey: plan.InstallRootKey, ConfigKey: plan.ConfigKey, ConfigFormat: plan.ConfigFormat}
for _, mapping := range plan.ConfigMappings {
body.ConfigMappings = append(body.ConfigMappings, RuntimeServerConfigMappingBody{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required})
}
for _, marker := range plan.DiscoveryMarkers {
body.DiscoveryMarkers = append(body.DiscoveryMarkers, RuntimeServerDiscoveryMarkerBody{Key: marker.Key, Kind: marker.Kind, TargetKey: marker.TargetKey, Expected: marker.Expected, Required: marker.Required})
}
for _, check := range plan.VerificationChecks {
body.VerificationChecks = append(body.VerificationChecks, RuntimeServerVerificationCheckBody{Key: check.Key, Kind: check.Kind, TargetKey: check.TargetKey, Required: check.Required})
}
return body
}
func serverDeploymentEvidenceToDomain(body *ServerDeploymentEvidenceBody) *domain.ServerDeploymentEvidence {
if body == nil {
return nil
}
return &domain.ServerDeploymentEvidence{TemplateKey: body.TemplateKey, TemplateVersion: body.TemplateVersion, PreflightState: body.PreflightState, DiscoveryState: body.DiscoveryState, MappingState: body.MappingState, VerificationState: body.VerificationState, DiscoveredFacts: domain.CopyStringMap(body.DiscoveredFacts), MappingResults: domain.CopyStringMap(body.MappingResults), VerificationResults: domain.CopyStringMap(body.VerificationResults), FailureCode: body.FailureCode}
}
func serverDeploymentEvidenceFromDomain(evidence *domain.ServerDeploymentEvidence) *ServerDeploymentEvidenceBody {
if evidence == nil {
return nil
}
return &ServerDeploymentEvidenceBody{TemplateKey: evidence.TemplateKey, TemplateVersion: evidence.TemplateVersion, PreflightState: evidence.PreflightState, DiscoveryState: evidence.DiscoveryState, MappingState: evidence.MappingState, VerificationState: evidence.VerificationState, DiscoveredFacts: domain.CopyStringMap(evidence.DiscoveredFacts), MappingResults: domain.CopyStringMap(evidence.MappingResults), VerificationResults: domain.CopyStringMap(evidence.VerificationResults), FailureCode: evidence.FailureCode}
}
func deploymentExecutionFromDomain(definition *domain.ServerDeploymentDefinition) *ServerDeploymentExecutionBody { func deploymentExecutionFromDomain(definition *domain.ServerDeploymentDefinition) *ServerDeploymentExecutionBody {
if definition == nil { if definition == nil {
return nil return nil
+4 -1
View File
@@ -506,6 +506,7 @@ type ServerInstanceResponse struct {
ConfigUpdatedAt *time.Time `json:"configUpdatedAt,omitempty"` ConfigUpdatedAt *time.Time `json:"configUpdatedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
DeploymentProjection ServerDeploymentProjectionBody `json:"deploymentProjection,omitempty"`
} }
type ServerInstanceListResponse struct { type ServerInstanceListResponse struct {
@@ -716,6 +717,7 @@ type JobExecutionResultResponse struct {
Checksum string `json:"checksum,omitempty"` Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"`
AuditSummary string `json:"auditSummary,omitempty"` AuditSummary string `json:"auditSummary,omitempty"`
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
} }
type JobListResponse struct { type JobListResponse struct {
@@ -1454,6 +1456,7 @@ func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstanceResp
ConfigUpdatedAt: optionalTime(instance.ConfigUpdatedAt), ConfigUpdatedAt: optionalTime(instance.ConfigUpdatedAt),
CreatedAt: instance.CreatedAt, CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt, UpdatedAt: instance.UpdatedAt,
DeploymentProjection: deploymentProjectionFromDomain(instance.DeploymentProjection),
} }
} }
@@ -1618,7 +1621,7 @@ func JobFromDomain(job domain.Job) JobResponse {
State: job.State, State: job.State,
Progress: progressFromDomain(job.Progress), Progress: progressFromDomain(job.Progress),
ResultRef: job.ResultRef, ResultRef: job.ResultRef,
ExecutionResult: JobExecutionResultResponse{Kind: job.ExecutionResult.Kind, ProcessState: job.ExecutionResult.ProcessState, ExitClassification: job.ExecutionResult.ExitClassification, ExitCode: job.ExecutionResult.ExitCode, Version: job.ExecutionResult.Version, Checksum: job.ExecutionResult.Checksum, SizeBytes: job.ExecutionResult.SizeBytes, AuditSummary: job.ExecutionResult.AuditSummary}, ExecutionResult: JobExecutionResultResponse{Kind: job.ExecutionResult.Kind, ProcessState: job.ExecutionResult.ProcessState, ExitClassification: job.ExecutionResult.ExitClassification, ExitCode: job.ExecutionResult.ExitCode, Version: job.ExecutionResult.Version, Checksum: job.ExecutionResult.Checksum, SizeBytes: job.ExecutionResult.SizeBytes, AuditSummary: job.ExecutionResult.AuditSummary, ServerDeploymentEvidence: serverDeploymentEvidenceFromDomain(job.ExecutionResult.ServerDeploymentEvidence)},
RetryPolicy: JobRetryPolicyResponse{ RetryPolicy: JobRetryPolicyResponse{
MaxAttempts: job.RetryPolicy.MaxAttempts, MaxAttempts: job.RetryPolicy.MaxAttempts,
InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds, InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds,
+70
View File
@@ -58,6 +58,42 @@ type RuntimeInstallPlanBody struct {
Steps []RuntimeInstallStepBody `json:"steps"` Steps []RuntimeInstallStepBody `json:"steps"`
} }
type RuntimeServerConfigMappingBody struct {
FieldKey string `json:"fieldKey"`
ConfigKey string `json:"configKey"`
ValueType string `json:"valueType"`
Required bool `json:"required,omitempty"`
}
type RuntimeServerDiscoveryMarkerBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey"`
Expected string `json:"expected,omitempty"`
Required bool `json:"required,omitempty"`
}
type RuntimeServerVerificationCheckBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey"`
Required bool `json:"required,omitempty"`
}
type RuntimeServerDeploymentProfileBody struct {
Key string `json:"key"`
Version string `json:"version"`
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
SteamAppID string `json:"steamAppId"`
ExecutableKey string `json:"executableKey"`
InstallRootKey string `json:"installRootKey"`
ConfigKey string `json:"configKey"`
ConfigFormat string `json:"configFormat"`
ConfigMappings []RuntimeServerConfigMappingBody `json:"configMappings"`
DiscoveryMarkers []RuntimeServerDiscoveryMarkerBody `json:"discoveryMarkers"`
VerificationChecks []RuntimeServerVerificationCheckBody `json:"verificationChecks"`
}
type RuntimeLogSourceBody struct { type RuntimeLogSourceBody struct {
Key string `json:"key"` Key string `json:"key"`
Kind string `json:"kind"` Kind string `json:"kind"`
@@ -217,6 +253,7 @@ type GamePluginRuntimeProfilesBody struct {
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"` LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"` DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"` InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"` LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"` TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
@@ -232,6 +269,7 @@ type GamePluginRuntimeProfilesResponseBody struct {
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"` LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"` DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"` InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"` LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"` TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
@@ -257,6 +295,22 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
} }
profiles.InstallPlans = append(profiles.InstallPlans, plan) profiles.InstallPlans = append(profiles.InstallPlans, plan)
} }
for _, item := range body.ServerDeployments {
profile := domain.RuntimeServerDeploymentProfile{Key: item.Key, Version: item.Version, SteamAppID: item.SteamAppID, ExecutableKey: item.ExecutableKey, InstallRootKey: item.InstallRootKey, ConfigKey: item.ConfigKey, ConfigFormat: item.ConfigFormat}
for _, target := range item.SupportedTargets {
profile.SupportedTargets = append(profile.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch})
}
for _, mapping := range item.ConfigMappings {
profile.ConfigMappings = append(profile.ConfigMappings, domain.RuntimeServerConfigMapping{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required})
}
for _, marker := range item.DiscoveryMarkers {
profile.DiscoveryMarkers = append(profile.DiscoveryMarkers, domain.RuntimeServerDiscoveryMarker{Key: marker.Key, Kind: marker.Kind, TargetKey: marker.TargetKey, Expected: marker.Expected, Required: marker.Required})
}
for _, check := range item.VerificationChecks {
profile.VerificationChecks = append(profile.VerificationChecks, domain.RuntimeServerVerificationCheck{Key: check.Key, Kind: check.Kind, TargetKey: check.TargetKey, Required: check.Required})
}
profiles.ServerDeployments = append(profiles.ServerDeployments, profile)
}
for _, item := range body.LogSources { for _, item := range body.LogSources {
profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays}) profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
} }
@@ -314,6 +368,22 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
} }
body.InstallPlans = append(body.InstallPlans, plan) body.InstallPlans = append(body.InstallPlans, plan)
} }
for _, item := range profiles.ServerDeployments {
bodyProfile := RuntimeServerDeploymentProfileBody{Key: item.Key, Version: item.Version, SteamAppID: item.SteamAppID, ExecutableKey: item.ExecutableKey, InstallRootKey: item.InstallRootKey, ConfigKey: item.ConfigKey, ConfigFormat: item.ConfigFormat}
for _, target := range item.SupportedTargets {
bodyProfile.SupportedTargets = append(bodyProfile.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch})
}
for _, mapping := range item.ConfigMappings {
bodyProfile.ConfigMappings = append(bodyProfile.ConfigMappings, RuntimeServerConfigMappingBody{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required})
}
for _, marker := range item.DiscoveryMarkers {
bodyProfile.DiscoveryMarkers = append(bodyProfile.DiscoveryMarkers, RuntimeServerDiscoveryMarkerBody{Key: marker.Key, Kind: marker.Kind, TargetKey: marker.TargetKey, Expected: marker.Expected, Required: marker.Required})
}
for _, check := range item.VerificationChecks {
bodyProfile.VerificationChecks = append(bodyProfile.VerificationChecks, RuntimeServerVerificationCheckBody{Key: check.Key, Kind: check.Kind, TargetKey: check.TargetKey, Required: check.Required})
}
body.ServerDeployments = append(body.ServerDeployments, bodyProfile)
}
for _, item := range profiles.LogSources { for _, item := range profiles.LogSources {
body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays}) body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
} }
+22 -1
View File
@@ -35,6 +35,23 @@ type ServerDeploymentResponse struct {
Shell domain.ServerCommandShell `json:"shell,omitempty"` Shell domain.ServerCommandShell `json:"shell,omitempty"`
Revision int `json:"revision"` Revision int `json:"revision"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Projection ServerDeploymentProjectionBody `json:"projection,omitempty"`
}
type ServerDeploymentProjectionBody struct {
State string `json:"state,omitempty"`
Operation string `json:"operation,omitempty"`
TemplateKey string `json:"templateKey,omitempty"`
TemplateVersion string `json:"templateVersion,omitempty"`
PreflightState string `json:"preflightState,omitempty"`
DiscoveryState string `json:"discoveryState,omitempty"`
MappingState string `json:"mappingState,omitempty"`
VerificationState string `json:"verificationState,omitempty"`
DiscoveredFacts map[string]string `json:"discoveredFacts,omitempty"`
MappingResults map[string]string `json:"mappingResults,omitempty"`
VerificationResults map[string]string `json:"verificationResults,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
} }
type ServerLifecycleCreateRequest struct { type ServerLifecycleCreateRequest struct {
@@ -85,7 +102,11 @@ func (request ServerDeploymentRequest) deploymentDefinition() domain.ServerDeplo
} }
func ServerDeploymentFromDomain(view domain.ServerDeploymentView) ServerDeploymentResponse { func ServerDeploymentFromDomain(view domain.ServerDeploymentView) ServerDeploymentResponse {
return ServerDeploymentResponse{ServerInstanceID: view.ServerInstanceID, Mode: view.Mode, ProfileKey: view.ProfileKey, CreateInputs: domain.CopyStringMap(view.CreateInputs), ServerRootConfigured: view.ServerRootConfigured, WorkingDirectoryConfigured: view.WorkingDirectoryConfigured, InstallCommandConfigured: view.InstallCommandConfigured, StartCommandConfigured: view.StartCommandConfigured, StopCommandConfigured: view.StopCommandConfigured, StatusCommandConfigured: view.StatusCommandConfigured, Shell: view.Shell, Revision: view.Revision, UpdatedAt: optionalTime(view.UpdatedAt)} return ServerDeploymentResponse{ServerInstanceID: view.ServerInstanceID, Mode: view.Mode, ProfileKey: view.ProfileKey, CreateInputs: domain.CopyStringMap(view.CreateInputs), ServerRootConfigured: view.ServerRootConfigured, WorkingDirectoryConfigured: view.WorkingDirectoryConfigured, InstallCommandConfigured: view.InstallCommandConfigured, StartCommandConfigured: view.StartCommandConfigured, StopCommandConfigured: view.StopCommandConfigured, StatusCommandConfigured: view.StatusCommandConfigured, Shell: view.Shell, Revision: view.Revision, UpdatedAt: optionalTime(view.UpdatedAt), Projection: deploymentProjectionFromDomain(view.Projection)}
}
func deploymentProjectionFromDomain(projection domain.ServerDeploymentProjection) ServerDeploymentProjectionBody {
return ServerDeploymentProjectionBody{State: projection.State, Operation: projection.Operation, TemplateKey: projection.TemplateKey, TemplateVersion: projection.TemplateVersion, PreflightState: projection.PreflightState, DiscoveryState: projection.DiscoveryState, MappingState: projection.MappingState, VerificationState: projection.VerificationState, DiscoveredFacts: domain.CopyStringMap(projection.DiscoveredFacts), MappingResults: domain.CopyStringMap(projection.MappingResults), VerificationResults: domain.CopyStringMap(projection.VerificationResults), FailureCode: projection.FailureCode, UpdatedAt: optionalTime(projection.UpdatedAt)}
} }
func (request ServerLifecycleCommandRequest) ToDomain(serverInstanceID string) domain.ServerLifecycleCommand { func (request ServerLifecycleCommandRequest) ToDomain(serverInstanceID string) domain.ServerLifecycleCommand {
+25 -2
View File
@@ -5,6 +5,14 @@ execute a protected server deployment plan. Platform only sends the plan in a
leased `RunJobAssignmentResponse.executionInput.deployment`; it never appears leased `RunJobAssignmentResponse.executionInput.deployment`; it never appears
in public server, job, audit, log, or plugin-bridge responses. in public server, job, audit, log, or plugin-bridge responses.
SCUM controlled deployments additionally require `deployment.scum.v1`. The
leased assignment includes `executionInput.serverDeploymentPlan` with
`schemaVersion: "1"`, an operation of `install` or `adopt`, the frozen template
key/version, Steam app id, logical executable/config references, explicit field
mappings, discovery markers, and required verification checks. The plan is
secret-free and contains logical keys only; the protected deployment body still
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 `deployment.plan.v1` along with its normal lifecycle
@@ -25,6 +33,21 @@ Before a write, install, or process action, Run validates the selected plan:
- 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
configuration materialization and health checks. For `adopt`, Run performs a
scan first and must not reinstall or overwrite existing configuration. A
controlled SCUM install or adoption requires an explicit protected server root;
the root is never exposed in browser projections. Adoption does not require
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`.
An `existing-server` plan may omit installation. A `custom-command` plan An `existing-server` plan may omit installation. A `custom-command` plan
requires a start command. Guided templates remain plugin recommendations; requires a start command. Guided templates remain plugin recommendations;
Run owns their local resolution and execution. Run owns their local resolution and execution.
@@ -32,8 +55,8 @@ 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`, `install`, allowed phase vocabulary is `queued`, `claimed`, `preflight`, `scan`, `install`,
`configure`, `start`, and `health`. On failure it reports a stable safe error `configure`, `mapping`, `start`, and `health`. On failure it reports a stable safe error
code and summary such as `working-directory-unavailable`, never the supplied code and summary such as `working-directory-unavailable`, never the supplied
path or command text. path or command text.
+6
View File
@@ -160,6 +160,9 @@ func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (do
if err := svc.updateScheduledJob(job); err != nil { if err := svc.updateScheduledJob(job); err != nil {
return domain.RunJobProgressResult{}, err return domain.RunJobProgressResult{}, err
} }
if err := svc.projectServerDeploymentProgress(job, stamp); err != nil {
return domain.RunJobProgressResult{}, err
}
if err := svc.projectDistributionBuildProgress(job, stamp); err != nil { if err := svc.projectDistributionBuildProgress(job, stamp); err != nil {
return domain.RunJobProgressResult{}, err return domain.RunJobProgressResult{}, err
} }
@@ -266,6 +269,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
} }
func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) error { func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) error {
if job.ExecutionInput.ServerDeploymentPlan != nil {
return validateSCUMDeploymentEvidence(job.ExecutionInput.ServerDeploymentPlan, result)
}
if result.ExecutionResult.Kind == "" { if result.ExecutionResult.Kind == "" {
return nil return nil
} }
+225
View File
@@ -0,0 +1,225 @@
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")
}
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,
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 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)
}
+103
View File
@@ -0,0 +1,103 @@
package service
import (
"testing"
"browser.local/platform/domain"
)
func scumDeploymentTestPlugin() domain.GamePlugin {
return domain.GamePlugin{
ID: "game.scum",
CreateFields: []domain.PluginCreateField{
{Key: "serverName", Type: "text", DefaultValue: "SCUM Test"},
{Key: "gamePort", Type: "port", DefaultValue: "7777"},
{Key: "queryPort", Type: "port", DefaultValue: "27015"},
{Key: "maxPlayers", Type: "number", DefaultValue: "64"},
},
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",
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: "version", Kind: "version.matches", 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}},
}}},
}
}
func TestSCUMDeploymentPlanSeparatesInstallAndAdopt(t *testing.T) {
plugin := scumDeploymentTestPlugin()
definition := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, CreateInputs: map[string]string{"serverName": "Alpha", "gamePort": "7777", "queryPort": "27015", "maxPlayers": "64"}}
plan, err := scumDeploymentPlan(plugin, definition, "install")
if err != nil || plan == nil || plan.Operation != "install" || plan.SteamAppID != "3792580" {
t.Fatalf("unexpected install plan: %+v, %v", plan, err)
}
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) {
plugin := scumDeploymentTestPlugin()
plan, err := scumDeploymentPlan(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided}, "install")
if err != nil {
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 err := validateSCUMDeploymentEvidence(plan, result); err != nil {
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")
}
}
+31
View File
@@ -38,9 +38,13 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
if err != nil { if err != nil {
return domain.ServerDeploymentView{}, err return domain.ServerDeploymentView{}, err
} }
definition = applyPluginCreateDefaults(plugin, definition)
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
@@ -48,6 +52,17 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
instance.RunEndpointID = update.RunEndpointID instance.RunEndpointID = update.RunEndpointID
} }
instance.Deployment = definition instance.Deployment = definition
operation := "install"
if definition.Mode == domain.ServerDeploymentModeExisting {
operation = "adopt"
}
if plan, planErr := scumDeploymentPlan(plugin, definition, operation); planErr != nil {
return domain.ServerDeploymentView{}, planErr
} else if plan != nil {
instance.DeploymentProjection = scumDeploymentProjection(plan, operation, definition.UpdatedAt)
} else {
instance.DeploymentProjection = domain.ServerDeploymentProjection{}
}
instance.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
@@ -85,9 +100,16 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command
if err != nil { if err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment)
if !containsString(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) { if !containsString(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) {
return domain.ServerLifecycleResult{}, validationError("run endpoint missing required capability: deployment.plan.v1") return domain.ServerLifecycleResult{}, validationError("run endpoint missing required capability: deployment.plan.v1")
} }
if scumDeploymentCapabilityRequired(plugin, instance.Deployment) && !containsString(endpoint.Capabilities, domain.JobCapabilitySCUMDeploymentPlan) {
return domain.ServerLifecycleResult{}, validationError("run endpoint missing required capability: deployment.scum.v1")
}
if err := validateSCUMDeploymentTarget(plugin, instance.Deployment, endpoint); err != nil {
return domain.ServerLifecycleResult{}, err
}
if requiredShellCapability := deploymentShellCapability(instance.Deployment.Shell); requiredShellCapability != "" && !containsString(endpoint.Capabilities, requiredShellCapability) { if requiredShellCapability := deploymentShellCapability(instance.Deployment.Shell); requiredShellCapability != "" && !containsString(endpoint.Capabilities, requiredShellCapability) {
return domain.ServerLifecycleResult{}, validationError("run endpoint policy does not allow selected command shell") return domain.ServerLifecycleResult{}, validationError("run endpoint policy does not allow selected command shell")
} }
@@ -97,6 +119,9 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); 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
} }
@@ -123,6 +148,11 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command
} }
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
} }
@@ -185,5 +215,6 @@ func deploymentView(instance domain.ServerInstance) domain.ServerDeploymentView
return domain.CopyServerDeploymentView(domain.ServerDeploymentView{ return domain.CopyServerDeploymentView(domain.ServerDeploymentView{
ServerInstanceID: instance.ID, Mode: definition.Mode, ProfileKey: definition.ProfileKey, CreateInputs: domain.CopyStringMap(definition.CreateInputs), ServerInstanceID: instance.ID, Mode: definition.Mode, ProfileKey: definition.ProfileKey, CreateInputs: domain.CopyStringMap(definition.CreateInputs),
ServerRootConfigured: definition.ServerRoot != "", WorkingDirectoryConfigured: definition.WorkingDirectory != "", InstallCommandConfigured: definition.InstallCommand != "", StartCommandConfigured: definition.StartCommand != "", StopCommandConfigured: definition.StopCommand != "", StatusCommandConfigured: definition.StatusCommand != "", Shell: definition.Shell, Revision: definition.Revision, UpdatedAt: definition.UpdatedAt, ServerRootConfigured: definition.ServerRoot != "", WorkingDirectoryConfigured: definition.WorkingDirectory != "", InstallCommandConfigured: definition.InstallCommand != "", StartCommandConfigured: definition.StartCommand != "", StopCommandConfigured: definition.StopCommand != "", StatusCommandConfigured: definition.StatusCommand != "", Shell: definition.Shell, Revision: definition.Revision, UpdatedAt: definition.UpdatedAt,
Projection: instance.DeploymentProjection,
}) })
} }
+36
View File
@@ -25,6 +25,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
if plugin.Status != domain.GamePluginStatusInstalled { if plugin.Status != domain.GamePluginStatusInstalled {
return domain.ServerLifecycleResult{}, validationError("plugin must be installed") return domain.ServerLifecycleResult{}, validationError("plugin must be installed")
} }
create.Deployment = applyPluginCreateDefaults(plugin, create.Deployment)
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil { if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
@@ -32,6 +33,9 @@ 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()
@@ -54,6 +58,15 @@ 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))
@@ -80,9 +93,20 @@ 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 instance.Deployment.Mode != "" && !containsString(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) { if instance.Deployment.Mode != "" && !containsString(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) {
return domain.ServerLifecycleResult{}, validationError("run endpoint missing required capability: deployment.plan.v1") return domain.ServerLifecycleResult{}, validationError("run endpoint missing required capability: deployment.plan.v1")
} }
if scumDeploymentCapabilityRequired(plugin, instance.Deployment) && !containsString(endpoint.Capabilities, domain.JobCapabilitySCUMDeploymentPlan) {
return domain.ServerLifecycleResult{}, validationError("run endpoint missing required capability: deployment.scum.v1")
}
if err := validateSCUMDeploymentTarget(plugin, instance.Deployment, endpoint); err != nil {
return domain.ServerLifecycleResult{}, err
}
if requiredShellCapability := deploymentShellCapability(instance.Deployment.Shell); requiredShellCapability != "" && !containsString(endpoint.Capabilities, requiredShellCapability) { if requiredShellCapability := deploymentShellCapability(instance.Deployment.Shell); requiredShellCapability != "" && !containsString(endpoint.Capabilities, requiredShellCapability) {
return domain.ServerLifecycleResult{}, validationError("run endpoint policy does not allow selected command shell") return domain.ServerLifecycleResult{}, validationError("run endpoint policy does not allow selected command shell")
} }
@@ -284,6 +308,17 @@ 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,
@@ -298,6 +333,7 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
LifecycleOperation: lifecycleExecutionOperation(action), LifecycleOperation: lifecycleExecutionOperation(action),
DLLExtensions: dllExtensions, DLLExtensions: dllExtensions,
Deployment: deploymentPlanForDispatch(instance.Deployment), Deployment: deploymentPlanForDispatch(instance.Deployment),
ServerDeploymentPlan: serverDeploymentPlan,
}, },
}) })
if err != nil { if err != nil {
@@ -58,6 +58,31 @@ 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 {
@@ -73,6 +98,36 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
return svc.recordAuditEvent("run:"+job.RunEndpointID, "lifecycle.result", "server-instance", instance.ID, auditResult, job.Progress.Message) return svc.recordAuditEvent("run:"+job.RunEndpointID, "lifecycle.result", "server-instance", instance.ID, auditResult, job.Progress.Message)
} }
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
plan := job.ExecutionInput.ServerDeploymentPlan
if plan == nil || job.ServerInstanceID == "" {
return nil
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil {
return err
}
projection := domain.CopyServerDeploymentProjection(instance.DeploymentProjection)
projection.State = "running"
projection.Operation = plan.Operation
projection.TemplateKey = plan.TemplateKey
projection.TemplateVersion = plan.TemplateVersion
switch job.Progress.Phase {
case "preflight":
projection.PreflightState = "running"
case "scan", "discover", "discovery":
projection.DiscoveryState = "running"
case "install", "configure", "mapping":
projection.MappingState = "running"
case "start", "health":
projection.VerificationState = "running"
}
projection.UpdatedAt = stamp
instance.DeploymentProjection = projection
instance.UpdatedAt = stamp
return svc.store.ServerInstances().Update(instance)
}
func lifecycleProjectedState(capability string, jobState domain.JobState) (domain.ServerInstanceState, bool) { func lifecycleProjectedState(capability string, jobState domain.JobState) (domain.ServerInstanceState, bool) {
if capability != domain.LifecycleCapabilityInstall && capability != domain.LifecycleCapabilityStart && capability != domain.LifecycleCapabilityStop { if capability != domain.LifecycleCapabilityInstall && capability != domain.LifecycleCapabilityStart && capability != domain.LifecycleCapabilityStop {
return "", false return "", false
+1 -1
View File
@@ -1757,7 +1757,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan, 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",
+88
View File
@@ -29,6 +29,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
discoveryKeys := map[string]struct{}{} discoveryKeys := map[string]struct{}{}
dependencyKeys := map[string]struct{}{} dependencyKeys := map[string]struct{}{}
installPlanKeys := map[string]struct{}{} installPlanKeys := map[string]struct{}{}
serverDeploymentKeys := map[string]struct{}{}
logSourceKeys := map[string]struct{}{} logSourceKeys := map[string]struct{}{}
logSourceRetentions := map[string]int{} logSourceRetentions := map[string]int{}
logEventKeys := map[string]struct{}{} logEventKeys := map[string]struct{}{}
@@ -150,6 +151,76 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
} }
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", plan.Platforms)...) violations = append(violations, validateRuntimePlatforms(prefix+".platforms", plan.Platforms)...)
} }
for i, profile := range profiles.ServerDeployments {
prefix := fmt.Sprintf("runtimeProfiles.serverDeployments[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", profile.Key)...)
violations = append(violations, recordRuntimeProfileKey(serverDeploymentKeys, prefix+".key", profile.Key)...)
if !validSemanticVersion(profile.Version) {
violations = append(violations, prefix+".version must be semantic")
}
if !regexp.MustCompile(`^[0-9]{1,12}$`).MatchString(profile.SteamAppID) {
violations = append(violations, prefix+".steamAppId must be numeric")
}
for field, value := range map[string]string{"executableKey": profile.ExecutableKey, "installRootKey": profile.InstallRootKey, "configKey": profile.ConfigKey} {
violations = append(violations, validateProfileKey(prefix+"."+field, value)...)
}
if profile.ConfigFormat != "ini" && profile.ConfigFormat != "json" && profile.ConfigFormat != "yaml" && profile.ConfigFormat != "properties" {
violations = append(violations, prefix+".configFormat is invalid")
}
if len(profile.SupportedTargets) == 0 {
violations = append(violations, prefix+".supportedTargets must not be empty")
}
for j, target := range profile.SupportedTargets {
if !validPluginSupportedOS(target.OS) || !oneOf(target.Arch, "amd64", "arm64") {
violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is invalid", prefix, j))
}
}
mappingKeys := map[string]struct{}{}
for j, mapping := range profile.ConfigMappings {
mappingPrefix := fmt.Sprintf("%s.configMappings[%d]", prefix, j)
if !regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._/-]{0,79}$`).MatchString(mapping.FieldKey) {
violations = append(violations, mappingPrefix+".fieldKey is invalid")
}
violations = append(violations, validateProfileKey(mappingPrefix+".configKey", mapping.ConfigKey)...)
if _, exists := mappingKeys[mapping.FieldKey]; exists {
violations = append(violations, mappingPrefix+".fieldKey duplicates another mapping")
}
mappingKeys[mapping.FieldKey] = struct{}{}
if !oneOf(mapping.ValueType, "text", "integer", "number", "boolean", "port") {
violations = append(violations, mappingPrefix+".valueType is invalid")
}
}
markerKeys := map[string]struct{}{}
for j, marker := range profile.DiscoveryMarkers {
markerPrefix := fmt.Sprintf("%s.discoveryMarkers[%d]", prefix, j)
violations = append(violations, validateProfileKey(markerPrefix+".key", marker.Key)...)
violations = append(violations, validateProfileKey(markerPrefix+".targetKey", marker.TargetKey)...)
if _, exists := markerKeys[marker.Key]; exists {
violations = append(violations, markerPrefix+".key duplicates another marker")
}
markerKeys[marker.Key] = struct{}{}
if !oneOf(marker.Kind, "file.exists", "command.version", "port.open", "steam.app") {
violations = append(violations, markerPrefix+".kind is invalid")
}
violations = append(violations, validateSafeRuntimeValue(markerPrefix+".expected", marker.Expected)...)
}
checkKeys := map[string]struct{}{}
for j, check := range profile.VerificationChecks {
checkPrefix := fmt.Sprintf("%s.verificationChecks[%d]", prefix, j)
violations = append(violations, validateProfileKey(checkPrefix+".key", check.Key)...)
violations = append(violations, validateProfileKey(checkPrefix+".targetKey", check.TargetKey)...)
if _, exists := checkKeys[check.Key]; exists {
violations = append(violations, checkPrefix+".key duplicates another check")
}
checkKeys[check.Key] = struct{}{}
if !oneOf(check.Kind, "executable.present", "version.matches", "port.bound", "config.readable", "process.healthy") {
violations = append(violations, checkPrefix+".kind is invalid")
}
}
if len(profile.VerificationChecks) == 0 || !containsRequiredVerification(profile.VerificationChecks) {
violations = append(violations, prefix+".verificationChecks must include executable, config, port, and process checks")
}
}
for i, source := range profiles.LogSources { for i, source := range profiles.LogSources {
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i) prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...) violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
@@ -396,6 +467,23 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
return finish(violations) return finish(violations)
} }
func containsRequiredVerification(checks []domain.RuntimeServerVerificationCheck) bool {
required := map[string]bool{"executable.present": false, "version.matches": false, "port.bound": false, "config.readable": false, "process.healthy": false}
for _, check := range checks {
if check.Required {
if _, ok := required[check.Kind]; ok {
required[check.Kind] = true
}
}
}
for _, present := range required {
if !present {
return false
}
}
return true
}
func validateRuntimeDLLExtensionProfile(prefix string, extension domain.RuntimeDLLExtensionProfile) []string { func validateRuntimeDLLExtensionProfile(prefix string, extension domain.RuntimeDLLExtensionProfile) []string {
var violations []string var violations []string
if extension.Kind != "ue4ss-dll" || extension.Activation != "server-start" { if extension.Kind != "ue4ss-dll" || extension.Activation != "server-start" {
+33
View File
@@ -259,6 +259,20 @@ export interface RuntimeInstallPlanResponse {
steps: Array<{ type: string; targetKey: string; packageManager?: string; packageName?: string; version?: string; downloadRef?: string; checksum?: string }>; steps: Array<{ type: string; targetKey: string; packageManager?: string; packageName?: string; version?: string; downloadRef?: string; checksum?: string }>;
} }
export interface RuntimeServerDeploymentProfileResponse {
key: string;
version: string;
supportedTargets: Array<{ os: string; arch: string }>;
steamAppId: string;
executableKey: string;
installRootKey: string;
configKey: string;
configFormat: string;
configMappings: Array<{ fieldKey: string; configKey: string; valueType: string; required?: boolean }>;
discoveryMarkers: Array<{ key: string; kind: string; targetKey: string; expected?: string; required?: boolean }>;
verificationChecks: Array<{ key: string; kind: string; targetKey: string; required?: boolean }>;
}
export interface RuntimeLogSourceResponse { export interface RuntimeLogSourceResponse {
key: string; key: string;
kind: string; kind: string;
@@ -325,6 +339,7 @@ export interface GamePluginRuntimeProfilesResponse {
lifecycleProfiles?: RuntimeLifecycleProfileResponse[]; lifecycleProfiles?: RuntimeLifecycleProfileResponse[];
dependencyProbes?: RuntimeDependencyProbeResponse[]; dependencyProbes?: RuntimeDependencyProbeResponse[];
installPlans?: RuntimeInstallPlanResponse[]; installPlans?: RuntimeInstallPlanResponse[];
serverDeployments?: RuntimeServerDeploymentProfileResponse[];
logSources?: RuntimeLogSourceResponse[]; logSources?: RuntimeLogSourceResponse[];
logEvents?: RuntimeLogEventResponse[]; logEvents?: RuntimeLogEventResponse[];
transportProfiles?: RuntimeTransportProfileResponse[]; transportProfiles?: RuntimeTransportProfileResponse[];
@@ -434,6 +449,23 @@ export interface ServerInstanceResponse {
configUpdatedAt?: string; configUpdatedAt?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deploymentProjection?: ServerDeploymentProjectionResponse;
}
export interface ServerDeploymentProjectionResponse {
state?: string;
operation?: string;
templateKey?: string;
templateVersion?: string;
preflightState?: string;
discoveryState?: string;
mappingState?: string;
verificationState?: string;
discoveredFacts?: Record<string, string>;
mappingResults?: Record<string, string>;
verificationResults?: Record<string, string>;
failureCode?: string;
updatedAt?: string;
} }
export interface ServerInstanceUpdateRequest { export interface ServerInstanceUpdateRequest {
@@ -494,6 +526,7 @@ export interface ServerDeploymentResponse {
shell?: ServerCommandShell; shell?: ServerCommandShell;
revision: number; revision: number;
updatedAt?: string; updatedAt?: string;
projection?: ServerDeploymentProjectionResponse;
} }
export interface RuntimeBindingUpdateRequest { export interface RuntimeBindingUpdateRequest {
@@ -36,6 +36,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
const pluginFields = selectedPlugin?.createFields ?? []; const pluginFields = selectedPlugin?.createFields ?? [];
const bindingFields = runtimeBindingFields(selectedPlugin, form.profileKey); const bindingFields = runtimeBindingFields(selectedPlugin, form.profileKey);
const activeServer = kind === "edit" && Boolean(deployment); const activeServer = kind === "edit" && Boolean(deployment);
const isScum = selectedPlugin?.id === "game.scum";
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -63,9 +64,10 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
if (step === 0) return Boolean(form.pluginId && (saveAsDraft || form.runEndpointId)); if (step === 0) return Boolean(form.pluginId && (saveAsDraft || form.runEndpointId));
if (step === 2) { if (step === 2) {
if (kind === "create" && !form.name.trim()) return false; if (kind === "create" && !form.name.trim()) return false;
if (isScum && form.deploymentMode === "guided-install" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false;
if (form.deploymentMode === "existing-server" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false; if (form.deploymentMode === "existing-server" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false;
if (form.deploymentMode === "custom-command" && !form.startCommand.trim() && !deployment?.startCommandConfigured) return false; if (form.deploymentMode === "custom-command" && !form.startCommand.trim() && !deployment?.startCommandConfigured) return false;
return pluginFields.filter((field) => field.required).every((field) => Boolean(form.createInputs[field.key]?.trim())); return form.deploymentMode !== "guided-install" || pluginFields.filter((field) => field.required).every((field) => Boolean(form.createInputs[field.key]?.trim()));
} }
return true; return true;
} }
@@ -87,7 +89,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
<div className="form-grid"><label><select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label><select name="runEndpointId" value={form.runEndpointId} onChange={updateForm} disabled={saveAsDraft} required={!saveAsDraft}><option value=""></option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label></div> <div className="form-grid"><label><select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label><select name="runEndpointId" value={form.runEndpointId} onChange={updateForm} disabled={saveAsDraft} required={!saveAsDraft}><option value=""></option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label></div>
<label className="deployment-draft-choice"><input type="checkbox" checked={saveAsDraft} onChange={(event) => setSaveAsDraft(event.target.checked)} /><span><strong>{kind === "create" ? "仅保存为草稿" : "保持为未绑定草稿"}</strong><small></small></span></label> <label className="deployment-draft-choice"><input type="checkbox" checked={saveAsDraft} onChange={(event) => setSaveAsDraft(event.target.checked)} /><span><strong>{kind === "create" ? "仅保存为草稿" : "保持为未绑定草稿"}</strong><small></small></span></label>
</div>} </div>}
{step === 1 && <div className="deployment-workflow-body"><p className="section-copy"></p><div className="deployment-mode-grid"> {step === 1 && <div className="deployment-workflow-body"><p className="section-copy"></p>{isScum && <div className="form-guidance"><strong>SCUM </strong><span>Run </span></div>}<div className="deployment-mode-grid">
<ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} /> <ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} />
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} /> <ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由节点策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} /> <ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由节点策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
@@ -95,7 +97,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
{step === 2 && <div className="deployment-workflow-body"><div className="form-grid"> {step === 2 && <div className="deployment-workflow-body"><div className="form-grid">
{kind === "create" && <label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label>} {kind === "create" && <label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label>}
{kind === "create" && <label><select name="profileKey" value={form.profileKey} onChange={updateForm}><option value="">使</option>{profileOptions.map((profile) => <option key={profile.key} value={profile.key}>{profile.key} · {profile.mode}</option>)}</select><small className="field-help"></small></label>} {kind === "create" && <label><select name="profileKey" value={form.profileKey} onChange={updateForm}><option value="">使</option>{profileOptions.map((profile) => <option key={profile.key} value={profile.key}>{profile.key} · {profile.mode}</option>)}</select><small className="field-help"></small></label>}
{form.deploymentMode === "guided-install" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "由插件默认位置安装,或填写完整绝对路径"} autoComplete="off" /><small className="field-help"> Run </small></label>} {form.deploymentMode === "guided-install" && <label>{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">SCUM </small></label>}
{form.deploymentMode === "existing-server" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run </small></label>} {form.deploymentMode === "existing-server" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run </small></label>}
{form.deploymentMode === "custom-command" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help"></small></label>} {form.deploymentMode === "custom-command" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help"></small></label>}
{form.deploymentMode === "guided-install" && pluginFields.map((field) => ( {form.deploymentMode === "guided-install" && pluginFields.map((field) => (
@@ -110,7 +112,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
{form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help"></p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label><select name="shell" value={form.shell} onChange={updateForm}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>} {form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help"></p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label><select name="shell" value={form.shell} onChange={updateForm}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>}
{kind === "create" && bindingFields.length > 0 && <details className="provider-advanced-settings"><summary></summary><p className="field-help"></p><div className="form-grid">{bindingFields.map((field) => <label key={field.key}>{field.key}{field.required ? "(必填)" : ""}<input type={field.sensitive ? "password" : "text"} autoComplete="off" value={form.bindings[field.key] ?? ""} onChange={(event) => updateBinding(field.key, event.target.value)} placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"} required={field.required} /></label>)}</div></details>} {kind === "create" && bindingFields.length > 0 && <details className="provider-advanced-settings"><summary></summary><p className="field-help"></p><div className="form-grid">{bindingFields.map((field) => <label key={field.key}>{field.key}{field.required ? "(必填)" : ""}<input type={field.sensitive ? "password" : "text"} autoComplete="off" value={form.bindings[field.key] ?? ""} onChange={(event) => updateBinding(field.key, event.target.value)} placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"} required={field.required} /></label>)}</div></details>}
</div>} </div>}
{step === 3 && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span></span><strong>{saveAsDraft ? "保存为未绑定草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === form.runEndpointId), form.runEndpointId)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}</div><div className="form-guidance"><strong>{saveAsDraft ? "本次只保存定义" : activeServer ? "本次只保存部署设置" : "确认后将创建并派发部署"}</strong><span>{saveAsDraft ? "后续从服务器详情选择节点并部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : activeServer ? "保存后可在详情中明确发起部署;路径和命令不会显示原文。" : "Run 会在领取任务后执行本机预检,再进行安装、配置与启动。"}</span></div></div>} {step === 3 && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span></span><strong>{saveAsDraft ? "保存为未绑定草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === form.runEndpointId), form.runEndpointId)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{saveAsDraft ? "本次只保存定义" : activeServer ? "本次只保存部署设置" : "确认后将创建并派发部署"}</strong><span>{saveAsDraft ? "后续从服务器详情选择节点并部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : activeServer ? "保存后可在详情中明确发起部署;路径和命令不会显示原文。" : isScum ? "Run 只有在 SCUM 配置映射与健康验证通过后才会报告安装成功。" : "Run 会在领取任务后执行本机预检,再进行安装、配置与启动。"}</span></div></div>}
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? onClose() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < workflowSteps.length - 1 ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div> <div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? onClose() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < workflowSteps.length - 1 ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div>
</form> </form>
</ManagementDialog>; </ManagementDialog>;
+1 -1
View File
@@ -179,7 +179,7 @@ describe("first-party console pages", () => {
expect(serverDeploymentWorkflowSource).toContain("仅保存为草稿"); expect(serverDeploymentWorkflowSource).toContain("仅保存为草稿");
expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)"); expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)");
expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录"); expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录");
expect(serverDeploymentWorkflowSource).toContain("安装目录(可选)"); expect(serverDeploymentWorkflowSource).toContain("安装目录{isScum ? \"(必填)\" : \"(可选)\"}");
expect(serverDeploymentWorkflowSource).toContain("已有服务器目录"); expect(serverDeploymentWorkflowSource).toContain("已有服务器目录");
expect(serverDeploymentWorkflowSource).toContain("不会重装或覆盖现有游戏配置"); expect(serverDeploymentWorkflowSource).toContain("不会重装或覆盖现有游戏配置");
expect(serverDeploymentWorkflowSource).toContain("运行连接设置"); expect(serverDeploymentWorkflowSource).toContain("运行连接设置");
+16
View File
@@ -451,14 +451,30 @@ function ServerDeploymentSection({ instance, deployment, onEdit }: ServerDeploym
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />; if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />; if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
const view = deployment.data; const view = deployment.data;
const projection = view.projection;
const isScumTemplate = (instance.pluginId === "game.scum" && (view.mode === "guided-install" || view.mode === "existing-server")) || projection?.templateKey?.startsWith("scum-");
return <article className="console-panel" aria-label="server deployment"> return <article className="console-panel" aria-label="server deployment">
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> </h2><span className="page-status">{view.mode || "未配置"} · {view.revision}</span></div> <div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> </h2><span className="page-status">{view.mode || "未配置"} · {view.revision}</span></div>
<p className="section-copy"></p> <p className="section-copy"></p>
<div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div></div> <div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div></div>
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM </span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span></span><strong>{projection.failureCode}</strong></div>}</div>}
<div className="action-strip" style={{ marginTop: 12 }}><button type="button" className="primary-command" disabled={instance.state === "running" || instance.state === "installing"} onClick={onEdit}><Pencil size={14} /><span></span></button>{(instance.state === "draft" || instance.state === "failed") && <span className="field-help"></span>}</div> <div className="action-strip" style={{ marginTop: 12 }}><button type="button" className="primary-command" disabled={instance.state === "running" || instance.state === "installing"} onClick={onEdit}><Pencil size={14} /><span></span></button>{(instance.state === "draft" || instance.state === "failed") && <span className="field-help"></span>}</div>
</article>; </article>;
} }
function deploymentProjectionLabel(value?: string): string {
switch (value) {
case "queued": return "排队中";
case "running": return "执行中";
case "passed": return "已通过";
case "applied": return "已写入";
case "unchanged": return "未变化";
case "failed": return "失败";
case "skipped": return "已跳过";
default: return "待返回";
}
}
function deploymentWorkflowForm(instance: ServerInstanceResponse, deployment: ServerDeploymentResponse | undefined, plugins: GamePluginResponse[], endpoints: RunEndpointResponse[]): ServerCreateFormState { function deploymentWorkflowForm(instance: ServerInstanceResponse, deployment: ServerDeploymentResponse | undefined, plugins: GamePluginResponse[], endpoints: RunEndpointResponse[]): ServerCreateFormState {
const plugin = plugins.find((item) => item.id === instance.pluginId); const plugin = plugins.find((item) => item.id === instance.pluginId);
return { ...defaultServerCreateForm(plugins, endpoints), name: instance.name, pluginId: instance.pluginId, runEndpointId: instance.runEndpointId, profileKey: deployment?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", createInputs: deployment?.createInputs ?? pluginCreateInputDefaults(plugin), deploymentMode: deployment?.mode ?? "guided-install", shell: deployment?.shell ?? "" }; return { ...defaultServerCreateForm(plugins, endpoints), name: instance.name, pluginId: instance.pluginId, runEndpointId: instance.runEndpointId, profileKey: deployment?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", createInputs: deployment?.createInputs ?? pluginCreateInputDefaults(plugin), deploymentMode: deployment?.mode ?? "guided-install", shell: deployment?.shell ?? "" };
@@ -33,6 +33,7 @@
"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",
@@ -572,6 +573,41 @@
] ]
} }
], ],
"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",
"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-version", "kind": "command.version", "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": "version", "kind": "version.matches", "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-chat-events", "key": "scum-chat-events",
@@ -109,6 +109,12 @@
"items": { "$ref": "#/$defs/runtimeInstallPlan" }, "items": { "$ref": "#/$defs/runtimeInstallPlan" },
"uniqueItems": true "uniqueItems": true
}, },
"serverDeployments": {
"type": "array",
"items": { "$ref": "#/$defs/runtimeServerDeploymentProfile" },
"uniqueItems": true,
"maxItems": 8
},
"logSources": { "logSources": {
"type": "array", "type": "array",
"items": { "$ref": "#/$defs/runtimeLogSource" }, "items": { "$ref": "#/$defs/runtimeLogSource" },
@@ -329,6 +335,8 @@
"process.stop", "process.stop",
"process.restart", "process.restart",
"process.status", "process.status",
"deployment.plan.v1",
"deployment.scum.v1",
"config.write", "config.write",
"files.list", "files.list",
"files.read", "files.read",
@@ -518,6 +526,58 @@
"steps": { "type": "array", "items": { "$ref": "#/$defs/runtimeInstallStep" }, "minItems": 1, "maxItems": 64 } "steps": { "type": "array", "items": { "$ref": "#/$defs/runtimeInstallStep" }, "minItems": 1, "maxItems": 64 }
} }
}, },
"runtimeServerConfigMapping": {
"type": "object",
"required": ["fieldKey", "configKey", "valueType"],
"additionalProperties": false,
"properties": {
"fieldKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._/-]*$", "maxLength": 80 },
"configKey": { "$ref": "#/$defs/logicalKey" },
"valueType": { "enum": ["text", "integer", "number", "boolean", "port"] },
"required": { "type": "boolean" }
}
},
"runtimeServerDiscoveryMarker": {
"type": "object",
"required": ["key", "kind", "targetKey"],
"additionalProperties": false,
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"kind": { "enum": ["file.exists", "command.version", "port.open", "steam.app"] },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"expected": { "type": "string", "maxLength": 120 },
"required": { "type": "boolean" }
}
},
"runtimeServerVerificationCheck": {
"type": "object",
"required": ["key", "kind", "targetKey"],
"additionalProperties": false,
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"kind": { "enum": ["executable.present", "version.matches", "port.bound", "config.readable", "process.healthy"] },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"required": { "type": "boolean" }
}
},
"runtimeServerDeploymentProfile": {
"type": "object",
"required": ["key", "version", "supportedTargets", "steamAppId", "executableKey", "installRootKey", "configKey", "configFormat", "configMappings", "discoveryMarkers", "verificationChecks"],
"additionalProperties": false,
"properties": {
"key": { "$ref": "#/$defs/logicalKey" },
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$" },
"supportedTargets": { "type": "array", "items": { "$ref": "#/$defs/runtimeTarget" }, "minItems": 1, "uniqueItems": true },
"steamAppId": { "type": "string", "pattern": "^[0-9]{1,12}$" },
"executableKey": { "$ref": "#/$defs/logicalKey" },
"installRootKey": { "$ref": "#/$defs/logicalKey" },
"configKey": { "$ref": "#/$defs/logicalKey" },
"configFormat": { "enum": ["ini", "json", "yaml", "properties"] },
"configMappings": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerConfigMapping" }, "minItems": 1, "maxItems": 32, "uniqueItems": true },
"discoveryMarkers": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerDiscoveryMarker" }, "minItems": 1, "maxItems": 32, "uniqueItems": true },
"verificationChecks": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerVerificationCheck" }, "minItems": 5, "maxItems": 16, "uniqueItems": true }
}
},
"runtimeLogSource": { "runtimeLogSource": {
"type": "object", "type": "object",
"required": ["key", "kind", "streamKey"], "required": ["key", "kind", "streamKey"],
+24
View File
@@ -379,6 +379,29 @@ function validateDependencyPlans(manifest: unknown): string[] {
return errors; return errors;
} }
function validateServerDeploymentProfiles(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null || !("server" in manifest)) return [];
const record = manifest as { server?: { createFields?: Array<{ key?: string }> }; runtimeProfiles?: { serverDeployments?: Array<any> } };
const declaredFields = new Set((record.server?.createFields ?? []).map((field) => field.key).filter((key): key is string => Boolean(key)));
const errors: string[] = [];
for (const [index, profile] of (record.runtimeProfiles?.serverDeployments ?? []).entries()) {
const location = `manifest.runtimeProfiles.serverDeployments[${index}]`;
const mappingKeys = new Set<string>();
for (const [mappingIndex, mapping] of (profile.configMappings ?? []).entries()) {
const mappingLocation = `${location}.configMappings[${mappingIndex}]`;
if (!declaredFields.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: must reference a declared server.createFields key`);
if (mappingKeys.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: duplicate mapping`);
mappingKeys.add(mapping.fieldKey);
}
const requiredChecks = new Set(["executable.present", "version.matches", "port.bound", "config.readable", "process.healthy"]);
for (const check of profile.verificationChecks ?? []) {
if (check.required) requiredChecks.delete(check.kind);
}
for (const missing of requiredChecks) errors.push(`${location}.verificationChecks: required check ${missing} is missing`);
}
return errors;
}
function validateClientManagerProfiles(manifest: unknown): string[] { function validateClientManagerProfiles(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) { if (typeof manifest !== "object" || manifest === null) {
return []; return [];
@@ -1124,6 +1147,7 @@ export function validateManifestFile(manifestPath: string): string[] {
errors.push(...scanUnsafeValues(manifest, "manifest")); errors.push(...scanUnsafeValues(manifest, "manifest"));
errors.push(...validateCreateFieldDeclarations(manifest)); errors.push(...validateCreateFieldDeclarations(manifest));
errors.push(...validateDependencyPlans(manifest)); errors.push(...validateDependencyPlans(manifest));
errors.push(...validateServerDeploymentProfiles(manifest));
errors.push(...validateClientManagerProfiles(manifest)); errors.push(...validateClientManagerProfiles(manifest));
errors.push(...validateDLLExtensionProfiles(manifest)); errors.push(...validateDLLExtensionProfiles(manifest));
errors.push(...validateGameClientBridgeCatalog(manifest)); errors.push(...validateGameClientBridgeCatalog(manifest));
+15
View File
@@ -173,6 +173,21 @@ describe("plugin manifest validation", () => {
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]); expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
}); });
it("declares a frozen SCUM install/adopt template with explicit mapping and verification checks", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any;
const template = manifest.runtimeProfiles.serverDeployments[0];
expect(template).toMatchObject({ key: "scum-steamcmd-windows", version: "1.0.0", steamAppId: "3792580", configFormat: "ini" });
expect(template.configMappings.map((mapping: any) => mapping.fieldKey)).toEqual(["serverName", "gamePort", "queryPort", "maxPlayers"]);
expect(template.verificationChecks.filter((check: any) => check.required)).toHaveLength(5);
});
it("rejects an SCUM template mapping an undeclared field", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.runtimeProfiles.serverDeployments[0].configMappings[0].fieldKey = "hostCommand";
});
expect(errors.some((error) => error.includes("configMappings") && error.includes("fieldKey"))).toBe(true);
});
it("rejects unsupported or unsafe inline create-field declarations", () => { it("rejects unsupported or unsafe inline create-field declarations", () => {
const malformed = validateTemporaryScumCompanionManifest((manifest) => { const malformed = validateTemporaryScumCompanionManifest((manifest) => {
manifest.server.createFields[0].type = "path"; manifest.server.createFields[0].type = "path";