753 lines
32 KiB
Go
753 lines
32 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
const defaultGameClientBridgeLeaseDuration = 60 * time.Second
|
|
|
|
const defaultGameClientBridgeCommandRetention = 30 * 24 * time.Hour
|
|
|
|
type gameClientBridgeComponentSession struct {
|
|
Session domain.ClientManagerSession
|
|
Installation domain.ClientManagerInstallation
|
|
}
|
|
|
|
func (svc *CoreService) QueueGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
|
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
|
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
if !canAccessServer(user, instance) {
|
|
return domain.GameClientBridgeCommand{}, ErrForbidden
|
|
}
|
|
if instance.PluginID != request.PluginID {
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command plugin must match server instance")
|
|
}
|
|
return svc.queueGameClientBridgeCommand(user.ID, request)
|
|
}
|
|
|
|
func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverInstanceID string) (domain.GameClientBridgeStatus, error) {
|
|
if err := svc.authorizeServerLifecycle(sessionID, serverInstanceID); err != nil {
|
|
return domain.GameClientBridgeStatus{}, err
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeStatus{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeStatus{}, err
|
|
}
|
|
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}, Features: []domain.GameClientBridgeFeatureAvailability{}}
|
|
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
|
|
if err != nil {
|
|
return domain.GameClientBridgeStatus{}, err
|
|
}
|
|
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
|
if !containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
|
continue
|
|
}
|
|
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: profile.Key, Reason: "compatible companion session is offline", CommandTypes: gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands), SnapshotTypes: gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots), QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates)}
|
|
for _, installation := range installations {
|
|
if installation.ProfileKey != profile.Key || (installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded) || installation.RequiresRedeploy {
|
|
continue
|
|
}
|
|
sessions, listErr := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
|
|
if listErr != nil {
|
|
return domain.GameClientBridgeStatus{}, listErr
|
|
}
|
|
for _, session := range sessions {
|
|
if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID {
|
|
declaration.Available = true
|
|
declaration.Reason = ""
|
|
declaration.HandlerTypes = append(declaration.HandlerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "handler.")...)
|
|
declaration.EventProducerTypes = append(declaration.EventProducerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "event-producer.")...)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
status.Profiles = append(status.Profiles, declaration)
|
|
status.Available = status.Available || declaration.Available
|
|
}
|
|
if len(status.Profiles) > 0 {
|
|
status.Reason = "no compatible companion session is online"
|
|
}
|
|
if status.Available {
|
|
status.Reason = ""
|
|
}
|
|
status.Features = gameClientBridgeFeatureAvailability(plugin.GameClientBridge.Features, status.Profiles)
|
|
return domain.CopyGameClientBridgeStatus(status), nil
|
|
}
|
|
|
|
func gameClientBridgeSessionCapabilityValues(capabilities []string, prefix string) []string {
|
|
values := make([]string, 0, len(capabilities))
|
|
for _, capability := range capabilities {
|
|
if value, found := strings.CutPrefix(capability, prefix); found && value != "" {
|
|
values = append(values, value)
|
|
}
|
|
}
|
|
sort.Strings(values)
|
|
return values
|
|
}
|
|
|
|
func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatureDeclaration, profiles []domain.GameClientBridgeProfileDeclaration) []domain.GameClientBridgeFeatureAvailability {
|
|
result := make([]domain.GameClientBridgeFeatureAvailability, 0, len(features))
|
|
for _, feature := range features {
|
|
handlers, producers := map[string]bool{}, map[string]bool{}
|
|
for _, profile := range profiles {
|
|
if !profile.Available {
|
|
continue
|
|
}
|
|
for _, handler := range profile.HandlerTypes {
|
|
handlers[handler] = true
|
|
}
|
|
for _, producer := range profile.EventProducerTypes {
|
|
producers[producer] = true
|
|
}
|
|
}
|
|
missing := make([]string, 0)
|
|
for _, handler := range feature.RequiredHandlers {
|
|
if !handlers[handler] {
|
|
missing = append(missing, "handler."+handler)
|
|
}
|
|
}
|
|
for _, producer := range feature.RequiredEventProducers {
|
|
if !producers[producer] {
|
|
missing = append(missing, "event-producer."+producer)
|
|
}
|
|
}
|
|
availability := domain.GameClientBridgeFeatureAvailability{Key: feature.Key, Available: len(missing) == 0}
|
|
if len(missing) > 0 {
|
|
availability.Reason = "compatible Companion is missing " + strings.Join(missing, ", ")
|
|
}
|
|
result = append(result, availability)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) {
|
|
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
|
return nil, err
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(filter.ServerInstanceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
filter.PluginID = instance.PluginID
|
|
commands, err := svc.store.GameClientBridgeCommands().List(filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]domain.GameClientBridgeCommand, len(commands))
|
|
for index, command := range commands {
|
|
result[index] = domain.CopyGameClientBridgeCommand(command)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (svc *CoreService) GetGameClientBridgeCommandForSession(sessionID, commandID string) (domain.GameClientBridgeCommand, error) {
|
|
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
return domain.CopyGameClientBridgeCommand(command), nil
|
|
}
|
|
|
|
func (svc *CoreService) QueryGameClientBridgeSnapshotsForSession(sessionID string, query domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error) {
|
|
if err := validator.ValidateGameClientBridgeSnapshotQuery(query); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := svc.authorizeServerLifecycle(sessionID, query.ServerInstanceID); err != nil {
|
|
return nil, err
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(query.ServerInstanceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if query.PluginID != instance.PluginID {
|
|
return nil, ErrForbidden
|
|
}
|
|
limit := query.Limit
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: query.ServerInstanceID, PluginID: query.PluginID, ProfileKey: query.ProfileKey, Type: query.Type, StreamKey: query.StreamKey, ObservedAfter: query.ObservedAfter, Limit: limit})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]domain.GameClientBridgeSnapshot, len(snapshots))
|
|
for index, snapshot := range snapshots {
|
|
result[index] = domain.CopyGameClientBridgeSnapshot(snapshot)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func gameClientBridgeCommandDeclaration(plugin domain.GamePlugin, profileKey, commandType string) (domain.GameClientBridgeCommandDeclaration, bool) {
|
|
profileDeclared := false
|
|
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
|
if profile.Key == profileKey && containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
|
profileDeclared = true
|
|
break
|
|
}
|
|
}
|
|
if !profileDeclared {
|
|
return domain.GameClientBridgeCommandDeclaration{}, false
|
|
}
|
|
for _, declaration := range plugin.GameClientBridge.Commands {
|
|
if declaration.Type == commandType {
|
|
return declaration, true
|
|
}
|
|
}
|
|
return domain.GameClientBridgeCommandDeclaration{}, false
|
|
}
|
|
|
|
func gameClientBridgeCommandTypes(declarations []domain.GameClientBridgeCommandDeclaration) []string {
|
|
values := make([]string, len(declarations))
|
|
for index, declaration := range declarations {
|
|
values[index] = declaration.Type
|
|
}
|
|
return values
|
|
}
|
|
|
|
func gameClientBridgeSnapshotTypes(declarations []domain.GameClientBridgeSnapshotDeclaration) []string {
|
|
seen := map[string]struct{}{}
|
|
values := make([]string, 0, len(declarations))
|
|
for _, declaration := range declarations {
|
|
if _, exists := seen[declaration.Type]; exists {
|
|
continue
|
|
}
|
|
seen[declaration.Type] = struct{}{}
|
|
values = append(values, declaration.Type)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQueryTemplateDeclaration) []string {
|
|
values := make([]string, len(declarations))
|
|
for index, declaration := range declarations {
|
|
values[index] = declaration.Key
|
|
}
|
|
return values
|
|
}
|
|
|
|
func validateProtectedGameClientBridgePayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) error {
|
|
if declaration == nil {
|
|
return nil
|
|
}
|
|
if len(payload) != 1 {
|
|
return validationError("protected bridge request must contain only its declared text field")
|
|
}
|
|
value, exists := payload[declaration.TextField]
|
|
if !exists {
|
|
return validationError("protected bridge request text field is required")
|
|
}
|
|
text, ok := value.(string)
|
|
if !ok || len([]byte(text)) == 0 || len([]byte(text)) > declaration.MaxTextBytes {
|
|
return validationError("protected bridge request text is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func protectedGameClientBridgeAuditSummary(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) string {
|
|
text, _ := payload[declaration.TextField].(string)
|
|
digest := sha256.Sum256([]byte(text))
|
|
return fmt.Sprintf("queued protected %s request transport=%s target=%s text=redacted sha256=%x", declaration.Kind, declaration.TransportKey, declaration.TargetKey, digest[:8])
|
|
}
|
|
|
|
func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
|
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
|
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
stamp := svc.now()
|
|
svc.bridgeMu.Lock()
|
|
defer svc.bridgeMu.Unlock()
|
|
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
declaration, declared := gameClientBridgeCommandDeclaration(plugin, request.ProfileKey, request.CommandType)
|
|
if !declared {
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command type is not declared for the profile")
|
|
}
|
|
payload, err := json.Marshal(request.Payload)
|
|
if err != nil || len(payload) > declaration.MaxPayloadBytes {
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command payload exceeds declaration")
|
|
}
|
|
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
|
|
}
|
|
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 {
|
|
return domain.CopyGameClientBridgeCommand(existing), nil
|
|
}
|
|
if err != nil && err != repo.ErrNotFound {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
if !request.ExpiresAt.After(stamp) {
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command expiresAt must be in the future")
|
|
}
|
|
approvalState := domain.GameClientBridgeApprovalNotRequired
|
|
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelOperator {
|
|
approvalState = domain.GameClientBridgeApprovalApproved
|
|
}
|
|
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
|
approvalState = domain.GameClientBridgeApprovalPending
|
|
if requester, requesterErr := svc.store.Users().Get(requesterID); requesterErr == nil && isPlatformAdmin(requester) {
|
|
approvalState = domain.GameClientBridgeApprovalApproved
|
|
}
|
|
}
|
|
svc.bridgeSeq++
|
|
command := domain.GameClientBridgeCommand{
|
|
ID: fmt.Sprintf("bridge-command-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
|
|
ServerInstanceID: request.ServerInstanceID,
|
|
PluginID: request.PluginID,
|
|
ProfileKey: request.ProfileKey,
|
|
CommandType: request.CommandType,
|
|
Payload: domain.CopyGameClientBridgePayload(request.Payload),
|
|
IdempotencyKey: request.IdempotencyKey,
|
|
Priority: request.Priority,
|
|
State: domain.GameClientBridgeCommandPending,
|
|
ApprovalState: approvalState,
|
|
RequesterID: requesterID,
|
|
ExpiresAt: request.ExpiresAt,
|
|
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)
|
|
}
|
|
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, summary)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
command.AuditReferences = []string{auditID}
|
|
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
|
|
}
|
|
|
|
func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridgeComponentSession, limit int) ([]domain.GameClientBridgeCommand, error) {
|
|
if limit == 0 {
|
|
limit = 10
|
|
}
|
|
if limit < 1 || limit > 50 {
|
|
return nil, validationError("bridge claim limit must be between 1 and 50")
|
|
}
|
|
stamp := svc.now()
|
|
svc.bridgeMu.Lock()
|
|
defer svc.bridgeMu.Unlock()
|
|
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
|
|
return nil, err
|
|
}
|
|
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, State: domain.GameClientBridgeCommandPending})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.SliceStable(commands, func(left, right int) bool {
|
|
if commands[left].Priority != commands[right].Priority {
|
|
return commands[left].Priority > commands[right].Priority
|
|
}
|
|
if !commands[left].CreatedAt.Equal(commands[right].CreatedAt) {
|
|
return commands[left].CreatedAt.Before(commands[right].CreatedAt)
|
|
}
|
|
return commands[left].ID < commands[right].ID
|
|
})
|
|
claimed := make([]domain.GameClientBridgeCommand, 0, limit)
|
|
for _, command := range commands {
|
|
if len(claimed) == limit {
|
|
break
|
|
}
|
|
if command.RunJobID != "" {
|
|
continue
|
|
}
|
|
if command.ApprovalState != domain.GameClientBridgeApprovalNotRequired && command.ApprovalState != domain.GameClientBridgeApprovalApproved {
|
|
continue
|
|
}
|
|
fencingToken := command.Claim.FencingToken + 1
|
|
command.State = domain.GameClientBridgeCommandClaimed
|
|
command.Claim = domain.GameClientBridgeClaim{SessionID: component.Session.ID, InstallationID: component.Installation.ID, DeploymentGeneration: component.Session.DeploymentGeneration, FencingToken: fencingToken, LeaseExpiresAt: gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt), ClaimedAt: stamp}
|
|
command.UpdatedAt = stamp
|
|
auditID, auditErr := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.claim", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion claimed bridge command")
|
|
if auditErr != nil {
|
|
return nil, auditErr
|
|
}
|
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
|
return nil, err
|
|
}
|
|
claimed = append(claimed, domain.CopyGameClientBridgeCommand(command))
|
|
}
|
|
return claimed, nil
|
|
}
|
|
|
|
func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
|
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
stamp := svc.now()
|
|
svc.bridgeMu.Lock()
|
|
defer svc.bridgeMu.Unlock()
|
|
command, err := svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
command.Claim.AcknowledgedAt = stamp
|
|
command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt)
|
|
command.UpdatedAt = stamp
|
|
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.ack", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion acknowledged bridge command")
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
return domain.CopyGameClientBridgeCommand(command), nil
|
|
}
|
|
|
|
func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
|
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
|
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
stamp := svc.now()
|
|
svc.bridgeMu.Lock()
|
|
defer svc.bridgeMu.Unlock()
|
|
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
if isTerminalGameClientBridgeCommandState(command.State) {
|
|
if command.Result.CompletedBy == component.Session.ID && gameClientBridgeClaimMatches(command, component, request.FencingToken) && command.Result.Status == request.Status && command.Result.Summary == request.Summary && reflect.DeepEqual(command.Result.Payload, request.Payload) {
|
|
return domain.CopyGameClientBridgeCommand(command), nil
|
|
}
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command already has a terminal result")
|
|
}
|
|
command, err = svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
switch request.Status {
|
|
case domain.GameClientBridgeResultSucceeded:
|
|
command.State = domain.GameClientBridgeCommandSucceeded
|
|
case domain.GameClientBridgeResultFailed:
|
|
command.State = domain.GameClientBridgeCommandFailed
|
|
case domain.GameClientBridgeResultUnknown:
|
|
command.State = domain.GameClientBridgeCommandUnknown
|
|
case domain.GameClientBridgeResultCancelled:
|
|
command.State = domain.GameClientBridgeCommandCancelled
|
|
}
|
|
command.Result = domain.GameClientBridgeResult{Status: request.Status, Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload), CompletedBy: component.Session.ID, CompletedAt: stamp}
|
|
command.CompletedAt = stamp
|
|
command.UpdatedAt = stamp
|
|
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion recorded terminal bridge command result")
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
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
|
|
}
|
|
|
|
func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error) {
|
|
if err := validator.ValidateGameClientBridgeCancelRequest(request); err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
stamp := svc.now()
|
|
svc.bridgeMu.Lock()
|
|
defer svc.bridgeMu.Unlock()
|
|
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
if !canAccessServer(user, instance) {
|
|
return domain.GameClientBridgeCommand{}, ErrForbidden
|
|
}
|
|
if !isTerminalGameClientBridgeCommandState(command.State) && !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
|
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
|
|
}
|
|
if isTerminalGameClientBridgeCommandState(command.State) {
|
|
if command.State == domain.GameClientBridgeCommandCancelled {
|
|
return domain.CopyGameClientBridgeCommand(command), nil
|
|
}
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command is already terminal")
|
|
}
|
|
command.State = domain.GameClientBridgeCommandCancelled
|
|
command.Cancellation = domain.GameClientBridgeCancellation{RequestedBy: user.ID, Reason: request.Reason, CancelledAt: stamp}
|
|
command.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled by operator", CompletedAt: stamp}
|
|
command.CompletedAt = stamp
|
|
command.UpdatedAt = stamp
|
|
auditID, err := svc.recordAuditEventWithID(user.ID, "game-client-bridge.command.cancel", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "operator cancelled bridge command")
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
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
|
|
}
|
|
|
|
func (svc *CoreService) ReconcileGameClientBridgeCommands() error {
|
|
stamp := svc.now()
|
|
svc.bridgeMu.Lock()
|
|
defer svc.bridgeMu.Unlock()
|
|
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.pruneGameClientBridgeCommandsLocked(stamp); err != nil {
|
|
return err
|
|
}
|
|
return svc.pruneGameClientBridgeSnapshotsLocked(stamp)
|
|
}
|
|
|
|
func (svc *CoreService) pruneGameClientBridgeCommandsLocked(stamp time.Time) error {
|
|
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
groups := map[string][]domain.GameClientBridgeCommand{}
|
|
for _, command := range commands {
|
|
if !isTerminalGameClientBridgeCommandState(command.State) || command.CompletedAt.IsZero() {
|
|
continue
|
|
}
|
|
retention := defaultGameClientBridgeCommandRetention
|
|
maxRecords := 0
|
|
if plugin, pluginErr := svc.store.GamePlugins().Get(command.PluginID); pluginErr == nil {
|
|
if plugin.GameClientBridge.Retention.KeepForSeconds > 0 {
|
|
retention = time.Duration(plugin.GameClientBridge.Retention.KeepForSeconds) * time.Second
|
|
}
|
|
maxRecords = plugin.GameClientBridge.Retention.MaxRecords
|
|
}
|
|
if !command.CompletedAt.After(stamp.Add(-retention)) {
|
|
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if maxRecords > 0 {
|
|
key := command.ServerInstanceID + "\x00" + command.PluginID
|
|
groups[key] = append(groups[key], command)
|
|
}
|
|
}
|
|
for _, group := range groups {
|
|
if len(group) == 0 {
|
|
continue
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(group[0].PluginID)
|
|
if err != nil || plugin.GameClientBridge.Retention.MaxRecords <= 0 || len(group) <= plugin.GameClientBridge.Retention.MaxRecords {
|
|
continue
|
|
}
|
|
sort.SliceStable(group, func(left, right int) bool {
|
|
if !group[left].CompletedAt.Equal(group[right].CompletedAt) {
|
|
return group[left].CompletedAt.After(group[right].CompletedAt)
|
|
}
|
|
return group[left].ID < group[right].ID
|
|
})
|
|
for _, command := range group[plugin.GameClientBridge.Retention.MaxRecords:] {
|
|
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) pruneGameClientBridgeSnapshotsLocked(stamp time.Time) error {
|
|
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
groups := map[string][]domain.GameClientBridgeSnapshot{}
|
|
for _, snapshot := range snapshots {
|
|
if !snapshot.ExpiresAt.IsZero() && !snapshot.ExpiresAt.After(stamp) {
|
|
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
key := snapshot.ServerInstanceID + "\x00" + snapshot.PluginID + "\x00" + snapshot.ProfileKey + "\x00" + snapshot.Type + "\x00" + snapshot.StreamKey
|
|
groups[key] = append(groups[key], snapshot)
|
|
}
|
|
for _, group := range groups {
|
|
if len(group) == 0 {
|
|
continue
|
|
}
|
|
maxRecords := group[0].Retention.MaxRecords
|
|
if plugin, pluginErr := svc.store.GamePlugins().Get(group[0].PluginID); pluginErr == nil {
|
|
if declaration, ok := gameClientBridgeSnapshotDeclaration(plugin, group[0].Type, group[0].SchemaVersion); ok {
|
|
maxRecords = declaration.Retention.MaxRecords
|
|
}
|
|
}
|
|
if maxRecords <= 0 || len(group) <= maxRecords {
|
|
continue
|
|
}
|
|
sort.SliceStable(group, func(left, right int) bool {
|
|
if group[left].Sequence != group[right].Sequence {
|
|
return group[left].Sequence > group[right].Sequence
|
|
}
|
|
return group[left].ID < group[right].ID
|
|
})
|
|
for _, snapshot := range group[maxRecords:] {
|
|
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) error {
|
|
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, command := range commands {
|
|
if isTerminalGameClientBridgeCommandState(command.State) {
|
|
continue
|
|
}
|
|
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
|
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if command.State == domain.GameClientBridgeCommandClaimed && !command.Claim.LeaseExpiresAt.IsZero() && !command.Claim.LeaseExpiresAt.After(stamp) {
|
|
fencingToken := command.Claim.FencingToken
|
|
command.State = domain.GameClientBridgeCommandPending
|
|
command.Claim = domain.GameClientBridgeClaim{FencingToken: fencingToken}
|
|
command.UpdatedAt = stamp
|
|
auditID, auditErr := svc.recordAuditEventWithID("platform", "game-client-bridge.command.lease-expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "expired bridge claim returned to pending")
|
|
if auditErr != nil {
|
|
return auditErr
|
|
}
|
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) fencedGameClientBridgeCommand(component gameClientBridgeComponentSession, commandID string, fencingToken uint64, stamp time.Time) (domain.GameClientBridgeCommand, error) {
|
|
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
|
|
if err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
if command.State != domain.GameClientBridgeCommandClaimed || !gameClientBridgeClaimMatches(command, component, fencingToken) {
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command claim is stale")
|
|
}
|
|
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
|
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
|
return domain.GameClientBridgeCommand{}, err
|
|
}
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
|
|
}
|
|
if !command.Claim.LeaseExpiresAt.After(stamp) {
|
|
return domain.GameClientBridgeCommand{}, validationError("bridge command claim lease expired")
|
|
}
|
|
return command, nil
|
|
}
|
|
|
|
func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameClientBridgeCommand, stamp time.Time) error {
|
|
if isTerminalGameClientBridgeCommandState(command.State) {
|
|
return nil
|
|
}
|
|
command.State = domain.GameClientBridgeCommandExpired
|
|
command.CompletedAt = stamp
|
|
command.UpdatedAt = stamp
|
|
auditID, err := svc.recordAuditEventWithID("platform", "game-client-bridge.command.expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "bridge command expired before completion")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
return svc.store.GameClientBridgeCommands().Update(command)
|
|
}
|
|
|
|
func gameClientBridgeClaimLeaseExpiry(stamp, commandExpiry time.Time) time.Time {
|
|
leaseExpiry := stamp.Add(defaultGameClientBridgeLeaseDuration)
|
|
if !commandExpiry.IsZero() && commandExpiry.Before(leaseExpiry) {
|
|
return commandExpiry
|
|
}
|
|
return leaseExpiry
|
|
}
|
|
|
|
func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, component gameClientBridgeComponentSession, fencingToken uint64) bool {
|
|
return command.Claim.SessionID == component.Session.ID && command.Claim.InstallationID == component.Installation.ID && command.Claim.DeploymentGeneration == component.Session.DeploymentGeneration && command.Claim.FencingToken == fencingToken
|
|
}
|
|
|
|
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
|
|
switch state {
|
|
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|