Make SCUM logs live relay only
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
const gameClientBridgeCapability = "game-client.bridge"
|
||||
const gameClientBridgeLogStreamCapability = "logs.stream"
|
||||
|
||||
func (svc *CoreService) ClaimGameClientBridgeCommands(request domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeClaimRequest(request); err != nil {
|
||||
@@ -127,6 +128,27 @@ func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClient
|
||||
return domain.CopyGameClientBridgeSnapshot(snapshot), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) AuthorizeGameClientBridgeLogStream(request domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error) {
|
||||
if err := validator.ValidateGameClientBridgeLogStreamRequest(request); err != nil {
|
||||
return domain.ServerInstance{}, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, err
|
||||
}
|
||||
if !containsString(component.Session.Capabilities, gameClientBridgeLogStreamCapability) {
|
||||
return domain.ServerInstance{}, ErrForbidden
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(component.Session.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, err
|
||||
}
|
||||
if instance.PluginID != component.Installation.PluginID {
|
||||
return domain.ServerInstance{}, ErrUnauthorized
|
||||
}
|
||||
return domain.CopyServerInstance(instance), nil
|
||||
}
|
||||
|
||||
func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) {
|
||||
for _, declaration := range plugin.GameClientBridge.Snapshots {
|
||||
if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func seedGameClientBridgeComponentSession(t *testing.T, svc *CoreService, now time.Time, token string) (domain.ClientManagerInstallation, domain.ClientManagerSession) {
|
||||
t.Helper()
|
||||
installation := domain.ClientManagerInstallation{ID: "installation-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", RunEndpointID: "run-1", Status: domain.ClientManagerLifecycleOnline, ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability, gameClientBridgeLogStreamCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
key := domain.EncryptedComponentKey{ID: "key-1", ServerInstanceID: installation.ServerInstanceID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: installation.ProfileKey, Generation: installation.KeyGeneration, Status: domain.ComponentKeyStatusActive}
|
||||
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -80,6 +80,34 @@ func TestGameClientBridgeComponentSessionAuthorizesCommandsAndSnapshots(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeComponentSessionAuthorizesLiveLogStream(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const token = "component-session-token"
|
||||
_, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||
if err := svc.store.RunEndpoints().Create(domain.RunEndpoint{ID: session.RunEndpointID, DisplayName: "Component Run", Status: domain.RunEndpointStatusOnline, Capabilities: []string{"process.start", "logs.read"}, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: *clock}); err != nil {
|
||||
t.Fatalf("seed run endpoint: %v", err)
|
||||
}
|
||||
server := domain.ServerInstance{ID: session.ServerInstanceID, PluginID: "game.scum", PluginVersion: "1.0.0", RunEndpointID: session.RunEndpointID, Name: "SCUM"}
|
||||
if err := svc.store.ServerInstances().Create(server); err != nil {
|
||||
t.Fatalf("create server instance: %v", err)
|
||||
}
|
||||
instance, err := svc.AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest{SessionToken: token})
|
||||
if err != nil || instance.ID != server.ID || instance.PluginID != server.PluginID {
|
||||
t.Fatalf("authorize companion log stream: instance=%#v err=%v", instance, err)
|
||||
}
|
||||
if instance.RunEndpointID != session.RunEndpointID {
|
||||
t.Fatalf("authorized stream was not bound to component server: %#v", instance)
|
||||
}
|
||||
|
||||
session.Capabilities = []string{"component.heartbeat", gameClientBridgeCapability}
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest{SessionToken: token}); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected missing logs.stream rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeClaimCannotBeCompletedByAnotherCurrentSession(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const firstToken = "component-session-token-one"
|
||||
|
||||
@@ -12,7 +12,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := repo.NewMemoryStore()
|
||||
plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
plugin := domain.GamePlugin{ID: "game.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability, gameClientBridgeLogStreamCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
if err := store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("seed bridge plugin: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
type LogEventSubscriptionEvent struct {
|
||||
Kind LogEventSubscriptionEventKind
|
||||
LogEvent domain.LogStreamEvent
|
||||
Live bool
|
||||
ServerInstanceID string
|
||||
ProcessState domain.ServerInstanceState
|
||||
}
|
||||
@@ -66,6 +67,14 @@ func (svc *CoreService) SubscribeLogEventsForSession(sessionID string, serverIns
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLogEvents(stream domain.LogStream, entries []domain.LogEntry) {
|
||||
svc.publishLogEventsWithMode(stream, entries, false)
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLiveLogEvents(stream domain.LogStream, entries []domain.LogEntry) {
|
||||
svc.publishLogEventsWithMode(stream, entries, true)
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLogEventsWithMode(stream domain.LogStream, entries []domain.LogEntry, live bool) {
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -73,6 +82,7 @@ func (svc *CoreService) publishLogEvents(stream domain.LogStream, entries []doma
|
||||
for index, entry := range entries {
|
||||
events[index] = LogEventSubscriptionEvent{
|
||||
Kind: LogEventSubscriptionEventLog,
|
||||
Live: live,
|
||||
LogEvent: domain.CopyLogStreamEvent(domain.LogStreamEvent{
|
||||
ServerInstanceID: stream.ServerInstanceID,
|
||||
Stream: stream,
|
||||
|
||||
@@ -12,6 +12,65 @@ import (
|
||||
|
||||
const defaultLogQueryLimit = 100
|
||||
|
||||
// RelayLiveLogBatch forwards output observed by Run to live subscribers. It
|
||||
// intentionally updates only stream metadata; the log body is not written to
|
||||
// the platform log store. Game plugins own durable log storage and analysis.
|
||||
func (svc *CoreService) RelayLiveLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
|
||||
batch = domain.CopyLogBatchIngest(batch)
|
||||
if err := validator.ValidateLogBatchIngest(batch); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
|
||||
lock := svc.logIngestLock(batch.ServerInstanceID)
|
||||
lock.Lock()
|
||||
stamp := svc.now()
|
||||
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
if repairErr := svc.ensureLogStreamForBatch(batch, stamp); repairErr != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, repairErr
|
||||
}
|
||||
stream, err = svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
}
|
||||
if err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := validateLogBatchStream(batch, stream); err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if batch.LastSeq > stream.LatestSeq {
|
||||
stream.LatestSeq = batch.LastSeq
|
||||
}
|
||||
stream.UpdatedAt = stamp
|
||||
if err := svc.store.LogStreams().Update(stream); err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
lock.Unlock()
|
||||
|
||||
entries := domain.CopyLogEntries(batch.Entries)
|
||||
// Run may be on a machine whose wall clock is skewed. Relay time is the
|
||||
// authoritative observation time for this best-effort live event; using it
|
||||
// keeps the SSE live boundary from treating current output as old history.
|
||||
for index := range entries {
|
||||
entries[index].Timestamp = stamp.Add(time.Duration(index) * time.Nanosecond)
|
||||
}
|
||||
svc.publishLiveLogEvents(stream, entries)
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
AcceptedFrom: batch.FirstSeq,
|
||||
AcceptedTo: batch.LastSeq,
|
||||
LatestSeq: stream.LatestSeq,
|
||||
ServerTime: stamp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
|
||||
batch = domain.CopyLogBatchIngest(batch)
|
||||
if err := validator.ValidateLogBatchIngest(batch); err != nil {
|
||||
|
||||
@@ -92,6 +92,45 @@ func TestCoreServicePublishesLogEventsForAcceptedBatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRelaysLiveBatchWithoutPersistingBody(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
createLogStreamFixture(t, svc)
|
||||
subscription, err := svc.SubscribeLogEvents("server-1")
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe log events: %v", err)
|
||||
}
|
||||
defer subscription.Close()
|
||||
|
||||
batch := validLogBatch(t, sessionToken, 1, 1)
|
||||
batch.Entries[0].Timestamp = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
batch.Checksum, err = validator.LogEntriesChecksum(batch.Entries)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum live batch: %v", err)
|
||||
}
|
||||
ack, err := svc.RelayLiveLogBatch(batch)
|
||||
if err != nil || !ack.Accepted || ack.LatestSeq != 1 {
|
||||
t.Fatalf("relay live batch: ack=%+v err=%v", ack, err)
|
||||
}
|
||||
query, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: batch.LogStreamID, AfterSeq: 0, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("query relayed log stream: %v", err)
|
||||
}
|
||||
if len(query.Entries) != 0 || query.LatestSeq != 1 {
|
||||
t.Fatalf("live relay wrote a platform log body: %+v", query)
|
||||
}
|
||||
select {
|
||||
case event := <-subscription.Events:
|
||||
if !event.Live {
|
||||
t.Fatalf("expected live relay event marker: %+v", event)
|
||||
}
|
||||
if event.LogEvent.Entry.Timestamp.Before(fixedTime) {
|
||||
t.Fatalf("live relay kept stale source timestamp: %+v", event.LogEvent.Entry)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected relayed live log event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServicePersistsAndEnforcesImmutableProcessLogSessionMetadata(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
startedAt := time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
|
||||
|
||||
@@ -194,6 +194,7 @@ type Core interface {
|
||||
CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error)
|
||||
CancelGameClientBridgeCommandForSession(string, domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error)
|
||||
UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error)
|
||||
AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error)
|
||||
ReconcileGameClientBridgeCommands() error
|
||||
GetGameClientBridgeStatusForSession(string, string) (domain.GameClientBridgeStatus, error)
|
||||
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
|
||||
@@ -221,6 +222,7 @@ type Core interface {
|
||||
SubscribeLogEvents(string) (LogEventSubscription, error)
|
||||
SubscribeLogEventsForSession(string, string) (LogEventSubscription, error)
|
||||
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
||||
RelayLiveLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
||||
GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, error)
|
||||
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
|
||||
SeedPlatformAdmin(string, string) error
|
||||
|
||||
Reference in New Issue
Block a user