Fix guided lifecycle job scoping

This commit is contained in:
npc0-hue
2026-08-01 17:11:25 +08:00
parent e37f00c48a
commit 9f82e310b2
6 changed files with 92 additions and 20 deletions
+16 -2
View File
@@ -302,6 +302,10 @@ type JobExecutionInput struct {
DLLExtensions []domain.RuntimeDLLExtensionPlan `json:"dllExtensions,omitempty" db:"dll_extensions"` DLLExtensions []domain.RuntimeDLLExtensionPlan `json:"dllExtensions,omitempty" db:"dll_extensions"`
// SourceRCON is secret-free connection metadata for a one-time Run command. // SourceRCON is secret-free connection metadata for a one-time Run command.
SourceRCON *domain.RuntimeSourceRCONPlan `json:"sourceRcon,omitempty" db:"source_rcon"` SourceRCON *domain.RuntimeSourceRCONPlan `json:"sourceRcon,omitempty" db:"source_rcon"`
// Deployment is protected lifecycle execution material delivered only to Run.
Deployment *domain.ServerDeploymentDefinition `json:"deployment,omitempty" db:"deployment"`
// ServerDeploymentPlan is the legacy generic deployment-plan payload.
ServerDeploymentPlan *domain.ServerDeploymentPlan `json:"serverDeploymentPlan,omitempty" db:"server_deployment_plan"`
} }
type JobExecutionResult struct { type JobExecutionResult struct {
@@ -895,11 +899,21 @@ func (job Job) ToDomain() domain.Job {
} }
func executionInputFromDomain(input domain.JobExecutionInput) JobExecutionInput { func executionInputFromDomain(input domain.JobExecutionInput) JobExecutionInput {
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON)} var deployment *domain.ServerDeploymentDefinition
if input.Deployment != nil {
copy := domain.CopyServerDeploymentDefinition(*input.Deployment)
deployment = &copy
}
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON), Deployment: deployment, ServerDeploymentPlan: domain.CopyServerDeploymentPlan(input.ServerDeploymentPlan)}
} }
func (input JobExecutionInput) ToDomain() domain.JobExecutionInput { func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON)} var deployment *domain.ServerDeploymentDefinition
if input.Deployment != nil {
copy := domain.CopyServerDeploymentDefinition(*input.Deployment)
deployment = &copy
}
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON), Deployment: deployment, ServerDeploymentPlan: domain.CopyServerDeploymentPlan(input.ServerDeploymentPlan)}
} }
func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult { func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult {
+31 -2
View File
@@ -101,6 +101,24 @@ func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T)
LifecycleOperation: "upgrade", LifecycleOperation: "upgrade",
TargetVersion: "2.0.0", TargetVersion: "2.0.0",
Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-123"}, Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-123"},
Deployment: &domain.ServerDeploymentDefinition{
Mode: domain.ServerDeploymentModeGuided,
ProfileKey: "run-local",
RuntimeBindings: map[string]string{"installRoot": "D:\\scumserver"},
CreateInputs: map[string]string{"gamePort": "27000", "maxPlayers": "128"},
ServerRoot: "D:\\scumserver",
WorkingDirectory: "D:\\scumserver",
InstallCommand: "install.cmd",
StartCommand: "start.cmd",
Shell: domain.ServerCommandShellCmd,
Revision: 3,
},
ServerDeploymentPlan: &domain.ServerDeploymentPlan{
SchemaVersion: "1",
Operation: "install",
PluginID: "game.scum",
Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "tool"}},
},
} }
row := executionInputFromDomain(source) row := executionInputFromDomain(source)
@@ -108,20 +126,31 @@ func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T)
if row.Inputs["playerId"] != "steam-123" { if row.Inputs["playerId"] != "steam-123" {
t.Fatalf("expected model execution inputs to be isolated from source mutation, row=%+v", row.Inputs) t.Fatalf("expected model execution inputs to be isolated from source mutation, row=%+v", row.Inputs)
} }
source.Deployment.CreateInputs["maxPlayers"] = "64"
if row.Deployment == nil || row.Deployment.CreateInputs["maxPlayers"] != "128" {
t.Fatalf("expected model deployment inputs to be isolated from source mutation, row=%+v", row.Deployment)
}
source.Inputs["playerId"] = "steam-123" source.Inputs["playerId"] = "steam-123"
source.Deployment.CreateInputs["maxPlayers"] = "128"
row.Inputs["playerId"] = "row-mutated" row.Inputs["playerId"] = "row-mutated"
if source.Inputs["playerId"] != "steam-123" { if source.Inputs["playerId"] != "steam-123" {
t.Fatalf("expected source execution inputs to be isolated from model mutation, source=%+v", source.Inputs) t.Fatalf("expected source execution inputs to be isolated from model mutation, source=%+v", source.Inputs)
} }
row.Deployment.CreateInputs["maxPlayers"] = "32"
if source.Deployment.CreateInputs["maxPlayers"] != "128" {
t.Fatalf("expected source deployment inputs to be isolated from model mutation, source=%+v", source.Deployment)
}
row.Inputs["playerId"] = "steam-123" row.Inputs["playerId"] = "steam-123"
row.Deployment.CreateInputs["maxPlayers"] = "128"
roundTrip := row.ToDomain() roundTrip := row.ToDomain()
if !reflect.DeepEqual(roundTrip, source) { if !reflect.DeepEqual(roundTrip, source) {
t.Fatalf("expected lifecycle execution metadata to round-trip, got %+v", roundTrip) t.Fatalf("expected lifecycle execution metadata to round-trip, got %+v", roundTrip)
} }
roundTrip.Inputs["playerId"] = "mutated" roundTrip.Inputs["playerId"] = "mutated"
if source.Inputs["playerId"] != "steam-123" || row.Inputs["playerId"] != "steam-123" { roundTrip.Deployment.CreateInputs["maxPlayers"] = "16"
t.Fatalf("expected execution inputs to round-trip without aliasing, source=%+v row=%+v", source.Inputs, row.Inputs) if source.Inputs["playerId"] != "steam-123" || row.Inputs["playerId"] != "steam-123" || source.Deployment.CreateInputs["maxPlayers"] != "128" || row.Deployment.CreateInputs["maxPlayers"] != "128" {
t.Fatalf("expected execution inputs to round-trip without aliasing, source=%+v row=%+v", source, row)
} }
} }
+33 -5
View File
@@ -129,8 +129,23 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
LastReconciledAt: stamp, LastReconciledAt: stamp,
ReconcileCount: 2, ReconcileCount: 2,
ReconcileOutcome: "confirmed active attempt", ReconcileOutcome: "confirmed active attempt",
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "name=approved\n", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("1", 64), MaxReadBytes: 64 * 1024, Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-123"}}, ExecutionInput: domain.JobExecutionInput{
ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, AuditSummary: "bounded read", Content: "private-read"}, WorkspaceScope: "local",
Content: "name=approved\n",
ExpectedVersion: 1,
ExpectedChecksum: "sha256:" + strings.Repeat("1", 64),
MaxReadBytes: 64 * 1024,
Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-123"},
Deployment: &domain.ServerDeploymentDefinition{
Mode: domain.ServerDeploymentModeGuided,
ProfileKey: "local",
CreateInputs: map[string]string{"gamePort": "27000", "maxPlayers": "128"},
ServerRoot: "D:\\scumserver",
Revision: 2,
},
ServerDeploymentPlan: &domain.ServerDeploymentPlan{SchemaVersion: "1", Operation: "install", PluginID: "game.runtime", Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "tool"}}},
},
ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, AuditSummary: "bounded read", Content: "private-read"},
} }
if err := store.Jobs().Create(job); err != nil { if err := store.Jobs().Create(job); err != nil {
t.Fatalf("create job: %v", err) t.Fatalf("create job: %v", err)
@@ -194,7 +209,7 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("get reloaded job by idempotency: %v", err) t.Fatalf("get reloaded job by idempotency: %v", err)
} }
if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" || gotJob.Attempt != 2 || gotJob.RetryPolicy.MaxAttempts != 4 || gotJob.LeaseTokenHash != strings.Repeat("d", 64) || gotJob.ReconcileCount != 2 || gotJob.ExecutionInput.Content != "name=approved\n" || gotJob.ExecutionInput.Inputs["templateKey"] != "players.by-id" || gotJob.ExecutionInput.Inputs["playerId"] != "steam-123" || gotJob.ExecutionResult.Content != "private-read" { if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" || gotJob.Attempt != 2 || gotJob.RetryPolicy.MaxAttempts != 4 || gotJob.LeaseTokenHash != strings.Repeat("d", 64) || gotJob.ReconcileCount != 2 || gotJob.ExecutionInput.Content != "name=approved\n" || gotJob.ExecutionInput.Inputs["templateKey"] != "players.by-id" || gotJob.ExecutionInput.Inputs["playerId"] != "steam-123" || gotJob.ExecutionInput.Deployment == nil || gotJob.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || gotJob.ExecutionInput.ServerDeploymentPlan == nil || gotJob.ExecutionInput.ServerDeploymentPlan.Prerequisites[0].Key != "steamcmd" || gotJob.ExecutionResult.Content != "private-read" {
t.Fatalf("unexpected reloaded job: %+v", gotJob) t.Fatalf("unexpected reloaded job: %+v", gotJob)
} }
gotJob.ExecutionInput.Inputs["playerId"] = "mutated" gotJob.ExecutionInput.Inputs["playerId"] = "mutated"
@@ -248,7 +263,20 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
Attempt: 2, QueueEligibleAt: stamp, LeaseTokenHash: strings.Repeat("e", 64), LeaseSessionGen: 4, Attempt: 2, QueueEligibleAt: stamp, LeaseTokenHash: strings.Repeat("e", 64), LeaseSessionGen: 4,
LeaseExpiresAt: stamp.Add(time.Minute), LastProgressSeq: 8, CancelReason: "stop", CancelRequestedAt: stamp, LeaseExpiresAt: stamp.Add(time.Minute), LastProgressSeq: 8, CancelReason: "stop", CancelRequestedAt: stamp,
LastReconciledAt: stamp, ReconcileCount: 3, ReconcileOutcome: "confirmed active attempt", CreatedAt: stamp, UpdatedAt: stamp, LastReconciledAt: stamp, ReconcileCount: 3, ReconcileOutcome: "confirmed active attempt", CreatedAt: stamp, UpdatedAt: stamp,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "mysql-approved", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("3", 64), MaxReadBytes: 64 * 1024, Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-456"}}, ExecutionInput: domain.JobExecutionInput{
WorkspaceScope: "local",
Content: "mysql-approved",
ExpectedVersion: 1,
ExpectedChecksum: "sha256:" + strings.Repeat("3", 64),
MaxReadBytes: 64 * 1024,
Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-456"},
Deployment: &domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ProfileKey: "local", CreateInputs: map[string]string{"gamePort": "27000"}, ServerRoot: "D:\\scumserver", Revision: 3},
ServerDeploymentPlan: &domain.ServerDeploymentPlan{
SchemaVersion: "1",
Operation: "install",
PluginID: "game.runtime",
},
},
ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, AuditSummary: "atomic write"}, ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, AuditSummary: "atomic write"},
} }
if err := source.MemoryStore.Jobs().Create(job); err != nil { if err := source.MemoryStore.Jobs().Create(job); err != nil {
@@ -265,7 +293,7 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
target := &MySQLStore{MemoryStore: NewMemoryStore()} target := &MySQLStore{MemoryStore: NewMemoryStore()}
target.loadSnapshot(snapshot) target.loadSnapshot(snapshot)
got, err := target.MemoryStore.Jobs().Get(job.ID) got, err := target.MemoryStore.Jobs().Get(job.ID)
if err != nil || got.Attempt != job.Attempt || got.LeaseTokenHash != job.LeaseTokenHash || got.LastProgressSeq != job.LastProgressSeq || got.ReconcileCount != job.ReconcileCount || got.ExecutionInput.Content != job.ExecutionInput.Content || got.ExecutionInput.Inputs["templateKey"] != "players.by-id" || got.ExecutionInput.Inputs["playerId"] != "steam-456" || got.ExecutionResult.Checksum != job.ExecutionResult.Checksum { if err != nil || got.Attempt != job.Attempt || got.LeaseTokenHash != job.LeaseTokenHash || got.LastProgressSeq != job.LastProgressSeq || got.ReconcileCount != job.ReconcileCount || got.ExecutionInput.Content != job.ExecutionInput.Content || got.ExecutionInput.Inputs["templateKey"] != "players.by-id" || got.ExecutionInput.Inputs["playerId"] != "steam-456" || got.ExecutionInput.Deployment == nil || got.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || got.ExecutionInput.ServerDeploymentPlan == nil || got.ExecutionInput.ServerDeploymentPlan.PluginID != "game.runtime" || got.ExecutionResult.Checksum != job.ExecutionResult.Checksum {
t.Fatalf("unexpected MySQL snapshot job: job=%+v err=%v", got, err) t.Fatalf("unexpected MySQL snapshot job: job=%+v err=%v", got, err)
} }
got.ExecutionInput.Inputs["playerId"] = "mutated" got.ExecutionInput.Inputs["playerId"] = "mutated"
+6 -6
View File
@@ -322,8 +322,8 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
t.Fatalf("generated Run registration should queue install without deployment target, server=%+v err=%v", storedGenerated, err) t.Fatalf("generated Run registration should queue install without deployment target, server=%+v err=%v", storedGenerated, err)
} }
jobs, err = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: generatedRunDraft.ID}) jobs, err = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: generatedRunDraft.ID})
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall { if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall || jobs[0].ExecutionInput.WorkspaceScope != "local" {
t.Fatalf("expected one automatic generated Run install job, jobs=%+v err=%v", jobs, err) t.Fatalf("expected one scoped automatic generated Run install job, jobs=%+v err=%v", jobs, err)
} }
existing, err := svc.CreateServerInstanceWorkflowForSession(owner, domain.ServerLifecycleCreate{ID: "managed-existing", PluginID: plugin.ID, DeploymentTargetID: "run-local", Name: "Managed Existing", IdempotencyKey: "managed-existing-create", ProfileKey: "local", Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: "C:\\existing-scum"}}) existing, err := svc.CreateServerInstanceWorkflowForSession(owner, domain.ServerLifecycleCreate{ID: "managed-existing", PluginID: plugin.ID, DeploymentTargetID: "run-local", Name: "Managed Existing", IdempotencyKey: "managed-existing-create", ProfileKey: "local", Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: "C:\\existing-scum"}})
@@ -431,15 +431,15 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
t.Fatalf("expected one SCUM install job, jobs=%+v err=%v", jobs, err) t.Fatalf("expected one SCUM install job, jobs=%+v err=%v", jobs, err)
} }
job := jobs[0] job := jobs[0]
if job.Capability != domain.LifecycleCapabilityInstall || job.TargetKey != "actions/install.json" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil { if job.Capability != domain.LifecycleCapabilityInstall || job.TargetKey != "actions/install.json" || job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil {
t.Fatalf("expected SCUM install job with plugin action and generic deployment inputs, job=%+v", job) t.Fatalf("expected SCUM install job with scoped plugin action and generic deployment inputs, job=%+v", job)
} }
if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" { if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
t.Fatalf("SCUM install job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs) t.Fatalf("SCUM install job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs)
} }
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}}) claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil { if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.WorkspaceScope != "run-local" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
t.Fatalf("generated SCUM Run should claim plugin-owned install action, claim=%+v err=%v", claim, err) t.Fatalf("generated SCUM Run should claim scoped plugin-owned install action, claim=%+v err=%v", claim, err)
} }
} }
+1 -1
View File
@@ -95,7 +95,7 @@ func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) {
t.Fatalf("unexpected complete binding: view=%+v err=%v", view, err) t.Fatalf("unexpected complete binding: view=%+v err=%v", view, err)
} }
result, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-complete-binding"}) result, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-complete-binding"})
if err != nil || result.Job.TargetKey != "local" { if err != nil || result.Job.TargetKey != "actions/start.json" || result.Job.ExecutionInput.WorkspaceScope != "local" {
t.Fatalf("expected complete binding to permit start, result=%+v err=%v", result, err) t.Fatalf("expected complete binding to permit start, result=%+v err=%v", result, err)
} }
} }
+5 -4
View File
@@ -291,19 +291,20 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
return domain.Job{}, err return domain.Job{}, err
} }
profileKey := "" profileKey := ""
if err == nil { if err == nil && strings.TrimSpace(binding.ProfileKey) != "" {
profileKey = binding.ProfileKey profileKey = binding.ProfileKey
} else { } else {
profileKey = instance.Deployment.ProfileKey profileKey = instance.Deployment.ProfileKey
} }
actionRef := profileKey actionRef := ""
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey) profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey)
if hasProfile { if hasProfile {
if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" { if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" {
actionRef = ref actionRef = ref
} }
} else if ref := lifecycleActionRef(plugin, action); ref != "" { }
actionRef = ref if strings.TrimSpace(actionRef) == "" {
actionRef = lifecycleActionRef(plugin, action)
} }
if strings.TrimSpace(actionRef) == "" { if strings.TrimSpace(actionRef) == "" {
return domain.Job{}, validationError(fmt.Sprintf("plugin %s lifecycle action is required", action)) return domain.Job{}, validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))