feat(platform): forward protected requests to run
This commit is contained in:
@@ -14,4 +14,4 @@
|
||||
|
||||
- [x] 3.1 Update focused Go and TypeScript tests for declarations, request generation, redaction, and safe rejection.
|
||||
- [x] 3.2 Run focused Go/TS tests, OpenSpec strict validation, and structure verification.
|
||||
- [ ] 3.3 Stage scoped files, commit, and push `main` (commit created; push remains blocked by remote SSH access).
|
||||
- [x] 3.3 Forward approved protected bridge requests through a signed, fenced, one-time Platform→Run input route; verify redaction, server/transport binding, terminal result projection, commit, and push `main`.
|
||||
|
||||
@@ -142,11 +142,12 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
|
||||
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
|
||||
privateUpdateBodies := map[string]any{
|
||||
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8},
|
||||
"/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
|
||||
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/protected-request-input": dto.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, FencingToken: 1},
|
||||
"/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8},
|
||||
"/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
|
||||
}
|
||||
nonce := 10
|
||||
for path, request := range privateUpdateBodies {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// runProtectedRequestInput godoc
|
||||
// @Summary Read one protected request for the active Run lease
|
||||
// @Description Returns approved SQL, RCON, or management-program text exactly once to its signed, fenced Run lease. Browser and plugin clients never receive this payload.
|
||||
// @Tags run-job-channel
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.ProtectedRequestExecutionInputRequest true "Fenced protected request input request"
|
||||
// @Success 200 {object} dto.ProtectedRequestExecutionInputResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/run/jobs/protected-request-input [post]
|
||||
func (h *coreHandlers) runProtectedRequestInput(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ProtectedRequestExecutionInputRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
input, err := h.core.GetProtectedRequestExecutionInput(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ProtectedRequestExecutionInputFromDomain(input))
|
||||
}
|
||||
@@ -125,6 +125,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/run/jobs/build-input", h.requireRunSignature(h.runJobBuildInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/dependency-input", h.requireRunSignature(h.runJobDependencyInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/protected-request-input", h.requireRunSignature(h.runProtectedRequestInput))
|
||||
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/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
|
||||
|
||||
@@ -168,7 +168,7 @@ Runtime distribution and client-manager APIs require the current bearer session,
|
||||
|
||||
## Private Run Dependency And Update Routes
|
||||
|
||||
The following signed routes are Run-only and never part of browser/plugin DTOs: `POST /api/v1/run/jobs/dependency-input`, `POST /api/v1/run/jobs/update-input`, `POST /api/v1/run/jobs/update-chunk`, and `POST /api/v1/run/jobs/update-health`. They require the current endpoint/session signature; input/chunk calls additionally require active attempt/lease/cancel fencing. Update chunks are bounded to 1 MiB and resolve only an available same-server target-matched Run distribution. Health reports are accepted only after the terminal staged job, matching attempt/lease proof, current online endpoint release, and reconciliation-capable session are verified. These routes never return raw artifact paths, browser download tokens, host paths, credentials, secret refs, or session/lease hashes.
|
||||
The following signed routes are Run-only and never part of browser/plugin DTOs: `POST /api/v1/run/jobs/dependency-input`, `POST /api/v1/run/jobs/protected-request-input`, `POST /api/v1/run/jobs/update-input`, `POST /api/v1/run/jobs/update-chunk`, and `POST /api/v1/run/jobs/update-health`. They require the current endpoint/session signature; input/chunk calls additionally require active attempt/lease/cancel fencing. Protected-request input additionally requires the current fencing token and returns approved text exactly once for the server-bound logical transport; the text is not persisted in a job, bridge command, journal, response projection, or audit summary. Update chunks are bounded to 1 MiB and resolve only an available same-server target-matched Run distribution. Health reports are accepted only after the terminal staged job, matching attempt/lease proof, current online endpoint release, and reconciliation-capable session are verified. These routes never return raw artifact paths, browser download tokens, host paths, credentials, secret refs, or session/lease hashes.
|
||||
|
||||
## Implemented Run Control Actions
|
||||
|
||||
@@ -186,6 +186,7 @@ Control is the highest-priority run-facing channel; artifact/file transfer press
|
||||
- `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal result or durable retry-wait transition with capped exponential backoff.
|
||||
- `POST /api/v1/run/jobs/cancel`: accept fenced `RunJobCancelPollRequest` and return durable pending cancellation intent for the current attempt.
|
||||
- `POST /api/v1/run/jobs/reconcile`: accept persisted Run journal evidence (`jobId`, `attempt`, `leaseToken`), rebind only matching active attempts to the current authenticated session generation, persist reconciliation metadata, retry/cancel platform-active missing work, and return confirmed assignments plus discard IDs.
|
||||
- `POST /api/v1/run/jobs/protected-request-input`: accept `ProtectedRequestExecutionInputRequest`, fence endpoint/session/attempt/lease/token, and return one approved, unexpired SQL, RCON, or management-program request only for its exact server-bound logical transport. The route never returns credentials, DSNs, paths, sockets, raw connections, or host shell material.
|
||||
- `POST /api/v1/jobs/{id}/cancel`: authorize the server owner/administrator or platform administrator and durably record cancellation intent; queued/retrying work becomes cancelled immediately while active work completes through fenced Run polling/result.
|
||||
|
||||
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.
|
||||
|
||||
@@ -131,6 +131,7 @@ type GameClientBridgeCommand struct {
|
||||
ProfileKey string
|
||||
CommandType string
|
||||
Payload map[string]any
|
||||
RunJobID string
|
||||
IdempotencyKey string
|
||||
Priority int
|
||||
State GameClientBridgeCommandState
|
||||
|
||||
@@ -22,6 +22,7 @@ type RunJobAssignment struct {
|
||||
ExecutionInput JobExecutionInput
|
||||
LeaseToken string
|
||||
Attempt int
|
||||
FencingToken uint64
|
||||
MaxAttempts int
|
||||
AckDeadlineAt time.Time
|
||||
LeaseExpiresAt time.Time
|
||||
@@ -168,6 +169,32 @@ type SourceRCONExecutionInput struct {
|
||||
Command string
|
||||
}
|
||||
|
||||
// ProtectedRequestExecutionInput is returned exactly once to the active,
|
||||
// fenced Run lease. RequestText is never persisted in a job or bridge command.
|
||||
type ProtectedRequestExecutionInputRequest struct {
|
||||
RunEndpointID string
|
||||
SessionToken string
|
||||
JobID string
|
||||
LeaseToken string
|
||||
Attempt int
|
||||
FencingToken uint64
|
||||
}
|
||||
|
||||
type ProtectedRequestExecutionInput struct {
|
||||
JobID string
|
||||
ServerInstanceID string
|
||||
RunEndpointID string
|
||||
FencingToken uint64
|
||||
Authorized bool
|
||||
ApprovalState string
|
||||
QueueState string
|
||||
ExpiresAt time.Time
|
||||
Kind string
|
||||
TransportKey string
|
||||
TargetKey string
|
||||
RequestText string
|
||||
}
|
||||
|
||||
type RunUpdateInputRequest struct {
|
||||
RunEndpointID string
|
||||
SessionToken string
|
||||
@@ -355,6 +382,10 @@ func CopySourceRCONExecutionInput(input SourceRCONExecutionInput) SourceRCONExec
|
||||
return input
|
||||
}
|
||||
|
||||
func CopyProtectedRequestExecutionInput(input ProtectedRequestExecutionInput) ProtectedRequestExecutionInput {
|
||||
return input
|
||||
}
|
||||
|
||||
func CopyRunUpdateChunk(chunk RunUpdateChunk) RunUpdateChunk {
|
||||
chunk.Payload = append([]byte(nil), chunk.Payload...)
|
||||
return chunk
|
||||
|
||||
@@ -79,6 +79,7 @@ type GameClientBridgeCommandResponse struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
CommandType string `json:"commandType"`
|
||||
RunJobID string `json:"runJobId,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
State string `json:"state"`
|
||||
ApprovalState string `json:"approvalState"`
|
||||
@@ -256,6 +257,7 @@ func GameClientBridgeCommandFromDomain(value domain.GameClientBridgeCommand) Gam
|
||||
PluginID: value.PluginID,
|
||||
ProfileKey: value.ProfileKey,
|
||||
CommandType: value.CommandType,
|
||||
RunJobID: value.RunJobID,
|
||||
Priority: value.Priority,
|
||||
State: string(value.State),
|
||||
ApprovalState: string(value.ApprovalState),
|
||||
|
||||
@@ -20,6 +20,7 @@ type RunJobAssignmentResponse struct {
|
||||
ExecutionInput RunJobExecutionInputBody `json:"executionInput,omitempty"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
FencingToken uint64 `json:"fencingToken,omitempty"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
AckDeadlineAt time.Time `json:"ackDeadlineAt,omitempty"`
|
||||
LeaseExpiresAt time.Time `json:"leaseExpiresAt,omitempty"`
|
||||
@@ -263,6 +264,30 @@ type SourceRCONExecutionInputResponse struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type ProtectedRequestExecutionInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
}
|
||||
|
||||
type ProtectedRequestExecutionInputResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
Authorized bool `json:"authorized"`
|
||||
ApprovalState string `json:"approvalState"`
|
||||
QueueState string `json:"queueState"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Kind string `json:"kind"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
RequestText string `json:"requestText"`
|
||||
}
|
||||
|
||||
type RunUpdateInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
@@ -442,6 +467,10 @@ func (request SourceRCONExecutionInputRequest) ToDomain() domain.SourceRCONExecu
|
||||
return domain.SourceRCONExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
|
||||
}
|
||||
|
||||
func (request ProtectedRequestExecutionInputRequest) ToDomain() domain.ProtectedRequestExecutionInputRequest {
|
||||
return domain.ProtectedRequestExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt, FencingToken: request.FencingToken}
|
||||
}
|
||||
|
||||
func (request RunUpdateInputRequest) ToDomain() domain.RunUpdateInputRequest {
|
||||
return domain.RunUpdateInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
|
||||
}
|
||||
@@ -555,6 +584,10 @@ func SourceRCONExecutionInputFromDomain(input domain.SourceRCONExecutionInput) S
|
||||
return SourceRCONExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, Command: input.Command}
|
||||
}
|
||||
|
||||
func ProtectedRequestExecutionInputFromDomain(input domain.ProtectedRequestExecutionInput) ProtectedRequestExecutionInputResponse {
|
||||
return ProtectedRequestExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, FencingToken: input.FencingToken, Authorized: input.Authorized, ApprovalState: input.ApprovalState, QueueState: input.QueueState, ExpiresAt: input.ExpiresAt, Kind: input.Kind, TransportKey: input.TransportKey, TargetKey: input.TargetKey, RequestText: input.RequestText}
|
||||
}
|
||||
|
||||
func RunUpdateInputFromDomain(input domain.RunUpdateInput) RunUpdateInputResponse {
|
||||
return RunUpdateInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, ArtifactID: input.ArtifactID, Checksum: input.Checksum, SizeBytes: input.SizeBytes, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PackageFormat: input.PackageFormat, ExecutableName: input.ExecutableName, TargetRelease: input.TargetRelease, ChunkSizeBytes: input.ChunkSizeBytes}
|
||||
}
|
||||
@@ -629,6 +662,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
|
||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)},
|
||||
LeaseToken: assignment.LeaseToken,
|
||||
Attempt: assignment.Attempt,
|
||||
FencingToken: assignment.FencingToken,
|
||||
MaxAttempts: assignment.MaxAttempts,
|
||||
AckDeadlineAt: assignment.AckDeadlineAt,
|
||||
LeaseExpiresAt: assignment.LeaseExpiresAt,
|
||||
|
||||
@@ -34,6 +34,7 @@ Implemented HTTP JSON routes:
|
||||
- `POST /api/v1/run/jobs/result`
|
||||
- `POST /api/v1/run/jobs/cancel`
|
||||
- `POST /api/v1/run/jobs/reconcile`
|
||||
- `POST /api/v1/run/jobs/protected-request-input`
|
||||
|
||||
Named job DTOs:
|
||||
|
||||
@@ -43,6 +44,8 @@ Named job DTOs:
|
||||
- `RunJobProgressRequest`
|
||||
- `RunJobResultRequest`
|
||||
- `RunJobCancelPollRequest`
|
||||
- `ProtectedRequestExecutionInputRequest`
|
||||
- `ProtectedRequestExecutionInputResponse`
|
||||
- `RunJobReconcileRequest`
|
||||
- `RunJobReconcileResponse`
|
||||
|
||||
|
||||
@@ -302,6 +302,9 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
||||
if err := validateProtectedGameClientBridgePayload(declaration.ProtectedRequest, request.Payload); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if declaration.ProtectedRequest != nil && declaration.TimeoutSeconds > protectedRequestMaxTimeoutSeconds {
|
||||
return domain.GameClientBridgeCommand{}, validationError("protected bridge request timeout exceeds Run policy")
|
||||
}
|
||||
|
||||
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
|
||||
if err == nil {
|
||||
@@ -340,6 +343,12 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
if declaration.ProtectedRequest != nil {
|
||||
command.Payload = redactedProtectedRequestPayload(declaration.ProtectedRequest)
|
||||
if approvalState == domain.GameClientBridgeApprovalApproved {
|
||||
command.RunJobID = jobIDFromParts("job-protected-request", command.ServerInstanceID, command.ID)
|
||||
}
|
||||
}
|
||||
summary := "queued declared game client bridge command"
|
||||
if declaration.ProtectedRequest != nil {
|
||||
summary = protectedGameClientBridgeAuditSummary(declaration.ProtectedRequest, request.Payload)
|
||||
@@ -352,6 +361,14 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
||||
if err := svc.store.GameClientBridgeCommands().Create(command); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if declaration.ProtectedRequest != nil && command.RunJobID != "" {
|
||||
if err := svc.dispatchProtectedRequest(command, declaration, request.Payload); err != nil {
|
||||
if deleteErr := svc.store.GameClientBridgeCommands().Delete(command.ID); deleteErr != nil {
|
||||
return domain.GameClientBridgeCommand{}, deleteErr
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
@@ -386,6 +403,9 @@ func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridge
|
||||
if len(claimed) == limit {
|
||||
break
|
||||
}
|
||||
if command.RunJobID != "" {
|
||||
continue
|
||||
}
|
||||
if command.ApprovalState != domain.GameClientBridgeApprovalNotRequired && command.ApprovalState != domain.GameClientBridgeApprovalApproved {
|
||||
continue
|
||||
}
|
||||
@@ -474,6 +494,9 @@ func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBrid
|
||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if command.RunJobID != "" {
|
||||
svc.protectedRequests.Delete(command.RunJobID)
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
@@ -524,6 +547,9 @@ func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string
|
||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if command.RunJobID != "" {
|
||||
svc.protectedRequests.Delete(command.RunJobID)
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -115,6 +116,79 @@ func TestProtectedGameClientBridgeRequestIsScopedAndRedacted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedGameClientBridgeRequestDispatchesOneTimeRunInput(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
plugin, err := svc.store.GamePlugins().Get("game.scum")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin.RequiredRunCapabilities = []string{domain.JobCapabilityRemoteRunProtectedSQL}
|
||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}}
|
||||
plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "database.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 120, MaxPayloadBytes: 4096, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "sql", TransportKey: "scum-database", TargetKey: "scum-database", TextField: "requestText", MaxTextBytes: 1024}})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.Users().Create(domain.User{ID: "platform-admin", Email: "admin@example.test", Roles: []string{"platform-admin"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: "run-local", Name: "Protected Bridge", State: domain.ServerInstanceStateRunning}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hello := validRunControlHello()
|
||||
hello.CapabilityReport.Capabilities = []string{domain.JobCapabilityRemoteRunProtectedSQL}
|
||||
hello.CapabilityReport.Fingerprint = "protected-request-capabilities"
|
||||
run, err := svc.RegisterRunHello(hello)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
|
||||
text := "SELECT player_id, position FROM players WHERE player_id = 7"
|
||||
command, err := svc.queueGameClientBridgeCommand("platform-admin", domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: plugin.ID, ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text}, IdempotencyKey: "protected-run-1", ExpiresAt: clock.Add(time.Minute)})
|
||||
if err != nil {
|
||||
t.Fatalf("queue protected request: %v", err)
|
||||
}
|
||||
if command.RunJobID == "" || command.Payload["requestText"] != "redacted" || command.ApprovalState != domain.GameClientBridgeApprovalApproved {
|
||||
t.Fatalf("protected command was not redacted and dispatched: %#v", command)
|
||||
}
|
||||
commandJSON, _ := json.Marshal(command)
|
||||
if strings.Contains(string(commandJSON), text) {
|
||||
t.Fatalf("protected bridge command persisted request text: %s", commandJSON)
|
||||
}
|
||||
if claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 10); err != nil || len(claimed) != 0 {
|
||||
t.Fatalf("protected request must not be exposed to the Companion: commands=%#v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: run.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != command.RunJobID || claim.Job.FencingToken == 0 {
|
||||
t.Fatalf("claim protected Run job: claim=%#v err=%v", claim, err)
|
||||
}
|
||||
assignmentJSON, _ := json.Marshal(claim.Job)
|
||||
if strings.Contains(string(assignmentJSON), text) {
|
||||
t.Fatalf("Run assignment exposed protected request text: %s", assignmentJSON)
|
||||
}
|
||||
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
|
||||
if err != nil || !ack.Accepted {
|
||||
t.Fatalf("ack protected Run job: ack=%#v err=%v", ack, err)
|
||||
}
|
||||
if _, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken + 1}); err == nil {
|
||||
t.Fatal("expected fencing mismatch rejection")
|
||||
}
|
||||
input, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken})
|
||||
if err != nil || input.RequestText != text || input.Kind != "sql" || input.TransportKey != "scum-database" || !input.Authorized {
|
||||
t.Fatalf("read protected Run input: input=%#v err=%v", input, err)
|
||||
}
|
||||
if _, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken}); err == nil {
|
||||
t.Fatal("expected one-time protected input rejection")
|
||||
}
|
||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100, Message: "unknown request"}, ErrorCode: "protected_request_unknown", ExecutionResult: domain.JobExecutionResult{Kind: "protected.sql.unknown", AuditSummary: "protected request outcome is unknown"}}); err != nil {
|
||||
t.Fatalf("complete protected Run job: %v", err)
|
||||
}
|
||||
completed, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || completed.State != domain.GameClientBridgeCommandUnknown || completed.Result.Status != domain.GameClientBridgeResultUnknown || strings.Contains(completed.Result.Summary, text) {
|
||||
t.Fatalf("project protected Run result: command=%#v err=%v", completed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "scope-key")
|
||||
|
||||
@@ -263,6 +263,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err := svc.updateScheduledJob(job); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectProtectedRequestJobResult(job, result, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
@@ -621,6 +624,10 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t
|
||||
}
|
||||
|
||||
func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignment {
|
||||
fencingToken := uint64(0)
|
||||
if isProtectedRequestCapability(job.Capability) {
|
||||
fencingToken = uint64(job.Attempt)
|
||||
}
|
||||
return domain.RunJobAssignment{
|
||||
JobID: job.ID,
|
||||
ServerInstanceID: job.ServerInstanceID,
|
||||
@@ -635,6 +642,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment)},
|
||||
LeaseToken: leaseToken,
|
||||
Attempt: job.Attempt,
|
||||
FencingToken: fencingToken,
|
||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||
AckDeadlineAt: job.AckDeadlineAt,
|
||||
LeaseExpiresAt: job.LeaseExpiresAt,
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const protectedRequestMaxTimeoutSeconds = 120
|
||||
|
||||
type protectedRequestPayload struct {
|
||||
commandID string
|
||||
kind string
|
||||
transportKey string
|
||||
targetKey string
|
||||
requestText string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// protectedRequestBroker keeps opaque request text out of durable jobs and
|
||||
// bridge records. It releases a payload exactly once to a current Run lease.
|
||||
type protectedRequestBroker struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
payloads map[string]protectedRequestPayload
|
||||
}
|
||||
|
||||
func newProtectedRequestBroker(now func() time.Time) *protectedRequestBroker {
|
||||
return &protectedRequestBroker{now: now, payloads: map[string]protectedRequestPayload{}}
|
||||
}
|
||||
|
||||
func (broker *protectedRequestBroker) Put(jobID string, payload protectedRequestPayload) error {
|
||||
broker.mu.Lock()
|
||||
defer broker.mu.Unlock()
|
||||
broker.pruneLocked()
|
||||
if _, exists := broker.payloads[jobID]; exists {
|
||||
return validationError("protected request idempotency key is already pending")
|
||||
}
|
||||
broker.payloads[jobID] = payload
|
||||
return nil
|
||||
}
|
||||
|
||||
func (broker *protectedRequestBroker) Consume(jobID string) (protectedRequestPayload, error) {
|
||||
broker.mu.Lock()
|
||||
defer broker.mu.Unlock()
|
||||
broker.pruneLocked()
|
||||
payload, exists := broker.payloads[jobID]
|
||||
if !exists {
|
||||
return protectedRequestPayload{}, validationError("protected request input is unavailable")
|
||||
}
|
||||
delete(broker.payloads, jobID)
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (broker *protectedRequestBroker) Delete(jobID string) {
|
||||
broker.mu.Lock()
|
||||
defer broker.mu.Unlock()
|
||||
delete(broker.payloads, jobID)
|
||||
}
|
||||
|
||||
func (broker *protectedRequestBroker) pruneLocked() {
|
||||
stamp := broker.now()
|
||||
for jobID, payload := range broker.payloads {
|
||||
if !stamp.Before(payload.expiresAt) {
|
||||
delete(broker.payloads, jobID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func protectedRequestCapability(kind string) (string, string, error) {
|
||||
switch kind {
|
||||
case "sql":
|
||||
return domain.JobCapabilityRemoteRunProtectedSQL, "protected-sql", nil
|
||||
case "rcon":
|
||||
return domain.JobCapabilityRemoteRunProtectedRCON, "protected-rcon", nil
|
||||
case "program":
|
||||
return domain.JobCapabilityRemoteRunProgram, "protected-program", nil
|
||||
default:
|
||||
return "", "", validationError("protected request kind is unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
func redactedProtectedRequestPayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration) map[string]any {
|
||||
return map[string]any{declaration.TextField: "redacted"}
|
||||
}
|
||||
|
||||
func (svc *CoreService) dispatchProtectedRequest(command domain.GameClientBridgeCommand, declaration domain.GameClientBridgeCommandDeclaration, payload map[string]any) error {
|
||||
if declaration.ProtectedRequest == nil || declaration.TimeoutSeconds < 1 || declaration.TimeoutSeconds > protectedRequestMaxTimeoutSeconds {
|
||||
return validationError("protected request timeout is out of bounds")
|
||||
}
|
||||
requestText, _ := payload[declaration.ProtectedRequest.TextField].(string)
|
||||
capability, adapterKind, err := protectedRequestCapability(declaration.ProtectedRequest.Kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobID := command.RunJobID
|
||||
if jobID == "" {
|
||||
return validationError("protected request job binding is missing")
|
||||
}
|
||||
if err := svc.protectedRequests.Put(jobID, protectedRequestPayload{commandID: command.ID, kind: declaration.ProtectedRequest.Kind, transportKey: declaration.ProtectedRequest.TransportKey, targetKey: declaration.ProtectedRequest.TargetKey, requestText: requestText, expiresAt: command.ExpiresAt}); err != nil {
|
||||
return err
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: jobID,
|
||||
ServerInstanceID: command.ServerInstanceID,
|
||||
RunEndpointID: mustProtectedRequestRunEndpoint(svc, command.ServerInstanceID),
|
||||
Capability: capability,
|
||||
TargetKey: declaration.ProtectedRequest.TargetKey,
|
||||
InputRef: "input://protected-request/" + command.ID,
|
||||
IdempotencyKey: "protected-request:" + command.ID,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "protected request queued"},
|
||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1},
|
||||
ExecutionInput: domain.JobExecutionInput{
|
||||
WorkspaceScope: command.ProfileKey,
|
||||
RemoteAdapterKey: declaration.ProtectedRequest.TransportKey,
|
||||
RemoteAdapterKind: adapterKind,
|
||||
TimeoutSeconds: declaration.TimeoutSeconds,
|
||||
PluginID: command.PluginID,
|
||||
},
|
||||
}
|
||||
if job.RunEndpointID == "" {
|
||||
svc.protectedRequests.Delete(jobID)
|
||||
return validationError("protected request server binding is unavailable")
|
||||
}
|
||||
created, err := svc.CreateJob(job)
|
||||
if err != nil {
|
||||
svc.protectedRequests.Delete(jobID)
|
||||
return err
|
||||
}
|
||||
if created.ID != jobID || created.ServerInstanceID != job.ServerInstanceID || created.Capability != capability || created.TargetKey != job.TargetKey || created.ExecutionInput.RemoteAdapterKey != job.ExecutionInput.RemoteAdapterKey || created.ExecutionInput.RemoteAdapterKind != adapterKind {
|
||||
svc.protectedRequests.Delete(jobID)
|
||||
return validationError("protected request idempotency key is already bound")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustProtectedRequestRunEndpoint(svc *CoreService, serverInstanceID string) string {
|
||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return instance.RunEndpointID
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetProtectedRequestExecutionInput(request domain.ProtectedRequestExecutionInputRequest) (domain.ProtectedRequestExecutionInput, error) {
|
||||
if err := validator.ValidateProtectedRequestExecutionInputRequest(request); err != nil {
|
||||
return domain.ProtectedRequestExecutionInput{}, err
|
||||
}
|
||||
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
||||
if err != nil {
|
||||
return domain.ProtectedRequestExecutionInput{}, err
|
||||
}
|
||||
if request.FencingToken != uint64(job.Attempt) || !isProtectedRequestCapability(job.Capability) || job.RetryPolicy.MaxAttempts != 1 || !strings.HasPrefix(job.InputRef, "input://protected-request/") {
|
||||
return domain.ProtectedRequestExecutionInput{}, validationError("job is not a fenced protected request")
|
||||
}
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return domain.ProtectedRequestExecutionInput{}, err
|
||||
}
|
||||
var command domain.GameClientBridgeCommand
|
||||
for _, candidate := range commands {
|
||||
if candidate.RunJobID == job.ID {
|
||||
command = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if command.ID == "" || command.ApprovalState != domain.GameClientBridgeApprovalApproved || command.State != domain.GameClientBridgeCommandPending || !command.ExpiresAt.After(svc.now()) {
|
||||
return domain.ProtectedRequestExecutionInput{}, validationError("protected request is not currently authorized")
|
||||
}
|
||||
payload, err := svc.protectedRequests.Consume(job.ID)
|
||||
if err != nil {
|
||||
return domain.ProtectedRequestExecutionInput{}, err
|
||||
}
|
||||
capability, adapterKind, capabilityErr := protectedRequestCapability(payload.kind)
|
||||
if capabilityErr != nil || capability != job.Capability || payload.targetKey != job.TargetKey || payload.transportKey != job.ExecutionInput.RemoteAdapterKey || adapterKind != job.ExecutionInput.RemoteAdapterKind {
|
||||
return domain.ProtectedRequestExecutionInput{}, validationError("protected request logical binding is invalid")
|
||||
}
|
||||
return domain.CopyProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, FencingToken: request.FencingToken, Authorized: true, ApprovalState: "approved", QueueState: "claimed", ExpiresAt: payload.expiresAt, Kind: payload.kind, TransportKey: payload.transportKey, TargetKey: payload.targetKey, RequestText: payload.requestText}), nil
|
||||
}
|
||||
|
||||
func isProtectedRequestCapability(capability string) bool {
|
||||
_, _, err := protectedRequestCapabilityForCapability(capability)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func protectedRequestCapabilityForCapability(capability string) (string, string, error) {
|
||||
switch capability {
|
||||
case domain.JobCapabilityRemoteRunProtectedSQL:
|
||||
return "sql", "protected-sql", nil
|
||||
case domain.JobCapabilityRemoteRunProtectedRCON:
|
||||
return "rcon", "protected-rcon", nil
|
||||
case domain.JobCapabilityRemoteRunProgram:
|
||||
return "program", "protected-program", nil
|
||||
default:
|
||||
return "", "", validationError("job is not a protected request")
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectProtectedRequestJobResult(job domain.Job, result domain.RunJobResult, stamp time.Time) error {
|
||||
if !isProtectedRequestCapability(job.Capability) {
|
||||
return nil
|
||||
}
|
||||
svc.protectedRequests.Delete(job.ID)
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, command := range commands {
|
||||
if command.RunJobID != job.ID || isTerminalGameClientBridgeCommandState(command.State) {
|
||||
continue
|
||||
}
|
||||
switch result.State {
|
||||
case domain.JobStateSucceeded:
|
||||
command.State = domain.GameClientBridgeCommandSucceeded
|
||||
command.Result.Status = domain.GameClientBridgeResultSucceeded
|
||||
case domain.JobStateCancelled:
|
||||
command.State = domain.GameClientBridgeCommandCancelled
|
||||
command.Result.Status = domain.GameClientBridgeResultCancelled
|
||||
case domain.JobStateFailed:
|
||||
command.State = domain.GameClientBridgeCommandFailed
|
||||
command.Result.Status = domain.GameClientBridgeResultFailed
|
||||
if result.ErrorCode == "protected_request_unknown" || strings.HasSuffix(result.ExecutionResult.Kind, ".unknown") {
|
||||
command.State = domain.GameClientBridgeCommandUnknown
|
||||
command.Result.Status = domain.GameClientBridgeResultUnknown
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
command.Result.Summary = "protected request completed by Run"
|
||||
command.Result.CompletedBy = "run"
|
||||
command.Result.CompletedAt = stamp
|
||||
command.CompletedAt = stamp
|
||||
command.UpdatedAt = stamp
|
||||
auditID, auditErr := svc.recordAuditEventWithID("run", "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "Run recorded protected bridge command result")
|
||||
if auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
||||
return svc.store.GameClientBridgeCommands().Update(command)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -140,6 +140,7 @@ type Core interface {
|
||||
GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error)
|
||||
DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error)
|
||||
GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error)
|
||||
GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest) (domain.ProtectedRequestExecutionInput, error)
|
||||
GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error)
|
||||
ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
|
||||
ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error)
|
||||
@@ -244,6 +245,7 @@ type CoreService struct {
|
||||
auditSeq uint64
|
||||
productionMu sync.Mutex
|
||||
sourceRCONCommands *sourceRCONCommandBroker
|
||||
protectedRequests *protectedRequestBroker
|
||||
aiProviderClient AIProviderClient
|
||||
secretEnvelope SecretEnvelope
|
||||
networkFingerprintKey []byte
|
||||
@@ -278,6 +280,7 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
|
||||
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
||||
artifactPayloads: map[string][]byte{},
|
||||
sourceRCONCommands: newSourceRCONCommandBroker(now),
|
||||
protectedRequests: newProtectedRequestBroker(now),
|
||||
aiProviderClient: MockAIProviderClient{},
|
||||
secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey),
|
||||
networkFingerprintKey: []byte(developmentSecretEnvelopeKey),
|
||||
|
||||
@@ -68,6 +68,14 @@ func ValidateSourceRCONExecutionInputRequest(request domain.SourceRCONExecutionI
|
||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||
}
|
||||
|
||||
func ValidateProtectedRequestExecutionInputRequest(request domain.ProtectedRequestExecutionInputRequest) error {
|
||||
violations := appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
||||
if request.FencingToken == 0 {
|
||||
violations = append(violations, "fencingToken is required")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunUpdateInputRequest(request domain.RunUpdateInputRequest) error {
|
||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user