Align runtime profiles with plugin-owned records
This commit is contained in:
@@ -4,29 +4,19 @@ import "testing"
|
||||
|
||||
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
|
||||
manifest := GameClientBridgeManifest{
|
||||
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", MergeExisting: true}}}},
|
||||
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"}, 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"}}},
|
||||
}},
|
||||
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", MergeExisting: true}}}},
|
||||
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"}}},
|
||||
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", 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.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || !manifest.QueryTemplates[0].Projections[0].MergeExisting || 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" {
|
||||
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || !manifest.QueryTemplates[0].Projections[0].MergeExisting || manifest.LifecycleProjections[0].Capabilities[0] != "process.stop" || manifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" || 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -442,26 +442,6 @@ type RuntimeLogSource struct {
|
||||
RetentionDays int
|
||||
}
|
||||
|
||||
type RuntimeLogEventSeverity string
|
||||
|
||||
const (
|
||||
RuntimeLogEventSeverityInfo RuntimeLogEventSeverity = "info"
|
||||
RuntimeLogEventSeverityNotice RuntimeLogEventSeverity = "notice"
|
||||
RuntimeLogEventSeverityWarning RuntimeLogEventSeverity = "warning"
|
||||
RuntimeLogEventSeverityCritical RuntimeLogEventSeverity = "critical"
|
||||
)
|
||||
|
||||
type RuntimeLogEvent struct {
|
||||
Key string
|
||||
Title string
|
||||
SourceKey string
|
||||
EventType string
|
||||
Permission string
|
||||
SchemaRef string
|
||||
RetentionDays int
|
||||
Severity RuntimeLogEventSeverity
|
||||
}
|
||||
|
||||
type RuntimeTransportProfile struct {
|
||||
Key string
|
||||
Kind string
|
||||
@@ -469,6 +449,20 @@ type RuntimeTransportProfile struct {
|
||||
Capabilities []string
|
||||
}
|
||||
|
||||
// RuntimeDataTarget declares a bounded plugin-owned local data snapshot that
|
||||
// Run may materialize before a typed query. It never carries a host path.
|
||||
type RuntimeDataTarget struct {
|
||||
Key string
|
||||
Kind string
|
||||
TransportKey string
|
||||
SourceRootKey string
|
||||
SourcePath string
|
||||
WorkspaceKey string
|
||||
RefreshPolicy string
|
||||
MaxBytes int64
|
||||
Platforms []string
|
||||
}
|
||||
|
||||
type RuntimeClientManagerProfile struct {
|
||||
Key string
|
||||
DisplayName string
|
||||
@@ -597,8 +591,8 @@ type GamePluginRuntimeProfiles struct {
|
||||
InstallPlans []RuntimeInstallPlan
|
||||
ServerDeployments []RuntimeServerDeploymentProfile
|
||||
LogSources []RuntimeLogSource
|
||||
LogEvents []RuntimeLogEvent
|
||||
TransportProfiles []RuntimeTransportProfile
|
||||
DataTargets []RuntimeDataTarget
|
||||
ClientManagers []RuntimeClientManagerProfile
|
||||
DLLExtensions []RuntimeDLLExtensionProfile
|
||||
}
|
||||
@@ -1926,11 +1920,14 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
|
||||
profiles.ServerDeployments[i] = CopyRuntimeServerDeploymentProfile(profiles.ServerDeployments[i])
|
||||
}
|
||||
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
|
||||
profiles.LogEvents = append([]RuntimeLogEvent(nil), profiles.LogEvents...)
|
||||
profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...)
|
||||
for i := range profiles.TransportProfiles {
|
||||
profiles.TransportProfiles[i].Capabilities = CopyStringSlice(profiles.TransportProfiles[i].Capabilities)
|
||||
}
|
||||
profiles.DataTargets = append([]RuntimeDataTarget(nil), profiles.DataTargets...)
|
||||
for i := range profiles.DataTargets {
|
||||
profiles.DataTargets[i].Platforms = CopyStringSlice(profiles.DataTargets[i].Platforms)
|
||||
}
|
||||
profiles.ClientManagers = append([]RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
|
||||
for i := range profiles.ClientManagers {
|
||||
profiles.ClientManagers[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.ClientManagers[i].SupportedTargets...)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCopyGamePluginRuntimeProfilesCopiesLogEvents(t *testing.T) {
|
||||
profiles := GamePluginRuntimeProfiles{
|
||||
LogEvents: []RuntimeLogEvent{{
|
||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||
}},
|
||||
}
|
||||
|
||||
copied := CopyGamePluginRuntimeProfiles(profiles)
|
||||
copied.LogEvents[0].Title = "Mutated"
|
||||
|
||||
if profiles.LogEvents[0].Title != "Chat message" {
|
||||
t.Fatalf("expected log event declarations to be copied, got %+v", profiles.LogEvents)
|
||||
}
|
||||
}
|
||||
@@ -139,20 +139,15 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
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", MergeExisting: true}},
|
||||
}},
|
||||
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"}, 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"}}},
|
||||
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}},
|
||||
CommandRetentionSeconds: 86400,
|
||||
MaxCommands: 1000,
|
||||
Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||
}
|
||||
|
||||
domainManifest := body.ToDomain()
|
||||
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" || !domainManifest.QueryTemplates[0].Projections[0].MergeExisting || 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" {
|
||||
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" || !domainManifest.QueryTemplates[0].Projections[0].MergeExisting || 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"
|
||||
@@ -160,11 +155,6 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
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")
|
||||
@@ -190,10 +180,6 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
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")
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestRuntimeLogEventDeclarationRoundTripUsesSafeProjection(t *testing.T) {
|
||||
body := GamePluginRuntimeProfilesBody{
|
||||
LogEvents: []RuntimeLogEventBody{{
|
||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||
}},
|
||||
}
|
||||
|
||||
profiles := body.ToDomain()
|
||||
if len(profiles.LogEvents) != 1 || profiles.LogEvents[0].EventType != "chat.message" || profiles.LogEvents[0].Severity != "info" {
|
||||
t.Fatalf("log event conversion lost declaration fields: %+v", profiles.LogEvents)
|
||||
}
|
||||
profiles.LogEvents[0].Title = "Mutated"
|
||||
if body.LogEvents[0].Title != "Chat message" {
|
||||
t.Fatal("log event domain conversion aliases request DTO data")
|
||||
}
|
||||
|
||||
projection := runtimeProfilesFromDomain(domain.GamePluginRuntimeProfiles{LogEvents: profiles.LogEvents})
|
||||
encoded, err := json.Marshal(projection.LogEvents[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal log event projection: %v", err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||
t.Fatalf("decode log event projection: %v", err)
|
||||
}
|
||||
expected := []string{"key", "title", "sourceKey", "eventType", "permission", "schemaRef", "retentionDays", "severity"}
|
||||
if len(fields) != len(expected) {
|
||||
t.Fatalf("log event projection contains unexpected fields: %s", encoded)
|
||||
}
|
||||
for _, field := range expected {
|
||||
if _, exists := fields[field]; !exists {
|
||||
t.Fatalf("log event projection is missing %q: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,28 @@ func applyPluginCreateDefaults(plugin domain.GamePlugin, definition domain.Serve
|
||||
func deploymentNeedsCompleteRuntimeBinding(_ domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool {
|
||||
return definition.Mode == ""
|
||||
}
|
||||
|
||||
// lifecycleDefaultProfileKey selects a plugin-declared lifecycle profile when
|
||||
// an older server record has no explicit deployment profile. Runtime bindings
|
||||
// remain optional logical transport overrides; they must not be required for
|
||||
// standard Run lifecycle, file, or log workflows.
|
||||
func lifecycleDefaultProfileKey(_ domain.ServerInstance, plugin domain.GamePlugin, profileKey string) string {
|
||||
if profileKey != "" {
|
||||
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey); ok {
|
||||
return profile.Key
|
||||
}
|
||||
return profileKey
|
||||
}
|
||||
return firstRuntimeLifecycleProfileKey(plugin)
|
||||
}
|
||||
|
||||
func defaultRunDistributionProfileKey(plugin domain.GamePlugin, profileKey string) string {
|
||||
return lifecycleDefaultProfileKey(domain.ServerInstance{}, plugin, profileKey)
|
||||
}
|
||||
|
||||
func firstRuntimeLifecycleProfileKey(plugin domain.GamePlugin) string {
|
||||
if len(plugin.RuntimeProfiles.LifecycleProfiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
return plugin.RuntimeProfiles.LifecycleProfiles[0].Key
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package service
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -231,16 +233,24 @@ func readLogSegment(path string) (domain.LogBatchRecord, error) {
|
||||
defer file.Close()
|
||||
|
||||
entries := []domain.LogEntry{}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
reader := bufio.NewReader(file)
|
||||
for {
|
||||
line, readErr := reader.ReadBytes('\n')
|
||||
if len(line) == 0 && errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
var entry domain.LogEntry
|
||||
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
|
||||
if err := json.Unmarshal(line, &entry); err != nil {
|
||||
return domain.LogBatchRecord{}, fmt.Errorf("decode log segment %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
entries = append(entries, domain.CopyLogEntry(entry))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return domain.LogBatchRecord{}, fmt.Errorf("read log segment %s: %w", filepath.Base(path), err)
|
||||
if readErr == nil {
|
||||
continue
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return domain.LogBatchRecord{}, fmt.Errorf("read log segment %s: %w", filepath.Base(path), readErr)
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Seq < entries[j].Seq })
|
||||
if len(entries) == 0 {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestSanitizeLogNetworkFieldsIsGameAgnostic(t *testing.T) {
|
||||
func TestLogIngestPreservesOpaqueFields(t *testing.T) {
|
||||
batch := domain.LogBatchIngest{Entries: []domain.LogEntry{
|
||||
{Fields: map[string]string{
|
||||
"eventType": "game.session.opened",
|
||||
@@ -17,15 +17,10 @@ func TestSanitizeLogNetworkFieldsIsGameAgnostic(t *testing.T) {
|
||||
}},
|
||||
}}
|
||||
|
||||
sanitizeLogNetworkFields(&batch)
|
||||
|
||||
fields := batch.Entries[0].Fields
|
||||
for _, key := range []string{"networkFingerprint", "ip", "ipAddress"} {
|
||||
if _, exists := fields[key]; exists {
|
||||
t.Fatalf("expected %s to be removed", key)
|
||||
fields := storedLogEntries(batch.Entries)[0].Fields
|
||||
for key, expected := range map[string]string{"networkFingerprint": "fingerprint", "ip": "192.0.2.1", "ipAddress": "2001:db8::1", "playerId": "player-1"} {
|
||||
if fields[key] != expected {
|
||||
t.Fatalf("expected opaque field %s to be preserved, got %q", key, fields[key])
|
||||
}
|
||||
}
|
||||
if fields["playerId"] != "player-1" {
|
||||
t.Fatal("expected unrelated fields to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"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 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)
|
||||
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(instance.ID, 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
|
||||
}
|
||||
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(instance.ID, *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
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if target.ObservedAtField != "" {
|
||||
value[target.ObservedAtField] = observedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestDurableStdoutProjectionCreatesUsersAndSuppressesRapidDuplicates(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
plugin.RuntimeProfiles.ClientManagers = append(plugin.RuntimeProfiles.ClientManagers, domain.RuntimeClientManagerProfile{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}})
|
||||
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",
|
||||
},
|
||||
},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin projection: %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.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, 0)
|
||||
|
||||
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, 0)
|
||||
|
||||
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, 0)
|
||||
}
|
||||
|
||||
func TestTradeLogProjectionsCreateCatalogAndTradeEvents(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
pattern := `^\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}: \[Trade\] Tradeable \((?P<itemCode>[A-Za-z0-9_.-]{1,128}) \(x(?P<quantity>\d{1,9})\)\) (?P<tradeVerb>purchased|sold) by .*?\((?P<steamId>\d{1,50})\) for (?P<price>-?\d{1,12})$`
|
||||
plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{
|
||||
Key: "scum.trade.catalog", StreamKeys: []string{"scum.trade"}, Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{{Pattern: pattern}}, CorrelationFields: []string{"itemCode"}, MaxInterveningLines: 0,
|
||||
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_trade_goods", UpsertKeys: []string{"code"}, CaptureMappings: map[string]string{"code": "itemCode"}, FixedValues: map[string]string{"catalogType": "item", "source": "scum.trade"}, ObservedAtField: "lastSeenAt"},
|
||||
}, {
|
||||
Key: "scum.trade.events", StreamKeys: []string{"scum.trade"}, Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{{Pattern: pattern}}, CorrelationFields: []string{"steamId", "itemCode", "tradeVerb"}, MaxInterveningLines: 0,
|
||||
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_trade_events", UpsertKeys: []string{"steamId", "itemCode", "tradeVerb", "quantity", "price", "observedAt"}, CaptureMappings: map[string]string{"steamId": "steamId", "itemCode": "itemCode", "tradeVerb": "tradeVerb", "quantity": "quantity", "price": "price"}, FixedValues: map[string]string{"eventType": "trade", "source": "scum.trade"}, ObservedAtField: "observedAt"},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update trade projections: %v", err)
|
||||
}
|
||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-trade-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM trade projection", State: domain.ServerInstanceStateRunning})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-trade-log-projection"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
stream, err := svc.CreateLogStream(domain.LogStream{ID: "trade-log-projection", ServerInstanceID: instance.ID, Source: domain.LogStreamSourceFile, StreamKey: "scum.trade", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"})
|
||||
if err != nil {
|
||||
t.Fatalf("create trade stream: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 8, 27, 12, 34, 56, 0, time.UTC)
|
||||
ingestTradeProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 1, base, []string{`2026.08.27-12.34.56: [Trade] Tradeable (BPC_Apple (x2)) purchased by Mira(76561198000000001) for 120`})
|
||||
goods, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods"})
|
||||
if err != nil || len(goods) != 1 || goods[0].Key != "BPC_Apple" || goods[0].Value["catalogType"] != "item" || goods[0].Value["lastSeenAt"] == nil {
|
||||
t.Fatalf("trade item catalog was not projected: %+v err=%v", goods, err)
|
||||
}
|
||||
trades, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_events"})
|
||||
if err != nil || len(trades) != 1 || trades[0].Value["itemCode"] != "BPC_Apple" || trades[0].Value["quantity"] != "2" || trades[0].Value["tradeVerb"] != "purchased" || trades[0].Value["steamId"] != "76561198000000001" || trades[0].Value["price"] != "120" {
|
||||
t.Fatalf("trade event was not projected: %+v err=%v", trades, err)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
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 ingestTradeProjectionLines(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: "info", Line: line}
|
||||
}
|
||||
lastSeq := firstSeq + uint64(len(entries)) - 1
|
||||
batch := domain.LogBatchIngest{RunEndpointID: endpointID, SessionToken: sessionToken, LogStreamID: streamID, ServerInstanceID: serverID, StreamKey: "scum.trade", Source: domain.LogStreamSourceFile, 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 trade 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 bridge commands=%+v err=%v", queued, err)
|
||||
}
|
||||
}
|
||||
@@ -1006,6 +1006,32 @@ func TestServerFileListReportsFailedRuntimeRefresh(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerFileManagerUsesDefaultProfileWithoutRuntimeBinding(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityFilesList)
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("update file list capability: %v", err)
|
||||
}
|
||||
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-default-profile", DisplayName: "File Default Profile", Email: "file-default-profile@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-default-profile", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Default Profile Server", State: domain.ServerInstanceStateRunning, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: `C:\scumserver`}})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
if _, err := svc.runtimeBindingForServer(instance.ID); !errors.Is(err, repo.ErrNotFound) {
|
||||
t.Fatalf("file manager must not require a runtime binding: %v", err)
|
||||
}
|
||||
|
||||
list, err := svc.RefreshServerFileListForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "server-root", IdempotencyKey: "file-default-profile-list"})
|
||||
if err != nil || list.State != "pending" || list.Job.ExecutionInput.WorkspaceScope != "local" {
|
||||
t.Fatalf("expected default-profile file list job, result=%+v err=%v", list, err)
|
||||
}
|
||||
read, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ServerInstanceID: instance.ID, PluginID: plugin.ID, Operation: domain.FileOperationRead, Key: "logs/latest.log", IdempotencyKey: "file-default-profile-read"})
|
||||
if err != nil || read.Job.ExecutionInput.WorkspaceScope != "local" {
|
||||
t.Fatalf("expected default-profile file read job, result=%+v err=%v", read, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerFileBrowseWaitsForFreshRunResultWithoutCachedList(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) {
|
||||
t.Fatalf("unexpected unconfigured view: view=%+v err=%v", view, err)
|
||||
}
|
||||
withoutBinding, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-without-binding"})
|
||||
if err != nil || withoutBinding.Job.TargetKey != "actions/start.json" || withoutBinding.Job.ExecutionInput.WorkspaceScope != "" {
|
||||
if err != nil || withoutBinding.Job.TargetKey != "actions/start.json" || withoutBinding.Job.ExecutionInput.WorkspaceScope != "local" {
|
||||
t.Fatalf("expected plugin lifecycle start without manual runtime binding, result=%+v err=%v", withoutBinding, err)
|
||||
}
|
||||
if _, err := svc.UpdateServerRuntimeBindingForSession(otherSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local"}); err != ErrForbidden {
|
||||
|
||||
@@ -54,7 +54,7 @@ type runFileListEntry struct {
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serverInstanceID string) (domain.ServerFileWorkspaceView, error) {
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false, false)
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false)
|
||||
if err != nil {
|
||||
return domain.ServerFileWorkspaceView{}, err
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma
|
||||
if err := validator.ValidateServerFileListRequest(request); err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, false)
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false)
|
||||
if err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func (svc *CoreService) RefreshServerFileListForSession(sessionID string, reques
|
||||
if err := validator.ValidateServerFileListRequest(request); err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, true)
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false)
|
||||
if err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
@@ -288,7 +288,7 @@ func (svc *CoreService) UploadServerFileForSession(sessionID string, request dom
|
||||
if err := validator.ValidateServerFileUploadRequest(request); err != nil {
|
||||
return domain.ServerFileUploadDispatch{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true, true)
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true)
|
||||
if err != nil {
|
||||
return domain.ServerFileUploadDispatch{}, err
|
||||
}
|
||||
@@ -326,7 +326,7 @@ func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, re
|
||||
if err := validator.ValidateServerFileDownloadRequest(request); err != nil {
|
||||
return domain.ServerFileDownloadResult{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false, true)
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false)
|
||||
if err != nil {
|
||||
return domain.ServerFileDownloadResult{}, err
|
||||
}
|
||||
@@ -412,7 +412,7 @@ func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRe
|
||||
return domain.CopyRunFileInputChunk(chunk), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool, requireRuntime bool) (serverFileContext, error) {
|
||||
func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool) (serverFileContext, error) {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||
if err != nil {
|
||||
return serverFileContext{}, err
|
||||
@@ -421,11 +421,6 @@ func (svc *CoreService) serverFileContextForSession(sessionID string, serverInst
|
||||
if err != nil {
|
||||
return serverFileContext{}, err
|
||||
}
|
||||
if requireRuntime {
|
||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "file.manager.denied"); err != nil {
|
||||
return serverFileContext{}, err
|
||||
}
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return serverFileContext{}, err
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
||||
bridge := domain.GameClientBridgeManifest{
|
||||
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"},
|
||||
},
|
||||
}},
|
||||
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, 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: "invalid timestamp field", expected: "presence.timestampField must reference", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Presence.TimestampField = "missingAt"
|
||||
}},
|
||||
{name: "invalid activity target", expected: "presence.activityTarget.upsertKeys field missing is not projected", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Presence.ActivityTarget.UpsertKeys = []string{"missing"}
|
||||
}},
|
||||
}
|
||||
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, nil, candidateProfiles)
|
||||
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
|
||||
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
const (
|
||||
MaxLogBatchEntries = 512
|
||||
MaxLogLineLength = 8192
|
||||
MaxLogQueryLimit = 10000
|
||||
)
|
||||
|
||||
@@ -63,12 +62,6 @@ func ValidateLogBatchIngest(batch domain.LogBatchIngest) error {
|
||||
if entry.Seq != batch.FirstSeq+uint64(i) {
|
||||
violations = append(violations, fmt.Sprintf("entries[%d].seq must be contiguous", i))
|
||||
}
|
||||
if strings.TrimSpace(entry.Line) == "" {
|
||||
violations = append(violations, fmt.Sprintf("entries[%d].line is required", i))
|
||||
}
|
||||
if len(entry.Line) > MaxLogLineLength {
|
||||
violations = append(violations, fmt.Sprintf("entries[%d].line is too long", i))
|
||||
}
|
||||
for key := range entry.Fields {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
violations = append(violations, fmt.Sprintf("entries[%d].fields key is required", i))
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestValidateLogBatchIngestAcceptsVerbatimBlankAndLongLines(t *testing.T) {
|
||||
entries := []domain.LogEntry{
|
||||
{Seq: 1, Timestamp: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Line: ""},
|
||||
{Seq: 2, Timestamp: time.Date(2026, 9, 1, 0, 0, 1, 0, time.UTC), Line: strings.Repeat("x", 32*1024)},
|
||||
}
|
||||
checksum, err := LogEntriesChecksum(entries)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum: %v", err)
|
||||
}
|
||||
batch := domain.LogBatchIngest{RunEndpointID: "run-1", SessionToken: "session-1", LogStreamID: "run.run-1.server-1.stdout", ServerInstanceID: "server-1", StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: 1, LastSeq: 2, Compression: "none", Checksum: checksum, Entries: entries}
|
||||
if err := ValidateLogBatchIngest(batch); err != nil {
|
||||
t.Fatalf("verbatim log batch was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,6 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
||||
violations = append(violations, err.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.RequiredRunCapabilities, plugin.Pages, plugin.RuntimeProfiles)...)
|
||||
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
|
||||
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
|
||||
@@ -237,7 +236,6 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
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.Capabilities, manifest.Pages, manifest.RuntimeProfiles)...)
|
||||
violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
|
||||
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
|
||||
@@ -469,7 +467,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
|
||||
|
||||
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.LifecycleProjections) == 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.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
|
||||
@@ -628,18 +626,6 @@ 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)...)
|
||||
}
|
||||
lifecycleProjectionKeys := map[string]struct{}{}
|
||||
for index, projection := range bridge.LifecycleProjections {
|
||||
prefix := fmt.Sprintf("%s.lifecycleProjections[%d]", field, index)
|
||||
@@ -662,10 +648,10 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
violations = append(violations, prefix+".key is duplicated")
|
||||
}
|
||||
dataPackKeys[dataPack.Key] = struct{}{}
|
||||
if dataPack.DatabaseUserVersion < 1 || len(dataPack.LogParserRefs) == 0 || len(dataPack.ConfigMapRefs) == 0 {
|
||||
violations = append(violations, prefix+" must declare a database version and parser/config assets")
|
||||
if dataPack.DatabaseUserVersion < 1 || len(dataPack.ConfigMapRefs) == 0 {
|
||||
violations = append(violations, prefix+" must declare a database version and config assets")
|
||||
}
|
||||
refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...)
|
||||
refs := domain.CopyStringSlice(dataPack.ConfigMapRefs)
|
||||
refs = append(refs, dataPack.DataRefs...)
|
||||
for _, ref := range refs {
|
||||
if !safeRelativeJSONRef(ref) {
|
||||
@@ -939,154 +925,6 @@ func validateGameClientBridgeBulkActivityTarget(prefix string, target domain.Gam
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration) []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)...)
|
||||
}
|
||||
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.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")
|
||||
}
|
||||
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
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesAllowsSafeCasePreservingDataTargetPath(t *testing.T) {
|
||||
profiles := validRuntimeDataTargetProfiles("SCUM/Saved/SaveFiles/SCUM.db")
|
||||
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
||||
t.Fatalf("expected safe cross-platform data target path to validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesRejectsUnsafeDataTargetPath(t *testing.T) {
|
||||
for _, sourcePath := range []string{"../SCUM.db", "/SCUM.db", `SCUM\\Saved\\SCUM.db`, "C:/SCUM.db", "https://example.test/SCUM.db"} {
|
||||
t.Run(sourcePath, func(t *testing.T) {
|
||||
err := ValidateGamePluginRuntimeProfiles(validRuntimeDataTargetProfiles(sourcePath))
|
||||
if err == nil || !strings.Contains(err.Error(), "sourcePath must be a safe relative path") {
|
||||
t.Fatalf("expected unsafe source path rejection, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validRuntimeDataTargetProfiles(sourcePath string) domain.GamePluginRuntimeProfiles {
|
||||
return domain.GamePluginRuntimeProfiles{
|
||||
TransportProfiles: []domain.RuntimeTransportProfile{{
|
||||
Key: "scum-database",
|
||||
Kind: "sqlite",
|
||||
TargetKey: "scum-database",
|
||||
Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery},
|
||||
}},
|
||||
DataTargets: []domain.RuntimeDataTarget{{
|
||||
Key: "scum-database",
|
||||
Kind: "sqlite.snapshot",
|
||||
TransportKey: "scum-database",
|
||||
SourceRootKey: "server-root",
|
||||
SourcePath: sourcePath,
|
||||
WorkspaceKey: "databases/scum-database",
|
||||
RefreshPolicy: "on-demand-snapshot",
|
||||
MaxBytes: 1024 * 1024,
|
||||
Platforms: []string{"windows"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func validRuntimeLogEventProfiles() domain.GamePluginRuntimeProfiles {
|
||||
return domain.GamePluginRuntimeProfiles{
|
||||
LogSources: []domain.RuntimeLogSource{{Key: "chat-log", Kind: "file.tail", StreamKey: "chat", CursorKind: "offset", RetentionDays: 30}},
|
||||
LogEvents: []domain.RuntimeLogEvent{{
|
||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesValidatesLogEvents(t *testing.T) {
|
||||
if err := ValidateGamePluginRuntimeProfiles(validRuntimeLogEventProfiles()); err != nil {
|
||||
t.Fatalf("expected valid runtime log event declaration, got %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
mutate func(*domain.GamePluginRuntimeProfiles)
|
||||
}{
|
||||
{name: "undeclared source", expected: "sourceKey must reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].SourceKey = "missing" }},
|
||||
{name: "invalid event type", expected: "eventType is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].EventType = "chat message" }},
|
||||
{name: "invalid permission", expected: "permission is not allowed", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Permission = "server.admin" }},
|
||||
{name: "unsafe schema", expected: "schemaRef must be a bounded safe relative JSON reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||
profiles.LogEvents[0].SchemaRef = "schemas/log events/chat.json"
|
||||
}},
|
||||
{name: "retention bound", expected: "retentionDays is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 366 }},
|
||||
{name: "retention exceeds source", expected: "retentionDays must not exceed the source retention", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 31 }},
|
||||
{name: "invalid severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "emergency" }},
|
||||
{name: "plugin error severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "error" }},
|
||||
{name: "duplicate key", expected: "key is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||
profiles.LogEvents = append(profiles.LogEvents, profiles.LogEvents[0])
|
||||
}},
|
||||
{name: "duplicate event type", expected: "eventType is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||
duplicate := profiles.LogEvents[0]
|
||||
duplicate.Key = "chat-message-copy"
|
||||
profiles.LogEvents = append(profiles.LogEvents, duplicate)
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
profiles := domain.CopyGamePluginRuntimeProfiles(validRuntimeLogEventProfiles())
|
||||
test.mutate(&profiles)
|
||||
err := ValidateGamePluginRuntimeProfiles(profiles)
|
||||
if err == nil || !strings.Contains(err.Error(), test.expected) {
|
||||
t.Fatalf("expected %q validation error, got %v", test.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesAcceptsDeclaredLogEventSeverities(t *testing.T) {
|
||||
for _, severity := range []domain.RuntimeLogEventSeverity{
|
||||
domain.RuntimeLogEventSeverityInfo,
|
||||
domain.RuntimeLogEventSeverityNotice,
|
||||
domain.RuntimeLogEventSeverityWarning,
|
||||
domain.RuntimeLogEventSeverityCritical,
|
||||
} {
|
||||
t.Run(string(severity), func(t *testing.T) {
|
||||
profiles := validRuntimeLogEventProfiles()
|
||||
profiles.LogEvents[0].Severity = severity
|
||||
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
||||
t.Fatalf("expected severity %q to validate, got %v", severity, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesAllowsEventRetentionWhenSourceUsesDefault(t *testing.T) {
|
||||
profiles := validRuntimeLogEventProfiles()
|
||||
profiles.LogSources[0].RetentionDays = 0
|
||||
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
||||
t.Fatalf("expected source default retention to allow bounded event retention, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesRejectsUnsafeLogEventSemantics(t *testing.T) {
|
||||
unsafeEventTypes := []string{
|
||||
"ops.shell.execute",
|
||||
"ops.execute",
|
||||
"ops.sql.query",
|
||||
"ops.raw-host-path",
|
||||
"run.socket.open",
|
||||
"auth.credential.exposed",
|
||||
"auth.api-key.exposed",
|
||||
}
|
||||
for _, eventType := range unsafeEventTypes {
|
||||
t.Run(eventType, func(t *testing.T) {
|
||||
profiles := validRuntimeLogEventProfiles()
|
||||
profiles.LogEvents[0].EventType = eventType
|
||||
err := ValidateGamePluginRuntimeProfiles(profiles)
|
||||
if err == nil || !strings.Contains(err.Error(), "eventType contains unsafe operation semantics") {
|
||||
t.Fatalf("expected unsafe event type %q to be rejected, got %v", eventType, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationRequiresDeclaredLogEventPermission(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.RuntimeProfiles = validRuntimeLogEventProfiles()
|
||||
registration.Manifest.RuntimeProfiles.LogEvents[0].Permission = "server.game-client.read"
|
||||
|
||||
err := ValidateGamePluginManifestRegistration(registration)
|
||||
if err == nil || !strings.Contains(err.Error(), "manifest.runtimeProfiles.logEvents[0].permission must be declared by the plugin") {
|
||||
t.Fatalf("expected undeclared log event permission rejection, got %v", err)
|
||||
}
|
||||
|
||||
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read")
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||
t.Fatalf("expected declared log event permission to validate, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
runtimeLogEventSchemaRefPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]+\.json$`)
|
||||
runtimeLogEventTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,119}$`)
|
||||
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
|
||||
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
|
||||
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
|
||||
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
|
||||
)
|
||||
|
||||
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
|
||||
@@ -23,6 +21,9 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
var violations []string
|
||||
lifecycleKeys := map[string]struct{}{}
|
||||
transportKeys := map[string]struct{}{}
|
||||
transportProfiles := map[string]domain.RuntimeTransportProfile{}
|
||||
dataTargetKeys := map[string]struct{}{}
|
||||
dataTargetWorkspaces := map[string]struct{}{}
|
||||
managerKeys := map[string]struct{}{}
|
||||
dllExtensionKeys := map[string]struct{}{}
|
||||
dllExtensionStates := map[string]string{}
|
||||
@@ -31,9 +32,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
installPlanKeys := map[string]struct{}{}
|
||||
serverDeploymentKeys := map[string]struct{}{}
|
||||
logSourceKeys := map[string]struct{}{}
|
||||
logSourceRetentions := map[string]int{}
|
||||
logEventKeys := map[string]struct{}{}
|
||||
logEventTypes := map[string]struct{}{}
|
||||
|
||||
for i, probe := range profiles.Discovery {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i)
|
||||
@@ -237,9 +235,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
||||
violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...)
|
||||
if source.Key != "" {
|
||||
logSourceRetentions[source.Key] = source.RetentionDays
|
||||
}
|
||||
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") {
|
||||
violations = append(violations, prefix+".kind is invalid")
|
||||
}
|
||||
@@ -254,44 +249,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
violations = append(violations, prefix+".retentionDays is invalid")
|
||||
}
|
||||
}
|
||||
if len(profiles.LogEvents) > 128 {
|
||||
violations = append(violations, "runtimeProfiles.logEvents must not exceed 128")
|
||||
}
|
||||
for i, event := range profiles.LogEvents {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.logEvents[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", event.Key)...)
|
||||
violations = append(violations, recordRuntimeProfileKey(logEventKeys, prefix+".key", event.Key)...)
|
||||
if strings.TrimSpace(event.Title) == "" || len([]rune(event.Title)) > 80 {
|
||||
violations = append(violations, prefix+".title is invalid")
|
||||
}
|
||||
violations = append(violations, validateSafeRuntimeValue(prefix+".title", event.Title)...)
|
||||
violations = append(violations, validateProfileKey(prefix+".sourceKey", event.SourceKey)...)
|
||||
if _, exists := logSourceKeys[event.SourceKey]; !exists {
|
||||
violations = append(violations, prefix+".sourceKey must reference a declared runtime log source")
|
||||
}
|
||||
if !runtimeLogEventTypePattern.MatchString(event.EventType) {
|
||||
violations = append(violations, prefix+".eventType is invalid")
|
||||
}
|
||||
violations = append(violations, recordRuntimeProfileKey(logEventTypes, prefix+".eventType", event.EventType)...)
|
||||
if hasUnsafeRuntimeLogEventSemantics(event.EventType) {
|
||||
violations = append(violations, prefix+".eventType contains unsafe operation semantics")
|
||||
}
|
||||
if !validPluginPermission(event.Permission) {
|
||||
violations = append(violations, prefix+".permission is not allowed")
|
||||
}
|
||||
if len(event.SchemaRef) > 240 || !runtimeLogEventSchemaRefPattern.MatchString(event.SchemaRef) || !safeRelativeJSONRef(event.SchemaRef) {
|
||||
violations = append(violations, prefix+".schemaRef must be a bounded safe relative JSON reference")
|
||||
}
|
||||
if event.RetentionDays < 1 || event.RetentionDays > 365 {
|
||||
violations = append(violations, prefix+".retentionDays is invalid")
|
||||
}
|
||||
if sourceRetention, exists := logSourceRetentions[event.SourceKey]; exists && sourceRetention > 0 && event.RetentionDays > sourceRetention {
|
||||
violations = append(violations, prefix+".retentionDays must not exceed the source retention")
|
||||
}
|
||||
if !oneOf(string(event.Severity), string(domain.RuntimeLogEventSeverityInfo), string(domain.RuntimeLogEventSeverityNotice), string(domain.RuntimeLogEventSeverityWarning), string(domain.RuntimeLogEventSeverityCritical)) {
|
||||
violations = append(violations, prefix+".severity is invalid")
|
||||
}
|
||||
}
|
||||
for i, transport := range profiles.TransportProfiles {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
|
||||
@@ -311,6 +268,36 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
}
|
||||
}
|
||||
violations = append(violations, duplicateViolations(prefix+".capabilities", transport.Capabilities)...)
|
||||
if transport.Key != "" {
|
||||
transportProfiles[transport.Key] = transport
|
||||
}
|
||||
}
|
||||
if len(profiles.DataTargets) > 16 {
|
||||
violations = append(violations, "runtimeProfiles.dataTargets must not exceed 16")
|
||||
}
|
||||
for i, target := range profiles.DataTargets {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.dataTargets[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", target.Key)...)
|
||||
violations = append(violations, recordRuntimeProfileKey(dataTargetKeys, prefix+".key", target.Key)...)
|
||||
violations = append(violations, validateProfileKey(prefix+".transportKey", target.TransportKey)...)
|
||||
violations = append(violations, validateProfileKey(prefix+".sourceRootKey", target.SourceRootKey)...)
|
||||
violations = append(violations, validateSafeRelativeRuntimePath(prefix+".sourcePath", target.SourcePath)...)
|
||||
violations = append(violations, validateProfileKey(prefix+".workspaceKey", target.WorkspaceKey)...)
|
||||
if target.Kind != "sqlite.snapshot" || target.RefreshPolicy != "on-demand-snapshot" || !strings.HasPrefix(target.WorkspaceKey, "databases/") {
|
||||
violations = append(violations, prefix+" must declare an on-demand sqlite snapshot workspace")
|
||||
}
|
||||
if target.MaxBytes < 1 || target.MaxBytes > 1024*1024*1024 {
|
||||
violations = append(violations, prefix+".maxBytes is invalid")
|
||||
}
|
||||
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", target.Platforms)...)
|
||||
if _, exists := dataTargetWorkspaces[target.WorkspaceKey]; exists {
|
||||
violations = append(violations, prefix+".workspaceKey is duplicated")
|
||||
}
|
||||
dataTargetWorkspaces[target.WorkspaceKey] = struct{}{}
|
||||
transport, exists := transportProfiles[target.TransportKey]
|
||||
if !exists || transport.Kind != "sqlite" || transport.TargetKey != target.TransportKey || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
violations = append(violations, prefix+".transportKey must reference a declared SQLite query transport")
|
||||
}
|
||||
}
|
||||
for i, manager := range profiles.ClientManagers {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.clientManagers[%d]", i)
|
||||
@@ -609,42 +596,6 @@ func validateSafeRuntimeValue(field, value string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasUnsafeRuntimeLogEventSemantics(eventType string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(eventType))
|
||||
tokens := strings.FieldsFunc(lowered, func(char rune) bool {
|
||||
return char == '.' || char == '_' || char == '-' || char == '/'
|
||||
})
|
||||
unsafeTokens := map[string]struct{}{
|
||||
"apikey": {}, "credential": {}, "credentials": {}, "eval": {}, "exec": {},
|
||||
"execute": {}, "password": {}, "powershell": {}, "script": {}, "secret": {},
|
||||
"shell": {}, "socket": {}, "terminal": {}, "token": {},
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if _, unsafe := unsafeTokens[token]; unsafe {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for index := 0; index+1 < len(tokens); index++ {
|
||||
pair := tokens[index] + "." + tokens[index+1]
|
||||
switch pair {
|
||||
case "absolute.path", "access.key", "api.key", "component.key", "database.query", "direct.socket", "file.path", "host.path", "private.key", "raw.path", "run.direct", "run.socket", "unix.socket":
|
||||
return true
|
||||
}
|
||||
}
|
||||
tokenSet := make(map[string]struct{}, len(tokens))
|
||||
for _, token := range tokens {
|
||||
tokenSet[token] = struct{}{}
|
||||
}
|
||||
if _, hasSQL := tokenSet["sql"]; hasSQL {
|
||||
for _, token := range []string{"query", "statement", "raw"} {
|
||||
if _, unsafe := tokenSet[token]; unsafe {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []string {
|
||||
if key == "" {
|
||||
return nil
|
||||
@@ -681,20 +632,6 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateRuntimeLogEventPermissionDeclarations(field string, profiles domain.GamePluginRuntimeProfiles, declared []string) []string {
|
||||
declaredSet := make(map[string]struct{}, len(declared))
|
||||
for _, permission := range declared {
|
||||
declaredSet[permission] = struct{}{}
|
||||
}
|
||||
var violations []string
|
||||
for i, event := range profiles.LogEvents {
|
||||
if _, exists := declaredSet[event.Permission]; !exists {
|
||||
violations = append(violations, fmt.Sprintf("%s[%d].permission must be declared by the plugin", field, i))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
|
||||
var violations []string
|
||||
for field, value := range map[string]string{"install": actions.Install, "start": actions.Start, "stop": actions.Stop, "restart": actions.Restart, "status": actions.Status} {
|
||||
|
||||
Reference in New Issue
Block a user