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
+28
View File
@@ -110,6 +110,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverDeclaredFileReadSnapshot)
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig)
@@ -1430,6 +1431,33 @@ func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config))
}
// serverDeclaredFileReadSnapshot godoc
// @Summary Read the latest declared file snapshot
// @Description Returns a redacted bounded result only for an authorized plugin-declared logical file key.
// @Tags server-instances
// @Produce json
// @Param id path string true "Server instance ID"
// @Param key query string true "Plugin-declared logical file key"
// @Success 200 {object} dto.DeclaredFileReadSnapshotResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/files/read-snapshot [get]
func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
snapshot, err := h.core.GetDeclaredFileReadSnapshotForSession(bearerToken(r), r.PathValue("id"), r.URL.Query().Get("key"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot))
}
// serverInstanceConfigDiff godoc
// @Summary Preview server config diff
// @Description Compares current logical server config with proposed content without dispatching a write job.
+99
View File
@@ -301,6 +301,105 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
}
}
func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
ID: "user-owner-file-snapshot-api",
DisplayName: "File Snapshot API Owner",
Email: "owner-file-snapshot-api@example.test",
Roles: []string{"server-owner"},
Password: "secret-password",
}, adminSession)
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
ID: "user-other-file-snapshot-api",
DisplayName: "File Snapshot API Other",
Email: "other-file-snapshot-api@example.test",
Roles: []string{"server-admin"},
Password: "secret-password",
}, adminSession)
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
pluginRequest := validGamePluginRequest()
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesRead)
pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"}
pluginRequest.Permissions.Files = true
pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{
DefaultDirectoryKey: "scum-config",
Directories: []dto.PluginLogicalDirectoryBody{
{Key: "scum-config", Label: "服务器配置", Scope: "config"},
{Key: "scum-logs", Label: "日志文件", Scope: "logs"},
},
Files: []dto.PluginLogicalFileBody{
{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: []dto.PluginConfigFieldBody{
{Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"},
},
}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-file-snapshot-api",
PluginID: "server.scum",
RunEndpointID: "run-local",
Name: "File Snapshot API Server",
State: domain.ServerInstanceStateRunning,
}, ownerSession)
snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
if snapshot.State != "not-read" || snapshot.Content != "" {
t.Fatalf("expected not-read snapshot, got %+v", snapshot)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=logs/latest.log", "", ownerSession), http.StatusBadRequest, errorCodeValidation)
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", otherSession), http.StatusForbidden, errorCodeForbidden)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
ID: "job-file-snapshot-api-read",
ServerInstanceID: instance.ID,
RunEndpointID: "run-local",
Capability: domain.JobCapabilityFilesRead,
TargetKey: "scum-server-settings",
IdempotencyKey: "idem-file-snapshot-api-read",
})
helloRequest := validRunControlHelloRequest()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityFilesRead)
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capabilities: []string{domain.JobCapabilityFilesRead},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
}))
if !claim.HasJob || claim.Job.JobID != "job-file-snapshot-api-read" {
t.Fatalf("expected file read job claim, got %+v", claim)
}
content := "ServerName=API\nRconPassword=secret\n"
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: domain.JobStateSucceeded,
Progress: dto.JobProgressBody{Percent: 100, Message: "file read completed"},
Message: "file read completed",
ExecutionResult: dto.RunJobExecutionResultBody{
Kind: "file.read",
Version: 9,
SizeBytes: int64(len(content)),
Content: content,
},
})
assertStatus(t, resultRecorder, http.StatusOK)
ready := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
if ready.State != "ready" || ready.Version != 9 || !strings.Contains(ready.Content, "ServerName=API") || !strings.Contains(ready.Content, "RconPassword=<redacted>") || strings.Contains(ready.Content, "secret") {
t.Fatalf("expected ready redacted snapshot, got %+v", ready)
}
}
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
releaseBuilds := make(chan struct{})
t.Cleanup(func() { close(releaseBuilds) })