Implement platform management features

This commit is contained in:
npc0-hue
2026-08-17 08:48:45 +08:00
parent 65353cf269
commit 302f1f64b7
38 changed files with 2185 additions and 110 deletions
@@ -66,7 +66,17 @@ func (svc *CoreService) enqueueDistributionBuild(job domain.Job) {
delete(svc.distributionBuilds, job.ID)
svc.distributionBuildMu.Unlock()
}()
_ = svc.executeDistributionBuild(job)
defer func() {
if recover() != nil {
_ = svc.failDistributionBuildJob(job, "platform builder failed unexpectedly")
}
}()
if err := svc.executeDistributionBuild(job); err != nil {
// executeDistributionBuild may fail after persisting the running state.
// A second terminalization attempt is idempotent when the specific
// failure path already marked the job failed.
_ = svc.failDistributionBuildJob(job, "platform builder failed before completion")
}
}()
}
@@ -99,7 +109,7 @@ func (svc *CoreService) executeDistributionBuild(job domain.Job) error {
payload, buildErr = builder.Build(input)
}
if buildErr != nil {
return svc.failDistributionBuildJob(job, builderJobFailureMessage(buildErr))
return svc.failDistributionBuildJob(job, builderJobFailureMessage(buildErr, input.AuthKey))
}
if _, err := svc.platformDistributionBuildInput(job); err != nil {
return svc.failDistributionBuildJob(job, "platform builder discarded output because the component key is no longer current")
@@ -25,6 +25,34 @@ type progressDistributionBuilder struct {
payload []byte
}
type failingBuildProgressStore struct {
repo.Store
buildJobs repo.ClientManagerBuildJobRepository
}
func (store failingBuildProgressStore) ClientManagerBuildJobs() repo.ClientManagerBuildJobRepository {
return store.buildJobs
}
type failWhenDistributionJobRunningRepository struct {
repo.ClientManagerBuildJobRepository
jobs repo.JobRepository
err error
}
func (repository failWhenDistributionJobRunningRepository) List(filter domain.ClientManagerBuildJobFilter) ([]domain.ClientManagerBuildJob, error) {
jobs, err := repository.jobs.List(domain.JobFilter{ServerInstanceID: filter.ServerInstanceID})
if err != nil {
return nil, err
}
for _, job := range jobs {
if job.Capability == domain.JobCapabilityDistributionBuild && job.State == domain.JobStateRunning {
return nil, repository.err
}
}
return repository.ClientManagerBuildJobRepository.List(filter)
}
func (builder captureDistributionBuilder) Readiness() (bool, string) {
return true, ""
}
@@ -379,6 +407,49 @@ func TestCoreServiceProjectsPlatformBuilderProgressBeforeCompletion(t *testing.T
completeDistributionBuild(t, svc, distribution, nil)
}
func TestCoreServiceTerminalizesPlatformBuildWhenRunningProjectionFails(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
const leakedDetail = "sensitive-builder-token /private/platform/build/input/auth-key"
baseStore := svc.store
svc.store = failingBuildProgressStore{
Store: baseStore,
buildJobs: failWhenDistributionJobRunningRepository{
ClientManagerBuildJobRepository: baseStore.ClientManagerBuildJobs(),
jobs: baseStore.Jobs(),
err: errors.New(leakedDetail),
},
}
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "running-projection-failure-terminalizes",
})
if err != nil {
t.Fatalf("queue platform distribution: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
job, jobErr := svc.GetJob(distribution.BuildJobID)
updated, distributionErr := svc.store.RunDistributions().Get(distribution.ID)
if jobErr != nil || distributionErr != nil {
t.Fatalf("read failed build state: jobErr=%v distributionErr=%v", jobErr, distributionErr)
}
if isTerminalJobState(job.State) {
if job.State != domain.JobStateFailed || updated.Status != domain.DistributionStatusFailed {
t.Fatalf("projection failure did not end in failed state: job=%+v distribution=%+v", job, updated)
}
if strings.Contains(job.Progress.Message, "sensitive-builder-token") || strings.Contains(job.Progress.Message, "/private/") || strings.Contains(job.TerminalFingerprint, "sensitive-builder-token") {
t.Fatalf("terminal build state leaked internal failure details: %+v", job)
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("platform build remained non-terminal after running projection failure")
}
func TestCoreServiceDiscardsBuildCompletedAfterKeyReset(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
inputs := make(chan domain.DistributionBuildInput, 1)
+2 -2
View File
@@ -553,11 +553,11 @@ func redactBuilderHostPaths(line string) string {
return strings.Join(fields, " ")
}
func builderJobFailureMessage(err error) string {
func builderJobFailureMessage(err error, sensitiveValues ...string) string {
if err == nil {
return "platform builder failed"
}
message := strings.TrimSpace(err.Error())
message := safeBuilderFailure([]byte(err.Error()), sensitiveValues...)
if message == "" {
return "platform builder failed"
}
@@ -289,6 +289,14 @@ func TestDockerDistributionBuilderRedactsFailureAndTimeout(t *testing.T) {
}
}
func TestBuilderJobFailureMessageRedactsSensitiveValuesAndHostPaths(t *testing.T) {
const secret = "sensitive-component-key"
message := builderJobFailureMessage(errors.New(secret+" /private/platform/build/input/auth-key"), secret)
if strings.Contains(message, secret) || strings.Contains(message, "/private/") || strings.Contains(message, "auth-key") {
t.Fatalf("builder job failure leaked sensitive details: %s", message)
}
}
func TestPackageClientManagerDistributionProducesProtectedArchives(t *testing.T) {
for _, packageFormat := range []string{"zip", "tar.gz"} {
t.Run(packageFormat, func(t *testing.T) {
+43 -11
View File
@@ -8,14 +8,28 @@ import (
const logEventSubscriberBuffer = 512
type LogEventSubscriptionEventKind string
const (
LogEventSubscriptionEventLog LogEventSubscriptionEventKind = "log"
LogEventSubscriptionEventProcessState LogEventSubscriptionEventKind = "process-state"
)
type LogEventSubscriptionEvent struct {
Kind LogEventSubscriptionEventKind
LogEvent domain.LogStreamEvent
ServerInstanceID string
ProcessState domain.ServerInstanceState
}
type LogEventSubscription struct {
Events <-chan domain.LogStreamEvent
Events <-chan LogEventSubscriptionEvent
Close func()
}
type logEventSubscriber struct {
serverInstanceID string
events chan domain.LogStreamEvent
events chan LogEventSubscriptionEvent
}
func (svc *CoreService) SubscribeLogEvents(serverInstanceID string) (LogEventSubscription, error) {
@@ -26,7 +40,7 @@ func (svc *CoreService) SubscribeLogEvents(serverInstanceID string) (LogEventSub
if _, err := svc.store.ServerInstances().Get(serverInstanceID); err != nil {
return LogEventSubscription{}, err
}
events := make(chan domain.LogStreamEvent, logEventSubscriberBuffer)
events := make(chan LogEventSubscriptionEvent, logEventSubscriberBuffer)
svc.logEventMu.Lock()
svc.logEventSubscriberSeq++
id := svc.logEventSubscriberSeq
@@ -55,18 +69,36 @@ func (svc *CoreService) publishLogEvents(stream domain.LogStream, entries []doma
if len(entries) == 0 {
return
}
events := make([]domain.LogStreamEvent, len(entries))
events := make([]LogEventSubscriptionEvent, len(entries))
for index, entry := range entries {
events[index] = domain.CopyLogStreamEvent(domain.LogStreamEvent{
ServerInstanceID: stream.ServerInstanceID,
Stream: stream,
Entry: entry,
LatestSeq: stream.LatestSeq,
})
events[index] = LogEventSubscriptionEvent{
Kind: LogEventSubscriptionEventLog,
LogEvent: domain.CopyLogStreamEvent(domain.LogStreamEvent{
ServerInstanceID: stream.ServerInstanceID,
Stream: stream,
Entry: entry,
LatestSeq: stream.LatestSeq,
}),
}
}
svc.publishLogSubscriptionEvents(stream.ServerInstanceID, events)
}
func (svc *CoreService) publishLogProcessState(instance domain.ServerInstance) {
svc.publishLogSubscriptionEvents(instance.ID, []LogEventSubscriptionEvent{{
Kind: LogEventSubscriptionEventProcessState,
ServerInstanceID: instance.ID,
ProcessState: instance.State,
}})
}
func (svc *CoreService) publishLogSubscriptionEvents(serverInstanceID string, events []LogEventSubscriptionEvent) {
if len(events) == 0 {
return
}
svc.logEventMu.Lock()
for id, subscriber := range svc.logEventSubscribers {
if subscriber.serverInstanceID != stream.ServerInstanceID {
if subscriber.serverInstanceID != serverInstanceID {
continue
}
dropped := false
+23 -13
View File
@@ -20,6 +20,9 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
return domain.LogBatchIngestResult{}, err
}
lock := svc.logIngestLock(batch.ServerInstanceID)
lock.Lock()
defer lock.Unlock()
stamp := svc.now()
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
@@ -58,7 +61,7 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
}
storedBatch := domain.CopyLogBatchIngest(batch)
sanitizeGamePlayerNetworkFields(&storedBatch)
sanitizeLogNetworkFields(&storedBatch)
record := domain.CopyLogBatchRecord(domain.LogBatchRecord{
Checksum: batch.Checksum,
FirstSeq: batch.FirstSeq,
@@ -96,7 +99,7 @@ func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest,
if job.ServerInstanceID != batch.ServerInstanceID || job.RunEndpointID != batch.RunEndpointID {
return validationError("log batch job scope does not match stream")
}
return svc.ensureJobLogStreams(job, stamp)
return svc.ensureJobLogStreamsUnlocked(job, stamp)
}
func (svc *CoreService) ensureLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error {
@@ -107,7 +110,7 @@ func (svc *CoreService) ensureLogStreamForBatch(batch domain.LogBatchIngest, sta
if job.ServerInstanceID != batch.ServerInstanceID || job.RunEndpointID != batch.RunEndpointID {
return validationError("log batch job scope does not match stream")
}
return svc.ensureJobLogStreams(job, stamp)
return svc.ensureJobLogStreamsUnlocked(job, stamp)
}
if !errors.Is(err, repo.ErrNotFound) || !strings.HasPrefix(jobID, "autonomous-") {
return err
@@ -120,8 +123,14 @@ func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest,
if batch.Source != domain.LogStreamSourceProcess && batch.Source != domain.LogStreamSourceFile && batch.Source != domain.LogStreamSourceManagementProgram {
return repo.ErrNotFound
}
if batch.LogStreamID != runLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.StreamKey) && !legacyAutonomousLogStream(batch) {
return repo.ErrNotFound
expectedStreamID := runLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.StreamKey)
if batch.LogSessionID != "" {
expectedStreamID = runSessionLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.LogSessionID, batch.StreamKey)
}
if batch.LogStreamID != expectedStreamID {
if batch.LogSessionID != "" || !legacyAutonomousLogStream(batch) {
return repo.ErrNotFound
}
}
instance, err := svc.store.ServerInstances().Get(batch.ServerInstanceID)
if err != nil {
@@ -130,7 +139,7 @@ func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest,
if instance.RunEndpointID != batch.RunEndpointID {
return validationError("server instance run endpoint must match log batch endpoint")
}
_, err = svc.CreateLogStream(domain.LogStream{ID: batch.LogStreamID, ServerInstanceID: batch.ServerInstanceID, Source: batch.Source, StreamKey: batch.StreamKey, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", CreatedAt: stamp, UpdatedAt: stamp})
_, err = svc.createLogStream(domain.LogStream{ID: batch.LogStreamID, ServerInstanceID: batch.ServerInstanceID, Source: batch.Source, StreamKey: batch.StreamKey, LogSessionID: batch.LogSessionID, SessionStartedAt: batch.SessionStartedAt, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", CreatedAt: stamp, UpdatedAt: stamp})
if errors.Is(err, repo.ErrDuplicate) {
return nil
}
@@ -166,18 +175,16 @@ func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIn
return false
}
// sanitizeGamePlayerNetworkFields removes raw network material before the durable log body is written.
func sanitizeGamePlayerNetworkFields(batch *domain.LogBatchIngest) {
// sanitizeLogNetworkFields removes raw network material before the durable log body is written.
func sanitizeLogNetworkFields(batch *domain.LogBatchIngest) {
for index := range batch.Entries {
fields := batch.Entries[index].Fields
if fields == nil {
continue
}
if fields["eventType"] == "scum.login" {
delete(fields, "networkFingerprint")
delete(fields, "ip")
delete(fields, "ipAddress")
}
delete(fields, "networkFingerprint")
delete(fields, "ip")
delete(fields, "ipAddress")
}
}
@@ -249,5 +256,8 @@ func validateLogBatchStream(batch domain.LogBatchIngest, stream domain.LogStream
if stream.Source != batch.Source {
return validationError("source must match stream")
}
if stream.LogSessionID != batch.LogSessionID || !stream.SessionStartedAt.Equal(batch.SessionStartedAt) {
return validationError("log session metadata must match stream")
}
return nil
}
@@ -0,0 +1,31 @@
package service
import (
"testing"
"browser.local/platform/domain"
)
func TestSanitizeLogNetworkFieldsIsGameAgnostic(t *testing.T) {
batch := domain.LogBatchIngest{Entries: []domain.LogEntry{
{Fields: map[string]string{
"eventType": "game.session.opened",
"networkFingerprint": "fingerprint",
"ip": "192.0.2.1",
"ipAddress": "2001:db8::1",
"playerId": "player-1",
}},
}}
sanitizeLogNetworkFields(&batch)
fields := batch.Entries[0].Fields
for _, key := range []string{"networkFingerprint", "ip", "ipAddress"} {
if _, exists := fields[key]; exists {
t.Fatalf("expected %s to be removed", key)
}
}
if fields["playerId"] != "player-1" {
t.Fatal("expected unrelated fields to be preserved")
}
}
+83 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -74,7 +75,7 @@ func TestCoreServicePublishesLogEventsForAcceptedBatch(t *testing.T) {
}
select {
case event := <-subscription.Events:
if event.Stream.ID != "log-1" || event.Entry.Seq != 1 || event.LatestSeq != 1 {
if event.Kind != LogEventSubscriptionEventLog || event.LogEvent.Stream.ID != "log-1" || event.LogEvent.Entry.Seq != 1 || event.LogEvent.LatestSeq != 1 {
t.Fatalf("unexpected log event: %+v", event)
}
case <-time.After(time.Second):
@@ -91,6 +92,76 @@ func TestCoreServicePublishesLogEventsForAcceptedBatch(t *testing.T) {
}
}
func TestCoreServicePersistsAndEnforcesImmutableProcessLogSessionMetadata(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
startedAt := time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
streamID := runSessionLogStreamID("run-local", "server-1", "session-a", "stdout")
batch := validLogBatch(t, sessionToken, 1, 1)
batch.LogStreamID = streamID
batch.LogSessionID = "session-a"
batch.SessionStartedAt = startedAt
if _, err := svc.IngestLogBatch(batch); err != nil {
t.Fatalf("ingest session-scoped process batch: %v", err)
}
stream, err := svc.GetLogStream(streamID)
if err != nil || stream.LogSessionID != "session-a" || !stream.SessionStartedAt.Equal(startedAt) {
t.Fatalf("session metadata was not persisted: stream=%+v err=%v", stream, err)
}
conflict := validLogBatch(t, sessionToken, 2, 2)
conflict.LogStreamID = streamID
conflict.LogSessionID = "session-a"
conflict.SessionStartedAt = startedAt.Add(time.Second)
if _, err := svc.IngestLogBatch(conflict); err == nil || !strings.Contains(err.Error(), "metadata must match") {
t.Fatalf("expected immutable stream metadata rejection, got %v", err)
}
legacy := createLogStreamFixture(t, svc)
legacyBatch := validLogBatch(t, sessionToken, 1, 1)
legacyBatch.LogStreamID = legacy.ID
legacyBatch.LogSessionID = "session-a"
legacyBatch.SessionStartedAt = startedAt
if _, err := svc.IngestLogBatch(legacyBatch); err == nil || !strings.Contains(err.Error(), "metadata must match") {
t.Fatalf("expected legacy stream to reject attached session metadata, got %v", err)
}
}
func TestCoreServiceSerializesConsistentSessionStreamCreation(t *testing.T) {
svc, _ := newRegisteredLogIngestService(t)
startedAt := time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
streams := []domain.LogStream{
{ID: "session-stream-stdout", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stdout", LogSessionID: "session-a", SessionStartedAt: startedAt, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"},
{ID: "session-stream-stderr", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stderr", LogSessionID: "session-a", SessionStartedAt: startedAt, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"},
}
errorsByStream := make(chan error, len(streams))
var wait sync.WaitGroup
for _, stream := range streams {
stream := stream
wait.Add(1)
go func() {
defer wait.Done()
_, err := svc.CreateLogStream(stream)
errorsByStream <- err
}()
}
wait.Wait()
close(errorsByStream)
for err := range errorsByStream {
if err != nil {
t.Fatalf("create consistent session stream: %v", err)
}
}
_, err := svc.CreateLogStream(domain.LogStream{ID: "session-stream-conflict", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "console", LogSessionID: "session-a", SessionStartedAt: startedAt.Add(time.Second), StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"})
if err == nil || !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("expected conflicting session timestamp rejection, got %v", err)
}
_, err = svc.CreateLogStream(domain.LogStream{ID: "session-file-tail", ServerInstanceID: "server-1", Source: domain.LogStreamSourceFile, StreamKey: "file", LogSessionID: "session-file", SessionStartedAt: startedAt, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"})
if err == nil || !strings.Contains(err.Error(), "only valid for process") {
t.Fatalf("expected file-tail session metadata rejection, got %v", err)
}
}
func TestCoreServiceLogBatchDuplicateAck(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
@@ -346,6 +417,17 @@ func TestCoreServiceAcceptsLegacyAutonomousJobLogStreamWithoutPlatformJob(t *tes
}
}
func TestCoreServiceRejectsSessionMetadataOnLegacyAutonomousStreamID(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
batch := validLogBatch(t, sessionToken, 1, 1)
batch.LogStreamID = jobLogStreamID("autonomous-bootstrap-start", "stdout")
batch.LogSessionID = "session-a"
batch.SessionStartedAt = time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
if _, err := svc.IngestLogBatch(batch); err == nil {
t.Fatal("expected session-scoped batch with legacy autonomous stream ID to be rejected")
}
}
func TestCoreServiceRejectsOutOfOrderAndConflictingLogBatches(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
+48 -1
View File
@@ -233,6 +233,7 @@ type CoreService struct {
bridgeMu sync.Mutex
bridgeSeq uint64
logStore LogBodyStore
logIngestMu [64]sync.Mutex
logEventMu sync.Mutex
logEventSubscribers map[uint64]logEventSubscriber
logEventSubscriberSeq uint64
@@ -2476,6 +2477,13 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
}
func (svc *CoreService) ensureJobLogStreams(job domain.Job, stamp time.Time) error {
lock := svc.logIngestLock(job.ServerInstanceID)
lock.Lock()
defer lock.Unlock()
return svc.ensureJobLogStreamsUnlocked(job, stamp)
}
func (svc *CoreService) ensureJobLogStreamsUnlocked(job domain.Job, stamp time.Time) error {
if strings.TrimSpace(job.ServerInstanceID) == "" || strings.TrimSpace(job.ID) == "" {
return nil
}
@@ -2524,7 +2532,7 @@ func (svc *CoreService) ensureJobLogStreams(job domain.Job, stamp time.Time) err
CreatedAt: stamp,
UpdatedAt: stamp,
}
if _, err := svc.CreateLogStream(stream); err != nil && !errors.Is(err, repo.ErrDuplicate) {
if _, err := svc.createLogStream(stream); err != nil && !errors.Is(err, repo.ErrDuplicate) {
return err
}
}
@@ -2539,6 +2547,10 @@ func runLogStreamID(runEndpointID string, serverInstanceID string, streamKey str
return fmt.Sprintf("run.%s.%s.%s", runEndpointID, serverInstanceID, streamKey)
}
func runSessionLogStreamID(runEndpointID string, serverInstanceID string, logSessionID string, streamKey string) string {
return fmt.Sprintf("run.%s.%s.%s.%s", runEndpointID, serverInstanceID, logSessionID, streamKey)
}
func (svc *CoreService) GetJob(id string) (domain.Job, error) {
job, err := svc.store.Jobs().Get(id)
if err != nil {
@@ -2587,6 +2599,22 @@ func (svc *CoreService) ListArtifacts(filter domain.ArtifactFilter) ([]domain.Ar
}
func (svc *CoreService) CreateLogStream(stream domain.LogStream) (domain.LogStream, error) {
lock := svc.logIngestLock(stream.ServerInstanceID)
lock.Lock()
defer lock.Unlock()
return svc.createLogStream(stream)
}
func (svc *CoreService) logIngestLock(serverInstanceID string) *sync.Mutex {
hash := uint32(2166136261)
for index := 0; index < len(serverInstanceID); index++ {
hash ^= uint32(serverInstanceID[index])
hash *= 16777619
}
return &svc.logIngestMu[hash%uint32(len(svc.logIngestMu))]
}
func (svc *CoreService) createLogStream(stream domain.LogStream) (domain.LogStream, error) {
instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID)
if err != nil {
return domain.LogStream{}, fmt.Errorf("get server instance dependency: %w", err)
@@ -2604,12 +2632,31 @@ func (svc *CoreService) CreateLogStream(stream domain.LogStream) (domain.LogStre
if err := validator.ValidateLogStream(stream); err != nil {
return domain.LogStream{}, err
}
if err := svc.validateLogStreamSession(stream); err != nil {
return domain.LogStream{}, err
}
if err := svc.store.LogStreams().Create(stream); err != nil {
return domain.LogStream{}, err
}
return domain.CopyLogStream(stream), nil
}
func (svc *CoreService) validateLogStreamSession(stream domain.LogStream) error {
if stream.LogSessionID == "" {
return nil
}
streams, err := svc.store.LogStreams().List(domain.LogStreamFilter{ServerInstanceID: stream.ServerInstanceID})
if err != nil {
return err
}
for _, existing := range streams {
if existing.LogSessionID == stream.LogSessionID && !existing.SessionStartedAt.Equal(stream.SessionStartedAt) {
return validationError("log session metadata conflicts with an existing stream")
}
}
return nil
}
func (svc *CoreService) GetLogStream(id string) (domain.LogStream, error) {
return svc.store.LogStreams().Get(id)
}
+9
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
@@ -255,6 +256,14 @@ func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLif
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(action)); err != nil {
return domain.ServerLifecycleResult{}, err
}
if action == domain.ServerLifecycleActionStart {
instance.LifecycleProcessID = ""
instance.LifecycleObservationSeq = 0
instance.LifecycleObservedAt = time.Time{}
if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
}
job, err := svc.dispatchLifecycleJob(instance, action, command.IdempotencyKey)
if err != nil {
@@ -50,6 +50,7 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do
if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.RunLifecycleReportResult{}, err
}
svc.publishLogProcessState(instance)
}
auditResult := domain.AuditResultSuccess
if report.State == domain.JobStateFailed || report.State == domain.JobStateCancelled {
@@ -140,6 +141,7 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
if err := svc.store.ServerInstances().Update(instance); err != nil {
return err
}
svc.publishLogProcessState(instance)
auditResult := domain.AuditResultSuccess
if job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled {
auditResult = domain.AuditResultFailed
+56
View File
@@ -3,6 +3,7 @@ package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
@@ -103,6 +104,61 @@ func TestLifecycleProjectedStateUsesRunProcessFacts(t *testing.T) {
}
}
func TestLifecycleJobResultsPublishProcessStateEvents(t *testing.T) {
svc, sessionToken := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
created, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ID: "server-state-events", PluginID: "server.scum", RunEndpointID: "run-local", Name: "State Events", IdempotencyKey: "state-events-create", ProfileKey: "local"})
if err != nil {
t.Fatalf("create lifecycle server: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, created.Instance.ID, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
ready, err := svc.GetServerInstance(created.Instance.ID)
if err != nil {
t.Fatalf("get ready server: %v", err)
}
if _, err := svc.StartServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: ready.ID, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "state-events-start"}); err != nil {
t.Fatalf("dispatch start: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, ready.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
subscription, err := svc.SubscribeLogEvents(ready.ID)
if err != nil {
t.Fatalf("subscribe state events: %v", err)
}
defer subscription.Close()
running, err := svc.GetServerInstance(ready.ID)
if err != nil {
t.Fatalf("get running server: %v", err)
}
if _, err := svc.StopServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: running.ID, ExpectedConfigVersion: running.ConfigVersion, IdempotencyKey: "state-events-stop"}); err != nil {
t.Fatalf("dispatch stop: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, running.ID, domain.LifecycleCapabilityStop, domain.JobStateSucceeded)
assertLogProcessStateEvent(t, subscription, domain.ServerInstanceStateStopped)
stopped, err := svc.GetServerInstance(running.ID)
if err != nil {
t.Fatalf("get stopped server: %v", err)
}
if _, err := svc.StartServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: stopped.ID, ExpectedConfigVersion: stopped.ConfigVersion, IdempotencyKey: "state-events-restart"}); err != nil {
t.Fatalf("dispatch restart: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, stopped.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
assertLogProcessStateEvent(t, subscription, domain.ServerInstanceStateRunning)
}
func assertLogProcessStateEvent(t *testing.T, subscription LogEventSubscription, want domain.ServerInstanceState) {
t.Helper()
select {
case event := <-subscription.Events:
if event.Kind != LogEventSubscriptionEventProcessState || event.ProcessState != want {
t.Fatalf("unexpected process state event: %+v", event)
}
case <-time.After(time.Second):
t.Fatalf("expected process state event %q", want)
}
}
func TestCoreServiceCreatesTargetBoundDraftAndRequiresDedicatedRunRegistration(t *testing.T) {
svc, _ := newLifecycleRunService(t)
plugin := createLifecyclePlugin(t, svc)