Rebuild SCUM plugin-owned data flow
This commit is contained in:
@@ -61,24 +61,68 @@ type GameClientBridgeSnapshotDeclaration struct {
|
||||
}
|
||||
|
||||
type GameClientBridgeQueryTemplateDeclaration struct {
|
||||
Key string
|
||||
Title string
|
||||
Permission string
|
||||
Engine string
|
||||
TransportKey string
|
||||
TargetKey string
|
||||
ParameterSchemaRef string
|
||||
ResultSchemaRef string
|
||||
SQLRef string
|
||||
MaxRows int
|
||||
TimeoutSeconds int
|
||||
RowTarget *PluginDataRowTargetDeclaration
|
||||
Key string
|
||||
Title string
|
||||
Permission string
|
||||
Engine string
|
||||
TransportKey string
|
||||
TargetKey string
|
||||
ParameterSchemaRef string
|
||||
ResultSchemaRef string
|
||||
SQLRef string
|
||||
MaxRows int
|
||||
TimeoutSeconds int
|
||||
PollIntervalSeconds int
|
||||
RowTarget *PluginDataRowTargetDeclaration
|
||||
}
|
||||
|
||||
const (
|
||||
PluginDataRowWriteModeMerge = "merge"
|
||||
PluginDataRowWriteModeReplace = "replace"
|
||||
)
|
||||
|
||||
type PluginDataRowTargetDeclaration struct {
|
||||
Collection string
|
||||
UpsertKeys []string
|
||||
ColumnMappings map[string]string
|
||||
WriteMode string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionStepDeclaration struct {
|
||||
Pattern string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionTargetDeclaration struct {
|
||||
Collection string
|
||||
UpsertKeys []string
|
||||
CaptureMappings map[string]string
|
||||
FixedValues map[string]string
|
||||
ObservedAtField string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionAnnouncementDeclaration struct {
|
||||
ProfileKey string
|
||||
CommandType string
|
||||
TextField string
|
||||
NewTextTemplate string
|
||||
ReturningTextTemplate string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionPresenceDeclaration struct {
|
||||
TimestampField string
|
||||
ActiveWindowSeconds int
|
||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration
|
||||
Announcement GameClientBridgeLogProjectionAnnouncementDeclaration
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionDeclaration struct {
|
||||
Key string
|
||||
StreamKeys []string
|
||||
Steps []GameClientBridgeLogProjectionStepDeclaration
|
||||
CorrelationFields []string
|
||||
MaxInterveningLines int
|
||||
Target GameClientBridgeLogProjectionTargetDeclaration
|
||||
Presence *GameClientBridgeLogProjectionPresenceDeclaration
|
||||
}
|
||||
|
||||
type GameClientBridgeDataPackDeclaration struct {
|
||||
@@ -170,6 +214,7 @@ type GameClientBridgeManifest struct {
|
||||
Commands []GameClientBridgeCommandDeclaration
|
||||
Snapshots []GameClientBridgeSnapshotDeclaration
|
||||
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
|
||||
LogProjections []GameClientBridgeLogProjectionDeclaration
|
||||
DataPacks []GameClientBridgeDataPackDeclaration
|
||||
OperationTemplates []GameClientBridgeOperationTemplateDeclaration
|
||||
Retention GameClientBridgeRetention
|
||||
@@ -475,6 +520,10 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
|
||||
value.QueryTemplates[index].RowTarget = ©
|
||||
}
|
||||
}
|
||||
value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...)
|
||||
for index := range value.LogProjections {
|
||||
value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index])
|
||||
}
|
||||
value.DataPacks = append([]GameClientBridgeDataPackDeclaration(nil), value.DataPacks...)
|
||||
for index := range value.DataPacks {
|
||||
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
|
||||
@@ -503,6 +552,29 @@ func CopyPluginDataRowTargetDeclaration(value PluginDataRowTargetDeclaration) Pl
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration {
|
||||
value.StreamKeys = CopyStringSlice(value.StreamKeys)
|
||||
value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...)
|
||||
value.CorrelationFields = CopyStringSlice(value.CorrelationFields)
|
||||
value.Target = CopyGameClientBridgeLogProjectionTargetDeclaration(value.Target)
|
||||
if value.Presence != nil {
|
||||
presence := *value.Presence
|
||||
if presence.ActivityTarget != nil {
|
||||
activityTarget := CopyGameClientBridgeLogProjectionTargetDeclaration(*presence.ActivityTarget)
|
||||
presence.ActivityTarget = &activityTarget
|
||||
}
|
||||
value.Presence = &presence
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeLogProjectionTargetDeclaration(value GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclaration {
|
||||
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
|
||||
value.CaptureMappings = CopyStringMap(value.CaptureMappings)
|
||||
value.FixedValues = CopyStringMap(value.FixedValues)
|
||||
return value
|
||||
}
|
||||
|
||||
func copyGameClientBridgePayloadValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
|
||||
@@ -4,7 +4,12 @@ import "testing"
|
||||
|
||||
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
|
||||
manifest := GameClientBridgeManifest{
|
||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", RowTarget: &PluginDataRowTargetDeclaration{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}}}},
|
||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, RowTarget: &PluginDataRowTargetDeclaration{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}, WriteMode: PluginDataRowWriteModeMerge}}},
|
||||
LogProjections: []GameClientBridgeLogProjectionDeclaration{{
|
||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
|
||||
Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
||||
Presence: &GameClientBridgeLogProjectionPresenceDeclaration{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}},
|
||||
}},
|
||||
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}}},
|
||||
OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}},
|
||||
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}},
|
||||
@@ -12,11 +17,14 @@ func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T)
|
||||
manifestCopy := CopyGameClientBridgeManifest(manifest)
|
||||
manifestCopy.QueryTemplates[0].Key = "mutated"
|
||||
manifestCopy.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated"
|
||||
manifestCopy.LogProjections[0].StreamKeys[0] = "mutated"
|
||||
manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
||||
manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated"
|
||||
manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated"
|
||||
manifestCopy.OperationTemplates[0].Key = "mutated"
|
||||
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||
manifestCopy.Pages[0].OperationKeys[0] = "mutated"
|
||||
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" {
|
||||
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" {
|
||||
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
|
||||
}
|
||||
|
||||
|
||||
+152
-18
@@ -290,24 +290,63 @@ type GameClientBridgeSnapshotDeclarationBody struct {
|
||||
}
|
||||
|
||||
type GameClientBridgeQueryTemplateDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Permission string `json:"permission"`
|
||||
Engine string `json:"engine"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
||||
ResultSchemaRef string `json:"resultSchemaRef"`
|
||||
SQLRef string `json:"sqlRef,omitempty"`
|
||||
MaxRows int `json:"maxRows"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
RowTarget *PluginDataRowTargetDeclarationBody `json:"rowTarget,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Permission string `json:"permission"`
|
||||
Engine string `json:"engine"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
||||
ResultSchemaRef string `json:"resultSchemaRef"`
|
||||
SQLRef string `json:"sqlRef,omitempty"`
|
||||
MaxRows int `json:"maxRows"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
PollIntervalSeconds int `json:"pollIntervalSeconds"`
|
||||
RowTarget *PluginDataRowTargetDeclarationBody `json:"rowTarget,omitempty"`
|
||||
}
|
||||
|
||||
type PluginDataRowTargetDeclarationBody struct {
|
||||
Collection string `json:"collection"`
|
||||
UpsertKeys []string `json:"upsertKeys"`
|
||||
ColumnMappings map[string]string `json:"columnMappings"`
|
||||
WriteMode string `json:"writeMode"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionStepDeclarationBody struct {
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionTargetDeclarationBody struct {
|
||||
Collection string `json:"collection"`
|
||||
UpsertKeys []string `json:"upsertKeys"`
|
||||
CaptureMappings map[string]string `json:"captureMappings"`
|
||||
FixedValues map[string]string `json:"fixedValues,omitempty"`
|
||||
ObservedAtField string `json:"observedAtField,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionAnnouncementDeclarationBody struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
CommandType string `json:"commandType"`
|
||||
TextField string `json:"textField"`
|
||||
NewTextTemplate string `json:"newTextTemplate"`
|
||||
ReturningTextTemplate string `json:"returningTextTemplate"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionPresenceDeclarationBody struct {
|
||||
TimestampField string `json:"timestampField"`
|
||||
ActiveWindowSeconds int `json:"activeWindowSeconds"`
|
||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
|
||||
Announcement GameClientBridgeLogProjectionAnnouncementDeclarationBody `json:"announcement"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
StreamKeys []string `json:"streamKeys"`
|
||||
Steps []GameClientBridgeLogProjectionStepDeclarationBody `json:"steps"`
|
||||
CorrelationFields []string `json:"correlationFields"`
|
||||
MaxInterveningLines int `json:"maxInterveningLines"`
|
||||
Target GameClientBridgeLogProjectionTargetDeclarationBody `json:"target"`
|
||||
Presence *GameClientBridgeLogProjectionPresenceDeclarationBody `json:"presence,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeDataPackDeclarationBody struct {
|
||||
@@ -392,6 +431,7 @@ type GameClientBridgeManifestBody struct {
|
||||
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
|
||||
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
|
||||
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
|
||||
LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"`
|
||||
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
|
||||
OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"`
|
||||
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
|
||||
@@ -1193,10 +1233,14 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
for index, template := range body.QueryTemplates {
|
||||
var rowTarget *domain.PluginDataRowTargetDeclaration
|
||||
if template.RowTarget != nil {
|
||||
value := domain.PluginDataRowTargetDeclaration{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings)}
|
||||
value := domain.PluginDataRowTargetDeclaration{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings), WriteMode: template.RowTarget.WriteMode}
|
||||
rowTarget = &value
|
||||
}
|
||||
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, RowTarget: rowTarget}
|
||||
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, RowTarget: rowTarget}
|
||||
}
|
||||
logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections))
|
||||
for index, projection := range body.LogProjections {
|
||||
logProjections[index] = gameClientBridgeLogProjectionToDomain(projection)
|
||||
}
|
||||
dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks))
|
||||
for index, dataPack := range body.DataPacks {
|
||||
@@ -1218,7 +1262,50 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
if body.Companion != nil {
|
||||
companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds}
|
||||
}
|
||||
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, DataPacks: dataPacks, OperationTemplates: operationTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
|
||||
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, DataPacks: dataPacks, OperationTemplates: operationTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDeclarationBody) domain.GameClientBridgeLogProjectionDeclaration {
|
||||
steps := make([]domain.GameClientBridgeLogProjectionStepDeclaration, len(value.Steps))
|
||||
for index, step := range value.Steps {
|
||||
steps[index] = domain.GameClientBridgeLogProjectionStepDeclaration{Pattern: step.Pattern}
|
||||
}
|
||||
var presence *domain.GameClientBridgeLogProjectionPresenceDeclaration
|
||||
if value.Presence != nil {
|
||||
presence = &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||
TimestampField: value.Presence.TimestampField,
|
||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||
ActivityTarget: gameClientBridgeLogProjectionTargetToDomainPointer(value.Presence.ActivityTarget),
|
||||
Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{
|
||||
ProfileKey: value.Presence.Announcement.ProfileKey,
|
||||
CommandType: value.Presence.Announcement.CommandType,
|
||||
TextField: value.Presence.Announcement.TextField,
|
||||
NewTextTemplate: value.Presence.Announcement.NewTextTemplate,
|
||||
ReturningTextTemplate: value.Presence.Announcement.ReturningTextTemplate,
|
||||
},
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeLogProjectionDeclaration{
|
||||
Key: value.Key,
|
||||
StreamKeys: domain.CopyStringSlice(value.StreamKeys),
|
||||
Steps: steps,
|
||||
CorrelationFields: domain.CopyStringSlice(value.CorrelationFields),
|
||||
MaxInterveningLines: value.MaxInterveningLines,
|
||||
Target: gameClientBridgeLogProjectionTargetToDomain(value.Target),
|
||||
Presence: presence,
|
||||
}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetToDomain(value GameClientBridgeLogProjectionTargetDeclarationBody) domain.GameClientBridgeLogProjectionTargetDeclaration {
|
||||
return domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeLogProjectionTargetDeclarationBody) *domain.GameClientBridgeLogProjectionTargetDeclaration {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
target := gameClientBridgeLogProjectionTargetToDomain(*value)
|
||||
return &target
|
||||
}
|
||||
|
||||
func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration {
|
||||
@@ -1633,9 +1720,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
for index, template := range value.QueryTemplates {
|
||||
var rowTarget *PluginDataRowTargetDeclarationBody
|
||||
if template.RowTarget != nil {
|
||||
rowTarget = &PluginDataRowTargetDeclarationBody{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings)}
|
||||
rowTarget = &PluginDataRowTargetDeclarationBody{Collection: template.RowTarget.Collection, UpsertKeys: domain.CopyStringSlice(template.RowTarget.UpsertKeys), ColumnMappings: domain.CopyStringMap(template.RowTarget.ColumnMappings), WriteMode: template.RowTarget.WriteMode}
|
||||
}
|
||||
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, RowTarget: rowTarget}
|
||||
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, RowTarget: rowTarget}
|
||||
}
|
||||
logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections))
|
||||
for index, projection := range value.LogProjections {
|
||||
logProjections[index] = gameClientBridgeLogProjectionFromDomain(projection)
|
||||
}
|
||||
dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks))
|
||||
for index, dataPack := range value.DataPacks {
|
||||
@@ -1657,7 +1748,50 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
if value.Companion.ProfileKey != "" {
|
||||
companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds}
|
||||
}
|
||||
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, DataPacks: dataPacks, OperationTemplates: operationTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
|
||||
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, DataPacks: dataPacks, OperationTemplates: operationTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclarationBody {
|
||||
steps := make([]GameClientBridgeLogProjectionStepDeclarationBody, len(value.Steps))
|
||||
for index, step := range value.Steps {
|
||||
steps[index] = GameClientBridgeLogProjectionStepDeclarationBody{Pattern: step.Pattern}
|
||||
}
|
||||
var presence *GameClientBridgeLogProjectionPresenceDeclarationBody
|
||||
if value.Presence != nil {
|
||||
presence = &GameClientBridgeLogProjectionPresenceDeclarationBody{
|
||||
TimestampField: value.Presence.TimestampField,
|
||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||
ActivityTarget: gameClientBridgeLogProjectionTargetFromDomainPointer(value.Presence.ActivityTarget),
|
||||
Announcement: GameClientBridgeLogProjectionAnnouncementDeclarationBody{
|
||||
ProfileKey: value.Presence.Announcement.ProfileKey,
|
||||
CommandType: value.Presence.Announcement.CommandType,
|
||||
TextField: value.Presence.Announcement.TextField,
|
||||
NewTextTemplate: value.Presence.Announcement.NewTextTemplate,
|
||||
ReturningTextTemplate: value.Presence.Announcement.ReturningTextTemplate,
|
||||
},
|
||||
}
|
||||
}
|
||||
return GameClientBridgeLogProjectionDeclarationBody{
|
||||
Key: value.Key,
|
||||
StreamKeys: domain.CopyStringSlice(value.StreamKeys),
|
||||
Steps: steps,
|
||||
CorrelationFields: domain.CopyStringSlice(value.CorrelationFields),
|
||||
MaxInterveningLines: value.MaxInterveningLines,
|
||||
Target: gameClientBridgeLogProjectionTargetFromDomain(value.Target),
|
||||
Presence: presence,
|
||||
}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetFromDomain(value domain.GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclarationBody {
|
||||
return GameClientBridgeLogProjectionTargetDeclarationBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClientBridgeLogProjectionTargetDeclaration) *GameClientBridgeLogProjectionTargetDeclarationBody {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
target := gameClientBridgeLogProjectionTargetFromDomain(*value)
|
||||
return &target
|
||||
}
|
||||
|
||||
func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody {
|
||||
|
||||
@@ -137,8 +137,13 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
body := GameClientBridgeManifestBody{
|
||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
|
||||
Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite",
|
||||
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10,
|
||||
RowTarget: &PluginDataRowTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}},
|
||||
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, PollIntervalSeconds: 3,
|
||||
RowTarget: &PluginDataRowTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}, WriteMode: "merge"},
|
||||
}},
|
||||
LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{
|
||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
|
||||
Target: GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
||||
Presence: &GameClientBridgeLogProjectionPresenceDeclarationBody{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}, Announcement: GameClientBridgeLogProjectionAnnouncementDeclarationBody{ProfileKey: "scum-client", CommandType: "announcement.send", TextField: "message", NewTextTemplate: "welcome {{name}}", ReturningTextTemplate: "welcome back {{name}}"}},
|
||||
}},
|
||||
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}}},
|
||||
CommandRetentionSeconds: 86400,
|
||||
@@ -147,7 +152,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
}
|
||||
|
||||
domainManifest := body.ToDomain()
|
||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].RowTarget.Collection != "users" || len(domainManifest.DataPacks) != 1 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || domainManifest.QueryTemplates[0].RowTarget.Collection != "users" || domainManifest.QueryTemplates[0].RowTarget.WriteMode != "merge" || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
|
||||
}
|
||||
domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated"
|
||||
@@ -155,6 +160,11 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
t.Fatal("query template row target aliases request DTO data")
|
||||
}
|
||||
domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "user_id"
|
||||
domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
||||
if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" {
|
||||
t.Fatal("log projection target aliases request DTO data")
|
||||
}
|
||||
domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "steamId"
|
||||
domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||
if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatal("query template page keys alias request DTO data")
|
||||
@@ -162,6 +172,10 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
domainManifest.Pages[0].QueryTemplateKeys[0] = "player.lookup"
|
||||
|
||||
response := gameClientBridgeManifestFromDomain(domainManifest)
|
||||
response.LogProjections[0].Target.FixedValues["source"] = "mutated"
|
||||
if domainManifest.LogProjections[0].Target.FixedValues["source"] != "stdout" {
|
||||
t.Fatal("log projection target aliases domain data")
|
||||
}
|
||||
response.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||
if domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatal("query template page keys alias domain data")
|
||||
@@ -175,7 +189,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
if err := json.Unmarshal(encoded, &projection); err != nil {
|
||||
t.Fatalf("decode safe query template projection: %v", err)
|
||||
}
|
||||
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "maxRows", "timeoutSeconds", "rowTarget"}
|
||||
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "maxRows", "timeoutSeconds", "pollIntervalSeconds", "rowTarget"}
|
||||
if len(projection) != len(expectedFields) {
|
||||
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
||||
if err := svc.sweepExpiredJobs(claim.RunEndpointID, stamp); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
if err := svc.scheduleDuePluginQueries(claim.RunEndpointID, claim.Capabilities, stamp); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs {
|
||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||
}
|
||||
|
||||
@@ -22,7 +22,12 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
lock := svc.logIngestLock(batch.ServerInstanceID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
locked := true
|
||||
defer func() {
|
||||
if locked {
|
||||
lock.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
stamp := svc.now()
|
||||
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
@@ -44,6 +49,11 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) {
|
||||
locked = false
|
||||
lock.Unlock()
|
||||
if err := svc.projectPluginLogBatch(stream, storedLogEntries(batch.Entries)); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
@@ -76,6 +86,11 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
if err := svc.store.LogStreams().Update(stream); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
locked = false
|
||||
lock.Unlock()
|
||||
if err := svc.projectPluginLogBatch(stream, storedBatch.Entries); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
svc.publishLogEvents(stream, storedBatch.Entries)
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
@@ -87,6 +102,13 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}, nil
|
||||
}
|
||||
|
||||
func storedLogEntries(entries []domain.LogEntry) []domain.LogEntry {
|
||||
stored := domain.CopyLogEntries(entries)
|
||||
batch := domain.LogBatchIngest{Entries: stored}
|
||||
sanitizeLogNetworkFields(&batch)
|
||||
return batch.Entries
|
||||
}
|
||||
|
||||
func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error {
|
||||
jobID, ok := jobIDFromLogBatch(batch)
|
||||
if !ok {
|
||||
|
||||
@@ -2,10 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
type pluginDataQueryResult struct {
|
||||
@@ -42,7 +45,7 @@ func (svc *CoreService) projectPluginDataJobResult(job domain.Job) error {
|
||||
if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &result); err != nil {
|
||||
return validationError("declared query result is not valid JSON")
|
||||
}
|
||||
mutations := make([]domain.PluginDataMutation, 0, len(result.Rows))
|
||||
mutationsByKey := make(map[string]domain.PluginDataMutation, len(result.Rows))
|
||||
for _, row := range result.Rows {
|
||||
value := make(map[string]any, len(template.RowTarget.ColumnMappings))
|
||||
for destination, source := range template.RowTarget.ColumnMappings {
|
||||
@@ -52,15 +55,54 @@ func (svc *CoreService) projectPluginDataJobResult(job domain.Job) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mutations = append(mutations, domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value})
|
||||
if template.RowTarget.WriteMode == domain.PluginDataRowWriteModeMerge {
|
||||
existing, getErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, template.RowTarget.Collection, key))
|
||||
if getErr == nil {
|
||||
value = mergePluginDataValues(existing.Value, value)
|
||||
} else if !errors.Is(getErr, repo.ErrNotFound) {
|
||||
return getErr
|
||||
}
|
||||
}
|
||||
mutationsByKey[key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value}
|
||||
}
|
||||
if len(mutations) == 0 {
|
||||
if template.RowTarget.WriteMode == domain.PluginDataRowWriteModeReplace {
|
||||
existing, listErr := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: template.RowTarget.Collection})
|
||||
if listErr != nil {
|
||||
return listErr
|
||||
}
|
||||
for _, record := range existing {
|
||||
if _, present := mutationsByKey[record.Key]; !present {
|
||||
mutationsByKey[record.Key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationDelete, Key: record.Key}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(mutationsByKey) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(mutationsByKey))
|
||||
for key := range mutationsByKey {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
mutations := make([]domain.PluginDataMutation, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
mutations = append(mutations, mutationsByKey[key])
|
||||
}
|
||||
_, err = svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: template.RowTarget.Collection, Mutations: mutations})
|
||||
return err
|
||||
}
|
||||
|
||||
func mergePluginDataValues(existing, incoming map[string]any) map[string]any {
|
||||
merged := domain.CopyGameClientBridgePayload(existing)
|
||||
if merged == nil {
|
||||
merged = make(map[string]any, len(incoming))
|
||||
}
|
||||
for key, value := range incoming {
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func pluginDataRowKey(value map[string]any, keys []string) (string, error) {
|
||||
parts := make([]string, len(keys))
|
||||
for index, key := range keys {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func (svc *CoreService) scheduleDuePluginQueries(runEndpointID string, capabilities []string, stamp time.Time) error {
|
||||
if !containsString(capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return nil
|
||||
}
|
||||
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: runEndpointID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: runEndpointID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, instance := range instances {
|
||||
if instance.State == domain.ServerInstanceStateDeleted {
|
||||
continue
|
||||
}
|
||||
plugin, getErr := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if getErr != nil {
|
||||
return getErr
|
||||
}
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.PollIntervalSeconds <= 0 || template.RowTarget == nil || strings.TrimSpace(template.SQLRef) == "" {
|
||||
continue
|
||||
}
|
||||
if !pluginQueryTemplateDue(jobs, instance.ID, template.Key, time.Duration(template.PollIntervalSeconds)*time.Second, stamp) {
|
||||
continue
|
||||
}
|
||||
bucket := stamp.Unix() / int64(template.PollIntervalSeconds)
|
||||
idempotencyKey := fmt.Sprintf("plugin-query:%s:%s:%d", instance.ID, template.Key, bucket)
|
||||
job := domain.Job{
|
||||
ID: jobIDFromParts("job-plugin-query", instance.ID, idempotencyKey),
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: runEndpointID,
|
||||
Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
TargetKey: template.TargetKey,
|
||||
InputRef: "input://plugin-query/" + template.Key,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "declared automatic plugin query queued"},
|
||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1},
|
||||
ExecutionInput: domain.JobExecutionInput{
|
||||
WorkspaceScope: svc.runtimeProfileScope(instance.ID),
|
||||
RemoteAdapterKey: template.TransportKey,
|
||||
RemoteAdapterKind: string(domain.RemoteAdapterDatabase),
|
||||
TimeoutSeconds: template.TimeoutSeconds,
|
||||
Inputs: map[string]string{
|
||||
"templateKey": template.Key,
|
||||
"sqlRef": template.SQLRef,
|
||||
"maxRows": strconv.Itoa(template.MaxRows),
|
||||
"limit": strconv.Itoa(template.MaxRows),
|
||||
},
|
||||
},
|
||||
}
|
||||
created, createErr := svc.CreateJob(job)
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
jobs = append(jobs, created)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginQueryTemplateDue(jobs []domain.Job, serverInstanceID, templateKey string, interval time.Duration, stamp time.Time) bool {
|
||||
var latest time.Time
|
||||
for _, job := range jobs {
|
||||
if job.ServerInstanceID != serverInstanceID || job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.ExecutionInput.Inputs["templateKey"] != templateKey {
|
||||
continue
|
||||
}
|
||||
if !isTerminalJobState(job.State) {
|
||||
return false
|
||||
}
|
||||
attemptedAt := job.TerminalAt
|
||||
if attemptedAt.IsZero() {
|
||||
attemptedAt = job.UpdatedAt
|
||||
}
|
||||
if attemptedAt.After(latest) {
|
||||
latest = attemptedAt
|
||||
}
|
||||
}
|
||||
return latest.IsZero() || !stamp.Before(latest.Add(interval))
|
||||
}
|
||||
@@ -167,6 +167,92 @@ func TestDeclaredSQLiteQueryProjectionRejectsInvalidBatchAtomically(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredSQLiteQueryProjectionMergesPresenceAndReplacesCompleteSnapshots(t *testing.T) {
|
||||
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
template := &plugin.GameClientBridge.QueryTemplates[0]
|
||||
template.RowTarget = &domain.PluginDataRowTargetDeclaration{
|
||||
Collection: "users", UpsertKeys: []string{"steamId"}, WriteMode: domain.PluginDataRowWriteModeMerge,
|
||||
ColumnMappings: map[string]string{"steamId": "steam_id", "displayName": "display_name", "x": "x"},
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update merge target: %v", err)
|
||||
}
|
||||
if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users", Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "online": true, "lastLoginAt": "2026-07-03T12:00:00Z"}}); err != nil {
|
||||
t.Fatalf("seed stdout user: %v", err)
|
||||
}
|
||||
job := domain.Job{ServerInstanceID: instance.ID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, State: domain.JobStateSucceeded, ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": template.Key}}, ExecutionResult: domain.JobExecutionResult{Content: `{"rows":[{"steam_id":"steam-1","display_name":"Ada","x":12.5}]}`}}
|
||||
if err := svc.projectPluginDataJobResult(job); err != nil {
|
||||
t.Fatalf("merge query projection: %v", err)
|
||||
}
|
||||
users, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "users"})
|
||||
if err != nil || len(users) != 1 || users[0].Value["online"] != true || users[0].Value["displayName"] != "Ada" {
|
||||
t.Fatalf("merged users=%+v err=%v", users, err)
|
||||
}
|
||||
|
||||
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
|
||||
Collection: "vehicles", UpsertKeys: []string{"vehicleId"}, WriteMode: domain.PluginDataRowWriteModeReplace,
|
||||
ColumnMappings: map[string]string{"vehicleId": "vehicle_id", "x": "x"},
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update replace target: %v", err)
|
||||
}
|
||||
for _, id := range []string{"keep", "gone"} {
|
||||
if _, err := svc.PutPluginDataForSession(session, domain.PluginDataRecord{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles", Key: id, Value: map[string]any{"vehicleId": id}}); err != nil {
|
||||
t.Fatalf("seed vehicle %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
job.ExecutionResult.Content = `{"rows":[{"vehicle_id":"keep","x":7}]}`
|
||||
if err := svc.projectPluginDataJobResult(job); err != nil {
|
||||
t.Fatalf("replace query projection: %v", err)
|
||||
}
|
||||
vehicles, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles"})
|
||||
if err != nil || len(vehicles) != 1 || vehicles[0].Key != "keep" {
|
||||
t.Fatalf("replaced vehicles=%+v err=%v", vehicles, err)
|
||||
}
|
||||
job.ExecutionResult.Content = `{"rows":[]}`
|
||||
if err := svc.projectPluginDataJobResult(job); err != nil {
|
||||
t.Fatalf("empty replace query projection: %v", err)
|
||||
}
|
||||
vehicles, err = svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "vehicles"})
|
||||
if err != nil || len(vehicles) != 0 {
|
||||
t.Fatalf("empty replace did not clear vehicles=%+v err=%v", vehicles, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPollSchedulesDueDeclaredPluginQueryWithoutBrowserSession(t *testing.T) {
|
||||
svc, plugin, endpoint, _, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("enable automatic query: %v", err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job == nil {
|
||||
t.Fatalf("automatic query claim=%+v err=%v", claim, err)
|
||||
}
|
||||
if claim.Job.ServerInstanceID != instance.ID || claim.Job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["limit"] != "25" {
|
||||
t.Fatalf("unexpected automatic query assignment: %+v", claim.Job)
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID})
|
||||
if err != nil || len(jobs) != 1 {
|
||||
t.Fatalf("automatic query jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1, RunningJobs: 1}})
|
||||
if err != nil || second.HasJob {
|
||||
t.Fatalf("overlapping automatic query was not suppressed: %+v err=%v", second, err)
|
||||
}
|
||||
jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpoint.ID})
|
||||
if err != nil || len(jobs) != 1 {
|
||||
t.Fatalf("overlap created duplicate jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredSQLiteQueryProjectionFailureKeepsJobRetryable(t *testing.T) {
|
||||
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
type pluginLogSequenceState struct {
|
||||
StepIndex int
|
||||
Captures map[string]string
|
||||
LastSeq uint64
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectPluginLogBatch(stream domain.LogStream, entries []domain.LogEntry) error {
|
||||
if stream.Source != domain.LogStreamSourceProcess || len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, projection := range plugin.GameClientBridge.LogProjections {
|
||||
if !containsString(projection.StreamKeys, stream.StreamKey) {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
captures, complete, matchErr := svc.advancePluginLogProjection(stream, projection, entry)
|
||||
if matchErr != nil {
|
||||
return matchErr
|
||||
}
|
||||
if complete {
|
||||
observedAt := entry.Timestamp
|
||||
if observedAt.IsZero() {
|
||||
observedAt = svc.now()
|
||||
}
|
||||
if err := svc.applyPluginLogProjection(instance, plugin, projection, captures, observedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) advancePluginLogProjection(stream domain.LogStream, projection domain.GameClientBridgeLogProjectionDeclaration, entry domain.LogEntry) (map[string]string, bool, error) {
|
||||
if len(projection.Steps) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
stateKey := strings.Join([]string{stream.ServerInstanceID, stream.ID, stream.LogSessionID, projection.Key}, "\x00")
|
||||
svc.logProjectionMu.Lock()
|
||||
defer svc.logProjectionMu.Unlock()
|
||||
states := svc.logProjectionStates[stateKey]
|
||||
if states == nil {
|
||||
states = map[string]pluginLogSequenceState{}
|
||||
svc.logProjectionStates[stateKey] = states
|
||||
}
|
||||
nextStates := make(map[string]pluginLogSequenceState, len(states)+1)
|
||||
var completed map[string]string
|
||||
for correlationKey, state := range states {
|
||||
if state.StepIndex < 1 || state.StepIndex >= len(projection.Steps) {
|
||||
continue
|
||||
}
|
||||
if projection.MaxInterveningLines >= 0 && state.LastSeq > 0 && entry.Seq > state.LastSeq+uint64(projection.MaxInterveningLines)+1 {
|
||||
continue
|
||||
}
|
||||
match, err := matchLogProjectionStep(projection.Steps[state.StepIndex].Pattern, entry.Line)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if match == nil {
|
||||
nextStates[correlationKey] = state
|
||||
continue
|
||||
}
|
||||
merged, ok := mergeLogCaptures(state.Captures, match)
|
||||
if !ok || !correlationCapturesAgree(state.Captures, match, projection.CorrelationFields) {
|
||||
continue
|
||||
}
|
||||
if state.StepIndex+1 == len(projection.Steps) {
|
||||
completed = merged
|
||||
continue
|
||||
}
|
||||
nextKey := logCorrelationKey(merged, projection.CorrelationFields)
|
||||
nextStates[nextKey] = pluginLogSequenceState{StepIndex: state.StepIndex + 1, Captures: merged, LastSeq: entry.Seq}
|
||||
}
|
||||
first, err := matchLogProjectionStep(projection.Steps[0].Pattern, entry.Line)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if first != nil {
|
||||
if len(projection.Steps) == 1 {
|
||||
completed = first
|
||||
} else {
|
||||
key := logCorrelationKey(first, projection.CorrelationFields)
|
||||
nextStates[key] = pluginLogSequenceState{StepIndex: 1, Captures: first, LastSeq: entry.Seq}
|
||||
}
|
||||
}
|
||||
svc.logProjectionStates[stateKey] = nextStates
|
||||
return completed, completed != nil, nil
|
||||
}
|
||||
|
||||
func matchLogProjectionStep(pattern, line string) (map[string]string, error) {
|
||||
expression, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, validationError("declared log projection pattern is invalid")
|
||||
}
|
||||
values := expression.FindStringSubmatch(line)
|
||||
if values == nil {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string]string)
|
||||
for index, name := range expression.SubexpNames() {
|
||||
if index > 0 && name != "" && index < len(values) {
|
||||
result[name] = values[index]
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mergeLogCaptures(existing, incoming map[string]string) (map[string]string, bool) {
|
||||
merged := make(map[string]string, len(existing)+len(incoming))
|
||||
for key, value := range existing {
|
||||
merged[key] = value
|
||||
}
|
||||
for key, value := range incoming {
|
||||
if previous, exists := merged[key]; exists && previous != value {
|
||||
return nil, false
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
return merged, true
|
||||
}
|
||||
|
||||
func correlationCapturesAgree(existing, incoming map[string]string, fields []string) bool {
|
||||
for _, field := range fields {
|
||||
left, leftExists := existing[field]
|
||||
right, rightExists := incoming[field]
|
||||
if leftExists && rightExists && left != right {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func logCorrelationKey(captures map[string]string, fields []string) string {
|
||||
parts := make([]string, len(fields))
|
||||
for index, field := range fields {
|
||||
parts[index] = captures[field]
|
||||
}
|
||||
return strings.Join(parts, "\x1f")
|
||||
}
|
||||
|
||||
func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance, plugin domain.GamePlugin, projection domain.GameClientBridgeLogProjectionDeclaration, captures map[string]string, observedAt time.Time) error {
|
||||
value := pluginLogProjectionValue(projection.Target, captures, observedAt)
|
||||
key, err := pluginDataRowKey(value, projection.Target.UpsertKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, getErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, projection.Target.Collection, key))
|
||||
isNew := errors.Is(getErr, repo.ErrNotFound)
|
||||
if getErr != nil && !isNew {
|
||||
return getErr
|
||||
}
|
||||
insideWindow := false
|
||||
sameObservation := false
|
||||
if projection.Presence != nil && !isNew {
|
||||
if previous, ok := pluginDataTimestamp(existing.Value[projection.Presence.TimestampField]); ok {
|
||||
if observedAt.Before(previous) {
|
||||
return nil
|
||||
}
|
||||
sameObservation = observedAt.Equal(previous)
|
||||
insideWindow = observedAt.Sub(previous) < time.Duration(projection.Presence.ActiveWindowSeconds)*time.Second
|
||||
}
|
||||
}
|
||||
if insideWindow && !sameObservation {
|
||||
return nil
|
||||
}
|
||||
announcementAlreadyQueued := false
|
||||
announcementIdempotencyKey := ""
|
||||
if projection.Presence != nil {
|
||||
announcementIdempotencyKey = fmt.Sprintf("log-projection:%s:%s:%d", projection.Key, key, observedAt.Unix()/int64(projection.Presence.ActiveWindowSeconds))
|
||||
_, commandErr := svc.store.GameClientBridgeCommands().GetByIdempotency(instance.ID, "system:log-projection", projection.Presence.Announcement.CommandType, announcementIdempotencyKey)
|
||||
if commandErr == nil {
|
||||
announcementAlreadyQueued = true
|
||||
} else if !errors.Is(commandErr, repo.ErrNotFound) {
|
||||
return commandErr
|
||||
}
|
||||
}
|
||||
if !isNew {
|
||||
value = mergePluginDataValues(existing.Value, value)
|
||||
}
|
||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: projection.Target.Collection, Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: key, Value: value}}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if projection.Presence != nil && projection.Presence.ActivityTarget != nil {
|
||||
activity := pluginLogProjectionValue(*projection.Presence.ActivityTarget, captures, observedAt)
|
||||
activityKey, keyErr := pluginDataRowKey(activity, projection.Presence.ActivityTarget.UpsertKeys)
|
||||
if keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
if _, applyErr := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: projection.Presence.ActivityTarget.Collection, Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: activityKey, Value: activity}}}); applyErr != nil {
|
||||
return applyErr
|
||||
}
|
||||
}
|
||||
if projection.Presence != nil && !announcementAlreadyQueued {
|
||||
announcement := projection.Presence.Announcement
|
||||
template := announcement.ReturningTextTemplate
|
||||
if isNew || sameObservation {
|
||||
template = announcement.NewTextTemplate
|
||||
}
|
||||
requestText := renderLogProjectionTemplate(template, captures)
|
||||
expiresAt := svc.now().Add(gameClientBridgeCommandTimeout(plugin, announcement.CommandType))
|
||||
if _, err := svc.queueGameClientBridgeCommand("system:log-projection", domain.GameClientBridgeQueueRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
ProfileKey: announcement.ProfileKey,
|
||||
CommandType: announcement.CommandType,
|
||||
Payload: map[string]any{announcement.TextField: requestText},
|
||||
IdempotencyKey: announcementIdempotencyKey,
|
||||
Priority: 100,
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func gameClientBridgeCommandTimeout(plugin domain.GamePlugin, commandType string) time.Duration {
|
||||
for _, declaration := range plugin.GameClientBridge.Commands {
|
||||
if declaration.Type == commandType && declaration.TimeoutSeconds > 0 {
|
||||
return time.Duration(declaration.TimeoutSeconds) * time.Second
|
||||
}
|
||||
}
|
||||
return time.Minute
|
||||
}
|
||||
|
||||
func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any {
|
||||
value := make(map[string]any, len(target.CaptureMappings)+len(target.FixedValues)+1)
|
||||
for destination, capture := range target.CaptureMappings {
|
||||
value[destination] = captures[capture]
|
||||
}
|
||||
for key, fixed := range target.FixedValues {
|
||||
value[key] = renderLogProjectionTemplate(fixed, captures)
|
||||
}
|
||||
if target.ObservedAtField != "" {
|
||||
value[target.ObservedAtField] = observedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func renderLogProjectionTemplate(template string, captures map[string]string) string {
|
||||
result := template
|
||||
for key, value := range captures {
|
||||
result = strings.ReplaceAll(result, "{{"+key+"}}", value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func pluginDataTimestamp(value any) (time.Time, bool) {
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" || text == "<nil>" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, text)
|
||||
return parsed, err == nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnouncesReturns(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
capability := domain.JobCapabilityRemoteRunProtectedRCON
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{capability}})
|
||||
plugin.RuntimeProfiles.ClientManagers = append(plugin.RuntimeProfiles.ClientManagers, domain.RuntimeClientManagerProfile{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}})
|
||||
plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{
|
||||
Type: "presence.announce", Title: "Presence announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator,
|
||||
PayloadSchemaRef: "schemas/presence-announcement.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096,
|
||||
ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "rcon", TransportKey: "scum-management", TargetKey: "scum-management", TextField: "requestText", MaxTextBytes: 1024},
|
||||
}}
|
||||
plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{
|
||||
Key: "player.login", StreamKeys: []string{"stdout"}, CorrelationFields: []string{"playerSlot"}, MaxInterveningLines: 4,
|
||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
||||
{Pattern: `Player "(?P<displayName>[^"]+)" reported as player (?P<playerSlot>[0-9]+)`},
|
||||
{Pattern: `Player (?P<playerSlot>[0-9]+) SteamID \(assumed\): (?P<steamId>[0-9]+)`},
|
||||
},
|
||||
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{
|
||||
Collection: "scum_users", UpsertKeys: []string{"steamId"},
|
||||
CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName", "playerSlot": "playerSlot"},
|
||||
FixedValues: map[string]string{"online": "true", "source": "supervised-stdout"}, ObservedAtField: "lastLoginAt",
|
||||
},
|
||||
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
|
||||
ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{
|
||||
Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt"},
|
||||
CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "login"}, ObservedAtField: "observedAt",
|
||||
},
|
||||
Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{
|
||||
ProfileKey: "scum-client", CommandType: "presence.announce", TextField: "requestText",
|
||||
NewTextTemplate: "#announce Welcome {{displayName}}", ReturningTextTemplate: "#announce Welcome back {{displayName}}",
|
||||
},
|
||||
},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin projection: %v", err)
|
||||
}
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, capability)
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("update Run capability: %v", err)
|
||||
}
|
||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-log-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM projection", State: domain.ServerInstanceStateRunning})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, capability)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-log-projection"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
stream, err := svc.CreateLogStream(domain.LogStream{ID: "log-projection", ServerInstanceID: instance.ID, Source: domain.LogStreamSourceProcess, StreamKey: "stdout", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"})
|
||||
if err != nil {
|
||||
t.Fatalf("create stdout stream: %v", err)
|
||||
}
|
||||
|
||||
base := time.Date(2026, 8, 18, 23, 25, 12, 0, time.UTC)
|
||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 1, base, []string{
|
||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
||||
`LogBattlEye: Display: Player #0 love_fitting (redacted) connected`,
|
||||
})
|
||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 3, base.Add(2*time.Second), []string{
|
||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
||||
})
|
||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 1)
|
||||
|
||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 4, base.Add(5*time.Minute), []string{
|
||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
||||
})
|
||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 1)
|
||||
|
||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 6, base.Add(11*time.Minute), []string{
|
||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
||||
})
|
||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 2)
|
||||
|
||||
svc.protectedRequests.mu.Lock()
|
||||
texts := make([]string, 0, len(svc.protectedRequests.payloads))
|
||||
for _, payload := range svc.protectedRequests.payloads {
|
||||
texts = append(texts, payload.requestText)
|
||||
}
|
||||
svc.protectedRequests.mu.Unlock()
|
||||
if len(texts) != 2 || !containsText(texts, "#announce Welcome love_fitting") || !containsText(texts, "#announce Welcome back love_fitting") {
|
||||
t.Fatalf("unexpected plugin-declared announcement requests: %v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) {
|
||||
t.Helper()
|
||||
entries := make([]domain.LogEntry, len(lines))
|
||||
for index, line := range lines {
|
||||
entries[index] = domain.LogEntry{Seq: firstSeq + uint64(index), Timestamp: observedAt.Add(time.Duration(index) * time.Second), Level: "display", Line: line}
|
||||
}
|
||||
lastSeq := firstSeq + uint64(len(entries)) - 1
|
||||
batch := domain.LogBatchIngest{RunEndpointID: endpointID, SessionToken: sessionToken, LogStreamID: streamID, ServerInstanceID: serverID, StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: firstSeq, LastSeq: lastSeq, Compression: "none", Checksum: checksumForEntries(t, entries), Entries: entries}
|
||||
if result, err := svc.IngestLogBatch(batch); err != nil || !result.Accepted {
|
||||
t.Fatalf("ingest projection lines result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPresenceProjectionCounts(t *testing.T, svc *CoreService, pluginID, serverID string, users, activities, commands int) {
|
||||
t.Helper()
|
||||
userRows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: pluginID, ServerInstanceID: serverID, Collection: "scum_users"})
|
||||
if err != nil || len(userRows) != users {
|
||||
t.Fatalf("projected users=%+v err=%v", userRows, err)
|
||||
}
|
||||
activityRows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: pluginID, ServerInstanceID: serverID, Collection: "scum_activity_events"})
|
||||
if err != nil || len(activityRows) != activities {
|
||||
t.Fatalf("projected activities=%+v err=%v", activityRows, err)
|
||||
}
|
||||
queued, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: serverID, PluginID: pluginID})
|
||||
if err != nil || len(queued) != commands {
|
||||
t.Fatalf("presence announcements=%+v err=%v", queued, err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(values []string, expected string) bool {
|
||||
for _, value := range values {
|
||||
if strings.Contains(value, expected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -234,6 +234,8 @@ type CoreService struct {
|
||||
bridgeSeq uint64
|
||||
logStore LogBodyStore
|
||||
logIngestMu [64]sync.Mutex
|
||||
logProjectionMu sync.Mutex
|
||||
logProjectionStates map[string]map[string]pluginLogSequenceState
|
||||
logEventMu sync.Mutex
|
||||
logEventSubscribers map[uint64]logEventSubscriber
|
||||
logEventSubscriberSeq uint64
|
||||
@@ -280,6 +282,7 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
|
||||
authSessions: map[string]string{},
|
||||
runSessions: map[string]domain.RunControlSession{},
|
||||
logStore: logStore,
|
||||
logProjectionStates: map[string]map[string]pluginLogSequenceState{},
|
||||
logEventSubscribers: map[uint64]logEventSubscriber{},
|
||||
artifactStore: artifactStore,
|
||||
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
||||
bridge := domain.GameClientBridgeManifest{
|
||||
Commands: []domain.GameClientBridgeCommandDeclaration{{
|
||||
Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone,
|
||||
PayloadSchemaRef: "schemas/bridge/announcement.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096,
|
||||
}},
|
||||
LogProjections: []domain.GameClientBridgeLogProjectionDeclaration{{
|
||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 16,
|
||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
||||
{Pattern: `Player "(?<name>[^"]+)" reported as player (?<slot>\d+)`},
|
||||
{Pattern: `Player (?<slot>\d+) SteamID \(assumed\): (?<steamId>\d+)`},
|
||||
},
|
||||
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId", "name": "name"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
||||
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
|
||||
ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, ObservedAtField: "observedAt"},
|
||||
Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{ProfileKey: "scum-client", CommandType: "announcement.send", TextField: "message", NewTextTemplate: "welcome {{name}}", ReturningTextTemplate: "welcome back {{name}}"},
|
||||
},
|
||||
}},
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||
}
|
||||
profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}}}}
|
||||
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, []string{"server.game-client.command"}, nil, profiles); len(violations) != 0 {
|
||||
t.Fatalf("expected repeated named captures across steps to validate, got %v", violations)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
mutate func(*domain.GameClientBridgeManifest, *domain.GamePluginRuntimeProfiles)
|
||||
}{
|
||||
{name: "invalid regex", expected: "valid regular expression", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Steps[0].Pattern = "("
|
||||
}},
|
||||
{name: "missing capture", expected: "references undeclared capture missing", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Target.CaptureMappings["steamId"] = "missing"
|
||||
}},
|
||||
{name: "missing profile", expected: "must reference a declared game-client bridge profile", mutate: func(_ *domain.GameClientBridgeManifest, value *domain.GamePluginRuntimeProfiles) {
|
||||
value.ClientManagers = nil
|
||||
}},
|
||||
{name: "missing command", expected: "must reference a declared command", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Presence.Announcement.CommandType = "missing.command"
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := domain.CopyGameClientBridgeManifest(bridge)
|
||||
candidateProfiles := profiles
|
||||
candidateProfiles.ClientManagers = append([]domain.RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
|
||||
test.mutate(&candidate, &candidateProfiles)
|
||||
violations := validateGameClientBridgeManifest("gameClientBridge", candidate, []string{"server.game-client.command"}, nil, candidateProfiles)
|
||||
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
|
||||
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -24,6 +25,12 @@ const (
|
||||
maxProductionMessageLength = 320
|
||||
)
|
||||
|
||||
var (
|
||||
gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`)
|
||||
gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`)
|
||||
gameClientBridgeCaptureNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,79}$`)
|
||||
)
|
||||
|
||||
type ValidationError struct {
|
||||
Violations []string
|
||||
}
|
||||
@@ -427,7 +434,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
|
||||
|
||||
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
|
||||
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
|
||||
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.DataPacks) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
||||
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LogProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
||||
return nil
|
||||
}
|
||||
var violations []string
|
||||
@@ -566,6 +573,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
|
||||
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
||||
}
|
||||
if template.PollIntervalSeconds < 0 || template.PollIntervalSeconds > 86400 {
|
||||
violations = append(violations, prefix+".pollIntervalSeconds is invalid")
|
||||
}
|
||||
projectsRows := template.SQLRef != "" || template.RowTarget != nil
|
||||
if projectsRows {
|
||||
if !safeRelativeSQLRef(template.SQLRef) {
|
||||
@@ -578,6 +588,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if !clientManagerIdentifierPattern.MatchString(target.Collection) || len(target.UpsertKeys) == 0 || len(target.ColumnMappings) == 0 {
|
||||
violations = append(violations, prefix+".rowTarget must declare a collection, upsert keys, and column mappings")
|
||||
}
|
||||
if target.WriteMode != "" && target.WriteMode != domain.PluginDataRowWriteModeMerge && target.WriteMode != domain.PluginDataRowWriteModeReplace {
|
||||
violations = append(violations, prefix+".rowTarget.writeMode must be merge or replace")
|
||||
}
|
||||
for _, key := range target.UpsertKeys {
|
||||
if !clientManagerIdentifierPattern.MatchString(key) {
|
||||
violations = append(violations, prefix+".rowTarget upsert key is invalid")
|
||||
@@ -602,6 +615,18 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
|
||||
}
|
||||
}
|
||||
logProjectionKeys := map[string]struct{}{}
|
||||
for index, projection := range bridge.LogProjections {
|
||||
prefix := fmt.Sprintf("%s.logProjections[%d]", field, index)
|
||||
if !clientManagerIdentifierPattern.MatchString(projection.Key) {
|
||||
violations = append(violations, prefix+".key is invalid")
|
||||
}
|
||||
if _, exists := logProjectionKeys[projection.Key]; exists {
|
||||
violations = append(violations, prefix+".key is duplicated")
|
||||
}
|
||||
logProjectionKeys[projection.Key] = struct{}{}
|
||||
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection, bridge.Commands, runtimeProfiles.ClientManagers)...)
|
||||
}
|
||||
dataPackKeys := map[string]struct{}{}
|
||||
for index, dataPack := range bridge.DataPacks {
|
||||
prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index)
|
||||
@@ -795,6 +820,176 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration, commands []domain.GameClientBridgeCommandDeclaration, clientManagers []domain.RuntimeClientManagerProfile) []string {
|
||||
var violations []string
|
||||
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
|
||||
violations = append(violations, prefix+".streamKeys must contain between 1 and 64 streams")
|
||||
}
|
||||
for _, streamKey := range projection.StreamKeys {
|
||||
if !clientManagerIdentifierPattern.MatchString(streamKey) {
|
||||
violations = append(violations, prefix+".streamKeys contains an invalid stream key")
|
||||
}
|
||||
}
|
||||
violations = append(violations, duplicateViolations(prefix+".streamKeys", projection.StreamKeys)...)
|
||||
|
||||
captures := map[string]struct{}{}
|
||||
if len(projection.Steps) < 1 || len(projection.Steps) > 64 {
|
||||
violations = append(violations, prefix+".steps must contain between 1 and 64 patterns")
|
||||
}
|
||||
for index, step := range projection.Steps {
|
||||
stepPrefix := fmt.Sprintf("%s.steps[%d].pattern", prefix, index)
|
||||
if strings.TrimSpace(step.Pattern) == "" || len([]rune(step.Pattern)) > 16384 {
|
||||
violations = append(violations, stepPrefix+" is empty or too large")
|
||||
continue
|
||||
}
|
||||
compiled, err := regexp.Compile(step.Pattern)
|
||||
if err != nil {
|
||||
violations = append(violations, stepPrefix+" must be a valid regular expression")
|
||||
continue
|
||||
}
|
||||
for _, capture := range compiled.SubexpNames() {
|
||||
if capture != "" {
|
||||
captures[capture] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(projection.CorrelationFields) < 1 || len(projection.CorrelationFields) > 64 {
|
||||
violations = append(violations, prefix+".correlationFields must contain between 1 and 64 captures")
|
||||
}
|
||||
for _, field := range projection.CorrelationFields {
|
||||
if !gameClientBridgeCaptureNamePattern.MatchString(field) {
|
||||
violations = append(violations, prefix+".correlationFields contains an invalid capture name")
|
||||
continue
|
||||
}
|
||||
if _, exists := captures[field]; !exists {
|
||||
violations = append(violations, prefix+".correlationFields references undeclared capture "+field)
|
||||
}
|
||||
}
|
||||
violations = append(violations, duplicateViolations(prefix+".correlationFields", projection.CorrelationFields)...)
|
||||
if projection.MaxInterveningLines < 0 || projection.MaxInterveningLines > 100000 {
|
||||
violations = append(violations, prefix+".maxInterveningLines is invalid")
|
||||
}
|
||||
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".target", projection.Target, captures)...)
|
||||
|
||||
if projection.Presence == nil {
|
||||
return violations
|
||||
}
|
||||
presence := projection.Presence
|
||||
if !gameClientBridgeFieldPattern.MatchString(presence.TimestampField) || !gameClientBridgeLogProjectionTargetDeclaresField(projection.Target, presence.TimestampField) {
|
||||
violations = append(violations, prefix+".presence.timestampField must reference a projected target field")
|
||||
}
|
||||
if presence.ActiveWindowSeconds < 1 || presence.ActiveWindowSeconds > 31536000 {
|
||||
violations = append(violations, prefix+".presence.activeWindowSeconds is invalid")
|
||||
}
|
||||
if presence.ActivityTarget != nil {
|
||||
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".presence.activityTarget", *presence.ActivityTarget, captures)...)
|
||||
}
|
||||
|
||||
announcement := presence.Announcement
|
||||
if !clientManagerIdentifierPattern.MatchString(announcement.ProfileKey) {
|
||||
violations = append(violations, prefix+".presence.announcement.profileKey is invalid")
|
||||
} else {
|
||||
profileFound := false
|
||||
for _, profile := range clientManagers {
|
||||
if profile.Key == announcement.ProfileKey && containsString(profile.Health.RequiredCapabilities, "game-client.bridge") {
|
||||
profileFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !profileFound {
|
||||
violations = append(violations, prefix+".presence.announcement.profileKey must reference a declared game-client bridge profile")
|
||||
}
|
||||
}
|
||||
var command *domain.GameClientBridgeCommandDeclaration
|
||||
for index := range commands {
|
||||
if commands[index].Type == announcement.CommandType {
|
||||
command = &commands[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if command == nil {
|
||||
violations = append(violations, prefix+".presence.announcement.commandType must reference a declared command")
|
||||
}
|
||||
if !gameClientBridgeFieldPattern.MatchString(announcement.TextField) {
|
||||
violations = append(violations, prefix+".presence.announcement.textField is invalid")
|
||||
} else if command != nil && command.ProtectedRequest != nil && command.ProtectedRequest.TextField != announcement.TextField {
|
||||
violations = append(violations, prefix+".presence.announcement.textField must match the command protected request")
|
||||
}
|
||||
if strings.TrimSpace(announcement.NewTextTemplate) == "" || len([]rune(announcement.NewTextTemplate)) > 4096 {
|
||||
violations = append(violations, prefix+".presence.announcement.newTextTemplate is empty or too large")
|
||||
}
|
||||
if strings.TrimSpace(announcement.ReturningTextTemplate) == "" || len([]rune(announcement.ReturningTextTemplate)) > 4096 {
|
||||
violations = append(violations, prefix+".presence.announcement.returningTextTemplate is empty or too large")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateGameClientBridgeLogProjectionTarget(prefix string, target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]struct{}) []string {
|
||||
var violations []string
|
||||
if !gameClientBridgeCollectionPattern.MatchString(target.Collection) {
|
||||
violations = append(violations, prefix+".collection is invalid")
|
||||
}
|
||||
if len(target.UpsertKeys) < 1 || len(target.UpsertKeys) > 8 {
|
||||
violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields")
|
||||
}
|
||||
for _, key := range target.UpsertKeys {
|
||||
if !gameClientBridgeFieldPattern.MatchString(key) {
|
||||
violations = append(violations, prefix+".upsertKeys contains an invalid field")
|
||||
}
|
||||
if !gameClientBridgeLogProjectionTargetDeclaresField(target, key) {
|
||||
violations = append(violations, prefix+".upsertKeys field "+key+" is not projected")
|
||||
}
|
||||
}
|
||||
violations = append(violations, duplicateViolations(prefix+".upsertKeys", target.UpsertKeys)...)
|
||||
|
||||
if len(target.CaptureMappings) < 1 || len(target.CaptureMappings) > 64 {
|
||||
violations = append(violations, prefix+".captureMappings must contain between 1 and 64 mappings")
|
||||
}
|
||||
projectedFields := map[string]struct{}{}
|
||||
for destination, capture := range target.CaptureMappings {
|
||||
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) {
|
||||
violations = append(violations, prefix+".captureMappings contains an invalid field or capture")
|
||||
}
|
||||
if _, exists := captures[capture]; !exists {
|
||||
violations = append(violations, prefix+".captureMappings references undeclared capture "+capture)
|
||||
}
|
||||
projectedFields[destination] = struct{}{}
|
||||
}
|
||||
if len(target.FixedValues) > 64 {
|
||||
violations = append(violations, prefix+".fixedValues contains too many fields")
|
||||
}
|
||||
for destination, value := range target.FixedValues {
|
||||
if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 {
|
||||
violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value")
|
||||
}
|
||||
if _, exists := projectedFields[destination]; exists {
|
||||
violations = append(violations, prefix+" declares field "+destination+" more than once")
|
||||
}
|
||||
projectedFields[destination] = struct{}{}
|
||||
}
|
||||
if target.ObservedAtField != "" {
|
||||
if !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) {
|
||||
violations = append(violations, prefix+".observedAtField is invalid")
|
||||
}
|
||||
if _, exists := projectedFields[target.ObservedAtField]; exists {
|
||||
violations = append(violations, prefix+" declares field "+target.ObservedAtField+" more than once")
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetDeclaresField(target domain.GameClientBridgeLogProjectionTargetDeclaration, field string) bool {
|
||||
if target.ObservedAtField == field {
|
||||
return true
|
||||
}
|
||||
if _, exists := target.CaptureMappings[field]; exists {
|
||||
return true
|
||||
}
|
||||
_, exists := target.FixedValues[field]
|
||||
return exists
|
||||
}
|
||||
|
||||
func validCompanionProofEnvironment(value string) bool {
|
||||
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
||||
return false
|
||||
|
||||
@@ -204,6 +204,13 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(
|
||||
{name: "timeout bound", expected: "timeoutSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].TimeoutSeconds = 61
|
||||
}},
|
||||
{name: "poll interval bound", expected: "pollIntervalSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 86401
|
||||
}},
|
||||
{name: "write mode", expected: "writeMode must be merge or replace", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].SQLRef = "sql/player-lookup.sql"
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].RowTarget = &domain.PluginDataRowTargetDeclaration{Collection: "users", UpsertKeys: []string{"userId"}, ColumnMappings: map[string]string{"userId": "user_id"}, WriteMode: "append"}
|
||||
}},
|
||||
{name: "unknown transport", expected: "transportKey must reference", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing"
|
||||
}},
|
||||
|
||||
Reference in New Issue
Block a user