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))
|
||||
}
|
||||
@@ -123,9 +123,6 @@ type DistributionBuildInput struct {
|
||||
PackageFormat string
|
||||
RepositoryURL string
|
||||
SourceRevision string
|
||||
WorkspaceRef string
|
||||
EntryRef string
|
||||
ConfigTemplateRef string
|
||||
ArtifactID string
|
||||
OutputFilename string
|
||||
SecretRef string
|
||||
@@ -152,6 +149,7 @@ type RunAutonomousLifecyclePlan struct {
|
||||
InstallPlans []RunAutonomousInstallPlan `json:"installPlans,omitempty"`
|
||||
LogSources []RunAutonomousLogSource `json:"logSources,omitempty"`
|
||||
DLLExtensions []RunAutonomousDLLExtension `json:"dllExtensions,omitempty"`
|
||||
DataTargets []RunAutonomousDataTarget `json:"dataTargets,omitempty"`
|
||||
RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"`
|
||||
Deployment *RunAutonomousDeployment `json:"deployment,omitempty"`
|
||||
}
|
||||
@@ -212,6 +210,20 @@ type RunAutonomousDLLExtension struct {
|
||||
RCONPort int `json:"rconPort,omitempty"`
|
||||
}
|
||||
|
||||
// RunAutonomousDataTarget is a package-local snapshot declaration. The source
|
||||
// root is resolved only inside Run from a private generated package binding.
|
||||
type RunAutonomousDataTarget struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
SourceRootKey string `json:"sourceRootKey"`
|
||||
SourcePath string `json:"sourcePath"`
|
||||
WorkspaceKey string `json:"workspaceKey"`
|
||||
RefreshPolicy string `json:"refreshPolicy"`
|
||||
MaxBytes int64 `json:"maxBytes,omitempty"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
type RunAutonomousDeployment struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Mode ServerDeploymentMode `json:"mode"`
|
||||
@@ -474,6 +486,10 @@ func CopyRunAutonomousLifecyclePlanPtr(plan *RunAutonomousLifecyclePlan) *RunAut
|
||||
}
|
||||
copy.LogSources = append([]RunAutonomousLogSource(nil), plan.LogSources...)
|
||||
copy.DLLExtensions = append([]RunAutonomousDLLExtension(nil), plan.DLLExtensions...)
|
||||
copy.DataTargets = append([]RunAutonomousDataTarget(nil), plan.DataTargets...)
|
||||
for i := range copy.DataTargets {
|
||||
copy.DataTargets[i].Platforms = CopyStringSlice(plan.DataTargets[i].Platforms)
|
||||
}
|
||||
copy.RuntimeBindings = CopyStringMap(plan.RuntimeBindings)
|
||||
if plan.Deployment != nil {
|
||||
deployment := *plan.Deployment
|
||||
|
||||
+38
-44
@@ -209,28 +209,25 @@ type DistributionBuildInputRequest struct {
|
||||
}
|
||||
|
||||
type DistributionBuildInputResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ComponentKind string `json:"componentKind"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
TargetRelease string `json:"targetRelease"`
|
||||
PlatformURL string `json:"platformUrl,omitempty"`
|
||||
PackageFormat string `json:"packageFormat"`
|
||||
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
WorkspaceRef string `json:"workspaceRef,omitempty"`
|
||||
EntryRef string `json:"entryRef,omitempty"`
|
||||
ConfigTemplateRef string `json:"configTemplateRef,omitempty"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
OutputFilename string `json:"outputFilename"`
|
||||
SecretRef string `json:"secretRef"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
AuthKey string `json:"authKey"`
|
||||
WorkspaceSeed string `json:"workspaceSeed,omitempty"`
|
||||
JobID string `json:"jobId"`
|
||||
ComponentKind string `json:"componentKind"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
TargetRelease string `json:"targetRelease"`
|
||||
PlatformURL string `json:"platformUrl,omitempty"`
|
||||
PackageFormat string `json:"packageFormat"`
|
||||
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
OutputFilename string `json:"outputFilename"`
|
||||
SecretRef string `json:"secretRef"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
AuthKey string `json:"authKey"`
|
||||
WorkspaceSeed string `json:"workspaceSeed,omitempty"`
|
||||
}
|
||||
|
||||
type DependencyExecutionInputRequest struct {
|
||||
@@ -554,28 +551,25 @@ func RunJobResultFromDomain(result domain.RunJobResultResult) RunJobResultRespon
|
||||
|
||||
func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) DistributionBuildInputResponse {
|
||||
return DistributionBuildInputResponse{
|
||||
JobID: input.JobID,
|
||||
ComponentKind: string(input.ComponentKind),
|
||||
ServerInstanceID: input.ServerInstanceID,
|
||||
PluginID: input.PluginID,
|
||||
RunEndpointID: input.RunEndpointID,
|
||||
ProfileKey: input.ProfileKey,
|
||||
TargetOS: input.TargetOS,
|
||||
TargetArch: input.TargetArch,
|
||||
TargetRelease: input.TargetRelease,
|
||||
PlatformURL: input.PlatformURL,
|
||||
PackageFormat: input.PackageFormat,
|
||||
RepositoryURL: input.RepositoryURL,
|
||||
SourceRevision: input.SourceRevision,
|
||||
WorkspaceRef: input.WorkspaceRef,
|
||||
EntryRef: input.EntryRef,
|
||||
ConfigTemplateRef: input.ConfigTemplateRef,
|
||||
ArtifactID: input.ArtifactID,
|
||||
OutputFilename: input.OutputFilename,
|
||||
SecretRef: input.SecretRef,
|
||||
KeyGeneration: input.KeyGeneration,
|
||||
AuthKey: input.AuthKey,
|
||||
WorkspaceSeed: input.WorkspaceSeed,
|
||||
JobID: input.JobID,
|
||||
ComponentKind: string(input.ComponentKind),
|
||||
ServerInstanceID: input.ServerInstanceID,
|
||||
PluginID: input.PluginID,
|
||||
RunEndpointID: input.RunEndpointID,
|
||||
ProfileKey: input.ProfileKey,
|
||||
TargetOS: input.TargetOS,
|
||||
TargetArch: input.TargetArch,
|
||||
TargetRelease: input.TargetRelease,
|
||||
PlatformURL: input.PlatformURL,
|
||||
PackageFormat: input.PackageFormat,
|
||||
RepositoryURL: input.RepositoryURL,
|
||||
SourceRevision: input.SourceRevision,
|
||||
ArtifactID: input.ArtifactID,
|
||||
OutputFilename: input.OutputFilename,
|
||||
SecretRef: input.SecretRef,
|
||||
KeyGeneration: input.KeyGeneration,
|
||||
AuthKey: input.AuthKey,
|
||||
WorkspaceSeed: input.WorkspaceSeed,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-115
@@ -306,25 +306,6 @@ type GameClientBridgeQueryProjectionDeclarationBody struct {
|
||||
MergeExisting bool `json:"mergeExisting,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionStepDeclarationBody struct {
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionTargetDeclarationBody struct {
|
||||
Collection string `json:"collection"`
|
||||
UpsertKeys []string `json:"upsertKeys"`
|
||||
CaptureMappings map[string]string `json:"captureMappings"`
|
||||
HashMappings map[string]string `json:"hashMappings,omitempty"`
|
||||
FixedValues map[string]string `json:"fixedValues,omitempty"`
|
||||
ObservedAtField string `json:"observedAtField,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionPresenceDeclarationBody struct {
|
||||
TimestampField string `json:"timestampField"`
|
||||
ActiveWindowSeconds int `json:"activeWindowSeconds"`
|
||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLifecycleProjectionDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
@@ -349,20 +330,9 @@ type GameClientBridgeBulkActivityTargetBody struct {
|
||||
ObservedAtField string `json:"observedAtField,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
StreamKeys []string `json:"streamKeys"`
|
||||
Steps []GameClientBridgeLogProjectionStepDeclarationBody `json:"steps"`
|
||||
CorrelationFields []string `json:"correlationFields"`
|
||||
MaxInterveningLines int `json:"maxInterveningLines"`
|
||||
Target GameClientBridgeLogProjectionTargetDeclarationBody `json:"target"`
|
||||
Presence *GameClientBridgeLogProjectionPresenceDeclarationBody `json:"presence,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeDataPackDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
DatabaseUserVersion int `json:"databaseUserVersion"`
|
||||
LogParserRefs []string `json:"logParserRefs"`
|
||||
ConfigMapRefs []string `json:"configMapRefs"`
|
||||
DataRefs []string `json:"dataRefs,omitempty"`
|
||||
}
|
||||
@@ -403,7 +373,6 @@ type GameClientBridgeManifestBody struct {
|
||||
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
|
||||
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
|
||||
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
|
||||
LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"`
|
||||
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclarationBody `json:"lifecycleProjections,omitempty"`
|
||||
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
|
||||
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
|
||||
@@ -1283,17 +1252,13 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
for index, template := range body.QueryTemplates {
|
||||
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, Projections: gameClientBridgeQueryProjectionsToDomain(template.Projections)}
|
||||
}
|
||||
logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections))
|
||||
for index, projection := range body.LogProjections {
|
||||
logProjections[index] = gameClientBridgeLogProjectionToDomain(projection)
|
||||
}
|
||||
lifecycleProjections := make([]domain.GameClientBridgeLifecycleProjectionDeclaration, len(body.LifecycleProjections))
|
||||
for index, projection := range body.LifecycleProjections {
|
||||
lifecycleProjections[index] = gameClientBridgeLifecycleProjectionToDomain(projection)
|
||||
}
|
||||
dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks))
|
||||
for index, dataPack := range body.DataPacks {
|
||||
dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
}
|
||||
pages := make([]domain.GameClientBridgePageContract, len(body.Pages))
|
||||
for index, page := range body.Pages {
|
||||
@@ -1307,31 +1272,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
if body.Companion != nil {
|
||||
companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds}
|
||||
}
|
||||
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDeclarationBody) domain.GameClientBridgeLogProjectionDeclaration {
|
||||
steps := make([]domain.GameClientBridgeLogProjectionStepDeclaration, len(value.Steps))
|
||||
for index, step := range value.Steps {
|
||||
steps[index] = domain.GameClientBridgeLogProjectionStepDeclaration{Pattern: step.Pattern}
|
||||
}
|
||||
var presence *domain.GameClientBridgeLogProjectionPresenceDeclaration
|
||||
if value.Presence != nil {
|
||||
presence = &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||
TimestampField: value.Presence.TimestampField,
|
||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||
ActivityTarget: gameClientBridgeLogProjectionTargetToDomainPointer(value.Presence.ActivityTarget),
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeLogProjectionDeclaration{
|
||||
Key: value.Key,
|
||||
StreamKeys: domain.CopyStringSlice(value.StreamKeys),
|
||||
Steps: steps,
|
||||
CorrelationFields: domain.CopyStringSlice(value.CorrelationFields),
|
||||
MaxInterveningLines: value.MaxInterveningLines,
|
||||
Target: gameClientBridgeLogProjectionTargetToDomain(value.Target),
|
||||
Presence: presence,
|
||||
}
|
||||
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeQueryProjectionsToDomain(values []GameClientBridgeQueryProjectionDeclarationBody) []domain.GameClientBridgeQueryProjectionDeclaration {
|
||||
@@ -1361,18 +1302,6 @@ func gameClientBridgeBulkActivityTargetToDomainPointer(value *GameClientBridgeBu
|
||||
return &target
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetToDomain(value GameClientBridgeLogProjectionTargetDeclarationBody) domain.GameClientBridgeLogProjectionTargetDeclaration {
|
||||
return domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeLogProjectionTargetDeclarationBody) *domain.GameClientBridgeLogProjectionTargetDeclaration {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
target := gameClientBridgeLogProjectionTargetToDomain(*value)
|
||||
return &target
|
||||
}
|
||||
|
||||
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
||||
return domain.PluginLifecycleActions{
|
||||
Install: actions.Install,
|
||||
@@ -1782,17 +1711,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
for index, template := range value.QueryTemplates {
|
||||
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, Projections: gameClientBridgeQueryProjectionsFromDomain(template.Projections)}
|
||||
}
|
||||
logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections))
|
||||
for index, projection := range value.LogProjections {
|
||||
logProjections[index] = gameClientBridgeLogProjectionFromDomain(projection)
|
||||
}
|
||||
lifecycleProjections := make([]GameClientBridgeLifecycleProjectionDeclarationBody, len(value.LifecycleProjections))
|
||||
for index, projection := range value.LifecycleProjections {
|
||||
lifecycleProjections[index] = gameClientBridgeLifecycleProjectionFromDomain(projection)
|
||||
}
|
||||
dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks))
|
||||
for index, dataPack := range value.DataPacks {
|
||||
dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
}
|
||||
pages := make([]GameClientBridgePageContractBody, len(value.Pages))
|
||||
for index, page := range value.Pages {
|
||||
@@ -1806,31 +1731,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
if value.Companion.ProfileKey != "" {
|
||||
companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds}
|
||||
}
|
||||
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclarationBody {
|
||||
steps := make([]GameClientBridgeLogProjectionStepDeclarationBody, len(value.Steps))
|
||||
for index, step := range value.Steps {
|
||||
steps[index] = GameClientBridgeLogProjectionStepDeclarationBody{Pattern: step.Pattern}
|
||||
}
|
||||
var presence *GameClientBridgeLogProjectionPresenceDeclarationBody
|
||||
if value.Presence != nil {
|
||||
presence = &GameClientBridgeLogProjectionPresenceDeclarationBody{
|
||||
TimestampField: value.Presence.TimestampField,
|
||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||
ActivityTarget: gameClientBridgeLogProjectionTargetFromDomainPointer(value.Presence.ActivityTarget),
|
||||
}
|
||||
}
|
||||
return GameClientBridgeLogProjectionDeclarationBody{
|
||||
Key: value.Key,
|
||||
StreamKeys: domain.CopyStringSlice(value.StreamKeys),
|
||||
Steps: steps,
|
||||
CorrelationFields: domain.CopyStringSlice(value.CorrelationFields),
|
||||
MaxInterveningLines: value.MaxInterveningLines,
|
||||
Target: gameClientBridgeLogProjectionTargetFromDomain(value.Target),
|
||||
Presence: presence,
|
||||
}
|
||||
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeQueryProjectionsFromDomain(values []domain.GameClientBridgeQueryProjectionDeclaration) []GameClientBridgeQueryProjectionDeclarationBody {
|
||||
@@ -1860,18 +1761,6 @@ func gameClientBridgeBulkActivityTargetFromDomainPointer(value *domain.GameClien
|
||||
return &target
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetFromDomain(value domain.GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclarationBody {
|
||||
return GameClientBridgeLogProjectionTargetDeclarationBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClientBridgeLogProjectionTargetDeclaration) *GameClientBridgeLogProjectionTargetDeclarationBody {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
target := gameClientBridgeLogProjectionTargetFromDomain(*value)
|
||||
return &target
|
||||
}
|
||||
|
||||
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
|
||||
items := make([]MarketplacePluginResponse, len(plugins))
|
||||
for i, plugin := range plugins {
|
||||
|
||||
@@ -109,17 +109,6 @@ type RuntimeLogSourceBody struct {
|
||||
RetentionDays int `json:"retentionDays,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeLogEventBody struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
SourceKey string `json:"sourceKey"`
|
||||
EventType string `json:"eventType"`
|
||||
Permission string `json:"permission"`
|
||||
SchemaRef string `json:"schemaRef"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type RuntimeTransportProfileBody struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -127,6 +116,18 @@ type RuntimeTransportProfileBody struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
type RuntimeDataTargetBody struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
SourceRootKey string `json:"sourceRootKey"`
|
||||
SourcePath string `json:"sourcePath"`
|
||||
WorkspaceKey string `json:"workspaceKey"`
|
||||
RefreshPolicy string `json:"refreshPolicy"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeRepositoryBody struct {
|
||||
URL string `json:"url"`
|
||||
RevisionPolicy string `json:"revisionPolicy"`
|
||||
@@ -261,8 +262,8 @@ type GamePluginRuntimeProfilesBody struct {
|
||||
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
|
||||
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
|
||||
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
||||
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
|
||||
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
|
||||
DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"`
|
||||
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"`
|
||||
}
|
||||
@@ -277,8 +278,8 @@ type GamePluginRuntimeProfilesResponseBody struct {
|
||||
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
|
||||
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
|
||||
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
||||
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
|
||||
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
|
||||
DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"`
|
||||
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionProfileResponseBody `json:"dllExtensions,omitempty"`
|
||||
}
|
||||
@@ -323,12 +324,12 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
|
||||
for _, item := range body.LogSources {
|
||||
profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
||||
}
|
||||
for _, item := range body.LogEvents {
|
||||
profiles.LogEvents = append(profiles.LogEvents, domain.RuntimeLogEvent{Key: item.Key, Title: item.Title, SourceKey: item.SourceKey, EventType: item.EventType, Permission: item.Permission, SchemaRef: item.SchemaRef, RetentionDays: item.RetentionDays, Severity: domain.RuntimeLogEventSeverity(item.Severity)})
|
||||
}
|
||||
for _, item := range body.TransportProfiles {
|
||||
profiles.TransportProfiles = append(profiles.TransportProfiles, domain.RuntimeTransportProfile{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: domain.CopyStringSlice(item.Capabilities)})
|
||||
}
|
||||
for _, item := range body.DataTargets {
|
||||
profiles.DataTargets = append(profiles.DataTargets, domain.RuntimeDataTarget{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: domain.CopyStringSlice(item.Platforms)})
|
||||
}
|
||||
for _, item := range body.ClientManagers {
|
||||
manager := domain.RuntimeClientManagerProfile{
|
||||
Key: item.Key, DisplayName: item.DisplayName, Version: item.Version,
|
||||
@@ -399,12 +400,12 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
|
||||
for _, item := range profiles.LogSources {
|
||||
body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
||||
}
|
||||
for _, item := range profiles.LogEvents {
|
||||
body.LogEvents = append(body.LogEvents, RuntimeLogEventBody{Key: item.Key, Title: item.Title, SourceKey: item.SourceKey, EventType: item.EventType, Permission: item.Permission, SchemaRef: item.SchemaRef, RetentionDays: item.RetentionDays, Severity: string(item.Severity)})
|
||||
}
|
||||
for _, item := range profiles.TransportProfiles {
|
||||
body.TransportProfiles = append(body.TransportProfiles, RuntimeTransportProfileBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: item.Capabilities})
|
||||
}
|
||||
for _, item := range profiles.DataTargets {
|
||||
body.DataTargets = append(body.DataTargets, RuntimeDataTargetBody{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: item.Platforms})
|
||||
}
|
||||
for _, item := range profiles.ClientManagers {
|
||||
manager := RuntimeClientManagerProfileBody{
|
||||
Key: item.Key, DisplayName: item.DisplayName, Version: item.Version,
|
||||
|
||||
@@ -23,7 +23,7 @@ Named control DTOs:
|
||||
|
||||
Control payloads must remain small and must not include logs, artifact chunks, host paths, raw credentials, direct sockets, job assignments, execution input, or long task results. Hello creates or updates run endpoint metadata and issues an in-memory platform session token. A server-scoped generated Run must use the endpoint identity reserved for its server; Platform rejects a valid component key presented for another endpoint. Registration is binding/authentication only for generated Run bootstrap and must not enqueue lifecycle or status jobs merely because Run appeared. Heartbeat requires that active session token and may request capability refresh when the fingerprint changes. The control event stream is a signed Run-only `text/event-stream` wake channel; events such as `job.changed` only tell Run to claim durable work through `/run/jobs/claim`.
|
||||
|
||||
Capacity reports include bounded `maxJobs`, `runningJobs`, `queuedJobs`, compatibility `logBacklogBatches`, `artifactBacklogChunks`, and enumerated pressure codes. Current Run implementations must not use `logBacklogBatches` as a durable live-log spool; capacity reports never include log bodies, artifact chunks, machine paths, PIDs, sockets, credentials, or transport endpoints.
|
||||
Capacity reports include bounded `maxJobs`, `runningJobs`, `queuedJobs`, `logBacklogBatches`, `artifactBacklogChunks`, and enumerated pressure codes. They report queue and spool counts only, never log bodies, artifact chunks, machine paths, PIDs, sockets, credentials, or transport endpoints.
|
||||
|
||||
Control is the highest-priority run/platform path. Artifact/file transfer load must not delay heartbeat acceptance or mutate heartbeat capacity state through heavy payload fields.
|
||||
|
||||
@@ -65,34 +65,31 @@ The plan is build input for the generated package, not a machine-side job-channe
|
||||
|
||||
Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the active Run session and signed envelope when required. The route accepts only bounded terminal lifecycle facts for `process.install`, `process.start`, `process.stop`, or `process.status`; it validates the server/run binding, records bounded evidence, and projects server state from Run-reported process facts without creating or completing a Platform job. A managed-process report includes an opaque `managedProcessId`, monotonic `observationSeq`, and `observedAt`; retries are idempotent and a lower sequence cannot regress a newer fact for that process.
|
||||
|
||||
## Live Log Relay And Compatibility Log Ingest
|
||||
## Log Ingest
|
||||
|
||||
|
||||
Implemented HTTP JSON routes:
|
||||
|
||||
- `POST /api/v1/run/logs/relay`
|
||||
- `POST /api/v1/run/logs/batches`
|
||||
- `GET /api/v1/server-instances/{id}/logs/events`
|
||||
- `POST /api/v1/game-client-bridge/companion/logs/events`
|
||||
- `POST /api/v1/log-streams/query`
|
||||
|
||||
Named log DTOs:
|
||||
|
||||
- `LogBatchIngestRequest`
|
||||
- `LogBatchIngestResponse`
|
||||
- `RunLogStreamProgressRequest` / `RunLogStreamProgressResponse`: compatibility signed Run-only cursor metadata for older durable-ingest streams. Current live relay does not depend on this route for delivery or progress.
|
||||
- `RunLogStreamProgressRequest` / `RunLogStreamProgressResponse`: signed Run-only sequence recovery for a server-bound `run.<endpoint>.<server>.*` stream. The response contains only the latest acknowledged sequence.
|
||||
- `LogEntry`
|
||||
- `LogStreamCursorRequest`
|
||||
- `LogStreamCursorResponse`
|
||||
- `LogStreamEventResponse`
|
||||
|
||||
Current Run output uses `POST /api/v1/run/logs/relay`. The route validates the active Run session and stream binding, updates only bounded stream metadata such as latest sequence/session identity, and immediately fans entries out to live subscribers. It does not persist log bodies, does not create a resend backlog, does not require platform sequence acknowledgements for progress, and does not block lifecycle/control/job work on delivery success.
|
||||
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.
|
||||
|
||||
Server terminal streaming uses `GET /api/v1/server-instances/{id}/logs/events`; SCUM companion streaming uses `POST /api/v1/game-client-bridge/companion/logs/events` with the component session token in the typed JSON body. Both emit only the current supervised process session and do not replay retained platform log bodies. Platform is the live relay only. The game plugin/companion owns game-log storage, semantic analysis, and console-page fan-out behavior.
|
||||
Run-assigned Platform jobs use `job.<jobId>.<streamKey>` log stream IDs. Autonomous lifecycle bootstrap is Run-owned machine execution rather than a Platform job, so its durable process logs use `run.<runEndpointId>.<serverInstanceId>.<streamKey>`. Platform may auto-create those streams only after validating the active Run session and the server-to-Run binding. For retry compatibility, legacy spooled `job.autonomous-*.<streamKey>` batches are accepted as Run-owned autonomous streams without creating or completing a Platform job.
|
||||
|
||||
`POST /api/v1/run/logs/batches` and `POST /api/v1/log-streams/query` remain compatibility/internal maintenance contracts for older durable-ingest flows. New Run workers must not use durable local log spool/cache/resend semantics for current supervised stdout/stderr. 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.
|
||||
|
||||
The platform stores log stream metadata through `repo.Store`. Game-specific durable log bodies and derived semantic records belong to the owning game plugin; for SCUM that storage uses plugin-owned SQL tables and stores raw world coordinates without map projection or coordinate conversion.
|
||||
The platform stores log stream metadata through `repo.Store` and stores log bodies through the configured `LogBodyStore`. The default `file` backend persists platform metadata to `PLATFORM_METADATA_PATH` and appends log entries to segmented JSONL files under `PLATFORM_LOG_DIR`; the `memory` backend is only for tests and disposable local development. MySQL/Postgres are appropriate for platform metadata, stream state, retention policy, indexes, and operational records, but should not be the primary row-per-log-line store for hundreds or thousands of servers. Production log bodies should move behind the same boundary to append/query backends such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments with compact indexes.
|
||||
|
||||
## Artifact
|
||||
|
||||
@@ -115,9 +112,9 @@ Named artifact DTOs:
|
||||
- `ArtifactTransferCompleteResponse`
|
||||
- `ArtifactResponse`
|
||||
|
||||
Artifact upload supports active run session validation, job/server-instance owner scoping, bounded JSON chunk payloads, per-chunk checksum validation, duplicate chunk acknowledgement, resume status, and final checksum verification before an artifact becomes available. Artifact transport is separate from control, job result, live log relay, plugin bridge, and browser file APIs.
|
||||
Artifact upload supports active run session validation, job/server-instance owner scoping, bounded JSON chunk payloads, per-chunk checksum validation, duplicate chunk acknowledgement, resume status, and final checksum verification before an artifact becomes available. Artifact transport is separate from control, job result, log ingest, plugin bridge, and browser file APIs.
|
||||
|
||||
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or current live log relay. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
|
||||
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
|
||||
|
||||
## Server File Manager Transfer
|
||||
|
||||
@@ -131,7 +128,7 @@ Browser-facing file management uses server-instance scoped routes on Platform fo
|
||||
|
||||
Browser uploads are first staged as server-instance artifacts. Platform then queues a `files.write` job whose `inputRef` is `artifact://<id>` and whose execution input names the dedicated `run-file-transfer` channel. Run pulls those bytes through `POST /api/v1/run/files/input-chunk` while proving the active endpoint session plus job attempt and lease. The chunk route is fenced to the active file-write job, validates artifact ownership/checksum, and returns bounded byte ranges only.
|
||||
|
||||
File-manager transfer is a separate, low-priority heavy path. Slow uploads, downloads, retries, or file input chunk pulls must not block control heartbeat, job claim/ack/progress/result/cancel/reconcile, current live log relay, or artifact upload acknowledgements. Control, jobs, logs, artifacts, file transfer, and optional game-client bridge remain independently backpressured channels.
|
||||
File-manager transfer is a separate, low-priority heavy path. Slow uploads, downloads, retries, or file input chunk pulls must not block control heartbeat, job claim/ack/progress/result/cancel/reconcile, durable log batch ingest, or artifact upload acknowledgements. Control, jobs, logs, artifacts, file transfer, and optional game-client bridge remain independently backpressured channels.
|
||||
|
||||
## Client Manager lifecycle channel
|
||||
|
||||
@@ -139,8 +136,8 @@ Client Manager lifecycle jobs use the independent capabilities `client-manager.d
|
||||
|
||||
Run materializes the declared output such as `config.yaml` from the fenced values and its own configured Platform control URL. Source template values are not credentials and must not override the generated component identity or policy. The lifecycle input never contains the component proof itself, a component session, a browser credential, a host path, or a direct socket; proof remains inside the component package and is supplied to the supervised process only through the declared environment-variable name.
|
||||
|
||||
Run persists staging/active/previous slots and a local journal. It rejects stale lease/attempt/generation/target fences, traversal/link/device-file archives, checksum mismatches, undeclared executables, and arbitrary shell. Terminal results use `client-manager.deployed`, `client-manager.control`, `client-manager.updated`, `client-manager.rolled-back`, `client-manager.rollback.restored`, or `client-manager.uninstalled` with logical process/health state only. A stalled Client Manager download must not delay Run heartbeat, job ack/result/cancel, or current live log relay.
|
||||
Run persists staging/active/previous slots and a local journal. It rejects stale lease/attempt/generation/target fences, traversal/link/device-file archives, checksum mismatches, undeclared executables, and arbitrary shell. Terminal results use `client-manager.deployed`, `client-manager.control`, `client-manager.updated`, `client-manager.rolled-back`, `client-manager.rollback.restored`, or `client-manager.uninstalled` with logical process/health state only. A stalled Client Manager download must not delay Run heartbeat, job ack/result/cancel, or log spool acknowledgement.
|
||||
|
||||
## Game Client Bridge
|
||||
|
||||
The optional game client bridge is separate from run lifecycle, control registration, job handling, compatibility log ingest, and artifact transport. A plugin companion may subscribe to the current live log relay through its component session so it can own game-log storage and analysis without requiring Platform to persist or interpret game log bodies.
|
||||
The optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport.
|
||||
|
||||
@@ -231,7 +231,7 @@ func buildLifecycleDistribution(t *testing.T, svc *CoreService, session string,
|
||||
}
|
||||
payload := []byte("client-manager-package-" + version)
|
||||
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
|
||||
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: idempotency})
|
||||
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: idempotency})
|
||||
if err != nil {
|
||||
t.Fatalf("generate lifecycle distribution: %v", err)
|
||||
}
|
||||
|
||||
@@ -188,60 +188,29 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
profile, err := svc.clientManagerDistributionBuildProfile(distribution)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PlatformURL: runReleasePlatformURL(),
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
WorkspaceRef: profile.WorkspaceRef,
|
||||
EntryRef: profile.EntryRef,
|
||||
ConfigTemplateRef: clientManagerBuildConfigTemplateRef(profile),
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PlatformURL: runReleasePlatformURL(),
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
}, nil
|
||||
}
|
||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||
}
|
||||
|
||||
func (svc *CoreService) clientManagerDistributionBuildProfile(distribution domain.ClientManagerDistribution) (domain.RuntimeClientManagerProfile, error) {
|
||||
plugin, err := svc.store.GamePlugins().Get(distribution.PluginID)
|
||||
if err != nil {
|
||||
return domain.RuntimeClientManagerProfile{}, err
|
||||
}
|
||||
profile, err := findRuntimeClientManagerProfile(plugin, distribution.ProfileKey)
|
||||
if err != nil {
|
||||
return domain.RuntimeClientManagerProfile{}, err
|
||||
}
|
||||
if profile.RepositoryURL != distribution.RepositoryURL || !clientManagerProfileAllowsRevision(profile, distribution.SourceRevision) || !clientManagerProfileSupportsTarget(profile, distribution.TargetOS, distribution.TargetArch) {
|
||||
return domain.RuntimeClientManagerProfile{}, validationError("client-manager build no longer matches the declared profile")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func clientManagerBuildConfigTemplateRef(profile domain.RuntimeClientManagerProfile) string {
|
||||
for _, candidate := range profile.ConfigTemplates {
|
||||
if candidate.OutputRef == "config.yaml" && strings.TrimSpace(candidate.TemplateRef) != "" {
|
||||
return candidate.TemplateRef
|
||||
}
|
||||
}
|
||||
return "config.yaml.example"
|
||||
}
|
||||
|
||||
type runDistributionPackageContext struct {
|
||||
profileKey string
|
||||
workspaceSeed string
|
||||
@@ -277,39 +246,8 @@ func (svc *CoreService) runDistributionPackageContext(distribution domain.RunDis
|
||||
return runDistributionPackageContext{profileKey: profileKey, workspaceSeed: seed, autonomousLifecycle: plan}, nil
|
||||
}
|
||||
|
||||
func defaultRunDistributionProfileKey(plugin domain.GamePlugin, profileKey string) string {
|
||||
profileKey = strings.TrimSpace(profileKey)
|
||||
if profileKey != "" {
|
||||
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey); ok {
|
||||
return profile.Key
|
||||
}
|
||||
return profileKey
|
||||
}
|
||||
return firstRuntimeLifecycleProfileKey(plugin)
|
||||
}
|
||||
|
||||
func lifecycleDefaultProfileKey(instance domain.ServerInstance, plugin domain.GamePlugin, profileKey string) string {
|
||||
profileKey = strings.TrimSpace(profileKey)
|
||||
if profileKey != "" {
|
||||
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey); ok {
|
||||
return profile.Key
|
||||
}
|
||||
return profileKey
|
||||
}
|
||||
if instance.RunEndpointID != "" && instance.RunEndpointID == generatedRunEndpointID(instance.ID) {
|
||||
return firstRuntimeLifecycleProfileKey(plugin)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstRuntimeLifecycleProfileKey(plugin domain.GamePlugin) string {
|
||||
if len(plugin.RuntimeProfiles.LifecycleProfiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
return plugin.RuntimeProfiles.LifecycleProfiles[0].Key
|
||||
}
|
||||
|
||||
func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance domain.ServerInstance, plugin domain.GamePlugin, profileKey string, bindings map[string]string) (*domain.RunAutonomousLifecyclePlan, error) {
|
||||
privateBindings := autonomousDataTargetBindings(bindings, instance, plugin.RuntimeProfiles.DataTargets)
|
||||
plan := &domain.RunAutonomousLifecyclePlan{
|
||||
SchemaVersion: "1",
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -321,8 +259,8 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
TargetArch: distribution.TargetArch,
|
||||
TargetRelease: distribution.ID,
|
||||
DeploymentRevision: instance.Deployment.Revision,
|
||||
RuntimeBindings: domain.CopyStringMap(bindings),
|
||||
Deployment: autonomousDeploymentFromDefinition(instance.Deployment, profileKey, bindings),
|
||||
RuntimeBindings: privateBindings,
|
||||
Deployment: autonomousDeploymentFromDefinition(instance.Deployment, profileKey, privateBindings),
|
||||
}
|
||||
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey)
|
||||
for _, action := range []domain.ServerLifecycleAction{domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionStatus} {
|
||||
@@ -351,6 +289,11 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
plan.LogSources = append(plan.LogSources, autonomousLogSource(source))
|
||||
}
|
||||
}
|
||||
for _, target := range plugin.RuntimeProfiles.DataTargets {
|
||||
if runtimePlatformsContain(target.Platforms, distribution.TargetOS) {
|
||||
plan.DataTargets = append(plan.DataTargets, autonomousDataTarget(target))
|
||||
}
|
||||
}
|
||||
if hasProfile && len(profile.DLLExtensionRefs) > 0 {
|
||||
endpoint := domain.RunEndpoint{ID: distribution.RunEndpointID, Platform: distribution.TargetOS, Architecture: distribution.TargetArch}
|
||||
extensions, err := lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint)
|
||||
@@ -364,6 +307,19 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
return domain.CopyRunAutonomousLifecyclePlanPtr(plan), nil
|
||||
}
|
||||
|
||||
func autonomousDataTargetBindings(bindings map[string]string, instance domain.ServerInstance, targets []domain.RuntimeDataTarget) map[string]string {
|
||||
result := domain.CopyStringMap(bindings)
|
||||
if result == nil {
|
||||
result = map[string]string{}
|
||||
}
|
||||
for _, target := range targets {
|
||||
if result[target.SourceRootKey] == "" && target.SourceRootKey == "server-root" && instance.Deployment.ServerRoot != "" {
|
||||
result[target.SourceRootKey] = instance.Deployment.ServerRoot
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func autonomousLifecycleAction(plugin domain.GamePlugin, profile domain.RuntimeLifecycleProfile, hasProfile bool, action domain.ServerLifecycleAction) domain.RunAutonomousLifecycleAction {
|
||||
targetKey := ""
|
||||
if hasProfile {
|
||||
@@ -409,6 +365,10 @@ func autonomousDLLExtension(extension domain.RuntimeDLLExtensionPlan) domain.Run
|
||||
return domain.RunAutonomousDLLExtension{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort}
|
||||
}
|
||||
|
||||
func autonomousDataTarget(target domain.RuntimeDataTarget) domain.RunAutonomousDataTarget {
|
||||
return domain.RunAutonomousDataTarget{Key: target.Key, Kind: target.Kind, TransportKey: target.TransportKey, SourceRootKey: target.SourceRootKey, SourcePath: target.SourcePath, WorkspaceKey: target.WorkspaceKey, RefreshPolicy: target.RefreshPolicy, MaxBytes: target.MaxBytes, Platforms: domain.CopyStringSlice(target.Platforms)}
|
||||
}
|
||||
|
||||
func autonomousDeploymentFromDefinition(definition domain.ServerDeploymentDefinition, profileKey string, bindings map[string]string) *domain.RunAutonomousDeployment {
|
||||
if definition.Mode == "" {
|
||||
return nil
|
||||
|
||||
@@ -101,9 +101,24 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
domain.RuntimeLogSource{Key: "console", Kind: "process.stdout", TargetKey: "server/process", StreamKey: "console", CursorKind: "sequence", RetentionDays: 14},
|
||||
domain.RuntimeLogSource{Key: "server-events", Kind: "file.tail", TargetKey: "logs/server", StreamKey: "scum.server", CursorKind: "fingerprint", RetentionDays: 90},
|
||||
)
|
||||
plugin.RuntimeProfiles.DataTargets = []domain.RuntimeDataTarget{{
|
||||
Key: "scum-database",
|
||||
Kind: "sqlite.snapshot",
|
||||
TransportKey: "scum-database",
|
||||
SourceRootKey: "server-root",
|
||||
SourcePath: "SCUM/Saved/SaveFiles/SCUM.db",
|
||||
WorkspaceKey: "databases/scum-database",
|
||||
RefreshPolicy: "on-demand-snapshot",
|
||||
MaxBytes: 1024 * 1024,
|
||||
Platforms: []string{"linux"},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("seed plugin lifecycle assets: %v", err)
|
||||
}
|
||||
instance.Deployment.ServerRoot = "/srv/scum"
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
t.Fatalf("seed instance deployment root: %v", err)
|
||||
}
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
@@ -149,6 +164,9 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 1 || !hasAutonomousLogSource(plan.LogSources, "process.stdout", "console") || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" {
|
||||
t.Fatalf("autonomous lifecycle plan lost plugin runtime declarations: %+v", plan)
|
||||
}
|
||||
if len(plan.DataTargets) != 1 || plan.DataTargets[0].Key != "scum-database" || plan.DataTargets[0].Kind != "sqlite.snapshot" || plan.DataTargets[0].SourcePath != "SCUM/Saved/SaveFiles/SCUM.db" || plan.RuntimeBindings["server-root"] != instance.Deployment.ServerRoot {
|
||||
t.Fatalf("autonomous lifecycle plan lost private database target bindings: %+v", plan)
|
||||
}
|
||||
if hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || hasAutonomousLogSource(plan.LogSources, "file.tail", "scum.server") {
|
||||
t.Fatalf("autonomous lifecycle plan must not carry file-tail sources into process.start: %+v", plan.LogSources)
|
||||
}
|
||||
@@ -214,55 +232,6 @@ func hasAutonomousLogSource(sources []domain.RunAutonomousLogSource, kind string
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCoreServiceRunDistributionDefaultsEmptyDeploymentProfileToPluginLifecycleProfile(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin := scumDeploymentTestPlugin()
|
||||
plugin.Name = "SCUM"
|
||||
plugin.Version = "0.1.1"
|
||||
plugin.ServerType = "scum"
|
||||
plugin.Status = domain.GamePluginStatusInstalled
|
||||
plugin.SupportedOS = []string{"windows"}
|
||||
plugin.DeclaredPermissions = []string{"server.run.distribution"}
|
||||
plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}
|
||||
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}, ActionRefs: domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}, Platforms: []string{"windows"}}}
|
||||
requireManualSCUMRuntimeBindings(&plugin, "run-local")
|
||||
if err := svc.store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("create SCUM plugin: %v", err)
|
||||
}
|
||||
session := createServiceUserAndLogin(t, svc, domain.User{ID: "scum-default-profile-owner", DisplayName: "SCUM Default Profile Owner", Email: "scum-default-profile@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
created, err := svc.CreateServerInstanceWorkflowForSession(session, domain.ServerLifecycleCreate{
|
||||
ID: "scum-default-profile-package", PluginID: plugin.ID, Name: "SCUM Default Profile Package", IdempotencyKey: "scum-default-profile-package-create",
|
||||
Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: `D:\scum-default-profile`, CreateInputs: map[string]string{"serverName": "Moon", "gamePort": "27000", "queryPort": "27015", "maxPlayers": "128"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create SCUM guided draft without explicit profile: %v", err)
|
||||
}
|
||||
if created.Instance.Deployment.ProfileKey != "run-local" {
|
||||
t.Fatalf("expected create defaults to persist plugin lifecycle profile, got %+v", created.Instance.Deployment)
|
||||
}
|
||||
|
||||
legacy := created.Instance
|
||||
legacy.Deployment.ProfileKey = ""
|
||||
if err := svc.store.ServerInstances().Update(legacy); err != nil {
|
||||
t.Fatalf("simulate legacy empty deployment profile: %v", err)
|
||||
}
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
svc.ConfigureDistributionBuilder(captureDistributionBuilder{inputs: inputs, payload: []byte("profile-default-build")})
|
||||
if _, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: legacy.ID, TargetOS: "windows", TargetArch: "amd64", IdempotencyKey: "scum-default-profile-package"}); err != nil {
|
||||
t.Fatalf("generate Run distribution with empty deployment profile: %v", err)
|
||||
}
|
||||
var platformInput domain.DistributionBuildInput
|
||||
select {
|
||||
case platformInput = <-inputs:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("platform builder did not receive profile-defaulted input")
|
||||
}
|
||||
plan := platformInput.AutonomousLifecycle
|
||||
if platformInput.ProfileKey != "run-local" || plan == nil || plan.ProfileKey != "run-local" || plan.Deployment == nil || plan.Deployment.ProfileKey != "run-local" {
|
||||
t.Fatalf("expected package context to default empty deployment profile to run-local, input=%+v plan=%+v", platformInput, plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceBuildsWithoutRegisteredDistributionWorker(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
bootstrapEndpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
|
||||
@@ -96,30 +96,23 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
profile, err := svc.clientManagerDistributionBuildProfile(distribution)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
WorkspaceRef: profile.WorkspaceRef,
|
||||
EntryRef: profile.EntryRef,
|
||||
ConfigTemplateRef: clientManagerBuildConfigTemplateRef(profile),
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
}, nil
|
||||
}
|
||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||
|
||||
@@ -508,7 +508,7 @@ EOF
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildServerInstanceID=$SERVER_INSTANCE_ID"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildPluginID=$PLUGIN_ID"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildComponentKind=$COMPONENT_KIND"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildComponentKey="
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildComponentKey=$PROFILE_KEY"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildKeyGeneration=$KEY_GENERATION"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildVersion=$TARGET_RELEASE"
|
||||
progress 48 'build_compile: downloading Go modules'
|
||||
@@ -533,39 +533,20 @@ git init --quiet
|
||||
git remote add origin "$REPOSITORY_URL"
|
||||
git fetch --quiet --depth 1 origin "$SOURCE_REVISION"
|
||||
git checkout --quiet --detach FETCH_HEAD
|
||||
workspace_ref="${CLIENT_MANAGER_WORKSPACE_REF:-}"
|
||||
entry_ref="${CLIENT_MANAGER_ENTRY_REF:-.}"
|
||||
config_template_ref="${CLIENT_MANAGER_CONFIG_TEMPLATE_REF:-config.yaml.example}"
|
||||
for safe_ref in "$workspace_ref" "$entry_ref" "$config_template_ref"; do
|
||||
case "$safe_ref" in
|
||||
*..*|*://*|/*|*\\*) printf 'client-manager build reference is unsafe\n' >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
build_workdir=/workspace/build
|
||||
if [ -n "$workspace_ref" ]; then
|
||||
build_workdir="/workspace/build/$workspace_ref"
|
||||
fi
|
||||
if [ ! -d "$build_workdir" ]; then
|
||||
printf 'client-manager workspace is missing\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
cd "$build_workdir"
|
||||
if [ ! -f "$config_template_ref" ]; then
|
||||
printf 'client-manager config template is missing\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
progress 36 'env_check: materializing client-manager configuration template'
|
||||
cp "$config_template_ref" config.yaml
|
||||
build_target="$entry_ref"
|
||||
case "$build_target" in
|
||||
''|.) build_target=. ;;
|
||||
./*) ;;
|
||||
*) build_target="./$build_target" ;;
|
||||
esac
|
||||
progress 36 'env_check: writing client-manager configuration'
|
||||
{
|
||||
printf 'server_url: "%s"\n' "$PLATFORM_URL"
|
||||
printf 'server_instance_id: "%s"\n' "$SERVER_INSTANCE_ID"
|
||||
printf 'scum_client_credential: "%s"\n' "$auth_key"
|
||||
printf 'scum_client_name: "%s"\n' "$PROFILE_KEY"
|
||||
printf 'scum_client_version: "platform-build"\n'
|
||||
printf 'scum_client_machine_label: "managed-client"\n'
|
||||
printf 'ftp_provider: 3\n'
|
||||
} > config.yaml
|
||||
progress 52 'deps_download: downloading Go modules'
|
||||
go mod download
|
||||
progress 76 'build_compile: compiling client-manager executable'
|
||||
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" "$build_target"
|
||||
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" .
|
||||
cp config.yaml /workspace/output/config.yaml
|
||||
progress 88 'package_finalize: client-manager package inputs written'
|
||||
`
|
||||
@@ -601,9 +582,6 @@ func (builder *DockerDistributionBuilder) containerArgs(input domain.Distributio
|
||||
"-e", "PLATFORM_URL=" + platformURL,
|
||||
"-e", "REPOSITORY_URL=" + input.RepositoryURL,
|
||||
"-e", "SOURCE_REVISION=" + input.SourceRevision,
|
||||
"-e", "CLIENT_MANAGER_WORKSPACE_REF=" + input.WorkspaceRef,
|
||||
"-e", "CLIENT_MANAGER_ENTRY_REF=" + input.EntryRef,
|
||||
"-e", "CLIENT_MANAGER_CONFIG_TEMPLATE_REF=" + input.ConfigTemplateRef,
|
||||
"-e", "OUTPUT_FILENAME=" + outputName,
|
||||
builder.config.Image,
|
||||
"/workspace/input/build.sh",
|
||||
|
||||
@@ -203,9 +203,6 @@ func TestDockerDistributionBuilderKeepsSecretInIsolatedInput(t *testing.T) {
|
||||
if bytes.Contains(script, []byte(secret)) {
|
||||
t.Fatal("build script must not embed the component auth key")
|
||||
}
|
||||
if bytes.Contains(script, []byte("BuildComponentKey=$PROFILE_KEY")) || !bytes.Contains(script, []byte("BuildComponentKey=")) {
|
||||
t.Fatalf("Run build script must not use lifecycle profile as component identity: %s", script)
|
||||
}
|
||||
for _, ordered := range [][2]string{{"find /workspace/source -mindepth 1 -maxdepth 1 ! -name pax_global_header", "run_command_dir=\"$(find /workspace/build/run-source -type d -path '*/cmd/run' -print -quit)\""}, {"run_command_dir=\"$(find /workspace/build/run-source -type d -path '*/cmd/run' -print -quit)\"", "run_module_dir=\"${run_command_dir%/cmd/run}\""}, {"run_module_dir=\"${run_command_dir%/cmd/run}\"", "mkdir -p \"$run_module_dir/config\""}, {"mkdir -p \"$run_module_dir/config\"", "workspace_seed_generated.go"}, {"workspace_seed_generated.go", "cd \"$run_module_dir\""}, {"cd \"$run_module_dir\"", "go build -trimpath -ldflags"}} {
|
||||
if bytes.Index(script, []byte(ordered[0])) >= bytes.Index(script, []byte(ordered[1])) {
|
||||
t.Fatalf("Run build script must prepare source before injecting config and compiling: %q before %q", ordered[0], ordered[1])
|
||||
@@ -230,7 +227,6 @@ func TestDockerDistributionBuilderKeepsSecretInIsolatedInput(t *testing.T) {
|
||||
ServerInstanceID: "server-one",
|
||||
PluginID: "game.scum",
|
||||
RunEndpointID: "server-run-server-one",
|
||||
ProfileKey: "run-local",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
TargetRelease: "release-one",
|
||||
|
||||
@@ -533,7 +533,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
||||
ProfileKey: "scum-client-manager",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
RepositoryURL: "https://git.npc0.com/admin343/browser.git",
|
||||
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
||||
SourceRevision: "main",
|
||||
IdempotencyKey: "idem-client-manager",
|
||||
})
|
||||
@@ -551,7 +551,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
||||
if err != nil {
|
||||
t.Fatalf("get build job: %v", err)
|
||||
}
|
||||
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://git.npc0.com/admin343/browser.git" || build.SourceRevision != "main" {
|
||||
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
|
||||
t.Fatalf("unexpected build job: %+v", build)
|
||||
}
|
||||
clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
|
||||
@@ -565,7 +565,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
||||
ProfileKey: "scum-client-manager",
|
||||
TargetOS: "darwin",
|
||||
TargetArch: "amd64",
|
||||
RepositoryURL: "https://git.npc0.com/admin343/browser.git",
|
||||
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
||||
IdempotencyKey: "idem-client-manager-denied",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "targetOs") {
|
||||
@@ -625,7 +625,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://git.npc0.com/admin343/browser.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", WorkspaceRef: "plugins/examples/scum-server-plugin/companion", EntryRef: "cmd/scum-companion", 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}}}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func seedGameClientBridgeComponentSession(t *testing.T, svc *CoreService, now time.Time, token string) (domain.ClientManagerInstallation, domain.ClientManagerSession) {
|
||||
t.Helper()
|
||||
installation := domain.ClientManagerInstallation{ID: "installation-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", RunEndpointID: "run-1", Status: domain.ClientManagerLifecycleOnline, ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability, gameClientBridgeLogStreamCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
key := domain.EncryptedComponentKey{ID: "key-1", ServerInstanceID: installation.ServerInstanceID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: installation.ProfileKey, Generation: installation.KeyGeneration, Status: domain.ComponentKeyStatusActive}
|
||||
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -80,34 +80,6 @@ func TestGameClientBridgeComponentSessionAuthorizesCommandsAndSnapshots(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeComponentSessionAuthorizesLiveLogStream(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const token = "component-session-token"
|
||||
_, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||
if err := svc.store.RunEndpoints().Create(domain.RunEndpoint{ID: session.RunEndpointID, DisplayName: "Component Run", Status: domain.RunEndpointStatusOnline, Capabilities: []string{"process.start", "logs.read"}, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: *clock}); err != nil {
|
||||
t.Fatalf("seed run endpoint: %v", err)
|
||||
}
|
||||
server := domain.ServerInstance{ID: session.ServerInstanceID, PluginID: "game.scum", PluginVersion: "1.0.0", RunEndpointID: session.RunEndpointID, Name: "SCUM"}
|
||||
if err := svc.store.ServerInstances().Create(server); err != nil {
|
||||
t.Fatalf("create server instance: %v", err)
|
||||
}
|
||||
instance, err := svc.AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest{SessionToken: token})
|
||||
if err != nil || instance.ID != server.ID || instance.PluginID != server.PluginID {
|
||||
t.Fatalf("authorize companion log stream: instance=%#v err=%v", instance, err)
|
||||
}
|
||||
if instance.RunEndpointID != session.RunEndpointID {
|
||||
t.Fatalf("authorized stream was not bound to component server: %#v", instance)
|
||||
}
|
||||
|
||||
session.Capabilities = []string{"component.heartbeat", gameClientBridgeCapability}
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest{SessionToken: token}); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected missing logs.stream rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeClaimCannotBeCompletedByAnotherCurrentSession(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const firstToken = "component-session-token-one"
|
||||
|
||||
@@ -12,7 +12,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := repo.NewMemoryStore()
|
||||
plugin := domain.GamePlugin{ID: "game.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability, gameClientBridgeLogStreamCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
if err := store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("seed bridge plugin: %v", err)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,17 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
||||
}
|
||||
job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
|
||||
if !ok {
|
||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||
if err := svc.scheduleDuePluginQueryProjectionJobs(claim, stamp); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
|
||||
if err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
job, ok = firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
|
||||
if !ok {
|
||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||
}
|
||||
}
|
||||
|
||||
leaseToken, err := randomToken()
|
||||
@@ -343,6 +353,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectPluginQueryJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const (
|
||||
type LogEventSubscriptionEvent struct {
|
||||
Kind LogEventSubscriptionEventKind
|
||||
LogEvent domain.LogStreamEvent
|
||||
Live bool
|
||||
ServerInstanceID string
|
||||
ProcessState domain.ServerInstanceState
|
||||
}
|
||||
@@ -67,14 +66,6 @@ func (svc *CoreService) SubscribeLogEventsForSession(sessionID string, serverIns
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLogEvents(stream domain.LogStream, entries []domain.LogEntry) {
|
||||
svc.publishLogEventsWithMode(stream, entries, false)
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLiveLogEvents(stream domain.LogStream, entries []domain.LogEntry) {
|
||||
svc.publishLogEventsWithMode(stream, entries, true)
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLogEventsWithMode(stream domain.LogStream, entries []domain.LogEntry, live bool) {
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -82,7 +73,6 @@ func (svc *CoreService) publishLogEventsWithMode(stream domain.LogStream, entrie
|
||||
for index, entry := range entries {
|
||||
events[index] = LogEventSubscriptionEvent{
|
||||
Kind: LogEventSubscriptionEventLog,
|
||||
Live: live,
|
||||
LogEvent: domain.CopyLogStreamEvent(domain.LogStreamEvent{
|
||||
ServerInstanceID: stream.ServerInstanceID,
|
||||
Stream: stream,
|
||||
|
||||
@@ -12,65 +12,6 @@ import (
|
||||
|
||||
const defaultLogQueryLimit = 100
|
||||
|
||||
// RelayLiveLogBatch forwards output observed by Run to live subscribers. It
|
||||
// intentionally updates only stream metadata; the log body is not written to
|
||||
// the platform log store. Game plugins own durable log storage and analysis.
|
||||
func (svc *CoreService) RelayLiveLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
|
||||
batch = domain.CopyLogBatchIngest(batch)
|
||||
if err := validator.ValidateLogBatchIngest(batch); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
|
||||
lock := svc.logIngestLock(batch.ServerInstanceID)
|
||||
lock.Lock()
|
||||
stamp := svc.now()
|
||||
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
if repairErr := svc.ensureLogStreamForBatch(batch, stamp); repairErr != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, repairErr
|
||||
}
|
||||
stream, err = svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
}
|
||||
if err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := validateLogBatchStream(batch, stream); err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if batch.LastSeq > stream.LatestSeq {
|
||||
stream.LatestSeq = batch.LastSeq
|
||||
}
|
||||
stream.UpdatedAt = stamp
|
||||
if err := svc.store.LogStreams().Update(stream); err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
lock.Unlock()
|
||||
|
||||
entries := domain.CopyLogEntries(batch.Entries)
|
||||
// Run may be on a machine whose wall clock is skewed. Relay time is the
|
||||
// authoritative observation time for this best-effort live event; using it
|
||||
// keeps the SSE live boundary from treating current output as old history.
|
||||
for index := range entries {
|
||||
entries[index].Timestamp = stamp.Add(time.Duration(index) * time.Nanosecond)
|
||||
}
|
||||
svc.publishLiveLogEvents(stream, entries)
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
AcceptedFrom: batch.FirstSeq,
|
||||
AcceptedTo: batch.LastSeq,
|
||||
LatestSeq: stream.LatestSeq,
|
||||
ServerTime: stamp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
|
||||
batch = domain.CopyLogBatchIngest(batch)
|
||||
if err := validator.ValidateLogBatchIngest(batch); err != nil {
|
||||
@@ -110,9 +51,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) {
|
||||
locked = false
|
||||
lock.Unlock()
|
||||
if err := svc.projectPluginLogBatch(stream, storedLogEntries(batch.Entries)); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
@@ -130,7 +68,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
|
||||
storedBatch := domain.CopyLogBatchIngest(batch)
|
||||
sanitizeLogNetworkFields(&storedBatch)
|
||||
record := domain.CopyLogBatchRecord(domain.LogBatchRecord{
|
||||
Checksum: batch.Checksum,
|
||||
FirstSeq: batch.FirstSeq,
|
||||
@@ -147,9 +84,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
locked = false
|
||||
lock.Unlock()
|
||||
if err := svc.projectPluginLogBatch(stream, storedBatch.Entries); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
svc.publishLogEvents(stream, storedBatch.Entries)
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
@@ -162,10 +96,7 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
|
||||
func storedLogEntries(entries []domain.LogEntry) []domain.LogEntry {
|
||||
stored := domain.CopyLogEntries(entries)
|
||||
batch := domain.LogBatchIngest{Entries: stored}
|
||||
sanitizeLogNetworkFields(&batch)
|
||||
return batch.Entries
|
||||
return domain.CopyLogEntries(entries)
|
||||
}
|
||||
|
||||
func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error {
|
||||
@@ -209,7 +140,7 @@ func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest,
|
||||
expectedStreamID = runSessionLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.LogSessionID, batch.StreamKey)
|
||||
}
|
||||
if batch.LogStreamID != expectedStreamID {
|
||||
if !legacySessionRunLogStream(batch) && !legacyAutonomousLogStream(batch) {
|
||||
if batch.LogSessionID != "" || !legacyAutonomousLogStream(batch) {
|
||||
return repo.ErrNotFound
|
||||
}
|
||||
}
|
||||
@@ -227,10 +158,6 @@ func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest,
|
||||
return err
|
||||
}
|
||||
|
||||
func legacySessionRunLogStream(batch domain.LogBatchIngest) bool {
|
||||
return batch.LogSessionID != "" && batch.LogStreamID == runLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.StreamKey)
|
||||
}
|
||||
|
||||
func legacyAutonomousLogStream(batch domain.LogBatchIngest) bool {
|
||||
jobID, ok := jobIDFromLogBatch(batch)
|
||||
return ok && strings.HasPrefix(jobID, "autonomous-")
|
||||
@@ -260,19 +187,6 @@ func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIn
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
delete(fields, "networkFingerprint")
|
||||
delete(fields, "ip")
|
||||
delete(fields, "ipAddress")
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *CoreService) QueryLogStream(query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) {
|
||||
if err := validator.ValidateLogStreamCursorQuery(query); err != nil {
|
||||
return domain.LogStreamCursorResult{}, err
|
||||
|
||||
@@ -92,45 +92,6 @@ func TestCoreServicePublishesLogEventsForAcceptedBatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRelaysLiveBatchWithoutPersistingBody(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)
|
||||
batch.Entries[0].Timestamp = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
batch.Checksum, err = validator.LogEntriesChecksum(batch.Entries)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum live batch: %v", err)
|
||||
}
|
||||
ack, err := svc.RelayLiveLogBatch(batch)
|
||||
if err != nil || !ack.Accepted || ack.LatestSeq != 1 {
|
||||
t.Fatalf("relay live batch: ack=%+v err=%v", ack, err)
|
||||
}
|
||||
query, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: batch.LogStreamID, AfterSeq: 0, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("query relayed log stream: %v", err)
|
||||
}
|
||||
if len(query.Entries) != 0 || query.LatestSeq != 1 {
|
||||
t.Fatalf("live relay wrote a platform log body: %+v", query)
|
||||
}
|
||||
select {
|
||||
case event := <-subscription.Events:
|
||||
if !event.Live {
|
||||
t.Fatalf("expected live relay event marker: %+v", event)
|
||||
}
|
||||
if event.LogEvent.Entry.Timestamp.Before(fixedTime) {
|
||||
t.Fatalf("live relay kept stale source timestamp: %+v", event.LogEvent.Entry)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected relayed live log event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServicePersistsAndEnforcesImmutableProcessLogSessionMetadata(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
startedAt := time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
|
||||
@@ -480,47 +441,14 @@ func TestCoreServiceAcceptsLegacyAutonomousJobLogStreamWithoutPlatformJob(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceAcceptsSessionMetadataOnLegacyAutonomousStreamID(t *testing.T) {
|
||||
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)
|
||||
ack, err := svc.IngestLogBatch(batch)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest session-scoped legacy autonomous stream: %v", err)
|
||||
}
|
||||
if !ack.Accepted || ack.LogStreamID != batch.LogStreamID || ack.LatestSeq != batch.LastSeq {
|
||||
t.Fatalf("unexpected legacy autonomous session ack: %+v", ack)
|
||||
}
|
||||
stream, err := svc.GetLogStream(batch.LogStreamID)
|
||||
if err != nil {
|
||||
t.Fatalf("get session-scoped legacy autonomous stream: %v", err)
|
||||
}
|
||||
if stream.LogSessionID != batch.LogSessionID || !stream.SessionStartedAt.Equal(batch.SessionStartedAt) {
|
||||
t.Fatalf("session metadata was not persisted: %+v", stream)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceAcceptsSessionMetadataOnLegacyRunStreamID(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
batch := validLogBatch(t, sessionToken, 1, 1)
|
||||
batch.LogStreamID = runLogStreamID("run-local", "server-1", "stdout")
|
||||
batch.LogSessionID = "session-a"
|
||||
batch.SessionStartedAt = time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
|
||||
ack, err := svc.IngestLogBatch(batch)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest session-scoped legacy run stream: %v", err)
|
||||
}
|
||||
if !ack.Accepted || ack.LogStreamID != batch.LogStreamID || ack.LatestSeq != batch.LastSeq {
|
||||
t.Fatalf("unexpected legacy run session ack: %+v", ack)
|
||||
}
|
||||
stream, err := svc.GetLogStream(batch.LogStreamID)
|
||||
if err != nil {
|
||||
t.Fatalf("get session-scoped legacy run stream: %v", err)
|
||||
}
|
||||
if stream.LogSessionID != batch.LogSessionID || !stream.SessionStartedAt.Equal(batch.SessionStartedAt) {
|
||||
t.Fatalf("session metadata was not persisted: %+v", stream)
|
||||
if _, err := svc.IngestLogBatch(batch); err == nil {
|
||||
t.Fatal("expected session-scoped batch with legacy autonomous stream ID to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,6 +529,31 @@ func TestFileLogBodyStoreReloadsBatchesAndCursorEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileLogBodyStoreReloadsVerbatimLongLogLine(t *testing.T) {
|
||||
rootDir := filepath.Join(t.TempDir(), "logs")
|
||||
store, err := NewFileLogBodyStore(rootDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create file log store: %v", err)
|
||||
}
|
||||
line := strings.Repeat("log-output-", 16*1024)
|
||||
entries := []domain.LogEntry{{Seq: 1, Timestamp: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Line: line}}
|
||||
checksum, err := validator.LogEntriesChecksum(entries)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum: %v", err)
|
||||
}
|
||||
if err := store.AppendBatch("log-long", domain.LogBatchRecord{Checksum: checksum, FirstSeq: 1, LastSeq: 1, Entries: entries}); err != nil {
|
||||
t.Fatalf("append long log batch: %v", err)
|
||||
}
|
||||
reloaded, err := NewFileLogBodyStore(rootDir)
|
||||
if err != nil {
|
||||
t.Fatalf("reload long log store: %v", err)
|
||||
}
|
||||
batch, exists, err := reloaded.GetBatch("log-long", 1)
|
||||
if err != nil || !exists || len(batch.Entries) != 1 || batch.Entries[0].Line != line {
|
||||
t.Fatalf("long log line changed after reload: batch=%+v exists=%t err=%v", batch, exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newRegisteredLogIngestService(t *testing.T) (*CoreService, string) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
|
||||
@@ -137,7 +137,6 @@ func TestDeclaredSQLiteQueryDoesNotMutatePluginOrPlatformUserData(t *testing.T)
|
||||
func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) {
|
||||
svc, plugin, endpoint, _, _ := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
|
||||
plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{Collection: "scum_users", RowPath: "rows", UpsertKeys: []string{"steamId"}}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("enable query refresh hint: %v", err)
|
||||
}
|
||||
@@ -158,8 +157,9 @@ func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredSQLiteQueryProjectionsAreNotAppliedByPlatform(t *testing.T) {
|
||||
func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) {
|
||||
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
|
||||
plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{
|
||||
Collection: "scum_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"},
|
||||
FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt",
|
||||
@@ -168,38 +168,39 @@ func TestDeclaredSQLiteQueryProjectionsAreNotAppliedByPlatform(t *testing.T) {
|
||||
FieldMappings: map[string]string{"className": "displayName"}, FixedValues: map[string]string{"code": "#spawnvehicle {{displayName}}", "spawnCommand": "#spawnvehicle {{displayName}}", "catalogType": "vehicle", "type": "21", "typeName": "其他载具", "imagePath": "/original/{{displayName}}.webp", "source": "sqlite"}, ObservedAtField: "lastSeenAt", MergeExisting: true,
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("store legacy query projection declarations: %v", err)
|
||||
t.Fatalf("enable query projection polling: %v", err)
|
||||
}
|
||||
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-projection-ignored-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-projection-ignored-1", "input.templateKey": "players.by-id",
|
||||
}})
|
||||
if err != nil || queued.Status != "queued" {
|
||||
t.Fatalf("queue declared query=%+v err=%v", queued, err)
|
||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "#spawnvehicle Truck", Value: map[string]any{"code": "#spawnvehicle Truck", "name": "Named Truck"}}}}); err != nil {
|
||||
t.Fatalf("seed vehicle catalog: %v", err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-projection-ignored"
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job == nil {
|
||||
t.Fatalf("declared query job was not claimable: %+v err=%v", claim, err)
|
||||
t.Fatalf("projection query was not scheduled: %+v err=%v", claim, err)
|
||||
}
|
||||
if claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["sqlRef"] != "sql/players.by-id.sql" {
|
||||
t.Fatalf("declared query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs)
|
||||
t.Fatalf("scheduled projection query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs)
|
||||
}
|
||||
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"kind":"player","steamId":"steam-1","displayName":"Ada"},{"kind":"vehicle","steamId":"vehicle-1","displayName":"Truck"}]}`}})
|
||||
if err != nil {
|
||||
t.Fatalf("complete declared query job: %v", err)
|
||||
t.Fatalf("complete projection query job: %v", err)
|
||||
}
|
||||
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("platform applied query projection to plugin data=%+v err=%v", items, err)
|
||||
if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" || items[0].Value["source"] != "sqlite" || items[0].Value["sampledAt"] == nil {
|
||||
t.Fatalf("declared projection did not write scoped plugin data=%+v err=%v", items, err)
|
||||
}
|
||||
goods, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods"})
|
||||
if err != nil || len(goods) != 0 {
|
||||
t.Fatalf("platform applied vehicle catalog query projection=%+v err=%v", goods, err)
|
||||
if err != nil || len(goods) != 1 || goods[0].Key != "#spawnvehicle Truck" || goods[0].Value["name"] != "Named Truck" || goods[0].Value["className"] != "Truck" || goods[0].Value["type"] != "21" || goods[0].Value["lastSeenAt"] == nil {
|
||||
t.Fatalf("declared vehicle catalog projection did not merge scoped plugin data=%+v err=%v", goods, err)
|
||||
}
|
||||
second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || second.HasJob {
|
||||
t.Fatalf("fresh projection poll should not reschedule immediately: %+v err=%v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (svc *CoreService) applyPluginBulkProjection(instance domain.ServerInstance
|
||||
func pluginBulkProjectionValues(fixedValues map[string]string, stamp time.Time, row map[string]any, observedAtField string) map[string]any {
|
||||
value := make(map[string]any, len(fixedValues)+1)
|
||||
for key, fixed := range fixedValues {
|
||||
value[key] = renderPluginValueTemplate(fixed, row)
|
||||
value[key] = renderQueryProjectionTemplate(fixed, row)
|
||||
}
|
||||
if observedAtField != "" {
|
||||
value[observedAtField] = stamp.UTC().Format(time.RFC3339Nano)
|
||||
@@ -78,18 +78,10 @@ func pluginBulkActivityValue(target domain.GameClientBridgeBulkActivityTargetDec
|
||||
value[destination] = row[source]
|
||||
}
|
||||
for key, fixed := range target.FixedValues {
|
||||
value[key] = renderPluginValueTemplate(fixed, row)
|
||||
value[key] = renderQueryProjectionTemplate(fixed, row)
|
||||
}
|
||||
if target.ObservedAtField != "" {
|
||||
value[target.ObservedAtField] = stamp.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func renderPluginValueTemplate(template string, row map[string]any) string {
|
||||
result := template
|
||||
for key, value := range row {
|
||||
result = strings.ReplaceAll(result, "{{"+key+"}}", fmt.Sprint(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func (svc *CoreService) scheduleDuePluginQueryProjectionJobs(claim domain.RunJobClaim, stamp time.Time) error {
|
||||
if !containsString(claim.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return nil
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(claim.RunEndpointID)
|
||||
if err != nil || !containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return err
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: claim.RunEndpointID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, instance := range instances {
|
||||
if instance.State != domain.ServerInstanceStateRunning || strings.TrimSpace(instance.PluginID) == "" {
|
||||
continue
|
||||
}
|
||||
plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if pluginErr != nil || !plugin.Permissions.RemoteAccess || !containsString(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) || !containsString(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
continue
|
||||
}
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.PollIntervalSeconds <= 0 || len(template.Projections) == 0 || !pluginQueryTemplateTransportReady(plugin, endpoint, template) {
|
||||
continue
|
||||
}
|
||||
interval := time.Duration(template.PollIntervalSeconds) * time.Second
|
||||
prefix := pluginQueryPollPrefix(instance.ID, plugin.ID, template.Key)
|
||||
if pluginQueryPollActiveOrFresh(jobs, prefix, stamp, interval) {
|
||||
continue
|
||||
}
|
||||
bucket := stamp.Unix() / int64(template.PollIntervalSeconds)
|
||||
idempotencyKey := fmt.Sprintf("%s%d", prefix, bucket)
|
||||
inputs := map[string]string{"templateKey": template.Key, "maxRows": fmt.Sprint(template.MaxRows)}
|
||||
if template.SQLRef != "" {
|
||||
inputs["sqlRef"] = template.SQLRef
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: jobIDFromParts("job-plugin-query-poll", instance.ID, idempotencyKey),
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
TargetKey: template.TargetKey,
|
||||
InputRef: fmt.Sprintf("input://plugin-query-poll/%s/%s", instance.ID, template.Key),
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "plugin query projection poll queued"},
|
||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 2, MaxBackoffSeconds: 2},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: string(domain.RemoteAdapterDatabase), TimeoutSeconds: template.TimeoutSeconds, PluginID: plugin.ID, Inputs: inputs},
|
||||
}
|
||||
if _, createErr := svc.CreateJob(job); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginQueryTemplateTransportReady(plugin domain.GamePlugin, endpoint domain.RunEndpoint, template domain.GameClientBridgeQueryTemplateDeclaration) bool {
|
||||
if template.Engine != "sqlite" || template.TransportKey == "" || template.TargetKey == "" {
|
||||
return false
|
||||
}
|
||||
for _, profile := range plugin.RuntimeProfiles.TransportProfiles {
|
||||
if profile.Key == template.TransportKey && profile.Kind == "sqlite" && profile.TargetKey == template.TargetKey && containsString(profile.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pluginQueryPollPrefix(serverID, pluginID, templateKey string) string {
|
||||
return fmt.Sprintf("plugin-query-poll:%s:%s:%s:", serverID, pluginID, templateKey)
|
||||
}
|
||||
|
||||
func pluginQueryPollActiveOrFresh(jobs []domain.Job, prefix string, stamp time.Time, interval time.Duration) bool {
|
||||
for _, job := range jobs {
|
||||
if !strings.HasPrefix(job.IdempotencyKey, prefix) {
|
||||
continue
|
||||
}
|
||||
if !isTerminalJobState(job.State) {
|
||||
return true
|
||||
}
|
||||
freshAt := job.TerminalAt
|
||||
if freshAt.IsZero() {
|
||||
freshAt = job.UpdatedAt
|
||||
}
|
||||
if !freshAt.IsZero() && stamp.Sub(freshAt) < interval {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectPluginQueryJobResult(job domain.Job, stamp time.Time) error {
|
||||
if job.State != domain.JobStateSucceeded || job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.ExecutionResult.Kind != "sqlite.query" {
|
||||
return nil
|
||||
}
|
||||
templateKey := strings.TrimSpace(job.ExecutionInput.Inputs["templateKey"])
|
||||
if templateKey == "" || strings.TrimSpace(job.ServerInstanceID) == "" {
|
||||
return nil
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template, ok := pluginQueryTemplateByKey(plugin, templateKey)
|
||||
if !ok || len(template.Projections) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := pluginQueryRows(job.ExecutionResult.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mutationsByCollection := map[string]map[string]domain.PluginDataMutation{}
|
||||
for _, projection := range template.Projections {
|
||||
if projection.RowPath != "rows" {
|
||||
continue
|
||||
}
|
||||
collectionMutations := mutationsByCollection[projection.Collection]
|
||||
if collectionMutations == nil {
|
||||
collectionMutations = map[string]domain.PluginDataMutation{}
|
||||
mutationsByCollection[projection.Collection] = collectionMutations
|
||||
}
|
||||
for _, row := range rows {
|
||||
if projection.MatchField != "" && strings.TrimSpace(fmt.Sprint(row[projection.MatchField])) != projection.MatchValue {
|
||||
continue
|
||||
}
|
||||
value := pluginQueryProjectionValue(projection, row, stamp)
|
||||
key, keyErr := pluginDataRowKey(value, projection.UpsertKeys)
|
||||
if keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
if projection.MergeExisting {
|
||||
if existing, existingErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, projection.Collection, key)); existingErr == nil {
|
||||
value = mergePluginDataValues(existing.Value, value)
|
||||
} else if !errors.Is(existingErr, repo.ErrNotFound) {
|
||||
return existingErr
|
||||
}
|
||||
}
|
||||
collectionMutations[key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value}
|
||||
}
|
||||
}
|
||||
for collection, keyed := range mutationsByCollection {
|
||||
mutations := make([]domain.PluginDataMutation, 0, len(keyed))
|
||||
for _, mutation := range keyed {
|
||||
mutations = append(mutations, mutation)
|
||||
}
|
||||
if len(mutations) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: collection, Mutations: mutations}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginQueryTemplateByKey(plugin domain.GamePlugin, templateKey string) (domain.GameClientBridgeQueryTemplateDeclaration, bool) {
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.Key == templateKey {
|
||||
return template, true
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, false
|
||||
}
|
||||
|
||||
func pluginQueryRows(content string) ([]map[string]any, error) {
|
||||
var payload struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewBufferString(content))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return nil, validationError("sqlite query result content is not a row payload")
|
||||
}
|
||||
return payload.Rows, nil
|
||||
}
|
||||
|
||||
func pluginQueryProjectionValue(projection domain.GameClientBridgeQueryProjectionDeclaration, row map[string]any, observedAt time.Time) map[string]any {
|
||||
value := map[string]any{}
|
||||
if len(projection.FieldMappings) == 0 {
|
||||
for key, item := range row {
|
||||
value[key] = item
|
||||
}
|
||||
} else {
|
||||
for destination, source := range projection.FieldMappings {
|
||||
value[destination] = row[source]
|
||||
}
|
||||
}
|
||||
for key, fixed := range projection.FixedValues {
|
||||
value[key] = renderQueryProjectionTemplate(fixed, row)
|
||||
}
|
||||
if projection.ObservedAtField != "" {
|
||||
value[projection.ObservedAtField] = observedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func renderQueryProjectionTemplate(template string, row map[string]any) string {
|
||||
result := template
|
||||
for key, value := range row {
|
||||
result = strings.ReplaceAll(result, "{{"+key+"}}", fmt.Sprint(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -57,9 +57,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
|
||||
instance.State = domain.ServerInstanceStateDraft
|
||||
}
|
||||
if instance.Deployment.Mode != "" {
|
||||
if strings.TrimSpace(create.ProfileKey) != "" {
|
||||
instance.Deployment.ProfileKey = create.ProfileKey
|
||||
}
|
||||
instance.Deployment.ProfileKey = create.ProfileKey
|
||||
instance.Deployment.RuntimeBindings = domain.CopyStringMap(create.Bindings)
|
||||
instance.Deployment.Revision = maxInt(1, instance.Deployment.Revision)
|
||||
instance.Deployment.UpdatedAt = stamp
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
|
||||
@@ -106,43 +104,6 @@ func TestLifecycleProjectedStateUsesRunProcessFacts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedRunLifecycleUsesPackageDefaultProfileWithoutRuntimeBinding(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin := createLifecyclePlugin(t, svc)
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.LifecycleCapabilityStatus)
|
||||
plugin.LifecycleActions.Status = "actions/status.json"
|
||||
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{
|
||||
Key: "run-local",
|
||||
Mode: "local-process",
|
||||
Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus},
|
||||
ActionRefs: domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin profile: %v", err)
|
||||
}
|
||||
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "generated-run-owner", DisplayName: "Generated Run Owner", Email: "generated-run-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
serverID := "generated-run-server"
|
||||
endpointID := generatedRunEndpointID(serverID)
|
||||
if err := svc.store.RunEndpoints().Create(domain.RunEndpoint{ID: endpointID, DisplayName: "Generated Run", Version: "0.1.0", Status: domain.RunEndpointStatusOnline, Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}, Capacity: domain.RunCapacity{MaxJobs: 1}, LastHeartbeatAt: fixedTime}); err != nil {
|
||||
t.Fatalf("create generated run endpoint: %v", err)
|
||||
}
|
||||
instance := domain.ServerInstance{ID: serverID, PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: endpointID, Name: "Generated Run Server", OwnerUserID: "generated-run-owner", State: domain.ServerInstanceStateFailed, ConfigVersion: 1, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: `C:\scumserver`, Revision: 1}, CreatedAt: fixedTime, UpdatedAt: fixedTime}
|
||||
if err := svc.store.ServerInstances().Create(instance); err != nil {
|
||||
t.Fatalf("create generated run server: %v", err)
|
||||
}
|
||||
if _, err := svc.runtimeBindingForServer(serverID); !errors.Is(err, repo.ErrNotFound) {
|
||||
t.Fatalf("expected generated run server to have no manual runtime binding: %v", err)
|
||||
}
|
||||
|
||||
result, err := svc.QueryServerInstanceProcessForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: serverID, ExpectedConfigVersion: 1, IdempotencyKey: "generated-run-status"})
|
||||
if err != nil {
|
||||
t.Fatalf("query generated run status: %v", err)
|
||||
}
|
||||
if result.Job.ExecutionInput.WorkspaceScope != "run-local" || result.Job.TargetKey != "actions/status.json" {
|
||||
t.Fatalf("expected generated run status to use packaged profile scope, job=%+v", result.Job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleJobResultsPublishProcessStateEvents(t *testing.T) {
|
||||
svc, sessionToken := newLifecycleRunService(t)
|
||||
createLifecyclePlugin(t, svc)
|
||||
|
||||
Reference in New Issue
Block a user