Restore durable log ingest and typed plugin projections
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user