Fix SCUM lifecycle install path handling

This commit is contained in:
npc0-hue
2026-08-01 11:04:51 +08:00
parent 64c703b2c5
commit 8c1c457a06
5 changed files with 165 additions and 4 deletions
+4 -1
View File
@@ -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")
}
}
+128
View File
@@ -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})
@@ -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
@@ -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
@@ -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%");
});