Make SCUM logs live relay only
This commit is contained in:
@@ -6,6 +6,48 @@ import (
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// gameClientBridgeCompanionLogEvents godoc
|
||||
// @Summary Stream live Run logs to a game companion
|
||||
// @Description Authorizes a component session and relays only the current supervised process log stream to the companion. The platform does not persist log bodies on this route.
|
||||
// @Tags game-client-bridge
|
||||
// @Accept json
|
||||
// @Produce text/event-stream
|
||||
// @Param body body dto.GameClientBridgeLogStreamRequest true "Component log stream request"
|
||||
// @Success 200 {object} dto.LogStreamEventResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/game-client-bridge/companion/logs/events [post]
|
||||
func (h *coreHandlers) gameClientBridgeCompanionLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeLogStreamRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
instance, err := h.core.AuthorizeGameClientBridgeLogStream(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
subscription, err := h.core.SubscribeLogEvents(instance.ID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
defer subscription.Close()
|
||||
streams, liveEligible, err := h.loadLiveLogSnapshot(instance.ID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
h.streamCurrentLogEvents(w, r, instance, streams, liveEligible, subscription)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -38,6 +41,13 @@ func (core *gameClientBridgeCompanionCore) UploadGameClientBridgeSnapshot(domain
|
||||
return domain.CopyGameClientBridgeSnapshot(core.snapshot), nil
|
||||
}
|
||||
|
||||
func (core *gameClientBridgeCompanionCore) AuthorizeGameClientBridgeLogStream(request domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error) {
|
||||
if strings.TrimSpace(request.SessionToken) != "component-token" {
|
||||
return domain.ServerInstance{}, service.ErrUnauthorized
|
||||
}
|
||||
return core.Core.GetServerInstance("server-1")
|
||||
}
|
||||
|
||||
func TestGameClientBridgeOperatorRoutes(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
coreService := service.NewCoreService(store)
|
||||
@@ -149,3 +159,40 @@ func TestGameClientBridgeCompanionRoutesAreComponentSessionMediated(t *testing.T
|
||||
diagnostic := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/diagnostics", snapshotRequest)
|
||||
assertStatus(t, diagnostic, http.StatusAccepted)
|
||||
}
|
||||
|
||||
func TestGameClientBridgeCompanionLogEventsRelaysCurrentRunOutput(t *testing.T) {
|
||||
coreService := service.NewCoreService(repo.NewMemoryStore())
|
||||
if err := coreService.SeedLocalPlatformAdmin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core := &gameClientBridgeCompanionCore{Core: coreService}
|
||||
router := NewTestRouterWithCore(core)
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/game-client-bridge/companion/logs/events", strings.NewReader("{\"sessionToken\":\"component-token\"}")).WithContext(ctx)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
router.ServeHTTP(streamWriter, request)
|
||||
_ = streamWriter.Close()
|
||||
close(done)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
_ = streamReader.Close()
|
||||
<-done
|
||||
})
|
||||
if status := <-streamWriter.status; status != http.StatusOK {
|
||||
t.Fatalf("unexpected companion SSE status: %d", status)
|
||||
}
|
||||
reader := bufio.NewReader(streamReader)
|
||||
assertSSEEvent(t, reader, "session", "\"logSessionId\":\"session-current\"")
|
||||
assertSSEEvent(t, reader, "stream", "\"id\":\"log-1\"")
|
||||
assertSSEEvent(t, reader, "ready", "\"streamCount\":1")
|
||||
|
||||
live := validLogBatchRequest(t, hello.SessionToken, 5, 5)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", live), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "log", "\"seq\":5")
|
||||
}
|
||||
|
||||
@@ -24,13 +24,17 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
liveBoundary := time.Now().UTC().Add(-liveLogSourceClockSkew)
|
||||
instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
defer subscription.Close()
|
||||
h.streamCurrentLogEvents(w, r, instance, streams, liveEligible, subscription)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) streamCurrentLogEvents(w http.ResponseWriter, r *http.Request, instance domain.ServerInstance, streams []domain.LogStream, liveEligible bool, subscription service.LogEventSubscription) {
|
||||
liveBoundary := time.Now().UTC().Add(-liveLogSourceClockSkew)
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeServiceError(w, fmt.Errorf("streaming response unsupported"))
|
||||
@@ -131,7 +135,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if !active.contains(event.Stream) {
|
||||
continue
|
||||
}
|
||||
if !sourceLogEntryIsLive(event.Entry, liveBoundary) {
|
||||
if !subscriptionEvent.Live && !sourceLogEntryIsLive(event.Entry, liveBoundary) {
|
||||
if event.Entry.Seq > emittedThrough[event.Stream.ID] {
|
||||
emittedThrough[event.Stream.ID] = event.Entry.Seq
|
||||
}
|
||||
@@ -143,7 +147,10 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if event.Entry.Seq <= emittedThrough[event.Stream.ID] {
|
||||
// Live relay traffic is already a current best-effort observation.
|
||||
// Do not sequence-gate it: a restarted Run intentionally starts with
|
||||
// fresh in-memory sequence state and must still reach this subscriber.
|
||||
if !subscriptionEvent.Live && event.Entry.Seq <= emittedThrough[event.Stream.ID] {
|
||||
continue
|
||||
}
|
||||
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
|
||||
@@ -228,7 +235,9 @@ func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInsta
|
||||
return nil, err
|
||||
}
|
||||
for _, stream := range active.streams {
|
||||
emittedThrough[stream.ID] = stream.LatestSeq
|
||||
// Stream metadata identifies the current channel only. The platform
|
||||
// never replays its old body when a live subscriber connects.
|
||||
emittedThrough[stream.ID] = 0
|
||||
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -81,6 +81,26 @@ func TestLogIngestAPIWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveLogRelayAPIForwardsWithoutStoredOutput(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
batch := validLogBatchRequest(t, hello.SessionToken, 1, 1)
|
||||
|
||||
relay := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", batch)
|
||||
assertStatus(t, relay, http.StatusOK)
|
||||
ack := decodeBody[dto.LogBatchIngestResponse](t, relay)
|
||||
if !ack.Accepted || ack.AcceptedFrom != 1 || ack.AcceptedTo != 1 {
|
||||
t.Fatalf("unexpected live relay ack: %+v", ack)
|
||||
}
|
||||
|
||||
query := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: batch.LogStreamID, AfterSeq: 0, Limit: 10})
|
||||
assertStatus(t, query, http.StatusOK)
|
||||
body := decodeBody[dto.LogStreamCursorResponse](t, query)
|
||||
if len(body.Entries) != 0 || body.LatestSeq != 1 {
|
||||
t.Fatalf("live relay stored platform log output: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogEventsSSEDoesNotReplayHistory(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
@@ -153,6 +173,41 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) {
|
||||
assertSSEEvent(t, reader, "log", `"seq":2`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSERelaysLiveBatchesWithoutSequenceGate(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events", nil).WithContext(ctx)
|
||||
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
router.ServeHTTP(streamWriter, request)
|
||||
_ = streamWriter.Close()
|
||||
close(done)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
_ = streamReader.Close()
|
||||
<-done
|
||||
})
|
||||
if status := <-streamWriter.status; status != http.StatusOK {
|
||||
t.Fatalf("unexpected SSE status: %d", status)
|
||||
}
|
||||
reader := bufio.NewReader(streamReader)
|
||||
assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`)
|
||||
assertSSEEvent(t, reader, "stream", `"id":"log-1"`)
|
||||
assertSSEEvent(t, reader, "ready", `"streamCount":1`)
|
||||
|
||||
first := validLogBatchRequest(t, hello.SessionToken, 4, 4)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", first), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "log", `"seq":4`)
|
||||
|
||||
second := validLogBatchRequest(t, hello.SessionToken, 1, 1)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", second), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "log", `"seq":1`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSESkipsBufferedBackfillAfterOpen(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
|
||||
@@ -130,6 +130,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/run/jobs/cancel", h.requireRunSignature(h.runJobCancelPoll))
|
||||
mux.HandleFunc("/api/v1/run/jobs/reconcile", h.requireRunSignature(h.runJobReconcile))
|
||||
mux.HandleFunc("/api/v1/run/logs/batches", h.requireRunSignature(h.runLogBatchIngest))
|
||||
mux.HandleFunc("/api/v1/run/logs/relay", h.requireRunSignature(h.runLiveLogRelay))
|
||||
mux.HandleFunc("/api/v1/run/logs/progress", h.requireRunSignature(h.runLogStreamProgress))
|
||||
mux.HandleFunc("/api/v1/run/artifacts/open", h.requireRunSignature(h.runArtifactOpen))
|
||||
mux.HandleFunc("/api/v1/run/artifacts/chunks", h.requireRunSignature(h.runArtifactChunkUpload))
|
||||
@@ -153,6 +154,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/claim", h.gameClientBridgeCompanionClaim)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/{commandId}/ack", h.gameClientBridgeCompanionAck)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/{commandId}/result", h.gameClientBridgeCompanionResult)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/logs/events", h.gameClientBridgeCompanionLogEvents)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/snapshots", h.gameClientBridgeCompanionSnapshot)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/diagnostics", h.gameClientBridgeCompanionDiagnostics)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
||||
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. It does not replay retained log entries, and it drops accepted log batches whose source entry timestamps are older than the current SSE connection after a small clock-skew allowance; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits current live Run log relay events only. It does not replay retained platform log entries. SCUM companion streaming (`POST /api/v1/game-client-bridge/companion/logs/events`) uses the component session and receives the same current live stream so the plugin can own game-log storage, analysis, and console fan-out.
|
||||
|
||||
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||
|
||||
@@ -194,16 +194,17 @@ Job ack/progress/result/cancel/reconcile calls remain lightweight and independen
|
||||
|
||||
Run file input chunks are used only for browser-staged file uploads that produce `artifact://` job inputs. The route never returns storage backend paths, browser bearer credentials, machine paths, direct sockets, or unrestricted artifact bodies. This channel is lower priority than control, job lifecycle calls, and log ingest.
|
||||
|
||||
## Implemented Log Ingest Actions
|
||||
## Implemented Live Log Relay Actions
|
||||
|
||||
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
|
||||
- `POST /api/v1/run/logs/relay`: accept `LogBatchIngestRequest`, validate run session and stream metadata, update current stream/session metadata, and immediately fan out entries to live subscribers without storing log bodies.
|
||||
- `GET /api/v1/server-instances/{id}/logs/events`: stream the current supervised process session to the server terminal drawer without replaying old retained log bodies.
|
||||
- `POST /api/v1/game-client-bridge/companion/logs/events`: stream the current supervised process session to a component-authenticated game companion so the plugin can store/analyze game logs.
|
||||
- `POST /api/v1/run/logs/batches`: compatibility/internal durable ingest for older workers; current Run output should use `/run/logs/relay` instead of a local spool/cache/resend loop.
|
||||
- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
|
||||
Server-scoped SSE log streaming is removed from product routes. `POST /api/v1/log-streams/query` remains the bounded cursor contract for internal maintenance/debug reads.
|
||||
|
||||
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
|
||||
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
||||
Live log relay actions carry current log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data. Run must not block lifecycle/control/job progress on whether Platform or a plugin subscriber received live logs.
|
||||
|
||||
Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and operational records. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments.
|
||||
Platform storage is configured by `PLATFORM_STORAGE_BACKEND` for platform metadata and compatibility durable-ingest bodies only. Current live relay does not persist log bodies in Platform. SCUM durable console logs and semantic events are stored by the SCUM plugin companion in plugin-owned SQL tables; raw trajectory samples keep world coordinates and do not perform projection or coordinate conversion.
|
||||
|
||||
## Implemented Run Artifact Actions
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
type liveLogRelayCore interface {
|
||||
RelayLiveLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
||||
}
|
||||
|
||||
// runLiveLogRelay accepts current Run output and immediately fans it out to
|
||||
// subscribers. It has no durable body or delivery acknowledgement contract.
|
||||
func (h *coreHandlers) runLiveLogRelay(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
core, ok := h.core.(liveLogRelayCore)
|
||||
if !ok {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.LogBatchIngestRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := core.RelayLiveLogBatch(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.LogBatchIngestFromDomain(result))
|
||||
}
|
||||
Reference in New Issue
Block a user