diff --git a/platform/model/resources.go b/platform/model/resources.go index 977d655..fc00ec8 100644 --- a/platform/model/resources.go +++ b/platform/model/resources.go @@ -302,6 +302,10 @@ type JobExecutionInput struct { DLLExtensions []domain.RuntimeDLLExtensionPlan `json:"dllExtensions,omitempty" db:"dll_extensions"` // SourceRCON is secret-free connection metadata for a one-time Run command. 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 { @@ -895,11 +899,21 @@ func (job Job) ToDomain() domain.Job { } 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 = © + } + 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 { - 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 = © + } + 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 { diff --git a/platform/model/resources_test.go b/platform/model/resources_test.go index 1e834ef..51959ab 100644 --- a/platform/model/resources_test.go +++ b/platform/model/resources_test.go @@ -101,6 +101,24 @@ func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) LifecycleOperation: "upgrade", TargetVersion: "2.0.0", 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) @@ -108,20 +126,31 @@ func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) if row.Inputs["playerId"] != "steam-123" { 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.Deployment.CreateInputs["maxPlayers"] = "128" row.Inputs["playerId"] = "row-mutated" if source.Inputs["playerId"] != "steam-123" { 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.Deployment.CreateInputs["maxPlayers"] = "128" roundTrip := row.ToDomain() if !reflect.DeepEqual(roundTrip, source) { t.Fatalf("expected lifecycle execution metadata to round-trip, got %+v", roundTrip) } roundTrip.Inputs["playerId"] = "mutated" - if source.Inputs["playerId"] != "steam-123" || row.Inputs["playerId"] != "steam-123" { - t.Fatalf("expected execution inputs to round-trip without aliasing, source=%+v row=%+v", source.Inputs, row.Inputs) + roundTrip.Deployment.CreateInputs["maxPlayers"] = "16" + 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) } } diff --git a/platform/repo/resources_test.go b/platform/repo/resources_test.go index bf5402b..e5ed41a 100644 --- a/platform/repo/resources_test.go +++ b/platform/repo/resources_test.go @@ -129,8 +129,23 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) { LastReconciledAt: stamp, ReconcileCount: 2, 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"}}, - ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, AuditSummary: "bounded read", Content: "private-read"}, + 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"}, + 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 { t.Fatalf("create job: %v", err) @@ -194,7 +209,7 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) { if err != nil { 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) } 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, LeaseExpiresAt: stamp.Add(time.Minute), LastProgressSeq: 8, CancelReason: "stop", CancelRequestedAt: 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"}, } if err := source.MemoryStore.Jobs().Create(job); err != nil { @@ -265,7 +293,7 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) { target := &MySQLStore{MemoryStore: NewMemoryStore()} target.loadSnapshot(snapshot) 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) } got.ExecutionInput.Inputs["playerId"] = "mutated" diff --git a/platform/service/control_test.go b/platform/service/control_test.go index f58f8b5..471afdd 100644 --- a/platform/service/control_test.go +++ b/platform/service/control_test.go @@ -322,8 +322,8 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly( 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}) - if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall { - t.Fatalf("expected one automatic generated Run install job, jobs=%+v err=%v", jobs, err) + if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall || jobs[0].ExecutionInput.WorkspaceScope != "local" { + 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"}}) @@ -431,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.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.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 scoped 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.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) + 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 scoped plugin-owned install action, claim=%+v err=%v", claim, err) } } diff --git a/platform/service/runtime_bindings_test.go b/platform/service/runtime_bindings_test.go index 9283540..342ec60 100644 --- a/platform/service/runtime_bindings_test.go +++ b/platform/service/runtime_bindings_test.go @@ -95,7 +95,7 @@ func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) { 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"}) - 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) } } diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index 1299eb9..b1c8d4a 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -291,19 +291,20 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act return domain.Job{}, err } profileKey := "" - if err == nil { + if err == nil && strings.TrimSpace(binding.ProfileKey) != "" { profileKey = binding.ProfileKey } else { profileKey = instance.Deployment.ProfileKey } - actionRef := profileKey + actionRef := "" profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey) if hasProfile { if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" { actionRef = ref } - } else if ref := lifecycleActionRef(plugin, action); ref != "" { - actionRef = ref + } + if strings.TrimSpace(actionRef) == "" { + actionRef = lifecycleActionRef(plugin, action) } if strings.TrimSpace(actionRef) == "" { return domain.Job{}, validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))