Align runtime profiles with plugin-owned records
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user