Add server file manager workflow
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -92,6 +93,14 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/files/workspace", h.serverFilesWorkspace)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/files/list", h.serverFilesList)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/files/refresh", h.serverFilesRefresh)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverFilesReadSnapshot)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/files/read", h.serverFilesRead)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/files/write", h.serverFilesWrite)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/files/upload", h.serverFilesUpload)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/files/download", h.serverFilesDownload)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators)
|
||||
@@ -109,6 +118,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
|
||||
mux.HandleFunc("/api/v1/run/files/input-chunk", h.requireRunSignature(h.runFileInputChunk))
|
||||
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
|
||||
mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk))
|
||||
@@ -1278,6 +1288,196 @@ func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *htt
|
||||
writeJSON(w, http.StatusAccepted, dto.ServerConfigWriteDispatchFromDomain(dispatch))
|
||||
}
|
||||
|
||||
// serverFilesWorkspace godoc
|
||||
// @Summary Read server file workspace
|
||||
// @Description Returns plugin-declared logical directories and transfer policy without exposing host paths.
|
||||
// @Tags server-files
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Success 200 {object} dto.ServerFileWorkspaceResponse
|
||||
// @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/workspace [get]
|
||||
func (h *coreHandlers) serverFilesWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
workspace, err := h.core.GetServerFileWorkspaceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerFileWorkspaceFromDomain(workspace))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverFilesList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
directoryKey, err := h.serverFileDirectoryKey(r, r.URL.Query().Get("directoryKey"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.ListServerFilesForSession(bearerToken(r), domain.ServerFileListRequest{ServerInstanceID: r.PathValue("id"), DirectoryKey: directoryKey, Path: r.URL.Query().Get("path"), Query: r.URL.Query().Get("query"), Recursive: r.URL.Query().Get("recursive") == "true"})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerFileListFromDomain(result))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverFilesRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ServerFileListRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
request.DirectoryKey, err = h.serverFileDirectoryKey(r, request.DirectoryKey)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.RefreshServerFileListForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ServerFileListFromDomain(result))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverFilesReadSnapshot(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))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverFilesRead(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ServerFileReadRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.ReadServerFileForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverFilesWrite(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ServerFileWriteRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.WriteServerFileForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverFilesUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
upload, err := h.decodeServerFileUpload(r)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.UploadServerFileForSession(bearerToken(r), upload)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ServerFileUploadFromDomain(result))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverFilesDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ServerFileDownloadRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.PrepareServerFileDownloadForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ServerFileDownloadFromDomain(result))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverFileDirectoryKey(r *http.Request, requested string) (string, error) {
|
||||
requested = strings.TrimSpace(requested)
|
||||
if requested != "" {
|
||||
return requested, nil
|
||||
}
|
||||
workspace, err := h.core.GetServerFileWorkspaceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return workspace.DefaultDirectoryKey, nil
|
||||
}
|
||||
|
||||
func (h *coreHandlers) decodeServerFileUpload(r *http.Request) (domain.ServerFileUploadRequest, error) {
|
||||
if err := r.ParseMultipartForm(8 * 1024 * 1024); err != nil {
|
||||
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"multipart upload is invalid"}}
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"file is required"}}
|
||||
}
|
||||
defer file.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(file, validator.MaxArtifactBytes+1))
|
||||
if err != nil {
|
||||
return domain.ServerFileUploadRequest{}, err
|
||||
}
|
||||
if int64(len(payload)) > validator.MaxArtifactBytes {
|
||||
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"payload is too large"}}
|
||||
}
|
||||
checksum := strings.TrimSpace(r.FormValue("checksum"))
|
||||
if checksum == "" {
|
||||
checksum = validator.BytesChecksum(payload)
|
||||
}
|
||||
filename := strings.TrimSpace(r.FormValue("filename"))
|
||||
if filename == "" && header != nil {
|
||||
filename = header.Filename
|
||||
}
|
||||
return domain.ServerFileUploadRequest{ServerInstanceID: r.PathValue("id"), DirectoryKey: r.FormValue("directoryKey"), RelativePath: r.FormValue("relativePath"), Filename: filename, Payload: payload, Checksum: checksum, IdempotencyKey: r.FormValue("idempotencyKey")}, nil
|
||||
}
|
||||
|
||||
// fileOperationDispatch godoc
|
||||
// @Summary Dispatch scoped file operation
|
||||
// @Description Queues a scoped files.read or files.write job using logical file keys or refs, never raw host paths.
|
||||
@@ -1673,6 +1873,25 @@ func (h *coreHandlers) runJobUpdateChunk(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(result))
|
||||
}
|
||||
|
||||
// runFileInputChunk serves one bounded browser-uploaded file input range only to the active fenced Run attempt.
|
||||
func (h *coreHandlers) runFileInputChunk(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.RunFileInputChunkRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.ReadRunFileInputChunk(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.RunFileInputChunkFromDomain(result))
|
||||
}
|
||||
|
||||
// runJobUpdateHealth godoc
|
||||
// @Summary Confirm a reconciled Run self-update outcome
|
||||
// @Description Accepts a signed current-session health or rollback report fenced to the terminal update job attempt.
|
||||
|
||||
@@ -150,7 +150,9 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
|
||||
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-metrics@example.test", Password: "secret-password"}).SessionID
|
||||
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||
endpointRequest := validRunEndpointRequest()
|
||||
endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList)
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest)
|
||||
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-metrics-api",
|
||||
PluginID: "server.scum",
|
||||
@@ -234,11 +236,20 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
|
||||
func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "file-workspace-other", DisplayName: "File Workspace Other", Email: "file-workspace-other@example.test", Roles: []string{"server-admin"}, Password: "secret-password"}, adminSession)
|
||||
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "file-workspace-other@example.test", Password: "secret-password"}).SessionID
|
||||
pluginRequest := validGamePluginRequest()
|
||||
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite)
|
||||
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"}}}
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
||||
endpointRequest := validRunEndpointRequest()
|
||||
endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList)
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest)
|
||||
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-file-snapshot-api",
|
||||
PluginID: "server.scum",
|
||||
@@ -246,8 +257,33 @@ func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
|
||||
Name: "File Snapshot API Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
}, adminSession)
|
||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
||||
|
||||
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", adminSession), http.StatusNotFound)
|
||||
workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession)
|
||||
if workspace.DefaultDirectoryKey != "scum-config" || workspace.Transfer.Channel != "run-file-transfer" || len(workspace.Files) != 2 {
|
||||
t.Fatalf("unexpected workspace: %+v", workspace)
|
||||
}
|
||||
list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=scum-config", adminSession)
|
||||
foundSettings := false
|
||||
for _, entry := range list.Entries {
|
||||
if entry.LogicalKey == "scum-server-settings" && entry.Editable && entry.Downloadable {
|
||||
foundSettings = true
|
||||
}
|
||||
}
|
||||
if list.State != "declared" || !foundSettings {
|
||||
t.Fatalf("expected declared file list, got %+v", list)
|
||||
}
|
||||
readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession)
|
||||
assertStatus(t, readRecorder, http.StatusAccepted)
|
||||
read := decodeBody[dto.FileOperationDispatchResponse](t, readRecorder)
|
||||
if read.Job.Capability != domain.JobCapabilityFilesRead || read.Job.TargetKey != "scum-server-settings" {
|
||||
t.Fatalf("unexpected read dispatch: %+v", read)
|
||||
}
|
||||
snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", adminSession)
|
||||
if snapshot.State != "pending" || snapshot.JobID != read.Job.ID {
|
||||
t.Fatalf("expected pending read snapshot, got %+v", snapshot)
|
||||
}
|
||||
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/workspace", "", otherSession), http.StatusForbidden, errorCodeForbidden)
|
||||
}
|
||||
|
||||
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
|
||||
@@ -4,7 +4,7 @@ Route declarations and handler comments live in `platform/api`. Request, respons
|
||||
|
||||
## Implemented Core Resource Routes
|
||||
|
||||
All routes use JSON request and response bodies. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`.
|
||||
Routes use JSON request and response bodies unless a route explicitly accepts file upload multipart form data. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`.
|
||||
|
||||
| Resource | Collection | Detail | DTO contracts |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -17,6 +17,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
||||
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
|
||||
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
|
||||
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
|
||||
| Server file manager | `GET /api/v1/server-instances/{id}/files/workspace`, `GET /api/v1/server-instances/{id}/files/list`, `POST /api/v1/server-instances/{id}/files/refresh`, `POST /api/v1/server-instances/{id}/files/read`, `POST /api/v1/server-instances/{id}/files/write`, `POST /api/v1/server-instances/{id}/files/upload`, `POST /api/v1/server-instances/{id}/files/download` | `GET /api/v1/server-instances/{id}/files/read-snapshot` | `ServerFileWorkspaceResponse`, `ServerFileListResponse`, `DeclaredFileReadSnapshotResponse`, `ServerFileReadRequest`, `ServerFileWriteRequest`, `ServerFileUploadResponse`, `ServerFileDownloadRequest`, `ServerFileDownloadResponse` |
|
||||
| Plugin-owned data | n/a | `GET/PUT/DELETE /api/v1/server-instances/{id}/plugin-data/{collection}`, `POST .../plugin-data/{collection}/transaction` | `PluginDataPutRequest`, `PluginDataTransactionRequest`, `PluginDataRecordResponse`, `PluginDataListResponse` |
|
||||
| Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` |
|
||||
| Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` |
|
||||
@@ -186,6 +187,12 @@ Control is the highest-priority run-facing channel; artifact/file transfer press
|
||||
Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials.
|
||||
Job ack/progress/result/cancel/reconcile calls remain lightweight and independently valid while log batches or artifact/file chunks are queued, slow, or retrying. Equivalent duplicate terminal results remain idempotent under channel pressure.
|
||||
|
||||
## Implemented Run File Transfer Actions
|
||||
|
||||
- `POST /api/v1/run/files/input-chunk`: accept `RunFileInputChunkRequest`, validate the active Run endpoint session plus the fenced file-write job lease/attempt, and return `RunFileInputChunkResponse` with a bounded byte range from a server-instance scoped upload artifact.
|
||||
|
||||
Run file input chunks are used only for browser-staged file uploads that produce `artifact://` job inputs. The route never returns storage backend paths, browser bearer credentials, machine paths, direct sockets, or unrestricted artifact bodies. This channel is lower priority than control, job lifecycle calls, and log ingest.
|
||||
|
||||
## Implemented Log Ingest Actions
|
||||
|
||||
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
|
||||
|
||||
@@ -1000,6 +1000,7 @@ type ServerConfigWriteDispatch struct {
|
||||
type FileOperationKind string
|
||||
|
||||
const (
|
||||
FileOperationList FileOperationKind = "list"
|
||||
FileOperationRead FileOperationKind = "read"
|
||||
FileOperationWrite FileOperationKind = "write"
|
||||
)
|
||||
@@ -1042,6 +1043,151 @@ type DeclaredFileReadSnapshot struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ServerFileEntryKind string
|
||||
|
||||
const (
|
||||
ServerFileEntryDirectory ServerFileEntryKind = "directory"
|
||||
ServerFileEntryFile ServerFileEntryKind = "file"
|
||||
)
|
||||
|
||||
type ServerFileTransferPolicy struct {
|
||||
Channel string
|
||||
UploadChunkSizeBytes int
|
||||
DownloadChunkSizeBytes int
|
||||
MaxInlineEditBytes int
|
||||
MaxBrowserUploadBytes int64
|
||||
Notes []string
|
||||
}
|
||||
|
||||
type ServerFileWorkspaceView struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
DefaultDirectoryKey string
|
||||
Directories []PluginLogicalDirectory
|
||||
Files []PluginLogicalFile
|
||||
ConfigFields []PluginConfigField
|
||||
Transfer ServerFileTransferPolicy
|
||||
DeclaredOnly bool
|
||||
RuntimeWorkspaceScope string
|
||||
}
|
||||
|
||||
type ServerFileEntry struct {
|
||||
Name string
|
||||
Kind ServerFileEntryKind
|
||||
DirectoryKey string
|
||||
RelativePath string
|
||||
LogicalKey string
|
||||
Scope string
|
||||
SizeBytes int64
|
||||
ModifiedAt time.Time
|
||||
Checksum string
|
||||
Editable bool
|
||||
Downloadable bool
|
||||
Remark string
|
||||
}
|
||||
|
||||
type ServerFileListRequest struct {
|
||||
ServerInstanceID string
|
||||
DirectoryKey string
|
||||
Path string
|
||||
Query string
|
||||
Recursive bool
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ServerFileListResult struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
DirectoryKey string
|
||||
Path string
|
||||
State string
|
||||
Entries []ServerFileEntry
|
||||
Job Job
|
||||
RefreshedAt time.Time
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ServerFileReadRequest struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
Key string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ServerFileWriteRequest struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
Key string
|
||||
Content string
|
||||
InputRef string
|
||||
ExpectedVersion int
|
||||
ExpectedChecksum string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ServerFileUploadRequest struct {
|
||||
ServerInstanceID string
|
||||
DirectoryKey string
|
||||
RelativePath string
|
||||
Filename string
|
||||
Payload []byte
|
||||
Checksum string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ServerFileUploadDispatch struct {
|
||||
Status string
|
||||
ServerInstanceID string
|
||||
DirectoryKey string
|
||||
RelativePath string
|
||||
ArtifactID string
|
||||
InputRef string
|
||||
SizeBytes int64
|
||||
Checksum string
|
||||
Job Job
|
||||
}
|
||||
|
||||
type ServerFileDownloadRequest struct {
|
||||
ServerInstanceID string
|
||||
Key string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ServerFileDownloadResult struct {
|
||||
Status string
|
||||
ServerInstanceID string
|
||||
Key string
|
||||
Filename string
|
||||
ContentType string
|
||||
Content string
|
||||
Checksum string
|
||||
SizeBytes int64
|
||||
Artifact *ArtifactDownloadReference
|
||||
Job Job
|
||||
ReadAt time.Time
|
||||
Reason string
|
||||
}
|
||||
|
||||
type RunFileInputChunkRequest struct {
|
||||
RunEndpointID string
|
||||
SessionToken string
|
||||
JobID string
|
||||
LeaseToken string
|
||||
Attempt int
|
||||
Offset int64
|
||||
Length int
|
||||
}
|
||||
|
||||
type RunFileInputChunk struct {
|
||||
JobID string
|
||||
ArtifactID string
|
||||
Offset int64
|
||||
TotalBytes int64
|
||||
Checksum string
|
||||
Payload []byte
|
||||
Complete bool
|
||||
}
|
||||
|
||||
type RunCapacity struct {
|
||||
MaxJobs int
|
||||
RunningJobs int
|
||||
@@ -1054,6 +1200,7 @@ type RunCapacity struct {
|
||||
|
||||
const (
|
||||
JobCapabilityConfigWrite = "config.write"
|
||||
JobCapabilityFilesList = "files.list"
|
||||
JobCapabilityFilesRead = "files.read"
|
||||
JobCapabilityFilesWrite = "files.write"
|
||||
JobCapabilityRemoteFTPRead = "remote.ftp.read"
|
||||
@@ -1963,6 +2110,66 @@ func CopyFileOperationDispatchResult(result FileOperationDispatchResult) FileOpe
|
||||
return result
|
||||
}
|
||||
|
||||
func CopyServerFileTransferPolicy(policy ServerFileTransferPolicy) ServerFileTransferPolicy {
|
||||
policy.Notes = CopyStringSlice(policy.Notes)
|
||||
return policy
|
||||
}
|
||||
|
||||
func CopyServerFileWorkspaceView(view ServerFileWorkspaceView) ServerFileWorkspaceView {
|
||||
view.Directories = append([]PluginLogicalDirectory(nil), view.Directories...)
|
||||
view.Files = append([]PluginLogicalFile(nil), view.Files...)
|
||||
view.ConfigFields = append([]PluginConfigField(nil), view.ConfigFields...)
|
||||
view.Transfer = CopyServerFileTransferPolicy(view.Transfer)
|
||||
return view
|
||||
}
|
||||
|
||||
func CopyServerFileEntry(entry ServerFileEntry) ServerFileEntry {
|
||||
return entry
|
||||
}
|
||||
|
||||
func CopyServerFileEntries(entries []ServerFileEntry) []ServerFileEntry {
|
||||
if entries == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]ServerFileEntry, len(entries))
|
||||
copy(out, entries)
|
||||
return out
|
||||
}
|
||||
|
||||
func CopyServerFileListResult(result ServerFileListResult) ServerFileListResult {
|
||||
result.Entries = CopyServerFileEntries(result.Entries)
|
||||
result.Job = CopyJob(result.Job)
|
||||
return result
|
||||
}
|
||||
|
||||
func CopyServerFileUploadRequest(request ServerFileUploadRequest) ServerFileUploadRequest {
|
||||
request.Payload = CopyBytes(request.Payload)
|
||||
return request
|
||||
}
|
||||
|
||||
func CopyServerFileUploadDispatch(dispatch ServerFileUploadDispatch) ServerFileUploadDispatch {
|
||||
dispatch.Job = CopyJob(dispatch.Job)
|
||||
return dispatch
|
||||
}
|
||||
|
||||
func CopyServerFileDownloadResult(result ServerFileDownloadResult) ServerFileDownloadResult {
|
||||
if result.Artifact != nil {
|
||||
artifact := CopyArtifactDownloadReference(*result.Artifact)
|
||||
result.Artifact = &artifact
|
||||
}
|
||||
result.Job = CopyJob(result.Job)
|
||||
return result
|
||||
}
|
||||
|
||||
func CopyRunFileInputChunkRequest(request RunFileInputChunkRequest) RunFileInputChunkRequest {
|
||||
return request
|
||||
}
|
||||
|
||||
func CopyRunFileInputChunk(chunk RunFileInputChunk) RunFileInputChunk {
|
||||
chunk.Payload = CopyBytes(chunk.Payload)
|
||||
return chunk
|
||||
}
|
||||
|
||||
func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint {
|
||||
endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities)
|
||||
endpoint.Capacity.PressureCodes = CopyStringSlice(endpoint.Capacity.PressureCodes)
|
||||
|
||||
@@ -310,6 +310,26 @@ type RunUpdateChunkResponse struct {
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
type RunFileInputChunkRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
Offset int64 `json:"offset"`
|
||||
Length int `json:"length"`
|
||||
}
|
||||
|
||||
type RunFileInputChunkResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Offset int64 `json:"offset"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
Payload []byte `json:"payload"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
type RunUpdateHealthRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
@@ -428,6 +448,10 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
|
||||
}
|
||||
}
|
||||
|
||||
func (request RunFileInputChunkRequest) ToDomain() domain.RunFileInputChunkRequest {
|
||||
return domain.RunFileInputChunkRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt, Offset: request.Offset, Length: request.Length}
|
||||
}
|
||||
|
||||
func (request DistributionBuildInputRequest) ToDomain() domain.DistributionBuildInputRequest {
|
||||
return domain.DistributionBuildInputRequest{
|
||||
RunEndpointID: request.RunEndpointID,
|
||||
@@ -569,6 +593,11 @@ func RunUpdateChunkFromDomain(chunk domain.RunUpdateChunk) RunUpdateChunkRespons
|
||||
return RunUpdateChunkResponse{JobID: chunk.JobID, ArtifactID: chunk.ArtifactID, Offset: chunk.Offset, TotalBytes: chunk.TotalBytes, Checksum: chunk.Checksum, Payload: chunk.Payload, Complete: chunk.Complete}
|
||||
}
|
||||
|
||||
func RunFileInputChunkFromDomain(chunk domain.RunFileInputChunk) RunFileInputChunkResponse {
|
||||
chunk = domain.CopyRunFileInputChunk(chunk)
|
||||
return RunFileInputChunkResponse{JobID: chunk.JobID, ArtifactID: chunk.ArtifactID, Offset: chunk.Offset, TotalBytes: chunk.TotalBytes, Checksum: chunk.Checksum, Payload: chunk.Payload, Complete: chunk.Complete}
|
||||
}
|
||||
|
||||
func RunUpdateHealthFromDomain(result domain.RunUpdateHealthResult) RunUpdateHealthResponse {
|
||||
return RunUpdateHealthResponse{Accepted: result.Accepted, JobID: result.JobID, Phase: result.Phase, ServerTime: result.ServerTime}
|
||||
}
|
||||
|
||||
@@ -730,6 +730,110 @@ type DeclaredFileReadSnapshotResponse struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ServerFileTransferPolicyResponse struct {
|
||||
Channel string `json:"channel"`
|
||||
UploadChunkSizeBytes int `json:"uploadChunkSizeBytes"`
|
||||
DownloadChunkSizeBytes int `json:"downloadChunkSizeBytes"`
|
||||
MaxInlineEditBytes int `json:"maxInlineEditBytes"`
|
||||
MaxBrowserUploadBytes int64 `json:"maxBrowserUploadBytes"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type ServerFileWorkspaceResponse struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
DefaultDirectoryKey string `json:"defaultDirectoryKey"`
|
||||
Directories []PluginLogicalDirectoryBody `json:"directories"`
|
||||
Files []PluginLogicalFileBody `json:"files"`
|
||||
ConfigFields []PluginConfigFieldBody `json:"configFields"`
|
||||
Transfer ServerFileTransferPolicyResponse `json:"transfer"`
|
||||
DeclaredOnly bool `json:"declaredOnly"`
|
||||
RuntimeWorkspaceScope string `json:"runtimeWorkspaceScope,omitempty"`
|
||||
}
|
||||
|
||||
type ServerFileEntryResponse struct {
|
||||
Name string `json:"name"`
|
||||
Kind domain.ServerFileEntryKind `json:"kind"`
|
||||
DirectoryKey string `json:"directoryKey"`
|
||||
RelativePath string `json:"relativePath,omitempty"`
|
||||
LogicalKey string `json:"logicalKey,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
ModifiedAt time.Time `json:"modifiedAt,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
Editable bool `json:"editable"`
|
||||
Downloadable bool `json:"downloadable"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
}
|
||||
|
||||
type ServerFileListRequest struct {
|
||||
DirectoryKey string `json:"directoryKey"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Recursive bool `json:"recursive,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||
}
|
||||
|
||||
type ServerFileListResponse struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
DirectoryKey string `json:"directoryKey"`
|
||||
Path string `json:"path,omitempty"`
|
||||
State string `json:"state"`
|
||||
Entries []ServerFileEntryResponse `json:"entries"`
|
||||
Job *JobResponse `json:"job,omitempty"`
|
||||
RefreshedAt time.Time `json:"refreshedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ServerFileReadRequest struct {
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
Key string `json:"key"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type ServerFileWriteRequest struct {
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Content string `json:"content,omitempty"`
|
||||
InputRef string `json:"inputRef,omitempty"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type ServerFileUploadResponse struct {
|
||||
Status string `json:"status"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
DirectoryKey string `json:"directoryKey"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
InputRef string `json:"inputRef"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
Job JobResponse `json:"job"`
|
||||
}
|
||||
|
||||
type ServerFileDownloadRequest struct {
|
||||
Key string `json:"key"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type ServerFileDownloadResponse struct {
|
||||
Status string `json:"status"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Key string `json:"key"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
Artifact *ArtifactDownloadReferenceResponse `json:"artifact,omitempty"`
|
||||
Job *JobResponse `json:"job,omitempty"`
|
||||
ReadAt time.Time `json:"readAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type RunCapacityResponse struct {
|
||||
MaxJobs int `json:"maxJobs"`
|
||||
RunningJobs int `json:"runningJobs"`
|
||||
@@ -1295,6 +1399,22 @@ func (request FileOperationDispatchRequest) ToDomain() domain.FileOperationDispa
|
||||
}
|
||||
}
|
||||
|
||||
func (request ServerFileListRequest) ToDomain(serverInstanceID string) domain.ServerFileListRequest {
|
||||
return domain.ServerFileListRequest{ServerInstanceID: serverInstanceID, DirectoryKey: request.DirectoryKey, Path: request.Path, Query: request.Query, Recursive: request.Recursive, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func (request ServerFileReadRequest) ToDomain(serverInstanceID string) domain.ServerFileReadRequest {
|
||||
return domain.ServerFileReadRequest{ServerInstanceID: serverInstanceID, PluginID: request.PluginID, Key: request.Key, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func (request ServerFileWriteRequest) ToDomain(serverInstanceID string) domain.ServerFileWriteRequest {
|
||||
return domain.ServerFileWriteRequest{ServerInstanceID: serverInstanceID, PluginID: request.PluginID, Key: request.Key, Content: request.Content, InputRef: request.InputRef, ExpectedVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func (request ServerFileDownloadRequest) ToDomain(serverInstanceID string) domain.ServerFileDownloadRequest {
|
||||
return domain.ServerFileDownloadRequest{ServerInstanceID: serverInstanceID, Key: request.Key, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func (request RunEndpointCreateRequest) ToDomain() domain.RunEndpoint {
|
||||
return domain.RunEndpoint{
|
||||
ID: request.ID,
|
||||
@@ -1827,6 +1947,57 @@ func DeclaredFileReadSnapshotFromDomain(snapshot domain.DeclaredFileReadSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
func ServerFileWorkspaceFromDomain(view domain.ServerFileWorkspaceView) ServerFileWorkspaceResponse {
|
||||
view = domain.CopyServerFileWorkspaceView(view)
|
||||
workspace := domain.PluginFileWorkspace{DefaultDirectoryKey: view.DefaultDirectoryKey, Directories: view.Directories, Files: view.Files, ConfigFields: view.ConfigFields}
|
||||
body := fileWorkspaceFromDomain(workspace)
|
||||
return ServerFileWorkspaceResponse{ServerInstanceID: view.ServerInstanceID, PluginID: view.PluginID, DefaultDirectoryKey: view.DefaultDirectoryKey, Directories: body.Directories, Files: body.Files, ConfigFields: body.ConfigFields, Transfer: ServerFileTransferPolicyFromDomain(view.Transfer), DeclaredOnly: view.DeclaredOnly, RuntimeWorkspaceScope: view.RuntimeWorkspaceScope}
|
||||
}
|
||||
|
||||
func ServerFileTransferPolicyFromDomain(policy domain.ServerFileTransferPolicy) ServerFileTransferPolicyResponse {
|
||||
policy = domain.CopyServerFileTransferPolicy(policy)
|
||||
return ServerFileTransferPolicyResponse{Channel: policy.Channel, UploadChunkSizeBytes: policy.UploadChunkSizeBytes, DownloadChunkSizeBytes: policy.DownloadChunkSizeBytes, MaxInlineEditBytes: policy.MaxInlineEditBytes, MaxBrowserUploadBytes: policy.MaxBrowserUploadBytes, Notes: policy.Notes}
|
||||
}
|
||||
|
||||
func ServerFileListFromDomain(result domain.ServerFileListResult) ServerFileListResponse {
|
||||
result = domain.CopyServerFileListResult(result)
|
||||
items := make([]ServerFileEntryResponse, len(result.Entries))
|
||||
for index, entry := range result.Entries {
|
||||
items[index] = ServerFileEntryFromDomain(entry)
|
||||
}
|
||||
var job *JobResponse
|
||||
if result.Job.ID != "" {
|
||||
body := JobFromDomain(result.Job)
|
||||
job = &body
|
||||
}
|
||||
return ServerFileListResponse{ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, DirectoryKey: result.DirectoryKey, Path: result.Path, State: result.State, Entries: items, Job: job, RefreshedAt: result.RefreshedAt, Reason: result.Reason}
|
||||
}
|
||||
|
||||
func ServerFileEntryFromDomain(entry domain.ServerFileEntry) ServerFileEntryResponse {
|
||||
entry = domain.CopyServerFileEntry(entry)
|
||||
return ServerFileEntryResponse{Name: entry.Name, Kind: entry.Kind, DirectoryKey: entry.DirectoryKey, RelativePath: entry.RelativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: entry.ModifiedAt, Checksum: entry.Checksum, Editable: entry.Editable, Downloadable: entry.Downloadable, Remark: entry.Remark}
|
||||
}
|
||||
|
||||
func ServerFileUploadFromDomain(dispatch domain.ServerFileUploadDispatch) ServerFileUploadResponse {
|
||||
dispatch = domain.CopyServerFileUploadDispatch(dispatch)
|
||||
return ServerFileUploadResponse{Status: dispatch.Status, ServerInstanceID: dispatch.ServerInstanceID, DirectoryKey: dispatch.DirectoryKey, RelativePath: dispatch.RelativePath, ArtifactID: dispatch.ArtifactID, InputRef: dispatch.InputRef, SizeBytes: dispatch.SizeBytes, Checksum: dispatch.Checksum, Job: JobFromDomain(dispatch.Job)}
|
||||
}
|
||||
|
||||
func ServerFileDownloadFromDomain(result domain.ServerFileDownloadResult) ServerFileDownloadResponse {
|
||||
result = domain.CopyServerFileDownloadResult(result)
|
||||
var artifact *ArtifactDownloadReferenceResponse
|
||||
if result.Artifact != nil {
|
||||
body := ArtifactDownloadReferenceFromDomain(*result.Artifact)
|
||||
artifact = &body
|
||||
}
|
||||
var job *JobResponse
|
||||
if result.Job.ID != "" {
|
||||
body := JobFromDomain(result.Job)
|
||||
job = &body
|
||||
}
|
||||
return ServerFileDownloadResponse{Status: result.Status, ServerInstanceID: result.ServerInstanceID, Key: result.Key, Filename: result.Filename, ContentType: result.ContentType, Content: result.Content, Checksum: result.Checksum, SizeBytes: result.SizeBytes, Artifact: artifact, Job: job, ReadAt: result.ReadAt, Reason: result.Reason}
|
||||
}
|
||||
|
||||
func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse {
|
||||
endpoint = domain.CopyRunEndpoint(endpoint)
|
||||
return RunEndpointResponse{
|
||||
|
||||
@@ -50,7 +50,7 @@ Jobs must carry bounded metadata such as `jobId`, `runEndpointId`, `serverInstan
|
||||
|
||||
Plugin lifecycle assignments may add only a validated plugin identifier, enumerated lifecycle operation, target version, and logical workspace scope. Explicit operator-requested install, enable, disable, upgrade, rollback, retire, dependency-check, and bounded lifecycle commands remain Platform-authorized jobs. Generated Run package startup is not dependent on registration-time job assignment; it is driven by the autonomous lifecycle plan embedded by the platform builder. Assignments cannot carry arbitrary shell, provider configuration, raw credentials, host paths, PIDs, sockets, DSNs, or RCON secrets.
|
||||
|
||||
Approved `config.write` and bounded `files.read`/`files.write` assignments carry logical keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local.
|
||||
Approved `config.write` and bounded `files.list`/`files.read`/`files.write` assignments carry logical directory keys, file keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local.
|
||||
|
||||
Job ack, progress, cancellation polling, reconciliation, and terminal result calls are lightweight lifecycle metadata. They must remain valid while artifact chunks or log retries are pending, and duplicate equivalent terminal results remain idempotent under channel pressure.
|
||||
|
||||
@@ -113,6 +113,20 @@ Artifact upload supports active run session validation, job/server-instance owne
|
||||
|
||||
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
|
||||
|
||||
## Server File Manager Transfer
|
||||
|
||||
Implemented HTTP JSON routes:
|
||||
|
||||
- `POST /api/v1/run/files/input-chunk`
|
||||
|
||||
Browser-facing file management uses server-instance scoped routes on Platform for workspace, list, read, write, upload, and download preparation. The browser only sends plugin-declared logical directory keys, logical file keys, relative names, inline text for small edits, or platform-owned `artifact://` input refs. It never receives or sends host paths, direct Run sockets, Run sessions, job lease tokens, storage credentials, or raw machine endpoints.
|
||||
|
||||
`files.list` jobs return a bounded `file.list` execution result containing logical entries. `files.read` jobs may return a bounded inline `file.read` result for editable text or a `resultRef` pointing to a platform artifact for larger content. Browser downloads are prepared through Platform and then read in bounded chunks using the platform artifact download contract.
|
||||
|
||||
Browser uploads are first staged as server-instance artifacts. Platform then queues a `files.write` job whose `inputRef` is `artifact://<id>` and whose execution input names the dedicated `run-file-transfer` channel. Run pulls those bytes through `POST /api/v1/run/files/input-chunk` while proving the active endpoint session plus job attempt and lease. The chunk route is fenced to the active file-write job, validates artifact ownership/checksum, and returns bounded byte ranges only.
|
||||
|
||||
File-manager transfer is a separate, low-priority heavy path. Slow uploads, downloads, retries, or file input chunk pulls must not block control heartbeat, job claim/ack/progress/result/cancel/reconcile, durable log batch ingest, or artifact upload acknowledgements. Control, jobs, logs, artifacts, file transfer, and optional game-client bridge remain independently backpressured channels.
|
||||
|
||||
## Client Manager lifecycle channel
|
||||
|
||||
Client Manager lifecycle jobs use the independent capabilities `client-manager.deploy`, `client-manager.control`, `client-manager.update`, `client-manager.rollback`, and `client-manager.uninstall`. Run obtains a fenced logical contract from `POST /api/v1/run/jobs/client-manager-input` and reads resumable artifact chunks from `POST /api/v1/run/jobs/client-manager-chunk`; these routes are separate from artifact upload, Run control, logs, and optional game-client traffic. The contract carries installation/profile, target, version/revision, checksum, deployment/key generations, fixed executable reference, bounded arguments/timeouts, and idempotency. For a plugin-declared companion profile it also carries a generic `companionConfig` materialization contract: safe relative template/schema/output references, the fenced component identity, declared component capabilities, Platform URL source, proof environment-variable name, component-session/TLS policy, and bounded timing values.
|
||||
|
||||
@@ -311,6 +311,10 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
|
||||
if result.ExecutionResult.Checksum == "" || result.ExecutionResult.Checksum != validator.BytesChecksum([]byte(job.ExecutionInput.Content)) {
|
||||
return validationError("config write result checksum is invalid")
|
||||
}
|
||||
case domain.JobCapabilityFilesList:
|
||||
if result.ExecutionResult.Kind != "file.list" {
|
||||
return validationError("file list result type is invalid")
|
||||
}
|
||||
case domain.JobCapabilityFilesRead:
|
||||
if result.ExecutionResult.Kind != "file.read" {
|
||||
return validationError("file read result type is invalid")
|
||||
|
||||
@@ -123,6 +123,13 @@ type Core interface {
|
||||
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
|
||||
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
|
||||
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
|
||||
GetServerFileWorkspaceForSession(string, string) (domain.ServerFileWorkspaceView, error)
|
||||
ListServerFilesForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error)
|
||||
RefreshServerFileListForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error)
|
||||
ReadServerFileForSession(string, domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error)
|
||||
WriteServerFileForSession(string, domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error)
|
||||
UploadServerFileForSession(string, domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error)
|
||||
PrepareServerFileDownloadForSession(string, domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error)
|
||||
PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error)
|
||||
ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error)
|
||||
DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error)
|
||||
@@ -143,6 +150,7 @@ type Core interface {
|
||||
GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error)
|
||||
GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error)
|
||||
ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
|
||||
ReadRunFileInputChunk(domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error)
|
||||
ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error)
|
||||
RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error)
|
||||
PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error)
|
||||
@@ -2156,11 +2164,6 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != instance.ID || artifact.State != domain.ArtifactStateAvailable {
|
||||
return domain.FileOperationDispatchResult{}, ErrForbidden
|
||||
}
|
||||
payload, payloadErr := svc.artifactPayload(artifactID)
|
||||
if payloadErr != nil {
|
||||
return domain.FileOperationDispatchResult{}, payloadErr
|
||||
}
|
||||
content = string(payload)
|
||||
}
|
||||
if request.PluginID != "" {
|
||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||
@@ -2173,7 +2176,7 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
if plugin.Status != domain.GamePluginStatusInstalled {
|
||||
return domain.FileOperationDispatchResult{}, validationError("plugin must be installed")
|
||||
}
|
||||
if request.Operation == domain.FileOperationRead && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
|
||||
if (request.Operation == domain.FileOperationList || request.Operation == domain.FileOperationRead) && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
|
||||
return domain.FileOperationDispatchResult{}, ErrForbidden
|
||||
}
|
||||
if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") {
|
||||
@@ -2188,6 +2191,10 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
}
|
||||
capability := domain.JobCapabilityFilesRead
|
||||
message := "file read queued"
|
||||
if request.Operation == domain.FileOperationList {
|
||||
capability = domain.JobCapabilityFilesList
|
||||
message = "file list queued"
|
||||
}
|
||||
if request.Operation == domain.FileOperationWrite {
|
||||
capability = domain.JobCapabilityFilesWrite
|
||||
message = "file write queued"
|
||||
@@ -2218,6 +2225,17 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
}
|
||||
|
||||
func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) {
|
||||
if request.Operation == domain.FileOperationList {
|
||||
if len(workspace.Directories) == 0 {
|
||||
return domain.PluginLogicalFile{}, false, true
|
||||
}
|
||||
for _, directory := range workspace.Directories {
|
||||
if directory.Key == request.Key {
|
||||
return domain.PluginLogicalFile{}, true, true
|
||||
}
|
||||
}
|
||||
return domain.PluginLogicalFile{}, true, false
|
||||
}
|
||||
if len(workspace.Files) == 0 {
|
||||
return domain.PluginLogicalFile{}, false, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const (
|
||||
serverFileTransferChannel = "run-file-transfer"
|
||||
serverFileMaxInlineEditBytes = 64 * 1024
|
||||
)
|
||||
|
||||
type serverFileContext struct {
|
||||
User domain.User
|
||||
Instance domain.ServerInstance
|
||||
Plugin domain.GamePlugin
|
||||
Directory domain.PluginLogicalDirectory
|
||||
Scope string
|
||||
}
|
||||
|
||||
type runFileListEnvelope struct {
|
||||
DirectoryKey string `json:"directoryKey"`
|
||||
Path string `json:"path"`
|
||||
Entries []runFileListEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type runFileListEntry struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
DirectoryKey string `json:"directoryKey"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
LogicalKey string `json:"logicalKey"`
|
||||
Scope string `json:"scope"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
Checksum string `json:"checksum"`
|
||||
Editable bool `json:"editable"`
|
||||
Downloadable bool `json:"downloadable"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serverInstanceID string) (domain.ServerFileWorkspaceView, error) {
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false, false)
|
||||
if err != nil {
|
||||
return domain.ServerFileWorkspaceView{}, err
|
||||
}
|
||||
workspace := domain.CopyPluginFileWorkspace(ctx.Plugin.FileWorkspace)
|
||||
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
|
||||
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
|
||||
}
|
||||
view := domain.ServerFileWorkspaceView{
|
||||
ServerInstanceID: ctx.Instance.ID,
|
||||
PluginID: ctx.Plugin.ID,
|
||||
DefaultDirectoryKey: workspace.DefaultDirectoryKey,
|
||||
Directories: workspace.Directories,
|
||||
Files: workspace.Files,
|
||||
ConfigFields: workspace.ConfigFields,
|
||||
DeclaredOnly: true,
|
||||
RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID),
|
||||
Transfer: domain.ServerFileTransferPolicy{
|
||||
Channel: serverFileTransferChannel,
|
||||
UploadChunkSizeBytes: validator.MaxArtifactChunkBytes,
|
||||
DownloadChunkSizeBytes: validator.MaxArtifactDownloadBytes,
|
||||
MaxInlineEditBytes: serverFileMaxInlineEditBytes,
|
||||
MaxBrowserUploadBytes: validator.MaxArtifactBytes,
|
||||
Notes: []string{
|
||||
"文件字节通过独立文件传输端点传递,不占用 control、jobs 或 logs 通道。",
|
||||
"Web 只发送逻辑目录和相对路径;Run 在本机工作区内解析真实路径。",
|
||||
},
|
||||
},
|
||||
}
|
||||
return domain.CopyServerFileWorkspaceView(view), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListServerFilesForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
|
||||
request = normalizeServerFileListRequest(request)
|
||||
if err := validator.ValidateServerFileListRequest(request); err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, false)
|
||||
if err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
latest, hasLatest, err := svc.latestFileListJob(ctx.Instance.ID, request.DirectoryKey, request.Path)
|
||||
if err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
if hasLatest && latest.State == domain.JobStateSucceeded && latest.ExecutionResult.Kind == "file.list" {
|
||||
entries, parseErr := serverFileEntriesFromRunList(latest.ExecutionResult.Content, request.DirectoryKey, request.Path)
|
||||
if parseErr == nil {
|
||||
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "ready", Entries: filterServerFileEntries(entries, request.Query), Job: latest, RefreshedAt: latest.TerminalAt}), nil
|
||||
}
|
||||
}
|
||||
state := "declared"
|
||||
reason := "展示插件声明的逻辑文件;点击刷新可请求 Run 返回实时目录。"
|
||||
if hasLatest && !isTerminalJobState(latest.State) {
|
||||
state = "pending"
|
||||
reason = "Run 正在刷新目录。"
|
||||
}
|
||||
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
|
||||
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: state, Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: reason}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
|
||||
request = normalizeServerFileListRequest(request)
|
||||
if request.IdempotencyKey == "" {
|
||||
request.IdempotencyKey = fmt.Sprintf("file-list:%s:%s:%s", request.ServerInstanceID, request.DirectoryKey, request.Path)
|
||||
}
|
||||
if err := validator.ValidateServerFileListRequest(request); err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, true)
|
||||
if err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: jobIDFromParts("job-file-list", request.ServerInstanceID, request.IdempotencyKey),
|
||||
ServerInstanceID: ctx.Instance.ID,
|
||||
RunEndpointID: ctx.Instance.RunEndpointID,
|
||||
Capability: domain.JobCapabilityFilesList,
|
||||
TargetKey: request.DirectoryKey,
|
||||
InputRef: "",
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Inputs: map[string]string{
|
||||
"directoryKey": request.DirectoryKey,
|
||||
"path": request.Path,
|
||||
"recursive": strconv.FormatBool(request.Recursive),
|
||||
"query": request.Query,
|
||||
}, MaxReadBytes: serverFileMaxInlineEditBytes},
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file list queued"},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
|
||||
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "pending", Entries: entries, Job: job, Reason: "目录刷新任务已派发到 Run。"}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ReadServerFileForSession(sessionID string, request domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error) {
|
||||
if err := validator.ValidateServerFileReadRequest(request); err != nil {
|
||||
return domain.FileOperationDispatchResult{}, err
|
||||
}
|
||||
return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
|
||||
}
|
||||
|
||||
func (svc *CoreService) WriteServerFileForSession(sessionID string, request domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error) {
|
||||
if request.InputRef == "" {
|
||||
request.InputRef = fileManagerInlineInputRef(request.ServerInstanceID, request.Key, request.IdempotencyKey)
|
||||
}
|
||||
if err := validator.ValidateServerFileWriteRequest(request); err != nil {
|
||||
return domain.FileOperationDispatchResult{}, err
|
||||
}
|
||||
return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey})
|
||||
}
|
||||
|
||||
func (svc *CoreService) UploadServerFileForSession(sessionID string, request domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error) {
|
||||
request = domain.CopyServerFileUploadRequest(request)
|
||||
if err := validator.ValidateServerFileUploadRequest(request); err != nil {
|
||||
return domain.ServerFileUploadDispatch{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true, true)
|
||||
if err != nil {
|
||||
return domain.ServerFileUploadDispatch{}, err
|
||||
}
|
||||
if strings.EqualFold(ctx.Directory.Scope, "logs") {
|
||||
return domain.ServerFileUploadDispatch{}, validationError("log directories are read-only")
|
||||
}
|
||||
relativePath := cleanServerFileRelativePath(path.Join(request.RelativePath, request.Filename))
|
||||
artifactID := serverFileUploadArtifactID(request.ServerInstanceID, request.IdempotencyKey, relativePath)
|
||||
artifact := domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindServerInstance, OwnerID: ctx.Instance.ID, SizeBytes: int64(len(request.Payload)), Checksum: request.Checksum, State: domain.ArtifactStateAvailable, CreatedAt: svc.now(), UpdatedAt: svc.now()}
|
||||
if err := svc.putBrowserFileArtifact(artifact, request.Payload); err != nil {
|
||||
return domain.ServerFileUploadDispatch{}, err
|
||||
}
|
||||
inputRef := "artifact://" + artifact.ID
|
||||
targetKey := cleanServerFileRelativePath(path.Join(request.DirectoryKey, relativePath))
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: jobIDFromParts("job-file-upload", request.ServerInstanceID, request.IdempotencyKey),
|
||||
ServerInstanceID: ctx.Instance.ID,
|
||||
RunEndpointID: ctx.Instance.RunEndpointID,
|
||||
Capability: domain.JobCapabilityFilesWrite,
|
||||
TargetKey: targetKey,
|
||||
InputRef: inputRef,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), ExpectedChecksum: request.Checksum, Inputs: map[string]string{
|
||||
"directoryKey": request.DirectoryKey,
|
||||
"relativePath": relativePath,
|
||||
"filename": request.Filename,
|
||||
"transfer": serverFileTransferChannel,
|
||||
}},
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file upload staged; Run will pull input chunks"},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ServerFileUploadDispatch{}, err
|
||||
}
|
||||
return domain.CopyServerFileUploadDispatch(domain.ServerFileUploadDispatch{Status: "queued", ServerInstanceID: ctx.Instance.ID, DirectoryKey: request.DirectoryKey, RelativePath: relativePath, ArtifactID: artifact.ID, InputRef: inputRef, SizeBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Job: job}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, request domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error) {
|
||||
if err := validator.ValidateServerFileDownloadRequest(request); err != nil {
|
||||
return domain.ServerFileDownloadResult{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false, true)
|
||||
if err != nil {
|
||||
return domain.ServerFileDownloadResult{}, err
|
||||
}
|
||||
filename := serverFileDisplayName(ctx.Plugin.FileWorkspace, request.Key)
|
||||
job, hasJob, err := svc.latestFileReadJob(ctx.Instance.ID, request.Key)
|
||||
if err != nil {
|
||||
return domain.ServerFileDownloadResult{}, err
|
||||
}
|
||||
if hasJob && job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
|
||||
if strings.HasPrefix(job.ResultRef, "artifact://") {
|
||||
reference, openErr := svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: strings.TrimPrefix(job.ResultRef, "artifact://")})
|
||||
if openErr != nil {
|
||||
return domain.ServerFileDownloadResult{}, openErr
|
||||
}
|
||||
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: reference.ContentType, Checksum: reference.Checksum, SizeBytes: reference.SizeBytes, Artifact: &reference, Job: job, ReadAt: job.TerminalAt}), nil
|
||||
}
|
||||
if job.ExecutionResult.Content != "" {
|
||||
content := redactDeclaredFileReadContent(job.ExecutionResult.Content)
|
||||
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: "text/plain; charset=utf-8", Content: content, Checksum: job.ExecutionResult.Checksum, SizeBytes: int64(len([]byte(content))), Job: job, ReadAt: job.TerminalAt}), nil
|
||||
}
|
||||
}
|
||||
if hasJob && !isTerminalJobState(job.State) {
|
||||
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: job, Reason: "文件读取任务仍在执行。"}), nil
|
||||
}
|
||||
dispatch, err := svc.ReadServerFileForSession(sessionID, domain.ServerFileReadRequest{ServerInstanceID: request.ServerInstanceID, PluginID: ctx.Plugin.ID, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
|
||||
if err != nil {
|
||||
return domain.ServerFileDownloadResult{}, err
|
||||
}
|
||||
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: dispatch.Job, Reason: "文件读取任务已派发;完成后可再次下载。"}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error) {
|
||||
request = domain.CopyRunFileInputChunkRequest(request)
|
||||
if err := validator.ValidateRunFileInputChunkRequest(request); err != nil {
|
||||
return domain.RunFileInputChunk{}, err
|
||||
}
|
||||
session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.RunFileInputChunk{}, err
|
||||
}
|
||||
svc.jobMu.Lock()
|
||||
job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt)
|
||||
if err != nil {
|
||||
svc.jobMu.Unlock()
|
||||
return domain.RunFileInputChunk{}, err
|
||||
}
|
||||
if job.Capability != domain.JobCapabilityFilesWrite || !strings.HasPrefix(job.InputRef, "artifact://") {
|
||||
svc.jobMu.Unlock()
|
||||
return domain.RunFileInputChunk{}, validationError("job does not reference a file input artifact")
|
||||
}
|
||||
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
|
||||
svc.jobMu.Unlock()
|
||||
return domain.RunFileInputChunk{}, validationError("job is not active")
|
||||
}
|
||||
artifactID := strings.TrimPrefix(job.InputRef, "artifact://")
|
||||
serverInstanceID := job.ServerInstanceID
|
||||
svc.jobMu.Unlock()
|
||||
|
||||
artifact, err := svc.store.Artifacts().Get(artifactID)
|
||||
if err != nil {
|
||||
return domain.RunFileInputChunk{}, err
|
||||
}
|
||||
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable {
|
||||
return domain.RunFileInputChunk{}, ErrForbidden
|
||||
}
|
||||
payload, err := svc.artifactPayload(artifact.ID)
|
||||
if err != nil {
|
||||
return domain.RunFileInputChunk{}, err
|
||||
}
|
||||
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
|
||||
return domain.RunFileInputChunk{}, validationError("file input artifact checksum mismatch")
|
||||
}
|
||||
if request.Offset >= artifact.SizeBytes {
|
||||
return domain.RunFileInputChunk{}, validationError("offset must be inside artifact content")
|
||||
}
|
||||
length := request.Length
|
||||
remaining := artifact.SizeBytes - request.Offset
|
||||
if int64(length) > remaining {
|
||||
length = int(remaining)
|
||||
}
|
||||
end := int(request.Offset) + length
|
||||
chunk := domain.RunFileInputChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):end], Complete: int64(end) == artifact.SizeBytes}
|
||||
return domain.CopyRunFileInputChunk(chunk), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool, requireRuntime bool) (serverFileContext, error) {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||
if err != nil {
|
||||
return serverFileContext{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return serverFileContext{}, err
|
||||
}
|
||||
if requireRuntime {
|
||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "file.manager.denied"); err != nil {
|
||||
return serverFileContext{}, err
|
||||
}
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return serverFileContext{}, err
|
||||
}
|
||||
if plugin.Status != domain.GamePluginStatusInstalled {
|
||||
return serverFileContext{}, validationError("plugin must be installed")
|
||||
}
|
||||
if requireWrite {
|
||||
if !containsString(plugin.DeclaredPermissions, "server.files.write") {
|
||||
return serverFileContext{}, ErrForbidden
|
||||
}
|
||||
} else if !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
|
||||
return serverFileContext{}, ErrForbidden
|
||||
}
|
||||
directory := domain.PluginLogicalDirectory{}
|
||||
if directoryKey != "" {
|
||||
var found bool
|
||||
for _, candidate := range plugin.FileWorkspace.Directories {
|
||||
if candidate.Key == directoryKey {
|
||||
directory = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(plugin.FileWorkspace.Directories) > 0 {
|
||||
return serverFileContext{}, validationError("directoryKey must reference a plugin-declared directory")
|
||||
}
|
||||
}
|
||||
return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Scope: svc.runtimeProfileScope(instance.ID)}, nil
|
||||
}
|
||||
|
||||
func normalizeServerFileListRequest(request domain.ServerFileListRequest) domain.ServerFileListRequest {
|
||||
request.DirectoryKey = strings.TrimSpace(request.DirectoryKey)
|
||||
request.Path = cleanServerFileRelativePath(request.Path)
|
||||
request.Query = strings.TrimSpace(request.Query)
|
||||
return request
|
||||
}
|
||||
|
||||
func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace, directoryKey string) []domain.ServerFileEntry {
|
||||
entries := make([]domain.ServerFileEntry, 0)
|
||||
for _, directory := range workspace.Directories {
|
||||
if directoryKey == "" || directory.Key == directoryKey {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, domain.ServerFileEntry{Name: directory.Label, Kind: domain.ServerFileEntryDirectory, DirectoryKey: directory.Key, RelativePath: "", Scope: directory.Scope, Editable: false, Downloadable: false, Remark: "插件声明目录"})
|
||||
}
|
||||
for _, file := range workspace.Files {
|
||||
if directoryKey != "" && file.DirectoryKey != directoryKey {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, domain.ServerFileEntry{Name: file.Label, Kind: domain.ServerFileEntryFile, DirectoryKey: file.DirectoryKey, RelativePath: file.Key, LogicalKey: file.Key, Scope: file.Kind, Editable: file.Editable, Downloadable: true, Remark: fileRemark(file)})
|
||||
}
|
||||
sort.SliceStable(entries, func(i int, j int) bool {
|
||||
if entries[i].Kind == entries[j].Kind {
|
||||
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
||||
}
|
||||
return entries[i].Kind == domain.ServerFileEntryDirectory
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, fallbackPath string) ([]domain.ServerFileEntry, error) {
|
||||
var envelope runFileListEnvelope
|
||||
if err := json.Unmarshal([]byte(content), &envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]domain.ServerFileEntry, 0, len(envelope.Entries))
|
||||
for _, entry := range envelope.Entries {
|
||||
kind := domain.ServerFileEntryFile
|
||||
if entry.Kind == string(domain.ServerFileEntryDirectory) {
|
||||
kind = domain.ServerFileEntryDirectory
|
||||
}
|
||||
modifiedAt := time.Time{}
|
||||
if entry.ModifiedAt != "" {
|
||||
modifiedAt, _ = time.Parse(time.RFC3339, entry.ModifiedAt)
|
||||
}
|
||||
directoryKey := entry.DirectoryKey
|
||||
if directoryKey == "" {
|
||||
directoryKey = fallbackDirectoryKey
|
||||
}
|
||||
relativePath := cleanServerFileRelativePath(entry.RelativePath)
|
||||
if relativePath == "" {
|
||||
relativePath = fallbackPath
|
||||
}
|
||||
entries = append(entries, domain.ServerFileEntry{Name: entry.Name, Kind: kind, DirectoryKey: directoryKey, RelativePath: relativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: modifiedAt, Checksum: entry.Checksum, Editable: entry.Editable, Downloadable: entry.Downloadable, Remark: entry.Remark})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func filterServerFileEntries(entries []domain.ServerFileEntry, query string) []domain.ServerFileEntry {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
if query == "" {
|
||||
return domain.CopyServerFileEntries(entries)
|
||||
}
|
||||
filtered := make([]domain.ServerFileEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(strings.ToLower(entry.Name+" "+entry.RelativePath+" "+entry.LogicalKey+" "+entry.Remark), query) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) {
|
||||
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesList, directoryKey)
|
||||
}
|
||||
|
||||
func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) {
|
||||
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesRead, key)
|
||||
}
|
||||
|
||||
func (svc *CoreService) latestServerFileJob(serverInstanceID string, capability string, targetKey string) (domain.Job, bool, error) {
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
|
||||
if err != nil {
|
||||
return domain.Job{}, false, err
|
||||
}
|
||||
var latest domain.Job
|
||||
found := false
|
||||
for _, job := range jobs {
|
||||
if job.Capability != capability || job.TargetKey != targetKey {
|
||||
continue
|
||||
}
|
||||
if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) {
|
||||
latest = job
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return domain.CopyJob(latest), found, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) putBrowserFileArtifact(artifact domain.Artifact, payload []byte) error {
|
||||
svc.artifactMu.Lock()
|
||||
defer svc.artifactMu.Unlock()
|
||||
if existing, err := svc.store.Artifacts().Get(artifact.ID); err == nil {
|
||||
if existing.OwnerKind != artifact.OwnerKind || existing.OwnerID != artifact.OwnerID || existing.SizeBytes != artifact.SizeBytes || existing.Checksum != artifact.Checksum || existing.State != domain.ArtifactStateAvailable {
|
||||
return validationError("file upload idempotency key conflicts with existing artifact")
|
||||
}
|
||||
} else if !errors.Is(err, repo.ErrNotFound) {
|
||||
return err
|
||||
} else {
|
||||
if err := validator.ValidateArtifact(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.store.Artifacts().Create(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := svc.artifactStore.PutPayload(artifact.ID, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanServerFileRelativePath(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
if value == "" || value == "." {
|
||||
return ""
|
||||
}
|
||||
cleaned := path.Clean(value)
|
||||
if cleaned == "." {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(cleaned, "/")
|
||||
}
|
||||
|
||||
func serverFileUploadArtifactID(serverInstanceID string, idempotencyKey string, relativePath string) string {
|
||||
raw := serverInstanceID + ":" + idempotencyKey + ":" + relativePath
|
||||
return "artifact-file-upload-" + sanitizeIDPart(serverInstanceID) + "-" + fmt.Sprint(stableStringNumber(raw))
|
||||
}
|
||||
|
||||
func fileManagerInlineInputRef(serverInstanceID string, key string, idempotencyKey string) string {
|
||||
return "input://server-files/" + sanitizeIDPart(serverInstanceID) + "/" + sanitizeIDPart(key) + "/" + fmt.Sprint(stableStringNumber(idempotencyKey))
|
||||
}
|
||||
|
||||
func fileRemark(file domain.PluginLogicalFile) string {
|
||||
if file.StreamKey != "" {
|
||||
return "日志流 " + file.StreamKey
|
||||
}
|
||||
if file.Editable {
|
||||
return "可编辑配置"
|
||||
}
|
||||
return "插件声明文件"
|
||||
}
|
||||
|
||||
func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string {
|
||||
for _, file := range workspace.Files {
|
||||
if file.Key == key && strings.TrimSpace(file.Label) != "" {
|
||||
return file.Label
|
||||
}
|
||||
}
|
||||
name := path.Base(key)
|
||||
if name == "." || name == "/" || name == "" {
|
||||
return "server-file.txt"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -1421,6 +1421,104 @@ func ValidateFileOperationDispatchRequest(request domain.FileOperationDispatchRe
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateServerFileListRequest(request domain.ServerFileListRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||
violations = appendRequired(violations, "directoryKey", request.DirectoryKey)
|
||||
if !validLogicalFileKey(request.DirectoryKey) {
|
||||
violations = append(violations, "directoryKey is not allowed")
|
||||
}
|
||||
if request.Path != "" && !validLogicalFileKey(request.Path) {
|
||||
violations = append(violations, "path is not allowed")
|
||||
}
|
||||
if len([]rune(request.Query)) > 80 || containsUnsafeRuntimeSecret(request.Query) || looksLikeRawHostPath(request.Query) {
|
||||
violations = append(violations, "query is not allowed")
|
||||
}
|
||||
if request.IdempotencyKey != "" && (containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey)) {
|
||||
violations = append(violations, "idempotencyKey is not allowed")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateServerFileReadRequest(request domain.ServerFileReadRequest) error {
|
||||
return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
|
||||
}
|
||||
|
||||
func ValidateServerFileWriteRequest(request domain.ServerFileWriteRequest) error {
|
||||
return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey})
|
||||
}
|
||||
|
||||
func ValidateServerFileUploadRequest(request domain.ServerFileUploadRequest) error {
|
||||
request = domain.CopyServerFileUploadRequest(request)
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||
violations = appendRequired(violations, "directoryKey", request.DirectoryKey)
|
||||
violations = appendRequired(violations, "filename", request.Filename)
|
||||
violations = appendRequired(violations, "checksum", request.Checksum)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if !validLogicalFileKey(request.DirectoryKey) {
|
||||
violations = append(violations, "directoryKey is not allowed")
|
||||
}
|
||||
if request.RelativePath != "" && !validLogicalFileKey(request.RelativePath) {
|
||||
violations = append(violations, "relativePath is not allowed")
|
||||
}
|
||||
if !validUploadFilename(request.Filename) {
|
||||
violations = append(violations, "filename is not allowed")
|
||||
}
|
||||
if len(request.Payload) == 0 {
|
||||
violations = append(violations, "payload is required")
|
||||
}
|
||||
if int64(len(request.Payload)) > MaxArtifactBytes {
|
||||
violations = append(violations, fmt.Sprintf("payload must not exceed %d", MaxArtifactBytes))
|
||||
}
|
||||
if request.Checksum != "" {
|
||||
if !validSHA256Checksum(request.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
} else if request.Checksum != BytesChecksum(request.Payload) {
|
||||
violations = append(violations, "checksum does not match payload")
|
||||
}
|
||||
}
|
||||
for _, value := range []string{request.ServerInstanceID, request.DirectoryKey, request.RelativePath, request.Filename, request.IdempotencyKey} {
|
||||
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "unix://") {
|
||||
violations = append(violations, "request contains unsafe content")
|
||||
break
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateServerFileDownloadRequest(request domain.ServerFileDownloadRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||
violations = appendRequired(violations, "key", request.Key)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if !validLogicalFileKey(request.Key) {
|
||||
violations = append(violations, "key is not allowed")
|
||||
}
|
||||
if containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey) {
|
||||
violations = append(violations, "idempotencyKey is not allowed")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunFileInputChunkRequest(request domain.RunFileInputChunkRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", request.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", request.SessionToken)
|
||||
violations = appendRequired(violations, "jobId", request.JobID)
|
||||
violations = appendRequired(violations, "leaseToken", request.LeaseToken)
|
||||
if request.Attempt <= 0 {
|
||||
violations = append(violations, "attempt must be positive")
|
||||
}
|
||||
if request.Offset < 0 {
|
||||
violations = append(violations, "offset must not be negative")
|
||||
}
|
||||
if request.Length <= 0 || request.Length > MaxArtifactDownloadBytes {
|
||||
violations = append(violations, fmt.Sprintf("length must be between 1 and %d", MaxArtifactDownloadBytes))
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func appendPercentViolation(violations []string, field string, value float64) []string {
|
||||
if value < 0 || value > 100 {
|
||||
return append(violations, field+" must be between 0 and 100")
|
||||
@@ -1588,7 +1686,7 @@ func ValidateJob(job domain.Job) error {
|
||||
if len(job.ExecutionResult.Summary) > maxSummaryLength {
|
||||
violations = append(violations, "executionResult.summary is too long")
|
||||
}
|
||||
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
||||
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesList || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
||||
if job.ServerInstanceID == "" {
|
||||
violations = append(violations, "serverInstanceId is required for scoped file jobs")
|
||||
}
|
||||
@@ -2305,13 +2403,27 @@ func validRemoteDatabaseEngine(engine string) bool {
|
||||
|
||||
func validFileOperationKind(operation domain.FileOperationKind) bool {
|
||||
switch operation {
|
||||
case domain.FileOperationRead, domain.FileOperationWrite:
|
||||
case domain.FileOperationList, domain.FileOperationRead, domain.FileOperationWrite:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validUploadFilename(name string) bool {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" || trimmed != name || len([]rune(name)) > 120 || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "..") || strings.Contains(name, "://") || looksLikeRawHostPath(name) || containsUnsafeRuntimeSecret(name) {
|
||||
return false
|
||||
}
|
||||
for _, char := range name {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == ' ' || char == '(' || char == ')' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validConfigFileKey(key string) bool {
|
||||
switch key {
|
||||
case "server.properties", "config/server.properties":
|
||||
|
||||
Reference in New Issue
Block a user