286 lines
18 KiB
Go
286 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"browser.local/platform/domain"
|
|
)
|
|
|
|
func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibleRun(t *testing.T) {
|
|
svc, _ := newLifecycleRunService(t)
|
|
createLifecyclePlugin(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "deployment-owner", DisplayName: "Deployment Owner", Email: "deployment-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
|
|
draft, err := svc.CreateServerInstanceWorkflowForSession(ownerSession, domain.ServerLifecycleCreate{
|
|
ID: "venv-draft", PluginID: "server.scum", Name: "Venv server", IdempotencyKey: "draft-venv",
|
|
Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/venv-server", WorkingDirectory: "/srv/venv-server", StartCommand: "/srv/venv-server/.venv/bin/python server.py"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create unbound deployment draft: %v", err)
|
|
}
|
|
if draft.Instance.State != domain.ServerInstanceStateDraft || draft.Job.ID != "" {
|
|
t.Fatalf("draft must have no lifecycle job, got %+v", draft)
|
|
}
|
|
view, err := svc.GetServerDeploymentForSession(ownerSession, draft.Instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("read deployment view: %v", err)
|
|
}
|
|
if !view.ServerRootConfigured || !view.WorkingDirectoryConfigured || !view.StartCommandConfigured {
|
|
t.Fatalf("expected protected values to be marked configured: %+v", view)
|
|
}
|
|
if strings.Contains(strings.Join([]string{view.ServerInstanceID, string(view.Mode), view.ProfileKey}, " "), "/srv/") {
|
|
t.Fatalf("redacted deployment view leaked host path: %+v", view)
|
|
}
|
|
revealed, err := svc.RevealServerDeploymentForSession(ownerSession, draft.Instance.ID)
|
|
if err != nil || revealed.ServerRoot != "/srv/venv-server" || revealed.WorkingDirectory != "/srv/venv-server" || revealed.StartCommand != "/srv/venv-server/.venv/bin/python server.py" {
|
|
t.Fatalf("expected explicit deployment reveal, reveal=%+v err=%v", revealed, err)
|
|
}
|
|
|
|
if _, err := svc.UpdateServerDeploymentForSession(ownerSession, draft.Instance.ID, domain.ServerDeploymentUpdate{RunEndpointID: "run-local", Mode: domain.ServerDeploymentModeCustom}); err != nil {
|
|
t.Fatalf("bind draft to run: %v", err)
|
|
}
|
|
if _, err := svc.DeployServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: draft.Instance.ID, ExpectedConfigVersion: draft.Instance.ConfigVersion, IdempotencyKey: "deploy-incompatible"}); err == nil || !strings.Contains(err.Error(), "deployment.plan.v1") {
|
|
t.Fatalf("expected incompatible Run rejection, got %v", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
deployed, err := svc.DeployServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: draft.Instance.ID, ExpectedConfigVersion: draft.Instance.ConfigVersion, IdempotencyKey: "deploy-compatible"})
|
|
if err != nil {
|
|
t.Fatalf("deploy compatible draft: %v", err)
|
|
}
|
|
if deployed.Job.ExecutionInput.Deployment == nil || deployed.Job.ExecutionInput.Deployment.StartCommand != "/srv/venv-server/.venv/bin/python server.py" || deployed.Job.Progress.Phase != "queued" {
|
|
t.Fatalf("Run job must carry protected plan and queued phase: %+v", deployed.Job)
|
|
}
|
|
view, err = svc.GetServerDeploymentForSession(ownerSession, draft.Instance.ID)
|
|
if err != nil || view.LatestDispatch == nil || view.LatestDispatch.JobID != deployed.Job.ID || view.LatestDispatch.DeploymentRevision != deployed.Job.ExecutionInput.Deployment.Revision || !view.LatestDispatch.DeploymentDefinitionIncluded {
|
|
t.Fatalf("expected safe dispatch evidence, view=%+v err=%v", view, err)
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceUpdatesDeploymentWhileServerIsActive(t *testing.T) {
|
|
svc, _ := newLifecycleRunService(t)
|
|
createLifecyclePlugin(t, svc)
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "deployment-active-owner", DisplayName: "Deployment Active Owner", Email: "deployment-active-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
|
|
for _, state := range []domain.ServerInstanceState{domain.ServerInstanceStateInstalling, domain.ServerInstanceStateRunning} {
|
|
stateLabel := string(state)
|
|
created, err := svc.CreateServerInstanceWorkflowForSession(ownerSession, domain.ServerLifecycleCreate{
|
|
ID: "active-deployment-edit-" + stateLabel, PluginID: "server.scum", Name: "Active Deployment Edit " + stateLabel, IdempotencyKey: "active-deployment-edit-" + stateLabel,
|
|
Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/active-server-" + stateLabel, WorkingDirectory: "/srv/active-server-" + stateLabel, StartCommand: "./start"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create %s deployment fixture: %v", state, err)
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(created.Instance.ID)
|
|
if err != nil {
|
|
t.Fatalf("get %s deployment fixture: %v", state, err)
|
|
}
|
|
instance.RunEndpointID = "run-local"
|
|
instance.State = state
|
|
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
|
t.Fatalf("mark fixture %s: %v", state, err)
|
|
}
|
|
|
|
view, err := svc.UpdateServerDeploymentForSession(ownerSession, instance.ID, domain.ServerDeploymentUpdate{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/active-server-next-" + stateLabel, StartCommand: "./start-next"})
|
|
if err != nil {
|
|
t.Fatalf("update %s deployment: %v", state, err)
|
|
}
|
|
if view.Revision != instance.Deployment.Revision+1 || !view.ServerRootConfigured || !view.StartCommandConfigured {
|
|
t.Fatalf("expected %s deployment revision and protected field flags to update, view=%+v", state, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) {
|
|
svc := newTestCoreService()
|
|
runHello := validRunControlHello()
|
|
runHello.RunEndpointID = "run-scum-guided"
|
|
runHello.Platform = "windows"
|
|
runHello.Architecture = "amd64"
|
|
runHello.CapabilityReport.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}
|
|
runHello.CapabilityReport.Fingerprint = "cap-scum-guided"
|
|
session, err := svc.RegisterRunHello(runHello)
|
|
if err != nil {
|
|
t.Fatalf("register scoped SCUM Run: %v", err)
|
|
}
|
|
plugin := scumDeploymentTestPlugin()
|
|
plugin.Name = "SCUM"
|
|
plugin.Version = "1.0.0"
|
|
plugin.ServerType = "scum"
|
|
plugin.Status = domain.GamePluginStatusInstalled
|
|
plugin.RequiredRunCapabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "process.restart", "files.list", "files.patch", domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, "artifacts.read", "artifacts.write", "ai.invoke"}
|
|
plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"}
|
|
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}}
|
|
requireManualSCUMRuntimeBindings(&plugin, "local")
|
|
if err := svc.store.GamePlugins().Create(plugin); err != nil {
|
|
t.Fatalf("create SCUM plugin fixture: %v", err)
|
|
}
|
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "scum-guided-owner", DisplayName: "SCUM Guided Owner", Email: "scum-guided@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
|
|
created, err := svc.CreateServerInstanceWorkflowForSession(ownerSession, domain.ServerLifecycleCreate{
|
|
ID: "scum-guided-scoped", PluginID: plugin.ID, RunEndpointID: "run-scum-guided", Name: "SCUM Guided", IdempotencyKey: "scum-guided-scoped-create", ProfileKey: "local",
|
|
Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: "C:\\scum-guided", CreateInputs: map[string]string{"serverName": "Moon", "gamePort": "27000", "queryPort": "27015", "maxPlayers": "128"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SCUM guided deployment should not require unrelated manifest capabilities: %v", err)
|
|
}
|
|
if created.Job.TargetKey != "actions/install.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil {
|
|
t.Fatalf("expected plugin-owned install action without SCUM deployment plan, job=%+v", created.Job)
|
|
}
|
|
if created.Job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || created.Job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
|
|
t.Fatalf("SCUM install job lost create inputs: %+v", created.Job.ExecutionInput.Deployment.CreateInputs)
|
|
}
|
|
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
|
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
|
|
t.Fatalf("claimed SCUM install must use plugin action without SCUM plan, claim=%+v err=%v", claim, err)
|
|
}
|
|
}
|
|
|
|
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})
|
|
if preserved.Shell != domain.ServerCommandShellCmd || preserved.StopCommand != "./stop" {
|
|
t.Fatalf("omitted protected fields must be preserved: %+v", preserved)
|
|
}
|
|
cleared := mergeDeploymentDefinition(current, domain.ServerDeploymentUpdate{Mode: domain.ServerDeploymentModeCustom, ClearFields: []string{"stopCommand", "shell"}})
|
|
if cleared.StopCommand != "" || cleared.Shell != domain.ServerCommandShellNone || cleared.StartCommand != "./start" {
|
|
t.Fatalf("only explicitly cleared fields may be removed: %+v", cleared)
|
|
}
|
|
}
|