Add SCUM log sessions and trajectory projections

This commit is contained in:
npc0-hue
2026-08-27 12:34:07 +08:00
parent 0940780058
commit 316efbe780
38 changed files with 1549 additions and 84 deletions
+93 -9
View File
@@ -44,6 +44,18 @@ type GameClientBridgeQueryTemplateDeclaration struct {
MaxRows int
TimeoutSeconds int
PollIntervalSeconds int
Projections []GameClientBridgeQueryProjectionDeclaration
}
type GameClientBridgeQueryProjectionDeclaration struct {
Collection string
RowPath string
MatchField string
MatchValue string
UpsertKeys []string
FieldMappings map[string]string
FixedValues map[string]string
ObservedAtField string
}
type GameClientBridgeLogProjectionStepDeclaration struct {
@@ -54,6 +66,7 @@ type GameClientBridgeLogProjectionTargetDeclaration struct {
Collection string
UpsertKeys []string
CaptureMappings map[string]string
HashMappings map[string]string
FixedValues map[string]string
ObservedAtField string
}
@@ -64,6 +77,30 @@ type GameClientBridgeLogProjectionPresenceDeclaration struct {
ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration
}
type GameClientBridgeLifecycleProjectionDeclaration struct {
Key string
Capabilities []string
ProcessStates []string
Target GameClientBridgeBulkProjectionTargetDeclaration
}
type GameClientBridgeBulkProjectionTargetDeclaration struct {
Collection string
MatchField string
MatchValue string
FixedValues map[string]string
ObservedAtField string
ActivityTarget *GameClientBridgeBulkActivityTargetDeclaration
}
type GameClientBridgeBulkActivityTargetDeclaration struct {
Collection string
UpsertKeys []string
RowMappings map[string]string
FixedValues map[string]string
ObservedAtField string
}
type GameClientBridgeLogProjectionDeclaration struct {
Key string
StreamKeys []string
@@ -115,15 +152,16 @@ type GameClientBridgeCompanionDeclaration struct {
}
type GameClientBridgeManifest struct {
Commands []GameClientBridgeCommandDeclaration
Snapshots []GameClientBridgeSnapshotDeclaration
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
LogProjections []GameClientBridgeLogProjectionDeclaration
DataPacks []GameClientBridgeDataPackDeclaration
Retention GameClientBridgeRetention
Pages []GameClientBridgePageContract
Features []GameClientBridgeFeatureDeclaration
Companion GameClientBridgeCompanionDeclaration
Commands []GameClientBridgeCommandDeclaration
Snapshots []GameClientBridgeSnapshotDeclaration
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
LogProjections []GameClientBridgeLogProjectionDeclaration
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclaration
DataPacks []GameClientBridgeDataPackDeclaration
Retention GameClientBridgeRetention
Pages []GameClientBridgePageContract
Features []GameClientBridgeFeatureDeclaration
Companion GameClientBridgeCompanionDeclaration
}
type GameClientBridgeResultStatus string
@@ -394,10 +432,17 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
for index := range value.QueryTemplates {
value.QueryTemplates[index] = CopyGameClientBridgeQueryTemplateDeclaration(value.QueryTemplates[index])
}
value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...)
for index := range value.LogProjections {
value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index])
}
value.LifecycleProjections = append([]GameClientBridgeLifecycleProjectionDeclaration(nil), value.LifecycleProjections...)
for index := range value.LifecycleProjections {
value.LifecycleProjections[index] = CopyGameClientBridgeLifecycleProjectionDeclaration(value.LifecycleProjections[index])
}
value.DataPacks = append([]GameClientBridgeDataPackDeclaration(nil), value.DataPacks...)
for index := range value.DataPacks {
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
@@ -419,6 +464,44 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
return value
}
func CopyGameClientBridgeQueryTemplateDeclaration(value GameClientBridgeQueryTemplateDeclaration) GameClientBridgeQueryTemplateDeclaration {
value.Projections = append([]GameClientBridgeQueryProjectionDeclaration(nil), value.Projections...)
for index := range value.Projections {
value.Projections[index] = CopyGameClientBridgeQueryProjectionDeclaration(value.Projections[index])
}
return value
}
func CopyGameClientBridgeQueryProjectionDeclaration(value GameClientBridgeQueryProjectionDeclaration) GameClientBridgeQueryProjectionDeclaration {
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
value.FieldMappings = CopyStringMap(value.FieldMappings)
value.FixedValues = CopyStringMap(value.FixedValues)
return value
}
func CopyGameClientBridgeLifecycleProjectionDeclaration(value GameClientBridgeLifecycleProjectionDeclaration) GameClientBridgeLifecycleProjectionDeclaration {
value.Capabilities = CopyStringSlice(value.Capabilities)
value.ProcessStates = CopyStringSlice(value.ProcessStates)
value.Target = CopyGameClientBridgeBulkProjectionTargetDeclaration(value.Target)
return value
}
func CopyGameClientBridgeBulkProjectionTargetDeclaration(value GameClientBridgeBulkProjectionTargetDeclaration) GameClientBridgeBulkProjectionTargetDeclaration {
value.FixedValues = CopyStringMap(value.FixedValues)
if value.ActivityTarget != nil {
activity := CopyGameClientBridgeBulkActivityTargetDeclaration(*value.ActivityTarget)
value.ActivityTarget = &activity
}
return value
}
func CopyGameClientBridgeBulkActivityTargetDeclaration(value GameClientBridgeBulkActivityTargetDeclaration) GameClientBridgeBulkActivityTargetDeclaration {
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
value.RowMappings = CopyStringMap(value.RowMappings)
value.FixedValues = CopyStringMap(value.FixedValues)
return value
}
func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration {
value.StreamKeys = CopyStringSlice(value.StreamKeys)
value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...)
@@ -438,6 +521,7 @@ func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProje
func CopyGameClientBridgeLogProjectionTargetDeclaration(value GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclaration {
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
value.CaptureMappings = CopyStringMap(value.CaptureMappings)
value.HashMappings = CopyStringMap(value.HashMappings)
value.FixedValues = CopyStringMap(value.FixedValues)
return value
}
+10 -5
View File
@@ -4,24 +4,29 @@ import "testing"
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
manifest := GameClientBridgeManifest{
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql"}},
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql", Projections: []GameClientBridgeQueryProjectionDeclaration{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt"}}}},
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"},
Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, HashMappings: map[string]string{"networkCorrelation": "ip"}, 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"}, DataRefs: []string{"data.json"}}},
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclaration{{Key: "server.stop", Capabilities: []string{"process.stop"}, ProcessStates: []string{"stopped"}, Target: GameClientBridgeBulkProjectionTargetDeclaration{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ActivityTarget: &GameClientBridgeBulkActivityTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}}}}},
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}},
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
}
manifestCopy := CopyGameClientBridgeManifest(manifest)
manifestCopy.QueryTemplates[0].Key = "mutated"
manifestCopy.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
manifestCopy.LogProjections[0].StreamKeys[0] = "mutated"
manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
manifestCopy.LogProjections[0].Target.HashMappings["networkCorrelation"] = "mutated"
manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated"
manifestCopy.LifecycleProjections[0].Capabilities[0] = "mutated"
manifestCopy.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated"
manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated"
manifestCopy.DataPacks[0].DataRefs[0] = "mutated"
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || 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.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.LifecycleProjections[0].Capabilities[0] != "process.stop" || manifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
}
+128 -28
View File
@@ -279,18 +279,30 @@ 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"`
PollIntervalSeconds int `json:"pollIntervalSeconds"`
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"`
Projections []GameClientBridgeQueryProjectionDeclarationBody `json:"projections,omitempty"`
}
type GameClientBridgeQueryProjectionDeclarationBody struct {
Collection string `json:"collection"`
RowPath string `json:"rowPath"`
MatchField string `json:"matchField,omitempty"`
MatchValue string `json:"matchValue,omitempty"`
UpsertKeys []string `json:"upsertKeys"`
FieldMappings map[string]string `json:"fieldMappings,omitempty"`
FixedValues map[string]string `json:"fixedValues,omitempty"`
ObservedAtField string `json:"observedAtField,omitempty"`
}
type GameClientBridgeLogProjectionStepDeclarationBody struct {
@@ -301,6 +313,7 @@ type GameClientBridgeLogProjectionTargetDeclarationBody struct {
Collection string `json:"collection"`
UpsertKeys []string `json:"upsertKeys"`
CaptureMappings map[string]string `json:"captureMappings"`
HashMappings map[string]string `json:"hashMappings,omitempty"`
FixedValues map[string]string `json:"fixedValues,omitempty"`
ObservedAtField string `json:"observedAtField,omitempty"`
}
@@ -311,6 +324,30 @@ type GameClientBridgeLogProjectionPresenceDeclarationBody struct {
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
}
type GameClientBridgeLifecycleProjectionDeclarationBody struct {
Key string `json:"key"`
Capabilities []string `json:"capabilities"`
ProcessStates []string `json:"processStates,omitempty"`
Target GameClientBridgeBulkProjectionTargetBody `json:"target"`
}
type GameClientBridgeBulkProjectionTargetBody struct {
Collection string `json:"collection"`
MatchField string `json:"matchField"`
MatchValue string `json:"matchValue"`
FixedValues map[string]string `json:"fixedValues"`
ObservedAtField string `json:"observedAtField,omitempty"`
ActivityTarget *GameClientBridgeBulkActivityTargetBody `json:"activityTarget,omitempty"`
}
type GameClientBridgeBulkActivityTargetBody struct {
Collection string `json:"collection"`
UpsertKeys []string `json:"upsertKeys"`
RowMappings map[string]string `json:"rowMappings"`
FixedValues map[string]string `json:"fixedValues,omitempty"`
ObservedAtField string `json:"observedAtField,omitempty"`
}
type GameClientBridgeLogProjectionDeclarationBody struct {
Key string `json:"key"`
StreamKeys []string `json:"streamKeys"`
@@ -362,16 +399,17 @@ type GameClientBridgeCompanionDeclarationBody struct {
}
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"`
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
MaxCommands int `json:"maxCommands"`
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"`
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclarationBody `json:"lifecycleProjections,omitempty"`
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
MaxCommands int `json:"maxCommands"`
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
}
type GamePluginManifestBody struct {
ID string `json:"id"`
@@ -1242,12 +1280,16 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
}
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
for index, template := range body.QueryTemplates {
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}
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, Projections: gameClientBridgeQueryProjectionsToDomain(template.Projections)}
}
logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections))
for index, projection := range body.LogProjections {
logProjections[index] = gameClientBridgeLogProjectionToDomain(projection)
}
lifecycleProjections := make([]domain.GameClientBridgeLifecycleProjectionDeclaration, len(body.LifecycleProjections))
for index, projection := range body.LifecycleProjections {
lifecycleProjections[index] = gameClientBridgeLifecycleProjectionToDomain(projection)
}
dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks))
for index, dataPack := range body.DataPacks {
dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
@@ -1264,7 +1306,7 @@ 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, LogProjections: logProjections, DataPacks: dataPacks, 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, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
}
func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDeclarationBody) domain.GameClientBridgeLogProjectionDeclaration {
@@ -1291,8 +1333,35 @@ func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDe
}
}
func gameClientBridgeQueryProjectionsToDomain(values []GameClientBridgeQueryProjectionDeclarationBody) []domain.GameClientBridgeQueryProjectionDeclaration {
if values == nil {
return nil
}
out := make([]domain.GameClientBridgeQueryProjectionDeclaration, len(values))
for index, value := range values {
out[index] = domain.GameClientBridgeQueryProjectionDeclaration{Collection: value.Collection, RowPath: value.RowPath, MatchField: value.MatchField, MatchValue: value.MatchValue, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), FieldMappings: domain.CopyStringMap(value.FieldMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
}
return out
}
func gameClientBridgeLifecycleProjectionToDomain(value GameClientBridgeLifecycleProjectionDeclarationBody) domain.GameClientBridgeLifecycleProjectionDeclaration {
return domain.GameClientBridgeLifecycleProjectionDeclaration{Key: value.Key, Capabilities: domain.CopyStringSlice(value.Capabilities), ProcessStates: domain.CopyStringSlice(value.ProcessStates), Target: gameClientBridgeBulkProjectionTargetToDomain(value.Target)}
}
func gameClientBridgeBulkProjectionTargetToDomain(value GameClientBridgeBulkProjectionTargetBody) domain.GameClientBridgeBulkProjectionTargetDeclaration {
return domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: value.Collection, MatchField: value.MatchField, MatchValue: value.MatchValue, FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField, ActivityTarget: gameClientBridgeBulkActivityTargetToDomainPointer(value.ActivityTarget)}
}
func gameClientBridgeBulkActivityTargetToDomainPointer(value *GameClientBridgeBulkActivityTargetBody) *domain.GameClientBridgeBulkActivityTargetDeclaration {
if value == nil {
return nil
}
target := domain.GameClientBridgeBulkActivityTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), RowMappings: domain.CopyStringMap(value.RowMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
return &target
}
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}
return domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
}
func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeLogProjectionTargetDeclarationBody) *domain.GameClientBridgeLogProjectionTargetDeclaration {
@@ -1710,12 +1779,16 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
}
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
for index, template := range value.QueryTemplates {
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}
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, Projections: gameClientBridgeQueryProjectionsFromDomain(template.Projections)}
}
logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections))
for index, projection := range value.LogProjections {
logProjections[index] = gameClientBridgeLogProjectionFromDomain(projection)
}
lifecycleProjections := make([]GameClientBridgeLifecycleProjectionDeclarationBody, len(value.LifecycleProjections))
for index, projection := range value.LifecycleProjections {
lifecycleProjections[index] = gameClientBridgeLifecycleProjectionFromDomain(projection)
}
dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks))
for index, dataPack := range value.DataPacks {
dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
@@ -1732,7 +1805,7 @@ 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, LogProjections: logProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
}
func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclarationBody {
@@ -1759,8 +1832,35 @@ func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogPro
}
}
func gameClientBridgeQueryProjectionsFromDomain(values []domain.GameClientBridgeQueryProjectionDeclaration) []GameClientBridgeQueryProjectionDeclarationBody {
if values == nil {
return nil
}
out := make([]GameClientBridgeQueryProjectionDeclarationBody, len(values))
for index, value := range values {
out[index] = GameClientBridgeQueryProjectionDeclarationBody{Collection: value.Collection, RowPath: value.RowPath, MatchField: value.MatchField, MatchValue: value.MatchValue, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), FieldMappings: domain.CopyStringMap(value.FieldMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
}
return out
}
func gameClientBridgeLifecycleProjectionFromDomain(value domain.GameClientBridgeLifecycleProjectionDeclaration) GameClientBridgeLifecycleProjectionDeclarationBody {
return GameClientBridgeLifecycleProjectionDeclarationBody{Key: value.Key, Capabilities: domain.CopyStringSlice(value.Capabilities), ProcessStates: domain.CopyStringSlice(value.ProcessStates), Target: gameClientBridgeBulkProjectionTargetFromDomain(value.Target)}
}
func gameClientBridgeBulkProjectionTargetFromDomain(value domain.GameClientBridgeBulkProjectionTargetDeclaration) GameClientBridgeBulkProjectionTargetBody {
return GameClientBridgeBulkProjectionTargetBody{Collection: value.Collection, MatchField: value.MatchField, MatchValue: value.MatchValue, FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField, ActivityTarget: gameClientBridgeBulkActivityTargetFromDomainPointer(value.ActivityTarget)}
}
func gameClientBridgeBulkActivityTargetFromDomainPointer(value *domain.GameClientBridgeBulkActivityTargetDeclaration) *GameClientBridgeBulkActivityTargetBody {
if value == nil {
return nil
}
target := GameClientBridgeBulkActivityTargetBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), RowMappings: domain.CopyStringMap(value.RowMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
return &target
}
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}
return GameClientBridgeLogProjectionTargetDeclarationBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
}
func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClientBridgeLogProjectionTargetDeclaration) *GameClientBridgeLogProjectionTargetDeclarationBody {
+24 -3
View File
@@ -137,12 +137,14 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
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, PollIntervalSeconds: 3,
Projections: []GameClientBridgeQueryProjectionDeclarationBody{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt"}},
}},
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"},
Target: GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, HashMappings: map[string]string{"networkCorrelation": "ip"}, 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"}}},
}},
LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclarationBody{{Key: "server.stop", Capabilities: []string{"process.stop"}, Target: GameClientBridgeBulkProjectionTargetBody{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ObservedAtField: "lastLogoutAt", ActivityTarget: &GameClientBridgeBulkActivityTargetBody{Collection: "activity", UpsertKeys: []string{"steamId", "observedAt"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}, ObservedAtField: "observedAt"}}}},
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}},
CommandRetentionSeconds: 86400,
MaxCommands: 1000,
@@ -150,14 +152,24 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
}
domainManifest := body.ToDomain()
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || 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 || len(domainManifest.QueryTemplates[0].Projections) != 1 || domainManifest.QueryTemplates[0].Projections[0].MatchValue != "player" || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || domainManifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || len(domainManifest.LifecycleProjections) != 1 || domainManifest.LifecycleProjections[0].Target.ActivityTarget.FixedValues["eventType"] != "logout" || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
}
domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
if body.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" {
t.Fatal("query projection target aliases request DTO data")
}
domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "steamId"
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.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated"
if body.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" {
t.Fatal("lifecycle projection target aliases request DTO data")
}
domainManifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["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")
@@ -170,10 +182,19 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
domainManifest.DataPacks[0].DataRefs[0] = "data/items.json"
response := gameClientBridgeManifestFromDomain(domainManifest)
response.QueryTemplates[0].Projections[0].FixedValues["source"] = "mutated"
if domainManifest.QueryTemplates[0].Projections[0].FixedValues["source"] != "sqlite" {
t.Fatal("query projection target aliases domain data")
}
response.QueryTemplates[0].Projections[0].FixedValues["source"] = "sqlite"
response.LogProjections[0].Target.FixedValues["source"] = "mutated"
if domainManifest.LogProjections[0].Target.FixedValues["source"] != "stdout" {
t.Fatal("log projection target aliases domain data")
}
response.LifecycleProjections[0].Target.FixedValues["online"] = "mutated"
if domainManifest.LifecycleProjections[0].Target.FixedValues["online"] != "false" {
t.Fatal("lifecycle 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")
@@ -187,7 +208,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", "pollIntervalSeconds"}
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "maxRows", "timeoutSeconds", "pollIntervalSeconds", "projections"}
if len(projection) != len(expectedFields) {
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
}
+14 -1
View File
@@ -50,7 +50,17 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
}
job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
if !ok {
return emptyJobClaim(claim.RunEndpointID, stamp), nil
if err := svc.scheduleDuePluginQueryProjectionJobs(claim, stamp); err != nil {
return domain.RunJobClaimResult{}, err
}
jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
if err != nil {
return domain.RunJobClaimResult{}, err
}
job, ok = firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
if !ok {
return emptyJobClaim(claim.RunEndpointID, stamp), nil
}
}
leaseToken, err := randomToken()
@@ -342,6 +352,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.projectPluginQueryJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
}
+38
View File
@@ -156,3 +156,41 @@ func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) {
t.Fatalf("automatic projection query persisted jobs=%+v err=%v", jobs, err)
}
}
func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) {
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{
Collection: "scum_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"},
FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt",
}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("enable query projection polling: %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("projection query was not scheduled: %+v err=%v", claim, err)
}
if claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["sqlRef"] != "sql/players.by-id.sql" {
t.Fatalf("scheduled projection query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs)
}
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"kind":"player","steamId":"steam-1","displayName":"Ada"},{"kind":"vehicle","steamId":"vehicle-1","displayName":"Truck"}]}`}})
if err != nil {
t.Fatalf("complete projection query job: %v", err)
}
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" || items[0].Value["source"] != "sqlite" || items[0].Value["sampledAt"] == nil {
t.Fatalf("declared projection did not write scoped plugin data=%+v err=%v", items, err)
}
second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || second.HasJob {
t.Fatalf("fresh projection poll should not reschedule immediately: %+v err=%v", second, err)
}
}
@@ -0,0 +1,87 @@
package service
import (
"fmt"
"strings"
"time"
"browser.local/platform/domain"
)
func (svc *CoreService) projectPluginLifecycleState(instance domain.ServerInstance, plugin domain.GamePlugin, capability string, result domain.JobExecutionResult, stamp time.Time) error {
if len(plugin.GameClientBridge.LifecycleProjections) == 0 {
return nil
}
for _, projection := range plugin.GameClientBridge.LifecycleProjections {
if !containsString(projection.Capabilities, capability) || len(projection.Target.FixedValues) == 0 {
continue
}
if len(projection.ProcessStates) > 0 && !containsString(projection.ProcessStates, strings.TrimSpace(result.ProcessState)) {
continue
}
if err := svc.applyPluginBulkProjection(instance, plugin, projection.Target, stamp); err != nil {
return err
}
}
return nil
}
func (svc *CoreService) applyPluginBulkProjection(instance domain.ServerInstance, plugin domain.GamePlugin, target domain.GameClientBridgeBulkProjectionTargetDeclaration, stamp time.Time) error {
rows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: target.Collection})
if err != nil {
return err
}
mutations := make([]domain.PluginDataMutation, 0, len(rows))
activityMutations := []domain.PluginDataMutation{}
for _, row := range rows {
if strings.TrimSpace(fmt.Sprint(row.Value[target.MatchField])) != target.MatchValue {
continue
}
value := mergePluginDataValues(row.Value, pluginBulkProjectionValues(target.FixedValues, stamp, row.Value, target.ObservedAtField))
mutations = append(mutations, domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: row.Key, Value: value})
if target.ActivityTarget != nil {
activity := pluginBulkActivityValue(*target.ActivityTarget, row.Value, stamp)
activityKey, keyErr := pluginDataRowKey(activity, target.ActivityTarget.UpsertKeys)
if keyErr != nil {
return keyErr
}
activityMutations = append(activityMutations, domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: activityKey, Value: activity})
}
}
if len(mutations) > 0 {
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: target.Collection, Mutations: mutations}); err != nil {
return err
}
}
if len(activityMutations) > 0 && target.ActivityTarget != nil {
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: target.ActivityTarget.Collection, Mutations: activityMutations}); err != nil {
return err
}
}
return nil
}
func pluginBulkProjectionValues(fixedValues map[string]string, stamp time.Time, row map[string]any, observedAtField string) map[string]any {
value := make(map[string]any, len(fixedValues)+1)
for key, fixed := range fixedValues {
value[key] = renderQueryProjectionTemplate(fixed, row)
}
if observedAtField != "" {
value[observedAtField] = stamp.UTC().Format(time.RFC3339Nano)
}
return value
}
func pluginBulkActivityValue(target domain.GameClientBridgeBulkActivityTargetDeclaration, row map[string]any, stamp time.Time) map[string]any {
value := make(map[string]any, len(target.RowMappings)+len(target.FixedValues)+1)
for destination, source := range target.RowMappings {
value[destination] = row[source]
}
for key, fixed := range target.FixedValues {
value[key] = renderQueryProjectionTemplate(fixed, row)
}
if target.ObservedAtField != "" {
value[target.ObservedAtField] = stamp.UTC().Format(time.RFC3339Nano)
}
return value
}
+15 -5
View File
@@ -1,6 +1,8 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"regexp"
@@ -18,7 +20,7 @@ type pluginLogSequenceState struct {
}
func (svc *CoreService) projectPluginLogBatch(stream domain.LogStream, entries []domain.LogEntry) error {
if stream.Source != domain.LogStreamSourceProcess || len(entries) == 0 {
if len(entries) == 0 || (stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram) {
return nil
}
instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID)
@@ -160,7 +162,7 @@ func logCorrelationKey(captures map[string]string, fields []string) string {
}
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)
value := pluginLogProjectionValue(instance.ID, projection.Target, captures, observedAt)
key, err := pluginDataRowKey(value, projection.Target.UpsertKeys)
if err != nil {
return err
@@ -191,7 +193,7 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance,
return err
}
if projection.Presence != nil && projection.Presence.ActivityTarget != nil {
activity := pluginLogProjectionValue(*projection.Presence.ActivityTarget, captures, observedAt)
activity := pluginLogProjectionValue(instance.ID, *projection.Presence.ActivityTarget, captures, observedAt)
activityKey, keyErr := pluginDataRowKey(activity, projection.Presence.ActivityTarget.UpsertKeys)
if keyErr != nil {
return keyErr
@@ -203,11 +205,14 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance,
return nil
}
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)
func pluginLogProjectionValue(serverID string, target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any {
value := make(map[string]any, len(target.CaptureMappings)+len(target.HashMappings)+len(target.FixedValues)+1)
for destination, capture := range target.CaptureMappings {
value[destination] = captures[capture]
}
for destination, capture := range target.HashMappings {
value[destination] = logProjectionCorrelationHash(serverID, captures[capture])
}
for key, fixed := range target.FixedValues {
value[key] = renderLogProjectionTemplate(fixed, captures)
}
@@ -217,6 +222,11 @@ func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetD
return value
}
func logProjectionCorrelationHash(serverID, value string) string {
digest := sha256.Sum256([]byte(serverID + "\x00" + value))
return hex.EncodeToString(digest[:])
}
func renderLogProjectionTemplate(template string, captures map[string]string) string {
result := template
for key, value := range captures {
@@ -71,6 +71,89 @@ func TestDurableStdoutProjectionCreatesUsersAndSuppressesRapidDuplicates(t *test
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 0)
}
func TestLifecycleProjectionMarksOnlineUsersOffline(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.GameClientBridge.LifecycleProjections = []domain.GameClientBridgeLifecycleProjectionDeclaration{{
Key: "server.stop", Capabilities: []string{domain.LifecycleCapabilityStop}, ProcessStates: []string{"stopped"},
Target: domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: "scum_users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false", "status": "offline", "logoutReason": "server-stop"}, ObservedAtField: "lastLogoutAt", ActivityTarget: &domain.GameClientBridgeBulkActivityTargetDeclaration{Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt", "eventType"}, RowMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "logout", "reason": "server-stop"}, ObservedAtField: "observedAt"}},
}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update lifecycle projection plugin: %v", err)
}
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-lifecycle-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM lifecycle", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Mutations: []domain.PluginDataMutation{
{Operation: domain.PluginDataMutationPut, Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "displayName": "Ada", "online": "true"}},
{Operation: domain.PluginDataMutationPut, Key: "steam-2", Value: map[string]any{"steamId": "steam-2", "displayName": "Lin", "online": "false"}},
}}); err != nil {
t.Fatalf("seed plugin users: %v", err)
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.LifecycleCapabilityStop)
helloRequest.CapabilityReport.Fingerprint = "cap-lifecycle-projection"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register Run: %v", err)
}
_, err = svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityStop, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}})
if err != nil {
t.Fatalf("report lifecycle stop: %v", err)
}
users, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
if err != nil || len(users) != 2 {
t.Fatalf("list lifecycle users=%+v err=%v", users, err)
}
for _, user := range users {
if user.Key == "steam-1" && (user.Value["online"] != "false" || user.Value["logoutReason"] != "server-stop" || user.Value["lastLogoutAt"] == nil) {
t.Fatalf("online user was not logged out: %+v", user)
}
if user.Key == "steam-2" && user.Value["logoutReason"] != nil {
t.Fatalf("offline user should not receive duplicate logout: %+v", user)
}
}
activity, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_activity_events"})
if err != nil || len(activity) != 1 || activity[0].Value["steamId"] != "steam-1" || activity[0].Value["eventType"] != "logout" {
t.Fatalf("lifecycle logout activity not projected: %+v err=%v", activity, err)
}
}
func TestLifecycleRestartReportMarksOnlineUsersOffline(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.GameClientBridge.LifecycleProjections = []domain.GameClientBridgeLifecycleProjectionDeclaration{{
Key: "server.restart", Capabilities: []string{"process.restart"},
Target: domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: "scum_users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false", "status": "offline", "logoutReason": "server-stop"}, ObservedAtField: "lastLogoutAt"},
}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update restart projection plugin: %v", err)
}
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-restart-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM restart", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "displayName": "Ada", "online": true}}}}); err != nil {
t.Fatalf("seed plugin users: %v", err)
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.restart")
helloRequest.CapabilityReport.Fingerprint = "cap-restart-lifecycle-projection"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register Run: %v", err)
}
report, err := svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, ServerInstanceID: instance.ID, Capability: "process.restart", State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}})
if err != nil || report.ProjectedState != domain.ServerInstanceStateRunning {
t.Fatalf("report lifecycle restart: report=%+v err=%v", report, err)
}
users, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
if err != nil || len(users) != 1 || users[0].Value["online"] != "false" || users[0].Value["logoutReason"] != "server-stop" || users[0].Value["lastLogoutAt"] == nil {
t.Fatalf("restart did not log out online users: %+v err=%v", users, err)
}
}
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))
+215
View File
@@ -0,0 +1,215 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
"browser.local/platform/domain"
)
func (svc *CoreService) scheduleDuePluginQueryProjectionJobs(claim domain.RunJobClaim, stamp time.Time) error {
if !containsString(claim.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
return nil
}
endpoint, err := svc.store.RunEndpoints().Get(claim.RunEndpointID)
if err != nil || !containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
return err
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
if err != nil {
return err
}
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: claim.RunEndpointID})
if err != nil {
return err
}
for _, instance := range instances {
if instance.State != domain.ServerInstanceStateRunning || strings.TrimSpace(instance.PluginID) == "" {
continue
}
plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID)
if pluginErr != nil || !plugin.Permissions.RemoteAccess || !containsString(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) || !containsString(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
continue
}
for _, template := range plugin.GameClientBridge.QueryTemplates {
if template.PollIntervalSeconds <= 0 || len(template.Projections) == 0 || !pluginQueryTemplateTransportReady(plugin, endpoint, template) {
continue
}
interval := time.Duration(template.PollIntervalSeconds) * time.Second
prefix := pluginQueryPollPrefix(instance.ID, plugin.ID, template.Key)
if pluginQueryPollActiveOrFresh(jobs, prefix, stamp, interval) {
continue
}
bucket := stamp.Unix() / int64(template.PollIntervalSeconds)
idempotencyKey := fmt.Sprintf("%s%d", prefix, bucket)
inputs := map[string]string{"templateKey": template.Key, "maxRows": fmt.Sprint(template.MaxRows)}
if template.SQLRef != "" {
inputs["sqlRef"] = template.SQLRef
}
job := domain.Job{
ID: jobIDFromParts("job-plugin-query-poll", instance.ID, idempotencyKey),
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery,
TargetKey: template.TargetKey,
InputRef: fmt.Sprintf("input://plugin-query-poll/%s/%s", instance.ID, template.Key),
IdempotencyKey: idempotencyKey,
Progress: domain.JobProgress{Percent: 0, Message: "plugin query projection poll queued"},
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 2, MaxBackoffSeconds: 2},
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: string(domain.RemoteAdapterDatabase), TimeoutSeconds: template.TimeoutSeconds, PluginID: plugin.ID, Inputs: inputs},
}
if _, createErr := svc.CreateJob(job); createErr != nil {
return createErr
}
}
}
return nil
}
func pluginQueryTemplateTransportReady(plugin domain.GamePlugin, endpoint domain.RunEndpoint, template domain.GameClientBridgeQueryTemplateDeclaration) bool {
if template.Engine != "sqlite" || template.TransportKey == "" || template.TargetKey == "" {
return false
}
for _, profile := range plugin.RuntimeProfiles.TransportProfiles {
if profile.Key == template.TransportKey && profile.Kind == "sqlite" && profile.TargetKey == template.TargetKey && containsString(profile.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
return containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
}
}
return false
}
func pluginQueryPollPrefix(serverID, pluginID, templateKey string) string {
return fmt.Sprintf("plugin-query-poll:%s:%s:%s:", serverID, pluginID, templateKey)
}
func pluginQueryPollActiveOrFresh(jobs []domain.Job, prefix string, stamp time.Time, interval time.Duration) bool {
for _, job := range jobs {
if !strings.HasPrefix(job.IdempotencyKey, prefix) {
continue
}
if !isTerminalJobState(job.State) {
return true
}
freshAt := job.TerminalAt
if freshAt.IsZero() {
freshAt = job.UpdatedAt
}
if !freshAt.IsZero() && stamp.Sub(freshAt) < interval {
return true
}
}
return false
}
func (svc *CoreService) projectPluginQueryJobResult(job domain.Job, stamp time.Time) error {
if job.State != domain.JobStateSucceeded || job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.ExecutionResult.Kind != "sqlite.query" {
return nil
}
templateKey := strings.TrimSpace(job.ExecutionInput.Inputs["templateKey"])
if templateKey == "" || strings.TrimSpace(job.ServerInstanceID) == "" {
return nil
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil {
return err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return err
}
template, ok := pluginQueryTemplateByKey(plugin, templateKey)
if !ok || len(template.Projections) == 0 {
return nil
}
rows, err := pluginQueryRows(job.ExecutionResult.Content)
if err != nil {
return err
}
mutationsByCollection := map[string]map[string]domain.PluginDataMutation{}
for _, projection := range template.Projections {
if projection.RowPath != "rows" {
continue
}
collectionMutations := mutationsByCollection[projection.Collection]
if collectionMutations == nil {
collectionMutations = map[string]domain.PluginDataMutation{}
mutationsByCollection[projection.Collection] = collectionMutations
}
for _, row := range rows {
if projection.MatchField != "" && strings.TrimSpace(fmt.Sprint(row[projection.MatchField])) != projection.MatchValue {
continue
}
value := pluginQueryProjectionValue(projection, row, stamp)
key, keyErr := pluginDataRowKey(value, projection.UpsertKeys)
if keyErr != nil {
return keyErr
}
collectionMutations[key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value}
}
}
for collection, keyed := range mutationsByCollection {
mutations := make([]domain.PluginDataMutation, 0, len(keyed))
for _, mutation := range keyed {
mutations = append(mutations, mutation)
}
if len(mutations) == 0 {
continue
}
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: collection, Mutations: mutations}); err != nil {
return err
}
}
return nil
}
func pluginQueryTemplateByKey(plugin domain.GamePlugin, templateKey string) (domain.GameClientBridgeQueryTemplateDeclaration, bool) {
for _, template := range plugin.GameClientBridge.QueryTemplates {
if template.Key == templateKey {
return template, true
}
}
return domain.GameClientBridgeQueryTemplateDeclaration{}, false
}
func pluginQueryRows(content string) ([]map[string]any, error) {
var payload struct {
Rows []map[string]any `json:"rows"`
}
decoder := json.NewDecoder(bytes.NewBufferString(content))
decoder.UseNumber()
if err := decoder.Decode(&payload); err != nil {
return nil, validationError("sqlite query result content is not a row payload")
}
return payload.Rows, nil
}
func pluginQueryProjectionValue(projection domain.GameClientBridgeQueryProjectionDeclaration, row map[string]any, observedAt time.Time) map[string]any {
value := map[string]any{}
if len(projection.FieldMappings) == 0 {
for key, item := range row {
value[key] = item
}
} else {
for destination, source := range projection.FieldMappings {
value[destination] = row[source]
}
}
for key, fixed := range projection.FixedValues {
value[key] = renderQueryProjectionTemplate(fixed, row)
}
if projection.ObservedAtField != "" {
value[projection.ObservedAtField] = observedAt.UTC().Format(time.RFC3339Nano)
}
return value
}
func renderQueryProjectionTemplate(template string, row map[string]any) string {
result := template
for key, value := range row {
result = strings.ReplaceAll(result, "{{"+key+"}}", fmt.Sprint(value))
}
return result
}
@@ -29,7 +29,8 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do
stamp := svc.now()
nextState, projected := lifecycleProjectedState(report.Capability, report.State, report.ExecutionResult)
if lifecycleObservationIsStale(instance, report) {
staleObservation := lifecycleObservationIsStale(instance, report)
if !projected || staleObservation {
projected = false
nextState = instance.State
}
@@ -52,6 +53,15 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do
}
svc.publishLogProcessState(instance)
}
if report.State == domain.JobStateSucceeded && !staleObservation {
plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID)
if pluginErr != nil {
return domain.RunLifecycleReportResult{}, pluginErr
}
if err := svc.projectPluginLifecycleState(instance, plugin, report.Capability, report.ExecutionResult, stamp); err != nil {
return domain.RunLifecycleReportResult{}, err
}
}
return domain.CopyRunLifecycleReportResult(domain.RunLifecycleReportResult{Accepted: true, RunEndpointID: report.RunEndpointID, ServerInstanceID: report.ServerInstanceID, ProjectedState: nextState, ServerTime: stamp}), nil
}
@@ -97,6 +107,9 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
}
nextState, ok := lifecycleProjectedState(job.Capability, job.State, job.ExecutionResult)
if !ok || job.ServerInstanceID == "" {
if job.State == domain.JobStateSucceeded && job.ServerInstanceID != "" {
return svc.projectPluginLifecycleStateForJob(job, stamp)
}
return nil
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
@@ -112,9 +125,30 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
return err
}
svc.publishLogProcessState(instance)
if job.State == domain.JobStateSucceeded {
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return err
}
if err := svc.projectPluginLifecycleState(instance, plugin, job.Capability, job.ExecutionResult, stamp); err != nil {
return err
}
}
return nil
}
func (svc *CoreService) projectPluginLifecycleStateForJob(job domain.Job, stamp time.Time) error {
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil {
return err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return err
}
return svc.projectPluginLifecycleState(instance, plugin, job.Capability, job.ExecutionResult, stamp)
}
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" {
return nil
@@ -36,7 +36,7 @@ func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest,
func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) {
bridge, profiles := validGameClientBridgeCompanionManifest()
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, profiles); len(violations) != 0 {
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, nil, profiles); len(violations) != 0 {
t.Fatalf("expected valid companion declaration, got %v", violations)
}
@@ -74,7 +74,7 @@ func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
candidateBridge, candidateProfiles := validGameClientBridgeCompanionManifest()
test.mutate(&candidateBridge, &candidateProfiles)
violations := validateGameClientBridgeManifest("gameClientBridge", candidateBridge, nil, nil, candidateProfiles)
violations := validateGameClientBridgeManifest("gameClientBridge", candidateBridge, nil, nil, nil, candidateProfiles)
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
t.Fatalf("expected %q violation, got %v", test.expected, violations)
}
@@ -24,7 +24,7 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
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 {
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, []string{"server.game-client.command"}, nil, nil, profiles); len(violations) != 0 {
t.Fatalf("expected repeated named captures across steps to validate, got %v", violations)
}
@@ -52,7 +52,7 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
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)
violations := validateGameClientBridgeManifest("gameClientBridge", candidate, []string{"server.game-client.command"}, nil, nil, candidateProfiles)
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
t.Fatalf("expected %q violation, got %v", test.expected, violations)
}
+2 -2
View File
@@ -65,7 +65,7 @@ func ValidateRunLifecycleReport(report domain.RunLifecycleReport) error {
violations = appendRequired(violations, "serverInstanceId", report.ServerInstanceID)
violations = appendRequired(violations, "capability", report.Capability)
if !validLifecycleReportCapability(report.Capability) {
violations = append(violations, "capability must be process.install, process.start, process.stop, or process.status")
violations = append(violations, "capability must be process.install, process.start, process.stop, process.restart, or process.status")
}
if !validTerminalJobState(report.State) {
violations = append(violations, "state must be succeeded, failed, or cancelled")
@@ -210,7 +210,7 @@ func validTerminalJobState(state domain.JobState) bool {
func validLifecycleReportCapability(capability string) bool {
switch capability {
case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus:
case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "process.restart", domain.LifecycleCapabilityStatus:
return true
default:
return false
+205 -4
View File
@@ -167,7 +167,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
}
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.RequiredRunCapabilities, plugin.Pages, plugin.RuntimeProfiles)...)
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
@@ -238,7 +238,7 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
}
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...)
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Capabilities, manifest.Pages, manifest.RuntimeProfiles)...)
violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
violations = append(violations, validateRegistrationAssetCoverage(registration.Manifest.AssetFiles, registration.AssetFiles)...)
@@ -467,9 +467,9 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
return finish(violations)
}
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, runCapabilities []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.LogProjections) == 0 && len(bridge.DataPacks) == 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.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
return nil
}
var violations []string
@@ -610,6 +610,12 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
if template.SQLRef != "" && !safeRelativeSQLRef(template.SQLRef) {
violations = append(violations, prefix+".sqlRef must reference a package-relative SQL asset")
}
if len(template.Projections) > 4 {
violations = append(violations, prefix+".projections must not exceed 4 targets")
}
for projectionIndex, projection := range template.Projections {
violations = append(violations, validateGameClientBridgeQueryProjection(fmt.Sprintf("%s.projections[%d]", prefix, projectionIndex), projection)...)
}
transport, exists := transports[template.TransportKey]
if !exists {
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
@@ -634,6 +640,18 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
logProjectionKeys[projection.Key] = struct{}{}
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection)...)
}
lifecycleProjectionKeys := map[string]struct{}{}
for index, projection := range bridge.LifecycleProjections {
prefix := fmt.Sprintf("%s.lifecycleProjections[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(projection.Key) {
violations = append(violations, prefix+".key is invalid")
}
if _, exists := lifecycleProjectionKeys[projection.Key]; exists {
violations = append(violations, prefix+".key is duplicated")
}
lifecycleProjectionKeys[projection.Key] = struct{}{}
violations = append(violations, validateGameClientBridgeLifecycleProjection(prefix, projection, runCapabilities)...)
}
dataPackKeys := map[string]struct{}{}
for index, dataPack := range bridge.DataPacks {
prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index)
@@ -753,6 +771,174 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
return violations
}
func validateGameClientBridgeQueryProjection(prefix string, projection domain.GameClientBridgeQueryProjectionDeclaration) []string {
var violations []string
if !gameClientBridgeCollectionPattern.MatchString(projection.Collection) {
violations = append(violations, prefix+".collection is invalid")
}
if projection.RowPath != "rows" {
violations = append(violations, prefix+".rowPath must be rows")
}
if projection.MatchField != "" && !gameClientBridgeFieldPattern.MatchString(projection.MatchField) {
violations = append(violations, prefix+".matchField is invalid")
}
if projection.MatchField == "" && projection.MatchValue != "" || projection.MatchField != "" && strings.TrimSpace(projection.MatchValue) == "" {
violations = append(violations, prefix+".matchField and matchValue must be declared together")
}
if len([]rune(projection.MatchValue)) > 120 {
violations = append(violations, prefix+".matchValue is too long")
}
if len(projection.UpsertKeys) < 1 || len(projection.UpsertKeys) > 8 {
violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields")
}
projectedFields := map[string]struct{}{}
for destination, source := range projection.FieldMappings {
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeFieldPattern.MatchString(source) {
violations = append(violations, prefix+".fieldMappings contains an invalid field")
}
projectedFields[destination] = struct{}{}
}
if len(projection.FixedValues) > 64 {
violations = append(violations, prefix+".fixedValues contains too many fields")
}
for destination, value := range projection.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 projection.ObservedAtField != "" {
if !gameClientBridgeFieldPattern.MatchString(projection.ObservedAtField) {
violations = append(violations, prefix+".observedAtField is invalid")
}
if _, exists := projectedFields[projection.ObservedAtField]; exists {
violations = append(violations, prefix+" declares field "+projection.ObservedAtField+" more than once")
}
projectedFields[projection.ObservedAtField] = struct{}{}
}
for _, key := range projection.UpsertKeys {
if !gameClientBridgeFieldPattern.MatchString(key) {
violations = append(violations, prefix+".upsertKeys contains an invalid field")
}
if len(projection.FieldMappings) > 0 {
if _, exists := projectedFields[key]; !exists {
violations = append(violations, prefix+".upsertKeys field "+key+" is not projected")
}
}
}
violations = append(violations, duplicateViolations(prefix+".upsertKeys", projection.UpsertKeys)...)
return violations
}
func validateGameClientBridgeLifecycleProjection(prefix string, projection domain.GameClientBridgeLifecycleProjectionDeclaration, pluginRunCapabilities []string) []string {
var violations []string
if len(projection.Capabilities) < 1 || len(projection.Capabilities) > 16 {
violations = append(violations, prefix+".capabilities must contain between 1 and 16 values")
}
for _, capability := range projection.Capabilities {
if !validPluginRunCapability(capability) {
violations = append(violations, prefix+".capabilities contains an invalid capability")
}
if !containsString(pluginRunCapabilities, capability) {
violations = append(violations, prefix+".capabilities must be declared by the plugin")
}
}
violations = append(violations, duplicateViolations(prefix+".capabilities", projection.Capabilities)...)
if len(projection.ProcessStates) > 8 {
violations = append(violations, prefix+".processStates must not exceed 8")
}
for _, state := range projection.ProcessStates {
if !oneOf(state, "running", "stopped", "not-started", "exited") {
violations = append(violations, prefix+".processStates contains an invalid process state")
}
}
violations = append(violations, duplicateViolations(prefix+".processStates", projection.ProcessStates)...)
violations = append(violations, validateGameClientBridgeBulkProjectionTarget(prefix+".target", projection.Target)...)
return violations
}
func validateGameClientBridgeBulkProjectionTarget(prefix string, target domain.GameClientBridgeBulkProjectionTargetDeclaration) []string {
var violations []string
if !gameClientBridgeCollectionPattern.MatchString(target.Collection) {
violations = append(violations, prefix+".collection is invalid")
}
if !gameClientBridgeFieldPattern.MatchString(target.MatchField) {
violations = append(violations, prefix+".matchField is invalid")
}
if strings.TrimSpace(target.MatchValue) == "" || len([]rune(target.MatchValue)) > 120 {
violations = append(violations, prefix+".matchValue is invalid")
}
if len(target.FixedValues) < 1 || len(target.FixedValues) > 64 {
violations = append(violations, prefix+".fixedValues must contain between 1 and 64 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 target.ObservedAtField != "" && !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) {
violations = append(violations, prefix+".observedAtField is invalid")
}
if target.ActivityTarget != nil {
violations = append(violations, validateGameClientBridgeBulkActivityTarget(prefix+".activityTarget", *target.ActivityTarget)...)
}
return violations
}
func validateGameClientBridgeBulkActivityTarget(prefix string, target domain.GameClientBridgeBulkActivityTargetDeclaration) []string {
var violations []string
if !gameClientBridgeCollectionPattern.MatchString(target.Collection) {
violations = append(violations, prefix+".collection is invalid")
}
if len(target.RowMappings) < 1 || len(target.RowMappings) > 64 {
violations = append(violations, prefix+".rowMappings must contain between 1 and 64 mappings")
}
projectedFields := map[string]struct{}{}
for destination, source := range target.RowMappings {
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeFieldPattern.MatchString(source) {
violations = append(violations, prefix+".rowMappings contains an invalid field")
}
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")
}
projectedFields[target.ObservedAtField] = struct{}{}
}
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 _, exists := projectedFields[key]; !exists {
violations = append(violations, prefix+".upsertKeys field "+key+" is not projected")
}
}
violations = append(violations, duplicateViolations(prefix+".upsertKeys", target.UpsertKeys)...)
return violations
}
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration) []string {
var violations []string
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
@@ -852,6 +1038,21 @@ func validateGameClientBridgeLogProjectionTarget(prefix string, target domain.Ga
}
projectedFields[destination] = struct{}{}
}
if len(target.HashMappings) > 64 {
violations = append(violations, prefix+".hashMappings contains too many fields")
}
for destination, capture := range target.HashMappings {
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) {
violations = append(violations, prefix+".hashMappings contains an invalid field or capture")
}
if _, exists := captures[capture]; !exists {
violations = append(violations, prefix+".hashMappings references undeclared capture "+capture)
}
if _, exists := projectedFields[destination]; exists {
violations = append(violations, prefix+" declares field "+destination+" more than once")
}
projectedFields[destination] = struct{}{}
}
if len(target.FixedValues) > 64 {
violations = append(violations, prefix+".fixedValues contains too many fields")
}