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
+159 -24
View File
@@ -521,11 +521,52 @@ type RuntimeConfigTemplate struct {
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 {
Discovery []RuntimeDiscoveryProbe
LifecycleProfiles []RuntimeLifecycleProfile
DependencyProbes []RuntimeDependencyProbe
InstallPlans []RuntimeInstallPlan
ServerDeployments []RuntimeServerDeploymentProfile
LogSources []RuntimeLogSource
LogEvents []RuntimeLogEvent
TransportProfiles []RuntimeTransportProfile
@@ -694,7 +735,24 @@ type ServerInstance struct {
UpdatedAt time.Time
// Deployment stores operator-supplied deployment inputs. Its protected path
// 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 {
@@ -803,6 +861,7 @@ type ServerDeploymentView struct {
Shell ServerCommandShell
Revision int
UpdatedAt time.Time
Projection ServerDeploymentProjection
}
type ConfigDiffLine struct {
@@ -914,6 +973,7 @@ const (
// JobCapabilityDeploymentPlan gates Run implementations that understand
// protected deployment definitions, absolute paths, and custom commands.
JobCapabilityDeploymentPlan = "deployment.plan.v1"
JobCapabilitySCUMDeploymentPlan = "deployment.scum.v1"
JobCapabilityDeploymentShellPosix = "deployment.shell.posix-sh"
JobCapabilityDeploymentShellPowerShell = "deployment.shell.powershell"
JobCapabilityDeploymentShellCmd = "deployment.shell.cmd"
@@ -944,33 +1004,64 @@ type JobRetryPolicy struct {
}
type JobExecutionInput struct {
WorkspaceScope string
Content string
ExpectedVersion int
ExpectedChecksum string
MaxReadBytes int
RemoteAdapterKey string
RemoteAdapterKind string
TimeoutSeconds int
WorkspaceScope string
Content string
ExpectedVersion int
ExpectedChecksum string
MaxReadBytes int
RemoteAdapterKey string
RemoteAdapterKind string
TimeoutSeconds int
PluginID string
LifecycleOperation string
TargetVersion string
Inputs map[string]string
DLLExtensions []RuntimeDLLExtensionPlan
SourceRCON *RuntimeSourceRCONPlan
Deployment *ServerDeploymentDefinition
ServerDeploymentPlan *ServerDeploymentPlan
}
type ServerDeploymentPlan struct {
SchemaVersion string
Operation string
PluginID string
LifecycleOperation string
TargetVersion string
Inputs map[string]string
DLLExtensions []RuntimeDLLExtensionPlan
SourceRCON *RuntimeSourceRCONPlan
Deployment *ServerDeploymentDefinition
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 {
Kind string
ProcessState string
ExitClassification string
ExitCode int
Version int
Checksum string
SizeBytes int64
AuditSummary string
Content string
Kind string
ProcessState string
ExitClassification string
ExitCode int
Version int
Checksum string
SizeBytes int64
AuditSummary string
Content string
ServerDeploymentEvidence *ServerDeploymentEvidence
}
type Job struct {
@@ -1570,6 +1661,10 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
profiles.InstallPlans[i].Platforms = CopyStringSlice(profiles.InstallPlans[i].Platforms)
profiles.InstallPlans[i].Steps = append([]RuntimeInstallStep(nil), profiles.InstallPlans[i].Steps...)
}
profiles.ServerDeployments = append([]RuntimeServerDeploymentProfile(nil), profiles.ServerDeployments...)
for i := range profiles.ServerDeployments {
profiles.ServerDeployments[i] = CopyRuntimeServerDeploymentProfile(profiles.ServerDeployments[i])
}
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
profiles.LogEvents = append([]RuntimeLogEvent(nil), profiles.LogEvents...)
profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...)
@@ -1637,9 +1732,47 @@ func CopyPluginBridgeExecuteResponse(response PluginBridgeExecuteResponse) Plugi
func CopyServerInstance(instance ServerInstance) ServerInstance {
instance.AdminUserIDs = CopyStringSlice(instance.AdminUserIDs)
instance.Deployment = CopyServerDeploymentDefinition(instance.Deployment)
instance.DeploymentProjection = CopyServerDeploymentProjection(instance.DeploymentProjection)
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 {
definition.RuntimeBindings = CopyStringMap(definition.RuntimeBindings)
definition.CreateInputs = CopyStringMap(definition.CreateInputs)
@@ -1713,6 +1846,8 @@ func CopyJob(job Job) Job {
job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs)
job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)
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 {
copy := CopyServerDeploymentDefinition(*job.ExecutionInput.Deployment)
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`.
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.
## Runtime Component Keys And Distributions
+88 -26
View File
@@ -92,21 +92,51 @@ type RunJobResultRequest struct {
}
type RunJobExecutionInputBody struct {
WorkspaceScope string `json:"workspaceScope,omitempty"`
Content string `json:"content,omitempty"`
ExpectedVersion int `json:"expectedVersion,omitempty"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
MaxReadBytes int `json:"maxReadBytes,omitempty"`
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
PluginID string `json:"pluginId,omitempty"`
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
TargetVersion string `json:"targetVersion,omitempty"`
Inputs map[string]string `json:"inputs,omitempty"`
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"`
Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"`
WorkspaceScope string `json:"workspaceScope,omitempty"`
Content string `json:"content,omitempty"`
ExpectedVersion int `json:"expectedVersion,omitempty"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
MaxReadBytes int `json:"maxReadBytes,omitempty"`
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
PluginID string `json:"pluginId,omitempty"`
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
TargetVersion string `json:"targetVersion,omitempty"`
Inputs map[string]string `json:"inputs,omitempty"`
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,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.
@@ -136,15 +166,16 @@ type RuntimeSourceRCONPlanBody struct {
}
type RunJobExecutionResultBody struct {
Kind string `json:"kind,omitempty"`
ProcessState string `json:"processState,omitempty"`
ExitClassification string `json:"exitClassification,omitempty"`
ExitCode int `json:"exitCode,omitempty"`
Version int `json:"version,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
AuditSummary string `json:"auditSummary,omitempty"`
Content string `json:"content,omitempty"`
Kind string `json:"kind,omitempty"`
ProcessState string `json:"processState,omitempty"`
ExitClassification string `json:"exitClassification,omitempty"`
ExitCode int `json:"exitCode,omitempty"`
Version int `json:"version,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
AuditSummary string `json:"auditSummary,omitempty"`
Content string `json:"content,omitempty"`
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
}
type RunJobResultResponse struct {
@@ -377,7 +408,7 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
Message: request.Message,
ErrorCode: request.ErrorCode,
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,
Progress: progressReportFromDomain(assignment.Progress),
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,
Attempt: assignment.Attempt,
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 {
if definition == nil {
return nil
+40 -37
View File
@@ -492,20 +492,21 @@ type ServerMemberListResponse struct {
}
type ServerInstanceResponse struct {
ID string `json:"id"`
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
RunEndpointID string `json:"runEndpointId"`
Name string `json:"name"`
OwnerUserID string `json:"ownerUserId,omitempty"`
AdminUserIDs []string `json:"adminUserIds"`
State domain.ServerInstanceState `json:"state"`
ConfigVersion int `json:"configVersion"`
ConfigKey string `json:"configKey,omitempty"`
ConfigChecksum string `json:"configChecksum,omitempty"`
ConfigUpdatedAt *time.Time `json:"configUpdatedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
RunEndpointID string `json:"runEndpointId"`
Name string `json:"name"`
OwnerUserID string `json:"ownerUserId,omitempty"`
AdminUserIDs []string `json:"adminUserIds"`
State domain.ServerInstanceState `json:"state"`
ConfigVersion int `json:"configVersion"`
ConfigKey string `json:"configKey,omitempty"`
ConfigChecksum string `json:"configChecksum,omitempty"`
ConfigUpdatedAt *time.Time `json:"configUpdatedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeploymentProjection ServerDeploymentProjectionBody `json:"deploymentProjection,omitempty"`
}
type ServerInstanceListResponse struct {
@@ -708,14 +709,15 @@ type JobResponse struct {
}
type JobExecutionResultResponse struct {
Kind string `json:"kind,omitempty"`
ProcessState string `json:"processState,omitempty"`
ExitClassification string `json:"exitClassification,omitempty"`
ExitCode int `json:"exitCode,omitempty"`
Version int `json:"version,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
AuditSummary string `json:"auditSummary,omitempty"`
Kind string `json:"kind,omitempty"`
ProcessState string `json:"processState,omitempty"`
ExitClassification string `json:"exitClassification,omitempty"`
ExitCode int `json:"exitCode,omitempty"`
Version int `json:"version,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
AuditSummary string `json:"auditSummary,omitempty"`
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
}
type JobListResponse struct {
@@ -1440,20 +1442,21 @@ func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstanceResp
adminUserIDs = []string{}
}
return ServerInstanceResponse{
ID: instance.ID,
PluginID: instance.PluginID,
PluginVersion: instance.PluginVersion,
RunEndpointID: instance.RunEndpointID,
Name: instance.Name,
OwnerUserID: instance.OwnerUserID,
AdminUserIDs: adminUserIDs,
State: instance.State,
ConfigVersion: instance.ConfigVersion,
ConfigKey: instance.ConfigKey,
ConfigChecksum: instance.ConfigChecksum,
ConfigUpdatedAt: optionalTime(instance.ConfigUpdatedAt),
CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt,
ID: instance.ID,
PluginID: instance.PluginID,
PluginVersion: instance.PluginVersion,
RunEndpointID: instance.RunEndpointID,
Name: instance.Name,
OwnerUserID: instance.OwnerUserID,
AdminUserIDs: adminUserIDs,
State: instance.State,
ConfigVersion: instance.ConfigVersion,
ConfigKey: instance.ConfigKey,
ConfigChecksum: instance.ConfigChecksum,
ConfigUpdatedAt: optionalTime(instance.ConfigUpdatedAt),
CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt,
DeploymentProjection: deploymentProjectionFromDomain(instance.DeploymentProjection),
}
}
@@ -1618,7 +1621,7 @@ func JobFromDomain(job domain.Job) JobResponse {
State: job.State,
Progress: progressFromDomain(job.Progress),
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{
MaxAttempts: job.RetryPolicy.MaxAttempts,
InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds,
+79 -9
View File
@@ -58,6 +58,42 @@ type RuntimeInstallPlanBody struct {
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 {
Key string `json:"key"`
Kind string `json:"kind"`
@@ -213,15 +249,16 @@ type RuntimeDLLExtensionPlanBody struct {
}
type GamePluginRuntimeProfilesBody struct {
Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"`
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"`
Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"`
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"`
}
// GamePluginRuntimeProfilesResponseBody is intentionally distinct from the
@@ -232,6 +269,7 @@ type GamePluginRuntimeProfilesResponseBody struct {
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
@@ -257,6 +295,22 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
}
profiles.InstallPlans = append(profiles.InstallPlans, plan)
}
for _, item := range body.ServerDeployments {
profile := domain.RuntimeServerDeploymentProfile{Key: item.Key, Version: item.Version, SteamAppID: item.SteamAppID, ExecutableKey: item.ExecutableKey, InstallRootKey: item.InstallRootKey, ConfigKey: item.ConfigKey, ConfigFormat: item.ConfigFormat}
for _, target := range item.SupportedTargets {
profile.SupportedTargets = append(profile.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch})
}
for _, mapping := range item.ConfigMappings {
profile.ConfigMappings = append(profile.ConfigMappings, domain.RuntimeServerConfigMapping{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required})
}
for _, marker := range item.DiscoveryMarkers {
profile.DiscoveryMarkers = append(profile.DiscoveryMarkers, domain.RuntimeServerDiscoveryMarker{Key: marker.Key, Kind: marker.Kind, TargetKey: marker.TargetKey, Expected: marker.Expected, Required: marker.Required})
}
for _, check := range item.VerificationChecks {
profile.VerificationChecks = append(profile.VerificationChecks, domain.RuntimeServerVerificationCheck{Key: check.Key, Kind: check.Kind, TargetKey: check.TargetKey, Required: check.Required})
}
profiles.ServerDeployments = append(profiles.ServerDeployments, profile)
}
for _, item := range body.LogSources {
profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
}
@@ -314,6 +368,22 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
}
body.InstallPlans = append(body.InstallPlans, plan)
}
for _, item := range profiles.ServerDeployments {
bodyProfile := RuntimeServerDeploymentProfileBody{Key: item.Key, Version: item.Version, SteamAppID: item.SteamAppID, ExecutableKey: item.ExecutableKey, InstallRootKey: item.InstallRootKey, ConfigKey: item.ConfigKey, ConfigFormat: item.ConfigFormat}
for _, target := range item.SupportedTargets {
bodyProfile.SupportedTargets = append(bodyProfile.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch})
}
for _, mapping := range item.ConfigMappings {
bodyProfile.ConfigMappings = append(bodyProfile.ConfigMappings, RuntimeServerConfigMappingBody{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required})
}
for _, marker := range item.DiscoveryMarkers {
bodyProfile.DiscoveryMarkers = append(bodyProfile.DiscoveryMarkers, RuntimeServerDiscoveryMarkerBody{Key: marker.Key, Kind: marker.Kind, TargetKey: marker.TargetKey, Expected: marker.Expected, Required: marker.Required})
}
for _, check := range item.VerificationChecks {
bodyProfile.VerificationChecks = append(bodyProfile.VerificationChecks, RuntimeServerVerificationCheckBody{Key: check.Key, Kind: check.Kind, TargetKey: check.TargetKey, Required: check.Required})
}
body.ServerDeployments = append(body.ServerDeployments, bodyProfile)
}
for _, item := range profiles.LogSources {
body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
}
+35 -14
View File
@@ -22,19 +22,36 @@ type ServerDeploymentRequest struct {
}
type ServerDeploymentResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
Mode domain.ServerDeploymentMode `json:"mode,omitempty"`
ProfileKey string `json:"profileKey,omitempty"`
CreateInputs map[string]string `json:"createInputs,omitempty"`
ServerRootConfigured bool `json:"serverRootConfigured"`
WorkingDirectoryConfigured bool `json:"workingDirectoryConfigured"`
InstallCommandConfigured bool `json:"installCommandConfigured"`
StartCommandConfigured bool `json:"startCommandConfigured"`
StopCommandConfigured bool `json:"stopCommandConfigured"`
StatusCommandConfigured bool `json:"statusCommandConfigured"`
Shell domain.ServerCommandShell `json:"shell,omitempty"`
Revision int `json:"revision"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
ServerInstanceID string `json:"serverInstanceId"`
Mode domain.ServerDeploymentMode `json:"mode,omitempty"`
ProfileKey string `json:"profileKey,omitempty"`
CreateInputs map[string]string `json:"createInputs,omitempty"`
ServerRootConfigured bool `json:"serverRootConfigured"`
WorkingDirectoryConfigured bool `json:"workingDirectoryConfigured"`
InstallCommandConfigured bool `json:"installCommandConfigured"`
StartCommandConfigured bool `json:"startCommandConfigured"`
StopCommandConfigured bool `json:"stopCommandConfigured"`
StatusCommandConfigured bool `json:"statusCommandConfigured"`
Shell domain.ServerCommandShell `json:"shell,omitempty"`
Revision int `json:"revision"`
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 {
@@ -85,7 +102,11 @@ func (request ServerDeploymentRequest) deploymentDefinition() domain.ServerDeplo
}
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 {
+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
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
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
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
requires a start command. Guided templates remain plugin recommendations;
Run owns their local resolution and execution.
@@ -32,8 +55,8 @@ Run owns their local resolution and execution.
## Safe progress reports
Run reports bounded progress with `percent`, `phase`, and a safe message. The
allowed phase vocabulary is `queued`, `claimed`, `preflight`, `install`,
`configure`, `start`, and `health`. On failure it reports a stable safe error
allowed phase vocabulary is `queued`, `claimed`, `preflight`, `scan`, `install`,
`configure`, `mapping`, `start`, and `health`. On failure it reports a stable safe error
code and summary such as `working-directory-unavailable`, never the supplied
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 {
return domain.RunJobProgressResult{}, err
}
if err := svc.projectServerDeploymentProgress(job, stamp); err != nil {
return domain.RunJobProgressResult{}, err
}
if err := svc.projectDistributionBuildProgress(job, stamp); err != nil {
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 {
if job.ExecutionInput.ServerDeploymentPlan != nil {
return validateSCUMDeploymentEvidence(job.ExecutionInput.ServerDeploymentPlan, result)
}
if result.ExecutionResult.Kind == "" {
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 {
return domain.ServerDeploymentView{}, err
}
definition = applyPluginCreateDefaults(plugin, definition)
if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, definition.CreateInputs); err != nil {
return domain.ServerDeploymentView{}, err
}
if err := validateScumDeploymentInputs(plugin, definition); err != nil {
return domain.ServerDeploymentView{}, err
}
if update.RunEndpointID != "" {
if _, err := svc.store.RunEndpoints().Get(update.RunEndpointID); err != nil {
return domain.ServerDeploymentView{}, err
@@ -48,6 +52,17 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
instance.RunEndpointID = update.RunEndpointID
}
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
if err := validator.ValidateServerInstance(instance); err != nil {
return domain.ServerDeploymentView{}, err
@@ -85,9 +100,16 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command
if err != nil {
return domain.ServerLifecycleResult{}, err
}
instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment)
if !containsString(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) {
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) {
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 {
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 {
return domain.ServerLifecycleResult{}, err
}
@@ -123,6 +148,11 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command
}
instance.State = domain.ServerInstanceStateInstalling
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 {
return domain.ServerLifecycleResult{}, err
}
@@ -185,5 +215,6 @@ func deploymentView(instance domain.ServerInstance) domain.ServerDeploymentView
return domain.CopyServerDeploymentView(domain.ServerDeploymentView{
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,
Projection: instance.DeploymentProjection,
})
}
+41 -5
View File
@@ -25,6 +25,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
if plugin.Status != domain.GamePluginStatusInstalled {
return domain.ServerLifecycleResult{}, validationError("plugin must be installed")
}
create.Deployment = applyPluginCreateDefaults(plugin, create.Deployment)
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
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 {
return domain.ServerLifecycleResult{}, err
}
if err := validateScumDeploymentInputs(plugin, create.Deployment); err != nil {
return domain.ServerLifecycleResult{}, err
}
}
stamp := svc.now()
@@ -54,6 +58,15 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
instance.Deployment.RuntimeBindings = domain.CopyStringMap(create.Bindings)
instance.Deployment.Revision = maxInt(1, instance.Deployment.Revision)
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.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 {
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) {
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) {
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
}
}
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{
ID: lifecycleJobID(instance.ID, action, idempotencyKey),
ServerInstanceID: instance.ID,
@@ -293,11 +328,12 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
IdempotencyKey: idempotencyKey,
Progress: lifecycleJobProgress(instance.Deployment),
ExecutionInput: domain.JobExecutionInput{
WorkspaceScope: profileKey,
PluginID: plugin.ID,
LifecycleOperation: lifecycleExecutionOperation(action),
DLLExtensions: dllExtensions,
Deployment: deploymentPlanForDispatch(instance.Deployment),
WorkspaceScope: profileKey,
PluginID: plugin.ID,
LifecycleOperation: lifecycleExecutionOperation(action),
DLLExtensions: dllExtensions,
Deployment: deploymentPlanForDispatch(instance.Deployment),
ServerDeploymentPlan: serverDeploymentPlan,
},
})
if err != nil {
@@ -58,6 +58,31 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
if err != nil {
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.UpdatedAt = stamp
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)
}
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) {
if capability != domain.LifecycleCapabilityInstall && capability != domain.LifecycleCapabilityStart && capability != domain.LifecycleCapabilityStop {
return "", false
+1 -1
View File
@@ -1757,7 +1757,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
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.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall,
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
+88
View File
@@ -29,6 +29,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
discoveryKeys := map[string]struct{}{}
dependencyKeys := map[string]struct{}{}
installPlanKeys := map[string]struct{}{}
serverDeploymentKeys := map[string]struct{}{}
logSourceKeys := map[string]struct{}{}
logSourceRetentions := map[string]int{}
logEventKeys := map[string]struct{}{}
@@ -150,6 +151,76 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
}
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 {
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
@@ -396,6 +467,23 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
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 {
var violations []string
if extension.Kind != "ue4ss-dll" || extension.Activation != "server-start" {