Stream live server logs over SSE

This commit is contained in:
npc0-hue
2026-08-03 22:28:54 +08:00
parent 5d4fca14f9
commit 7eac1926dd
48 changed files with 1526 additions and 263 deletions
+187
View File
@@ -0,0 +1,187 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/service"
)
const (
defaultLogEventHistoryLimit = 100
maxLogEventHistoryLimit = 500
logEventHeartbeatInterval = 15 * time.Second
)
// serverLogEvents godoc
// @Summary Stream server log events
// @Description Streams safe server log entries over Server-Sent Events. Durable cursor query remains available for history and reconnect repair.
// @Tags logs
// @Produce text/event-stream
// @Param id path string true "Server instance ID"
// @Param historyLimit query int false "Recent entries per stream to replay before live events"
// @Success 200 {string} string "event-stream"
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/logs/events [get]
func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
instance, streams, subscription, err := h.openLogEventSubscription(r)
if err != nil {
writeServiceError(w, err)
return
}
defer subscription.Close()
flusher, ok := w.(http.Flusher)
if !ok {
writeServiceError(w, fmt.Errorf("streaming response unsupported"))
return
}
header := w.Header()
header.Set("Content-Type", "text/event-stream")
header.Set("Cache-Control", "no-cache, no-transform")
header.Set("Connection", "keep-alive")
header.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
historyLimit := parseLogEventHistoryLimit(r.URL.Query().Get("historyLimit"))
for _, stream := range streams {
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
return
}
if historyLimit > 0 {
if err := h.writeLogEventHistory(w, stream, historyLimit); err != nil {
_ = writeSSEJSON(w, "error", "", map[string]string{"message": err.Error()})
flusher.Flush()
return
}
}
}
if err := writeSSEJSON(w, "ready", "", dto.LogStreamEventsReadyResponse{ServerInstanceID: instance.ID, StreamCount: len(streams), ServerTime: time.Now().UTC()}); err != nil {
return
}
flusher.Flush()
heartbeat := time.NewTicker(logEventHeartbeatInterval)
defer heartbeat.Stop()
for {
select {
case <-r.Context().Done():
return
case event, ok := <-subscription.Events:
if !ok {
return
}
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
return
}
flusher.Flush()
case <-heartbeat.C:
if _, err := fmt.Fprintf(w, ": heartbeat %s\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
return
}
flusher.Flush()
}
}
}
func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerInstance, []domain.LogStream, service.LogEventSubscription, error) {
var instance domain.ServerInstance
var streams []domain.LogStream
var subscription service.LogEventSubscription
var err error
if h.enforceAuthorization {
sessionID := bearerToken(r)
instance, err = h.core.GetServerInstanceForSession(sessionID, r.PathValue("id"))
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
}
streams, err = h.core.ListLogStreamsForSession(sessionID, domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
}
subscription, err = h.core.SubscribeLogEventsForSession(sessionID, instance.ID)
} else {
instance, err = h.core.GetServerInstance(r.PathValue("id"))
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
}
streams, err = h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
}
subscription, err = h.core.SubscribeLogEvents(instance.ID)
}
return instance, streams, subscription, err
}
func (h *coreHandlers) writeLogEventHistory(w http.ResponseWriter, stream domain.LogStream, limit int) error {
afterSeq := uint64(0)
if stream.LatestSeq > uint64(limit) {
afterSeq = stream.LatestSeq - uint64(limit)
}
cursor, err := h.core.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: stream.ID, AfterSeq: afterSeq, Limit: limit})
if err != nil {
return err
}
for _, entry := range cursor.Entries {
event := domain.LogStreamEvent{ServerInstanceID: stream.ServerInstanceID, Stream: stream, Entry: entry, LatestSeq: cursor.LatestSeq}
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
return err
}
}
return nil
}
func parseLogEventHistoryLimit(value string) int {
if strings.TrimSpace(value) == "" {
return defaultLogEventHistoryLimit
}
limit, err := strconv.Atoi(value)
if err != nil || limit < 0 {
return defaultLogEventHistoryLimit
}
if limit > maxLogEventHistoryLimit {
return maxLogEventHistoryLimit
}
return limit
}
func writeSSEJSON(w http.ResponseWriter, eventName string, id string, value any) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
if id != "" {
if _, err := fmt.Fprintf(w, "id: %s\n", sanitizeSSEField(id)); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "event: %s\n", sanitizeSSEField(eventName)); err != nil {
return err
}
_, err = fmt.Fprintf(w, "data: %s\n\n", payload)
return err
}
func sanitizeSSEField(value string) string {
value = strings.ReplaceAll(value, "\r", "")
value = strings.ReplaceAll(value, "\n", "")
return value
}
func logEventID(event domain.LogStreamEvent) string {
return fmt.Sprintf("%s:%d", sanitizeSSEField(event.Stream.ID), event.Entry.Seq)
}
+43
View File
@@ -1,7 +1,10 @@
package api
import (
"bufio"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -35,6 +38,32 @@ func TestLogIngestAPIWorkflow(t *testing.T) {
}
}
func TestLogEventsSSEReplaysHistory(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
batch := validLogBatchRequest(t, hello.SessionToken, 1, 2)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK)
server := httptest.NewServer(router)
defer server.Close()
client := server.Client()
client.Timeout = 2 * time.Second
response, err := client.Get(server.URL + "/api/v1/server-instances/server-1/logs/events?historyLimit=2")
if err != nil {
t.Fatalf("open log event stream: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") {
t.Fatalf("unexpected event stream response: status=%d content-type=%q", response.StatusCode, response.Header.Get("Content-Type"))
}
body := readSSEUntil(t, response, "event: ready")
for _, fragment := range []string{"event: stream", "event: log", `"streamId":"log-1"`, `"seq":1`, `"seq":2`} {
if !strings.Contains(body, fragment) {
t.Fatalf("expected SSE body to contain %q, got:\n%s", fragment, body)
}
}
}
func TestLogIngestAPIDuplicateAndErrors(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
@@ -77,6 +106,20 @@ func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunContro
return hello
}
func readSSEUntil(t *testing.T, response *http.Response, marker string) string {
t.Helper()
reader := bufio.NewReader(response.Body)
var body strings.Builder
for !strings.Contains(body.String(), marker) {
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read event stream: %v\n%s", err, body.String())
}
body.WriteString(line)
}
return body.String()
}
func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest {
t.Helper()
entries := make([]dto.LogEntryBody, 0, lastSeq-firstSeq+1)
+1
View File
@@ -108,6 +108,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
+22 -4
View File
@@ -114,7 +114,18 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
}
getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-1")
streams := getJSON[dto.LogStreamListResponse](t, router, "/api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout")
assertListCount(t, streams.Count, 1)
if streams.Count < 1 {
t.Fatalf("expected stdout streams, got %+v", streams)
}
foundExplicitStream := false
for _, stream := range streams.Items {
if stream.ID == "log-1" {
foundExplicitStream = true
}
}
if !foundExplicitStream {
t.Fatalf("expected explicitly created stream in list, got %+v", streams)
}
auditResponse := postJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events", dto.AuditEventCreateRequest{
ID: "audit-1",
@@ -361,7 +372,13 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
t.Fatalf("unexpected log backfill job: %+v", backfill)
}
liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession)
if liveLogs.Count != 1 || liveLogs.Items[0].StreamKey != "stdout" {
foundFileLog := false
for _, stream := range liveLogs.Items {
if stream.Source == domain.LogStreamSourceFile && stream.StreamKey == "latest-log" {
foundFileLog = true
}
}
if !foundFileLog {
t.Fatalf("unexpected live logs: %+v", liveLogs)
}
@@ -1503,7 +1520,7 @@ func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) {
assertErrorResponse(t, undeclared, http.StatusBadRequest, errorCodeValidation)
created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-complete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Complete", IdempotencyKey: "runtime-create-complete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-create-complete/rcon"}}, adminSession)
if created.Job.TargetKey != "local" {
if created.Job.TargetKey != "actions/install.json" {
t.Fatalf("create workflow did not dispatch selected profile: %+v", created.Job)
}
incompleteCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-incomplete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Incomplete", IdempotencyKey: "runtime-create-incomplete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession)
@@ -1858,6 +1875,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
}
pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
pluginRequest.RuntimeProfiles.LogSources = []dto.RuntimeLogSourceBody{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
@@ -1886,7 +1904,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
Name: "Runtime API Server",
State: domain.ServerInstanceStateReady,
}, adminSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"logs/latest": "runtime.logs.latest"}}, adminSession)
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-runtime-api",
ServerInstanceID: server.ID,
+4 -2
View File
@@ -14,7 +14,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest` |
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `GET /api/v1/server-instances/{id}/logs/events`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest`, `LogStreamEventResponse` |
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
@@ -146,7 +146,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
## Implemented Runtime Distribution And Client Manager Actions
- `GET /api/v1/server-instances/{id}/runtime-binding`: returns the visible server's selected profile and redacted logical binding readiness. Values are represented only by configured/secret-backed flags.
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs. Undeclared keys, unsafe paths/sockets/credentials, and changes to an existing active binding are rejected.
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs for non-deleted servers. Undeclared keys, unsafe paths/sockets/credentials, and plaintext secrets are rejected.
- `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons.
- `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, queues a platform-owned Docker build, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, build job ID, and redacted secret ref only.
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
@@ -160,6 +160,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
- `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.
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`.
- `GET /api/v1/server-instances/{id}/logs/events`: streams selected server log metadata and entries as `text/event-stream`; the optional `historyLimit` query replays recent stored entries before live push events.
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support. Responses and audit 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.
@@ -196,6 +197,7 @@ Job ack/progress/result/cancel/reconcile calls remain lightweight and independen
- `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/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
- `GET /api/v1/server-instances/{id}/logs/events`: authorize the browser session for the server, replay bounded recent entries, and push newly ingested log entries over SSE without polling log stream queries.
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.
+2
View File
@@ -324,6 +324,8 @@ type RunJobReconcileResult struct {
func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment {
assignment.ExecutionInput.Inputs = CopyStringMap(assignment.ExecutionInput.Inputs)
assignment.ExecutionInput.LogSource = CopyRuntimeLogSourcePtr(assignment.ExecutionInput.LogSource)
assignment.ExecutionInput.LogSources = CopyRuntimeLogSources(assignment.ExecutionInput.LogSources)
assignment.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), assignment.ExecutionInput.DLLExtensions...)
assignment.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(assignment.ExecutionInput.SourceRCON)
return assignment
+13
View File
@@ -48,6 +48,13 @@ type LogStreamCursorResult struct {
LatestSeq uint64
}
type LogStreamEvent struct {
ServerInstanceID string
Stream LogStream
Entry LogEntry
LatestSeq uint64
}
type LogBatchRecord struct {
Checksum string
FirstSeq uint64
@@ -87,6 +94,12 @@ func CopyLogStreamCursorResult(result LogStreamCursorResult) LogStreamCursorResu
return result
}
func CopyLogStreamEvent(event LogStreamEvent) LogStreamEvent {
event.Stream = CopyLogStream(event.Stream)
event.Entry = CopyLogEntry(event.Entry)
return event
}
func CopyLogBatchRecord(record LogBatchRecord) LogBatchRecord {
record.Entries = CopyLogEntries(record.Entries)
return record
+19
View File
@@ -1109,6 +1109,8 @@ type JobExecutionInput struct {
LifecycleOperation string
TargetVersion string
Inputs map[string]string
LogSource *RuntimeLogSource
LogSources []RuntimeLogSource
DLLExtensions []RuntimeDLLExtensionPlan
SourceRCON *RuntimeSourceRCONPlan
Deployment *ServerDeploymentDefinition
@@ -1988,6 +1990,8 @@ func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint {
func CopyJob(job Job) Job {
job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs)
job.ExecutionInput.LogSource = CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource)
job.ExecutionInput.LogSources = CopyRuntimeLogSources(job.ExecutionInput.LogSources)
job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)
job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON)
job.ExecutionInput.ServerDeploymentPlan = CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)
@@ -2000,6 +2004,21 @@ func CopyJob(job Job) Job {
return job
}
func CopyRuntimeLogSourcePtr(source *RuntimeLogSource) *RuntimeLogSource {
if source == nil {
return nil
}
copy := *source
return &copy
}
func CopyRuntimeLogSources(sources []RuntimeLogSource) []RuntimeLogSource {
if sources == nil {
return nil
}
return append([]RuntimeLogSource(nil), sources...)
}
func CopyRuntimeSourceRCONPlan(plan *RuntimeSourceRCONPlan) *RuntimeSourceRCONPlan {
if plan == nil {
return nil
+21 -1
View File
@@ -105,6 +105,8 @@ type RunJobExecutionInputBody struct {
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
TargetVersion string `json:"targetVersion,omitempty"`
Inputs map[string]string `json:"inputs,omitempty"`
LogSource *RuntimeLogSourceBody `json:"logSource,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"`
Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"`
@@ -661,7 +663,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
State: assignment.State,
Progress: progressReportFromDomain(assignment.Progress),
ResultRef: assignment.ResultRef,
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)},
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), LogSource: runtimeLogSourceFromDomain(assignment.ExecutionInput.LogSource), LogSources: runtimeLogSourcesFromDomain(assignment.ExecutionInput.LogSources), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)},
LeaseToken: assignment.LeaseToken,
Attempt: assignment.Attempt,
FencingToken: assignment.FencingToken,
@@ -736,6 +738,24 @@ func runtimeSourceRCONPlanFromDomain(plan *domain.RuntimeSourceRCONPlan) *Runtim
return &RuntimeSourceRCONPlanBody{Protocol: plan.Protocol, ExtensionKey: plan.ExtensionKey, ModKey: plan.ModKey, ConfigRef: plan.ConfigRef, DeploymentStateRef: plan.DeploymentStateRef, Port: plan.Port}
}
func runtimeLogSourceFromDomain(source *domain.RuntimeLogSource) *RuntimeLogSourceBody {
if source == nil {
return nil
}
return &RuntimeLogSourceBody{Key: source.Key, Kind: source.Kind, TargetKey: source.TargetKey, StreamKey: source.StreamKey, CursorKind: source.CursorKind, RetentionDays: source.RetentionDays}
}
func runtimeLogSourcesFromDomain(sources []domain.RuntimeLogSource) []RuntimeLogSourceBody {
if len(sources) == 0 {
return nil
}
out := make([]RuntimeLogSourceBody, 0, len(sources))
for _, source := range sources {
out = append(out, RuntimeLogSourceBody{Key: source.Key, Kind: source.Kind, TargetKey: source.TargetKey, StreamKey: source.StreamKey, CursorKind: source.CursorKind, RetentionDays: source.RetentionDays})
}
return out
}
func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressReport {
return domain.RunJobProgressReport{
Percent: progress.Percent,
+39 -8
View File
@@ -53,6 +53,21 @@ type LogStreamCursorResponse struct {
LatestSeq uint64 `json:"latestSeq"`
}
type LogStreamEventResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
StreamID string `json:"streamId"`
Source domain.LogStreamSource `json:"source"`
StreamKey string `json:"streamKey"`
LatestSeq uint64 `json:"latestSeq"`
Entry LogEntryBody `json:"entry"`
}
type LogStreamEventsReadyResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
StreamCount int `json:"streamCount"`
ServerTime time.Time `json:"serverTime"`
}
func (request LogBatchIngestRequest) ToDomain() domain.LogBatchIngest {
return domain.LogBatchIngest{
RunEndpointID: request.RunEndpointID,
@@ -99,6 +114,18 @@ func LogStreamCursorFromDomain(result domain.LogStreamCursorResult) LogStreamCur
}
}
func LogStreamEventFromDomain(event domain.LogStreamEvent) LogStreamEventResponse {
event = domain.CopyLogStreamEvent(event)
return LogStreamEventResponse{
ServerInstanceID: event.ServerInstanceID,
StreamID: event.Stream.ID,
Source: event.Stream.Source,
StreamKey: event.Stream.StreamKey,
LatestSeq: event.LatestSeq,
Entry: logEntryFromDomain(event.Entry),
}
}
func logEntriesToDomain(entries []LogEntryBody) []domain.LogEntry {
if entries == nil {
return nil
@@ -117,20 +144,24 @@ func logEntriesToDomain(entries []LogEntryBody) []domain.LogEntry {
return out
}
func logEntryFromDomain(entry domain.LogEntry) LogEntryBody {
return LogEntryBody{
Seq: entry.Seq,
Timestamp: entry.Timestamp,
Level: entry.Level,
Line: entry.Line,
Fields: copyStringMap(entry.Fields),
Redacted: entry.Redacted,
}
}
func logEntriesFromDomain(entries []domain.LogEntry) []LogEntryBody {
if entries == nil {
return nil
}
out := make([]LogEntryBody, len(entries))
for i, entry := range entries {
out[i] = LogEntryBody{
Seq: entry.Seq,
Timestamp: entry.Timestamp,
Level: entry.Level,
Line: entry.Line,
Fields: copyStringMap(entry.Fields),
Redacted: entry.Redacted,
}
out[i] = logEntryFromDomain(entry)
}
return out
}
+3 -1
View File
@@ -63,6 +63,7 @@ Implemented HTTP JSON routes:
- `POST /api/v1/run/logs/batches`
- `POST /api/v1/log-streams/query`
- `GET /api/v1/server-instances/{id}/logs/events`
Named log DTOs:
@@ -71,8 +72,9 @@ Named log DTOs:
- `LogEntry`
- `LogStreamCursorRequest`
- `LogStreamCursorResponse`
- `LogStreamEventResponse`
Log ingest supports bounded batches, sequence ranges, checksum validation, retry-safe duplicate acknowledgement, latest sequence tracking, and cursor query. Log payloads must not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
Log ingest supports bounded batches, sequence ranges, checksum validation, retry-safe duplicate acknowledgement, latest sequence tracking, cursor query, and browser SSE fan-out from already-ingested platform logs. Log payloads must 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 batch acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
+21 -1
View File
@@ -745,6 +745,11 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: plugin capability is not declared")
return domain.Job{}, ErrForbidden
}
source, err := declaredFileLogSource(plugin, request.SourceKey)
if err != nil {
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: log source is not declared")
return domain.Job{}, err
}
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "logs.backfill.denied"); err != nil {
return domain.Job{}, err
}
@@ -753,10 +758,11 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: domain.JobCapabilityLogsBackfill,
TargetKey: "logs/" + request.SourceKey,
TargetKey: "logs/" + source.Key,
InputRef: request.CheckpointRef,
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"},
ExecutionInput: domain.JobExecutionInput{LogSource: &source},
})
if err != nil {
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: endpoint unsupported or offline")
@@ -768,6 +774,20 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
return domain.CopyJob(job), nil
}
func declaredFileLogSource(plugin domain.GamePlugin, sourceKey string) (domain.RuntimeLogSource, error) {
for _, source := range plugin.RuntimeProfiles.LogSources {
if source.Key != sourceKey {
continue
}
if source.Kind != "file.tail" || strings.TrimSpace(source.TargetKey) == "" || strings.TrimSpace(source.StreamKey) == "" {
return domain.RuntimeLogSource{}, validationError("log source must be a file.tail source with target and stream keys")
}
copy := source
return copy, nil
}
return domain.RuntimeLogSource{}, validationError("log source is not declared by plugin")
}
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
if plugin.Status != domain.GamePluginStatusInstalled {
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin is not installed")
+27 -1
View File
@@ -627,6 +627,7 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
)
plugin.RuntimeProfiles.DependencyProbes = []domain.RuntimeDependencyProbe{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
plugin.RuntimeProfiles.InstallPlans = []domain.RuntimeInstallPlan{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []domain.RuntimeInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin fixture: %v", err)
@@ -664,10 +665,35 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
if err != nil {
t.Fatalf("create distribution server: %v", err)
}
createCompleteRuntimeBinding(t, svc, instance, "local")
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"logs/latest": "runtime.logs.latest"}}, true)
if err != nil {
t.Fatalf("build complete runtime binding: %v", err)
}
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
t.Fatalf("create runtime binding: %v", err)
}
return svc, session, instance
}
func TestQueueLogBackfillFreezesDeclaredFileLogSource(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
job, err := svc.QueueLogBackfillForSession(session, domain.LogBackfillRequest{ServerInstanceID: instance.ID, SourceKey: "latest", CheckpointRef: "artifact://logs/checkpoint/1", IdempotencyKey: "log-source-freeze"})
if err != nil {
t.Fatalf("queue log backfill: %v", err)
}
if job.ExecutionInput.LogSource == nil || job.ExecutionInput.LogSource.Key != "latest" || job.ExecutionInput.LogSource.StreamKey != "latest-log" || job.ExecutionInput.LogSource.TargetKey != "logs/latest" {
t.Fatalf("expected frozen declared log source, got %+v", job.ExecutionInput.LogSource)
}
stream, err := svc.GetLogStream(jobLogStreamID(job.ID, "latest-log"))
if err != nil || stream.Source != domain.LogStreamSourceFile || stream.StreamKey != "latest-log" {
t.Fatalf("expected file log stream for backfill job, stream=%+v err=%v", stream, err)
}
if _, err := svc.QueueLogBackfillForSession(session, domain.LogBackfillRequest{ServerInstanceID: instance.ID, SourceKey: "missing", IdempotencyKey: "log-source-missing"}); err == nil || !strings.Contains(err.Error(), "not declared") {
t.Fatalf("expected undeclared source rejection, got %v", err)
}
}
func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string, artifactID string) generatedPackageConfig {
t.Helper()
_ = session
+1 -1
View File
@@ -639,7 +639,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
State: job.State,
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Phase: job.Progress.Phase, Message: job.Progress.Message},
ResultRef: job.ResultRef,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
LeaseToken: leaseToken,
Attempt: job.Attempt,
FencingToken: fencingToken,
+87
View File
@@ -0,0 +1,87 @@
package service
import (
"strings"
"browser.local/platform/domain"
)
const logEventSubscriberBuffer = 512
type LogEventSubscription struct {
Events <-chan domain.LogStreamEvent
Close func()
}
type logEventSubscriber struct {
serverInstanceID string
events chan domain.LogStreamEvent
}
func (svc *CoreService) SubscribeLogEvents(serverInstanceID string) (LogEventSubscription, error) {
serverInstanceID = strings.TrimSpace(serverInstanceID)
if serverInstanceID == "" {
return LogEventSubscription{}, validationError("serverInstanceId is required")
}
if _, err := svc.store.ServerInstances().Get(serverInstanceID); err != nil {
return LogEventSubscription{}, err
}
events := make(chan domain.LogStreamEvent, logEventSubscriberBuffer)
svc.logEventMu.Lock()
svc.logEventSubscriberSeq++
id := svc.logEventSubscriberSeq
svc.logEventSubscribers[id] = logEventSubscriber{serverInstanceID: serverInstanceID, events: events}
svc.logEventMu.Unlock()
closeOnce := func() {
svc.logEventMu.Lock()
if subscriber, ok := svc.logEventSubscribers[id]; ok {
delete(svc.logEventSubscribers, id)
close(subscriber.events)
}
svc.logEventMu.Unlock()
}
return LogEventSubscription{Events: events, Close: closeOnce}, nil
}
func (svc *CoreService) SubscribeLogEventsForSession(sessionID string, serverInstanceID string) (LogEventSubscription, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return LogEventSubscription{}, err
}
return svc.SubscribeLogEvents(instance.ID)
}
func (svc *CoreService) publishLogEvents(stream domain.LogStream, entries []domain.LogEntry) {
if len(entries) == 0 {
return
}
events := make([]domain.LogStreamEvent, len(entries))
for index, entry := range entries {
events[index] = domain.CopyLogStreamEvent(domain.LogStreamEvent{
ServerInstanceID: stream.ServerInstanceID,
Stream: stream,
Entry: entry,
LatestSeq: stream.LatestSeq,
})
}
svc.logEventMu.Lock()
for id, subscriber := range svc.logEventSubscribers {
if subscriber.serverInstanceID != stream.ServerInstanceID {
continue
}
dropped := false
for _, event := range events {
select {
case subscriber.events <- event:
default:
delete(svc.logEventSubscribers, id)
close(subscriber.events)
dropped = true
}
if dropped {
break
}
}
}
svc.logEventMu.Unlock()
}
+40
View File
@@ -1,7 +1,12 @@
package service
import (
"errors"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
@@ -19,6 +24,11 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
stamp := svc.now()
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
if errors.Is(err, repo.ErrNotFound) {
if repairErr := svc.ensureJobLogStreamForBatch(batch, stamp); repairErr == nil {
stream, err = svc.store.LogStreams().Get(batch.LogStreamID)
}
}
if err != nil {
return domain.LogBatchIngestResult{}, err
}
@@ -76,6 +86,7 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
if err := svc.projectGameMapTrajectoryEvents(projectionBatch); err != nil {
return domain.LogBatchIngestResult{}, err
}
svc.publishLogEvents(stream, storedBatch.Entries)
return domain.LogBatchIngestResult{
Accepted: true,
LogStreamID: batch.LogStreamID,
@@ -86,6 +97,35 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
}, nil
}
func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error {
jobID, ok := jobIDFromLogBatch(batch)
if !ok {
return repo.ErrNotFound
}
job, err := svc.store.Jobs().Get(jobID)
if err != nil {
return err
}
if job.ServerInstanceID != batch.ServerInstanceID || job.RunEndpointID != batch.RunEndpointID {
return validationError("log batch job scope does not match stream")
}
return svc.ensureJobLogStreams(job, stamp)
}
func jobIDFromLogBatch(batch domain.LogBatchIngest) (string, bool) {
streamKey := strings.TrimSpace(batch.StreamKey)
if streamKey == "" || !strings.HasPrefix(batch.LogStreamID, "job.") {
return "", false
}
suffix := "." + streamKey
body := strings.TrimPrefix(batch.LogStreamID, "job.")
if !strings.HasSuffix(body, suffix) {
return "", false
}
jobID := strings.TrimSuffix(body, suffix)
return jobID, strings.TrimSpace(jobID) != ""
}
func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIngest) bool {
if record.Checksum == batch.Checksum {
return true
+119
View File
@@ -39,6 +39,38 @@ func TestCoreServiceIngestsLogBatchAndQueriesCursor(t *testing.T) {
}
}
func TestCoreServicePublishesLogEventsForAcceptedBatch(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)
if _, err := svc.IngestLogBatch(batch); err != nil {
t.Fatalf("ingest log batch: %v", err)
}
select {
case event := <-subscription.Events:
if event.Stream.ID != "log-1" || event.Entry.Seq != 1 || event.LatestSeq != 1 {
t.Fatalf("unexpected log event: %+v", event)
}
case <-time.After(time.Second):
t.Fatal("expected log event after accepted batch")
}
if _, err := svc.IngestLogBatch(batch); err != nil {
t.Fatalf("ingest duplicate batch: %v", err)
}
select {
case event := <-subscription.Events:
t.Fatalf("duplicate batch should not publish a second event: %+v", event)
default:
}
}
func TestCoreServiceLogBatchDuplicateAck(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
@@ -114,6 +146,93 @@ func TestCoreServiceAcceptsAutoCreatedRunJobLogStreams(t *testing.T) {
}
}
func TestCoreServiceAcceptsPluginDeclaredProcessLogStreams(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
job, err := svc.CreateJob(domain.Job{
ID: "job-declared-process-logs",
ServerInstanceID: "server-1",
RunEndpointID: "run-local",
Capability: domain.LifecycleCapabilityStart,
IdempotencyKey: "job-declared-process-logs",
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "run-local", LifecycleOperation: "start", LogSources: []domain.RuntimeLogSource{
{Key: "console-out", Kind: "process.stdout", StreamKey: "scum.console.stdout", CursorKind: "sequence", RetentionDays: 30},
{Key: "console-err", Kind: "process.stderr", StreamKey: "scum.console.stderr", CursorKind: "sequence", RetentionDays: 30},
}},
})
if err != nil {
t.Fatalf("create job: %v", err)
}
streamID := jobLogStreamID(job.ID, "scum.console.stdout")
stream, err := svc.GetLogStream(streamID)
if err != nil {
t.Fatalf("get declared process log stream: %v", err)
}
if stream.StreamKey != "scum.console.stdout" || stream.Source != domain.LogStreamSourceProcess {
t.Fatalf("unexpected declared stream metadata: %+v", stream)
}
entry := domain.LogEntry{Seq: 1, Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC), Level: "info", Line: "LogStreaming: Display: server ready"}
ack, err := svc.IngestLogBatch(domain.LogBatchIngest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
LogStreamID: streamID,
ServerInstanceID: "server-1",
StreamKey: "scum.console.stdout",
Source: domain.LogStreamSourceProcess,
FirstSeq: entry.Seq,
LastSeq: entry.Seq,
Compression: "none",
Checksum: validator.LogLineChecksum(entry.Line),
Entries: []domain.LogEntry{entry},
})
if err != nil {
t.Fatalf("ingest declared process log batch: %v", err)
}
if !ack.Accepted || ack.LatestSeq != entry.Seq {
t.Fatalf("unexpected declared stream ack: %+v", ack)
}
}
func TestCoreServiceRepairsMissingDeclaredProcessLogStreamOnIngest(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
job := domain.Job{
ID: "job-repaired-process-logs",
ServerInstanceID: "server-1",
RunEndpointID: "run-local",
Capability: domain.LifecycleCapabilityStart,
IdempotencyKey: "job-repaired-process-logs",
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "run-local", LifecycleOperation: "start", LogSources: []domain.RuntimeLogSource{
{Key: "console-out", Kind: "process.stdout", StreamKey: "scum.console.stdout", CursorKind: "sequence", RetentionDays: 30},
}},
}
if err := svc.store.Jobs().Create(job); err != nil {
t.Fatalf("seed legacy job without streams: %v", err)
}
streamID := jobLogStreamID(job.ID, "scum.console.stdout")
if _, err := svc.GetLogStream(streamID); err == nil {
t.Fatal("expected declared stream to be missing before ingest repair")
}
entry := domain.LogEntry{Seq: 1, Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC), Level: "info", Line: "LogStreaming: Display: recovered from spool"}
ack, err := svc.IngestLogBatch(domain.LogBatchIngest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
LogStreamID: streamID,
ServerInstanceID: "server-1",
StreamKey: "scum.console.stdout",
Source: domain.LogStreamSourceProcess,
FirstSeq: entry.Seq,
LastSeq: entry.Seq,
Compression: "none",
Checksum: validator.LogLineChecksum(entry.Line),
Entries: []domain.LogEntry{entry},
})
if err != nil {
t.Fatalf("ingest repaired declared process log batch: %v", err)
}
if !ack.Accepted || ack.LatestSeq != entry.Seq {
t.Fatalf("unexpected repaired stream ack: %+v", ack)
}
}
func TestCoreServiceRejectsOutOfOrderAndConflictingLogBatches(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
+14 -7
View File
@@ -100,6 +100,19 @@ func (svc *CoreService) dispatchProtectedRequest(command domain.GameClientBridge
if jobID == "" {
return validationError("protected request job binding is missing")
}
executionInput := domain.JobExecutionInput{
WorkspaceScope: command.ProfileKey,
RemoteAdapterKey: declaration.ProtectedRequest.TransportKey,
RemoteAdapterKind: adapterKind,
TimeoutSeconds: declaration.TimeoutSeconds,
PluginID: command.PluginID,
}
if declaration.ProtectedRequest.Kind == "rcon" {
if resolution, resolveErr := svc.resolveProtectedSourceRCONDispatch(command.ServerInstanceID, declaration.ProtectedRequest); resolveErr == nil {
executionInput.WorkspaceScope = resolution.binding.ProfileKey
executionInput.SourceRCON = resolution.plan
}
}
if err := svc.protectedRequests.Put(jobID, protectedRequestPayload{commandID: command.ID, kind: declaration.ProtectedRequest.Kind, transportKey: declaration.ProtectedRequest.TransportKey, targetKey: declaration.ProtectedRequest.TargetKey, requestText: requestText, expiresAt: command.ExpiresAt}); err != nil {
return err
}
@@ -113,13 +126,7 @@ func (svc *CoreService) dispatchProtectedRequest(command domain.GameClientBridge
IdempotencyKey: "protected-request:" + command.ID,
Progress: domain.JobProgress{Percent: 0, Message: "protected request queued"},
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1},
ExecutionInput: domain.JobExecutionInput{
WorkspaceScope: command.ProfileKey,
RemoteAdapterKey: declaration.ProtectedRequest.TransportKey,
RemoteAdapterKind: adapterKind,
TimeoutSeconds: declaration.TimeoutSeconds,
PluginID: command.PluginID,
},
ExecutionInput: executionInput,
}
if job.RunEndpointID == "" {
svc.protectedRequests.Delete(jobID)
+34 -13
View File
@@ -202,6 +202,8 @@ type Core interface {
GetLogStreamForSession(string, string) (domain.LogStream, error)
ListLogStreamsForSession(string, domain.LogStreamFilter) ([]domain.LogStream, error)
QueryLogStreamForSession(string, domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
SubscribeLogEvents(string) (LogEventSubscription, error)
SubscribeLogEventsForSession(string, string) (LogEventSubscription, error)
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
ListGamePlayersForSession(string, domain.GamePlayerFilter) ([]domain.GamePlayer, error)
@@ -236,6 +238,9 @@ type CoreService struct {
bridgeMu sync.Mutex
bridgeSeq uint64
logStore LogBodyStore
logEventMu sync.Mutex
logEventSubscribers map[uint64]logEventSubscriber
logEventSubscriberSeq uint64
artifactStore ArtifactBodyStore
artifactMu sync.Mutex
artifactTransfers map[string]domain.ArtifactTransferSession
@@ -279,6 +284,7 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
authSessions: map[string]string{},
runSessions: map[string]domain.RunControlSession{},
logStore: logStore,
logEventSubscribers: map[uint64]logEventSubscriber{},
artifactStore: artifactStore,
artifactTransfers: map[string]domain.ArtifactTransferSession{},
artifactPayloads: map[string][]byte{},
@@ -2329,21 +2335,36 @@ func (svc *CoreService) ensureJobLogStreams(job domain.Job, stamp time.Time) err
streams := []struct {
key string
source domain.LogStreamSource
}{
{key: "stdout", source: domain.LogStreamSourceProcess},
{key: "stderr", source: domain.LogStreamSourceProcess},
}{}
addStream := func(key string, source domain.LogStreamSource) {
key = strings.TrimSpace(key)
if key == "" {
return
}
for _, stream := range streams {
if stream.key == key {
return
}
}
streams = append(streams, struct {
key string
source domain.LogStreamSource
}{key: key, source: source})
}
addStream("stdout", domain.LogStreamSourceProcess)
addStream("stderr", domain.LogStreamSourceProcess)
for _, source := range job.ExecutionInput.LogSources {
if source.Kind != "process.stdout" && source.Kind != "process.stderr" {
continue
}
addStream(source.StreamKey, domain.LogStreamSourceProcess)
}
if job.Capability == domain.JobCapabilityRemoteRunProgram {
streams = append(streams,
struct {
key string
source domain.LogStreamSource
}{key: "management-program.stdout", source: domain.LogStreamSourceManagementProgram},
struct {
key string
source domain.LogStreamSource
}{key: "management-program.stderr", source: domain.LogStreamSourceManagementProgram},
)
addStream("management-program.stdout", domain.LogStreamSourceManagementProgram)
addStream("management-program.stderr", domain.LogStreamSourceManagementProgram)
}
if job.Capability == domain.JobCapabilityLogsBackfill && job.ExecutionInput.LogSource != nil && strings.TrimSpace(job.ExecutionInput.LogSource.StreamKey) != "" {
addStream(job.ExecutionInput.LogSource.StreamKey, domain.LogStreamSourceFile)
}
for _, item := range streams {
stream := domain.LogStream{
+19 -1
View File
@@ -1695,7 +1695,20 @@ func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domai
if err != nil {
t.Fatalf("get plugin for runtime binding: %v", err)
}
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: profileKey, Bindings: map[string]string{}}, true)
profile, ok := runtimeLifecycleProfile(plugin.RuntimeProfiles, profileKey)
if !ok {
t.Fatalf("runtime profile %s missing", profileKey)
}
required, _ := runtimeBindingKeys(plugin.RuntimeProfiles, profile)
bindings := map[string]string{}
for _, key := range required {
if runtimeBindingTestKeyIsSensitive(key) {
bindings[key] = "secret://" + instance.ID + "/" + strings.ReplaceAll(key, "/", "-")
} else {
bindings[key] = "runtime." + strings.ReplaceAll(key, "/", ".")
}
}
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: profileKey, Bindings: bindings}, true)
if err != nil {
t.Fatalf("build runtime binding: %v", err)
}
@@ -1705,6 +1718,11 @@ func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domai
return binding
}
func runtimeBindingTestKeyIsSensitive(key string) bool {
normalized := strings.ToLower(key)
return strings.Contains(normalized, "password") || strings.Contains(normalized, "credential") || strings.Contains(normalized, "secret") || strings.Contains(normalized, "token") || strings.Contains(normalized, "dsn")
}
func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
return domain.GamePluginManifestRegistration{
ManifestRef: "artifact://manifests/game.example/0.1.0",
+3 -3
View File
@@ -39,8 +39,8 @@ func (svc *CoreService) UpdateServerRuntimeBindingForSession(sessionID, serverIn
if existingErr != nil && !errors.Is(existingErr, repo.ErrNotFound) {
return domain.RuntimeBindingView{}, existingErr
}
if (instance.State == domain.ServerInstanceStateInstalling || instance.State == domain.ServerInstanceStateRunning) && existingErr == nil || instance.State == domain.ServerInstanceStateDeleted {
return domain.RuntimeBindingView{}, validationError("runtime binding cannot be changed while the server is active")
if instance.State == domain.ServerInstanceStateDeleted {
return domain.RuntimeBindingView{}, validationError("runtime binding cannot be changed after the server is deleted")
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
@@ -124,7 +124,7 @@ func runtimeBindingKeys(profiles domain.GamePluginRuntimeProfiles, profile domai
add(probe.TargetKey, probe.Required)
}
for _, source := range profiles.LogSources {
add(source.TargetKey, source.TargetKey != "")
add(source.TargetKey, false)
}
for _, plan := range profiles.InstallPlans {
for _, step := range plan.Steps {
+46
View File
@@ -98,6 +98,52 @@ func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) {
if err != nil || result.Job.TargetKey != "actions/start.json" || result.Job.ExecutionInput.WorkspaceScope != "local" {
t.Fatalf("expected complete binding to permit start, result=%+v err=%v", result, err)
}
view, err = svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root-updated"}})
if err != nil || view.Status != domain.RuntimeBindingStatusComplete {
t.Fatalf("expected active server runtime binding edits to remain available, view=%+v err=%v", view, err)
}
instance.State = domain.ServerInstanceStateDeleted
if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatalf("mark deleted: %v", err)
}
if _, err := svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}); err == nil || !strings.Contains(err.Error(), "deleted") {
t.Fatalf("expected deleted server runtime binding edit rejection, got %v", err)
}
}
func TestRuntimeBindingLogSourcesAreConfigurableButNotRequired(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.RuntimeProfiles = requiredRuntimeProfilesFixture()
plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{
{Key: "console-stdout", Kind: "process.stdout", TargetKey: "process/server", StreamKey: "console.stdout", CursorKind: "sequence", RetentionDays: 30},
{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30},
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin profiles: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "runtime-logs-owner", DisplayName: "Runtime Logs Owner", Email: "runtime-logs-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "runtime-log-sources", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Runtime Log Sources", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
view, err := svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-log-sources/rcon"}})
if err != nil || view.Status != domain.RuntimeBindingStatusComplete || len(view.MissingKeys) != 0 {
t.Fatalf("log sources should not block runtime readiness: view=%+v err=%v", view, err)
}
seenLogs := map[string]domain.RuntimeBindingKeyView{}
for _, key := range view.Keys {
if strings.HasPrefix(key.Key, "logs/") || strings.HasPrefix(key.Key, "process/") {
seenLogs[key.Key] = key
}
}
for _, key := range []string{"logs/latest", "process/server"} {
item, ok := seenLogs[key]
if !ok || item.Required || item.Configured {
t.Fatalf("expected optional unconfigured log key %s, seen=%+v view=%+v", key, seenLogs, view)
}
}
}
func requiredRuntimeProfilesFixture() domain.GamePluginRuntimeProfiles {
+13
View File
@@ -310,6 +310,7 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
return domain.Job{}, validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))
}
var dllExtensions []domain.RuntimeDLLExtensionPlan
var logSources []domain.RuntimeLogSource
if action == domain.ServerLifecycleActionStart && hasProfile {
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
@@ -319,6 +320,7 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
if err != nil {
return domain.Job{}, err
}
logSources = lifecycleProcessLogSources(plugin.RuntimeProfiles)
}
job, err := svc.CreateJob(domain.Job{
ID: lifecycleJobID(instance.ID, action, idempotencyKey),
@@ -332,6 +334,7 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
WorkspaceScope: profileKey,
PluginID: plugin.ID,
LifecycleOperation: lifecycleExecutionOperation(action),
LogSources: logSources,
DLLExtensions: dllExtensions,
Deployment: deploymentPlanForDispatch(instance.Deployment),
},
@@ -345,6 +348,16 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
return job, nil
}
func lifecycleProcessLogSources(profiles domain.GamePluginRuntimeProfiles) []domain.RuntimeLogSource {
sources := []domain.RuntimeLogSource{}
for _, source := range profiles.LogSources {
if source.Kind == "process.stdout" || source.Kind == "process.stderr" {
sources = append(sources, source)
}
}
return sources
}
func lifecycleJobProgress(deployment domain.ServerDeploymentDefinition) domain.JobProgress {
if deployment.Mode != "" {
return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"}
+8 -2
View File
@@ -52,6 +52,9 @@ func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
if started.Job.ExecutionInput.PluginID != "server.scum" || started.Job.ExecutionInput.WorkspaceScope != "local" || started.Job.ExecutionInput.LifecycleOperation != "start" {
t.Fatalf("expected start job to carry plugin/profile metadata, got %+v", started.Job.ExecutionInput)
}
if len(started.Job.ExecutionInput.LogSources) != 2 || started.Job.ExecutionInput.LogSources[0].StreamKey != "scum.console.stdout" || started.Job.ExecutionInput.LogSources[1].StreamKey != "scum.console.stderr" {
t.Fatalf("expected start job to carry plugin-declared process log sources, got %+v", started.Job.ExecutionInput.LogSources)
}
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
running, err := svc.GetServerInstance("server-1")
if err != nil {
@@ -362,8 +365,11 @@ func createLifecyclePlugin(t *testing.T, svc *CoreService) domain.GamePlugin {
Start: "actions/start.json",
Stop: "actions/stop.json",
},
Permissions: domain.PluginPermissions{Jobs: true, Logs: true},
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}}},
Permissions: domain.PluginPermissions{Jobs: true, Logs: true},
RuntimeProfiles: domain.GamePluginRuntimeProfiles{
LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}},
LogSources: []domain.RuntimeLogSource{{Key: "scum-console-stdout", Kind: "process.stdout", TargetKey: "scum/server-process", StreamKey: "scum.console.stdout", CursorKind: "sequence", RetentionDays: 30}, {Key: "scum-console-stderr", Kind: "process.stderr", TargetKey: "scum/server-process", StreamKey: "scum.console.stderr", CursorKind: "sequence", RetentionDays: 30}},
},
})
if err != nil {
t.Fatalf("create lifecycle plugin: %v", err)
+64 -1
View File
@@ -212,9 +212,16 @@ func (svc *CoreService) resolveSourceRCONDispatch(instance domain.ServerInstance
}
func sourceRCONTransport(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile) (domain.RuntimeTransportProfile, error) {
return sourceRCONTransportForCapability(profiles, profile, "", domain.JobCapabilityRemoteRunRCONCommand)
}
func sourceRCONTransportForCapability(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, requiredKey string, capability string) (domain.RuntimeTransportProfile, error) {
var selected domain.RuntimeTransportProfile
for _, candidate := range profiles.TransportProfiles {
if !containsString(profile.TransportKeys, candidate.Key) || candidate.Kind != "rcon" || !containsString(candidate.Capabilities, domain.JobCapabilityRemoteRunRCONCommand) {
if requiredKey != "" && candidate.Key != requiredKey {
continue
}
if !containsString(profile.TransportKeys, candidate.Key) || candidate.Kind != "rcon" || !containsString(candidate.Capabilities, capability) {
continue
}
if selected.Key != "" {
@@ -228,6 +235,62 @@ func sourceRCONTransport(profiles domain.GamePluginRuntimeProfiles, profile doma
return selected, nil
}
func (svc *CoreService) resolveProtectedSourceRCONDispatch(serverInstanceID string, request *domain.GameClientBridgeProtectedRequestDeclaration) (sourceRCONDispatchResolution, error) {
if request == nil || request.Kind != "rcon" {
return sourceRCONDispatchResolution{}, validationError("protected request is not RCON")
}
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
capability := domain.JobCapabilityRemoteRunProtectedRCON
if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion || !plugin.Permissions.RemoteAccess || !plugin.RemoteAccess.RCON || !containsString(plugin.RequiredRunCapabilities, capability) || !containsString(plugin.RemoteAccess.RunCapabilities, capability) {
return sourceRCONDispatchResolution{}, forbiddenError("plugin does not declare protected SCUM RCON access")
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil {
return sourceRCONDispatchResolution{}, err
}
if !strings.EqualFold(endpoint.Platform, "windows") || !strings.EqualFold(endpoint.Architecture, "amd64") {
return sourceRCONDispatchResolution{}, validationError("unsupported_extension_platform: SCUM Source RCON requires windows/amd64")
}
binding, err := svc.runtimeBindingForServer(instance.ID)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
binding, err = normalizeRuntimeBinding(plugin, binding)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
if binding.Status != domain.RuntimeBindingStatusComplete || binding.PluginVersion != plugin.Version {
return sourceRCONDispatchResolution{}, validationError("runtime binding is incomplete or stale")
}
profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey)
if !exists || !containsString(profile.Capabilities, capability) || !runtimePlatformsContain(profile.Platforms, "windows") {
return sourceRCONDispatchResolution{}, validationError("selected runtime profile does not support protected SCUM RCON")
}
transport, err := sourceRCONTransportForCapability(plugin.RuntimeProfiles, profile, request.TransportKey, capability)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
if transport.TargetKey != request.TargetKey {
return sourceRCONDispatchResolution{}, validationError("protected RCON transport target is invalid")
}
extension, err := sourceRCONExtension(plugin.RuntimeProfiles, profile, endpoint)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
plan := &domain.RuntimeSourceRCONPlan{Protocol: "source-rcon", ExtensionKey: extension.Key, ModKey: extension.ModKey, ConfigRef: "ue4ss/Mods/" + extension.ModKey + "/config.ini", DeploymentStateRef: "runtime/ue4ss-dll/" + extension.TargetKey + "/release.json", Port: extension.RCONPort}
return sourceRCONDispatchResolution{plugin: plugin, binding: binding, transport: transport, plan: plan}, nil
}
func sourceRCONExtension(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) (domain.RuntimeDLLExtensionProfile, error) {
byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(profiles.DLLExtensions))
for _, extension := range profiles.DLLExtensions {
+59
View File
@@ -85,6 +85,65 @@ func TestSourceRCONDispatchUsesOneTimeRedactedInput(t *testing.T) {
}
}
func TestProtectedRCONBridgeDispatchCarriesSourceRCONPlan(t *testing.T) {
svc, _, _, instance := newSourceRCONFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatal(err)
}
protectedCapability := domain.JobCapabilityRemoteRunProtectedRCON
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, protectedCapability)
plugin.RemoteAccess.RunCapabilities = append(plugin.RemoteAccess.RunCapabilities, protectedCapability)
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}
plugin.RuntimeProfiles.LifecycleProfiles[0].Capabilities = append(plugin.RuntimeProfiles.LifecycleProfiles[0].Capabilities, protectedCapability)
plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys = append(plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys, "scum-management")
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{protectedCapability}})
plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "management.rcon.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 120, MaxPayloadBytes: 8192, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "rcon", TransportKey: "scum-management", TargetKey: "scum-management", TextField: "requestText", MaxTextBytes: 8192}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatal(err)
}
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon": "runtime-rcon", "scum-management": "runtime-rcon"}}, true)
if err != nil {
t.Fatalf("refresh protected RCON binding: %v", err)
}
if err := svc.store.RuntimeBindings().Update(binding); err != nil {
t.Fatalf("store protected RCON binding: %v", err)
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
t.Fatal(err)
}
endpoint.Capabilities = append(endpoint.Capabilities, protectedCapability)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatal(err)
}
if _, err := svc.resolveProtectedSourceRCONDispatch(instance.ID, plugin.GameClientBridge.Commands[len(plugin.GameClientBridge.Commands)-1].ProtectedRequest); err != nil {
t.Fatalf("resolve protected Source RCON plan: %v", err)
}
command, err := svc.queueGameClientBridgeCommand("user-rcon-owner", domain.GameClientBridgeQueueRequest{ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: "scum-client-manager", CommandType: "management.rcon.request", Payload: map[string]any{"requestText": "#ListPlayers"}, IdempotencyKey: "protected-rcon-1", ExpiresAt: fixedTime.Add(time.Minute)})
if err != nil {
t.Fatalf("queue protected RCON: %v", err)
}
job, err := svc.store.Jobs().Get(command.RunJobID)
if err != nil {
t.Fatalf("get protected RCON job: %v", err)
}
if job.Capability != protectedCapability || job.InputRef == "" || !strings.HasPrefix(job.InputRef, "input://protected-request/") || job.ExecutionInput.SourceRCON == nil {
t.Fatalf("expected protected RCON job with frozen Source RCON plan, got %+v", job)
}
if job.ExecutionInput.WorkspaceScope != "local" || job.ExecutionInput.RemoteAdapterKey != "scum-management" || job.ExecutionInput.RemoteAdapterKind != "protected-rcon" || job.ExecutionInput.SourceRCON.Port != 27015 {
t.Fatalf("protected RCON plan did not preserve logical runtime binding: %+v", job.ExecutionInput)
}
serialized, err := json.Marshal(job)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(serialized), "#ListPlayers") || strings.Contains(string(serialized), "password=") {
t.Fatalf("protected RCON job leaked transient input: %s", serialized)
}
}
func TestSourceRCONDispatchRejectsUnsafeOrIncompatibleState(t *testing.T) {
svc, session, _, instance := newSourceRCONFixture(t)
unsafe := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "SetTime 12\nSpawnItem", IdempotencyKey: "rcon-unsafe"}
+66 -1
View File
@@ -1392,9 +1392,35 @@ func ValidateJob(job domain.Job) error {
for i, plan := range job.ExecutionInput.DLLExtensions {
violations = append(violations, validateRuntimeDLLExtensionPlan(fmt.Sprintf("executionInput.dllExtensions[%d]", i), plan)...)
}
if job.ExecutionInput.LogSource != nil {
violations = append(violations, validateRuntimeLogSourcePlanForJob("executionInput.logSource", job.ExecutionInput.LogSource)...)
if job.Capability != domain.JobCapabilityLogsBackfill || job.ServerInstanceID == "" {
violations = append(violations, "executionInput.logSource is allowed only for logs.backfill jobs")
}
}
if len(job.ExecutionInput.LogSources) > 0 {
if job.Capability != domain.LifecycleCapabilityStart || job.ExecutionInput.LifecycleOperation != "start" || job.ServerInstanceID == "" {
violations = append(violations, "executionInput.logSources are allowed only for scoped process.start jobs")
}
seenKinds := map[string]struct{}{}
for i, source := range job.ExecutionInput.LogSources {
prefix := fmt.Sprintf("executionInput.logSources[%d]", i)
violations = append(violations, validateRuntimeProcessLogSourcePlanForJob(prefix, source)...)
if _, exists := seenKinds[source.Kind]; exists {
violations = append(violations, "executionInput.logSources kind is duplicated")
}
seenKinds[source.Kind] = struct{}{}
}
}
if job.ExecutionInput.SourceRCON != nil {
violations = append(violations, validateRuntimeSourceRCONPlan("executionInput.sourceRcon", job.ExecutionInput.SourceRCON)...)
if job.Capability != domain.JobCapabilityRemoteRunRCONCommand || job.ExecutionInput.RemoteAdapterKind != "rcon" {
isSourceCommand := job.Capability == domain.JobCapabilityRemoteRunRCONCommand
isProtectedRCON := job.Capability == domain.JobCapabilityRemoteRunProtectedRCON
wantAdapterKind := "rcon"
if isProtectedRCON {
wantAdapterKind = "protected-rcon"
}
if (!isSourceCommand && !isProtectedRCON) || job.ExecutionInput.RemoteAdapterKind != wantAdapterKind {
violations = append(violations, "executionInput.sourceRcon is allowed only for rcon jobs")
}
if job.RetryPolicy.MaxAttempts != 1 {
@@ -1441,6 +1467,45 @@ func ValidateJob(job domain.Job) error {
return finish(violations)
}
func validateRuntimeLogSourcePlanForJob(prefix string, source *domain.RuntimeLogSource) []string {
if source == nil {
return nil
}
var violations []string
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
violations = append(violations, validateProfileKey(prefix+".targetKey", source.TargetKey)...)
violations = append(violations, validateProfileKey(prefix+".streamKey", source.StreamKey)...)
if source.Kind != "file.tail" {
violations = append(violations, prefix+".kind must be file.tail")
}
if source.CursorKind != "" && !oneOf(source.CursorKind, "offset", "fingerprint") {
violations = append(violations, prefix+".cursorKind is invalid")
}
if source.RetentionDays < 0 || source.RetentionDays > 365 {
violations = append(violations, prefix+".retentionDays is invalid")
}
return violations
}
func validateRuntimeProcessLogSourcePlanForJob(prefix string, source domain.RuntimeLogSource) []string {
var violations []string
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
if source.TargetKey != "" {
violations = append(violations, validateProfileKey(prefix+".targetKey", source.TargetKey)...)
}
violations = append(violations, validateProfileKey(prefix+".streamKey", source.StreamKey)...)
if source.Kind != "process.stdout" && source.Kind != "process.stderr" {
violations = append(violations, prefix+".kind must be process.stdout or process.stderr")
}
if source.CursorKind != "" && source.CursorKind != "sequence" {
violations = append(violations, prefix+".cursorKind is invalid")
}
if source.RetentionDays < 0 || source.RetentionDays > 365 {
violations = append(violations, prefix+".retentionDays is invalid")
}
return violations
}
func ValidateArtifact(artifact domain.Artifact) error {
var violations []string
violations = appendRequired(violations, "id", artifact.ID)