Fix declared server config file reads
This commit is contained in:
@@ -329,7 +329,7 @@ type PluginFileWorkspace struct {
|
||||
|
||||
type PluginLogicalDirectory struct{ Key, Label, Scope string }
|
||||
type PluginLogicalFile struct {
|
||||
Key, DirectoryKey, Label, Kind, StreamKey string
|
||||
Key, DirectoryKey, Label, Kind, StreamKey, TargetKey string
|
||||
Editable bool
|
||||
}
|
||||
type PluginConfigField struct {
|
||||
@@ -1209,6 +1209,7 @@ type JobRetryPolicy struct {
|
||||
type JobExecutionInput struct {
|
||||
WorkspaceScope string
|
||||
Content string
|
||||
FileTargetKey string
|
||||
ExpectedVersion int
|
||||
ExpectedChecksum string
|
||||
MaxReadBytes int
|
||||
|
||||
@@ -96,6 +96,7 @@ type RunJobResultRequest struct {
|
||||
type RunJobExecutionInputBody struct {
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
FileTargetKey string `json:"fileTargetKey,omitempty"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty"`
|
||||
@@ -663,7 +664,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
|
||||
State: assignment.State,
|
||||
Progress: progressReportFromDomain(assignment.Progress),
|
||||
ResultRef: assignment.ResultRef,
|
||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), LogSource: runtimeLogSourceFromDomain(assignment.ExecutionInput.LogSource), LogSources: runtimeLogSourcesFromDomain(assignment.ExecutionInput.LogSources), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)},
|
||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, FileTargetKey: assignment.ExecutionInput.FileTargetKey, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), LogSource: runtimeLogSourceFromDomain(assignment.ExecutionInput.LogSource), LogSources: runtimeLogSourcesFromDomain(assignment.ExecutionInput.LogSources), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)},
|
||||
LeaseToken: assignment.LeaseToken,
|
||||
Attempt: assignment.Attempt,
|
||||
FencingToken: assignment.FencingToken,
|
||||
|
||||
@@ -198,6 +198,7 @@ type PluginLogicalFileBody struct {
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
StreamKey string `json:"streamKey,omitempty"`
|
||||
TargetKey string `json:"targetKey,omitempty"`
|
||||
Editable bool `json:"editable,omitempty"`
|
||||
}
|
||||
type PluginConfigFieldBody struct {
|
||||
@@ -1146,7 +1147,7 @@ func fileWorkspaceToDomain(body PluginFileWorkspaceBody) domain.PluginFileWorksp
|
||||
workspace.Directories = append(workspace.Directories, domain.PluginLogicalDirectory{Key: item.Key, Label: item.Label, Scope: item.Scope})
|
||||
}
|
||||
for _, item := range body.Files {
|
||||
workspace.Files = append(workspace.Files, domain.PluginLogicalFile{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, Editable: item.Editable})
|
||||
workspace.Files = append(workspace.Files, domain.PluginLogicalFile{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, TargetKey: item.TargetKey, Editable: item.Editable})
|
||||
}
|
||||
for _, item := range body.ConfigFields {
|
||||
workspace.ConfigFields = append(workspace.ConfigFields, domain.PluginConfigField{Key: item.Key, FileKey: item.FileKey, ConfigKey: item.ConfigKey, Label: item.Label, Description: item.Description, Control: item.Control, Minimum: item.Minimum, Maximum: item.Maximum, DefaultValue: item.DefaultValue, RestartImpact: item.RestartImpact})
|
||||
@@ -1160,7 +1161,7 @@ func fileWorkspaceFromDomain(workspace domain.PluginFileWorkspace) PluginFileWor
|
||||
body.Directories = append(body.Directories, PluginLogicalDirectoryBody{Key: item.Key, Label: item.Label, Scope: item.Scope})
|
||||
}
|
||||
for _, item := range workspace.Files {
|
||||
body.Files = append(body.Files, PluginLogicalFileBody{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, Editable: item.Editable})
|
||||
body.Files = append(body.Files, PluginLogicalFileBody{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, TargetKey: item.TargetKey, Editable: item.Editable})
|
||||
}
|
||||
for _, item := range workspace.ConfigFields {
|
||||
body.ConfigFields = append(body.ConfigFields, PluginConfigFieldBody{Key: item.Key, FileKey: item.FileKey, ConfigKey: item.ConfigKey, Label: item.Label, Description: item.Description, Control: item.Control, Minimum: item.Minimum, Maximum: item.Maximum, DefaultValue: item.DefaultValue, RestartImpact: item.RestartImpact})
|
||||
|
||||
@@ -291,6 +291,7 @@ type JobRetryPolicy struct {
|
||||
type JobExecutionInput struct {
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty" db:"workspace_scope"`
|
||||
Content string `json:"content,omitempty" db:"content"`
|
||||
FileTargetKey string `json:"fileTargetKey,omitempty" db:"file_target_key"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty" db:"expected_version"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty" db:"expected_checksum"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty" db:"max_read_bytes"`
|
||||
@@ -868,7 +869,7 @@ func executionInputFromDomain(input domain.JobExecutionInput) JobExecutionInput
|
||||
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)}
|
||||
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, FileTargetKey: input.FileTargetKey, 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 {
|
||||
@@ -877,7 +878,7 @@ func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
|
||||
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)}
|
||||
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, FileTargetKey: input.FileTargetKey, 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 {
|
||||
|
||||
@@ -95,6 +95,7 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
|
||||
func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) {
|
||||
source := domain.JobExecutionInput{
|
||||
WorkspaceScope: "server-workspace",
|
||||
FileTargetKey: "SCUM/Saved/Config/WindowsServer/ServerSettings.ini",
|
||||
PluginID: "game.scum",
|
||||
LifecycleOperation: "upgrade",
|
||||
TargetVersion: "2.0.0",
|
||||
|
||||
@@ -691,7 +691,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
||||
State: job.State,
|
||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Phase: job.Progress.Phase, Message: job.Progress.Message},
|
||||
ResultRef: job.ResultRef,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, FileTargetKey: job.ExecutionInput.FileTargetKey, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
|
||||
LeaseToken: leaseToken,
|
||||
Attempt: job.Attempt,
|
||||
FencingToken: 0,
|
||||
|
||||
@@ -2114,6 +2114,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
|
||||
}
|
||||
var completed *domain.Job
|
||||
var pending *domain.Job
|
||||
var terminalFailure *domain.Job
|
||||
for i := range jobs {
|
||||
job := jobs[i]
|
||||
if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != fileKey {
|
||||
@@ -2129,6 +2130,11 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
|
||||
if declaredFileReadPendingState(job.State) && (pending == nil || newerJob(job, *pending)) {
|
||||
copy := job
|
||||
pending = ©
|
||||
continue
|
||||
}
|
||||
if (job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled) && (terminalFailure == nil || newerJob(job, *terminalFailure)) {
|
||||
copy := job
|
||||
terminalFailure = ©
|
||||
}
|
||||
}
|
||||
base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: fileKey}
|
||||
@@ -2152,6 +2158,16 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
|
||||
base.Reason = "等待运行端完成文件读取。"
|
||||
return base, nil
|
||||
}
|
||||
if terminalFailure != nil {
|
||||
base.State = "failed"
|
||||
if terminalFailure.State == domain.JobStateCancelled {
|
||||
base.State = "cancelled"
|
||||
}
|
||||
base.JobID = terminalFailure.ID
|
||||
base.ReadAt = jobCompletedAt(*terminalFailure)
|
||||
base.Reason = declaredFileReadFailureReason(*terminalFailure)
|
||||
return base, nil
|
||||
}
|
||||
base.State = "not-read"
|
||||
base.Reason = "尚未读取此文件。"
|
||||
return base, nil
|
||||
@@ -2166,6 +2182,24 @@ func declaredFileReadPendingState(state domain.JobState) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func declaredFileReadFailureReason(job domain.Job) string {
|
||||
prefix := "Run 文件读取失败"
|
||||
if job.State == domain.JobStateCancelled {
|
||||
prefix = "Run 文件读取已取消"
|
||||
}
|
||||
detail := strings.TrimSpace(job.CancelReason)
|
||||
if detail == "" {
|
||||
detail = strings.TrimSpace(job.Progress.Message)
|
||||
}
|
||||
if detail == "" {
|
||||
detail = strings.TrimSpace(job.ExecutionResult.Summary)
|
||||
}
|
||||
if detail == "" {
|
||||
return prefix + "。"
|
||||
}
|
||||
return prefix + ":" + detail
|
||||
}
|
||||
|
||||
func newerJob(left domain.Job, right domain.Job) bool {
|
||||
leftTime, rightTime := jobCompletedAt(left), jobCompletedAt(right)
|
||||
if !leftTime.Equal(rightTime) {
|
||||
@@ -2293,14 +2327,18 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
return domain.FileOperationDispatchResult{}, ErrForbidden
|
||||
}
|
||||
}
|
||||
if request.PluginID != "" {
|
||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||
pluginID := strings.TrimSpace(request.PluginID)
|
||||
if pluginID == "" {
|
||||
pluginID = instance.PluginID
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(pluginID)
|
||||
if err != nil {
|
||||
return domain.FileOperationDispatchResult{}, err
|
||||
}
|
||||
if plugin.ID != instance.PluginID {
|
||||
return domain.FileOperationDispatchResult{}, validationError("pluginId must match server instance")
|
||||
}
|
||||
if request.PluginID != "" {
|
||||
if plugin.Status != domain.GamePluginStatusInstalled {
|
||||
return domain.FileOperationDispatchResult{}, validationError("plugin must be installed")
|
||||
}
|
||||
@@ -2311,6 +2349,13 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
return domain.FileOperationDispatchResult{}, ErrForbidden
|
||||
}
|
||||
}
|
||||
fileTargetKey := ""
|
||||
if request.Operation != domain.FileOperationList {
|
||||
file, declared, allowed := declaredPluginFileRequest(plugin.FileWorkspace, request)
|
||||
if declared && allowed {
|
||||
fileTargetKey = file.TargetKey
|
||||
}
|
||||
}
|
||||
capability := domain.JobCapabilityFilesRead
|
||||
message := "file read queued"
|
||||
if request.Operation == domain.FileOperationList {
|
||||
@@ -2328,7 +2373,7 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
Capability: capability,
|
||||
TargetKey: request.Key,
|
||||
InputRef: request.InputRef,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), Content: content, ExpectedVersion: request.ExpectedConfigVersion, ExpectedChecksum: request.ExpectedChecksum, MaxReadBytes: 64 * 1024, Deployment: deploymentPlanForDispatch(instance.Deployment)},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), Content: content, FileTargetKey: fileTargetKey, ExpectedVersion: request.ExpectedConfigVersion, ExpectedChecksum: request.ExpectedChecksum, MaxReadBytes: 64 * 1024, PluginID: plugin.ID, Deployment: deploymentPlanForDispatch(instance.Deployment)},
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: message},
|
||||
})
|
||||
@@ -2337,7 +2382,7 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
}
|
||||
return domain.CopyFileOperationDispatchResult(domain.FileOperationDispatchResult{
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
PluginID: request.PluginID,
|
||||
PluginID: plugin.ID,
|
||||
Operation: request.Operation,
|
||||
Key: request.Key,
|
||||
InputRef: request.InputRef,
|
||||
|
||||
@@ -1019,7 +1019,7 @@ func TestPluginFileWorkspaceDoesNotConstrainServerFileDispatch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch declared file read: %v", err)
|
||||
}
|
||||
if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead || allowed.Job.ExecutionInput.Deployment == nil || allowed.Job.ExecutionInput.Deployment.ServerRoot != `C:\scumserver` {
|
||||
if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.ExecutionInput.FileTargetKey != "SCUM/Saved/Config/WindowsServer/ServerSettings.ini" || allowed.Job.Capability != domain.JobCapabilityFilesRead || allowed.Job.ExecutionInput.Deployment == nil || allowed.Job.ExecutionInput.Deployment.ServerRoot != `C:\scumserver` {
|
||||
t.Fatalf("unexpected declared file dispatch: %+v", allowed)
|
||||
}
|
||||
unknown, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
||||
@@ -1313,8 +1313,8 @@ func TestDeclaredFileReadSnapshotProjectionStatesAndPassThroughContent(t *testin
|
||||
}
|
||||
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)
|
||||
if err != nil || snapshot.State != "cancelled" || !strings.Contains(snapshot.Reason, "fixture read cancelled") {
|
||||
t.Fatalf("failed/cancelled reads must surface the latest Run failure, 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, "")
|
||||
@@ -1344,7 +1344,7 @@ func scumTestFileWorkspace() domain.PluginFileWorkspace {
|
||||
{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-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", TargetKey: "SCUM/Saved/Config/WindowsServer/ServerSettings.ini", Editable: true},
|
||||
{Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"},
|
||||
},
|
||||
ConfigFields: []domain.PluginConfigField{
|
||||
@@ -1374,6 +1374,11 @@ func createDeclaredFileReadJob(t *testing.T, svc *CoreService, instance domain.S
|
||||
}
|
||||
if state == domain.JobStateSucceeded {
|
||||
job.ExecutionResult = domain.JobExecutionResult{Kind: "file.read", Version: minuteOffset, Checksum: validator.BytesChecksum([]byte(content)), SizeBytes: int64(len(content)), Content: content}
|
||||
} else if state == domain.JobStateFailed {
|
||||
job.Progress.Message = "fixture read failed"
|
||||
job.ExecutionResult.Summary = "file_read_failed"
|
||||
} else if state == domain.JobStateCancelled {
|
||||
job.CancelReason = "fixture read cancelled"
|
||||
}
|
||||
if err := svc.store.Jobs().Update(job); err != nil {
|
||||
t.Fatalf("update declared file read job: %v", err)
|
||||
|
||||
@@ -1949,7 +1949,7 @@ func validatePluginFileWorkspace(prefix string, workspace domain.PluginFileWorks
|
||||
}
|
||||
for i, item := range workspace.Files {
|
||||
field := fmt.Sprintf("%s.files[%d]", prefix, i)
|
||||
if !validDistributionLogicalKey(item.Key) || !directories[item.DirectoryKey] || item.Label == "" || !oneOf(item.Kind, "config", "log") || (item.Kind == "log" && item.StreamKey == "") {
|
||||
if !validDistributionLogicalKey(item.Key) || !directories[item.DirectoryKey] || item.Label == "" || !oneOf(item.Kind, "config", "log") || (item.Kind == "log" && item.StreamKey == "") || (item.TargetKey != "" && len(validateSafeRelativeRuntimePath(field+".targetKey", item.TargetKey)) > 0) {
|
||||
violations = append(violations, field+" is invalid")
|
||||
}
|
||||
if _, exists := files[item.Key]; exists {
|
||||
|
||||
@@ -47,6 +47,11 @@ func TestValidateGamePluginManifestRegistrationValidatesLogicalFileWorkspace(t *
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||
t.Fatalf("expected safe logical file workspace: %v", err)
|
||||
}
|
||||
registration.Manifest.FileWorkspace.Files[0].TargetKey = "../server-settings.ini"
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err == nil {
|
||||
t.Fatal("expected unsafe file target reference to fail")
|
||||
}
|
||||
registration.Manifest.FileWorkspace.Files[0].TargetKey = "SCUM/Saved/Config/WindowsServer/ServerSettings.ini"
|
||||
registration.Manifest.FileWorkspace.Files[0].DirectoryKey = "../host"
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err == nil {
|
||||
t.Fatal("expected unsafe logical directory reference to fail")
|
||||
|
||||
@@ -403,7 +403,7 @@ export interface RuntimeLogSourceResponse {
|
||||
}
|
||||
|
||||
export interface PluginLogicalDirectoryResponse { key: string; label: string; scope: "config" | "logs"; }
|
||||
export interface PluginLogicalFileResponse { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean; }
|
||||
export interface PluginLogicalFileResponse { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; targetKey?: string; editable?: boolean; }
|
||||
export interface PluginConfigFieldResponse { key: string; fileKey: string; configKey: string; label: string; description: string; control: "text" | "number" | "boolean" | "port"; minimum?: number; maximum?: number; defaultValue?: string; restartImpact: "none" | "restart-required"; }
|
||||
export interface PluginFileWorkspaceResponse { defaultDirectoryKey: string; directories: PluginLogicalDirectoryResponse[]; files: PluginLogicalFileResponse[]; configFields: PluginConfigFieldResponse[]; }
|
||||
|
||||
@@ -1373,7 +1373,7 @@ export interface DeclaredFileReadSnapshotResponse {
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
key: string;
|
||||
state: "ready" | "pending" | "not-read" | string;
|
||||
state: "ready" | "pending" | "not-read" | "failed" | "cancelled" | string;
|
||||
content?: string;
|
||||
version?: number;
|
||||
checksum?: string;
|
||||
|
||||
@@ -80,4 +80,64 @@ describe("ServerConfigEditor", () => {
|
||||
expect(apiMocks.readServerFile).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("surfaces a terminal Run read failure without polling for thirty seconds", async () => {
|
||||
apiMocks.getServerFileReadSnapshot.mockResolvedValue({ serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "failed", reason: "Run 文件读取失败:file is missing" });
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
const operations: OperationTracker = { operations: [], begin: () => "operation-read", update: () => undefined, succeed: () => undefined, fail: () => undefined, isPending: () => false };
|
||||
|
||||
await act(async () => root?.render(<ServerConfigEditor instance={instance} operations={operations} requester="Operator" onClose={() => undefined} />));
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
expect(apiMocks.readServerFile).not.toHaveBeenCalled();
|
||||
expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("file is missing");
|
||||
expect(container.textContent).toContain("重试");
|
||||
});
|
||||
|
||||
it("does not dispatch a second read while an existing Run read is pending", async () => {
|
||||
apiMocks.getServerFileReadSnapshot.mockResolvedValue({ serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "pending", reason: "等待运行端完成文件读取。" });
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
const operations: OperationTracker = { operations: [], begin: () => "operation-read", update: () => undefined, succeed: () => undefined, fail: () => undefined, isPending: () => false };
|
||||
|
||||
await act(async () => root?.render(<ServerConfigEditor instance={instance} operations={operations} requester="Operator" onClose={() => undefined} />));
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
expect(apiMocks.readServerFile).not.toHaveBeenCalled();
|
||||
expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
|
||||
expect(apiMocks.readServerFile).not.toHaveBeenCalled();
|
||||
expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries a failed read with a new idempotency key and loads the file", async () => {
|
||||
let retryRequested = false;
|
||||
apiMocks.getServerFileReadSnapshot.mockImplementation(async () => retryRequested
|
||||
? { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "ready", content: "[General]\nscum.MaxPlayers=63\n", version: 1, checksum: "sha256:test" }
|
||||
: { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "failed", reason: "Run 文件读取失败:file is missing" });
|
||||
apiMocks.readServerFile.mockImplementation(async (_serverId: string, request: { idempotencyKey: string }) => {
|
||||
retryRequested = true;
|
||||
return { status: "queued", serverInstanceId: "server-1", pluginId: "game.scum", operation: "read", key: "scum-server-settings", job: { id: "job-read-retry", state: "queued" }, request };
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
const operations: OperationTracker = { operations: [], begin: () => "operation-read", update: () => undefined, succeed: () => undefined, fail: () => undefined, isPending: () => false };
|
||||
|
||||
await act(async () => root?.render(<ServerConfigEditor instance={instance} operations={operations} requester="Operator" onClose={() => undefined} />));
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
const retry = [...(container.querySelectorAll("button"))].find((button) => button.textContent?.includes("重试"));
|
||||
expect(retry).toBeDefined();
|
||||
|
||||
await act(async () => retry?.click());
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(500); await Promise.resolve(); });
|
||||
|
||||
expect(apiMocks.readServerFile).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.readServerFile.mock.calls[0][1].idempotencyKey).toBe("web:server-config:read:server-1:scum-server-settings:1");
|
||||
expect(container.textContent).toContain("已读取 ServerSettings.ini");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }:
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const readInFlightRef = useRef("");
|
||||
const readAttemptRef = useRef(new Map<string, number>());
|
||||
const operationsRef = useRef(operations);
|
||||
operationsRef.current = operations;
|
||||
|
||||
@@ -35,7 +36,7 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }:
|
||||
const selectedFile = configFiles.find((file) => file.key === selectedKey) ?? configFiles[0];
|
||||
const fields = useMemo(() => (workspace?.configFields ?? []).filter((field) => field.fileKey === selectedFile?.key), [selectedFile?.key, workspace?.configFields]);
|
||||
|
||||
const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse) => {
|
||||
const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse, forceRetry = false) => {
|
||||
const readKey = `${instance.id}:${file.key}`;
|
||||
if (readInFlightRef.current === readKey) return;
|
||||
readInFlightRef.current = readKey;
|
||||
@@ -44,16 +45,23 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }:
|
||||
setMessage("正在通过 Run 读取配置文件…");
|
||||
try {
|
||||
let next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
|
||||
if (next.state !== "ready") {
|
||||
const failedState = next.state === "failed" || next.state === "cancelled";
|
||||
let dispatched = false;
|
||||
if (next.state === "not-read" || (forceRetry && failedState)) {
|
||||
const readAttempt = (readAttemptRef.current.get(readKey) ?? 0) + 1;
|
||||
readAttemptRef.current.set(readKey, readAttempt);
|
||||
const operationId = operationsRef.current.begin({ intent: "读取服务器配置", targetKind: "config", targetId: instance.id, requester });
|
||||
const dispatch = await platformApiClient.readServerFile(instance.id, { key: file.key, idempotencyKey: configOperationKey("read", instance.id, file.key) });
|
||||
const dispatch = await platformApiClient.readServerFile(instance.id, { key: file.key, idempotencyKey: configOperationKey("read", instance.id, file.key, readAttempt) });
|
||||
operationsRef.current.succeed(operationId, `配置读取任务 ${dispatch.job.id} 已派发`, dispatch.job);
|
||||
for (let attempt = 0; attempt < 60 && next.state !== "ready"; attempt += 1) {
|
||||
dispatched = true;
|
||||
}
|
||||
if (dispatched || next.state === "pending") {
|
||||
for (let pollAttempt = 0; pollAttempt < 60 && next.state !== "ready"; pollAttempt += 1) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 500));
|
||||
next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
|
||||
}
|
||||
}
|
||||
if (next.state !== "ready" || next.content === undefined) throw new Error(next.reason ?? "Run 尚未返回配置文件内容。");
|
||||
if (next.state !== "ready" || next.content === undefined) throw new Error(next.reason ?? (next.state === "pending" ? "Run 仍在读取配置文件,请稍后重试。" : "Run 未返回配置文件内容。"));
|
||||
setSnapshot(next);
|
||||
setRaw(next.content);
|
||||
setValues(Object.fromEntries(fields.map((field) => [field.key, iniValue(next.content ?? "", field.configKey)])));
|
||||
@@ -120,7 +128,7 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }:
|
||||
<div className="panel-header"><div><h3>{selectedFile?.label}</h3><span className="page-status">配置内容由 Run 返回</span></div></div>
|
||||
<div className="file-workbench-mode"><span>{mode === "friendly" ? "人类可读模式:只修改插件声明的配置项" : "源码模式:编辑完整原始文件"}</span><button type="button" className={mode === "friendly" ? "file-workbench-mode-active" : undefined} onClick={() => setMode("friendly")}><FileCog size={13} /> 人类可读</button><button type="button" className={mode === "source" ? "file-workbench-mode-active" : undefined} onClick={() => setMode("source")}><Code2 size={13} /> 源码模式</button></div>
|
||||
{message && <ResultBadge status={error ? "failed" : "pending"} label={message} />}
|
||||
{error && workspace && <ErrorState title="配置操作失败" reason={error} diagnosticId={`server-config:${instance.id}:${selectedFile?.key ?? "unknown"}`} compact />}
|
||||
{error && workspace && <ErrorState title="配置操作失败" reason={error} diagnosticId={`server-config:${instance.id}:${selectedFile?.key ?? "unknown"}`} onRetry={selectedFile ? () => void readSelectedFile(selectedFile, true) : undefined} compact />}
|
||||
{loading && <LoadingState label="正在读取配置文件…" compact />}
|
||||
{!loading && mode === "friendly" && <div className="file-workbench-fields">{fields.length === 0 && <span className="provider-id">该配置文件没有声明可视化字段,请切换到源码模式。</span>}{configFieldGroups(fields).map((group) => <div key={group.label} className="file-workbench-group"><h4>{group.label}</h4>{group.fields.map((field) => <ConfigField key={field.key} field={field} value={values[field.key] ?? ""} onChange={(value) => setValues((current) => ({ ...current, [field.key]: value }))} />)}</div>)}</div>}
|
||||
{!loading && mode === "source" && <div className="file-workbench-raw"><textarea className="file-workbench-raw-editor" value={raw} spellCheck={false} onChange={(event) => setRaw(event.target.value)} aria-label={`${selectedFile?.label ?? "配置文件"}源码`} /><small>保存会把完整原文交给 Run 写回服务器工作区。</small></div>}
|
||||
@@ -147,5 +155,5 @@ function configFieldGroups(fields: PluginConfigFieldResponse[]): Array<{ label:
|
||||
return [...groups.entries()].map(([label, groupFields]) => ({ label, fields: groupFields }));
|
||||
}
|
||||
|
||||
function configOperationKey(operation: string, serverId: string, fileKey: string): string { return `web:server-config:${operation}:${serverId}:${fileKey}`; }
|
||||
function configOperationKey(operation: string, serverId: string, fileKey: string, attempt = 1): string { return `web:server-config:${operation}:${serverId}:${fileKey}:${attempt}`; }
|
||||
function formatReadTime(value: string): string { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN"); }
|
||||
|
||||
@@ -441,6 +441,7 @@
|
||||
"directoryKey": "scum-config",
|
||||
"label": "ServerSettings.ini",
|
||||
"kind": "config",
|
||||
"targetKey": "SCUM/Saved/Config/WindowsServer/ServerSettings.ini",
|
||||
"editable": true
|
||||
},
|
||||
{
|
||||
@@ -448,6 +449,7 @@
|
||||
"directoryKey": "scum-config",
|
||||
"label": "AdminUsers.ini",
|
||||
"kind": "config",
|
||||
"targetKey": "SCUM/Saved/Config/WindowsServer/AdminUsers.ini",
|
||||
"editable": true
|
||||
},
|
||||
{
|
||||
@@ -455,6 +457,7 @@
|
||||
"directoryKey": "scum-config",
|
||||
"label": "BannedUsers.ini",
|
||||
"kind": "config",
|
||||
"targetKey": "SCUM/Saved/Config/WindowsServer/BannedUsers.ini",
|
||||
"editable": true
|
||||
},
|
||||
{
|
||||
@@ -462,6 +465,7 @@
|
||||
"directoryKey": "scum-config",
|
||||
"label": "WhitelistUsers.ini",
|
||||
"kind": "config",
|
||||
"targetKey": "SCUM/Saved/Config/WindowsServer/WhitelistedUsers.ini",
|
||||
"editable": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
},
|
||||
"$defs": {
|
||||
"pluginLogicalDirectory": { "type": "object", "required": ["key", "label", "scope"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 60 }, "scope": { "enum": ["config", "logs"] } } },
|
||||
"pluginLogicalFile": { "type": "object", "required": ["key", "directoryKey", "label", "kind"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "directoryKey": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "kind": { "enum": ["config", "log"] }, "streamKey": { "$ref": "#/$defs/logicalKey" }, "editable": { "type": "boolean" } } },
|
||||
"pluginLogicalFile": { "type": "object", "required": ["key", "directoryKey", "label", "kind"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "directoryKey": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "kind": { "enum": ["config", "log"] }, "streamKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/relativePathRef" }, "editable": { "type": "boolean" } } },
|
||||
"pluginConfigField": { "type": "object", "required": ["key", "fileKey", "configKey", "label", "description", "control", "restartImpact"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "fileKey": { "$ref": "#/$defs/logicalKey" }, "configKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$", "maxLength": 120 }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "description": { "type": "string", "minLength": 1, "maxLength": 240 }, "control": { "enum": ["text", "number", "boolean", "port"] }, "minimum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "maximum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "defaultValue": { "type": "string", "maxLength": 120 }, "restartImpact": { "enum": ["none", "restart-required"] } } },
|
||||
"pluginAssetFile": {
|
||||
"type": "object",
|
||||
|
||||
@@ -347,7 +347,7 @@ describe("plugin manifest validation", () => {
|
||||
fileWorkspace?: {
|
||||
defaultDirectoryKey: string;
|
||||
directories: Array<{ key: string; label: string; scope: string }>;
|
||||
files: Array<{ key: string; directoryKey: string; label: string; kind: string; streamKey?: string; editable?: boolean }>;
|
||||
files: Array<{ key: string; directoryKey: string; label: string; kind: string; streamKey?: string; targetKey?: string; editable?: boolean }>;
|
||||
configFields: Array<{ key: string; fileKey: string; configKey: string; label: string }>;
|
||||
};
|
||||
runtimeProfiles?: { lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>; logSources?: Array<{ key: string }>; clientManagers?: unknown[] };
|
||||
@@ -382,6 +382,7 @@ describe("plugin manifest validation", () => {
|
||||
expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config");
|
||||
expect(manifest.fileWorkspace?.directories.map((directory) => `${directory.key}:${directory.scope}`)).toEqual(expect.arrayContaining(["scum-config:config", "scum-logs:logs"]));
|
||||
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-admin-users", "scum-chat-log", "scum-performance-log"]));
|
||||
expect(manifest.fileWorkspace?.files.find((file) => file.key === "scum-server-settings")?.targetKey).toBe("SCUM/Saved/Config/WindowsServer/ServerSettings.ini");
|
||||
expect(manifest.fileWorkspace?.configFields.map((field) => field.key)).toEqual(expect.arrayContaining(["server-name", "max-players", "welcome-message", "server-description", "server-playstyle"]));
|
||||
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")).toBeUndefined();
|
||||
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events"]));
|
||||
|
||||
Reference in New Issue
Block a user