Implement platform management features
This commit is contained in:
@@ -1,18 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/service"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
type logStreamListHookCore struct {
|
||||
service.Core
|
||||
once sync.Once
|
||||
hook func()
|
||||
}
|
||||
|
||||
type ssePipeResponseWriter struct {
|
||||
header http.Header
|
||||
pipe *io.PipeWriter
|
||||
status chan int
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newSSEPipeResponseWriter() (*ssePipeResponseWriter, *io.PipeReader) {
|
||||
reader, writer := io.Pipe()
|
||||
return &ssePipeResponseWriter{header: make(http.Header), pipe: writer, status: make(chan int, 1)}, reader
|
||||
}
|
||||
|
||||
func (writer *ssePipeResponseWriter) Header() http.Header { return writer.header }
|
||||
|
||||
func (writer *ssePipeResponseWriter) WriteHeader(status int) {
|
||||
writer.once.Do(func() { writer.status <- status })
|
||||
}
|
||||
|
||||
func (writer *ssePipeResponseWriter) Write(payload []byte) (int, error) {
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
return writer.pipe.Write(payload)
|
||||
}
|
||||
|
||||
func (writer *ssePipeResponseWriter) Flush() {}
|
||||
|
||||
func (writer *ssePipeResponseWriter) Close() error { return writer.pipe.Close() }
|
||||
|
||||
func (core *logStreamListHookCore) ListLogStreams(filter domain.LogStreamFilter) ([]domain.LogStream, error) {
|
||||
core.once.Do(core.hook)
|
||||
return core.Core.ListLogStreams(filter)
|
||||
}
|
||||
|
||||
func TestLogIngestAPIWorkflow(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
@@ -57,6 +100,7 @@ func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
||||
ID: "log-2", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stderr",
|
||||
LogSessionID: "session-current", SessionStartedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
|
||||
StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default",
|
||||
})
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-1", "stdout", 1, 2, 0)), http.StatusOK)
|
||||
@@ -70,6 +114,213 @@ func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
oldStream := dto.LogStreamCreateRequest{ID: "log-old", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stdout", LogSessionID: "session-old", SessionStartedAt: time.Date(2026, 7, 3, 11, 0, 0, 0, time.UTC), StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"}
|
||||
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", oldStream)
|
||||
oldBatch := validLogBatchRequestForStream(t, hello.SessionToken, "log-old", "stdout", 1, 1, 0)
|
||||
oldBatch.LogSessionID = "session-old"
|
||||
oldBatch.SessionStartedAt = oldStream.SessionStartedAt
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", oldBatch), http.StatusOK)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
|
||||
|
||||
recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10")
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, `"logSessionId":"session-current"`) || strings.Contains(body, `"streamId":"log-old"`) {
|
||||
t.Fatalf("expected only current session in live SSE, body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
|
||||
|
||||
stopped := dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}}
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", stopped), http.StatusOK)
|
||||
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10"))
|
||||
|
||||
running := stopped
|
||||
running.ManagedProcessID = "log-session:session-missing"
|
||||
running.ObservationSeq = 1
|
||||
running.ObservedAt = time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
||||
running.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", running), http.StatusOK)
|
||||
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10"))
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.0", Status: domain.RunEndpointStatusOffline, CapabilityFingerprint: "cap-logs", Capacity: dto.RunCapacityResponse{MaxJobs: 1}}), http.StatusOK)
|
||||
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10"))
|
||||
}
|
||||
|
||||
func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconnect(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=10", 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, "log", `"seq":1`)
|
||||
assertSSEEvent(t, reader, "ready", `"streamCount":1`)
|
||||
|
||||
statusReport := dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}}
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", statusReport), http.StatusOK)
|
||||
name, data := readSSEEvent(t, reader)
|
||||
if name != "session" || strings.Contains(data, `"logSessionId"`) || !strings.Contains(data, `"streamCount":0`) {
|
||||
t.Fatalf("expected stopped process to emit an empty session boundary, name=%q data=%s", name, data)
|
||||
}
|
||||
|
||||
nextStartedAt := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
||||
next := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stdout", "stdout", 1, 1, 20)
|
||||
next.LogSessionID = "session-next"
|
||||
next.SessionStartedAt = nextStartedAt
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", next), http.StatusOK)
|
||||
|
||||
statusReport.ManagedProcessID = "log-session:session-next"
|
||||
statusReport.ObservationSeq = 1
|
||||
statusReport.ObservedAt = nextStartedAt
|
||||
statusReport.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", statusReport), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`)
|
||||
assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`)
|
||||
assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicates(t *testing.T) {
|
||||
core := service.NewCoreService(repo.NewMemoryStore())
|
||||
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
||||
t.Fatalf("seed platform admin: %v", err)
|
||||
}
|
||||
setupRouter := NewTestRouterWithCore(core)
|
||||
hello := createLogIngestAPIFixtures(t, setupRouter)
|
||||
initial := validLogBatchRequest(t, hello.SessionToken, 1, 1)
|
||||
hookResult := make(chan error, 1)
|
||||
hooked := &logStreamListHookCore{Core: core, hook: func() {
|
||||
_, err := core.IngestLogBatch(initial.ToDomain())
|
||||
hookResult <- err
|
||||
}}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=10", nil).WithContext(ctx)
|
||||
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
NewTestRouterWithCore(hooked).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, "log", `"seq":1`)
|
||||
assertSSEEvent(t, reader, "ready", `"streamCount":1`)
|
||||
if err := <-hookResult; err != nil {
|
||||
t.Fatalf("ingest during stream snapshot: %v", err)
|
||||
}
|
||||
|
||||
nextStartedAt := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
||||
next := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stdout", "stdout", 1, 1, 20)
|
||||
next.LogSessionID = "session-next"
|
||||
next.SessionStartedAt = nextStartedAt
|
||||
if _, err := core.IngestLogBatch(next.ToDomain()); err != nil {
|
||||
t.Fatalf("ingest next session stdout: %v", err)
|
||||
}
|
||||
if _, err := core.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, ManagedProcessID: "log-session:session-next", ObservationSeq: 1, ObservedAt: nextStartedAt, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}}); err != nil {
|
||||
t.Fatalf("report next managed process: %v", err)
|
||||
}
|
||||
assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`)
|
||||
assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`)
|
||||
assertSSEEvent(t, reader, "log", `"seq":1`)
|
||||
|
||||
stderr := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stderr", "stderr", 1, 1, 21)
|
||||
stderr.LogSessionID = "session-next"
|
||||
stderr.SessionStartedAt = nextStartedAt
|
||||
if _, err := core.IngestLogBatch(stderr.ToDomain()); err != nil {
|
||||
t.Fatalf("ingest next session stderr: %v", err)
|
||||
}
|
||||
assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stderr"`)
|
||||
assertSSEEvent(t, reader, "log", `"streamKey":"stderr"`)
|
||||
|
||||
delayedOld := validLogBatchRequest(t, hello.SessionToken, 2, 2)
|
||||
if _, err := core.IngestLogBatch(delayedOld.ToDomain()); err != nil {
|
||||
t.Fatalf("ingest delayed old session batch: %v", err)
|
||||
}
|
||||
nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 22)
|
||||
nextLive.LogSessionID = "session-next"
|
||||
nextLive.SessionStartedAt = nextStartedAt
|
||||
if _, err := core.IngestLogBatch(nextLive.ToDomain()); err != nil {
|
||||
t.Fatalf("ingest next session live append: %v", err)
|
||||
}
|
||||
assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`)
|
||||
}
|
||||
|
||||
func assertSSEEvent(t *testing.T, reader *bufio.Reader, wantName string, wantData string) {
|
||||
t.Helper()
|
||||
name, data := readSSEEvent(t, reader)
|
||||
if name != wantName || !strings.Contains(data, wantData) {
|
||||
t.Fatalf("unexpected SSE event: name=%q data=%s; want name=%q containing %s", name, data, wantName, wantData)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmptyInitialLogSession(t *testing.T, recorder *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
assertStatus(t, recorder, http.StatusOK)
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, "event: session") || strings.Contains(body, `"logSessionId"`) || strings.Contains(body, "event: log") || !strings.Contains(body, `"streamCount":0`) {
|
||||
t.Fatalf("expected an empty initial live session, body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func readSSEEvent(t *testing.T, reader *bufio.Reader) (string, string) {
|
||||
t.Helper()
|
||||
for {
|
||||
name, data := "", ""
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read SSE event: %v", err)
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
if name != "" {
|
||||
return name, data
|
||||
}
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(line, "event: ") {
|
||||
name = strings.TrimPrefix(line, "event: ")
|
||||
}
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data = strings.TrimPrefix(line, "data: ")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func performCancelledSSE(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -110,15 +361,22 @@ func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunContro
|
||||
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1"}, adminSession)
|
||||
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1", State: domain.ServerInstanceStateRunning}, adminSession)
|
||||
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
||||
ID: "log-1",
|
||||
ServerInstanceID: "server-1",
|
||||
Source: domain.LogStreamSourceProcess,
|
||||
StreamKey: "stdout",
|
||||
LogSessionID: "session-current",
|
||||
SessionStartedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
|
||||
StorageBackend: domain.LogStorageBackendLocalSegments,
|
||||
RetentionPolicy: "default",
|
||||
})
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{
|
||||
RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded,
|
||||
ManagedProcessID: "log-session:session-current", ObservationSeq: 1, ObservedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
|
||||
ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"},
|
||||
}), http.StatusOK)
|
||||
return hello
|
||||
}
|
||||
|
||||
@@ -146,6 +404,8 @@ func validLogBatchRequestForStream(t *testing.T, sessionToken string, streamID s
|
||||
ServerInstanceID: "server-1",
|
||||
StreamKey: streamKey,
|
||||
Source: domain.LogStreamSourceProcess,
|
||||
LogSessionID: "session-current",
|
||||
SessionStartedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
|
||||
FirstSeq: firstSeq,
|
||||
LastSeq: lastSeq,
|
||||
Compression: "none",
|
||||
|
||||
Reference in New Issue
Block a user