Restore durable log ingest and typed plugin projections

This commit is contained in:
npc0-hue
2026-09-02 10:20:30 +08:00
parent 40ac46ba17
commit 6018d8f0fc
61 changed files with 809 additions and 3157 deletions
@@ -6,48 +6,6 @@ import (
"browser.local/platform/dto"
)
// gameClientBridgeCompanionLogEvents godoc
// @Summary Stream live Run logs to a game companion
// @Description Authorizes a component session and relays only the current supervised process log stream to the companion. The platform does not persist log bodies on this route.
// @Tags game-client-bridge
// @Accept json
// @Produce text/event-stream
// @Param body body dto.GameClientBridgeLogStreamRequest true "Component log stream request"
// @Success 200 {object} dto.LogStreamEventResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/game-client-bridge/companion/logs/events [post]
func (h *coreHandlers) gameClientBridgeCompanionLogEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.GameClientBridgeLogStreamRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
instance, err := h.core.AuthorizeGameClientBridgeLogStream(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
subscription, err := h.core.SubscribeLogEvents(instance.ID)
if err != nil {
writeServiceError(w, err)
return
}
defer subscription.Close()
streams, liveEligible, err := h.loadLiveLogSnapshot(instance.ID)
if err != nil {
writeServiceError(w, err)
return
}
h.streamCurrentLogEvents(w, r, instance, streams, liveEligible, subscription)
}
func (h *coreHandlers) gameClientBridgeCompanionClaim(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
@@ -1,10 +1,7 @@
package api
import (
"bufio"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -41,13 +38,6 @@ func (core *gameClientBridgeCompanionCore) UploadGameClientBridgeSnapshot(domain
return domain.CopyGameClientBridgeSnapshot(core.snapshot), nil
}
func (core *gameClientBridgeCompanionCore) AuthorizeGameClientBridgeLogStream(request domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error) {
if strings.TrimSpace(request.SessionToken) != "component-token" {
return domain.ServerInstance{}, service.ErrUnauthorized
}
return core.Core.GetServerInstance("server-1")
}
func TestGameClientBridgeOperatorRoutes(t *testing.T) {
store := repo.NewMemoryStore()
coreService := service.NewCoreService(store)
@@ -159,40 +149,3 @@ func TestGameClientBridgeCompanionRoutesAreComponentSessionMediated(t *testing.T
diagnostic := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/diagnostics", snapshotRequest)
assertStatus(t, diagnostic, http.StatusAccepted)
}
func TestGameClientBridgeCompanionLogEventsRelaysCurrentRunOutput(t *testing.T) {
coreService := service.NewCoreService(repo.NewMemoryStore())
if err := coreService.SeedLocalPlatformAdmin(); err != nil {
t.Fatal(err)
}
core := &gameClientBridgeCompanionCore{Core: coreService}
router := NewTestRouterWithCore(core)
hello := createLogIngestAPIFixtures(t, router)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
request := httptest.NewRequest(http.MethodPost, "/api/v1/game-client-bridge/companion/logs/events", strings.NewReader("{\"sessionToken\":\"component-token\"}")).WithContext(ctx)
request.Header.Set("Content-Type", "application/json")
streamWriter, streamReader := newSSEPipeResponseWriter()
done := make(chan struct{})
go func() {
router.ServeHTTP(streamWriter, request)
_ = streamWriter.Close()
close(done)
}()
t.Cleanup(func() {
cancel()
_ = streamReader.Close()
<-done
})
if status := <-streamWriter.status; status != http.StatusOK {
t.Fatalf("unexpected companion SSE status: %d", status)
}
reader := bufio.NewReader(streamReader)
assertSSEEvent(t, reader, "session", "\"logSessionId\":\"session-current\"")
assertSSEEvent(t, reader, "stream", "\"id\":\"log-1\"")
assertSSEEvent(t, reader, "ready", "\"streamCount\":1")
live := validLogBatchRequest(t, hello.SessionToken, 5, 5)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", live), http.StatusOK)
assertSSEEvent(t, reader, "log", "\"seq\":5")
}
-55
View File
@@ -81,26 +81,6 @@ func TestLogIngestAPIWorkflow(t *testing.T) {
}
}
func TestLiveLogRelayAPIForwardsWithoutStoredOutput(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
batch := validLogBatchRequest(t, hello.SessionToken, 1, 1)
relay := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", batch)
assertStatus(t, relay, http.StatusOK)
ack := decodeBody[dto.LogBatchIngestResponse](t, relay)
if !ack.Accepted || ack.AcceptedFrom != 1 || ack.AcceptedTo != 1 {
t.Fatalf("unexpected live relay ack: %+v", ack)
}
query := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: batch.LogStreamID, AfterSeq: 0, Limit: 10})
assertStatus(t, query, http.StatusOK)
body := decodeBody[dto.LogStreamCursorResponse](t, query)
if len(body.Entries) != 0 || body.LatestSeq != 1 {
t.Fatalf("live relay stored platform log output: %+v", body)
}
}
func TestLogEventsSSEDoesNotReplayHistory(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
@@ -173,41 +153,6 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) {
assertSSEEvent(t, reader, "log", `"seq":2`)
}
func TestLogEventsSSERelaysLiveBatchesWithoutSequenceGate(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events", nil).WithContext(ctx)
streamWriter, streamReader := newSSEPipeResponseWriter()
done := make(chan struct{})
go func() {
router.ServeHTTP(streamWriter, request)
_ = streamWriter.Close()
close(done)
}()
t.Cleanup(func() {
cancel()
_ = streamReader.Close()
<-done
})
if status := <-streamWriter.status; status != http.StatusOK {
t.Fatalf("unexpected SSE status: %d", status)
}
reader := bufio.NewReader(streamReader)
assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`)
assertSSEEvent(t, reader, "stream", `"id":"log-1"`)
assertSSEEvent(t, reader, "ready", `"streamCount":1`)
first := validLogBatchRequest(t, hello.SessionToken, 4, 4)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", first), http.StatusOK)
assertSSEEvent(t, reader, "log", `"seq":4`)
second := validLogBatchRequest(t, hello.SessionToken, 1, 1)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", second), http.StatusOK)
assertSSEEvent(t, reader, "log", `"seq":1`)
}
func TestLogEventsSSESkipsBufferedBackfillAfterOpen(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
+3 -3
View File
@@ -333,13 +333,13 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef {
t.Fatalf("unexpected client distribution: %+v", clientDistribution)
}
clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession)
clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession)
lifecycleList := getJSONWithAuth[dto.ClientManagerInstallationListResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers", adminSession)
if lifecycleList.Count != 1 || lifecycleList.Items[0].Status != string(domain.ClientManagerLifecycleBuilding) || lifecycleList.Items[0].Distribution == nil {
t.Fatalf("expected safe client-manager lifecycle projection, got %+v", lifecycleList)
@@ -1864,7 +1864,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://git.npc0.com/admin343/browser.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", WorkspaceRef: "plugins/examples/scum-server-plugin/companion", EntryRef: "cmd/scum-companion"}, 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}}}
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)
endpoint := validRunEndpointRequest()
-38
View File
@@ -1,38 +0,0 @@
package api
import (
"net/http"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/repo"
)
type liveLogRelayCore interface {
RelayLiveLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
}
// runLiveLogRelay accepts current Run output and immediately fans it out to
// subscribers. It has no durable body or delivery acknowledgement contract.
func (h *coreHandlers) runLiveLogRelay(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
core, ok := h.core.(liveLogRelayCore)
if !ok {
writeServiceError(w, repo.ErrNotFound)
return
}
request, err := decodeJSON[dto.LogBatchIngestRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := core.RelayLiveLogBatch(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.LogBatchIngestFromDomain(result))
}