diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 47ac6bd..6048d6f 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -290,7 +290,10 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) error { if definition := job.ExecutionInput.Deployment; definition != nil { receipt := result.ExecutionResult.DeploymentReceipt - if receipt == nil || receipt.SchemaVersion != "1" || receipt.Revision != definition.Revision || receipt.Action != job.ExecutionInput.LifecycleOperation || receipt.Mode != definition.Mode || receipt.Shell != definition.Shell { + if result.State == domain.JobStateSucceeded && definition.Mode == domain.ServerDeploymentModeCustom && receipt == nil { + return validationError("deployment execution receipt does not match leased definition") + } + if receipt != nil && (receipt.SchemaVersion != "1" || receipt.Revision != definition.Revision || receipt.Action != job.ExecutionInput.LifecycleOperation || receipt.Mode != definition.Mode || receipt.Shell != definition.Shell) { return validationError("deployment execution receipt does not match leased definition") } } diff --git a/platform/service/server_deployment_test.go b/platform/service/server_deployment_test.go index ccd718f..5bfc149 100644 --- a/platform/service/server_deployment_test.go +++ b/platform/service/server_deployment_test.go @@ -110,6 +110,134 @@ func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testin } } +func TestCoreServiceDeploymentLifecycleFailureDoesNotRequireExecutionReceipt(t *testing.T) { + svc, sessionToken := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "deployment-failure-owner", DisplayName: "Deployment Failure Owner", Email: "deployment-failure@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + endpoint, err := svc.store.RunEndpoints().Get("run-local") + if err != nil { + t.Fatalf("get endpoint: %v", err) + } + endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatalf("enable deployment capability: %v", err) + } + created, err := svc.CreateServerInstanceWorkflowForSession(ownerSession, domain.ServerLifecycleCreate{ + ID: "deployment-failure", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Deployment Failure", IdempotencyKey: "deployment-failure-create", ProfileKey: "local", + Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: "C:\\scumserver", WorkingDirectory: "C:\\scumserver", CreateInputs: map[string]string{"gamePort": "27000", "maxPlayers": "128"}}, + }) + if err != nil { + t.Fatalf("create deployment lifecycle server: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil { + t.Fatalf("claim deployment lifecycle job: claim=%+v err=%v", claim, err) + } + if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "installing"}); err != nil { + t.Fatalf("ack deployment lifecycle job: %v", err) + } + result, err := svc.CompleteRunJob(domain.RunJobResult{ + RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, + State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100, Message: "exit status 1"}, Message: "exit status 1", ErrorCode: "lifecycle_process_failed", + }) + if err != nil { + t.Fatalf("failed deployment lifecycle result without receipt should be terminal: %v", err) + } + if result.Job.State != domain.JobStateFailed { + t.Fatalf("expected terminal failed job, got %+v", result.Job) + } + stored, err := svc.GetJob(claim.Job.JobID) + if err != nil { + t.Fatalf("get terminal failed job: %v", err) + } + if stored.State != domain.JobStateFailed || stored.TerminalAt.IsZero() { + t.Fatalf("expected stored terminal failed job, got %+v", stored) + } + instance, err := svc.GetServerInstance(created.Instance.ID) + if err != nil { + t.Fatalf("get failed deployment instance: %v", err) + } + if instance.State != domain.ServerInstanceStateFailed { + t.Fatalf("expected failed deployment lifecycle to project instance failed, got %+v", instance) + } +} + +func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t *testing.T) { + svc, sessionToken := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "guided-success-owner", DisplayName: "Guided Success Owner", Email: "guided-success@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + endpoint, err := svc.store.RunEndpoints().Get("run-local") + if err != nil { + t.Fatalf("get endpoint: %v", err) + } + endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatalf("enable deployment capability: %v", err) + } + created, err := svc.CreateServerInstanceWorkflowForSession(ownerSession, domain.ServerLifecycleCreate{ + ID: "guided-success", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Guided Success", IdempotencyKey: "guided-success-create", ProfileKey: "local", + Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: "C:\\scumserver", WorkingDirectory: "C:\\scumserver", CreateInputs: map[string]string{"gamePort": "27000", "maxPlayers": "128"}}, + }) + if err != nil { + t.Fatalf("create guided lifecycle server: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil { + t.Fatalf("claim guided lifecycle job: claim=%+v err=%v", claim, err) + } + if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "installing"}); err != nil { + t.Fatalf("ack guided lifecycle job: %v", err) + } + if _, err := svc.CompleteRunJob(domain.RunJobResult{ + RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, + State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"}, Message: "done", ResultRef: "artifact://jobs/guided-success/lifecycle-result", + }); err != nil { + t.Fatalf("guided plugin lifecycle success without receipt should be terminal: %v", err) + } + instance, err := svc.GetServerInstance(created.Instance.ID) + if err != nil { + t.Fatalf("get ready guided instance: %v", err) + } + if instance.State != domain.ServerInstanceStateReady { + t.Fatalf("expected guided plugin lifecycle success to mark ready, got %+v", instance) + } +} + +func TestCoreServiceCustomCommandLifecycleSuccessRequiresExecutionReceipt(t *testing.T) { + svc, sessionToken := newLifecycleRunService(t) + createLifecyclePlugin(t, svc) + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "custom-receipt-owner", DisplayName: "Custom Receipt Owner", Email: "custom-receipt@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + endpoint, err := svc.store.RunEndpoints().Get("run-local") + if err != nil { + t.Fatalf("get endpoint: %v", err) + } + endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatalf("enable deployment capability: %v", err) + } + created, err := svc.CreateServerInstanceWorkflowForSession(ownerSession, domain.ServerLifecycleCreate{ + ID: "custom-receipt", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Custom Receipt", IdempotencyKey: "custom-receipt-create", ProfileKey: "local", + Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "C:\\custom", WorkingDirectory: "C:\\custom", StartCommand: "server.exe"}, + }) + if err != nil { + t.Fatalf("create custom lifecycle server: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil { + t.Fatalf("claim custom lifecycle job: claim=%+v err=%v", claim, err) + } + if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "installing"}); err != nil { + t.Fatalf("ack custom lifecycle job: %v", err) + } + _, err = svc.CompleteRunJob(domain.RunJobResult{ + RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, + State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"}, Message: "done", + }) + if err == nil || !strings.Contains(err.Error(), "deployment execution receipt") { + t.Fatalf("expected custom-command success without receipt rejection, got %v", err) + } +} + func TestMergeDeploymentPreservesOmittedShellAndClearsOnlyExplicitFields(t *testing.T) { current := domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/game", StartCommand: "./start", StopCommand: "./stop", Shell: domain.ServerCommandShellCmd} preserved := mergeDeploymentDefinition(current, domain.ServerDeploymentUpdate{Mode: domain.ServerDeploymentModeCustom}) diff --git a/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd b/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd index e75f40b..4065252 100755 --- a/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd +++ b/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd @@ -9,7 +9,7 @@ if "%SERVER_EXECUTABLE_REF%"=="" set "SERVER_EXECUTABLE_REF=SCUM\Binaries\Win64\ set "STEAMCMD_DIR=%SERVER_ROOT%\steamcmd" set "STEAMCMD_EXE=%STEAMCMD_DIR%\steamcmd.exe" -set "SCUM_EXE=%SERVER_ROOT%\%SERVER_EXECUTABLE_REF:/=\%" +set "SCUM_EXE_MARKER=%SERVER_ROOT%\.scum-exe-path" if not exist "%SERVER_ROOT%" mkdir "%SERVER_ROOT%" if not exist "%STEAMCMD_DIR%" mkdir "%STEAMCMD_DIR%" @@ -27,5 +27,15 @@ set "SCUM_STEAMCMD_RESULT=%ERRORLEVEL%" popd if not "%SCUM_STEAMCMD_RESULT%"=="0" exit /b %SCUM_STEAMCMD_RESULT% +call :resolve_scum_exe if not exist "%SCUM_EXE%" exit /b 2 +>"%SCUM_EXE_MARKER%" echo(%SCUM_EXE% +exit /b 0 + +:resolve_scum_exe +set "SCUM_EXE=%SERVER_ROOT%\%SERVER_EXECUTABLE_REF:/=\%" +if exist "%SCUM_EXE%" exit /b 0 +set "SCUM_EXE=%SERVER_ROOT%\steamcmd\steamapps\common\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%" +if exist "%SCUM_EXE%" exit /b 0 +set "SCUM_EXE=%SERVER_ROOT%\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%" exit /b 0 diff --git a/plugins/examples/scum-server-plugin/bin/scum-start.cmd b/plugins/examples/scum-server-plugin/bin/scum-start.cmd index c79c8b6..f37596d 100755 --- a/plugins/examples/scum-server-plugin/bin/scum-start.cmd +++ b/plugins/examples/scum-server-plugin/bin/scum-start.cmd @@ -7,11 +7,26 @@ if "%SERVER_CREATE_GAMEPORT%"=="" set "SERVER_CREATE_GAMEPORT=7779" if "%SERVER_CREATE_MAXPLAYERS%"=="" set "SERVER_CREATE_MAXPLAYERS=128" if "%SERVER_LOG_FLAG%"=="" set "SERVER_LOG_FLAG=-log" -set "SCUM_EXE=%SERVER_ROOT%\%SERVER_EXECUTABLE_REF:/=\%" +set "SCUM_EXE_MARKER=%SERVER_ROOT%\.scum-exe-path" +if exist "%SCUM_EXE_MARKER%" set /p SCUM_EXE=<"%SCUM_EXE_MARKER%" +if "%SCUM_EXE%"=="" set "SCUM_EXE=%SERVER_ROOT%\%SERVER_EXECUTABLE_REF:/=\%" +if not exist "%SCUM_EXE%" call :resolve_scum_exe if not exist "%SCUM_EXE%" exit /b 2 -pushd "%SERVER_ROOT%" +for %%I in ("%SCUM_EXE%") do set "SCUM_EXE_DIR=%%~dpI" +for %%I in ("%SCUM_EXE_DIR%..\..\..") do set "SCUM_WORKDIR=%%~fI" +if not exist "%SCUM_WORKDIR%" set "SCUM_WORKDIR=%SERVER_ROOT%" + +pushd "%SCUM_WORKDIR%" "%SCUM_EXE%" -port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG% set "SCUM_START_RESULT=%ERRORLEVEL%" popd exit /b %SCUM_START_RESULT% + +:resolve_scum_exe +set "SCUM_EXE=%SERVER_ROOT%\%SERVER_EXECUTABLE_REF:/=\%" +if exist "%SCUM_EXE%" exit /b 0 +set "SCUM_EXE=%SERVER_ROOT%\steamcmd\steamapps\common\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%" +if exist "%SCUM_EXE%" exit /b 0 +set "SCUM_EXE=%SERVER_ROOT%\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%" +exit /b 0 diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 31e45e3..ed05319 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -203,6 +203,11 @@ describe("plugin manifest validation", () => { expect(startAction).toMatchObject({ executableKey: "bin/scum-start.cmd", environment: { SERVER_LOG_FLAG: "-log" } }); expect(installScript).toContain("taskkill /IM SCUMServer.exe /F"); expect(installScript).toContain("%SERVER_STEAMCMD_INSTALL_DIR_ARG% \"%SERVER_ROOT%\" %SERVER_STEAMCMD_UPDATE_ARGS%"); + expect(installScript).toContain("steamcmd\\steamapps\\common\\SCUM Server"); + expect(installScript).toContain(".scum-exe-path"); + expect(startScript).toContain(".scum-exe-path"); + expect(startScript).toContain("steamcmd\\steamapps\\common\\SCUM Server"); + expect(startScript).toContain("SCUM_WORKDIR"); expect(startScript).toContain("-port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG%"); });