Move SCUM lifecycle ownership to plugin
This commit is contained in:
@@ -351,7 +351,6 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
|
||||
domain.LifecycleCapabilityStop,
|
||||
"process.restart",
|
||||
domain.LifecycleCapabilityStatus,
|
||||
domain.JobCapabilitySCUMDeploymentPlan,
|
||||
"files.list",
|
||||
domain.JobCapabilityFilesRead,
|
||||
"files.patch",
|
||||
@@ -374,7 +373,6 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
|
||||
Platforms: []string{"windows"},
|
||||
}}
|
||||
requireManualSCUMRuntimeBindings(&plugin, "run-local")
|
||||
plugin.RuntimeProfiles.ServerDeployments[0].SupportedTargets = []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}
|
||||
if err := svc.store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("create SCUM plugin fixture: %v", err)
|
||||
}
|
||||
@@ -418,7 +416,7 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
|
||||
hello.KeyGeneration = key.Generation
|
||||
hello.Platform = "windows"
|
||||
hello.Architecture = "amd64"
|
||||
hello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus, domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan}
|
||||
hello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}
|
||||
registered, err := svc.RegisterRunHello(hello)
|
||||
if err != nil || !registered.Accepted {
|
||||
t.Fatalf("register generated SCUM Run: result=%+v err=%v", registered, err)
|
||||
@@ -433,15 +431,15 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
|
||||
t.Fatalf("expected one SCUM install job, jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
job := jobs[0]
|
||||
if job.Capability != domain.LifecycleCapabilityInstall || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan == nil {
|
||||
t.Fatalf("expected SCUM install job with protected deployment plan, job=%+v", job)
|
||||
if job.Capability != domain.LifecycleCapabilityInstall || job.TargetKey != "actions/install.json" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
t.Fatalf("expected SCUM install job with plugin action and generic deployment inputs, job=%+v", job)
|
||||
}
|
||||
if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
|
||||
t.Fatalf("SCUM install job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job.ExecutionInput.ServerDeploymentPlan == nil {
|
||||
t.Fatalf("generated SCUM Run should claim install job with frozen plan, claim=%+v err=%v", claim, err)
|
||||
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
t.Fatalf("generated SCUM Run should claim plugin-owned install action, claim=%+v err=%v", claim, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
@@ -122,12 +124,17 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(distribution)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentRun,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: distribution.RunEndpointID,
|
||||
ProfileKey: profileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
TargetRelease: distribution.ID,
|
||||
@@ -138,6 +145,7 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
WorkspaceSeed: workspaceSeed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -183,6 +191,48 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
|
||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||
}
|
||||
|
||||
func (svc *CoreService) runDistributionWorkspaceSeed(distribution domain.RunDistribution) (string, string, error) {
|
||||
instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(distribution.PluginID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
seed, err := encodePluginWorkspaceSeed(plugin.LifecycleAssets)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
profileKey := instance.Deployment.ProfileKey
|
||||
if binding, bindingErr := svc.runtimeBindingForServer(distribution.ServerInstanceID); bindingErr == nil {
|
||||
profileKey = binding.ProfileKey
|
||||
} else if !errors.Is(bindingErr, repo.ErrNotFound) {
|
||||
return "", "", bindingErr
|
||||
}
|
||||
return profileKey, seed, nil
|
||||
}
|
||||
|
||||
func encodePluginWorkspaceSeed(files []domain.PluginAssetFile) (string, error) {
|
||||
if len(files) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
type seedFile struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Mode int `json:"mode,omitempty"`
|
||||
}
|
||||
seed := make([]seedFile, len(files))
|
||||
for i, file := range files {
|
||||
seed[i] = seedFile{Path: file.Path, Content: file.Content, Mode: file.Mode}
|
||||
}
|
||||
body, err := json.Marshal(seed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(body), nil
|
||||
}
|
||||
|
||||
// storeDistributionBuildArtifact records the built package under job ownership
|
||||
// so the existing ArtifactOwnerKindJob scope assertions keep guarding it.
|
||||
func (svc *CoreService) storeDistributionBuildArtifact(artifactID string, jobID string, payload []byte) error {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -31,6 +33,17 @@ func (builder captureDistributionBuilder) Build(input domain.DistributionBuildIn
|
||||
|
||||
func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin fixture: %v", err)
|
||||
}
|
||||
plugin.LifecycleAssets = []domain.PluginAssetFile{
|
||||
{Path: "actions/install.json", Content: `{"version":1,"action":"install","mode":"oneshot"}`, Mode: 0o600},
|
||||
{Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700},
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("seed plugin lifecycle assets: %v", err)
|
||||
}
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
@@ -55,6 +68,17 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
if platformInput.AuthKey == "" || platformInput.JobID != distribution.BuildJobID || platformInput.RunEndpointID != instance.RunEndpointID {
|
||||
t.Fatalf("platform builder received incomplete internal input: %+v", platformInput)
|
||||
}
|
||||
decodedSeed, err := base64.StdEncoding.DecodeString(platformInput.WorkspaceSeed)
|
||||
if err != nil {
|
||||
t.Fatalf("decode workspace seed: %v", err)
|
||||
}
|
||||
var seedFiles []domain.PluginAssetFile
|
||||
if err := json.Unmarshal(decodedSeed, &seedFiles); err != nil {
|
||||
t.Fatalf("unmarshal workspace seed: %v", err)
|
||||
}
|
||||
if platformInput.ProfileKey != "local" || len(seedFiles) != 2 || seedFiles[1].Path != "bin/install-server" || seedFiles[1].Content == "" {
|
||||
t.Fatalf("platform builder received incomplete plugin workspace seed: profile=%q seed=%+v", platformInput.ProfileKey, seedFiles)
|
||||
}
|
||||
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
ComponentKind: domain.DistributionComponentRun,
|
||||
|
||||
@@ -51,12 +51,17 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(distribution)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentRun,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: distribution.RunEndpointID,
|
||||
ProfileKey: profileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
TargetRelease: distribution.ID,
|
||||
@@ -67,6 +72,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
WorkspaceSeed: workspaceSeed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -158,6 +159,17 @@ func (builder *DockerDistributionBuilder) Build(input domain.DistributionBuildIn
|
||||
if err := os.WriteFile(filepath.Join(inputDir, "auth-key"), []byte(input.AuthKey), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seedPayload := []byte("[]")
|
||||
if strings.TrimSpace(input.WorkspaceSeed) != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(input.WorkspaceSeed)
|
||||
if err != nil {
|
||||
return nil, validationError("distribution build input has an invalid workspace seed")
|
||||
}
|
||||
seedPayload = decoded
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(inputDir, "workspace-seed.json"), seedPayload, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -197,7 +209,16 @@ set -eu
|
||||
|
||||
auth_key="$(cat /workspace/input/auth-key)"
|
||||
if [ "$COMPONENT_KIND" = "run" ]; then
|
||||
cd /workspace/source
|
||||
rm -rf /workspace/build/run-source
|
||||
mkdir -p /workspace/build/run-source
|
||||
cp -R /workspace/source/. /workspace/build/run-source/
|
||||
seed_b64="$(base64 /workspace/input/workspace-seed.json | tr -d '\n')"
|
||||
cat > /workspace/build/run-source/config/workspace_seed_generated.go <<EOF
|
||||
package config
|
||||
|
||||
func init() { BuildWorkspaceSeed = "$seed_b64" }
|
||||
EOF
|
||||
cd /workspace/build/run-source
|
||||
ldflags="-s -w"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildMode=worker"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildPlatformURL=$PLATFORM_URL"
|
||||
|
||||
@@ -132,7 +132,6 @@ func TestCoreServiceBuildsSCUMGuidedRunWithoutCompleteRuntimeBinding(t *testing.
|
||||
plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}
|
||||
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}, ActionRefs: domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}, Platforms: []string{"windows"}}}
|
||||
requireManualSCUMRuntimeBindings(&plugin, "run-local")
|
||||
plugin.RuntimeProfiles.ServerDeployments[0].SupportedTargets = []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}
|
||||
if err := svc.store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("create SCUM plugin: %v", err)
|
||||
}
|
||||
|
||||
@@ -294,9 +294,6 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
|
||||
return validationError("deployment execution receipt does not match leased definition")
|
||||
}
|
||||
}
|
||||
if job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
return validateSCUMDeploymentEvidence(job.ExecutionInput.ServerDeploymentPlan, result)
|
||||
}
|
||||
if result.ExecutionResult.Kind == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package service
|
||||
|
||||
import "browser.local/platform/domain"
|
||||
|
||||
func applyPluginCreateDefaults(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition) domain.ServerDeploymentDefinition {
|
||||
definition = domain.CopyServerDeploymentDefinition(definition)
|
||||
if definition.Mode != domain.ServerDeploymentModeGuided {
|
||||
return definition
|
||||
}
|
||||
if definition.CreateInputs == nil {
|
||||
definition.CreateInputs = map[string]string{}
|
||||
}
|
||||
for _, field := range plugin.CreateFields {
|
||||
if _, present := definition.CreateInputs[field.Key]; !present && field.DefaultValue != "" {
|
||||
definition.CreateInputs[field.Key] = field.DefaultValue
|
||||
}
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
func deploymentNeedsCompleteRuntimeBinding(_ domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool {
|
||||
return definition.Mode == ""
|
||||
}
|
||||
@@ -748,6 +748,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
|
||||
DeclaredPermissions: manifest.Permissions,
|
||||
Permissions: pluginPermissionsFromManifest(manifest.Permissions),
|
||||
LifecycleActions: manifest.Actions,
|
||||
LifecycleAssets: lifecycleAssetsFromManifestRegistration(registration),
|
||||
BridgeActions: manifest.Bridge.Actions,
|
||||
Pages: manifest.Pages,
|
||||
FileWorkspace: manifest.FileWorkspace,
|
||||
@@ -762,6 +763,25 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
|
||||
}
|
||||
}
|
||||
|
||||
func lifecycleAssetsFromManifestRegistration(registration domain.GamePluginManifestRegistration) []domain.PluginAssetFile {
|
||||
assets := domain.CopyPluginAssetFiles(registration.AssetFiles)
|
||||
if len(registration.Manifest.AssetFiles) == 0 {
|
||||
return assets
|
||||
}
|
||||
modeByPath := map[string]int{}
|
||||
for _, file := range registration.Manifest.AssetFiles {
|
||||
if file.Mode != 0 {
|
||||
modeByPath[file.Path] = file.Mode
|
||||
}
|
||||
}
|
||||
for i := range assets {
|
||||
if assets[i].Mode == 0 {
|
||||
assets[i].Mode = modeByPath[assets[i].Path]
|
||||
}
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
func (svc *CoreService) AuthorizePluginBridgeAction(request domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) {
|
||||
if err := validator.ValidatePluginBridgeAuthorizeRequest(request); err != nil {
|
||||
return domain.PluginBridgeAuthorization{}, err
|
||||
|
||||
@@ -872,7 +872,10 @@ func TestCoreServiceManagesAIProviderMetadata(t *testing.T) {
|
||||
func TestCoreServiceRegistersGamePluginManifest(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
|
||||
plugin, err := svc.RegisterGamePluginManifest(validPluginManifestRegistration())
|
||||
registration := validPluginManifestRegistration()
|
||||
registration.Manifest.AssetFiles = []domain.PluginAssetFile{{Path: "actions/install.json", Mode: 0o600}, {Path: "bin/install-server", Mode: 0o700}}
|
||||
registration.AssetFiles = []domain.PluginAssetFile{{Path: "actions/install.json", Content: "{}", Mode: 0o600}, {Path: "bin/install-server", Content: "#!/usr/bin/env sh\n"}}
|
||||
plugin, err := svc.RegisterGamePluginManifest(registration)
|
||||
if err != nil {
|
||||
t.Fatalf("register manifest: %v", err)
|
||||
}
|
||||
@@ -894,6 +897,9 @@ func TestCoreServiceRegistersGamePluginManifest(t *testing.T) {
|
||||
if len(plugin.BridgeActions) != 4 || plugin.BridgeActions[0] != string(domain.PluginBridgeActionServerInstancesRead) {
|
||||
t.Fatalf("expected bridge actions, got %+v", plugin.BridgeActions)
|
||||
}
|
||||
if len(plugin.LifecycleAssets) != 2 || plugin.LifecycleAssets[1].Path != "bin/install-server" || plugin.LifecycleAssets[1].Mode != 0o700 {
|
||||
t.Fatalf("expected declared lifecycle assets with manifest mode defaults, got %+v", plugin.LifecycleAssets)
|
||||
}
|
||||
|
||||
listed, err := svc.ListGamePlugins(domain.GamePluginFilter{ServerType: "example", Status: domain.GamePluginStatusInstalled})
|
||||
if err != nil || len(listed) != 1 {
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const scumPluginID = "game.scum"
|
||||
|
||||
func applyPluginCreateDefaults(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition) domain.ServerDeploymentDefinition {
|
||||
definition = domain.CopyServerDeploymentDefinition(definition)
|
||||
if definition.Mode != domain.ServerDeploymentModeGuided {
|
||||
return definition
|
||||
}
|
||||
if definition.CreateInputs == nil {
|
||||
definition.CreateInputs = map[string]string{}
|
||||
}
|
||||
for _, field := range plugin.CreateFields {
|
||||
if _, present := definition.CreateInputs[field.Key]; !present && field.DefaultValue != "" {
|
||||
definition.CreateInputs[field.Key] = field.DefaultValue
|
||||
}
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
func scumDeploymentPlan(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition, operation string) (*domain.ServerDeploymentPlan, error) {
|
||||
if plugin.ID != scumPluginID || (definition.Mode != domain.ServerDeploymentModeGuided && definition.Mode != domain.ServerDeploymentModeExisting) {
|
||||
return nil, nil
|
||||
}
|
||||
if operation != "install" && operation != "adopt" {
|
||||
return nil, validationError("SCUM deployment operation is invalid")
|
||||
}
|
||||
if len(plugin.RuntimeProfiles.ServerDeployments) == 0 {
|
||||
return nil, validationError("SCUM deployment template is not registered")
|
||||
}
|
||||
profile := plugin.RuntimeProfiles.ServerDeployments[0]
|
||||
if strings.TrimSpace(profile.Key) == "" || strings.TrimSpace(profile.Version) == "" || profile.SteamAppID == "" {
|
||||
return nil, validationError("SCUM deployment template is incomplete")
|
||||
}
|
||||
expectedPrerequisites := map[string]string{"steamcmd": "steamcmd", "vcredist-2012-x86": "windows-vcredist", "vcredist-2012-x64": "windows-vcredist", "vcredist-2013-x86": "windows-vcredist", "vcredist-2013-x64": "windows-vcredist", "vcredist-2015-2022-x86": "windows-vcredist", "vcredist-2015-2022-x64": "windows-vcredist", "directx-jun2010": "windows-directx"}
|
||||
if len(profile.Prerequisites) != len(expectedPrerequisites) {
|
||||
return nil, validationError("SCUM deployment template prerequisite list is incomplete")
|
||||
}
|
||||
for _, prerequisite := range profile.Prerequisites {
|
||||
if expectedPrerequisites[prerequisite.Key] != prerequisite.Kind {
|
||||
return nil, validationError("SCUM deployment template prerequisite is invalid")
|
||||
}
|
||||
}
|
||||
fieldKeys := make(map[string]struct{}, len(plugin.CreateFields))
|
||||
for _, field := range plugin.CreateFields {
|
||||
fieldKeys[field.Key] = struct{}{}
|
||||
}
|
||||
for _, mapping := range profile.ConfigMappings {
|
||||
if _, ok := fieldKeys[mapping.FieldKey]; !ok {
|
||||
return nil, validationError("SCUM deployment template maps an undeclared create field")
|
||||
}
|
||||
if strings.TrimSpace(mapping.ConfigKey) == "" || strings.TrimSpace(mapping.ValueType) == "" {
|
||||
return nil, validationError("SCUM deployment template contains an incomplete config mapping")
|
||||
}
|
||||
}
|
||||
for _, check := range profile.VerificationChecks {
|
||||
if check.Required && (strings.TrimSpace(check.Key) == "" || strings.TrimSpace(check.Kind) == "") {
|
||||
return nil, validationError("SCUM deployment template contains an incomplete verification check")
|
||||
}
|
||||
}
|
||||
return &domain.ServerDeploymentPlan{
|
||||
SchemaVersion: "1",
|
||||
Operation: operation,
|
||||
PluginID: plugin.ID,
|
||||
TemplateKey: profile.Key,
|
||||
TemplateVersion: profile.Version,
|
||||
SteamAppID: profile.SteamAppID,
|
||||
ExecutableKey: profile.ExecutableKey,
|
||||
InstallRootKey: profile.InstallRootKey,
|
||||
ConfigKey: profile.ConfigKey,
|
||||
ConfigFormat: profile.ConfigFormat,
|
||||
Prerequisites: append([]domain.RuntimeServerPrerequisite(nil), profile.Prerequisites...),
|
||||
ConfigMappings: append([]domain.RuntimeServerConfigMapping(nil), profile.ConfigMappings...),
|
||||
DiscoveryMarkers: append([]domain.RuntimeServerDiscoveryMarker(nil), profile.DiscoveryMarkers...),
|
||||
VerificationChecks: append([]domain.RuntimeServerVerificationCheck(nil), profile.VerificationChecks...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scumDeploymentProjection(plan *domain.ServerDeploymentPlan, operation string, stamp time.Time) domain.ServerDeploymentProjection {
|
||||
projection := domain.ServerDeploymentProjection{State: "draft", Operation: operation, UpdatedAt: stamp}
|
||||
if plan != nil {
|
||||
projection.TemplateKey = plan.TemplateKey
|
||||
projection.TemplateVersion = plan.TemplateVersion
|
||||
}
|
||||
return projection
|
||||
}
|
||||
|
||||
func scumDeploymentCapabilityRequired(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool {
|
||||
return plugin.ID == scumPluginID && (definition.Mode == domain.ServerDeploymentModeGuided || definition.Mode == domain.ServerDeploymentModeExisting)
|
||||
}
|
||||
|
||||
func deploymentNeedsCompleteRuntimeBinding(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool {
|
||||
return !scumDeploymentCapabilityRequired(plugin, definition)
|
||||
}
|
||||
|
||||
func validateSCUMDeploymentTarget(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition, endpoint domain.RunEndpoint) error {
|
||||
if !scumDeploymentCapabilityRequired(plugin, definition) {
|
||||
return nil
|
||||
}
|
||||
plan, err := scumDeploymentPlan(plugin, definition, "install")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, profile := range plugin.RuntimeProfiles.ServerDeployments {
|
||||
if profile.Key != plan.TemplateKey {
|
||||
continue
|
||||
}
|
||||
for _, target := range profile.SupportedTargets {
|
||||
if target.OS == endpoint.Platform && target.Arch == endpoint.Architecture {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return validationError("SCUM deployment template is incompatible with the selected Run target")
|
||||
}
|
||||
|
||||
func validateScumDeploymentInputs(plugin domain.GamePlugin, definition domain.ServerDeploymentDefinition) error {
|
||||
plan, err := scumDeploymentPlan(plugin, definition, "install")
|
||||
if err != nil || plan == nil {
|
||||
return err
|
||||
}
|
||||
if definition.Mode == domain.ServerDeploymentModeExisting {
|
||||
if strings.TrimSpace(definition.ServerRoot) == "" {
|
||||
return validationError("SCUM adoption requires an existing server directory")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(definition.ServerRoot) == "" {
|
||||
return validationError("SCUM installation requires an install directory")
|
||||
}
|
||||
for _, mapping := range plan.ConfigMappings {
|
||||
if mapping.Required && strings.TrimSpace(definition.CreateInputs[mapping.FieldKey]) == "" {
|
||||
field, found := findPluginCreateField(plugin.CreateFields, mapping.FieldKey)
|
||||
if !found || field.DefaultValue == "" {
|
||||
return validator.ValidationError{Violations: []string{"createInputs." + mapping.FieldKey + " is required by the SCUM deployment template"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findPluginCreateField(fields []domain.PluginCreateField, key string) (domain.PluginCreateField, bool) {
|
||||
for _, field := range fields {
|
||||
if field.Key == key {
|
||||
return field, true
|
||||
}
|
||||
}
|
||||
return domain.PluginCreateField{}, false
|
||||
}
|
||||
|
||||
func validateSCUMDeploymentEvidence(plan *domain.ServerDeploymentPlan, result domain.RunJobResult) error {
|
||||
if plan == nil {
|
||||
return nil
|
||||
}
|
||||
evidence := result.ExecutionResult.ServerDeploymentEvidence
|
||||
if evidence == nil {
|
||||
return validationError("SCUM deployment result must include deployment evidence")
|
||||
}
|
||||
if evidence.TemplateKey != plan.TemplateKey || evidence.TemplateVersion != plan.TemplateVersion {
|
||||
return validationError("SCUM deployment result template fence is invalid")
|
||||
}
|
||||
for name, values := range map[string]map[string]string{"discoveredFacts": evidence.DiscoveredFacts, "mappingResults": evidence.MappingResults, "verificationResults": evidence.VerificationResults} {
|
||||
if len(values) > 64 {
|
||||
return validationError("SCUM deployment evidence contains too many " + name)
|
||||
}
|
||||
for key, value := range values {
|
||||
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$`).MatchString(key) || len(value) > 160 || strings.TrimSpace(value) != value || unsafeSCUMEvidenceValue(value) {
|
||||
return validationError(fmt.Sprintf("SCUM deployment evidence contains an unsafe %s value", name))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(evidence.FailureCode) > 80 || (evidence.FailureCode != "" && !regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,79}$`).MatchString(evidence.FailureCode)) {
|
||||
return validationError("SCUM deployment failure code is invalid")
|
||||
}
|
||||
if result.State == domain.JobStateFailed {
|
||||
if result.ExecutionResult.Kind != "scum.install.failed" && result.ExecutionResult.Kind != "scum.adopt.failed" {
|
||||
return validationError("SCUM deployment failure result type is invalid")
|
||||
}
|
||||
if strings.TrimSpace(evidence.FailureCode) == "" {
|
||||
return validationError("SCUM deployment failure must include a stable failure code")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if result.State != domain.JobStateSucceeded {
|
||||
return nil
|
||||
}
|
||||
expectedKind := "scum.install.completed"
|
||||
if plan.Operation == "adopt" {
|
||||
expectedKind = "scum.adopt.completed"
|
||||
}
|
||||
if result.ExecutionResult.Kind != expectedKind {
|
||||
return validationError("SCUM deployment result type is invalid")
|
||||
}
|
||||
if evidence.PreflightState != "passed" || evidence.DiscoveryState != "passed" || evidence.VerificationState != "passed" {
|
||||
return validationError("SCUM deployment result is missing successful preflight, discovery, or verification evidence")
|
||||
}
|
||||
validMappingState := evidence.MappingState == "passed" || evidence.MappingState == "applied" || evidence.MappingState == "unchanged"
|
||||
if plan.Operation == "adopt" && evidence.MappingState == "skipped" {
|
||||
validMappingState = true
|
||||
}
|
||||
if !validMappingState {
|
||||
return validationError("SCUM deployment result is missing a successful config mapping state")
|
||||
}
|
||||
for _, mapping := range plan.ConfigMappings {
|
||||
mappingResult := evidence.MappingResults[mapping.FieldKey]
|
||||
if plan.Operation == "adopt" && mappingResult == "" {
|
||||
mappingResult = "skipped"
|
||||
}
|
||||
if mapping.Required && mappingResult != "applied" && mappingResult != "unchanged" && !(plan.Operation == "adopt" && mappingResult == "skipped") {
|
||||
return validationError("SCUM deployment result is missing a required config mapping")
|
||||
}
|
||||
}
|
||||
for _, check := range plan.VerificationChecks {
|
||||
if check.Required && evidence.VerificationResults[check.Key] != "passed" {
|
||||
return validationError("SCUM deployment result is missing a required verification check")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unsafeSCUMEvidenceValue(value string) bool {
|
||||
lower := strings.ToLower(value)
|
||||
for _, token := range []string{"password", "secret", "credential", "token", "private key", "powershell", "cmd.exe", "bash -c", "ssh://", "tcp://", "udp://"} {
|
||||
if strings.Contains(lower, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\\`) || regexp.MustCompile(`^[A-Za-z]:[\\/]`).MatchString(value)
|
||||
}
|
||||
@@ -11,16 +11,10 @@ func scumDeploymentTestPlugin() domain.GamePlugin {
|
||||
ID: "game.scum",
|
||||
CreateFields: []domain.PluginCreateField{
|
||||
{Key: "serverName", Type: "text", DefaultValue: "SCUM Test"},
|
||||
{Key: "gamePort", Type: "port", DefaultValue: "7777"},
|
||||
{Key: "gamePort", Type: "port", DefaultValue: "7779"},
|
||||
{Key: "queryPort", Type: "port", DefaultValue: "27015"},
|
||||
{Key: "maxPlayers", Type: "number", DefaultValue: "64"},
|
||||
{Key: "maxPlayers", Type: "number", DefaultValue: "128"},
|
||||
},
|
||||
RuntimeProfiles: domain.GamePluginRuntimeProfiles{ServerDeployments: []domain.RuntimeServerDeploymentProfile{{
|
||||
Key: "scum-steamcmd-windows", Version: "1.0.0", SteamAppID: "3792580", ExecutableKey: "scum/server-executable", InstallRootKey: "server/install-root", ConfigKey: "scum/server-settings", ConfigFormat: "ini",
|
||||
Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "steamcmd"}, {Key: "vcredist-2012-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2012-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x64", Kind: "windows-vcredist"}, {Key: "directx-jun2010", Kind: "windows-directx"}},
|
||||
ConfigMappings: []domain.RuntimeServerConfigMapping{{FieldKey: "serverName", ConfigKey: "server-settings.server-name", ValueType: "text", Required: true}, {FieldKey: "gamePort", ConfigKey: "server-settings.game-port", ValueType: "port", Required: true}, {FieldKey: "queryPort", ConfigKey: "server-settings.query-port", ValueType: "port", Required: true}, {FieldKey: "maxPlayers", ConfigKey: "server-settings.max-players", ValueType: "integer", Required: true}},
|
||||
VerificationChecks: []domain.RuntimeServerVerificationCheck{{Key: "executable", Kind: "executable.present", TargetKey: "scum/server-executable", Required: true}, {Key: "game-port", Kind: "port.bound", TargetKey: "game-port", Required: true}, {Key: "config", Kind: "config.readable", TargetKey: "scum/server-settings", Required: true}, {Key: "process", Kind: "process.healthy", TargetKey: "scum/server-executable", Required: true}},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,81 +36,21 @@ func requireManualSCUMRuntimeBindings(plugin *domain.GamePlugin, profileKey stri
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMDeploymentPlanSeparatesInstallAndAdopt(t *testing.T) {
|
||||
func TestApplyPluginCreateDefaultsUsesPluginFields(t *testing.T) {
|
||||
plugin := scumDeploymentTestPlugin()
|
||||
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)
|
||||
definition := applyPluginCreateDefaults(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, CreateInputs: map[string]string{"serverName": "Moon"}})
|
||||
|
||||
if definition.CreateInputs["serverName"] != "Moon" || definition.CreateInputs["gamePort"] != "7779" || definition.CreateInputs["maxPlayers"] != "128" {
|
||||
t.Fatalf("expected plugin defaults to fill missing guided inputs: %+v", definition.CreateInputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMDeploymentEvidenceRequiresAllRequiredChecks(t *testing.T) {
|
||||
func TestDeploymentDefinitionsUsePluginOwnedLifecycleWithoutRuntimeBinding(t *testing.T) {
|
||||
plugin := scumDeploymentTestPlugin()
|
||||
plan, err := scumDeploymentPlan(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided}, "install")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if deploymentNeedsCompleteRuntimeBinding(plugin, domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided}) {
|
||||
t.Fatal("guided plugin-owned lifecycle should not require a complete runtime binding")
|
||||
}
|
||||
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")
|
||||
if !deploymentNeedsCompleteRuntimeBinding(plugin, domain.ServerDeploymentDefinition{}) {
|
||||
t.Fatal("non-deployment runtime lifecycle still requires a complete runtime binding")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +59,6 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
|
||||
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
|
||||
@@ -69,17 +66,7 @@ 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.DeploymentProjection = domain.ServerDeploymentProjection{}
|
||||
instance.UpdatedAt = definition.UpdatedAt
|
||||
if err := validator.ValidateServerInstance(instance); err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
@@ -131,18 +118,12 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment)
|
||||
if err := validateSCUMDeploymentTarget(plugin, instance.Deployment, endpoint); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); 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
|
||||
}
|
||||
@@ -169,11 +150,6 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -65,13 +65,13 @@ func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibl
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceSCUMGuidedDeployUsesScopedCapabilitiesAndDispatchesFrozenPlan(t *testing.T) {
|
||||
func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
runHello := validRunControlHello()
|
||||
runHello.RunEndpointID = "run-scum-guided"
|
||||
runHello.Platform = "windows"
|
||||
runHello.Architecture = "amd64"
|
||||
runHello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan}
|
||||
runHello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}
|
||||
runHello.CapabilityReport.Fingerprint = "cap-scum-guided"
|
||||
session, err := svc.RegisterRunHello(runHello)
|
||||
if err != nil {
|
||||
@@ -86,7 +86,6 @@ func TestCoreServiceSCUMGuidedDeployUsesScopedCapabilitiesAndDispatchesFrozenPla
|
||||
plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"}
|
||||
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}}
|
||||
requireManualSCUMRuntimeBindings(&plugin, "local")
|
||||
plugin.RuntimeProfiles.ServerDeployments[0].SupportedTargets = []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}
|
||||
if err := svc.store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("create SCUM plugin fixture: %v", err)
|
||||
}
|
||||
@@ -99,12 +98,15 @@ func TestCoreServiceSCUMGuidedDeployUsesScopedCapabilitiesAndDispatchesFrozenPla
|
||||
if err != nil {
|
||||
t.Fatalf("SCUM guided deployment should not require unrelated manifest capabilities: %v", err)
|
||||
}
|
||||
if created.Job.ExecutionInput.ServerDeploymentPlan == nil || created.Job.ExecutionInput.ServerDeploymentPlan.SteamAppID != "3792580" {
|
||||
t.Fatalf("expected frozen SCUM deployment plan on queued job, got %+v", created.Job.ExecutionInput)
|
||||
if created.Job.TargetKey != "actions/install.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil {
|
||||
t.Fatalf("expected plugin-owned install action without SCUM deployment plan, job=%+v", created.Job)
|
||||
}
|
||||
if created.Job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || created.Job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
|
||||
t.Fatalf("SCUM install job lost create inputs: %+v", created.Job.ExecutionInput.Deployment.CreateInputs)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job.ExecutionInput.ServerDeploymentPlan == nil {
|
||||
t.Fatalf("claimed SCUM install must include frozen deployment plan, claim=%+v err=%v", claim, err)
|
||||
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
t.Fatalf("claimed SCUM install must use plugin action without SCUM plan, claim=%+v err=%v", claim, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
|
||||
if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, create.Deployment.CreateInputs); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validateScumDeploymentInputs(plugin, create.Deployment); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
@@ -63,15 +60,6 @@ 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))
|
||||
@@ -127,14 +115,6 @@ 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 err := validateSCUMDeploymentTarget(plugin, instance.Deployment, endpoint); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
@@ -339,17 +319,6 @@ 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,
|
||||
@@ -359,12 +328,11 @@ 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),
|
||||
ServerDeploymentPlan: serverDeploymentPlan,
|
||||
WorkspaceScope: profileKey,
|
||||
PluginID: plugin.ID,
|
||||
LifecycleOperation: lifecycleExecutionOperation(action),
|
||||
DLLExtensions: dllExtensions,
|
||||
Deployment: deploymentPlanForDispatch(instance.Deployment),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -520,11 +488,8 @@ func validateServerInstanceLifecycleDependencies(instance domain.ServerInstance,
|
||||
|
||||
func lifecycleRequiredRunCapabilities(instance domain.ServerInstance, plugin domain.GamePlugin, action domain.ServerLifecycleAction) []string {
|
||||
required := append([]string(nil), domain.LifecycleCapabilityForAction(action))
|
||||
if instance.Deployment.Mode != "" {
|
||||
if instance.Deployment.Mode == domain.ServerDeploymentModeCustom {
|
||||
required = append(required, domain.JobCapabilityDeploymentPlan)
|
||||
if scumDeploymentCapabilityRequired(plugin, instance.Deployment) {
|
||||
required = append(required, domain.JobCapabilitySCUMDeploymentPlan)
|
||||
}
|
||||
}
|
||||
return compactUniqueStrings(required)
|
||||
}
|
||||
|
||||
@@ -58,31 +58,6 @@ 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 {
|
||||
@@ -99,8 +74,7 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
|
||||
plan := job.ExecutionInput.ServerDeploymentPlan
|
||||
if plan == nil || job.ServerInstanceID == "" {
|
||||
if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" {
|
||||
return nil
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
||||
@@ -109,9 +83,7 @@ func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp ti
|
||||
}
|
||||
projection := domain.CopyServerDeploymentProjection(instance.DeploymentProjection)
|
||||
projection.State = "running"
|
||||
projection.Operation = plan.Operation
|
||||
projection.TemplateKey = plan.TemplateKey
|
||||
projection.TemplateVersion = plan.TemplateVersion
|
||||
projection.Operation = job.ExecutionInput.LifecycleOperation
|
||||
switch job.Progress.Phase {
|
||||
case "preflight":
|
||||
projection.PreflightState = "running"
|
||||
|
||||
Reference in New Issue
Block a user