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
+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