Add SCUM file management workbench

This commit is contained in:
npc0-hue
2026-08-04 11:33:34 +08:00
parent 2921edb401
commit f028a343d7
36 changed files with 1384 additions and 158 deletions
+2 -1
View File
@@ -130,7 +130,8 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
// queueManagedGuidedDeploymentAfterRegistration advances only a newly-created,
// dedicated guided server. Selecting guided-install is the owner's prior
// authorization for this bounded write; reconnects remain idempotent.
// authorization for the plugin-declared bootstrap action; reconnects remain
// idempotent.
func (svc *CoreService) queueManagedGuidedDeploymentAfterRegistration(hello domain.RunControlHello) error {
if hello.ComponentKind != domain.DistributionComponentRun || strings.TrimSpace(hello.ServerInstanceID) == "" {
return nil
+15 -15
View File
@@ -291,16 +291,16 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
stored, err := svc.GetServerInstance(guided.Instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateInstalling {
t.Fatalf("guided registration should queue install, server=%+v err=%v", stored, err)
t.Fatalf("guided registration should queue bootstrap start, server=%+v err=%v", stored, err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID})
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall {
t.Fatalf("expected one automatic install job, jobs=%+v err=%v", jobs, err)
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].TargetKey != "actions/start.json" || len(jobs[0].ExecutionInput.LogSources) != 2 {
t.Fatalf("expected one automatic supervised start job, jobs=%+v err=%v", jobs, err)
}
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID})
if len(jobs) != 1 {
t.Fatalf("Run reconnect must not duplicate automatic install, jobs=%+v", jobs)
t.Fatalf("Run reconnect must not duplicate automatic bootstrap, jobs=%+v", jobs)
}
generatedRunDraft := domain.ServerInstance{
@@ -319,11 +319,11 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
registerDedicatedRunForTest(t, svc, generatedRunDraft, plugin.ID)
storedGenerated, err := svc.GetServerInstance(generatedRunDraft.ID)
if err != nil || storedGenerated.State != domain.ServerInstanceStateInstalling {
t.Fatalf("generated Run registration should queue install without deployment target, server=%+v err=%v", storedGenerated, err)
t.Fatalf("generated Run registration should queue supervised start 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 || jobs[0].ExecutionInput.WorkspaceScope != "local" {
t.Fatalf("expected one scoped automatic generated Run install job, jobs=%+v err=%v", jobs, err)
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].ExecutionInput.WorkspaceScope != "local" {
t.Fatalf("expected one scoped automatic generated Run start 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"}})
@@ -337,7 +337,7 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
}
}
func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T) {
func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedStart(t *testing.T) {
svc := newTestCoreService()
plugin := scumDeploymentTestPlugin()
plugin.Name = "SCUM"
@@ -424,22 +424,22 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
stored, err := svc.GetServerInstance(instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateInstalling {
t.Fatalf("generated SCUM registration should queue install, server=%+v err=%v", stored, err)
t.Fatalf("generated SCUM registration should queue supervised bootstrap start, server=%+v err=%v", stored, err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 1 {
t.Fatalf("expected one SCUM install job, jobs=%+v err=%v", jobs, err)
t.Fatalf("expected one SCUM start job, jobs=%+v err=%v", jobs, err)
}
job := jobs[0]
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.Capability != domain.LifecycleCapabilityStart || job.TargetKey != "actions/start.json" || job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil || len(job.ExecutionInput.LogSources) == 0 {
t.Fatalf("expected SCUM supervised start 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)
t.Fatalf("SCUM start 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.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)
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/start.json" || claim.Job.ExecutionInput.WorkspaceScope != "run-local" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
t.Fatalf("generated SCUM Run should claim scoped plugin-owned start action, claim=%+v err=%v", claim, err)
}
}
+137
View File
@@ -123,6 +123,7 @@ type Core interface {
ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error)
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error)
ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error)
DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error)
@@ -1883,6 +1884,120 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan
return domain.CopyServerConfig(config), nil
}
func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, serverInstanceID string, fileKey string) (domain.DeclaredFileReadSnapshot, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.DeclaredFileReadSnapshot{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.DeclaredFileReadSnapshot{}, err
}
if plugin.Status != domain.GamePluginStatusInstalled || (!plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read")) {
return domain.DeclaredFileReadSnapshot{}, ErrForbidden
}
file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, domain.FileOperationDispatchRequest{Operation: domain.FileOperationRead, Key: fileKey})
if !constrained || !allowed || file.Key == "" {
return domain.DeclaredFileReadSnapshot{}, validationError("file key must reference a plugin-declared file")
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.DeclaredFileReadSnapshot{}, err
}
var completed *domain.Job
var pending *domain.Job
for i := range jobs {
job := jobs[i]
if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != file.Key {
continue
}
if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
if completed == nil || newerJob(job, *completed) {
copy := job
completed = &copy
}
continue
}
if declaredFileReadPendingState(job.State) && (pending == nil || newerJob(job, *pending)) {
copy := job
pending = &copy
}
}
base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: file.Key}
if completed != nil {
return domain.DeclaredFileReadSnapshot{
ServerInstanceID: base.ServerInstanceID,
PluginID: base.PluginID,
Key: base.Key,
State: "ready",
Content: redactDeclaredFileReadContent(completed.ExecutionResult.Content),
Version: completed.ExecutionResult.Version,
Checksum: completed.ExecutionResult.Checksum,
SizeBytes: completed.ExecutionResult.SizeBytes,
JobID: completed.ID,
ReadAt: jobCompletedAt(*completed),
}, nil
}
if pending != nil {
base.State = "pending"
base.JobID = pending.ID
base.Reason = "等待运行端完成文件读取。"
return base, nil
}
base.State = "not-read"
base.Reason = "尚未读取此声明文件。"
return base, nil
}
func declaredFileReadPendingState(state domain.JobState) bool {
switch state {
case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateRetrying:
return true
default:
return false
}
}
func newerJob(left domain.Job, right domain.Job) bool {
leftTime, rightTime := jobCompletedAt(left), jobCompletedAt(right)
if !leftTime.Equal(rightTime) {
return leftTime.After(rightTime)
}
return left.ID > right.ID
}
func jobCompletedAt(job domain.Job) time.Time {
if !job.TerminalAt.IsZero() {
return job.TerminalAt
}
if !job.UpdatedAt.IsZero() {
return job.UpdatedAt
}
return job.CreatedAt
}
func redactDeclaredFileReadContent(content string) string {
lines := strings.Split(content, "\n")
for index, line := range lines {
key, _, found := strings.Cut(line, "=")
if !found || !secretLikeFileAssignmentKey(key) {
continue
}
lines[index] = key + "=<redacted>"
}
return strings.Join(lines, "\n")
}
func secretLikeFileAssignmentKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(key), "_", ""), "-", ""))
for _, marker := range []string{"password", "passwd", "secret", "token", "apikey", "accesskey", "privatekey", "rcon"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, request domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) {
if request.Key == "" {
request.Key = "server.properties"
@@ -2022,6 +2137,12 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") {
return domain.FileOperationDispatchResult{}, ErrForbidden
}
if file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, request); constrained && !allowed {
if file.Key == "" {
return domain.FileOperationDispatchResult{}, validationError("file key must reference a plugin-declared file")
}
return domain.FileOperationDispatchResult{}, validationError("file key is not writable by plugin declaration")
}
}
capability := domain.JobCapabilityFilesRead
message := "file read queued"
@@ -2054,6 +2175,22 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
}), nil
}
func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) {
if len(workspace.Files) == 0 {
return domain.PluginLogicalFile{}, false, true
}
for _, file := range workspace.Files {
if file.Key != request.Key {
continue
}
if request.Operation == domain.FileOperationWrite && (file.Kind != "config" || !file.Editable) {
return file, true, false
}
return file, true, true
}
return domain.PluginLogicalFile{}, true, false
}
func (svc *CoreService) runtimeProfileScope(serverInstanceID string) string {
binding, err := svc.runtimeBindingForServer(serverInstanceID)
if err != nil {
+159
View File
@@ -839,6 +839,165 @@ func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) {
}
}
func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.FileWorkspace = scumTestFileWorkspace()
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin workspace: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
ID: "user-owner-file-workspace",
DisplayName: "File Workspace Owner",
Email: "file-workspace-owner@example.test",
Roles: []string{"server-owner"},
PasswordHash: "secret-password",
})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
ID: "server-file-workspace",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "File Workspace Server",
State: domain.ServerInstanceStateRunning,
})
if err != nil {
t.Fatalf("create server: %v", err)
}
createCompleteRuntimeBinding(t, svc, instance, "local")
allowed, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
Operation: domain.FileOperationRead,
Key: "scum-server-settings",
IdempotencyKey: "idem-file-workspace-read",
})
if err != nil {
t.Fatalf("dispatch declared file read: %v", err)
}
if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead {
t.Fatalf("unexpected declared file dispatch: %+v", allowed)
}
if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
Operation: domain.FileOperationRead,
Key: "logs/latest.log",
IdempotencyKey: "idem-file-workspace-unknown",
}); err == nil || !strings.Contains(err.Error(), "plugin-declared file") {
t.Fatalf("expected undeclared file key rejection, got %v", err)
}
if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
Operation: domain.FileOperationWrite,
Key: "scum-chat-log",
InputRef: "input://file-workspace/update",
Content: "line",
IdempotencyKey: "idem-file-workspace-log-write",
}); err == nil || !strings.Contains(err.Error(), "not writable") {
t.Fatalf("expected log write rejection, got %v", err)
}
}
func TestDeclaredFileReadSnapshotProjectionStatesAndRedaction(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.FileWorkspace = scumTestFileWorkspace()
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin workspace: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-snapshot-owner", DisplayName: "File Snapshot Owner", Email: "file-snapshot-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-snapshot-other", DisplayName: "File Snapshot Other", Email: "file-snapshot-other@example.test", Roles: []string{"server-admin"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-snapshot", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Snapshot Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
snapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
if err != nil || snapshot.State != "not-read" {
t.Fatalf("expected not-read without jobs, snapshot=%+v err=%v", snapshot, err)
}
queued := createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-queued", domain.JobStateQueued, 1, "")
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
if err != nil || snapshot.State != "pending" || snapshot.JobID != queued.ID {
t.Fatalf("expected pending queued job, snapshot=%+v err=%v", snapshot, err)
}
queued.State = domain.JobStateFailed
queued.UpdatedAt = fixedTime.Add(2 * time.Minute)
queued.TerminalAt = fixedTime.Add(2 * time.Minute)
if err := svc.store.Jobs().Update(queued); err != nil {
t.Fatalf("update failed read job: %v", err)
}
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-cancelled", domain.JobStateCancelled, 3, "")
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
if err != nil || snapshot.State != "not-read" {
t.Fatalf("failed/cancelled reads must not mask not-read, snapshot=%+v err=%v", snapshot, err)
}
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-old", domain.JobStateSucceeded, 4, "ServerName=Old\nRconPassword=secret\n")
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-failed-newer", domain.JobStateFailed, 5, "")
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
if err != nil || snapshot.State != "ready" || snapshot.JobID != "job-file-snapshot-success-old" || !strings.Contains(snapshot.Content, "RconPassword=<redacted>") {
t.Fatalf("expected older successful redacted result, snapshot=%+v err=%v", snapshot, err)
}
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-new", domain.JobStateSucceeded, 6, "ServerName=New\nApiToken=secret\n")
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
if err != nil || snapshot.JobID != "job-file-snapshot-success-new" || !strings.Contains(snapshot.Content, "ServerName=New") || strings.Contains(snapshot.Content, "secret") {
t.Fatalf("expected newest successful redacted result, snapshot=%+v err=%v", snapshot, err)
}
if _, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log"); err == nil || !strings.Contains(err.Error(), "plugin-declared file") {
t.Fatalf("expected unknown logical key rejection, got %v", err)
}
if _, err := svc.GetDeclaredFileReadSnapshotForSession(otherSession, instance.ID, "scum-server-settings"); !errors.Is(err, ErrForbidden) {
t.Fatalf("expected unrelated session forbidden, got %v", err)
}
}
func scumTestFileWorkspace() domain.PluginFileWorkspace {
return domain.PluginFileWorkspace{
DefaultDirectoryKey: "scum-config",
Directories: []domain.PluginLogicalDirectory{
{Key: "scum-config", Label: "服务器配置", Scope: "config"},
{Key: "scum-logs", Label: "日志文件", Scope: "logs"},
},
Files: []domain.PluginLogicalFile{
{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true},
{Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"},
},
ConfigFields: []domain.PluginConfigField{
{Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"},
},
}
}
func createDeclaredFileReadJob(t *testing.T, svc *CoreService, instance domain.ServerInstance, endpoint domain.RunEndpoint, id string, state domain.JobState, minuteOffset int, content string) domain.Job {
t.Helper()
job, err := svc.CreateJob(domain.Job{
ID: id,
ServerInstanceID: instance.ID,
RunEndpointID: endpoint.ID,
Capability: domain.JobCapabilityFilesRead,
TargetKey: "scum-server-settings",
IdempotencyKey: id,
})
if err != nil {
t.Fatalf("create declared file read job: %v", err)
}
stamp := fixedTime.Add(time.Duration(minuteOffset) * time.Minute)
job.State = state
job.UpdatedAt = stamp
if state == domain.JobStateSucceeded || state == domain.JobStateFailed || state == domain.JobStateCancelled {
job.TerminalAt = stamp
}
if state == domain.JobStateSucceeded {
job.ExecutionResult = domain.JobExecutionResult{Kind: "file.read", Version: minuteOffset, Checksum: validator.BytesChecksum([]byte(content)), SizeBytes: int64(len(content)), Content: content}
}
if err := svc.store.Jobs().Update(job); err != nil {
t.Fatalf("update declared file read job: %v", err)
}
return job
}
func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
+8 -7
View File
@@ -85,8 +85,8 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command
}
// deployServerInstance is the platform-owned transition from a saved deployment
// definition to one fenced install job. Callers must already have established
// the authority to act for the server.
// definition to one fenced plugin-owned bootstrap job. Callers must already
// have established the authority to act for the server.
func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
if err := validator.ValidateServerLifecycleCommand(command); err != nil {
return domain.ServerLifecycleResult{}, err
@@ -118,16 +118,17 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
return domain.ServerLifecycleResult{}, err
}
instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment)
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
bootstrapAction := deploymentBootstrapLifecycleAction(plugin, instance)
if err := validateLifecycleActionRef(plugin, bootstrapAction); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil {
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, bootstrapAction); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityInstall); err != nil {
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, command.IdempotencyKey, instance.ID, domain.LifecycleCapabilityInstall); err != nil {
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, command.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
return domain.ServerLifecycleResult{}, err
}
if strings.TrimSpace(instance.Deployment.ProfileKey) != "" && deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
@@ -153,7 +154,7 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, command.IdempotencyKey)
job, err := svc.dispatchLifecycleJob(instance, bootstrapAction, command.IdempotencyKey)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
+13 -13
View File
@@ -99,7 +99,7 @@ func TestCoreServiceUpdatesDeploymentWhileServerIsActive(t *testing.T) {
}
}
func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) {
func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedSupervisedStartAction(t *testing.T) {
svc := newTestCoreService()
runHello := validRunControlHello()
runHello.RunEndpointID = "run-scum-guided"
@@ -132,15 +132,15 @@ func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testin
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.Capability != domain.LifecycleCapabilityStart || created.Job.TargetKey != "actions/start.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil || len(created.Job.ExecutionInput.LogSources) == 0 {
t.Fatalf("expected plugin-owned supervised start 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)
t.Fatalf("SCUM start 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)
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/start.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
t.Fatalf("claimed SCUM start must use plugin action without SCUM plan, claim=%+v err=%v", claim, err)
}
}
@@ -163,11 +163,11 @@ func TestCoreServiceDeploymentLifecycleFailureDoesNotRequireExecutionReceipt(t *
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}})
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, 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 {
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "starting"}); err != nil {
t.Fatalf("ack deployment lifecycle job: %v", err)
}
result, err := svc.CompleteRunJob(domain.RunJobResult{
@@ -215,11 +215,11 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t
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}})
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, 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 {
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "starting"}); err != nil {
t.Fatalf("ack guided lifecycle job: %v", err)
}
if _, err := svc.CompleteRunJob(domain.RunJobResult{
@@ -232,8 +232,8 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t
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)
if instance.State != domain.ServerInstanceStateRunning {
t.Fatalf("expected guided plugin lifecycle success to mark running, got %+v", instance)
}
}
+22 -4
View File
@@ -109,13 +109,17 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
if err != nil {
return domain.ServerLifecycleResult{}, fmt.Errorf("get run endpoint dependency: %w", err)
}
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil {
bootstrapAction := deploymentBootstrapLifecycleAction(plugin, instance)
if err := validateLifecycleActionRef(plugin, bootstrapAction); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, bootstrapAction); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
return domain.ServerLifecycleResult{}, err
}
var binding domain.RuntimeBinding
@@ -135,7 +139,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
}
}
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey)
job, err := svc.dispatchLifecycleJob(instance, bootstrapAction, create.IdempotencyKey)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
@@ -358,6 +362,20 @@ func lifecycleProcessLogSources(profiles domain.GamePluginRuntimeProfiles) []dom
return sources
}
func deploymentBootstrapLifecycleAction(plugin domain.GamePlugin, instance domain.ServerInstance) domain.ServerLifecycleAction {
if instance.Deployment.Mode != domain.ServerDeploymentModeGuided {
return domain.ServerLifecycleActionCreate
}
profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, instance.Deployment.ProfileKey)
if !exists || !containsString(profile.Capabilities, domain.LifecycleCapabilityStart) {
return domain.ServerLifecycleActionCreate
}
if strings.TrimSpace(runtimeProfileActionRef(profile.ActionRefs, domain.ServerLifecycleActionStart)) == "" && strings.TrimSpace(plugin.LifecycleActions.Start) == "" {
return domain.ServerLifecycleActionCreate
}
return domain.ServerLifecycleActionStart
}
func lifecycleJobProgress(deployment domain.ServerDeploymentDefinition) domain.JobProgress {
if deployment.Mode != "" {
return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"}