Stream live server logs over SSE
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ©
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user