Remove pre-1.0 audit and protected request scaffolding
This commit is contained in:
@@ -89,9 +89,9 @@ Do not define business structs inside functions. Do not define request/response
|
|||||||
|
|
||||||
The external run executor must not expose host paths, raw credentials, or direct sockets to plugins or platform_web.
|
The external run executor must not expose host paths, raw credentials, or direct sockets to plugins or platform_web.
|
||||||
|
|
||||||
Platform, plugin, and run lifecycle ownership must stay separated. Run is the lifecycle authority for machine execution; Platform records desired lifecycle intent, registration/auth, audit, generated package inputs, and persisted projections from Run-reported facts, not observed process truth:
|
Platform, plugin, and run lifecycle ownership must stay separated. Run is the lifecycle authority for machine execution; Platform records desired lifecycle intent, registration/auth, generated package inputs, and persisted projections from Run-reported facts, not observed process truth:
|
||||||
|
|
||||||
- Platform owns server instances, plugin manifest validation, platform-side distribution builds, generated Run package inputs, run registration binding, authorization/audit, and persisted lifecycle projections.
|
- Platform owns server instances, plugin manifest validation, platform-side distribution builds, generated Run package inputs, run registration binding, authorization, and persisted lifecycle projections.
|
||||||
- Plugins own game-specific lifecycle declarations: init/install/update/pre-start checks, dependency probes/install plans, start arguments, stop logic, status/readiness probes, executable paths, Steam app IDs, and game-specific dependency commands.
|
- Plugins own game-specific lifecycle declarations: init/install/update/pre-start checks, dependency probes/install plans, start arguments, stop logic, status/readiness probes, executable paths, Steam app IDs, and game-specific dependency commands.
|
||||||
- Run owns generic machine lifecycle execution and the observed runtime/process state it supervises: local bootstrap from generated package plans, scoped file operations, bounded process execution/supervision, declared capability enforcement, logs, artifacts, and channel transport.
|
- Run owns generic machine lifecycle execution and the observed runtime/process state it supervises: local bootstrap from generated package plans, scoped file operations, bounded process execution/supervision, declared capability enforcement, logs, artifacts, and channel transport.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ Plugins and platform_web must never receive run credentials, raw host paths, or
|
|||||||
|
|
||||||
Platform may read plugin manifests, validate lifecycle/action declarations, package plugin-owned action assets into generated run distributions, and dispatch lifecycle jobs with typed inputs. Platform must not implement game-specific install/update/start behavior itself.
|
Platform may read plugin manifests, validate lifecycle/action declarations, package plugin-owned action assets into generated run distributions, and dispatch lifecycle jobs with typed inputs. Platform must not implement game-specific install/update/start behavior itself.
|
||||||
|
|
||||||
Platform may persist desired lifecycle state, last-known run reports, audit history, and UI projections. It must not treat those persisted fields as the authoritative source for the current machine/process state; current runtime status must come from the registered run endpoint's reports, heartbeats, supervised process facts, and job/log channels.
|
Platform may persist desired lifecycle state, last-known run reports, and UI projections. It must not treat those persisted fields as the authoritative source for the current machine/process state; current runtime status must come from the registered run endpoint's reports, heartbeats, supervised process facts, and job/log channels.
|
||||||
|
|
||||||
Do not add platform service code that hardcodes a game's executable path, Steam app ID, SteamCMD command line, process name, default launch flags, or update policy. For SCUM specifically, `SCUMServer.exe`, app `3792580`, `+app_update 3792580 validate`, `-port`, `-MaxPlayers`, and `-log` must come from the SCUM plugin action assets or plugin-declared startup fields.
|
Do not add platform service code that hardcodes a game's executable path, Steam app ID, SteamCMD command line, process name, default launch flags, or update policy. For SCUM specifically, `SCUMServer.exe`, app `3792580`, `+app_update 3792580 validate`, `-port`, `-MaxPlayers`, and `-log` must come from the SCUM plugin action assets or plugin-declared startup fields.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@ Backend control plane for the game server management platform.
|
|||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
- Users, roles, permissions, sessions, and audit.
|
- Users, roles, permissions, and sessions.
|
||||||
- Game management plugin installation metadata and marketplace views.
|
- Game management plugin installation metadata and marketplace views.
|
||||||
- Server instance records and lifecycle orchestration.
|
- Server instance records and lifecycle orchestration.
|
||||||
- AI provider configuration and platform-mediated AI invocation.
|
- AI provider configuration and platform-mediated AI invocation.
|
||||||
@@ -83,7 +83,7 @@ export PLATFORM_LOG_DIR=.platform-data/logs
|
|||||||
go run ./cmd/platform
|
go run ./cmd/platform
|
||||||
```
|
```
|
||||||
|
|
||||||
MySQL is the platform metadata database here. It stores the platform metadata snapshot table and should later hold normalized users/plugins/servers/jobs/audit/log stream indexes. It is not the high-volume log body store; keep log bodies in segmented files locally, or add a future ClickHouse/Loki/OpenSearch/object-storage `LogBodyStore` adapter for production scale.
|
MySQL is the platform metadata database here. It stores the platform metadata snapshot table and should later hold normalized users/plugins/servers/jobs/log stream indexes. It is not the high-volume log body store; keep log bodies in segmented files locally, or add a future ClickHouse/Loki/OpenSearch/object-storage `LogBodyStore` adapter for production scale.
|
||||||
|
|
||||||
For local direct debugging, copy `platform/.env.example` to `platform/.env`, edit the values, and run:
|
For local direct debugging, copy `platform/.env.example` to `platform/.env`, edit the values, and run:
|
||||||
|
|
||||||
@@ -103,4 +103,4 @@ Client Manager installations are durable aggregates separate from Run distributi
|
|||||||
|
|
||||||
Component registration uses the current client-manager key generation, a timestamped nonce, and a short-lived hashed component session. It never reuses a Run session or job lease. Key reset revokes old sessions/artifacts and marks the installation for current-generation rebuild/redeploy. Run reports only logical health, phase, and bounded execution evidence; host paths, PIDs, sockets, raw keys, and credential material are not operator or plugin projections. Production KMS/code-signing, private source credentials, and fleet rollout remain explicit non-goals.
|
Component registration uses the current client-manager key generation, a timestamped nonce, and a short-lived hashed component session. It never reuses a Run session or job lease. Key reset revokes old sessions/artifacts and marks the installation for current-generation rebuild/redeploy. Run reports only logical health, phase, and bounded execution evidence; host paths, PIDs, sockets, raw keys, and credential material are not operator or plugin projections. Production KMS/code-signing, private source credentials, and fleet rollout remain explicit non-goals.
|
||||||
|
|
||||||
Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata for advanced logical transports. Server creation requires only the plugin type and server name, and plugin-declared deployment/lifecycle actions must be enough for user-facing start/stop and generated Run package flows without forcing operators through a manual runtime-profile binding screen. Browser and plugin-facing responses expose readiness only, not binding values. Platform-owned Docker builds need no registered Run endpoint with `distribution.build`; component keys remain in platform-controlled per-job input. This change uses controlled secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks.
|
Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata for advanced logical transports. Server creation requires only the plugin type and server name, and plugin-declared deployment/lifecycle actions must be enough for user-facing start/stop and generated Run package flows without forcing operators through a manual runtime-profile binding screen. Browser and plugin-facing responses expose readiness only, not binding values. Platform-owned Docker builds need no registered Run endpoint with `distribution.build`; component keys remain in platform-held per-job input. This change uses scoped secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks.
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ func platformAdminRequest(r *http.Request) bool {
|
|||||||
if path == "/api/v1/users" || strings.HasPrefix(path, "/api/v1/users/") ||
|
if path == "/api/v1/users" || strings.HasPrefix(path, "/api/v1/users/") ||
|
||||||
path == "/api/v1/ai-providers" || strings.HasPrefix(path, "/api/v1/ai-providers/") ||
|
path == "/api/v1/ai-providers" || strings.HasPrefix(path, "/api/v1/ai-providers/") ||
|
||||||
path == "/api/v1/metrics/platform" ||
|
path == "/api/v1/metrics/platform" ||
|
||||||
path == "/api/v1/run/endpoints" || strings.HasPrefix(path, "/api/v1/run/endpoints/") ||
|
path == "/api/v1/run/endpoints" || strings.HasPrefix(path, "/api/v1/run/endpoints/") {
|
||||||
path == "/api/v1/audit-events" || strings.HasPrefix(path, "/api/v1/audit-events/") {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if r.Method != http.MethodGet && (path == "/api/v1/game-plugins" || strings.HasPrefix(path, "/api/v1/game-plugins/") || strings.Contains(path, "/plugin-marketplace/plugins/")) {
|
if r.Method != http.MethodGet && (path == "/api/v1/game-plugins" || strings.HasPrefix(path, "/api/v1/game-plugins/") || strings.Contains(path, "/plugin-marketplace/plugins/")) {
|
||||||
|
|||||||
@@ -142,13 +142,12 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
|
|||||||
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
|
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||||
|
|
||||||
privateUpdateBodies := map[string]any{
|
privateUpdateBodies := map[string]any{
|
||||||
"/api/v1/run/lifecycle/report": dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: token, ServerInstanceID: "server-signed", Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}},
|
"/api/v1/run/lifecycle/report": dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: token, ServerInstanceID: "server-signed", Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}},
|
||||||
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||||
"/api/v1/run/jobs/protected-request-input": dto.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, FencingToken: 1},
|
"/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||||
"/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
"/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||||
"/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
"/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8},
|
||||||
"/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8},
|
"/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
|
||||||
"/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
|
|
||||||
}
|
}
|
||||||
nonce := 10
|
nonce := 10
|
||||||
for path, request := range privateUpdateBodies {
|
for path, request := range privateUpdateBodies {
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func (h *coreHandlers) serverClientManagerRevokeSession(w http.ResponseWriter, r
|
|||||||
|
|
||||||
// serverClientManagerUninstall godoc
|
// serverClientManagerUninstall godoc
|
||||||
// @Summary Safely uninstall a Client Manager
|
// @Summary Safely uninstall a Client Manager
|
||||||
// @Description Stops the supervised process and removes only the controlled installation workspace while retaining audit and distribution history.
|
// @Description Stops the supervised process and removes the Client Manager installation workspace while retaining build and distribution records.
|
||||||
// @Tags client-managers
|
// @Tags client-managers
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestRunLifecycleReportAPIProjectsServerState(t *testing.T) {
|
|||||||
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
|
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
|
||||||
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-lifecycle-report-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Lifecycle Report API", State: domain.ServerInstanceStateReady}, adminSession)
|
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-lifecycle-report-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Lifecycle Report API", State: domain.ServerInstanceStateReady}, adminSession)
|
||||||
|
|
||||||
recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: server.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running", AuditSummary: "private supervised process identity"}})
|
recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: server.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running", Summary: "private supervised process identity"}})
|
||||||
assertStatus(t, recorder, http.StatusOK)
|
assertStatus(t, recorder, http.StatusOK)
|
||||||
response := decodeBody[dto.RunLifecycleReportResponse](t, recorder)
|
response := decodeBody[dto.RunLifecycleReportResponse](t, recorder)
|
||||||
if !response.Accepted || response.ProjectedState != domain.ServerInstanceStateRunning {
|
if !response.Accepted || response.ProjectedState != domain.ServerInstanceStateRunning {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
|
|||||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}}
|
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}}
|
||||||
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client", DisplayName: "SCUM Client", Version: "1.0.0", RepositoryURL: "https://github.com/example/scum-client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum-client"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum-client", 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", "game-client.bridge"}}, 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", DisplayName: "SCUM Client", Version: "1.0.0", RepositoryURL: "https://github.com/example/scum-client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum-client"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum-client", 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", "game-client.bridge"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||||
plugin.GameClientBridge = domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-result.schema.json", TimeoutSeconds: 3600, MaxPayloadBytes: 4096}}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}
|
plugin.GameClientBridge = domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, PayloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", ResultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", TimeoutSeconds: 3600, MaxPayloadBytes: 4096}}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}
|
||||||
if _, err := coreService.CreateGamePlugin(plugin); err != nil {
|
if _, err := coreService.CreateGamePlugin(plugin); err != nil {
|
||||||
t.Fatalf("create bridge plugin: %v", err)
|
t.Fatalf("create bridge plugin: %v", err)
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
|
|||||||
t.Fatalf("bridge status did not safely expose query template availability: %#v", status)
|
t.Fatalf("bridge status did not safely expose query template availability: %#v", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
queue := dto.GameClientBridgeQueueRequest{ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "announce-1", ExpiresAt: time.Now().UTC().Add(time.Hour)}
|
queue := dto.GameClientBridgeQueueRequest{ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "diag-1", ExpiresAt: time.Now().UTC().Add(time.Hour)}
|
||||||
queuedRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", queue, adminSession)
|
queuedRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", queue, adminSession)
|
||||||
assertStatus(t, queuedRecorder, http.StatusAccepted)
|
assertStatus(t, queuedRecorder, http.StatusAccepted)
|
||||||
queued := decodeBody[dto.GameClientBridgeCommandResponse](t, queuedRecorder)
|
queued := decodeBody[dto.GameClientBridgeCommandResponse](t, queuedRecorder)
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"browser.local/platform/dto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// runProtectedRequestInput godoc
|
|
||||||
// @Summary Read one protected request for the active Run lease
|
|
||||||
// @Description Returns approved SQL, RCON, or management-program text exactly once to its signed, fenced Run lease. Browser and plugin clients never receive this payload.
|
|
||||||
// @Tags run-job-channel
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param body body dto.ProtectedRequestExecutionInputRequest true "Fenced protected request input request"
|
|
||||||
// @Success 200 {object} dto.ProtectedRequestExecutionInputResponse
|
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
|
||||||
// @Failure 401 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/run/jobs/protected-request-input [post]
|
|
||||||
func (h *coreHandlers) runProtectedRequestInput(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
writeMethodNotAllowed(w, http.MethodPost)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request, err := decodeJSON[dto.ProtectedRequestExecutionInputRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
writeDecodeError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
input, err := h.core.GetProtectedRequestExecutionInput(request.ToDomain())
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, dto.ProtectedRequestExecutionInputFromDomain(input))
|
|
||||||
}
|
|
||||||
@@ -47,12 +47,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/game-plugins/{id}", h.gamePluginDetail)
|
mux.HandleFunc("/api/v1/game-plugins/{id}", h.gamePluginDetail)
|
||||||
mux.HandleFunc("/api/v1/metrics/platform", h.platformMetrics)
|
mux.HandleFunc("/api/v1/metrics/platform", h.platformMetrics)
|
||||||
mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics)
|
mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics)
|
||||||
mux.HandleFunc("/api/v1/production/capacity", h.productionCapacity)
|
|
||||||
mux.HandleFunc("/api/v1/production/capacity/admission", h.productionCapacityAdmission)
|
|
||||||
mux.HandleFunc("/api/v1/alerts", h.alerts)
|
|
||||||
mux.HandleFunc("/api/v1/alerts/{id}/acknowledge", h.alertAcknowledge)
|
|
||||||
mux.HandleFunc("/api/v1/alerts/{id}/resolve", h.alertResolve)
|
|
||||||
mux.HandleFunc("/api/v1/alerts/{id}/retry", h.alertRetry)
|
|
||||||
mux.HandleFunc("/api/v1/plugin-lifecycles", h.pluginLifecycles)
|
mux.HandleFunc("/api/v1/plugin-lifecycles", h.pluginLifecycles)
|
||||||
mux.HandleFunc("/api/v1/plugin-lifecycles/{pluginId}/actions", h.pluginLifecycleAction)
|
mux.HandleFunc("/api/v1/plugin-lifecycles/{pluginId}/actions", h.pluginLifecycleAction)
|
||||||
mux.HandleFunc("/api/v1/ai/config-diffs", h.aiConfigDiffs)
|
mux.HandleFunc("/api/v1/ai/config-diffs", h.aiConfigDiffs)
|
||||||
@@ -98,6 +92,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
|
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
|
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates)
|
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators)
|
mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail)
|
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail)
|
||||||
@@ -112,7 +107,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/run/jobs/build-input", h.requireRunSignature(h.runJobBuildInput))
|
mux.HandleFunc("/api/v1/run/jobs/build-input", h.requireRunSignature(h.runJobBuildInput))
|
||||||
mux.HandleFunc("/api/v1/run/jobs/dependency-input", h.requireRunSignature(h.runJobDependencyInput))
|
mux.HandleFunc("/api/v1/run/jobs/dependency-input", h.requireRunSignature(h.runJobDependencyInput))
|
||||||
mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput))
|
mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput))
|
||||||
mux.HandleFunc("/api/v1/run/jobs/protected-request-input", h.requireRunSignature(h.runProtectedRequestInput))
|
|
||||||
mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput))
|
mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput))
|
||||||
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
|
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
|
||||||
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
|
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
|
||||||
@@ -139,8 +133,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/log-streams", h.logStreams)
|
mux.HandleFunc("/api/v1/log-streams", h.logStreams)
|
||||||
mux.HandleFunc("/api/v1/log-streams/query", h.logStreamQuery)
|
mux.HandleFunc("/api/v1/log-streams/query", h.logStreamQuery)
|
||||||
mux.HandleFunc("/api/v1/log-streams/{id}", h.logStreamDetail)
|
mux.HandleFunc("/api/v1/log-streams/{id}", h.logStreamDetail)
|
||||||
mux.HandleFunc("/api/v1/audit-events", h.auditEvents)
|
|
||||||
mux.HandleFunc("/api/v1/audit-events/{id}", h.auditEventDetail)
|
|
||||||
mux.HandleFunc("/api/v1/client-managers/register", h.clientManagerRegister)
|
mux.HandleFunc("/api/v1/client-managers/register", h.clientManagerRegister)
|
||||||
mux.HandleFunc("/api/v1/client-managers/heartbeat", h.clientManagerHeartbeat)
|
mux.HandleFunc("/api/v1/client-managers/heartbeat", h.clientManagerHeartbeat)
|
||||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/claim", h.gameClientBridgeCompanionClaim)
|
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/claim", h.gameClientBridgeCompanionClaim)
|
||||||
@@ -150,180 +142,9 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/diagnostics", h.gameClientBridgeCompanionDiagnostics)
|
mux.HandleFunc("/api/v1/game-client-bridge/companion/diagnostics", h.gameClientBridgeCompanionDiagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// productionCapacity godoc
|
|
||||||
// @Summary Get production capacity governance state
|
|
||||||
// @Description Returns bounded Run endpoint capacity and durable pressure counts visible to the current operator.
|
|
||||||
// @Tags production-operations
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} dto.ProductionCapacitySummaryResponse
|
|
||||||
// @Failure 401 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/production/capacity [get]
|
|
||||||
func (h *coreHandlers) productionCapacity(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
writeMethodNotAllowed(w, http.MethodGet)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
summary, err := h.core.GetProductionCapacityForSession(bearerToken(r))
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, dto.ProductionCapacityFromDomain(summary))
|
|
||||||
}
|
|
||||||
|
|
||||||
// productionCapacityAdmission godoc
|
|
||||||
// @Summary Check production capacity admission
|
|
||||||
// @Description Evaluates endpoint heartbeat, capability, durable jobs, and bounded backlog pressure without dispatching work.
|
|
||||||
// @Tags production-operations
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param body body dto.CapacityAdmissionRequest true "Capacity admission request"
|
|
||||||
// @Success 200 {object} dto.CapacityAdmissionDecisionResponse
|
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
|
||||||
// @Failure 401 {object} dto.ErrorResponse
|
|
||||||
// @Failure 403 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/production/capacity/admission [post]
|
|
||||||
func (h *coreHandlers) productionCapacityAdmission(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
writeMethodNotAllowed(w, http.MethodPost)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request, err := decodeJSON[dto.CapacityAdmissionRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
writeDecodeError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
decision, err := h.core.CheckCapacityAdmissionForSession(bearerToken(r), request.ToDomain())
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, dto.CapacityDecisionFromDomain(decision))
|
|
||||||
}
|
|
||||||
|
|
||||||
// alerts godoc
|
|
||||||
// @Summary List durable production alerts
|
|
||||||
// @Description Lists alerts visible to the current operator with optional safe state/source/severity filters.
|
|
||||||
// @Tags production-operations
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} dto.AlertListResponse
|
|
||||||
// @Failure 401 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/alerts [get]
|
|
||||||
func (h *coreHandlers) alerts(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
writeMethodNotAllowed(w, http.MethodGet)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
alerts, err := h.core.ListAlertsForSession(bearerToken(r), domain.AlertFilter{State: domain.AlertState(r.URL.Query().Get("state")), SourceKind: r.URL.Query().Get("sourceKind"), SourceID: r.URL.Query().Get("sourceId"), Severity: domain.AlertSeverity(r.URL.Query().Get("severity"))})
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, dto.AlertListFromDomain(alerts))
|
|
||||||
}
|
|
||||||
|
|
||||||
// alertAcknowledge godoc
|
|
||||||
// @Summary Acknowledge a durable alert
|
|
||||||
// @Description Persists acknowledgement actor, timestamp, and linked audit evidence for one alert.
|
|
||||||
// @Tags production-operations
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Alert ID"
|
|
||||||
// @Param body body dto.AlertAcknowledgeRequest true "Acknowledgement request"
|
|
||||||
// @Success 200 {object} dto.AlertResponse
|
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
|
||||||
// @Failure 401 {object} dto.ErrorResponse
|
|
||||||
// @Failure 403 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/alerts/{id}/acknowledge [post]
|
|
||||||
func (h *coreHandlers) alertAcknowledge(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
writeMethodNotAllowed(w, http.MethodPost)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request, err := decodeJSON[dto.AlertAcknowledgeRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
writeDecodeError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
alert, err := h.core.AcknowledgeAlertForSession(bearerToken(r), domain.AlertAcknowledgeRequest{AlertID: r.PathValue("id"), Note: request.Note})
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, dto.AlertFromDomain(alert))
|
|
||||||
}
|
|
||||||
|
|
||||||
// alertResolve godoc
|
|
||||||
// @Summary Resolve a durable alert
|
|
||||||
// @Description Resolves one alert with a safe operator note and linked audit evidence.
|
|
||||||
// @Tags production-operations
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Alert ID"
|
|
||||||
// @Param body body dto.AlertResolveRequest true "Resolution request"
|
|
||||||
// @Success 200 {object} dto.AlertResponse
|
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
|
||||||
// @Failure 401 {object} dto.ErrorResponse
|
|
||||||
// @Failure 403 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/alerts/{id}/resolve [post]
|
|
||||||
func (h *coreHandlers) alertResolve(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
writeMethodNotAllowed(w, http.MethodPost)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request, err := decodeJSON[dto.AlertResolveRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
writeDecodeError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
alert, err := h.core.ResolveAlertForSession(bearerToken(r), domain.AlertResolveRequest{AlertID: r.PathValue("id"), Note: request.Note})
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, dto.AlertFromDomain(alert))
|
|
||||||
}
|
|
||||||
|
|
||||||
// alertRetry godoc
|
|
||||||
// @Summary Retry one durable alert source
|
|
||||||
// @Description Retries only the bounded source represented by an alert and preserves idempotency.
|
|
||||||
// @Tags production-operations
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Alert ID"
|
|
||||||
// @Param body body dto.AlertRetryRequest true "Scoped retry request"
|
|
||||||
// @Success 202 {object} dto.AlertRetryResponse
|
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
|
||||||
// @Failure 401 {object} dto.ErrorResponse
|
|
||||||
// @Failure 403 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/alerts/{id}/retry [post]
|
|
||||||
func (h *coreHandlers) alertRetry(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
writeMethodNotAllowed(w, http.MethodPost)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request, err := decodeJSON[dto.AlertRetryRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
writeDecodeError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, err := h.core.RetryAlertForSession(bearerToken(r), domain.AlertRetryRequest{AlertID: r.PathValue("id"), IdempotencyKey: request.IdempotencyKey})
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusAccepted, dto.AlertRetryFromDomain(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
// pluginLifecycles godoc
|
// pluginLifecycles godoc
|
||||||
// @Summary List server-bound plugin lifecycle state
|
// @Summary List server-bound plugin lifecycle state
|
||||||
// @Description Lists durable plugin installation, desired/current state, compatibility, dependency, job, alert, and audit metadata.
|
// @Description Lists durable plugin installation, desired/current state, compatibility, dependency, and job metadata.
|
||||||
// @Tags production-operations
|
// @Tags production-operations
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} dto.PluginLifecycleListResponse
|
// @Success 200 {object} dto.PluginLifecycleListResponse
|
||||||
@@ -2598,70 +2419,3 @@ func (h *coreHandlers) logStreamDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, dto.LogStreamFromDomain(stream))
|
writeJSON(w, http.StatusOK, dto.LogStreamFromDomain(stream))
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditEvents godoc
|
|
||||||
// @Summary Create or list audit events
|
|
||||||
// @Description Creates or lists audit event metadata.
|
|
||||||
// @Tags audit-events
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param body body dto.AuditEventCreateRequest false "Audit event create request"
|
|
||||||
// @Success 200 {object} dto.AuditEventListResponse
|
|
||||||
// @Success 201 {object} dto.AuditEventResponse
|
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/audit-events [get]
|
|
||||||
// @Router /api/v1/audit-events [post]
|
|
||||||
func (h *coreHandlers) auditEvents(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
events, err := h.core.ListAuditEvents(domain.AuditEventFilter{
|
|
||||||
ActorID: r.URL.Query().Get("actorId"),
|
|
||||||
ResourceKind: r.URL.Query().Get("resourceKind"),
|
|
||||||
ResourceID: r.URL.Query().Get("resourceId"),
|
|
||||||
Result: domain.AuditResult(r.URL.Query().Get("result")),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, dto.AuditEventListFromDomain(events))
|
|
||||||
case http.MethodPost:
|
|
||||||
request, err := decodeJSON[dto.AuditEventCreateRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
writeDecodeError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event, err := h.core.CreateAuditEvent(request.ToDomain())
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusCreated, dto.AuditEventFromDomain(event))
|
|
||||||
default:
|
|
||||||
writeMethodNotAllowed(w, "GET, POST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// auditEventDetail godoc
|
|
||||||
// @Summary Get audit event
|
|
||||||
// @Description Returns one audit event by ID.
|
|
||||||
// @Tags audit-events
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Audit event ID"
|
|
||||||
// @Success 200 {object} dto.AuditEventResponse
|
|
||||||
// @Failure 404 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/audit-events/{id} [get]
|
|
||||||
func (h *coreHandlers) auditEventDetail(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
writeMethodNotAllowed(w, http.MethodGet)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event, err := h.core.GetAuditEvent(r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, dto.AuditEventFromDomain(event))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -127,21 +127,6 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
|
|||||||
t.Fatalf("expected explicitly created stream in list, got %+v", streams)
|
t.Fatalf("expected explicitly created stream in list, got %+v", streams)
|
||||||
}
|
}
|
||||||
|
|
||||||
auditResponse := postJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events", dto.AuditEventCreateRequest{
|
|
||||||
ID: "audit-1",
|
|
||||||
ActorID: "user-1",
|
|
||||||
Action: "server.create",
|
|
||||||
ResourceKind: "server-instance",
|
|
||||||
ResourceID: "server-1",
|
|
||||||
Result: domain.AuditResultSuccess,
|
|
||||||
Summary: "created server instance",
|
|
||||||
})
|
|
||||||
if auditResponse.Result != domain.AuditResultSuccess {
|
|
||||||
t.Fatalf("expected successful audit event, got %+v", auditResponse)
|
|
||||||
}
|
|
||||||
getJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events/audit-1")
|
|
||||||
auditEvents := getJSON[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?actorId=user-1&resourceKind=server-instance&resourceId=server-1&result=success")
|
|
||||||
assertListCount(t, auditEvents.Count, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
|
func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
|
||||||
@@ -349,16 +334,6 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
audits := getJSONWithAuth[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?resourceId="+serverID, adminSession)
|
|
||||||
auditActions := map[string]bool{}
|
|
||||||
for _, audit := range audits.Items {
|
|
||||||
auditActions[audit.Action] = true
|
|
||||||
}
|
|
||||||
for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "runtime-key.reset"} {
|
|
||||||
if !auditActions[action] {
|
|
||||||
t.Fatalf("expected audit action %q in %+v", action, audits.Items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreAPIRunDistributionDenialNamesMissingPluginPermission(t *testing.T) {
|
func TestCoreAPIRunDistributionDenialNamesMissingPluginPermission(t *testing.T) {
|
||||||
@@ -1538,31 +1513,19 @@ func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProductionOperationsGovernanceRoutesAreDurableAndRedacted(t *testing.T) {
|
func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) {
|
||||||
router := newTestRouter()
|
router := newTestRouter()
|
||||||
adminSession := createAdminSession(t, router)
|
adminSession := createAdminSession(t, router)
|
||||||
serverID := createRuntimeAPIFixtures(t, router, adminSession)
|
serverID := createRuntimeAPIFixtures(t, router, adminSession)
|
||||||
createAIProviderFixture(t, router, adminSession)
|
createAIProviderFixture(t, router, adminSession)
|
||||||
|
|
||||||
capacity := getJSONWithAuth[dto.ProductionCapacitySummaryResponse](t, router, "/api/v1/production/capacity", adminSession)
|
lifecycle := postOKJSONWithAuth[dto.PluginLifecycleActionResponse](t, router, "/api/v1/plugin-lifecycles/server.runtime/actions", dto.PluginLifecycleActionRequest{ServerInstanceID: serverID, Operation: "install", TargetVersion: "1.0.0", IdempotencyKey: "api-plugin-install", Confirmed: false}, adminSession)
|
||||||
if len(capacity.Endpoints) == 0 {
|
if lifecycle.Status != "queued" || lifecycle.Job.ID == "" || lifecycle.Installation.ID == "" {
|
||||||
t.Fatalf("expected persisted capacity endpoints, got %+v", capacity)
|
t.Fatalf("expected queued plugin lifecycle job, got %+v", lifecycle)
|
||||||
}
|
}
|
||||||
decision := postOKJSONWithAuth[dto.CapacityAdmissionDecisionResponse](t, router, "/api/v1/production/capacity/admission", dto.CapacityAdmissionRequest{ServerInstanceID: serverID, Capability: domain.JobCapabilityRemoteRunRCONCommand, IdempotencyKey: "api-capacity-check"}, adminSession)
|
lifecycles := getJSONWithAuth[dto.PluginLifecycleListResponse](t, router, "/api/v1/plugin-lifecycles?serverInstanceId="+serverID, adminSession)
|
||||||
if decision.Accepted || decision.AlertID == "" || decision.AuditEventID == "" {
|
if lifecycles.Count != 1 || lifecycles.Items[0].JobID != lifecycle.Job.ID {
|
||||||
t.Fatalf("expected unavailable capability admission to create durable evidence, got %+v", decision)
|
t.Fatalf("expected persisted plugin lifecycle, got %+v", lifecycles)
|
||||||
}
|
|
||||||
alerts := getJSONWithAuth[dto.AlertListResponse](t, router, "/api/v1/alerts?state=active", adminSession)
|
|
||||||
if alerts.Count == 0 {
|
|
||||||
t.Fatalf("expected durable alert list, got %+v", alerts)
|
|
||||||
}
|
|
||||||
acknowledged := postOKJSONWithAuth[dto.AlertResponse](t, router, "/api/v1/alerts/"+decision.AlertID+"/acknowledge", dto.AlertAcknowledgeRequest{Note: "operator review"}, adminSession)
|
|
||||||
if acknowledged.State != string(domain.AlertStateAcknowledged) {
|
|
||||||
t.Fatalf("expected acknowledged state, got %+v", acknowledged)
|
|
||||||
}
|
|
||||||
resolved := postOKJSONWithAuth[dto.AlertResponse](t, router, "/api/v1/alerts/"+decision.AlertID+"/resolve", dto.AlertResolveRequest{Note: "review complete"}, adminSession)
|
|
||||||
if resolved.State != string(domain.AlertStateResolved) {
|
|
||||||
t.Fatalf("expected resolved state, got %+v", resolved)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-diff", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession)
|
invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-diff", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession)
|
||||||
@@ -1580,10 +1543,10 @@ func TestProductionOperationsGovernanceRoutesAreDurableAndRedacted(t *testing.T)
|
|||||||
t.Fatalf("expected approved diff with config write job, got %+v", approval)
|
t.Fatalf("expected approved diff with config write job, got %+v", approval)
|
||||||
}
|
}
|
||||||
|
|
||||||
evidence := fmt.Sprintf("%+v %+v %+v %+v %+v %+v", capacity, decision, alerts, resolved, diffs, approval)
|
evidence := fmt.Sprintf("%+v %+v %+v %+v", lifecycle, lifecycles, diffs, approval)
|
||||||
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} {
|
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} {
|
||||||
if strings.Contains(evidence, forbidden) {
|
if strings.Contains(evidence, forbidden) {
|
||||||
t.Fatalf("production governance response leaked forbidden fragment %q: %s", forbidden, evidence)
|
t.Fatalf("production operations response leaked forbidden fragment %q: %s", forbidden, evidence)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-13
@@ -23,7 +23,6 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
|||||||
| Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` |
|
| Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` |
|
||||||
| Artifacts | `GET /api/v1/artifacts`, `POST /api/v1/artifacts` | `GET /api/v1/artifacts/{id}`, `POST /api/v1/artifacts/{id}/download`, `GET /api/v1/artifacts/{id}/content` | `ArtifactCreateRequest`, `ArtifactResponse`, `ArtifactListResponse`, `ArtifactDownloadReferenceResponse`, `ArtifactContentRequest` |
|
| Artifacts | `GET /api/v1/artifacts`, `POST /api/v1/artifacts` | `GET /api/v1/artifacts/{id}`, `POST /api/v1/artifacts/{id}/download`, `GET /api/v1/artifacts/{id}/content` | `ArtifactCreateRequest`, `ArtifactResponse`, `ArtifactListResponse`, `ArtifactDownloadReferenceResponse`, `ArtifactContentRequest` |
|
||||||
| Log streams | `GET /api/v1/log-streams`, `POST /api/v1/log-streams` | `GET /api/v1/log-streams/{id}` | `LogStreamCreateRequest`, `LogStreamResponse`, `LogStreamListResponse` |
|
| Log streams | `GET /api/v1/log-streams`, `POST /api/v1/log-streams` | `GET /api/v1/log-streams/{id}` | `LogStreamCreateRequest`, `LogStreamResponse`, `LogStreamListResponse` |
|
||||||
| Audit events | `GET /api/v1/audit-events`, `POST /api/v1/audit-events` | `GET /api/v1/audit-events/{id}` | `AuditEventCreateRequest`, `AuditEventResponse`, `AuditEventListResponse` |
|
|
||||||
|
|
||||||
Client Manager lifecycle routes are grouped under the server instance and return only the safe installation projection: `GET /api/v1/server-instances/{id}/client-managers`, `GET .../{profileKey}`, and typed `POST` routes for `deploy`, `control`, `update`, `retry`, `revoke-session`, and confirmed `uninstall`. Component-only `POST /api/v1/client-managers/register` and `/heartbeat` use the separate signed component identity/session contract. Run-only input/chunk routes are fenced by the active Run job lease. None of these DTOs return raw component keys, bearer sessions, secret refs/values, host paths, PIDs, sockets, or endpoint addresses.
|
Client Manager lifecycle routes are grouped under the server instance and return only the safe installation projection: `GET /api/v1/server-instances/{id}/client-managers`, `GET .../{profileKey}`, and typed `POST` routes for `deploy`, `control`, `update`, `retry`, `revoke-session`, and confirmed `uninstall`. Component-only `POST /api/v1/client-managers/register` and `/heartbeat` use the separate signed component identity/session contract. Run-only input/chunk routes are fenced by the active Run job lease. None of these DTOs return raw component keys, bearer sessions, secret refs/values, host paths, PIDs, sockets, or endpoint addresses.
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ Client Manager lifecycle routes are grouped under the server instance and return
|
|||||||
- `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued`
|
- `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued`
|
||||||
- `GET /api/v1/artifacts?ownerKind=job&ownerId=job-1&state=uploading`
|
- `GET /api/v1/artifacts?ownerKind=job&ownerId=job-1&state=uploading`
|
||||||
- `GET /api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout`
|
- `GET /api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout`
|
||||||
- `GET /api/v1/audit-events?actorId=user-1&resourceKind=server-instance&resourceId=server-1&result=success`
|
|
||||||
|
|
||||||
## Implemented Authentication And Current User Actions
|
## Implemented Authentication And Current User Actions
|
||||||
|
|
||||||
@@ -52,7 +50,7 @@ Client Manager lifecycle routes are grouped under the server instance and return
|
|||||||
- `PUT /api/v1/users/current/profile`: update bounded current-user profile fields using `UserProfileBody`.
|
- `PUT /api/v1/users/current/profile`: update bounded current-user profile fields using `UserProfileBody`.
|
||||||
- `PUT /api/v1/users/current/theme`: persist current-user console theme preferences using `UserThemePreferenceRequest`.
|
- `PUT /api/v1/users/current/theme`: persist current-user console theme preferences using `UserThemePreferenceRequest`.
|
||||||
|
|
||||||
Bearer sessions are stored as SHA-256 verifiers with issued/expiry/revocation timestamps and rotation generation; raw tokens are never written to FileStore/MySQLStore snapshots. Browser sessions use HttpOnly SameSite cookies, while explicit CLI bearer mode returns the token once. Production router construction requires authentication for sensitive API paths, reserves user/provider/plugin install/Run endpoint/audit/global create operations for platform administrators, and repeats server/job/log/artifact ownership checks in services. After the first account exists, public registration defaults to `pending` plus server-scoped roles and does not grant platform administrator privileges. A bootstrap administrator is created only when `PLATFORM_BOOTSTRAP_ADMIN_PASSWORD` is explicitly configured; local debug scripts provide their own development-only value.
|
Bearer sessions are stored as SHA-256 verifiers with issued/expiry/revocation timestamps and rotation generation; raw tokens are never written to FileStore/MySQLStore snapshots. Browser sessions use HttpOnly SameSite cookies, while explicit CLI bearer mode returns the token once. Production router construction requires authentication for sensitive API paths, reserves user/provider/plugin install/Run endpoint/global create operations for platform administrators, and repeats server/job/log/artifact ownership checks in services. After the first account exists, public registration defaults to `pending` plus server-scoped roles and does not grant platform administrator privileges. A bootstrap administrator is created only when `PLATFORM_BOOTSTRAP_ADMIN_PASSWORD` is explicitly configured; local debug scripts provide their own development-only value.
|
||||||
|
|
||||||
## Implemented Role-Scoped Server Access
|
## Implemented Role-Scoped Server Access
|
||||||
|
|
||||||
@@ -91,7 +89,7 @@ File dispatch responses expose only logical target keys, scoped input/artifact r
|
|||||||
AI invocation is platform-mediated. `PLATFORM_AI_PROVIDER_MODE=live` uses the Platform-owned HTTP client and environment secret resolver; local verification explicitly uses `mock`. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions persist an expiring diff and never dispatch run-side writes before separate approval.
|
AI invocation is platform-mediated. `PLATFORM_AI_PROVIDER_MODE=live` uses the Platform-owned HTTP client and environment secret resolver; local verification explicitly uses `mock`. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions persist an expiring diff and never dispatch run-side writes before separate approval.
|
||||||
AI Provider management responses also return only `baseUrlConfigured` and `apiKeyConfigured`; an empty base URL or secret reference in an update preserves the Platform-owned value instead of round-tripping it through the browser.
|
AI Provider management responses also return only `baseUrlConfigured` and `apiKeyConfigured`; an empty base URL or secret reference in an update preserves the Platform-owned value instead of round-tripping it through the browser.
|
||||||
|
|
||||||
AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a controlled secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser.
|
AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a scoped secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser.
|
||||||
|
|
||||||
## Implemented Game Plugin Registry Actions
|
## Implemented Game Plugin Registry Actions
|
||||||
|
|
||||||
@@ -111,18 +109,18 @@ Marketplace state actions are metadata-only in this change. `install` and `enabl
|
|||||||
|
|
||||||
Marketplace catalog state remains separate from production lifecycle installations. Server-bound install/enable/disable/upgrade/rollback/retire operations use the production lifecycle routes below.
|
Marketplace catalog state remains separate from production lifecycle installations. Server-bound install/enable/disable/upgrade/rollback/retire operations use the production lifecycle routes below.
|
||||||
|
|
||||||
## Production Operations Governance
|
## Production Operations
|
||||||
|
|
||||||
- `GET /api/v1/production/capacity`: return bounded endpoint capacity, durable job pressure, backlog counts, pressure codes, and active-alert count visible to the session.
|
- `GET /api/v1/production/capacity`: return bounded endpoint capacity, durable job pressure, backlog counts, pressure codes, and active-alert count visible to the session.
|
||||||
- `POST /api/v1/production/capacity/admission`: evaluate server binding, endpoint heartbeat/capability, job limits, queue pressure, and spool pressure without dispatching work.
|
- `POST /api/v1/production/capacity/admission`: evaluate server binding, endpoint heartbeat/capability, job limits, queue pressure, and spool pressure without dispatching work.
|
||||||
- `GET /api/v1/alerts`: list durable alerts with state/source/severity filters.
|
- `GET /api/v1/alerts`: list durable alerts with state/source/severity filters.
|
||||||
- `POST /api/v1/alerts/{id}/acknowledge`, `/resolve`, and `/retry`: persist one scoped alert transition or source retry with actor/audit evidence.
|
- `POST /api/v1/alerts/{id}/acknowledge`, `/resolve`, and `/retry`: persist one scoped alert transition or source retry with actor evidence.
|
||||||
- `GET /api/v1/plugin-lifecycles`: list server-bound plugin lifecycle installations.
|
- `GET /api/v1/plugin-lifecycles`: list server-bound plugin lifecycle installations.
|
||||||
- `POST /api/v1/plugin-lifecycles/{pluginId}/actions`: validate manifest declaration, compatibility, confirmation, idempotency, and capacity before creating one durable Run job.
|
- `POST /api/v1/plugin-lifecycles/{pluginId}/actions`: validate manifest declaration, compatibility, confirmation, idempotency, and capacity before creating one durable Run job.
|
||||||
- `GET /api/v1/ai/config-diffs`: list reviewable AI config recommendations visible to the session.
|
- `GET /api/v1/ai/config-diffs`: list reviewable AI config recommendations visible to the session.
|
||||||
- `POST /api/v1/ai/config-diffs/{id}/approve`: revalidate actor/server/config revision/checksum/expiry and dispatch exactly one bounded `config.write` job.
|
- `POST /api/v1/ai/config-diffs/{id}/approve`: revalidate actor/server/config revision/checksum/expiry and dispatch exactly one bounded `config.write` job.
|
||||||
|
|
||||||
These responses expose logical IDs, counts, states, pressure codes, safe diagnostics, and job/audit links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material.
|
These responses expose logical IDs, counts, states, pressure codes, safe diagnostics, and job links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material.
|
||||||
|
|
||||||
## Implemented Plugin Bridge Actions
|
## Implemented Plugin Bridge Actions
|
||||||
|
|
||||||
@@ -150,7 +148,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
|||||||
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
|
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
|
||||||
- `POST /api/v1/server-instances/{id}/run/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`.
|
- `POST /api/v1/server-instances/{id}/run/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`.
|
||||||
- `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`.
|
- `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`.
|
||||||
- `GET /api/v1/server-instances/{id}/run/update`: lists safe update phase, target, progress message, artifact checksum, release identity, rollback, and audit summary for the authorized server.
|
- `GET /api/v1/server-instances/{id}/run/update`: lists safe update phase, target, progress message, artifact checksum, release identity, rollback, and summary for the authorized server.
|
||||||
- `POST /api/v1/server-instances/{id}/client-managers/generate`: accepts `ClientManagerBuildRequest`, validates the plugin-declared client-manager profile and target platform, injects a distinct current client-manager key into the package config, publishes a downloadable artifact, and returns `ClientManagerDistributionResponse`.
|
- `POST /api/v1/server-instances/{id}/client-managers/generate`: accepts `ClientManagerBuildRequest`, validates the plugin-declared client-manager profile and target platform, injects a distinct current client-manager key into the package config, publishes a downloadable artifact, and returns `ClientManagerDistributionResponse`.
|
||||||
- `POST /api/v1/server-instances/{id}/client-managers/download`: accepts `ClientManagerDownloadRequest` and opens the latest authorized client-manager artifact through `ArtifactDownloadReferenceResponse`.
|
- `POST /api/v1/server-instances/{id}/client-managers/download`: accepts `ClientManagerDownloadRequest` and opens the latest authorized client-manager artifact through `ArtifactDownloadReferenceResponse`.
|
||||||
- `POST /api/v1/server-instances/{id}/client-managers/key/reset`: accepts `ComponentKeyResetRequest`, resets only the named client-manager component key, increments generation, revokes older client-manager packages, and returns `ComponentKeyResponse`.
|
- `POST /api/v1/server-instances/{id}/client-managers/key/reset`: accepts `ComponentKeyResetRequest`, resets only the named client-manager component key, increments generation, revokes older client-manager packages, and returns `ComponentKeyResponse`.
|
||||||
@@ -159,15 +157,15 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
|||||||
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
||||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted log SSE history/live events only. The raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs; internal log ingest and cursor query remain available for run/platform maintenance flows.
|
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted log SSE history/live events only. The raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs; internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||||
|
|
||||||
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||||
|
|
||||||
SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and audit-safe summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials.
|
SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials.
|
||||||
|
|
||||||
`POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so.
|
`POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so.
|
||||||
|
|
||||||
## Private Run Dependency And Update Routes
|
## Private Run Dependency And Update Routes
|
||||||
|
|
||||||
The following signed routes are Run-only and never part of browser/plugin DTOs: `POST /api/v1/run/jobs/dependency-input`, `POST /api/v1/run/jobs/protected-request-input`, `POST /api/v1/run/jobs/update-input`, `POST /api/v1/run/jobs/update-chunk`, and `POST /api/v1/run/jobs/update-health`. They require the current endpoint/session signature; input/chunk calls additionally require active attempt/lease/cancel fencing. Protected-request input additionally requires the current fencing token and returns approved text exactly once for the server-bound logical transport; the text is not persisted in a job, bridge command, journal, response projection, or audit summary. Update chunks are bounded to 1 MiB and resolve only an available same-server target-matched Run distribution. Health reports are accepted only after the terminal staged job, matching attempt/lease proof, current online endpoint release, and reconciliation-capable session are verified. These routes never return raw artifact paths, browser download tokens, host paths, credentials, secret refs, or session/lease hashes.
|
The following signed routes are Run-only and never part of browser/plugin DTOs: `POST /api/v1/run/jobs/dependency-input`, `POST /api/v1/run/jobs/source-rcon-input`, `POST /api/v1/run/jobs/update-input`, `POST /api/v1/run/jobs/update-chunk`, and `POST /api/v1/run/jobs/update-health`. They require the current endpoint/session signature; input/chunk calls additionally require active attempt/lease/cancel fencing. Source RCON input returns one queued command exactly once to the active Run lease. Update chunks are bounded to 1 MiB and resolve only an available same-server target-matched Run distribution. Health reports are accepted only after the terminal staged job, matching attempt/lease proof, current online endpoint release, and reconciliation-capable session are verified. These routes never return raw artifact paths, browser download tokens, host paths, credentials, secret refs, or session/lease hashes.
|
||||||
|
|
||||||
## Implemented Run Control Actions
|
## Implemented Run Control Actions
|
||||||
|
|
||||||
@@ -185,7 +183,6 @@ Control is the highest-priority run-facing channel; artifact/file transfer press
|
|||||||
- `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal result or durable retry-wait transition with capped exponential backoff.
|
- `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal result or durable retry-wait transition with capped exponential backoff.
|
||||||
- `POST /api/v1/run/jobs/cancel`: accept fenced `RunJobCancelPollRequest` and return durable pending cancellation intent for the current attempt.
|
- `POST /api/v1/run/jobs/cancel`: accept fenced `RunJobCancelPollRequest` and return durable pending cancellation intent for the current attempt.
|
||||||
- `POST /api/v1/run/jobs/reconcile`: accept persisted Run journal evidence (`jobId`, `attempt`, `leaseToken`), rebind only matching active attempts to the current authenticated session generation, persist reconciliation metadata, retry/cancel platform-active missing work, and return confirmed assignments plus discard IDs.
|
- `POST /api/v1/run/jobs/reconcile`: accept persisted Run journal evidence (`jobId`, `attempt`, `leaseToken`), rebind only matching active attempts to the current authenticated session generation, persist reconciliation metadata, retry/cancel platform-active missing work, and return confirmed assignments plus discard IDs.
|
||||||
- `POST /api/v1/run/jobs/protected-request-input`: accept `ProtectedRequestExecutionInputRequest`, fence endpoint/session/attempt/lease/token, and return one approved, unexpired SQL, RCON, or management-program request only for its exact server-bound logical transport. The route never returns credentials, DSNs, paths, sockets, raw connections, or host shell material.
|
|
||||||
- `POST /api/v1/jobs/{id}/cancel`: authorize the server owner/administrator or platform administrator and durably record cancellation intent; queued/retrying work becomes cancelled immediately while active work completes through fenced Run polling/result.
|
- `POST /api/v1/jobs/{id}/cancel`: authorize the server owner/administrator or platform administrator and durably record cancellation intent; queued/retrying work becomes cancelled immediately while active work completes through fenced Run polling/result.
|
||||||
|
|
||||||
Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials.
|
Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials.
|
||||||
@@ -200,7 +197,7 @@ Server-scoped SSE log streaming is removed from product routes. `POST /api/v1/lo
|
|||||||
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
|
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do 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 acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
||||||
|
|
||||||
Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and audit trails. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments.
|
Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and operational records. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments.
|
||||||
|
|
||||||
## Implemented Run Artifact Actions
|
## Implemented Run Artifact Actions
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"browser.local/platform/dto"
|
"browser.local/platform/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sourceRCONCommands is kept as legacy service plumbing but is not registered as a browser product route.
|
// sourceRCONCommands dispatches a one-time SCUM Source RCON command through Run.
|
||||||
func (h *coreHandlers) sourceRCONCommands(w http.ResponseWriter, r *http.Request) {
|
func (h *coreHandlers) sourceRCONCommands(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
writeMethodNotAllowed(w, http.MethodPost)
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
|||||||
@@ -40,17 +40,6 @@ type GameClientBridgeCommandDeclaration struct {
|
|||||||
ResultSchemaRef string
|
ResultSchemaRef string
|
||||||
TimeoutSeconds int
|
TimeoutSeconds int
|
||||||
MaxPayloadBytes int
|
MaxPayloadBytes int
|
||||||
ProtectedRequest *GameClientBridgeProtectedRequestDeclaration
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameClientBridgeProtectedRequestDeclaration binds plugin-generated text to a
|
|
||||||
// logical server transport. It never carries its resolved connection details.
|
|
||||||
type GameClientBridgeProtectedRequestDeclaration struct {
|
|
||||||
Kind string
|
|
||||||
TransportKey string
|
|
||||||
TargetKey string
|
|
||||||
TextField string
|
|
||||||
MaxTextBytes int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeSnapshotDeclaration struct {
|
type GameClientBridgeSnapshotDeclaration struct {
|
||||||
@@ -100,19 +89,10 @@ type GameClientBridgeLogProjectionTargetDeclaration struct {
|
|||||||
ObservedAtField string
|
ObservedAtField string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeLogProjectionAnnouncementDeclaration struct {
|
|
||||||
ProfileKey string
|
|
||||||
CommandType string
|
|
||||||
TextField string
|
|
||||||
NewTextTemplate string
|
|
||||||
ReturningTextTemplate string
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameClientBridgeLogProjectionPresenceDeclaration struct {
|
type GameClientBridgeLogProjectionPresenceDeclaration struct {
|
||||||
TimestampField string
|
TimestampField string
|
||||||
ActiveWindowSeconds int
|
ActiveWindowSeconds int
|
||||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration
|
ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration
|
||||||
Announcement GameClientBridgeLogProjectionAnnouncementDeclaration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeLogProjectionDeclaration struct {
|
type GameClientBridgeLogProjectionDeclaration struct {
|
||||||
@@ -130,7 +110,7 @@ type GameClientBridgeDataPackDeclaration struct {
|
|||||||
DatabaseUserVersion int
|
DatabaseUserVersion int
|
||||||
LogParserRefs []string
|
LogParserRefs []string
|
||||||
ConfigMapRefs []string
|
ConfigMapRefs []string
|
||||||
DataRefs []string
|
DataRefs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeOperationKind string
|
type GameClientBridgeOperationKind string
|
||||||
@@ -249,7 +229,6 @@ type GameClientBridgeCommand struct {
|
|||||||
Claim GameClientBridgeClaim
|
Claim GameClientBridgeClaim
|
||||||
Cancellation GameClientBridgeCancellation
|
Cancellation GameClientBridgeCancellation
|
||||||
Result GameClientBridgeResult
|
Result GameClientBridgeResult
|
||||||
AuditReferences []string
|
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
@@ -293,7 +272,6 @@ type GameClientBridgeSnapshot struct {
|
|||||||
ObservedAt time.Time
|
ObservedAt time.Time
|
||||||
Payload map[string]any
|
Payload map[string]any
|
||||||
Retention GameClientBridgeRetention
|
Retention GameClientBridgeRetention
|
||||||
AuditReferences []string
|
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
@@ -314,14 +292,6 @@ type GameClientBridgeRetention struct {
|
|||||||
MaxRecords int
|
MaxRecords int
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeAuditReference struct {
|
|
||||||
ID string
|
|
||||||
CommandID string
|
|
||||||
SnapshotID string
|
|
||||||
AuditEventID string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameClientBridgeCommandFilter struct {
|
type GameClientBridgeCommandFilter struct {
|
||||||
ServerInstanceID string
|
ServerInstanceID string
|
||||||
PluginID string
|
PluginID string
|
||||||
@@ -460,7 +430,6 @@ func CopyGameClientBridgeCommand(value GameClientBridgeCommand) GameClientBridge
|
|||||||
value.Claim = CopyGameClientBridgeClaim(value.Claim)
|
value.Claim = CopyGameClientBridgeClaim(value.Claim)
|
||||||
value.Cancellation = CopyGameClientBridgeCancellation(value.Cancellation)
|
value.Cancellation = CopyGameClientBridgeCancellation(value.Cancellation)
|
||||||
value.Result = CopyGameClientBridgeResult(value.Result)
|
value.Result = CopyGameClientBridgeResult(value.Result)
|
||||||
value.AuditReferences = CopyStringSlice(value.AuditReferences)
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +447,6 @@ func CopyGameClientBridgeResult(value GameClientBridgeResult) GameClientBridgeRe
|
|||||||
func CopyGameClientBridgeSnapshot(value GameClientBridgeSnapshot) GameClientBridgeSnapshot {
|
func CopyGameClientBridgeSnapshot(value GameClientBridgeSnapshot) GameClientBridgeSnapshot {
|
||||||
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
||||||
value.Retention = CopyGameClientBridgeRetention(value.Retention)
|
value.Retention = CopyGameClientBridgeRetention(value.Retention)
|
||||||
value.AuditReferences = CopyStringSlice(value.AuditReferences)
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,10 +458,6 @@ func CopyGameClientBridgeRetention(value GameClientBridgeRetention) GameClientBr
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyGameClientBridgeAuditReference(value GameClientBridgeAuditReference) GameClientBridgeAuditReference {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyGameClientBridgePayload(value map[string]any) map[string]any {
|
func CopyGameClientBridgePayload(value map[string]any) map[string]any {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -507,12 +471,6 @@ func CopyGameClientBridgePayload(value map[string]any) map[string]any {
|
|||||||
|
|
||||||
func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest {
|
func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest {
|
||||||
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
|
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
|
||||||
for index := range value.Commands {
|
|
||||||
if value.Commands[index].ProtectedRequest != nil {
|
|
||||||
copy := *value.Commands[index].ProtectedRequest
|
|
||||||
value.Commands[index].ProtectedRequest = ©
|
|
||||||
}
|
|
||||||
}
|
|
||||||
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
||||||
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
||||||
for index := range value.QueryTemplates {
|
for index := range value.QueryTemplates {
|
||||||
@@ -529,7 +487,7 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
|
|||||||
for index := range value.DataPacks {
|
for index := range value.DataPacks {
|
||||||
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
|
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
|
||||||
value.DataPacks[index].ConfigMapRefs = CopyStringSlice(value.DataPacks[index].ConfigMapRefs)
|
value.DataPacks[index].ConfigMapRefs = CopyStringSlice(value.DataPacks[index].ConfigMapRefs)
|
||||||
value.DataPacks[index].DataRefs = CopyStringSlice(value.DataPacks[index].DataRefs)
|
value.DataPacks[index].DataRefs = CopyStringSlice(value.DataPacks[index].DataRefs)
|
||||||
}
|
}
|
||||||
value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...)
|
value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...)
|
||||||
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
|
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T)
|
|||||||
Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
||||||
Presence: &GameClientBridgeLogProjectionPresenceDeclaration{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}},
|
Presence: &GameClientBridgeLogProjectionPresenceDeclaration{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}},
|
||||||
}},
|
}},
|
||||||
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}},
|
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}},
|
||||||
OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}},
|
OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}},
|
||||||
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}},
|
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}},
|
||||||
}
|
}
|
||||||
@@ -21,11 +21,11 @@ func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T)
|
|||||||
manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
||||||
manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated"
|
manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated"
|
||||||
manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated"
|
manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated"
|
||||||
manifestCopy.DataPacks[0].DataRefs[0] = "mutated"
|
manifestCopy.DataPacks[0].DataRefs[0] = "mutated"
|
||||||
manifestCopy.OperationTemplates[0].Key = "mutated"
|
manifestCopy.OperationTemplates[0].Key = "mutated"
|
||||||
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
|
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||||
manifestCopy.Pages[0].OperationKeys[0] = "mutated"
|
manifestCopy.Pages[0].OperationKeys[0] = "mutated"
|
||||||
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" {
|
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" {
|
||||||
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
|
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -264,32 +264,6 @@ type SourceRCONExecutionInput struct {
|
|||||||
Command string
|
Command string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProtectedRequestExecutionInput is returned exactly once to the active,
|
|
||||||
// fenced Run lease. RequestText is never persisted in a job or bridge command.
|
|
||||||
type ProtectedRequestExecutionInputRequest struct {
|
|
||||||
RunEndpointID string
|
|
||||||
SessionToken string
|
|
||||||
JobID string
|
|
||||||
LeaseToken string
|
|
||||||
Attempt int
|
|
||||||
FencingToken uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProtectedRequestExecutionInput struct {
|
|
||||||
JobID string
|
|
||||||
ServerInstanceID string
|
|
||||||
RunEndpointID string
|
|
||||||
FencingToken uint64
|
|
||||||
Authorized bool
|
|
||||||
ApprovalState string
|
|
||||||
QueueState string
|
|
||||||
ExpiresAt time.Time
|
|
||||||
Kind string
|
|
||||||
TransportKey string
|
|
||||||
TargetKey string
|
|
||||||
RequestText string
|
|
||||||
}
|
|
||||||
|
|
||||||
type RunUpdateInputRequest struct {
|
type RunUpdateInputRequest struct {
|
||||||
RunEndpointID string
|
RunEndpointID string
|
||||||
SessionToken string
|
SessionToken string
|
||||||
@@ -510,10 +484,6 @@ func CopySourceRCONExecutionInput(input SourceRCONExecutionInput) SourceRCONExec
|
|||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyProtectedRequestExecutionInput(input ProtectedRequestExecutionInput) ProtectedRequestExecutionInput {
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyRunUpdateChunk(chunk RunUpdateChunk) RunUpdateChunk {
|
func CopyRunUpdateChunk(chunk RunUpdateChunk) RunUpdateChunk {
|
||||||
chunk.Payload = append([]byte(nil), chunk.Payload...)
|
chunk.Payload = append([]byte(nil), chunk.Payload...)
|
||||||
return chunk
|
return chunk
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ type RemoteAdapterResult struct {
|
|||||||
Retryable bool
|
Retryable bool
|
||||||
Message string
|
Message string
|
||||||
ResultRef string
|
ResultRef string
|
||||||
AuditEventID string
|
|
||||||
CompletedAt time.Time
|
CompletedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,146 +2,6 @@ package domain
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type CapacityAdmissionState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CapacityAdmissionAccepted CapacityAdmissionState = "accepted"
|
|
||||||
CapacityAdmissionDeferred CapacityAdmissionState = "deferred"
|
|
||||||
CapacityAdmissionDenied CapacityAdmissionState = "denied"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CapacityPressureCode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CapacityPressureEndpointOffline CapacityPressureCode = "endpoint.offline"
|
|
||||||
CapacityPressureEndpointStale CapacityPressureCode = "endpoint.stale"
|
|
||||||
CapacityPressureCapabilityGap CapacityPressureCode = "capability.missing"
|
|
||||||
CapacityPressureJobLimit CapacityPressureCode = "job.limit"
|
|
||||||
CapacityPressureQueueLimit CapacityPressureCode = "queue.limit"
|
|
||||||
CapacityPressureBacklog CapacityPressureCode = "spool.backlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CapacityAdmissionRequest struct {
|
|
||||||
ServerInstanceID string
|
|
||||||
RunEndpointID string
|
|
||||||
Capability string
|
|
||||||
TargetKey string
|
|
||||||
IdempotencyKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CapacityAdmissionDecision struct {
|
|
||||||
Accepted bool
|
|
||||||
State CapacityAdmissionState
|
|
||||||
Reason string
|
|
||||||
RetryAfterSeconds int
|
|
||||||
ServerInstanceID string
|
|
||||||
RunEndpointID string
|
|
||||||
Capability string
|
|
||||||
TargetKey string
|
|
||||||
MaxJobs int
|
|
||||||
RunningJobs int
|
|
||||||
QueuedJobs int
|
|
||||||
PressureCodes []CapacityPressureCode
|
|
||||||
CheckedAt time.Time
|
|
||||||
AlertID string
|
|
||||||
AuditEventID string
|
|
||||||
}
|
|
||||||
|
|
||||||
type EndpointCapacityProjection struct {
|
|
||||||
RunEndpointID string
|
|
||||||
DisplayName string
|
|
||||||
Status RunEndpointStatus
|
|
||||||
Capabilities []string
|
|
||||||
MaxJobs int
|
|
||||||
RunningJobs int
|
|
||||||
QueuedJobs int
|
|
||||||
LogBacklogBatches int
|
|
||||||
ArtifactBacklogChunks int
|
|
||||||
PressureCodes []CapacityPressureCode
|
|
||||||
Summary string
|
|
||||||
LastHeartbeatAt time.Time
|
|
||||||
LastAdmissionDecision CapacityAdmissionState
|
|
||||||
LastAdmissionReason string
|
|
||||||
LastAdmissionCheckedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProductionCapacitySummary struct {
|
|
||||||
Endpoints []EndpointCapacityProjection
|
|
||||||
TotalMaxJobs int
|
|
||||||
TotalRunningJobs int
|
|
||||||
TotalQueuedJobs int
|
|
||||||
ActiveAlerts int
|
|
||||||
GeneratedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertSeverity string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AlertSeverityInfo AlertSeverity = "info"
|
|
||||||
AlertSeverityWarning AlertSeverity = "warning"
|
|
||||||
AlertSeverityCritical AlertSeverity = "critical"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AlertStateActive AlertState = "active"
|
|
||||||
AlertStateAcknowledged AlertState = "acknowledged"
|
|
||||||
AlertStateResolved AlertState = "resolved"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertRecord struct {
|
|
||||||
ID string
|
|
||||||
SourceKind string
|
|
||||||
SourceID string
|
|
||||||
RuleKey string
|
|
||||||
Severity AlertSeverity
|
|
||||||
State AlertState
|
|
||||||
Title string
|
|
||||||
Message string
|
|
||||||
OccurrenceCount int
|
|
||||||
Retryable bool
|
|
||||||
RetryAfterSeconds int
|
|
||||||
LastJobID string
|
|
||||||
LastAuditEventID string
|
|
||||||
LastSeenAt time.Time
|
|
||||||
AcknowledgedBy string
|
|
||||||
AcknowledgedAt time.Time
|
|
||||||
ResolvedBy string
|
|
||||||
ResolvedAt time.Time
|
|
||||||
ResolutionNote string
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertFilter struct {
|
|
||||||
State AlertState
|
|
||||||
SourceKind string
|
|
||||||
SourceID string
|
|
||||||
Severity AlertSeverity
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertAcknowledgeRequest struct {
|
|
||||||
AlertID string
|
|
||||||
Note string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertResolveRequest struct {
|
|
||||||
AlertID string
|
|
||||||
Note string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertRetryRequest struct {
|
|
||||||
AlertID string
|
|
||||||
IdempotencyKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertRetryResult struct {
|
|
||||||
Alert AlertRecord
|
|
||||||
Decision CapacityAdmissionDecision
|
|
||||||
Status string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PluginLifecycleState string
|
type PluginLifecycleState string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -180,8 +40,6 @@ type PluginLifecycleInstallation struct {
|
|||||||
Compatibility string
|
Compatibility string
|
||||||
DependencyState DependencyState
|
DependencyState DependencyState
|
||||||
JobID string
|
JobID string
|
||||||
AlertID string
|
|
||||||
AuditEventID string
|
|
||||||
FailureReason string
|
FailureReason string
|
||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -206,8 +64,6 @@ type PluginLifecycleRequest struct {
|
|||||||
type PluginLifecycleResult struct {
|
type PluginLifecycleResult struct {
|
||||||
Installation PluginLifecycleInstallation
|
Installation PluginLifecycleInstallation
|
||||||
Job Job
|
Job Job
|
||||||
Decision CapacityAdmissionDecision
|
|
||||||
Alert *AlertRecord
|
|
||||||
Status string
|
Status string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,53 +117,6 @@ type AIConfigDiffApprovalResult struct {
|
|||||||
Dispatch ServerConfigWriteDispatch
|
Dispatch ServerConfigWriteDispatch
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyCapacityAdmissionDecision(decision CapacityAdmissionDecision) CapacityAdmissionDecision {
|
|
||||||
decision.PressureCodes = CopyCapacityPressureCodes(decision.PressureCodes)
|
|
||||||
return decision
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyEndpointCapacityProjection(projection EndpointCapacityProjection) EndpointCapacityProjection {
|
|
||||||
projection.Capabilities = CopyStringSlice(projection.Capabilities)
|
|
||||||
projection.PressureCodes = CopyCapacityPressureCodes(projection.PressureCodes)
|
|
||||||
return projection
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyProductionCapacitySummary(summary ProductionCapacitySummary) ProductionCapacitySummary {
|
|
||||||
if summary.Endpoints != nil {
|
|
||||||
summary.Endpoints = append([]EndpointCapacityProjection(nil), summary.Endpoints...)
|
|
||||||
for i := range summary.Endpoints {
|
|
||||||
summary.Endpoints[i] = CopyEndpointCapacityProjection(summary.Endpoints[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyCapacityPressureCodes(codes []CapacityPressureCode) []CapacityPressureCode {
|
|
||||||
if codes == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]CapacityPressureCode, len(codes))
|
|
||||||
copy(out, codes)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyAlertRecord(alert AlertRecord) AlertRecord { return alert }
|
|
||||||
|
|
||||||
func CopyAlertRecords(alerts []AlertRecord) []AlertRecord {
|
|
||||||
if alerts == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]AlertRecord, len(alerts))
|
|
||||||
copy(out, alerts)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyAlertRetryResult(result AlertRetryResult) AlertRetryResult {
|
|
||||||
result.Alert = CopyAlertRecord(result.Alert)
|
|
||||||
result.Decision = CopyCapacityAdmissionDecision(result.Decision)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyPluginLifecycleInstallation(installation PluginLifecycleInstallation) PluginLifecycleInstallation {
|
func CopyPluginLifecycleInstallation(installation PluginLifecycleInstallation) PluginLifecycleInstallation {
|
||||||
return installation
|
return installation
|
||||||
}
|
}
|
||||||
@@ -324,11 +133,6 @@ func CopyPluginLifecycleInstallations(installations []PluginLifecycleInstallatio
|
|||||||
func CopyPluginLifecycleResult(result PluginLifecycleResult) PluginLifecycleResult {
|
func CopyPluginLifecycleResult(result PluginLifecycleResult) PluginLifecycleResult {
|
||||||
result.Installation = CopyPluginLifecycleInstallation(result.Installation)
|
result.Installation = CopyPluginLifecycleInstallation(result.Installation)
|
||||||
result.Job = CopyJob(result.Job)
|
result.Job = CopyJob(result.Job)
|
||||||
result.Decision = CopyCapacityAdmissionDecision(result.Decision)
|
|
||||||
if result.Alert != nil {
|
|
||||||
alert := CopyAlertRecord(*result.Alert)
|
|
||||||
result.Alert = &alert
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,15 +187,6 @@ const (
|
|||||||
LogStorageBackendElasticsearch LogStorageBackend = "elasticsearch"
|
LogStorageBackendElasticsearch LogStorageBackend = "elasticsearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuditResult string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AuditResultSuccess AuditResult = "success"
|
|
||||||
AuditResultDenied AuditResult = "denied"
|
|
||||||
AuditResultFailed AuditResult = "failed"
|
|
||||||
AuditResultQueued AuditResult = "queued"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string
|
ID string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
@@ -1078,7 +1069,6 @@ const (
|
|||||||
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||||
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||||
JobCapabilityRemoteRunProtectedSQL = "remote.run.protected.sql"
|
JobCapabilityRemoteRunProtectedSQL = "remote.run.protected.sql"
|
||||||
JobCapabilityRemoteRunProtectedRCON = "remote.run.protected.rcon"
|
|
||||||
JobCapabilityRemoteRunProgram = "remote.run.program.command"
|
JobCapabilityRemoteRunProgram = "remote.run.program.command"
|
||||||
JobCapabilityRunSelfUpdate = "run.self-update"
|
JobCapabilityRunSelfUpdate = "run.self-update"
|
||||||
JobCapabilityDistributionBuild = "distribution.build"
|
JobCapabilityDistributionBuild = "distribution.build"
|
||||||
@@ -1176,7 +1166,7 @@ type JobExecutionResult struct {
|
|||||||
Version int
|
Version int
|
||||||
Checksum string
|
Checksum string
|
||||||
SizeBytes int64
|
SizeBytes int64
|
||||||
AuditSummary string
|
Summary string
|
||||||
Content string
|
Content string
|
||||||
ServerDeploymentEvidence *ServerDeploymentEvidence
|
ServerDeploymentEvidence *ServerDeploymentEvidence
|
||||||
DeploymentReceipt *ServerDeploymentExecutionReceipt
|
DeploymentReceipt *ServerDeploymentExecutionReceipt
|
||||||
@@ -1515,17 +1505,6 @@ type LogStream struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditEvent struct {
|
|
||||||
ID string
|
|
||||||
ActorID string
|
|
||||||
Action string
|
|
||||||
ResourceKind string
|
|
||||||
ResourceID string
|
|
||||||
Result AuditResult
|
|
||||||
Summary string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserFilter struct {
|
type UserFilter struct {
|
||||||
Status UserStatus
|
Status UserStatus
|
||||||
}
|
}
|
||||||
@@ -1626,13 +1605,6 @@ type LogStreamFilter struct {
|
|||||||
StreamKey string
|
StreamKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditEventFilter struct {
|
|
||||||
ActorID string
|
|
||||||
ResourceKind string
|
|
||||||
ResourceID string
|
|
||||||
Result AuditResult
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyStringSlice(values []string) []string {
|
func CopyStringSlice(values []string) []string {
|
||||||
if values == nil {
|
if values == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -2145,7 +2117,3 @@ func CopyLogBackfillRequest(request LogBackfillRequest) LogBackfillRequest {
|
|||||||
func CopyLogStream(stream LogStream) LogStream {
|
func CopyLogStream(stream LogStream) LogStream {
|
||||||
return stream
|
return stream
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyAuditEvent(event AuditEvent) AuditEvent {
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This file defines the first platform resource contracts. Concrete Go domain stru
|
|||||||
|
|
||||||
## Implemented Boundaries
|
## Implemented Boundaries
|
||||||
|
|
||||||
- Domain constants centralize allowed status, state, provider kind, relay mode, artifact owner, storage backend, and audit result values.
|
- Domain constants centralize allowed status, state, provider kind, relay mode, artifact owner, storage backend, and result summary values.
|
||||||
- DTO responses expose AI-provider secret presence only (`apiKeyConfigured`), never the stored reference or raw key material.
|
- DTO responses expose AI-provider secret presence only (`apiKeyConfigured`), never the stored reference or raw key material.
|
||||||
- Model structs include JSON/database tags and explicit `TableName()` mappings for future persistence work.
|
- Model structs include JSON/database tags and explicit `TableName()` mappings for future persistence work.
|
||||||
- `platform/repo.NewFileStore` provides durable local metadata snapshots for platform startup, while `platform/repo.NewMemoryStore` provides deterministic in-memory repository behavior for unit tests and disposable local runs.
|
- `platform/repo.NewFileStore` provides durable local metadata snapshots for platform startup, while `platform/repo.NewMemoryStore` provides deterministic in-memory repository behavior for unit tests and disposable local runs.
|
||||||
@@ -193,16 +193,6 @@ Failed or cancelled lifecycle jobs project the server instance to `failed`. Acti
|
|||||||
- `storageBackend`: `local-segments`, `loki`, `clickhouse`, `opensearch`, or `elasticsearch`.
|
- `storageBackend`: `local-segments`, `loki`, `clickhouse`, `opensearch`, or `elasticsearch`.
|
||||||
- `retentionPolicy`: retention key.
|
- `retentionPolicy`: retention key.
|
||||||
|
|
||||||
## AuditEvent
|
|
||||||
|
|
||||||
- `id`: audit event ID.
|
|
||||||
- `actorId`: user or system actor.
|
|
||||||
- `action`: stable action key.
|
|
||||||
- `resourceKind`: resource kind.
|
|
||||||
- `resourceId`: resource ID.
|
|
||||||
- `result`: `success`, `denied`, `failed`, or `queued`.
|
|
||||||
- `summary`: bounded redacted summary.
|
|
||||||
- `createdAt`: event time.
|
|
||||||
# Client Manager lifecycle aggregates
|
# Client Manager lifecycle aggregates
|
||||||
|
|
||||||
`ClientManagerInstallation` owns desired/active/previous artifact and version metadata, target and key/deployment generations, the current job, logical health/last-seen projection, retry/fencing flags, and uninstall history. Valid statuses are `requested`, `building`, `available`, `deploying`, `installed`, `registering`, `online`, `degraded`, `offline`, `updating`, `rolling_back`, `stopping`, `failed`, and `uninstalled`. `ClientManagerSession` is a separate short-lived component identity bound to installation, endpoint, artifact, key generation, and deployment generation; it is not a Run session or lease.
|
`ClientManagerInstallation` owns desired/active/previous artifact and version metadata, target and key/deployment generations, the current job, logical health/last-seen projection, retry/fencing flags, and uninstall history. Valid statuses are `requested`, `building`, `available`, `deploying`, `installed`, `registering`, `online`, `degraded`, `offline`, `updating`, `rolling_back`, `stopping`, `failed`, and `uninstalled`. `ClientManagerSession` is a separate short-lived component identity bound to installation, endpoint, artifact, key generation, and deployment generation; it is not a Run session or lease.
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func (request RunLifecycleReportRequest) ToDomain() domain.RunLifecycleReport {
|
|||||||
ManagedProcessID: request.ManagedProcessID,
|
ManagedProcessID: request.ManagedProcessID,
|
||||||
ObservationSeq: request.ObservationSeq,
|
ObservationSeq: request.ObservationSeq,
|
||||||
ObservedAt: request.ObservedAt,
|
ObservedAt: request.ObservedAt,
|
||||||
ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)},
|
ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, Summary: request.ExecutionResult.Summary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ type GameClientBridgeCommandResponse struct {
|
|||||||
ResultSummary string `json:"resultSummary,omitempty"`
|
ResultSummary string `json:"resultSummary,omitempty"`
|
||||||
Result *GameClientBridgeCommandResultResponse `json:"result,omitempty"`
|
Result *GameClientBridgeCommandResultResponse `json:"result,omitempty"`
|
||||||
Cancellation *GameClientBridgeCommandCancellationResponse `json:"cancellation,omitempty"`
|
Cancellation *GameClientBridgeCommandCancellationResponse `json:"cancellation,omitempty"`
|
||||||
AuditReferences []string `json:"auditReferences,omitempty"`
|
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
@@ -111,7 +110,6 @@ type GameClientBridgeSnapshotResponse struct {
|
|||||||
ObservedAt time.Time `json:"observedAt"`
|
ObservedAt time.Time `json:"observedAt"`
|
||||||
Payload map[string]any `json:"payload"`
|
Payload map[string]any `json:"payload"`
|
||||||
Retention GameClientBridgeRetentionResponse `json:"retention"`
|
Retention GameClientBridgeRetentionResponse `json:"retention"`
|
||||||
AuditReferences []string `json:"auditReferences,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
}
|
}
|
||||||
@@ -163,11 +161,10 @@ type GameClientBridgeResultResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeCancelResponse struct {
|
type GameClientBridgeCancelResponse struct {
|
||||||
CommandID string `json:"commandId"`
|
CommandID string `json:"commandId"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Cancellation GameClientBridgeCommandCancellationResponse `json:"cancellation"`
|
Cancellation GameClientBridgeCommandCancellationResponse `json:"cancellation"`
|
||||||
AuditReferences []string `json:"auditReferences,omitempty"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeSnapshotIngestResponse struct {
|
type GameClientBridgeSnapshotIngestResponse struct {
|
||||||
@@ -181,19 +178,6 @@ type GameClientBridgeSnapshotIngestResponse struct {
|
|||||||
ExpiresAt time.Time `json:"expiresAt"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeAuditReferenceResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CommandID string `json:"commandId,omitempty"`
|
|
||||||
SnapshotID string `json:"snapshotId,omitempty"`
|
|
||||||
AuditEventID string `json:"auditEventId"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameClientBridgeAuditReferenceListResponse struct {
|
|
||||||
Items []GameClientBridgeAuditReferenceResponse `json:"items"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameClientBridgeProfileDeclarationResponse struct {
|
type GameClientBridgeProfileDeclarationResponse struct {
|
||||||
PluginID string `json:"pluginId"`
|
PluginID string `json:"pluginId"`
|
||||||
ProfileKey string `json:"profileKey"`
|
ProfileKey string `json:"profileKey"`
|
||||||
@@ -263,7 +247,6 @@ func GameClientBridgeCommandFromDomain(value domain.GameClientBridgeCommand) Gam
|
|||||||
ApprovalState: string(value.ApprovalState),
|
ApprovalState: string(value.ApprovalState),
|
||||||
RequesterID: value.RequesterID,
|
RequesterID: value.RequesterID,
|
||||||
ResultSummary: value.Result.Summary,
|
ResultSummary: value.Result.Summary,
|
||||||
AuditReferences: copyStrings(value.AuditReferences),
|
|
||||||
ExpiresAt: value.ExpiresAt,
|
ExpiresAt: value.ExpiresAt,
|
||||||
CreatedAt: value.CreatedAt,
|
CreatedAt: value.CreatedAt,
|
||||||
UpdatedAt: value.UpdatedAt,
|
UpdatedAt: value.UpdatedAt,
|
||||||
@@ -294,7 +277,6 @@ func GameClientBridgeSnapshotFromDomain(value domain.GameClientBridgeSnapshot) G
|
|||||||
ObservedAt: value.ObservedAt,
|
ObservedAt: value.ObservedAt,
|
||||||
Payload: value.Payload,
|
Payload: value.Payload,
|
||||||
Retention: gameClientBridgeRetentionFromDomain(value.Retention),
|
Retention: gameClientBridgeRetentionFromDomain(value.Retention),
|
||||||
AuditReferences: copyStrings(value.AuditReferences),
|
|
||||||
CreatedAt: value.CreatedAt,
|
CreatedAt: value.CreatedAt,
|
||||||
ExpiresAt: value.ExpiresAt,
|
ExpiresAt: value.ExpiresAt,
|
||||||
}
|
}
|
||||||
@@ -344,25 +326,13 @@ func GameClientBridgeResultFromDomain(value domain.GameClientBridgeCommand) Game
|
|||||||
|
|
||||||
func GameClientBridgeCancelFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeCancelResponse {
|
func GameClientBridgeCancelFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeCancelResponse {
|
||||||
value = domain.CopyGameClientBridgeCommand(value)
|
value = domain.CopyGameClientBridgeCommand(value)
|
||||||
return GameClientBridgeCancelResponse{CommandID: value.ID, State: string(value.State), Cancellation: gameClientBridgeCommandCancellationFromDomain(value.Cancellation), AuditReferences: copyStrings(value.AuditReferences), UpdatedAt: value.UpdatedAt}
|
return GameClientBridgeCancelResponse{CommandID: value.ID, State: string(value.State), Cancellation: gameClientBridgeCommandCancellationFromDomain(value.Cancellation), UpdatedAt: value.UpdatedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GameClientBridgeSnapshotIngestFromDomain(value domain.GameClientBridgeSnapshot) GameClientBridgeSnapshotIngestResponse {
|
func GameClientBridgeSnapshotIngestFromDomain(value domain.GameClientBridgeSnapshot) GameClientBridgeSnapshotIngestResponse {
|
||||||
return GameClientBridgeSnapshotIngestResponse{SnapshotID: value.ID, ProfileKey: value.ProfileKey, Type: value.Type, SchemaVersion: value.SchemaVersion, StreamKey: value.StreamKey, Sequence: value.Sequence, AcceptedAt: value.CreatedAt, ExpiresAt: value.ExpiresAt}
|
return GameClientBridgeSnapshotIngestResponse{SnapshotID: value.ID, ProfileKey: value.ProfileKey, Type: value.Type, SchemaVersion: value.SchemaVersion, StreamKey: value.StreamKey, Sequence: value.Sequence, AcceptedAt: value.CreatedAt, ExpiresAt: value.ExpiresAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GameClientBridgeAuditReferenceFromDomain(value domain.GameClientBridgeAuditReference) GameClientBridgeAuditReferenceResponse {
|
|
||||||
return GameClientBridgeAuditReferenceResponse{ID: value.ID, CommandID: value.CommandID, SnapshotID: value.SnapshotID, AuditEventID: value.AuditEventID, CreatedAt: value.CreatedAt}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GameClientBridgeAuditReferencesFromDomain(values []domain.GameClientBridgeAuditReference) GameClientBridgeAuditReferenceListResponse {
|
|
||||||
items := make([]GameClientBridgeAuditReferenceResponse, len(values))
|
|
||||||
for index, value := range values {
|
|
||||||
items[index] = GameClientBridgeAuditReferenceFromDomain(value)
|
|
||||||
}
|
|
||||||
return GameClientBridgeAuditReferenceListResponse{Items: items, Count: len(items)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GameClientBridgeStatusFromDomain(value domain.GameClientBridgeStatus) GameClientBridgeStatusResponse {
|
func GameClientBridgeStatusFromDomain(value domain.GameClientBridgeStatus) GameClientBridgeStatusResponse {
|
||||||
value = domain.CopyGameClientBridgeStatus(value)
|
value = domain.CopyGameClientBridgeStatus(value)
|
||||||
profiles := make([]GameClientBridgeProfileDeclarationResponse, len(value.Profiles))
|
profiles := make([]GameClientBridgeProfileDeclarationResponse, len(value.Profiles))
|
||||||
|
|||||||
@@ -33,29 +33,27 @@ func TestGameClientBridgeRequestConversionsInjectScopeAndCopyPayloads(t *testing
|
|||||||
func TestGameClientBridgeBrowserProjectionsAreCompleteAndOmitInternalData(t *testing.T) {
|
func TestGameClientBridgeBrowserProjectionsAreCompleteAndOmitInternalData(t *testing.T) {
|
||||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||||
command := domain.GameClientBridgeCommand{
|
command := domain.GameClientBridgeCommand{
|
||||||
ID: "command-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "internal command payload"}, IdempotencyKey: "internal-idempotency", Priority: 9,
|
ID: "command-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "internal command payload"}, IdempotencyKey: "internal-idempotency", Priority: 9,
|
||||||
State: domain.GameClientBridgeCommandCancelled, ApprovalState: domain.GameClientBridgeApprovalApproved, RequesterID: "operator-1",
|
State: domain.GameClientBridgeCommandCancelled, ApprovalState: domain.GameClientBridgeApprovalApproved, RequesterID: "operator-1",
|
||||||
Claim: domain.GameClientBridgeClaim{SessionID: "internal-session-secret", InstallationID: "internal-installation", DeploymentGeneration: 9, FencingToken: 42, LeaseExpiresAt: now.Add(time.Minute)},
|
Claim: domain.GameClientBridgeClaim{SessionID: "internal-session-secret", InstallationID: "internal-installation", DeploymentGeneration: 9, FencingToken: 42, LeaseExpiresAt: now.Add(time.Minute)},
|
||||||
Result: domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled safely", Payload: map[string]any{"code": "cancelled"}, CompletedBy: "internal-completing-session", CompletedAt: now.Add(3 * time.Minute)},
|
Result: domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled safely", Payload: map[string]any{"code": "cancelled"}, CompletedBy: "internal-completing-session", CompletedAt: now.Add(3 * time.Minute)},
|
||||||
Cancellation: domain.GameClientBridgeCancellation{RequestedBy: "operator-2", Reason: "operator request", CancelledAt: now.Add(2 * time.Minute)},
|
Cancellation: domain.GameClientBridgeCancellation{RequestedBy: "operator-2", Reason: "operator request", CancelledAt: now.Add(2 * time.Minute)},
|
||||||
AuditReferences: []string{"audit-1"}, ExpiresAt: now.Add(10 * time.Minute), CreatedAt: now, UpdatedAt: now.Add(3 * time.Minute), CompletedAt: now.Add(3 * time.Minute),
|
ExpiresAt: now.Add(10 * time.Minute), CreatedAt: now, UpdatedAt: now.Add(3 * time.Minute), CompletedAt: now.Add(3 * time.Minute),
|
||||||
}
|
}
|
||||||
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 2, SourceSessionID: "internal-source-session", ObservedAt: now, Payload: map[string]any{"players": []any{map[string]any{"name": "Alice"}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 10}, AuditReferences: []string{"audit-2"}, CreatedAt: now.Add(time.Second), ExpiresAt: now.Add(time.Hour)}
|
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 2, SourceSessionID: "internal-source-session", ObservedAt: now, Payload: map[string]any{"players": []any{map[string]any{"name": "Alice"}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 10}, CreatedAt: now.Add(time.Second), ExpiresAt: now.Add(time.Hour)}
|
||||||
|
|
||||||
commandProjection := GameClientBridgeCommandFromDomain(command)
|
commandProjection := GameClientBridgeCommandFromDomain(command)
|
||||||
snapshotProjection := GameClientBridgeSnapshotFromDomain(snapshot)
|
snapshotProjection := GameClientBridgeSnapshotFromDomain(snapshot)
|
||||||
if commandProjection.Result == nil || commandProjection.Result.Status != "cancelled" || commandProjection.Cancellation == nil || commandProjection.Cancellation.Reason != "operator request" || commandProjection.Priority != 9 || commandProjection.AuditReferences[0] != "audit-1" || !commandProjection.CompletedAt.Equal(command.CompletedAt) {
|
if commandProjection.Result == nil || commandProjection.Result.Status != "cancelled" || commandProjection.Cancellation == nil || commandProjection.Cancellation.Reason != "operator request" || commandProjection.Priority != 9 || !commandProjection.CompletedAt.Equal(command.CompletedAt) {
|
||||||
t.Fatalf("incomplete command projection: %#v", commandProjection)
|
t.Fatalf("incomplete command projection: %#v", commandProjection)
|
||||||
}
|
}
|
||||||
if snapshotProjection.ProfileKey != "scum-client" || snapshotProjection.Retention.KeepForSeconds != 3600 || snapshotProjection.AuditReferences[0] != "audit-2" || !snapshotProjection.CreatedAt.Equal(snapshot.CreatedAt) {
|
if snapshotProjection.ProfileKey != "scum-client" || snapshotProjection.Retention.KeepForSeconds != 3600 || !snapshotProjection.CreatedAt.Equal(snapshot.CreatedAt) {
|
||||||
t.Fatalf("incomplete snapshot projection: %#v", snapshotProjection)
|
t.Fatalf("incomplete snapshot projection: %#v", snapshotProjection)
|
||||||
}
|
}
|
||||||
|
|
||||||
commandProjection.Result.Payload["code"] = "changed"
|
commandProjection.Result.Payload["code"] = "changed"
|
||||||
commandProjection.AuditReferences[0] = "changed"
|
|
||||||
snapshotProjection.Payload["players"].([]any)[0].(map[string]any)["name"] = "changed"
|
snapshotProjection.Payload["players"].([]any)[0].(map[string]any)["name"] = "changed"
|
||||||
snapshotProjection.AuditReferences[0] = "changed"
|
if command.Result.Payload["code"] != "cancelled" || snapshot.Payload["players"].([]any)[0].(map[string]any)["name"] != "Alice" {
|
||||||
if command.Result.Payload["code"] != "cancelled" || command.AuditReferences[0] != "audit-1" || snapshot.Payload["players"].([]any)[0].(map[string]any)["name"] != "Alice" || snapshot.AuditReferences[0] != "audit-2" {
|
|
||||||
t.Fatal("browser projection aliases domain data")
|
t.Fatal("browser projection aliases domain data")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +78,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) {
|
|||||||
ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, Priority: 3, State: domain.GameClientBridgeCommandSucceeded,
|
ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, Priority: 3, State: domain.GameClientBridgeCommandSucceeded,
|
||||||
Claim: domain.GameClientBridgeClaim{SessionID: "private-session", FencingToken: 7, ClaimedAt: now, AcknowledgedAt: now.Add(time.Second), LeaseExpiresAt: now.Add(time.Minute)},
|
Claim: domain.GameClientBridgeClaim{SessionID: "private-session", FencingToken: 7, ClaimedAt: now, AcknowledgedAt: now.Add(time.Second), LeaseExpiresAt: now.Add(time.Minute)},
|
||||||
Result: domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultSucceeded, Summary: "ok", Payload: map[string]any{"healthy": true}, CompletedBy: "private-session", CompletedAt: now.Add(2 * time.Second)},
|
Result: domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultSucceeded, Summary: "ok", Payload: map[string]any{"healthy": true}, CompletedBy: "private-session", CompletedAt: now.Add(2 * time.Second)},
|
||||||
Cancellation: domain.GameClientBridgeCancellation{RequestedBy: "operator", Reason: "superseded", CancelledAt: now.Add(3 * time.Second)}, AuditReferences: []string{"audit-1"}, ExpiresAt: now.Add(5 * time.Minute), UpdatedAt: now.Add(2 * time.Second), CompletedAt: now.Add(2 * time.Second),
|
Cancellation: domain.GameClientBridgeCancellation{RequestedBy: "operator", Reason: "superseded", CancelledAt: now.Add(3 * time.Second)}, ExpiresAt: now.Add(5 * time.Minute), UpdatedAt: now.Add(2 * time.Second), CompletedAt: now.Add(2 * time.Second),
|
||||||
}
|
}
|
||||||
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 4, CreatedAt: now, ExpiresAt: now.Add(time.Hour)}
|
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 4, CreatedAt: now, ExpiresAt: now.Add(time.Hour)}
|
||||||
|
|
||||||
@@ -91,8 +89,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) {
|
|||||||
ingest := GameClientBridgeSnapshotIngestFromDomain(snapshot)
|
ingest := GameClientBridgeSnapshotIngestFromDomain(snapshot)
|
||||||
commands := GameClientBridgeCommandsFromDomain([]domain.GameClientBridgeCommand{command})
|
commands := GameClientBridgeCommandsFromDomain([]domain.GameClientBridgeCommand{command})
|
||||||
snapshots := GameClientBridgeSnapshotsFromDomain([]domain.GameClientBridgeSnapshot{snapshot})
|
snapshots := GameClientBridgeSnapshotsFromDomain([]domain.GameClientBridgeSnapshot{snapshot})
|
||||||
audits := GameClientBridgeAuditReferencesFromDomain([]domain.GameClientBridgeAuditReference{{ID: "reference-1", CommandID: "command-1", AuditEventID: "audit-1", CreatedAt: now}})
|
if claim.Count != 1 || claim.Items[0].FencingToken != 7 || claim.Items[0].ProfileKey != "scum-client" || ack.CommandID != "command-1" || !ack.AcknowledgedAt.Equal(now.Add(time.Second)) || result.Result.Summary != "ok" || cancel.Cancellation.Reason != "superseded" || ingest.SnapshotID != "snapshot-1" || commands.Count != 1 || snapshots.Count != 1 {
|
||||||
if claim.Count != 1 || claim.Items[0].FencingToken != 7 || claim.Items[0].ProfileKey != "scum-client" || ack.CommandID != "command-1" || !ack.AcknowledgedAt.Equal(now.Add(time.Second)) || result.Result.Summary != "ok" || cancel.Cancellation.Reason != "superseded" || ingest.SnapshotID != "snapshot-1" || commands.Count != 1 || snapshots.Count != 1 || audits.Count != 1 {
|
|
||||||
t.Fatalf("unexpected responses: claim=%#v ack=%#v result=%#v cancel=%#v ingest=%#v", claim, ack, result, cancel, ingest)
|
t.Fatalf("unexpected responses: claim=%#v ack=%#v result=%#v cancel=%#v ingest=%#v", claim, ack, result, cancel, ingest)
|
||||||
}
|
}
|
||||||
claim.Items[0].Payload["scope"] = "changed"
|
claim.Items[0].Payload["scope"] = "changed"
|
||||||
@@ -100,7 +97,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) {
|
|||||||
if command.Payload["scope"] != "health" || command.Result.Payload["healthy"] != true {
|
if command.Payload["scope"] != "health" || command.Result.Payload["healthy"] != true {
|
||||||
t.Fatal("companion response aliases domain payload")
|
t.Fatal("companion response aliases domain payload")
|
||||||
}
|
}
|
||||||
for name, value := range map[string]any{"claim": claim, "ack": ack, "result": result, "cancel": cancel, "ingest": ingest, "commands": commands, "snapshots": snapshots, "audits": audits} {
|
for name, value := range map[string]any{"claim": claim, "ack": ack, "result": result, "cancel": cancel, "ingest": ingest, "commands": commands, "snapshots": snapshots} {
|
||||||
encoded, err := json.Marshal(value)
|
encoded, err := json.Marshal(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal %s response: %v", name, err)
|
t.Fatalf("marshal %s response: %v", name, err)
|
||||||
@@ -110,7 +107,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if GameClientBridgeCommandsFromDomain(nil).Items == nil || GameClientBridgeSnapshotsFromDomain(nil).Items == nil || GameClientBridgeClaimBatchFromDomain(nil).Items == nil || GameClientBridgeAuditReferencesFromDomain(nil).Items == nil {
|
if GameClientBridgeCommandsFromDomain(nil).Items == nil || GameClientBridgeSnapshotsFromDomain(nil).Items == nil || GameClientBridgeClaimBatchFromDomain(nil).Items == nil {
|
||||||
t.Fatal("list converters must serialize empty items as [] instead of null")
|
t.Fatal("list converters must serialize empty items as [] instead of null")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ type RunJobExecutionResultBody struct {
|
|||||||
Version int `json:"version,omitempty"`
|
Version int `json:"version,omitempty"`
|
||||||
Checksum string `json:"checksum,omitempty"`
|
Checksum string `json:"checksum,omitempty"`
|
||||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||||
AuditSummary string `json:"auditSummary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
|
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
|
||||||
DeploymentReceipt *ServerDeploymentExecutionReceiptBody `json:"deploymentReceipt,omitempty"`
|
DeploymentReceipt *ServerDeploymentExecutionReceiptBody `json:"deploymentReceipt,omitempty"`
|
||||||
@@ -267,30 +267,6 @@ type SourceRCONExecutionInputResponse struct {
|
|||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProtectedRequestExecutionInputRequest struct {
|
|
||||||
RunEndpointID string `json:"runEndpointId"`
|
|
||||||
SessionToken string `json:"sessionToken"`
|
|
||||||
JobID string `json:"jobId"`
|
|
||||||
LeaseToken string `json:"leaseToken"`
|
|
||||||
Attempt int `json:"attempt"`
|
|
||||||
FencingToken uint64 `json:"fencingToken"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProtectedRequestExecutionInputResponse struct {
|
|
||||||
JobID string `json:"jobId"`
|
|
||||||
ServerInstanceID string `json:"serverInstanceId"`
|
|
||||||
RunEndpointID string `json:"runEndpointId"`
|
|
||||||
FencingToken uint64 `json:"fencingToken"`
|
|
||||||
Authorized bool `json:"authorized"`
|
|
||||||
ApprovalState string `json:"approvalState"`
|
|
||||||
QueueState string `json:"queueState"`
|
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
TransportKey string `json:"transportKey"`
|
|
||||||
TargetKey string `json:"targetKey"`
|
|
||||||
RequestText string `json:"requestText"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RunUpdateInputRequest struct {
|
type RunUpdateInputRequest struct {
|
||||||
RunEndpointID string `json:"runEndpointId"`
|
RunEndpointID string `json:"runEndpointId"`
|
||||||
SessionToken string `json:"sessionToken"`
|
SessionToken string `json:"sessionToken"`
|
||||||
@@ -448,7 +424,7 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
|
|||||||
Message: request.Message,
|
Message: request.Message,
|
||||||
ErrorCode: request.ErrorCode,
|
ErrorCode: request.ErrorCode,
|
||||||
Retryable: request.Retryable,
|
Retryable: request.Retryable,
|
||||||
ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)},
|
ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, Summary: request.ExecutionResult.Summary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,10 +446,6 @@ func (request SourceRCONExecutionInputRequest) ToDomain() domain.SourceRCONExecu
|
|||||||
return domain.SourceRCONExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
|
return domain.SourceRCONExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (request ProtectedRequestExecutionInputRequest) ToDomain() domain.ProtectedRequestExecutionInputRequest {
|
|
||||||
return domain.ProtectedRequestExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt, FencingToken: request.FencingToken}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (request RunUpdateInputRequest) ToDomain() domain.RunUpdateInputRequest {
|
func (request RunUpdateInputRequest) ToDomain() domain.RunUpdateInputRequest {
|
||||||
return domain.RunUpdateInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
|
return domain.RunUpdateInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
|
||||||
}
|
}
|
||||||
@@ -588,10 +560,6 @@ func SourceRCONExecutionInputFromDomain(input domain.SourceRCONExecutionInput) S
|
|||||||
return SourceRCONExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, Command: input.Command}
|
return SourceRCONExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, Command: input.Command}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProtectedRequestExecutionInputFromDomain(input domain.ProtectedRequestExecutionInput) ProtectedRequestExecutionInputResponse {
|
|
||||||
return ProtectedRequestExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, FencingToken: input.FencingToken, Authorized: input.Authorized, ApprovalState: input.ApprovalState, QueueState: input.QueueState, ExpiresAt: input.ExpiresAt, Kind: input.Kind, TransportKey: input.TransportKey, TargetKey: input.TargetKey, RequestText: input.RequestText}
|
|
||||||
}
|
|
||||||
|
|
||||||
func RunUpdateInputFromDomain(input domain.RunUpdateInput) RunUpdateInputResponse {
|
func RunUpdateInputFromDomain(input domain.RunUpdateInput) RunUpdateInputResponse {
|
||||||
return RunUpdateInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, ArtifactID: input.ArtifactID, Checksum: input.Checksum, SizeBytes: input.SizeBytes, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PackageFormat: input.PackageFormat, ExecutableName: input.ExecutableName, TargetRelease: input.TargetRelease, ChunkSizeBytes: input.ChunkSizeBytes}
|
return RunUpdateInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, ArtifactID: input.ArtifactID, Checksum: input.Checksum, SizeBytes: input.SizeBytes, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PackageFormat: input.PackageFormat, ExecutableName: input.ExecutableName, TargetRelease: input.TargetRelease, ChunkSizeBytes: input.ChunkSizeBytes}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ type RemoteAdapterResponse struct {
|
|||||||
Retryable bool `json:"retryable"`
|
Retryable bool `json:"retryable"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
ResultRef string `json:"resultRef,omitempty"`
|
ResultRef string `json:"resultRef,omitempty"`
|
||||||
AuditEventID string `json:"auditEventId,omitempty"`
|
|
||||||
CompletedAt time.Time `json:"completedAt,omitempty"`
|
CompletedAt time.Time `json:"completedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +170,7 @@ func (request RemoteAdapterRequestBody) ToDomain(serverInstanceID string) domain
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RemoteAdapterFromDomain(result domain.RemoteAdapterResult) RemoteAdapterResponse {
|
func RemoteAdapterFromDomain(result domain.RemoteAdapterResult) RemoteAdapterResponse {
|
||||||
return RemoteAdapterResponse{RequestID: result.RequestID, ServerInstanceID: result.ServerInstanceID, DeclarationKey: result.DeclarationKey, TargetKey: result.TargetKey, Kind: result.Kind, Status: result.Status, Retryable: result.Retryable, Message: result.Message, ResultRef: result.ResultRef, AuditEventID: result.AuditEventID, CompletedAt: result.CompletedAt}
|
return RemoteAdapterResponse{RequestID: result.RequestID, ServerInstanceID: result.ServerInstanceID, DeclarationKey: result.DeclarationKey, TargetKey: result.TargetKey, Kind: result.Kind, Status: result.Status, Retryable: result.Retryable, Message: result.Message, ResultRef: result.ResultRef, CompletedAt: result.CompletedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (request SourceRCONCommandRequestBody) ToDomain(serverInstanceID string) domain.SourceRCONCommandRequest {
|
func (request SourceRCONCommandRequestBody) ToDomain(serverInstanceID string) domain.SourceRCONCommandRequest {
|
||||||
|
|||||||
@@ -6,106 +6,6 @@ import (
|
|||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CapacityAdmissionRequest struct {
|
|
||||||
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
|
||||||
RunEndpointID string `json:"runEndpointId,omitempty"`
|
|
||||||
Capability string `json:"capability"`
|
|
||||||
TargetKey string `json:"targetKey,omitempty"`
|
|
||||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CapacityAdmissionDecisionResponse struct {
|
|
||||||
Accepted bool `json:"accepted"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"`
|
|
||||||
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
|
||||||
RunEndpointID string `json:"runEndpointId,omitempty"`
|
|
||||||
Capability string `json:"capability"`
|
|
||||||
TargetKey string `json:"targetKey,omitempty"`
|
|
||||||
MaxJobs int `json:"maxJobs"`
|
|
||||||
RunningJobs int `json:"runningJobs"`
|
|
||||||
QueuedJobs int `json:"queuedJobs"`
|
|
||||||
PressureCodes []string `json:"pressureCodes,omitempty"`
|
|
||||||
CheckedAt time.Time `json:"checkedAt"`
|
|
||||||
AlertID string `json:"alertId,omitempty"`
|
|
||||||
AuditEventID string `json:"auditEventId,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type EndpointCapacityProjectionResponse struct {
|
|
||||||
RunEndpointID string `json:"runEndpointId"`
|
|
||||||
DisplayName string `json:"displayName"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Capabilities []string `json:"capabilities"`
|
|
||||||
MaxJobs int `json:"maxJobs"`
|
|
||||||
RunningJobs int `json:"runningJobs"`
|
|
||||||
QueuedJobs int `json:"queuedJobs"`
|
|
||||||
LogBacklogBatches int `json:"logBacklogBatches,omitempty"`
|
|
||||||
ArtifactBacklogChunks int `json:"artifactBacklogChunks,omitempty"`
|
|
||||||
PressureCodes []string `json:"pressureCodes,omitempty"`
|
|
||||||
Summary string `json:"summary,omitempty"`
|
|
||||||
LastHeartbeatAt time.Time `json:"lastHeartbeatAt"`
|
|
||||||
LastAdmissionDecision string `json:"lastAdmissionDecision,omitempty"`
|
|
||||||
LastAdmissionReason string `json:"lastAdmissionReason,omitempty"`
|
|
||||||
LastAdmissionCheckedAt time.Time `json:"lastAdmissionCheckedAt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProductionCapacitySummaryResponse struct {
|
|
||||||
Endpoints []EndpointCapacityProjectionResponse `json:"endpoints"`
|
|
||||||
TotalMaxJobs int `json:"totalMaxJobs"`
|
|
||||||
TotalRunningJobs int `json:"totalRunningJobs"`
|
|
||||||
TotalQueuedJobs int `json:"totalQueuedJobs"`
|
|
||||||
ActiveAlerts int `json:"activeAlerts"`
|
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
SourceKind string `json:"sourceKind"`
|
|
||||||
SourceID string `json:"sourceId"`
|
|
||||||
RuleKey string `json:"ruleKey"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
OccurrenceCount int `json:"occurrenceCount"`
|
|
||||||
Retryable bool `json:"retryable"`
|
|
||||||
RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"`
|
|
||||||
LastJobID string `json:"lastJobId,omitempty"`
|
|
||||||
LastAuditEventID string `json:"lastAuditEventId,omitempty"`
|
|
||||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
|
||||||
AcknowledgedBy string `json:"acknowledgedBy,omitempty"`
|
|
||||||
AcknowledgedAt time.Time `json:"acknowledgedAt,omitempty"`
|
|
||||||
ResolvedBy string `json:"resolvedBy,omitempty"`
|
|
||||||
ResolvedAt time.Time `json:"resolvedAt,omitempty"`
|
|
||||||
ResolutionNote string `json:"resolutionNote,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertListResponse struct {
|
|
||||||
Items []AlertResponse `json:"items"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertAcknowledgeRequest struct {
|
|
||||||
Note string `json:"note,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertResolveRequest struct {
|
|
||||||
Note string `json:"note,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertRetryRequest struct {
|
|
||||||
IdempotencyKey string `json:"idempotencyKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertRetryResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Alert AlertResponse `json:"alert"`
|
|
||||||
Decision CapacityAdmissionDecisionResponse `json:"decision"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PluginLifecycleActionRequest struct {
|
type PluginLifecycleActionRequest struct {
|
||||||
ServerInstanceID string `json:"serverInstanceId"`
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
Operation string `json:"operation"`
|
Operation string `json:"operation"`
|
||||||
@@ -127,8 +27,6 @@ type PluginLifecycleInstallationResponse struct {
|
|||||||
Compatibility string `json:"compatibility,omitempty"`
|
Compatibility string `json:"compatibility,omitempty"`
|
||||||
DependencyState string `json:"dependencyState,omitempty"`
|
DependencyState string `json:"dependencyState,omitempty"`
|
||||||
JobID string `json:"jobId,omitempty"`
|
JobID string `json:"jobId,omitempty"`
|
||||||
AlertID string `json:"alertId,omitempty"`
|
|
||||||
AuditEventID string `json:"auditEventId,omitempty"`
|
|
||||||
FailureReason string `json:"failureReason,omitempty"`
|
FailureReason string `json:"failureReason,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
@@ -143,8 +41,6 @@ type PluginLifecycleActionResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Installation PluginLifecycleInstallationResponse `json:"installation"`
|
Installation PluginLifecycleInstallationResponse `json:"installation"`
|
||||||
Job JobResponse `json:"job"`
|
Job JobResponse `json:"job"`
|
||||||
Decision CapacityAdmissionDecisionResponse `json:"decision"`
|
|
||||||
Alert *AlertResponse `json:"alert,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIConfigDiffPreviewResponse struct {
|
type AIConfigDiffPreviewResponse struct {
|
||||||
@@ -185,49 +81,13 @@ type AIConfigDiffApprovalResponse struct {
|
|||||||
Dispatch ServerConfigWriteDispatchResponse `json:"dispatch"`
|
Dispatch ServerConfigWriteDispatchResponse `json:"dispatch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (request CapacityAdmissionRequest) ToDomain() domain.CapacityAdmissionRequest {
|
|
||||||
return domain.CapacityAdmissionRequest{ServerInstanceID: request.ServerInstanceID, RunEndpointID: request.RunEndpointID, Capability: request.Capability, TargetKey: request.TargetKey, IdempotencyKey: request.IdempotencyKey}
|
|
||||||
}
|
|
||||||
|
|
||||||
func CapacityDecisionFromDomain(decision domain.CapacityAdmissionDecision) CapacityAdmissionDecisionResponse {
|
|
||||||
decision = domain.CopyCapacityAdmissionDecision(decision)
|
|
||||||
return CapacityAdmissionDecisionResponse{Accepted: decision.Accepted, State: string(decision.State), Reason: decision.Reason, RetryAfterSeconds: decision.RetryAfterSeconds, ServerInstanceID: decision.ServerInstanceID, RunEndpointID: decision.RunEndpointID, Capability: decision.Capability, TargetKey: decision.TargetKey, MaxJobs: decision.MaxJobs, RunningJobs: decision.RunningJobs, QueuedJobs: decision.QueuedJobs, PressureCodes: pressureCodesFromDomain(decision.PressureCodes), CheckedAt: decision.CheckedAt, AlertID: decision.AlertID, AuditEventID: decision.AuditEventID}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ProductionCapacityFromDomain(summary domain.ProductionCapacitySummary) ProductionCapacitySummaryResponse {
|
|
||||||
summary = domain.CopyProductionCapacitySummary(summary)
|
|
||||||
items := make([]EndpointCapacityProjectionResponse, len(summary.Endpoints))
|
|
||||||
for i, endpoint := range summary.Endpoints {
|
|
||||||
items[i] = EndpointCapacityProjectionResponse{RunEndpointID: endpoint.RunEndpointID, DisplayName: endpoint.DisplayName, Status: string(endpoint.Status), Capabilities: endpoint.Capabilities, MaxJobs: endpoint.MaxJobs, RunningJobs: endpoint.RunningJobs, QueuedJobs: endpoint.QueuedJobs, LogBacklogBatches: endpoint.LogBacklogBatches, ArtifactBacklogChunks: endpoint.ArtifactBacklogChunks, PressureCodes: pressureCodesFromDomain(endpoint.PressureCodes), Summary: endpoint.Summary, LastHeartbeatAt: endpoint.LastHeartbeatAt, LastAdmissionDecision: string(endpoint.LastAdmissionDecision), LastAdmissionReason: endpoint.LastAdmissionReason, LastAdmissionCheckedAt: endpoint.LastAdmissionCheckedAt}
|
|
||||||
}
|
|
||||||
return ProductionCapacitySummaryResponse{Endpoints: items, TotalMaxJobs: summary.TotalMaxJobs, TotalRunningJobs: summary.TotalRunningJobs, TotalQueuedJobs: summary.TotalQueuedJobs, ActiveAlerts: summary.ActiveAlerts, GeneratedAt: summary.GeneratedAt}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AlertFromDomain(alert domain.AlertRecord) AlertResponse {
|
|
||||||
alert = domain.CopyAlertRecord(alert)
|
|
||||||
return AlertResponse{ID: alert.ID, SourceKind: alert.SourceKind, SourceID: alert.SourceID, RuleKey: alert.RuleKey, Severity: string(alert.Severity), State: string(alert.State), Title: alert.Title, Message: alert.Message, OccurrenceCount: alert.OccurrenceCount, Retryable: alert.Retryable, RetryAfterSeconds: alert.RetryAfterSeconds, LastJobID: alert.LastJobID, LastAuditEventID: alert.LastAuditEventID, LastSeenAt: alert.LastSeenAt, AcknowledgedBy: alert.AcknowledgedBy, AcknowledgedAt: alert.AcknowledgedAt, ResolvedBy: alert.ResolvedBy, ResolvedAt: alert.ResolvedAt, ResolutionNote: alert.ResolutionNote, CreatedAt: alert.CreatedAt, UpdatedAt: alert.UpdatedAt}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AlertListFromDomain(alerts []domain.AlertRecord) AlertListResponse {
|
|
||||||
items := make([]AlertResponse, len(alerts))
|
|
||||||
for i, alert := range alerts {
|
|
||||||
items[i] = AlertFromDomain(alert)
|
|
||||||
}
|
|
||||||
return AlertListResponse{Items: items, Count: len(items)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AlertRetryFromDomain(result domain.AlertRetryResult) AlertRetryResponse {
|
|
||||||
result = domain.CopyAlertRetryResult(result)
|
|
||||||
return AlertRetryResponse{Status: result.Status, Alert: AlertFromDomain(result.Alert), Decision: CapacityDecisionFromDomain(result.Decision)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (request PluginLifecycleActionRequest) ToDomain(pluginID string) domain.PluginLifecycleRequest {
|
func (request PluginLifecycleActionRequest) ToDomain(pluginID string) domain.PluginLifecycleRequest {
|
||||||
return domain.PluginLifecycleRequest{PluginID: pluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Operation), TargetVersion: request.TargetVersion, IdempotencyKey: request.IdempotencyKey, Confirmed: request.Confirmed}
|
return domain.PluginLifecycleRequest{PluginID: pluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Operation), TargetVersion: request.TargetVersion, IdempotencyKey: request.IdempotencyKey, Confirmed: request.Confirmed}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PluginLifecycleFromDomain(installation domain.PluginLifecycleInstallation) PluginLifecycleInstallationResponse {
|
func PluginLifecycleFromDomain(installation domain.PluginLifecycleInstallation) PluginLifecycleInstallationResponse {
|
||||||
installation = domain.CopyPluginLifecycleInstallation(installation)
|
installation = domain.CopyPluginLifecycleInstallation(installation)
|
||||||
return PluginLifecycleInstallationResponse{ID: installation.ID, PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, CurrentVersion: installation.CurrentVersion, TargetVersion: installation.TargetVersion, PreviousVersion: installation.PreviousVersion, DesiredState: string(installation.DesiredState), CurrentState: string(installation.CurrentState), LastOperation: string(installation.LastOperation), Compatibility: installation.Compatibility, DependencyState: string(installation.DependencyState), JobID: installation.JobID, AlertID: installation.AlertID, AuditEventID: installation.AuditEventID, FailureReason: installation.FailureReason, CreatedAt: installation.CreatedAt, UpdatedAt: installation.UpdatedAt}
|
return PluginLifecycleInstallationResponse{ID: installation.ID, PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, CurrentVersion: installation.CurrentVersion, TargetVersion: installation.TargetVersion, PreviousVersion: installation.PreviousVersion, DesiredState: string(installation.DesiredState), CurrentState: string(installation.CurrentState), LastOperation: string(installation.LastOperation), Compatibility: installation.Compatibility, DependencyState: string(installation.DependencyState), JobID: installation.JobID, FailureReason: installation.FailureReason, CreatedAt: installation.CreatedAt, UpdatedAt: installation.UpdatedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PluginLifecycleListFromDomain(installations []domain.PluginLifecycleInstallation) PluginLifecycleListResponse {
|
func PluginLifecycleListFromDomain(installations []domain.PluginLifecycleInstallation) PluginLifecycleListResponse {
|
||||||
@@ -240,12 +100,7 @@ func PluginLifecycleListFromDomain(installations []domain.PluginLifecycleInstall
|
|||||||
|
|
||||||
func PluginLifecycleResultFromDomain(result domain.PluginLifecycleResult) PluginLifecycleActionResponse {
|
func PluginLifecycleResultFromDomain(result domain.PluginLifecycleResult) PluginLifecycleActionResponse {
|
||||||
result = domain.CopyPluginLifecycleResult(result)
|
result = domain.CopyPluginLifecycleResult(result)
|
||||||
var alert *AlertResponse
|
return PluginLifecycleActionResponse{Status: result.Status, Installation: PluginLifecycleFromDomain(result.Installation), Job: JobFromDomain(result.Job)}
|
||||||
if result.Alert != nil {
|
|
||||||
item := AlertFromDomain(*result.Alert)
|
|
||||||
alert = &item
|
|
||||||
}
|
|
||||||
return PluginLifecycleActionResponse{Status: result.Status, Installation: PluginLifecycleFromDomain(result.Installation), Job: JobFromDomain(result.Job), Decision: CapacityDecisionFromDomain(result.Decision), Alert: alert}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func AIConfigDiffFromDomain(preview domain.AIConfigDiffPreview) AIConfigDiffPreviewResponse {
|
func AIConfigDiffFromDomain(preview domain.AIConfigDiffPreview) AIConfigDiffPreviewResponse {
|
||||||
@@ -269,14 +124,3 @@ func AIConfigDiffApprovalFromDomain(result domain.AIConfigDiffApprovalResult) AI
|
|||||||
result = domain.CopyAIConfigDiffApprovalResult(result)
|
result = domain.CopyAIConfigDiffApprovalResult(result)
|
||||||
return AIConfigDiffApprovalResponse{Preview: AIConfigDiffFromDomain(result.Preview), Dispatch: ServerConfigWriteDispatchFromDomain(result.Dispatch)}
|
return AIConfigDiffApprovalResponse{Preview: AIConfigDiffFromDomain(result.Preview), Dispatch: ServerConfigWriteDispatchFromDomain(result.Dispatch)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pressureCodesFromDomain(codes []domain.CapacityPressureCode) []string {
|
|
||||||
if codes == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, len(codes))
|
|
||||||
for i, code := range codes {
|
|
||||||
out[i] = string(code)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
+15
-120
@@ -262,23 +262,14 @@ type GamePluginRemoteAccessBody struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeCommandDeclarationBody struct {
|
type GameClientBridgeCommandDeclarationBody struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Permission string `json:"permission"`
|
Permission string `json:"permission"`
|
||||||
ApprovalLevel string `json:"approvalLevel"`
|
ApprovalLevel string `json:"approvalLevel"`
|
||||||
PayloadSchemaRef string `json:"payloadSchemaRef"`
|
PayloadSchemaRef string `json:"payloadSchemaRef"`
|
||||||
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
|
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
|
||||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||||
MaxPayloadBytes int `json:"maxPayloadBytes"`
|
MaxPayloadBytes int `json:"maxPayloadBytes"`
|
||||||
ProtectedRequest *GameClientBridgeProtectedRequestDeclarationBody `json:"protectedRequest,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameClientBridgeProtectedRequestDeclarationBody struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
TransportKey string `json:"transportKey"`
|
|
||||||
TargetKey string `json:"targetKey"`
|
|
||||||
TextField string `json:"textField"`
|
|
||||||
MaxTextBytes int `json:"maxTextBytes"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeSnapshotDeclarationBody struct {
|
type GameClientBridgeSnapshotDeclarationBody struct {
|
||||||
@@ -324,19 +315,10 @@ type GameClientBridgeLogProjectionTargetDeclarationBody struct {
|
|||||||
ObservedAtField string `json:"observedAtField,omitempty"`
|
ObservedAtField string `json:"observedAtField,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeLogProjectionAnnouncementDeclarationBody struct {
|
|
||||||
ProfileKey string `json:"profileKey"`
|
|
||||||
CommandType string `json:"commandType"`
|
|
||||||
TextField string `json:"textField"`
|
|
||||||
NewTextTemplate string `json:"newTextTemplate"`
|
|
||||||
ReturningTextTemplate string `json:"returningTextTemplate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameClientBridgeLogProjectionPresenceDeclarationBody struct {
|
type GameClientBridgeLogProjectionPresenceDeclarationBody struct {
|
||||||
TimestampField string `json:"timestampField"`
|
TimestampField string `json:"timestampField"`
|
||||||
ActiveWindowSeconds int `json:"activeWindowSeconds"`
|
ActiveWindowSeconds int `json:"activeWindowSeconds"`
|
||||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
|
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
|
||||||
Announcement GameClientBridgeLogProjectionAnnouncementDeclarationBody `json:"announcement"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeLogProjectionDeclarationBody struct {
|
type GameClientBridgeLogProjectionDeclarationBody struct {
|
||||||
@@ -895,7 +877,7 @@ type JobExecutionResultResponse struct {
|
|||||||
Version int `json:"version,omitempty"`
|
Version int `json:"version,omitempty"`
|
||||||
Checksum string `json:"checksum,omitempty"`
|
Checksum string `json:"checksum,omitempty"`
|
||||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||||
AuditSummary string `json:"auditSummary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
|
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,32 +940,6 @@ type LogStreamListResponse struct {
|
|||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditEventCreateRequest struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ActorID string `json:"actorId"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
ResourceKind string `json:"resourceKind"`
|
|
||||||
ResourceID string `json:"resourceId"`
|
|
||||||
Result domain.AuditResult `json:"result"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuditEventResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ActorID string `json:"actorId"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
ResourceKind string `json:"resourceKind"`
|
|
||||||
ResourceID string `json:"resourceId"`
|
|
||||||
Result domain.AuditResult `json:"result"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuditEventListResponse struct {
|
|
||||||
Items []AuditEventResponse `json:"items"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -1223,7 +1179,7 @@ func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAcces
|
|||||||
func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManifest {
|
func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManifest {
|
||||||
commands := make([]domain.GameClientBridgeCommandDeclaration, len(body.Commands))
|
commands := make([]domain.GameClientBridgeCommandDeclaration, len(body.Commands))
|
||||||
for index, command := range body.Commands {
|
for index, command := range body.Commands {
|
||||||
commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes, ProtectedRequest: protectedRequestToDomain(command.ProtectedRequest)}
|
commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes}
|
||||||
}
|
}
|
||||||
snapshots := make([]domain.GameClientBridgeSnapshotDeclaration, len(body.Snapshots))
|
snapshots := make([]domain.GameClientBridgeSnapshotDeclaration, len(body.Snapshots))
|
||||||
for index, snapshot := range body.Snapshots {
|
for index, snapshot := range body.Snapshots {
|
||||||
@@ -1276,13 +1232,6 @@ func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDe
|
|||||||
TimestampField: value.Presence.TimestampField,
|
TimestampField: value.Presence.TimestampField,
|
||||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||||
ActivityTarget: gameClientBridgeLogProjectionTargetToDomainPointer(value.Presence.ActivityTarget),
|
ActivityTarget: gameClientBridgeLogProjectionTargetToDomainPointer(value.Presence.ActivityTarget),
|
||||||
Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{
|
|
||||||
ProfileKey: value.Presence.Announcement.ProfileKey,
|
|
||||||
CommandType: value.Presence.Announcement.CommandType,
|
|
||||||
TextField: value.Presence.Announcement.TextField,
|
|
||||||
NewTextTemplate: value.Presence.Announcement.NewTextTemplate,
|
|
||||||
ReturningTextTemplate: value.Presence.Announcement.ReturningTextTemplate,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return domain.GameClientBridgeLogProjectionDeclaration{
|
return domain.GameClientBridgeLogProjectionDeclaration{
|
||||||
@@ -1308,13 +1257,6 @@ func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeL
|
|||||||
return &target
|
return &target
|
||||||
}
|
}
|
||||||
|
|
||||||
func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &domain.GameClientBridgeProtectedRequestDeclaration{Kind: value.Kind, TransportKey: value.TransportKey, TargetKey: value.TargetKey, TextField: value.TextField, MaxTextBytes: value.MaxTextBytes}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
||||||
return domain.PluginLifecycleActions{
|
return domain.PluginLifecycleActions{
|
||||||
Install: actions.Install,
|
Install: actions.Install,
|
||||||
@@ -1461,18 +1403,6 @@ func (request LogStreamCreateRequest) ToDomain() domain.LogStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (request AuditEventCreateRequest) ToDomain() domain.AuditEvent {
|
|
||||||
return domain.AuditEvent{
|
|
||||||
ID: request.ID,
|
|
||||||
ActorID: request.ActorID,
|
|
||||||
Action: request.Action,
|
|
||||||
ResourceKind: request.ResourceKind,
|
|
||||||
ResourceID: request.ResourceID,
|
|
||||||
Result: request.Result,
|
|
||||||
Summary: request.Summary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UserFromDomain(user domain.User) UserResponse {
|
func UserFromDomain(user domain.User) UserResponse {
|
||||||
user = domain.CopyUser(user)
|
user = domain.CopyUser(user)
|
||||||
return UserResponse{
|
return UserResponse{
|
||||||
@@ -1710,7 +1640,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
|||||||
value = domain.CopyGameClientBridgeManifest(value)
|
value = domain.CopyGameClientBridgeManifest(value)
|
||||||
commands := make([]GameClientBridgeCommandDeclarationBody, len(value.Commands))
|
commands := make([]GameClientBridgeCommandDeclarationBody, len(value.Commands))
|
||||||
for index, command := range value.Commands {
|
for index, command := range value.Commands {
|
||||||
commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: string(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes, ProtectedRequest: protectedRequestFromDomain(command.ProtectedRequest)}
|
commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: string(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes}
|
||||||
}
|
}
|
||||||
snapshots := make([]GameClientBridgeSnapshotDeclarationBody, len(value.Snapshots))
|
snapshots := make([]GameClientBridgeSnapshotDeclarationBody, len(value.Snapshots))
|
||||||
for index, snapshot := range value.Snapshots {
|
for index, snapshot := range value.Snapshots {
|
||||||
@@ -1762,13 +1692,6 @@ func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogPro
|
|||||||
TimestampField: value.Presence.TimestampField,
|
TimestampField: value.Presence.TimestampField,
|
||||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||||
ActivityTarget: gameClientBridgeLogProjectionTargetFromDomainPointer(value.Presence.ActivityTarget),
|
ActivityTarget: gameClientBridgeLogProjectionTargetFromDomainPointer(value.Presence.ActivityTarget),
|
||||||
Announcement: GameClientBridgeLogProjectionAnnouncementDeclarationBody{
|
|
||||||
ProfileKey: value.Presence.Announcement.ProfileKey,
|
|
||||||
CommandType: value.Presence.Announcement.CommandType,
|
|
||||||
TextField: value.Presence.Announcement.TextField,
|
|
||||||
NewTextTemplate: value.Presence.Announcement.NewTextTemplate,
|
|
||||||
ReturningTextTemplate: value.Presence.Announcement.ReturningTextTemplate,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return GameClientBridgeLogProjectionDeclarationBody{
|
return GameClientBridgeLogProjectionDeclarationBody{
|
||||||
@@ -1794,13 +1717,6 @@ func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClie
|
|||||||
return &target
|
return &target
|
||||||
}
|
}
|
||||||
|
|
||||||
func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &GameClientBridgeProtectedRequestDeclarationBody{Kind: value.Kind, TransportKey: value.TransportKey, TargetKey: value.TargetKey, TextField: value.TextField, MaxTextBytes: value.MaxTextBytes}
|
|
||||||
}
|
|
||||||
|
|
||||||
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
|
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
|
||||||
items := make([]MarketplacePluginResponse, len(plugins))
|
items := make([]MarketplacePluginResponse, len(plugins))
|
||||||
for i, plugin := range plugins {
|
for i, plugin := range plugins {
|
||||||
@@ -2012,7 +1928,7 @@ func JobFromDomain(job domain.Job) JobResponse {
|
|||||||
State: job.State,
|
State: job.State,
|
||||||
Progress: progressFromDomain(job.Progress),
|
Progress: progressFromDomain(job.Progress),
|
||||||
ResultRef: job.ResultRef,
|
ResultRef: job.ResultRef,
|
||||||
ExecutionResult: JobExecutionResultResponse{Kind: job.ExecutionResult.Kind, ProcessState: job.ExecutionResult.ProcessState, ExitClassification: job.ExecutionResult.ExitClassification, ExitCode: job.ExecutionResult.ExitCode, Version: job.ExecutionResult.Version, Checksum: job.ExecutionResult.Checksum, SizeBytes: job.ExecutionResult.SizeBytes, AuditSummary: job.ExecutionResult.AuditSummary, ServerDeploymentEvidence: serverDeploymentEvidenceFromDomain(job.ExecutionResult.ServerDeploymentEvidence)},
|
ExecutionResult: JobExecutionResultResponse{Kind: job.ExecutionResult.Kind, ProcessState: job.ExecutionResult.ProcessState, ExitClassification: job.ExecutionResult.ExitClassification, ExitCode: job.ExecutionResult.ExitCode, Version: job.ExecutionResult.Version, Checksum: job.ExecutionResult.Checksum, SizeBytes: job.ExecutionResult.SizeBytes, Summary: job.ExecutionResult.Summary, ServerDeploymentEvidence: serverDeploymentEvidenceFromDomain(job.ExecutionResult.ServerDeploymentEvidence)},
|
||||||
RetryPolicy: JobRetryPolicyResponse{
|
RetryPolicy: JobRetryPolicyResponse{
|
||||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||||
InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds,
|
InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds,
|
||||||
@@ -2095,27 +2011,6 @@ func LogStreamListFromDomain(streams []domain.LogStream) LogStreamListResponse {
|
|||||||
return LogStreamListResponse{Items: items, Count: len(items)}
|
return LogStreamListResponse{Items: items, Count: len(items)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuditEventFromDomain(event domain.AuditEvent) AuditEventResponse {
|
|
||||||
return AuditEventResponse{
|
|
||||||
ID: event.ID,
|
|
||||||
ActorID: event.ActorID,
|
|
||||||
Action: event.Action,
|
|
||||||
ResourceKind: event.ResourceKind,
|
|
||||||
ResourceID: event.ResourceID,
|
|
||||||
Result: event.Result,
|
|
||||||
Summary: event.Summary,
|
|
||||||
CreatedAt: event.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuditEventListFromDomain(events []domain.AuditEvent) AuditEventListResponse {
|
|
||||||
items := make([]AuditEventResponse, len(events))
|
|
||||||
for i, event := range events {
|
|
||||||
items[i] = AuditEventFromDomain(event)
|
|
||||||
}
|
|
||||||
return AuditEventListResponse{Items: items, Count: len(items)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func permissionsFromDomain(permissions domain.PluginPermissions) PluginPermissionsResponse {
|
func permissionsFromDomain(permissions domain.PluginPermissions) PluginPermissionsResponse {
|
||||||
return PluginPermissionsResponse{
|
return PluginPermissionsResponse{
|
||||||
AI: permissions.AI,
|
AI: permissions.AI,
|
||||||
|
|||||||
@@ -143,16 +143,16 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
|||||||
LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{
|
LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{
|
||||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
|
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
|
||||||
Target: GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
Target: GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
||||||
Presence: &GameClientBridgeLogProjectionPresenceDeclarationBody{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}, Announcement: GameClientBridgeLogProjectionAnnouncementDeclarationBody{ProfileKey: "scum-client", CommandType: "announcement.send", TextField: "message", NewTextTemplate: "welcome {{name}}", ReturningTextTemplate: "welcome back {{name}}"}},
|
Presence: &GameClientBridgeLogProjectionPresenceDeclarationBody{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}},
|
||||||
}},
|
}},
|
||||||
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}},
|
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}},
|
||||||
CommandRetentionSeconds: 86400,
|
CommandRetentionSeconds: 86400,
|
||||||
MaxCommands: 1000,
|
MaxCommands: 1000,
|
||||||
Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
domainManifest := body.ToDomain()
|
domainManifest := body.ToDomain()
|
||||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || domainManifest.QueryTemplates[0].RowTarget.Collection != "users" || domainManifest.QueryTemplates[0].RowTarget.WriteMode != "merge" || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || domainManifest.QueryTemplates[0].RowTarget.Collection != "users" || domainManifest.QueryTemplates[0].RowTarget.WriteMode != "merge" || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
|
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
|
||||||
}
|
}
|
||||||
domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated"
|
domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated"
|
||||||
@@ -170,11 +170,11 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
|||||||
t.Fatal("query template page keys alias request DTO data")
|
t.Fatal("query template page keys alias request DTO data")
|
||||||
}
|
}
|
||||||
domainManifest.Pages[0].QueryTemplateKeys[0] = "player.lookup"
|
domainManifest.Pages[0].QueryTemplateKeys[0] = "player.lookup"
|
||||||
domainManifest.DataPacks[0].DataRefs[0] = "mutated"
|
domainManifest.DataPacks[0].DataRefs[0] = "mutated"
|
||||||
if body.DataPacks[0].DataRefs[0] != "data/items.json" {
|
if body.DataPacks[0].DataRefs[0] != "data/items.json" {
|
||||||
t.Fatal("data pack data refs alias request DTO data")
|
t.Fatal("data pack data refs alias request DTO data")
|
||||||
}
|
}
|
||||||
domainManifest.DataPacks[0].DataRefs[0] = "data/items.json"
|
domainManifest.DataPacks[0].DataRefs[0] = "data/items.json"
|
||||||
|
|
||||||
response := gameClientBridgeManifestFromDomain(domainManifest)
|
response := gameClientBridgeManifestFromDomain(domainManifest)
|
||||||
response.LogProjections[0].Target.FixedValues["source"] = "mutated"
|
response.LogProjections[0].Target.FixedValues["source"] = "mutated"
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ Required model groups:
|
|||||||
- jobs and job events.
|
- jobs and job events.
|
||||||
- artifacts and chunks.
|
- artifacts and chunks.
|
||||||
- log streams and ingestion cursors.
|
- log streams and ingestion cursors.
|
||||||
- audit events.
|
- operational events.
|
||||||
- durable alerts and their acknowledgement/resolution audit links.
|
- durable alerts and their acknowledgement/resolution metadata.
|
||||||
- server-bound plugin lifecycle installations and linked jobs.
|
- server-bound plugin lifecycle installations and linked jobs.
|
||||||
- reviewable AI config diffs and approval fences.
|
- reviewable AI config diffs and approval fences.
|
||||||
|
|
||||||
Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack.
|
Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack.
|
||||||
# Durable Client Manager persistence
|
# Durable Client Manager persistence
|
||||||
|
|
||||||
File and MySQL stores persist the Client Manager installation aggregate, component sessions, registration nonce fences, and lifecycle audit references. Session tokens are stored only as hashes; nonce and heartbeat sequence checks are bounded and restart-safe. Reset, ownership/endpoint reassignment, update activation, rollback, revoke, and uninstall fence or expire component sessions without deleting distribution or audit history.
|
File and MySQL stores persist the Client Manager installation aggregate, component sessions, registration nonce fences, and lifecycle metadata. Session tokens are stored only as hashes; nonce and heartbeat sequence checks are bounded and restart-safe. Reset, ownership/endpoint reassignment, update activation, rollback, revoke, and uninstall fence or expire component sessions without deleting distribution history.
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ type GamePlugin struct {
|
|||||||
Tags []string `json:"tags" db:"tags"`
|
Tags []string `json:"tags" db:"tags"`
|
||||||
// AIPurposes stores platform-mediated AI usage purposes.
|
// AIPurposes stores platform-mediated AI usage purposes.
|
||||||
AIPurposes []string `json:"aiPurposes" db:"ai_purposes"`
|
AIPurposes []string `json:"aiPurposes" db:"ai_purposes"`
|
||||||
// ProductionLifecycle stores declared server-bound plugin lifecycle governance.
|
// ProductionLifecycle stores declared server-bound plugin lifecycle operations.
|
||||||
ProductionLifecycle domain.GamePluginProductionLifecycle `json:"productionLifecycle" db:"production_lifecycle"`
|
ProductionLifecycle domain.GamePluginProductionLifecycle `json:"productionLifecycle" db:"production_lifecycle"`
|
||||||
// RemoteAccess stores plugin-declared remote access metadata.
|
// RemoteAccess stores plugin-declared remote access metadata.
|
||||||
RemoteAccess GamePluginRemoteAccess `json:"remoteAccess" db:"remote_access"`
|
RemoteAccess GamePluginRemoteAccess `json:"remoteAccess" db:"remote_access"`
|
||||||
@@ -319,7 +319,7 @@ type JobExecutionResult struct {
|
|||||||
Version int `json:"version,omitempty" db:"version"`
|
Version int `json:"version,omitempty" db:"version"`
|
||||||
Checksum string `json:"checksum,omitempty" db:"checksum"`
|
Checksum string `json:"checksum,omitempty" db:"checksum"`
|
||||||
SizeBytes int64 `json:"sizeBytes,omitempty" db:"size_bytes"`
|
SizeBytes int64 `json:"sizeBytes,omitempty" db:"size_bytes"`
|
||||||
AuditSummary string `json:"auditSummary,omitempty" db:"audit_summary"`
|
Summary string `json:"summary,omitempty" db:"summary"`
|
||||||
Content string `json:"content,omitempty" db:"content"`
|
Content string `json:"content,omitempty" db:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,53 +433,6 @@ type LogStream struct {
|
|||||||
|
|
||||||
func (LogStream) TableName() string { return "log_streams" }
|
func (LogStream) TableName() string { return "log_streams" }
|
||||||
|
|
||||||
type AuditEvent struct {
|
|
||||||
// ID is the stable audit event identifier.
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
// ActorID references the user or system actor.
|
|
||||||
ActorID string `json:"actorId" db:"actor_id"`
|
|
||||||
// Action is the stable action key.
|
|
||||||
Action string `json:"action" db:"action"`
|
|
||||||
// ResourceKind identifies the audited resource type.
|
|
||||||
ResourceKind string `json:"resourceKind" db:"resource_kind"`
|
|
||||||
// ResourceID identifies the audited resource.
|
|
||||||
ResourceID string `json:"resourceId" db:"resource_id"`
|
|
||||||
// Result is the audit outcome.
|
|
||||||
Result domain.AuditResult `json:"result" db:"result"`
|
|
||||||
// Summary is a bounded redacted summary.
|
|
||||||
Summary string `json:"summary" db:"summary"`
|
|
||||||
// CreatedAt is the audit timestamp.
|
|
||||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (AuditEvent) TableName() string { return "audit_events" }
|
|
||||||
|
|
||||||
type Alert struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
SourceKind string `json:"sourceKind" db:"source_kind"`
|
|
||||||
SourceID string `json:"sourceId" db:"source_id"`
|
|
||||||
RuleKey string `json:"ruleKey" db:"rule_key"`
|
|
||||||
Severity domain.AlertSeverity `json:"severity" db:"severity"`
|
|
||||||
State domain.AlertState `json:"state" db:"state"`
|
|
||||||
Title string `json:"title" db:"title"`
|
|
||||||
Message string `json:"message" db:"message"`
|
|
||||||
OccurrenceCount int `json:"occurrenceCount" db:"occurrence_count"`
|
|
||||||
Retryable bool `json:"retryable" db:"retryable"`
|
|
||||||
RetryAfterSeconds int `json:"retryAfterSeconds" db:"retry_after_seconds"`
|
|
||||||
LastJobID string `json:"lastJobId,omitempty" db:"last_job_id"`
|
|
||||||
LastAuditEventID string `json:"lastAuditEventId,omitempty" db:"last_audit_event_id"`
|
|
||||||
LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"`
|
|
||||||
AcknowledgedBy string `json:"acknowledgedBy,omitempty" db:"acknowledged_by"`
|
|
||||||
AcknowledgedAt time.Time `json:"acknowledgedAt,omitempty" db:"acknowledged_at"`
|
|
||||||
ResolvedBy string `json:"resolvedBy,omitempty" db:"resolved_by"`
|
|
||||||
ResolvedAt time.Time `json:"resolvedAt,omitempty" db:"resolved_at"`
|
|
||||||
ResolutionNote string `json:"resolutionNote,omitempty" db:"resolution_note"`
|
|
||||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Alert) TableName() string { return "alerts" }
|
|
||||||
|
|
||||||
type PluginLifecycleInstallation struct {
|
type PluginLifecycleInstallation struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
PluginID string `json:"pluginId" db:"plugin_id"`
|
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||||
@@ -493,8 +446,6 @@ type PluginLifecycleInstallation struct {
|
|||||||
Compatibility string `json:"compatibility" db:"compatibility"`
|
Compatibility string `json:"compatibility" db:"compatibility"`
|
||||||
DependencyState domain.DependencyState `json:"dependencyState" db:"dependency_state"`
|
DependencyState domain.DependencyState `json:"dependencyState" db:"dependency_state"`
|
||||||
JobID string `json:"jobId,omitempty" db:"job_id"`
|
JobID string `json:"jobId,omitempty" db:"job_id"`
|
||||||
AlertID string `json:"alertId,omitempty" db:"alert_id"`
|
|
||||||
AuditEventID string `json:"auditEventId,omitempty" db:"audit_event_id"`
|
|
||||||
FailureReason string `json:"failureReason,omitempty" db:"failure_reason"`
|
FailureReason string `json:"failureReason,omitempty" db:"failure_reason"`
|
||||||
IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"`
|
IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"`
|
||||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
@@ -930,11 +881,11 @@ func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult {
|
func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult {
|
||||||
return JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, AuditSummary: result.AuditSummary, Content: result.Content}
|
return JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, Summary: result.Summary, Content: result.Content}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (result JobExecutionResult) ToDomain() domain.JobExecutionResult {
|
func (result JobExecutionResult) ToDomain() domain.JobExecutionResult {
|
||||||
return domain.JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, AuditSummary: result.AuditSummary, Content: result.Content}
|
return domain.JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, Summary: result.Summary, Content: result.Content}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (policy JobRetryPolicy) ToDomain() domain.JobRetryPolicy {
|
func (policy JobRetryPolicy) ToDomain() domain.JobRetryPolicy {
|
||||||
@@ -1020,29 +971,3 @@ func (stream LogStream) ToDomain() domain.LogStream {
|
|||||||
UpdatedAt: stream.UpdatedAt,
|
UpdatedAt: stream.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuditEventFromDomain(event domain.AuditEvent) AuditEvent {
|
|
||||||
return AuditEvent{
|
|
||||||
ID: event.ID,
|
|
||||||
ActorID: event.ActorID,
|
|
||||||
Action: event.Action,
|
|
||||||
ResourceKind: event.ResourceKind,
|
|
||||||
ResourceID: event.ResourceID,
|
|
||||||
Result: event.Result,
|
|
||||||
Summary: event.Summary,
|
|
||||||
CreatedAt: event.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (event AuditEvent) ToDomain() domain.AuditEvent {
|
|
||||||
return domain.AuditEvent{
|
|
||||||
ID: event.ID,
|
|
||||||
ActorID: event.ActorID,
|
|
||||||
Action: event.Action,
|
|
||||||
ResourceKind: event.ResourceKind,
|
|
||||||
ResourceID: event.ResourceID,
|
|
||||||
Result: event.Result,
|
|
||||||
Summary: event.Summary,
|
|
||||||
CreatedAt: event.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ func TestTableNames(t *testing.T) {
|
|||||||
Job{}.TableName(): "jobs",
|
Job{}.TableName(): "jobs",
|
||||||
Artifact{}.TableName(): "artifacts",
|
Artifact{}.TableName(): "artifacts",
|
||||||
LogStream{}.TableName(): "log_streams",
|
LogStream{}.TableName(): "log_streams",
|
||||||
AuditEvent{}.TableName(): "audit_events",
|
|
||||||
Alert{}.TableName(): "alerts",
|
|
||||||
PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations",
|
PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations",
|
||||||
AIConfigDiff{}.TableName(): "ai_config_diffs",
|
AIConfigDiff{}.TableName(): "ai_config_diffs",
|
||||||
ClientManagerInstallation{}.TableName(): "client_manager_installations",
|
ClientManagerInstallation{}.TableName(): "client_manager_installations",
|
||||||
|
|||||||
@@ -38,4 +38,4 @@ AI invocation responses must be bounded and must not include raw provider creden
|
|||||||
|
|
||||||
Management endpoints reject raw key-shaped values in `apiKeyRef`. In `live` mode Platform resolves `env://NAME` or `secret://providers/<id>` inside the service boundary and invokes OpenAI-compatible, OpenAI, Claude, Gemini, Ollama, or custom HTTP providers with bounded requests. Local debug uses explicit `mock` mode.
|
Management endpoints reject raw key-shaped values in `apiKeyRef`. In `live` mode Platform resolves `env://NAME` or `secret://providers/<id>` inside the service boundary and invokes OpenAI-compatible, OpenAI, Claude, Gemini, Ollama, or custom HTTP providers with bounded requests. Local debug uses explicit `mock` mode.
|
||||||
|
|
||||||
Provider failures create redacted audit/alert evidence and return a stable safe error without URL, header, key, request-body secret, or stack details. Config suggestions persist `AIConfigDiffPreview` with actor/server/plugin/provider/model, config version/checksum, expiry, and proposed content. Only `POST /api/v1/ai/config-diffs/{id}/approve` may dispatch the matching `config.write` job, and stale/expired/mismatched approvals are rejected.
|
Provider failures create redacted alert evidence and return a stable safe error without URL, header, key, request-body secret, or stack details. Config suggestions persist `AIConfigDiffPreview` with actor/server/plugin/provider/model, config version/checksum, expiry, and proposed content. Only `POST /api/v1/ai/config-diffs/{id}/approve` may dispatch the matching `config.write` job, and stale/expired/mismatched approvals are rejected.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
## Authorization roles
|
## Authorization roles
|
||||||
|
|
||||||
- `platform-admin`: user/provider/plugin installation and state, Run endpoint administration, platform metrics, audit, and global internal resource creation.
|
- `platform-admin`: user/provider/plugin installation and state, Run endpoint administration, platform metrics, and global internal resource creation.
|
||||||
- server owner: server membership, runtime binding changes, destructive/archive operations, and all visible server actions.
|
- server owner: server membership, runtime binding changes, destructive/archive operations, and all visible server actions.
|
||||||
- server administrator: non-owner operational access to assigned server resources, but no owner-only membership or secret/key rotation.
|
- server administrator: non-owner operational access to assigned server resources, but no owner-only membership or secret/key rotation.
|
||||||
- Run service: control/job/log/artifact channels for its current endpoint session; it cannot use browser bearer authority.
|
- Run service: control/job/log/artifact channels for its current endpoint session; it cannot use browser bearer authority.
|
||||||
@@ -25,6 +25,6 @@ The canonical payload is `METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE
|
|||||||
|
|
||||||
## Secret boundary
|
## Secret boundary
|
||||||
|
|
||||||
Platform snapshots may contain password verifiers, bearer/Run token hashes, encrypted component-key ciphertext, fingerprints, generations, and controlled `secret://`/`vault://` references. They never contain raw bearer tokens, raw component keys, provider key values, host paths, or direct sockets. Browser DTOs expose secret presence/configured flags only.
|
Platform snapshots may contain password verifiers, bearer/Run token hashes, encrypted component-key ciphertext, fingerprints, generations, and scoped `secret://`/`vault://` references. They never contain raw bearer tokens, raw component keys, provider key values, host paths, or direct sockets. Browser DTOs expose secret presence/configured flags only.
|
||||||
|
|
||||||
Component-key ciphertext uses an injectable AES-GCM envelope derived from `PLATFORM_SECRET_ENVELOPE_KEY`; the built-in key is a disposable-development fallback only. This boundary is not a production KMS/vault. External key wrapping, KMS/HSM integration, multi-node replay coordination, envelope-key migration, and secret-value rotation remain deferred risks.
|
Component-key ciphertext uses an injectable AES-GCM envelope derived from `PLATFORM_SECRET_ENVELOPE_KEY`; the built-in key is a disposable-development fallback only. This boundary is not a production KMS/vault. External key wrapping, KMS/HSM integration, multi-node replay coordination, envelope-key migration, and secret-value rotation remain deferred risks.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Dependency And Run Update Contracts
|
# Dependency And Run Update Contracts
|
||||||
|
|
||||||
Platform owns the reviewable dependency catalog, immutable plan digest, selected server/profile/binding, endpoint target, distribution artifact, job attempt, and audit projection. Plugins and `platform_web` see only catalog/status/update projections. They never receive resolved host paths, commands, raw bindings, credentials, secret refs, Run/session/lease values, fencing hashes, PIDs, sockets, or artifact bodies.
|
Platform owns the reviewable dependency catalog, immutable plan digest, selected server/profile/binding, endpoint target, distribution artifact, job attempt, and status projection. Plugins and `platform_web` see only catalog/status/update projections. They never receive resolved host paths, commands, raw bindings, credentials, secret refs, Run/session/lease values, fencing hashes, PIDs, sockets, or artifact bodies.
|
||||||
|
|
||||||
## Dependency flow
|
## Dependency flow
|
||||||
|
|
||||||
1. `GET /api/v1/server-instances/{id}/dependencies` resolves the installed plugin version, complete runtime binding, online Run endpoint OS/architecture, target-matched probes/plans, and canonical SHA-256 digest.
|
1. `GET /api/v1/server-instances/{id}/dependencies` resolves the installed plugin version, complete runtime binding, online Run endpoint OS/architecture, target-matched probes/plans, and canonical SHA-256 digest.
|
||||||
2. An install request must submit that exact digest. Platform re-resolves the declaration before creating `dependencies.install`; missing or stale approval is denied and audited.
|
2. An install request must submit that exact digest. Platform re-resolves the declaration before creating `dependencies.install`; missing or stale plan evidence is denied and reported.
|
||||||
3. Run retrieves private input through signed `POST /api/v1/run/jobs/dependency-input` only for the active endpoint/session/attempt/lease and non-cancelled job. It executes closed command-version, Java, Docker, package, service, Steam, file, package-manager, verified HTTPS download, and SteamCMD adapters with bounded output/timeouts and a durable step journal.
|
3. Run retrieves private input through signed `POST /api/v1/run/jobs/dependency-input` only for the active endpoint/session/attempt/lease and non-cancelled job. It executes closed command-version, Java, Docker, package, service, Steam, file, package-manager, verified HTTPS download, and SteamCMD adapters with bounded output/timeouts and a durable step journal.
|
||||||
4. Terminal evidence is typed and redacted. Platform verifies probe key, plan digest, result checksum, and job attempt before updating `DependencyStatus`.
|
4. Terminal evidence is typed and redacted. Platform verifies probe key, plan digest, result checksum, and job attempt before updating `DependencyStatus`.
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ Implemented HTTP JSON routes:
|
|||||||
- `POST /api/v1/run/jobs/result`
|
- `POST /api/v1/run/jobs/result`
|
||||||
- `POST /api/v1/run/jobs/cancel`
|
- `POST /api/v1/run/jobs/cancel`
|
||||||
- `POST /api/v1/run/jobs/reconcile`
|
- `POST /api/v1/run/jobs/reconcile`
|
||||||
- `POST /api/v1/run/jobs/protected-request-input`
|
|
||||||
|
|
||||||
Named job DTOs:
|
Named job DTOs:
|
||||||
|
|
||||||
@@ -44,8 +43,6 @@ Named job DTOs:
|
|||||||
- `RunJobProgressRequest`
|
- `RunJobProgressRequest`
|
||||||
- `RunJobResultRequest`
|
- `RunJobResultRequest`
|
||||||
- `RunJobCancelPollRequest`
|
- `RunJobCancelPollRequest`
|
||||||
- `ProtectedRequestExecutionInputRequest`
|
|
||||||
- `ProtectedRequestExecutionInputResponse`
|
|
||||||
- `RunJobReconcileRequest`
|
- `RunJobReconcileRequest`
|
||||||
- `RunJobReconcileResponse`
|
- `RunJobReconcileResponse`
|
||||||
|
|
||||||
@@ -63,7 +60,7 @@ Platform-owned Run distribution builds embed an autonomous lifecycle plan for th
|
|||||||
|
|
||||||
The plan is build input for the generated package, not a machine-side job-channel payload. Generated Run registration must not be treated as a trigger to enqueue `process.start`, `process.install`, or `process.status` work; Platform state converges from Run heartbeats, logs, lifecycle reports, supervised process facts, and terminal job/report messages. Platform and Run must not add game-specific hardcoding to interpret the plan.
|
The plan is build input for the generated package, not a machine-side job-channel payload. Generated Run registration must not be treated as a trigger to enqueue `process.start`, `process.install`, or `process.status` work; Platform state converges from Run heartbeats, logs, lifecycle reports, supervised process facts, and terminal job/report messages. Platform and Run must not add game-specific hardcoding to interpret the plan.
|
||||||
|
|
||||||
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 audit 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.
|
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.
|
||||||
|
|
||||||
## Log Ingest
|
## Log Ingest
|
||||||
|
|
||||||
@@ -89,7 +86,7 @@ Run-assigned Platform jobs use `job.<jobId>.<streamKey>` log stream IDs. Autonom
|
|||||||
|
|
||||||
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.
|
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` 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 audit 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.
|
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
|
## Artifact
|
||||||
|
|
||||||
@@ -122,7 +119,7 @@ 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 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.controlled`, `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.
|
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
|
## Game Client Bridge
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,8 @@ type StoreSnapshot struct {
|
|||||||
ClientManagerBuildJobs []domain.ClientManagerBuildJob `json:"clientManagerBuildJobs"`
|
ClientManagerBuildJobs []domain.ClientManagerBuildJob `json:"clientManagerBuildJobs"`
|
||||||
RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"`
|
RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"`
|
||||||
LogStreams []domain.LogStream `json:"logStreams"`
|
LogStreams []domain.LogStream `json:"logStreams"`
|
||||||
AuditEvents []domain.AuditEvent `json:"auditEvents"`
|
|
||||||
MetricSamples []domain.MetricSample `json:"metricSamples"`
|
MetricSamples []domain.MetricSample `json:"metricSamples"`
|
||||||
Backups []domain.BackupRecord `json:"backups"`
|
Backups []domain.BackupRecord `json:"backups"`
|
||||||
Alerts []domain.AlertRecord `json:"alerts"`
|
|
||||||
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
|
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
|
||||||
AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"`
|
AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"`
|
||||||
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
|
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
|
||||||
@@ -153,10 +151,6 @@ func (store *FileStore) LogStreams() LogStreamRepository {
|
|||||||
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist}
|
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileStore) AuditEvents() AuditEventRepository {
|
|
||||||
return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *FileStore) MetricSamples() MetricSampleRepository {
|
func (store *FileStore) MetricSamples() MetricSampleRepository {
|
||||||
return &persistentRepository[domain.MetricSample, domain.MetricSampleFilter]{repository: store.MemoryStore.metricSamples, persist: store.persist}
|
return &persistentRepository[domain.MetricSample, domain.MetricSampleFilter]{repository: store.MemoryStore.metricSamples, persist: store.persist}
|
||||||
}
|
}
|
||||||
@@ -165,10 +159,6 @@ func (store *FileStore) Backups() BackupRepository {
|
|||||||
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
|
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileStore) Alerts() AlertRepository {
|
|
||||||
return &persistentRepository[domain.AlertRecord, domain.AlertFilter]{repository: store.MemoryStore.alerts, persist: store.persist}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *FileStore) PluginLifecycles() PluginLifecycleRepository {
|
func (store *FileStore) PluginLifecycles() PluginLifecycleRepository {
|
||||||
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
||||||
}
|
}
|
||||||
@@ -257,10 +247,8 @@ func (store *FileStore) snapshot() StoreSnapshot {
|
|||||||
ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs),
|
ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs),
|
||||||
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
|
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
|
||||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||||
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
|
|
||||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||||
Alerts: snapshotRepository(store.MemoryStore.alerts),
|
|
||||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||||
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
||||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||||
@@ -291,10 +279,8 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
|||||||
loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs)
|
loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs)
|
||||||
loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs)
|
loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs)
|
||||||
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||||
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
|
|
||||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||||
loadRepository(store.MemoryStore.alerts, snapshot.Alerts)
|
|
||||||
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||||
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
||||||
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||||
|
|||||||
@@ -12,19 +12,19 @@ import (
|
|||||||
func TestGameClientBridgeCommandRepositoryIdempotencyAndRetention(t *testing.T) {
|
func TestGameClientBridgeCommandRepositoryIdempotencyAndRetention(t *testing.T) {
|
||||||
store := NewMemoryStore()
|
store := NewMemoryStore()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
command := domain.GameClientBridgeCommand{ID: "command-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", IdempotencyKey: "same-key", RequesterID: "user-1", State: domain.GameClientBridgeCommandSucceeded, Payload: map[string]any{"nested": map[string]any{"value": "original"}}, ExpiresAt: now.Add(-time.Minute), CompletedAt: now.Add(-time.Minute)}
|
command := domain.GameClientBridgeCommand{ID: "command-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", IdempotencyKey: "same-key", RequesterID: "user-1", State: domain.GameClientBridgeCommandSucceeded, Payload: map[string]any{"nested": map[string]any{"value": "original"}}, ExpiresAt: now.Add(-time.Minute), CompletedAt: now.Add(-time.Minute)}
|
||||||
if err := store.GameClientBridgeCommands().Create(command); err != nil {
|
if err := store.GameClientBridgeCommands().Create(command); err != nil {
|
||||||
t.Fatalf("create command: %v", err)
|
t.Fatalf("create command: %v", err)
|
||||||
}
|
}
|
||||||
command.Payload["nested"].(map[string]any)["value"] = "mutated"
|
command.Payload["nested"].(map[string]any)["value"] = "mutated"
|
||||||
loaded, err := store.GameClientBridgeCommands().GetByIdempotency("server-1", "user-1", "announcement.send", "same-key")
|
loaded, err := store.GameClientBridgeCommands().GetByIdempotency("server-1", "user-1", "diagnostic.ping", "same-key")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get by idempotency: %v", err)
|
t.Fatalf("get by idempotency: %v", err)
|
||||||
}
|
}
|
||||||
if loaded.Payload["nested"].(map[string]any)["value"] != "original" {
|
if loaded.Payload["nested"].(map[string]any)["value"] != "original" {
|
||||||
t.Fatal("repository did not isolate nested payload")
|
t.Fatal("repository did not isolate nested payload")
|
||||||
}
|
}
|
||||||
for _, lookup := range [][4]string{{"server-2", "user-1", "announcement.send", "same-key"}, {"server-1", "user-2", "announcement.send", "same-key"}, {"server-1", "user-1", "diagnostic.safe", "same-key"}} {
|
for _, lookup := range [][4]string{{"server-2", "user-1", "diagnostic.ping", "same-key"}, {"server-1", "user-2", "diagnostic.ping", "same-key"}, {"server-1", "user-1", "diagnostic.safe", "same-key"}} {
|
||||||
if _, err := store.GameClientBridgeCommands().GetByIdempotency(lookup[0], lookup[1], lookup[2], lookup[3]); !errors.Is(err, ErrNotFound) {
|
if _, err := store.GameClientBridgeCommands().GetByIdempotency(lookup[0], lookup[1], lookup[2], lookup[3]); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("idempotency scope leaked for %v: %v", lookup, err)
|
t.Fatalf("idempotency scope leaked for %v: %v", lookup, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,10 +133,6 @@ func (store *MySQLStore) LogStreams() LogStreamRepository {
|
|||||||
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist}
|
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *MySQLStore) AuditEvents() AuditEventRepository {
|
|
||||||
return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *MySQLStore) MetricSamples() MetricSampleRepository {
|
func (store *MySQLStore) MetricSamples() MetricSampleRepository {
|
||||||
return &persistentRepository[domain.MetricSample, domain.MetricSampleFilter]{repository: store.MemoryStore.metricSamples, persist: store.persist}
|
return &persistentRepository[domain.MetricSample, domain.MetricSampleFilter]{repository: store.MemoryStore.metricSamples, persist: store.persist}
|
||||||
}
|
}
|
||||||
@@ -145,10 +141,6 @@ func (store *MySQLStore) Backups() BackupRepository {
|
|||||||
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
|
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *MySQLStore) Alerts() AlertRepository {
|
|
||||||
return &persistentRepository[domain.AlertRecord, domain.AlertFilter]{repository: store.MemoryStore.alerts, persist: store.persist}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *MySQLStore) PluginLifecycles() PluginLifecycleRepository {
|
func (store *MySQLStore) PluginLifecycles() PluginLifecycleRepository {
|
||||||
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
||||||
}
|
}
|
||||||
@@ -254,10 +246,8 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
|
|||||||
ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs),
|
ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs),
|
||||||
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
|
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
|
||||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||||
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
|
|
||||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||||
Alerts: snapshotRepository(store.MemoryStore.alerts),
|
|
||||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||||
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
||||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||||
@@ -288,10 +278,8 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
|||||||
loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs)
|
loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs)
|
||||||
loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs)
|
loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs)
|
||||||
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||||
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
|
|
||||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||||
loadRepository(store.MemoryStore.alerts, snapshot.Alerts)
|
|
||||||
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||||
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
||||||
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||||
|
|||||||
@@ -155,13 +155,6 @@ type LogStreamRepository interface {
|
|||||||
Update(domain.LogStream) error
|
Update(domain.LogStream) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditEventRepository interface {
|
|
||||||
Create(domain.AuditEvent) error
|
|
||||||
Get(id string) (domain.AuditEvent, error)
|
|
||||||
List(domain.AuditEventFilter) ([]domain.AuditEvent, error)
|
|
||||||
Update(domain.AuditEvent) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type MetricSampleRepository interface {
|
type MetricSampleRepository interface {
|
||||||
Create(domain.MetricSample) error
|
Create(domain.MetricSample) error
|
||||||
Get(id string) (domain.MetricSample, error)
|
Get(id string) (domain.MetricSample, error)
|
||||||
@@ -178,13 +171,6 @@ type BackupRepository interface {
|
|||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertRepository interface {
|
|
||||||
Create(domain.AlertRecord) error
|
|
||||||
Get(id string) (domain.AlertRecord, error)
|
|
||||||
List(domain.AlertFilter) ([]domain.AlertRecord, error)
|
|
||||||
Update(domain.AlertRecord) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type PluginLifecycleRepository interface {
|
type PluginLifecycleRepository interface {
|
||||||
Create(domain.PluginLifecycleInstallation) error
|
Create(domain.PluginLifecycleInstallation) error
|
||||||
Get(id string) (domain.PluginLifecycleInstallation, error)
|
Get(id string) (domain.PluginLifecycleInstallation, error)
|
||||||
@@ -254,10 +240,8 @@ type Store interface {
|
|||||||
ClientManagerBuildJobs() ClientManagerBuildJobRepository
|
ClientManagerBuildJobs() ClientManagerBuildJobRepository
|
||||||
RunUpdateJobs() RunUpdateJobRepository
|
RunUpdateJobs() RunUpdateJobRepository
|
||||||
LogStreams() LogStreamRepository
|
LogStreams() LogStreamRepository
|
||||||
AuditEvents() AuditEventRepository
|
|
||||||
MetricSamples() MetricSampleRepository
|
MetricSamples() MetricSampleRepository
|
||||||
Backups() BackupRepository
|
Backups() BackupRepository
|
||||||
Alerts() AlertRepository
|
|
||||||
PluginLifecycles() PluginLifecycleRepository
|
PluginLifecycles() PluginLifecycleRepository
|
||||||
AIConfigDiffs() AIConfigDiffRepository
|
AIConfigDiffs() AIConfigDiffRepository
|
||||||
GameClientBridgeCommands() GameClientBridgeCommandRepository
|
GameClientBridgeCommands() GameClientBridgeCommandRepository
|
||||||
@@ -287,10 +271,8 @@ type MemoryStore struct {
|
|||||||
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
||||||
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
||||||
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
||||||
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
|
||||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||||
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
|
|
||||||
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||||
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
||||||
bridgeCommands *memoryGameClientBridgeCommandRepository
|
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||||
@@ -397,11 +379,6 @@ func NewMemoryStore() *MemoryStore {
|
|||||||
domain.CopyLogStream,
|
domain.CopyLogStream,
|
||||||
matchLogStream,
|
matchLogStream,
|
||||||
),
|
),
|
||||||
auditEvents: newMemoryRepository(
|
|
||||||
func(event domain.AuditEvent) string { return event.ID },
|
|
||||||
domain.CopyAuditEvent,
|
|
||||||
matchAuditEvent,
|
|
||||||
),
|
|
||||||
metricSamples: newMemoryRepository(
|
metricSamples: newMemoryRepository(
|
||||||
func(sample domain.MetricSample) string { return sample.ID },
|
func(sample domain.MetricSample) string { return sample.ID },
|
||||||
domain.CopyMetricSample,
|
domain.CopyMetricSample,
|
||||||
@@ -412,11 +389,6 @@ func NewMemoryStore() *MemoryStore {
|
|||||||
domain.CopyBackupRecord,
|
domain.CopyBackupRecord,
|
||||||
matchBackup,
|
matchBackup,
|
||||||
),
|
),
|
||||||
alerts: newMemoryRepository(
|
|
||||||
func(alert domain.AlertRecord) string { return alert.ID },
|
|
||||||
domain.CopyAlertRecord,
|
|
||||||
matchAlert,
|
|
||||||
),
|
|
||||||
pluginLifecycle: newMemoryRepository(
|
pluginLifecycle: newMemoryRepository(
|
||||||
func(installation domain.PluginLifecycleInstallation) string { return installation.ID },
|
func(installation domain.PluginLifecycleInstallation) string { return installation.ID },
|
||||||
domain.CopyPluginLifecycleInstallation,
|
domain.CopyPluginLifecycleInstallation,
|
||||||
@@ -470,10 +442,8 @@ func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobReposito
|
|||||||
}
|
}
|
||||||
func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs }
|
func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs }
|
||||||
func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams }
|
func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams }
|
||||||
func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents }
|
|
||||||
func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples }
|
func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples }
|
||||||
func (store *MemoryStore) Backups() BackupRepository { return store.backups }
|
func (store *MemoryStore) Backups() BackupRepository { return store.backups }
|
||||||
func (store *MemoryStore) Alerts() AlertRepository { return store.alerts }
|
|
||||||
func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository {
|
func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository {
|
||||||
return store.pluginLifecycle
|
return store.pluginLifecycle
|
||||||
}
|
}
|
||||||
@@ -737,13 +707,6 @@ func matchLogStream(stream domain.LogStream, filter domain.LogStreamFilter) bool
|
|||||||
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey)
|
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func matchAuditEvent(event domain.AuditEvent, filter domain.AuditEventFilter) bool {
|
|
||||||
return (filter.ActorID == "" || event.ActorID == filter.ActorID) &&
|
|
||||||
(filter.ResourceKind == "" || event.ResourceKind == filter.ResourceKind) &&
|
|
||||||
(filter.ResourceID == "" || event.ResourceID == filter.ResourceID) &&
|
|
||||||
(filter.Result == "" || event.Result == filter.Result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchMetricSample(sample domain.MetricSample, filter domain.MetricSampleFilter) bool {
|
func matchMetricSample(sample domain.MetricSample, filter domain.MetricSampleFilter) bool {
|
||||||
return (filter.ServerInstanceID == "" || sample.ServerInstanceID == filter.ServerInstanceID) &&
|
return (filter.ServerInstanceID == "" || sample.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
(filter.After.IsZero() || sample.CollectedAt.After(filter.After)) &&
|
(filter.After.IsZero() || sample.CollectedAt.After(filter.After)) &&
|
||||||
@@ -755,13 +718,6 @@ func matchBackup(record domain.BackupRecord, filter domain.BackupFilter) bool {
|
|||||||
(filter.State == "" || record.State == filter.State)
|
(filter.State == "" || record.State == filter.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
func matchAlert(alert domain.AlertRecord, filter domain.AlertFilter) bool {
|
|
||||||
return (filter.State == "" || alert.State == filter.State) &&
|
|
||||||
(filter.SourceKind == "" || alert.SourceKind == filter.SourceKind) &&
|
|
||||||
(filter.SourceID == "" || alert.SourceID == filter.SourceID) &&
|
|
||||||
(filter.Severity == "" || alert.Severity == filter.Severity)
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchPluginLifecycle(installation domain.PluginLifecycleInstallation, filter domain.PluginLifecycleFilter) bool {
|
func matchPluginLifecycle(installation domain.PluginLifecycleInstallation, filter domain.PluginLifecycleFilter) bool {
|
||||||
return (filter.PluginID == "" || installation.PluginID == filter.PluginID) &&
|
return (filter.PluginID == "" || installation.PluginID == filter.PluginID) &&
|
||||||
(filter.ServerInstanceID == "" || installation.ServerInstanceID == filter.ServerInstanceID) &&
|
(filter.ServerInstanceID == "" || installation.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
|
|||||||
},
|
},
|
||||||
ServerDeploymentPlan: &domain.ServerDeploymentPlan{SchemaVersion: "1", Operation: "install", PluginID: "game.runtime", Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "tool"}}},
|
ServerDeploymentPlan: &domain.ServerDeploymentPlan{SchemaVersion: "1", Operation: "install", PluginID: "game.runtime", Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "tool"}}},
|
||||||
},
|
},
|
||||||
ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, AuditSummary: "bounded read", Content: "private-read"},
|
ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, Summary: "bounded read", Content: "private-read"},
|
||||||
}
|
}
|
||||||
if err := store.Jobs().Create(job); err != nil {
|
if err := store.Jobs().Create(job); err != nil {
|
||||||
t.Fatalf("create job: %v", err)
|
t.Fatalf("create job: %v", err)
|
||||||
@@ -277,7 +277,7 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
|||||||
PluginID: "game.runtime",
|
PluginID: "game.runtime",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, AuditSummary: "atomic write"},
|
ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, Summary: "atomic write"},
|
||||||
}
|
}
|
||||||
if err := source.MemoryStore.Jobs().Create(job); err != nil {
|
if err := source.MemoryStore.Jobs().Create(job); err != nil {
|
||||||
t.Fatalf("create source job: %v", err)
|
t.Fatalf("create source job: %v", err)
|
||||||
@@ -313,20 +313,13 @@ func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) {
|
|||||||
t.Fatalf("create file store: %v", err)
|
t.Fatalf("create file store: %v", err)
|
||||||
}
|
}
|
||||||
stamp := time.Date(2026, 7, 18, 14, 0, 0, 0, time.UTC)
|
stamp := time.Date(2026, 7, 18, 14, 0, 0, 0, time.UTC)
|
||||||
alert := domain.AlertRecord{
|
|
||||||
ID: "alert-capacity", SourceKind: "run-endpoint", SourceID: "run-1", RuleKey: "capacity.pressure",
|
|
||||||
Severity: domain.AlertSeverityWarning, State: domain.AlertStateAcknowledged, Title: "Capacity pressure",
|
|
||||||
Message: "endpoint capacity is temporarily under pressure", OccurrenceCount: 2, Retryable: true,
|
|
||||||
RetryAfterSeconds: 30, LastAuditEventID: "audit-1", LastSeenAt: stamp, AcknowledgedBy: "operator-1",
|
|
||||||
AcknowledgedAt: stamp, CreatedAt: stamp.Add(-time.Minute), UpdatedAt: stamp,
|
|
||||||
}
|
|
||||||
installation := domain.PluginLifecycleInstallation{
|
installation := domain.PluginLifecycleInstallation{
|
||||||
ID: "plugin-lifecycle-1", PluginID: "game.scum", ServerInstanceID: "server-1",
|
ID: "plugin-lifecycle-1", PluginID: "game.scum", ServerInstanceID: "server-1",
|
||||||
CurrentVersion: "1.0.0", TargetVersion: "2.0.0", PreviousVersion: "0.9.0",
|
CurrentVersion: "1.0.0", TargetVersion: "2.0.0", PreviousVersion: "0.9.0",
|
||||||
DesiredState: domain.PluginLifecycleStateEnabled, CurrentState: domain.PluginLifecycleStateUpgrading,
|
DesiredState: domain.PluginLifecycleStateEnabled, CurrentState: domain.PluginLifecycleStateUpgrading,
|
||||||
LastOperation: domain.PluginLifecycleOperationUpgrade, Compatibility: "compatible",
|
LastOperation: domain.PluginLifecycleOperationUpgrade, Compatibility: "compatible",
|
||||||
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade", AlertID: alert.ID,
|
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade",
|
||||||
AuditEventID: "audit-2", IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
|
IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
|
||||||
}
|
}
|
||||||
diff := domain.AIConfigDiffPreview{
|
diff := domain.AIConfigDiffPreview{
|
||||||
ID: "ai-config-diff-1", RequestID: "ai-request-1", CreatedBy: "operator-1",
|
ID: "ai-config-diff-1", RequestID: "ai-request-1", CreatedBy: "operator-1",
|
||||||
@@ -335,9 +328,6 @@ func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) {
|
|||||||
ProposedConfig: "MaxPlayers=80\n", DiffSummary: "review required before config write dispatch",
|
ProposedConfig: "MaxPlayers=80\n", DiffSummary: "review required before config write dispatch",
|
||||||
State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(30 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp,
|
State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(30 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp,
|
||||||
}
|
}
|
||||||
if err := store.Alerts().Create(alert); err != nil {
|
|
||||||
t.Fatalf("create alert: %v", err)
|
|
||||||
}
|
|
||||||
if err := store.PluginLifecycles().Create(installation); err != nil {
|
if err := store.PluginLifecycles().Create(installation); err != nil {
|
||||||
t.Fatalf("create plugin lifecycle: %v", err)
|
t.Fatalf("create plugin lifecycle: %v", err)
|
||||||
}
|
}
|
||||||
@@ -349,10 +339,6 @@ func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("restart file store: %v", err)
|
t.Fatalf("restart file store: %v", err)
|
||||||
}
|
}
|
||||||
gotAlert, alertErr := restarted.Alerts().Get(alert.ID)
|
|
||||||
if alertErr != nil || gotAlert.State != alert.State || gotAlert.OccurrenceCount != 2 || gotAlert.LastAuditEventID != alert.LastAuditEventID {
|
|
||||||
t.Fatalf("unexpected durable alert: alert=%+v err=%v", gotAlert, alertErr)
|
|
||||||
}
|
|
||||||
gotInstallation, lifecycleErr := restarted.PluginLifecycles().Get(installation.ID)
|
gotInstallation, lifecycleErr := restarted.PluginLifecycles().Get(installation.ID)
|
||||||
if lifecycleErr != nil || gotInstallation.CurrentState != installation.CurrentState || gotInstallation.TargetVersion != installation.TargetVersion || gotInstallation.JobID != installation.JobID {
|
if lifecycleErr != nil || gotInstallation.CurrentState != installation.CurrentState || gotInstallation.TargetVersion != installation.TargetVersion || gotInstallation.JobID != installation.JobID {
|
||||||
t.Fatalf("unexpected durable plugin lifecycle: installation=%+v err=%v", gotInstallation, lifecycleErr)
|
t.Fatalf("unexpected durable plugin lifecycle: installation=%+v err=%v", gotInstallation, lifecycleErr)
|
||||||
|
|||||||
@@ -91,16 +91,6 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
|||||||
}
|
}
|
||||||
result, err := svc.aiProviderClient.Invoke(provider, request)
|
result, err := svc.aiProviderClient.Invoke(provider, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
auditID, auditErr := svc.recordAuditEventWithID(user.ID, "ai.provider.invoke.failed", "ai-provider", provider.ID, domain.AuditResultFailed, "AI provider invocation failed safely")
|
|
||||||
if auditErr != nil {
|
|
||||||
return domain.AIInvocationResponse{}, auditErr
|
|
||||||
}
|
|
||||||
svc.productionMu.Lock()
|
|
||||||
_, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider invocation failed", Message: "AI provider invocation failed safely", Retryable: false, LastAuditEventID: auditID})
|
|
||||||
svc.productionMu.Unlock()
|
|
||||||
if alertErr != nil {
|
|
||||||
return domain.AIInvocationResponse{}, alertErr
|
|
||||||
}
|
|
||||||
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
|
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
|
||||||
RequestID: request.RequestID,
|
RequestID: request.RequestID,
|
||||||
Purpose: request.Purpose,
|
Purpose: request.Purpose,
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ func (svc *CoreService) OpenArtifactDownloadForSession(sessionID string, request
|
|||||||
if err := validator.ValidateArtifactDownloadReference(reference); err != nil {
|
if err := validator.ValidateArtifactDownloadReference(reference); err != nil {
|
||||||
return domain.ArtifactDownloadReference{}, err
|
return domain.ArtifactDownloadReference{}, err
|
||||||
}
|
}
|
||||||
if err := svc.auditArtifactDownload(sessionID, artifact); err != nil {
|
|
||||||
return domain.ArtifactDownloadReference{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyArtifactDownloadReference(reference), nil
|
return domain.CopyArtifactDownloadReference(reference), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,13 +28,12 @@ func (svc *CoreService) DeployClientManagerForSession(sessionID string, request
|
|||||||
if err := validator.ValidateClientManagerDeployRequest(request); err != nil {
|
if err := validator.ValidateClientManagerDeployRequest(request); err != nil {
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
user, instance, plugin, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerDeploy, "client-manager.deploy.denied")
|
_, instance, plugin, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerDeploy, "client-manager.deploy.denied")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID)
|
distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.deploy.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager deployment denied: distribution ownership, target, revision, or key fence is invalid")
|
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution)
|
installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution)
|
||||||
@@ -93,9 +92,6 @@ func (svc *CoreService) DeployClientManagerForSession(sessionID string, request
|
|||||||
if job.ID != installation.CurrentJobID {
|
if job.ID != installation.CurrentJobID {
|
||||||
return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy job fence is invalid")
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy job fence is invalid")
|
||||||
}
|
}
|
||||||
if err := svc.recordAuditEvent(user.ID, "client-manager.deploy", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued typed client-manager deployment for current artifact and key generation"); err != nil {
|
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
|
||||||
}
|
|
||||||
return svc.clientManagerLifecycleView(installation)
|
return svc.clientManagerLifecycleView(installation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,12 +103,11 @@ func (svc *CoreService) ControlClientManagerForSession(sessionID string, request
|
|||||||
if request.Operation == domain.ClientManagerOperationRollback {
|
if request.Operation == domain.ClientManagerOperationRollback {
|
||||||
capability = domain.JobCapabilityClientManagerRollback
|
capability = domain.JobCapabilityClientManagerRollback
|
||||||
}
|
}
|
||||||
user, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager."+string(request.Operation)+".denied")
|
_, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager."+string(request.Operation)+".denied")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
if !containsString(profile.Lifecycle.Actions, string(request.Operation)) {
|
if !containsString(profile.Lifecycle.Actions, string(request.Operation)) {
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager."+string(request.Operation)+".denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager control denied: action is not declared")
|
|
||||||
return domain.ClientManagerLifecycleView{}, ErrForbidden
|
return domain.ClientManagerLifecycleView{}, ErrForbidden
|
||||||
}
|
}
|
||||||
installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key)
|
installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key)
|
||||||
@@ -175,7 +170,6 @@ func (svc *CoreService) ControlClientManagerForSession(sessionID string, request
|
|||||||
if job.ID != installation.CurrentJobID {
|
if job.ID != installation.CurrentJobID {
|
||||||
return domain.ClientManagerLifecycleView{}, validationError("client-manager control job fence is invalid")
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager control job fence is invalid")
|
||||||
}
|
}
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager."+string(request.Operation), "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued typed client-manager "+string(request.Operation)+" operation")
|
|
||||||
return svc.clientManagerLifecycleView(installation)
|
return svc.clientManagerLifecycleView(installation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +177,7 @@ func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request
|
|||||||
if err := validator.ValidateClientManagerUpdateRequest(request); err != nil {
|
if err := validator.ValidateClientManagerUpdateRequest(request); err != nil {
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
user, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUpdate, "client-manager.update.denied")
|
_, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUpdate, "client-manager.update.denied")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
@@ -199,11 +193,9 @@ func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request
|
|||||||
}
|
}
|
||||||
distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID)
|
distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.update.denied", "client-manager-installation", installation.ID, domain.AuditResultDenied, "client-manager update denied: artifact scope or generation is invalid")
|
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
if distribution.ArtifactID == installation.ActiveArtifactID || !clientManagerVersionAllowed(profile, installation.ActiveVersion, clientManagerDistributionVersion(distribution, profile)) {
|
if distribution.ArtifactID == installation.ActiveArtifactID || !clientManagerVersionAllowed(profile, installation.ActiveVersion, clientManagerDistributionVersion(distribution, profile)) {
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.update.denied", "client-manager-installation", installation.ID, domain.AuditResultDenied, "client-manager update denied: artifact is not compatible with the active deployment")
|
|
||||||
return domain.ClientManagerLifecycleView{}, validationError("client-manager update artifact is incompatible")
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager update artifact is incompatible")
|
||||||
}
|
}
|
||||||
if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil {
|
if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil {
|
||||||
@@ -246,7 +238,6 @@ func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request
|
|||||||
if job.ID != installation.CurrentJobID {
|
if job.ID != installation.CurrentJobID {
|
||||||
return domain.ClientManagerLifecycleView{}, validationError("client-manager update job fence is invalid")
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager update job fence is invalid")
|
||||||
}
|
}
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.update", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued approved staged client-manager update with rollback retention")
|
|
||||||
return svc.clientManagerLifecycleView(installation)
|
return svc.clientManagerLifecycleView(installation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +245,7 @@ func (svc *CoreService) UninstallClientManagerForSession(sessionID string, reque
|
|||||||
if err := validator.ValidateClientManagerUninstallRequest(request); err != nil {
|
if err := validator.ValidateClientManagerUninstallRequest(request); err != nil {
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
user, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUninstall, "client-manager.uninstall.denied")
|
_, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUninstall, "client-manager.uninstall.denied")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
@@ -304,13 +295,11 @@ func (svc *CoreService) UninstallClientManagerForSession(sessionID string, reque
|
|||||||
if job.ID != installation.CurrentJobID {
|
if job.ID != installation.CurrentJobID {
|
||||||
return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall job fence is invalid")
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall job fence is invalid")
|
||||||
}
|
}
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.uninstall", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued safe controlled-workspace uninstall")
|
|
||||||
return svc.clientManagerLifecycleView(installation)
|
return svc.clientManagerLifecycleView(installation)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, request domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error) {
|
func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, request domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error) {
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
if _, err := svc.GetCurrentUser(sessionID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
||||||
@@ -334,7 +323,6 @@ func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, r
|
|||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.revoke", "client-manager-installation", installation.ID, domain.AuditResultSuccess, "revoked Client Manager component session without exposing token material")
|
|
||||||
return svc.clientManagerLifecycleView(installation)
|
return svc.clientManagerLifecycleView(installation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +348,7 @@ func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string,
|
|||||||
case domain.ClientManagerOperationUninstall:
|
case domain.ClientManagerOperationUninstall:
|
||||||
capability = domain.JobCapabilityClientManagerUninstall
|
capability = domain.JobCapabilityClientManagerUninstall
|
||||||
}
|
}
|
||||||
user, _, _, _, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager.retry.denied")
|
_, _, _, _, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager.retry.denied")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ClientManagerLifecycleView{}, err
|
return domain.ClientManagerLifecycleView{}, err
|
||||||
}
|
}
|
||||||
@@ -402,7 +390,6 @@ func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string,
|
|||||||
if job.ID != installation.CurrentJobID {
|
if job.ID != installation.CurrentJobID {
|
||||||
return domain.ClientManagerLifecycleView{}, validationError("client-manager retry job fence is invalid")
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager retry job fence is invalid")
|
||||||
}
|
}
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.retry", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued bounded retry using the existing deployment generation fence")
|
|
||||||
return svc.clientManagerLifecycleView(installation)
|
return svc.clientManagerLifecycleView(installation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,7 +459,6 @@ func (svc *CoreService) authorizeClientManagerLifecycle(sessionID, serverInstanc
|
|||||||
}
|
}
|
||||||
profile, err := findRuntimeClientManagerProfile(plugin, profileKey)
|
profile, err := findRuntimeClientManagerProfile(plugin, profileKey)
|
||||||
if err != nil || profile.Deployment.Mode != "run-supervised" || !containsString(profile.Deployment.RequiredRunCapabilities, capability) {
|
if err != nil || profile.Deployment.Mode != "run-supervised" || !containsString(profile.Deployment.RequiredRunCapabilities, capability) {
|
||||||
_ = svc.recordAuditEvent(user.ID, deniedAction, "server-instance", instance.ID, domain.AuditResultDenied, "client-manager lifecycle denied: profile or capability is not declared")
|
|
||||||
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, ErrForbidden
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, ErrForbidden
|
||||||
}
|
}
|
||||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||||
@@ -480,7 +466,6 @@ func (svc *CoreService) authorizeClientManagerLifecycle(sessionID, serverInstanc
|
|||||||
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
||||||
}
|
}
|
||||||
if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil {
|
if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, deniedAction, "server-instance", instance.ID, domain.AuditResultDenied, "client-manager lifecycle denied: assigned Run endpoint is offline or unsupported")
|
|
||||||
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
||||||
}
|
}
|
||||||
return user, instance, plugin, profile, endpoint, nil
|
return user, instance, plugin, profile, endpoint, nil
|
||||||
@@ -974,7 +959,7 @@ func (svc *CoreService) projectClientManagerLifecycleResult(job domain.Job, stam
|
|||||||
installation.LastHeartbeatSequence = 0
|
installation.LastHeartbeatSequence = 0
|
||||||
case domain.ClientManagerOperationUninstall:
|
case domain.ClientManagerOperationUninstall:
|
||||||
installation.Status = domain.ClientManagerLifecycleUninstalled
|
installation.Status = domain.ClientManagerLifecycleUninstalled
|
||||||
installation.Phase = "controlled workspace removed"
|
installation.Phase = "managed workspace removed"
|
||||||
installation.Health = domain.ClientManagerHealthOffline
|
installation.Health = domain.ClientManagerHealthOffline
|
||||||
installation.HealthReason = "uninstalled"
|
installation.HealthReason = "uninstalled"
|
||||||
installation.ActiveArtifactID = ""
|
installation.ActiveArtifactID = ""
|
||||||
@@ -994,11 +979,7 @@ func (svc *CoreService) projectClientManagerLifecycleResult(job domain.Job, stam
|
|||||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result := domain.AuditResultSuccess
|
return nil
|
||||||
if !success {
|
|
||||||
result = domain.AuditResultFailed
|
|
||||||
}
|
|
||||||
return svc.recordAuditEvent("run:"+installation.RunEndpointID, "client-manager."+string(installation.LastOperation), "client-manager-installation", installation.ID, result, "client-manager lifecycle job reached a bounded terminal result")
|
|
||||||
}
|
}
|
||||||
return repo.ErrNotFound
|
return repo.ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -1134,7 +1115,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist
|
|||||||
}
|
}
|
||||||
stamp := svc.now()
|
stamp := svc.now()
|
||||||
if request.Timestamp.Before(stamp.Add(-clientManagerRegistrationWindow)) || request.Timestamp.After(stamp.Add(clientManagerRegistrationWindow)) {
|
if request.Timestamp.Before(stamp.Add(-clientManagerRegistrationWindow)) || request.Timestamp.After(stamp.Add(clientManagerRegistrationWindow)) {
|
||||||
_ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: timestamp expired")
|
|
||||||
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
||||||
}
|
}
|
||||||
installation, err := svc.store.ClientManagerInstallations().Get(request.InstallationID)
|
installation, err := svc.store.ClientManagerInstallations().Get(request.InstallationID)
|
||||||
@@ -1142,7 +1122,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist
|
|||||||
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
||||||
}
|
}
|
||||||
if installation.ServerInstanceID != request.ServerInstanceID || installation.ProfileKey != request.ProfileKey || installation.ActiveArtifactID != request.ArtifactID || installation.ActiveVersion != request.Version || installation.ActiveRevision != request.SourceRevision || installation.TargetOS != request.TargetOS || installation.TargetArch != request.TargetArch || installation.KeyGeneration != request.KeyGeneration || installation.DeploymentGeneration != request.DeploymentGeneration || installation.RequiresRedeploy || installation.Status == domain.ClientManagerLifecycleUninstalled {
|
if installation.ServerInstanceID != request.ServerInstanceID || installation.ProfileKey != request.ProfileKey || installation.ActiveArtifactID != request.ArtifactID || installation.ActiveVersion != request.Version || installation.ActiveRevision != request.SourceRevision || installation.TargetOS != request.TargetOS || installation.TargetArch != request.TargetArch || installation.KeyGeneration != request.KeyGeneration || installation.DeploymentGeneration != request.DeploymentGeneration || installation.RequiresRedeploy || installation.Status == domain.ClientManagerLifecycleUninstalled {
|
||||||
_ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: identity or deployment fence is stale")
|
|
||||||
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
||||||
}
|
}
|
||||||
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
||||||
@@ -1155,7 +1134,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist
|
|||||||
}
|
}
|
||||||
profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey)
|
profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey)
|
||||||
if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, request.Capabilities) {
|
if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, request.Capabilities) {
|
||||||
_ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: capabilities do not match declaration")
|
|
||||||
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
||||||
}
|
}
|
||||||
key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey)
|
key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey)
|
||||||
@@ -1168,12 +1146,10 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist
|
|||||||
}
|
}
|
||||||
expected := clientManagerRegistrationSignature(plainKey, request)
|
expected := clientManagerRegistrationSignature(plainKey, request)
|
||||||
if subtle.ConstantTimeCompare([]byte(expected), []byte(request.Signature)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(expected), []byte(request.Signature)) != 1 {
|
||||||
_ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: signature mismatch")
|
|
||||||
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
||||||
}
|
}
|
||||||
nonceID := clientManagerNonceID(request.InstallationID, request.Nonce)
|
nonceID := clientManagerNonceID(request.InstallationID, request.Nonce)
|
||||||
if _, err := svc.store.ClientManagerNonces().Get(nonceID); err == nil {
|
if _, err := svc.store.ClientManagerNonces().Get(nonceID); err == nil {
|
||||||
_ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: nonce replay")
|
|
||||||
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
||||||
} else if !errors.Is(err, repo.ErrNotFound) {
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
||||||
return domain.ClientManagerRegisterResult{}, err
|
return domain.ClientManagerRegisterResult{}, err
|
||||||
@@ -1209,7 +1185,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist
|
|||||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||||
return domain.ClientManagerRegisterResult{}, err
|
return domain.ClientManagerRegisterResult{}, err
|
||||||
}
|
}
|
||||||
_ = svc.recordAuditEvent("client-manager:"+installation.ID, "client-manager.register", "client-manager-installation", installation.ID, domain.AuditResultSuccess, "Client Manager registered with an isolated expiring component session")
|
|
||||||
return domain.ClientManagerRegisterResult{Accepted: true, InstallationID: installation.ID, SessionToken: token, ExpiresAt: session.ExpiresAt, HeartbeatEvery: profile.Health.IntervalSeconds, ServerTime: stamp}, nil
|
return domain.ClientManagerRegisterResult{Accepted: true, InstallationID: installation.ID, SessionToken: token, ExpiresAt: session.ExpiresAt, HeartbeatEvery: profile.Health.IntervalSeconds, ServerTime: stamp}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ func claimClientManagerJob(t *testing.T, svc *CoreService, sessionToken, capabil
|
|||||||
|
|
||||||
func completeClientManagerJob(t *testing.T, svc *CoreService, sessionToken string, claim domain.RunJobClaimResult, state domain.JobState, kind, processState string) {
|
func completeClientManagerJob(t *testing.T, svc *CoreService, sessionToken string, claim domain.RunJobClaimResult, state domain.JobState, kind, processState string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: state, Progress: domain.RunJobProgressReport{Percent: 100, Message: "client-manager lifecycle terminal"}, Message: "client-manager lifecycle terminal", ExecutionResult: domain.JobExecutionResult{Kind: kind, ProcessState: processState, AuditSummary: "bounded lifecycle result"}})
|
_, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: state, Progress: domain.RunJobProgressReport{Percent: 100, Message: "client-manager lifecycle terminal"}, Message: "client-manager lifecycle terminal", ExecutionResult: domain.JobExecutionResult{Kind: kind, ProcessState: processState, Summary: "bounded lifecycle result"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("complete lifecycle job: %v", err)
|
t.Fatalf("complete lifecycle job: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ func TestCoreServiceRunLifecycleReportProjectsGeneratedRunFacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
registered := registerGeneratedRunForStatusTest(t, svc, instance, plugin.ID)
|
registered := registerGeneratedRunForStatusTest(t, svc, instance, plugin.ID)
|
||||||
|
|
||||||
reported, err := svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", AuditSummary: "private supervised process identity"}})
|
reported, err := svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", Summary: "private supervised process identity"}})
|
||||||
if err != nil || !reported.Accepted || reported.ProjectedState != domain.ServerInstanceStateRunning {
|
if err != nil || !reported.Accepted || reported.ProjectedState != domain.ServerInstanceStateRunning {
|
||||||
t.Fatalf("expected accepted lifecycle report projected running, result=%+v err=%v", reported, err)
|
t.Fatalf("expected accepted lifecycle report projected running, result=%+v err=%v", reported, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ func (svc *CoreService) projectDependencyAndRunUpdateResult(job domain.Job, stam
|
|||||||
if err := svc.store.DependencyStatuses().Update(status); err != nil {
|
if err := svc.store.DependencyStatuses().Update(status); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return svc.recordAuditEvent("run", "dependency.result", "server-instance", job.ServerInstanceID, auditResultForJob(job), status.Message)
|
return nil
|
||||||
}
|
}
|
||||||
if job.Capability != domain.JobCapabilityRunSelfUpdate {
|
if job.Capability != domain.JobCapabilityRunSelfUpdate {
|
||||||
return nil
|
return nil
|
||||||
@@ -494,7 +494,7 @@ func (svc *CoreService) projectDependencyAndRunUpdateResult(job domain.Job, stam
|
|||||||
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
|
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return svc.recordAuditEvent("run", "run.update.result", "server-instance", job.ServerInstanceID, auditResultForJob(job), update.Message)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) projectDependencyAndRunUpdateProgress(job domain.Job, stamp time.Time) error {
|
func (svc *CoreService) projectDependencyAndRunUpdateProgress(job domain.Job, stamp time.Time) error {
|
||||||
@@ -589,26 +589,9 @@ func (svc *CoreService) ReportRunUpdateHealth(report domain.RunUpdateHealthRepor
|
|||||||
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
|
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
|
||||||
return domain.RunUpdateHealthResult{}, err
|
return domain.RunUpdateHealthResult{}, err
|
||||||
}
|
}
|
||||||
auditResult := domain.AuditResultSuccess
|
|
||||||
if report.Outcome == "rolled-back" {
|
|
||||||
auditResult = domain.AuditResultFailed
|
|
||||||
}
|
|
||||||
if err := svc.recordAuditEvent("run", "run.update.health", "server-instance", update.ServerInstanceID, auditResult, update.Message); err != nil {
|
|
||||||
return domain.RunUpdateHealthResult{}, err
|
|
||||||
}
|
|
||||||
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
|
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func auditResultForJob(job domain.Job) domain.AuditResult {
|
|
||||||
if job.State == domain.JobStateSucceeded {
|
|
||||||
return domain.AuditResultSuccess
|
|
||||||
}
|
|
||||||
if job.State == domain.JobStateCancelled {
|
|
||||||
return domain.AuditResultDenied
|
|
||||||
}
|
|
||||||
return domain.AuditResultFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameRunUpdateTarget(existing, expected domain.RunUpdateJob) bool {
|
func sameRunUpdateTarget(existing, expected domain.RunUpdateJob) bool {
|
||||||
return existing.ServerInstanceID == expected.ServerInstanceID && existing.RunEndpointID == expected.RunEndpointID && existing.ArtifactID == expected.ArtifactID && existing.Checksum == expected.Checksum && existing.TargetOS == expected.TargetOS && existing.TargetArch == expected.TargetArch && existing.TargetRelease == expected.TargetRelease && existing.JobID == expected.JobID && existing.IdempotencyKey == expected.IdempotencyKey
|
return existing.ServerInstanceID == expected.ServerInstanceID && existing.RunEndpointID == expected.RunEndpointID && existing.ArtifactID == expected.ArtifactID && existing.Checksum == expected.Checksum && existing.TargetOS == expected.TargetOS && existing.TargetArch == expected.TargetArch && existing.TargetRelease == expected.TargetRelease && existing.JobID == expected.JobID && existing.IdempotencyKey == expected.IdempotencyKey
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,18 +35,6 @@ func TestDependencyCatalogRequiresCurrentReviewedDigest(t *testing.T) {
|
|||||||
t.Fatalf("stale digest created a job: %+v", job)
|
t.Fatalf("stale digest created a job: %+v", job)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audits, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list audits: %v", err)
|
|
||||||
}
|
|
||||||
foundDenied := false
|
|
||||||
for _, audit := range audits {
|
|
||||||
foundDenied = foundDenied || audit.Action == "dependency.install.denied"
|
|
||||||
}
|
|
||||||
if !foundDenied {
|
|
||||||
t.Fatalf("expected stale digest audit, got %+v", audits)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.PlanDigest = catalog.Plans[0].Digest
|
request.PlanDigest = catalog.Plans[0].Digest
|
||||||
request.IdempotencyKey = "dependency-current-digest"
|
request.IdempotencyKey = "dependency-current-digest"
|
||||||
job, err := svc.QueueDependencyJobForSession(session, request)
|
job, err := svc.QueueDependencyJobForSession(session, request)
|
||||||
@@ -136,7 +124,7 @@ func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
evidence, _ := json.Marshal(domain.DependencyExecutionEvidence{ProbeKey: input.Probe.Key, PlanDigest: input.PlanDigest, State: string(domain.DependencyStatePresent), Evidence: "OpenJDK 21"})
|
evidence, _ := json.Marshal(domain.DependencyExecutionEvidence{ProbeKey: input.Probe.Key, PlanDigest: input.PlanDigest, State: string(domain.DependencyStatePresent), Evidence: "OpenJDK 21"})
|
||||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "dependency probe completed"}, ResultRef: "artifact://jobs/dependency-check/result", Message: "dependency probe completed", ExecutionResult: domain.JobExecutionResult{Kind: "dependency.check", Checksum: input.PlanDigest, AuditSummary: "dependency probe completed", Content: string(evidence)}}); err != nil {
|
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "dependency probe completed"}, ResultRef: "artifact://jobs/dependency-check/result", Message: "dependency probe completed", ExecutionResult: domain.JobExecutionResult{Kind: "dependency.check", Checksum: input.PlanDigest, Summary: "dependency probe completed", Content: string(evidence)}}); err != nil {
|
||||||
t.Fatalf("complete dependency result: %v", err)
|
t.Fatalf("complete dependency result: %v", err)
|
||||||
}
|
}
|
||||||
projected, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
projected, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
||||||
@@ -248,7 +236,7 @@ func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
evidence, _ := json.Marshal(domain.RunUpdateExecutionEvidence{TargetRelease: update.TargetRelease, Phase: "staged"})
|
evidence, _ := json.Marshal(domain.RunUpdateExecutionEvidence{TargetRelease: update.TargetRelease, Phase: "staged"})
|
||||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "Run update verified and staged"}, ResultRef: "artifact://jobs/run-update/staged", Message: "Run update verified and staged", ExecutionResult: domain.JobExecutionResult{Kind: "run.update.staged", Checksum: update.Checksum, SizeBytes: int64(len(payload)), AuditSummary: "verified update staged", Content: string(evidence)}}); err != nil {
|
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "Run update verified and staged"}, ResultRef: "artifact://jobs/run-update/staged", Message: "Run update verified and staged", ExecutionResult: domain.JobExecutionResult{Kind: "run.update.staged", Checksum: update.Checksum, SizeBytes: int64(len(payload)), Summary: "verified update staged", Content: string(evidence)}}); err != nil {
|
||||||
t.Fatalf("complete staged Run update: %v", err)
|
t.Fatalf("complete staged Run update: %v", err)
|
||||||
}
|
}
|
||||||
updates, err := svc.ListRunUpdateJobsForSession(session, instance.ID)
|
updates, err := svc.ListRunUpdateJobsForSession(session, instance.ID)
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
|||||||
return domain.RunDistribution{}, err
|
return domain.RunDistribution{}, err
|
||||||
}
|
}
|
||||||
if err := validatePluginTarget(plugin, request.TargetOS); err != nil {
|
if err := validatePluginTarget(plugin, request.TargetOS); err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, "run.generate.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run generation denied: unsupported target")
|
|
||||||
return domain.RunDistribution{}, err
|
return domain.RunDistribution{}, err
|
||||||
}
|
}
|
||||||
if deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
|
if deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
|
||||||
@@ -47,7 +46,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ready, reason := svc.distributionBuilderReadiness(); !ready {
|
if ready, reason := svc.distributionBuilderReadiness(); !ready {
|
||||||
_ = svc.recordAuditEvent(user.ID, "run.generate.denied", "server-instance", instance.ID, domain.AuditResultDenied, reason)
|
|
||||||
return domain.RunDistribution{}, validationError(reason)
|
return domain.RunDistribution{}, validationError(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +112,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
|||||||
return domain.RunDistribution{}, validationError("distribution build idempotency key conflicts with another job")
|
return domain.RunDistribution{}, validationError("distribution build idempotency key conflicts with another job")
|
||||||
}
|
}
|
||||||
svc.enqueueDistributionBuild(job)
|
svc.enqueueDistributionBuild(job)
|
||||||
if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultQueued, "queued run binary build job in the platform builder with redacted runtime key ref"); err != nil {
|
|
||||||
return domain.RunDistribution{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyRunDistribution(distribution), nil
|
return domain.CopyRunDistribution(distribution), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,26 +139,22 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
|||||||
return domain.ClientManagerDistribution{}, err
|
return domain.ClientManagerDistribution{}, err
|
||||||
}
|
}
|
||||||
if err := validatePluginTarget(plugin, request.TargetOS); err != nil {
|
if err := validatePluginTarget(plugin, request.TargetOS); err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager build denied: unsupported target")
|
|
||||||
return domain.ClientManagerDistribution{}, err
|
return domain.ClientManagerDistribution{}, err
|
||||||
}
|
}
|
||||||
profile, err := findRuntimeClientManagerProfile(plugin, request.ProfileKey)
|
profile, err := findRuntimeClientManagerProfile(plugin, request.ProfileKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager build denied: profile is not declared")
|
|
||||||
return domain.ClientManagerDistribution{}, err
|
return domain.ClientManagerDistribution{}, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(request.SourceRevision) == "" {
|
if strings.TrimSpace(request.SourceRevision) == "" {
|
||||||
request.SourceRevision = clientManagerProfileRevision(profile)
|
request.SourceRevision = clientManagerProfileRevision(profile)
|
||||||
}
|
}
|
||||||
if !clientManagerProfileSupportsTarget(profile, request.TargetOS, request.TargetArch) || request.RepositoryURL != profile.RepositoryURL || !clientManagerProfileAllowsRevision(profile, request.SourceRevision) {
|
if !clientManagerProfileSupportsTarget(profile, request.TargetOS, request.TargetArch) || request.RepositoryURL != profile.RepositoryURL || !clientManagerProfileAllowsRevision(profile, request.SourceRevision) {
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager build denied: repository, revision, or target is not declared")
|
|
||||||
return domain.ClientManagerDistribution{}, validationError("client-manager build must match the declared profile repository, revision, and target")
|
return domain.ClientManagerDistribution{}, validationError("client-manager build must match the declared profile repository, revision, and target")
|
||||||
}
|
}
|
||||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil {
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil {
|
||||||
return domain.ClientManagerDistribution{}, err
|
return domain.ClientManagerDistribution{}, err
|
||||||
}
|
}
|
||||||
if ready, reason := svc.distributionBuilderReadiness(); !ready {
|
if ready, reason := svc.distributionBuilderReadiness(); !ready {
|
||||||
_ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, reason)
|
|
||||||
return domain.ClientManagerDistribution{}, validationError(reason)
|
return domain.ClientManagerDistribution{}, validationError(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,9 +262,6 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
|||||||
return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job")
|
return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job")
|
||||||
}
|
}
|
||||||
svc.enqueueDistributionBuild(job)
|
svc.enqueueDistributionBuild(job)
|
||||||
if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultQueued, "queued client-manager source build in the platform builder with redacted runtime key ref"); err != nil {
|
|
||||||
return domain.ClientManagerDistribution{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyClientManagerDistribution(distribution), nil
|
return domain.CopyClientManagerDistribution(distribution), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,9 +364,6 @@ func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request do
|
|||||||
return domain.EncryptedComponentKey{}, err
|
return domain.EncryptedComponentKey{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := svc.recordAuditEvent(user.ID, "runtime-key.reset", "server-instance", instance.ID, domain.AuditResultSuccess, "reset "+string(request.ComponentKind)+" key; previous packages revoked"); err != nil {
|
|
||||||
return domain.EncryptedComponentKey{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyEncryptedComponentKey(newKey), nil
|
return domain.CopyEncryptedComponentKey(newKey), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +389,6 @@ func (svc *CoreService) AuthenticateComponent(request domain.ComponentAuthentica
|
|||||||
}
|
}
|
||||||
if key.Generation != request.Generation {
|
if key.Generation != request.Generation {
|
||||||
result.Reason = "key generation is no longer current"
|
result.Reason = "key generation is no longer current"
|
||||||
_ = svc.recordAuditEvent("runtime", "runtime-key.auth", "server-instance", request.ServerInstanceID, domain.AuditResultDenied, "component authentication denied: stale generation")
|
|
||||||
return domain.CopyComponentAuthenticationResult(result), nil
|
return domain.CopyComponentAuthenticationResult(result), nil
|
||||||
}
|
}
|
||||||
plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
|
plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
|
||||||
@@ -413,7 +397,6 @@ func (svc *CoreService) AuthenticateComponent(request domain.ComponentAuthentica
|
|||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare([]byte(plainKey), []byte(request.Key)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(plainKey), []byte(request.Key)) != 1 {
|
||||||
result.Reason = "key is not current"
|
result.Reason = "key is not current"
|
||||||
_ = svc.recordAuditEvent("runtime", "runtime-key.auth", "server-instance", request.ServerInstanceID, domain.AuditResultDenied, "component authentication denied: key mismatch")
|
|
||||||
return domain.CopyComponentAuthenticationResult(result), nil
|
return domain.CopyComponentAuthenticationResult(result), nil
|
||||||
}
|
}
|
||||||
result.Allowed = true
|
result.Allowed = true
|
||||||
@@ -523,14 +506,12 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain
|
|||||||
return domain.RunUpdateJob{}, err
|
return domain.RunUpdateJob{}, err
|
||||||
}
|
}
|
||||||
if artifact.State != domain.ArtifactStateAvailable {
|
if artifact.State != domain.ArtifactStateAvailable {
|
||||||
_ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: artifact is unavailable")
|
|
||||||
return domain.RunUpdateJob{}, validationError("artifact must be available")
|
return domain.RunUpdateJob{}, validationError("artifact must be available")
|
||||||
}
|
}
|
||||||
if request.Checksum == "" {
|
if request.Checksum == "" {
|
||||||
request.Checksum = artifact.Checksum
|
request.Checksum = artifact.Checksum
|
||||||
}
|
}
|
||||||
if request.Checksum != artifact.Checksum {
|
if request.Checksum != artifact.Checksum {
|
||||||
_ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: checksum mismatch")
|
|
||||||
return domain.RunUpdateJob{}, validationError("checksum must match artifact")
|
return domain.RunUpdateJob{}, validationError("checksum must match artifact")
|
||||||
}
|
}
|
||||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||||
@@ -546,7 +527,6 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain
|
|||||||
}
|
}
|
||||||
distribution, err := findRunDistributionForArtifact(distributions, artifact.ID)
|
distribution, err := findRunDistributionForArtifact(distributions, artifact.ID)
|
||||||
if err != nil || distribution.RunEndpointID != endpoint.ID || distribution.TargetOS != endpoint.Platform || distribution.TargetArch != endpoint.Architecture || distribution.Checksum != artifact.Checksum || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID {
|
if err != nil || distribution.RunEndpointID != endpoint.ID || distribution.TargetOS != endpoint.Platform || distribution.TargetArch != endpoint.Architecture || distribution.Checksum != artifact.Checksum || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID {
|
||||||
_ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: artifact is not an approved target-matched Run distribution")
|
|
||||||
return domain.RunUpdateJob{}, validationError("artifact must be an approved target-matched Run distribution")
|
return domain.RunUpdateJob{}, validationError("artifact must be an approved target-matched Run distribution")
|
||||||
}
|
}
|
||||||
job, err := svc.CreateJob(domain.Job{
|
job, err := svc.CreateJob(domain.Job{
|
||||||
@@ -560,7 +540,6 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain
|
|||||||
Progress: domain.JobProgress{Percent: 0, Message: "run self-update queued"},
|
Progress: domain.JobProgress{Percent: 0, Message: "run self-update queued"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: endpoint unsupported or offline")
|
|
||||||
return domain.RunUpdateJob{}, err
|
return domain.RunUpdateJob{}, err
|
||||||
}
|
}
|
||||||
stamp := svc.now()
|
stamp := svc.now()
|
||||||
@@ -598,9 +577,6 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain
|
|||||||
}
|
}
|
||||||
return domain.RunUpdateJob{}, err
|
return domain.RunUpdateJob{}, err
|
||||||
}
|
}
|
||||||
if err := svc.recordAuditEvent(user.ID, "run.update", "server-instance", instance.ID, domain.AuditResultQueued, "queued run self-update job with artifact checksum"); err != nil {
|
|
||||||
return domain.RunUpdateJob{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyRunUpdateJob(updateJob), nil
|
return domain.CopyRunUpdateJob(updateJob), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +601,6 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d
|
|||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
if !pluginDeclares(plugin, "server.dependencies.manage") {
|
if !pluginDeclares(plugin, "server.dependencies.manage") {
|
||||||
_ = svc.recordAuditEvent(user.ID, "dependency.install.denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: plugin permission is not declared")
|
|
||||||
return domain.Job{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
|
return domain.Job{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
|
||||||
}
|
}
|
||||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "dependency.install.denied"); err != nil {
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "dependency.install.denied"); err != nil {
|
||||||
@@ -656,20 +631,17 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d
|
|||||||
}
|
}
|
||||||
expectedDigest := dependencyPlanDigest(resolution, probe, plan)
|
expectedDigest := dependencyPlanDigest(resolution, probe, plan)
|
||||||
if request.Install && request.PlanDigest != expectedDigest {
|
if request.Install && request.PlanDigest != expectedDigest {
|
||||||
_ = svc.recordAuditEvent(user.ID, "dependency.install.denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency install denied: reviewed plan digest is stale or missing")
|
|
||||||
return domain.Job{}, validationError("planDigest must match the current reviewed install plan")
|
return domain.Job{}, validationError("planDigest must match the current reviewed install plan")
|
||||||
}
|
}
|
||||||
request.PlanDigest = expectedDigest
|
request.PlanDigest = expectedDigest
|
||||||
capability := domain.JobCapabilityDependenciesCheck
|
capability := domain.JobCapabilityDependenciesCheck
|
||||||
targetKey := "dependencies/" + request.ProbeKey
|
targetKey := "dependencies/" + request.ProbeKey
|
||||||
message := "dependency check queued"
|
message := "dependency check queued"
|
||||||
auditAction := "dependency.check"
|
|
||||||
state := domain.DependencyStateUnknown
|
state := domain.DependencyStateUnknown
|
||||||
if request.Install {
|
if request.Install {
|
||||||
capability = domain.JobCapabilityDependenciesInstall
|
capability = domain.JobCapabilityDependenciesInstall
|
||||||
targetKey = "dependencies/install/" + request.InstallPlanKey
|
targetKey = "dependencies/install/" + request.InstallPlanKey
|
||||||
message = "dependency install queued"
|
message = "dependency install queued"
|
||||||
auditAction = "dependency.install"
|
|
||||||
state = domain.DependencyStateInstalling
|
state = domain.DependencyStateInstalling
|
||||||
}
|
}
|
||||||
job, err := svc.CreateJob(domain.Job{
|
job, err := svc.CreateJob(domain.Job{
|
||||||
@@ -682,15 +654,11 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d
|
|||||||
Progress: domain.JobProgress{Percent: 0, Message: message},
|
Progress: domain.JobProgress{Percent: 0, Message: message},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, auditAction+".denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: endpoint unsupported or offline")
|
|
||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
if err := svc.upsertDependencyStatus(instance, request, job.ID, probe.Required, state, "queued through platform job"); err != nil {
|
if err := svc.upsertDependencyStatus(instance, request, job.ID, probe.Required, state, "queued through platform job"); err != nil {
|
||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
if err := svc.recordAuditEvent(user.ID, auditAction, "server-instance", instance.ID, domain.AuditResultQueued, message); err != nil {
|
|
||||||
return domain.Job{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyJob(job), nil
|
return domain.CopyJob(job), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,12 +683,10 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
|
|||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
if !pluginSupports(plugin, domain.JobCapabilityLogsBackfill) {
|
if !pluginSupports(plugin, domain.JobCapabilityLogsBackfill) {
|
||||||
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: plugin capability is not declared")
|
|
||||||
return domain.Job{}, ErrForbidden
|
return domain.Job{}, ErrForbidden
|
||||||
}
|
}
|
||||||
source, err := declaredFileLogSource(plugin, request.SourceKey)
|
source, err := declaredFileLogSource(plugin, request.SourceKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: log source is not declared")
|
|
||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "logs.backfill.denied"); err != nil {
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "logs.backfill.denied"); err != nil {
|
||||||
@@ -738,10 +704,6 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
|
|||||||
ExecutionInput: domain.JobExecutionInput{LogSource: &source},
|
ExecutionInput: domain.JobExecutionInput{LogSource: &source},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: endpoint unsupported or offline")
|
|
||||||
return domain.Job{}, err
|
|
||||||
}
|
|
||||||
if err := svc.recordAuditEvent(user.ID, "logs.backfill", "server-instance", instance.ID, domain.AuditResultQueued, "queued historical log backfill without log bodies in job result"); err != nil {
|
|
||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
return domain.CopyJob(job), nil
|
return domain.CopyJob(job), nil
|
||||||
@@ -763,11 +725,9 @@ func declaredFileLogSource(plugin domain.GamePlugin, sourceKey string) (domain.R
|
|||||||
|
|
||||||
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
|
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
|
||||||
if plugin.Status != domain.GamePluginStatusInstalled {
|
if plugin.Status != domain.GamePluginStatusInstalled {
|
||||||
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin is not installed")
|
|
||||||
return forbiddenError("plugin is not installed")
|
return forbiddenError("plugin is not installed")
|
||||||
}
|
}
|
||||||
if !containsString(plugin.DeclaredPermissions, permission) {
|
if !containsString(plugin.DeclaredPermissions, permission) {
|
||||||
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin permission is not declared")
|
|
||||||
return forbiddenError("plugin does not declare required permission: " + permission)
|
return forbiddenError("plugin does not declare required permission: " + permission)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1033,63 +993,6 @@ func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, r
|
|||||||
return svc.store.DependencyStatuses().Create(status)
|
return svc.store.DependencyStatuses().Create(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) recordAuditEvent(actorID string, action string, resourceKind string, resourceID string, result domain.AuditResult, summary string) error {
|
|
||||||
_, err := svc.recordAuditEventWithID(actorID, action, resourceKind, resourceID, result, summary)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) recordAuditEventWithID(actorID string, action string, resourceKind string, resourceID string, result domain.AuditResult, summary string) (string, error) {
|
|
||||||
svc.auditMu.Lock()
|
|
||||||
svc.auditSeq++
|
|
||||||
seq := svc.auditSeq
|
|
||||||
svc.auditMu.Unlock()
|
|
||||||
|
|
||||||
stamp := svc.now()
|
|
||||||
event := domain.AuditEvent{
|
|
||||||
ID: fmt.Sprintf("audit-%s-%d-%d", strings.ReplaceAll(action, ".", "-"), stamp.UnixNano(), seq),
|
|
||||||
ActorID: actorID,
|
|
||||||
Action: action,
|
|
||||||
ResourceKind: resourceKind,
|
|
||||||
ResourceID: resourceID,
|
|
||||||
Result: result,
|
|
||||||
Summary: safeBridgeReason(summary),
|
|
||||||
CreatedAt: stamp,
|
|
||||||
}
|
|
||||||
if err := validator.ValidateAuditEvent(event); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := svc.store.AuditEvents().Create(event); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return event.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) auditArtifactDownload(sessionID string, artifact domain.Artifact) error {
|
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, distribution := range runDistributions {
|
|
||||||
if distribution.ArtifactID == artifact.ID {
|
|
||||||
return svc.recordAuditEvent(user.ID, "run.download", "server-instance", distribution.ServerInstanceID, domain.AuditResultSuccess, "downloaded run package artifact with redacted runtime key ref")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, distribution := range clientDistributions {
|
|
||||||
if distribution.ArtifactID == artifact.ID {
|
|
||||||
return svc.recordAuditEvent(user.ID, "client-manager.download", "server-instance", distribution.ServerInstanceID, domain.AuditResultSuccess, "downloaded client-manager package artifact with redacted runtime key ref")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return svc.recordAuditEvent(user.ID, "artifact.download", string(artifact.OwnerKind), artifact.OwnerID, domain.AuditResultSuccess, "downloaded platform artifact")
|
|
||||||
}
|
|
||||||
|
|
||||||
func validatePluginTarget(plugin domain.GamePlugin, targetOS string) error {
|
func validatePluginTarget(plugin domain.GamePlugin, targetOS string) error {
|
||||||
if len(plugin.SupportedOS) == 0 || containsString(plugin.SupportedOS, targetOS) {
|
if len(plugin.SupportedOS) == 0 || containsString(plugin.SupportedOS, targetOS) {
|
||||||
return nil
|
return nil
|
||||||
@@ -1251,7 +1154,6 @@ func (svc *CoreService) requireCompleteRuntimeBindings(actorID string, serverIns
|
|||||||
if complete {
|
if complete {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "operation denied: "+reason)
|
|
||||||
return validationError(reason)
|
return validationError(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperations(t *testing.T) {
|
func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperations(t *testing.T) {
|
||||||
svc, session, instance := newDistributionTestFixture(t)
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
runDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
runDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||||
ServerInstanceID: instance.ID,
|
ServerInstanceID: instance.ID,
|
||||||
@@ -578,24 +578,6 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
|
|||||||
t.Fatalf("expected old client-manager key to be denied after reset, got %+v", auth)
|
t.Fatalf("expected old client-manager key to be denied after reset, got %+v", auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
audits, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list audits: %v", err)
|
|
||||||
}
|
|
||||||
actions := map[string]bool{}
|
|
||||||
for _, audit := range audits {
|
|
||||||
actions[audit.Action] = true
|
|
||||||
for _, forbidden := range []string{runConfig.AuthKey, clientConfig.AuthKey, "password=", "unix://", "/Users/"} {
|
|
||||||
if strings.Contains(audit.Summary, forbidden) {
|
|
||||||
t.Fatalf("audit leaked forbidden fragment %q in %+v", forbidden, audit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, action := range []string{"run.generate", "run.download", "client-manager.build", "client-manager.build.denied", "runtime-key.reset"} {
|
|
||||||
if !actions[action] {
|
|
||||||
t.Fatalf("expected audit action %q in %+v", action, audits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
|
func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -252,30 +251,6 @@ func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQue
|
|||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateProtectedGameClientBridgePayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) error {
|
|
||||||
if declaration == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(payload) != 1 {
|
|
||||||
return validationError("protected bridge request must contain only its declared text field")
|
|
||||||
}
|
|
||||||
value, exists := payload[declaration.TextField]
|
|
||||||
if !exists {
|
|
||||||
return validationError("protected bridge request text field is required")
|
|
||||||
}
|
|
||||||
text, ok := value.(string)
|
|
||||||
if !ok || len([]byte(text)) == 0 || len([]byte(text)) > declaration.MaxTextBytes {
|
|
||||||
return validationError("protected bridge request text is invalid")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func protectedGameClientBridgeAuditSummary(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) string {
|
|
||||||
text, _ := payload[declaration.TextField].(string)
|
|
||||||
digest := sha256.Sum256([]byte(text))
|
|
||||||
return fmt.Sprintf("queued protected %s request transport=%s target=%s text=redacted sha256=%x", declaration.Kind, declaration.TransportKey, declaration.TargetKey, digest[:8])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
||||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||||
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||||
@@ -299,13 +274,6 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
|||||||
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
|
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
|
||||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
|
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
|
||||||
}
|
}
|
||||||
if err := validateProtectedGameClientBridgePayload(declaration.ProtectedRequest, request.Payload); err != nil {
|
|
||||||
return domain.GameClientBridgeCommand{}, err
|
|
||||||
}
|
|
||||||
if declaration.ProtectedRequest != nil && declaration.TimeoutSeconds > protectedRequestMaxTimeoutSeconds {
|
|
||||||
return domain.GameClientBridgeCommand{}, validationError("protected bridge request timeout exceeds Run policy")
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
|
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return domain.CopyGameClientBridgeCommand(existing), nil
|
return domain.CopyGameClientBridgeCommand(existing), nil
|
||||||
@@ -343,32 +311,9 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
|||||||
CreatedAt: stamp,
|
CreatedAt: stamp,
|
||||||
UpdatedAt: stamp,
|
UpdatedAt: stamp,
|
||||||
}
|
}
|
||||||
if declaration.ProtectedRequest != nil {
|
|
||||||
command.Payload = redactedProtectedRequestPayload(declaration.ProtectedRequest)
|
|
||||||
if approvalState == domain.GameClientBridgeApprovalApproved {
|
|
||||||
command.RunJobID = jobIDFromParts("job-protected-request", command.ServerInstanceID, command.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
summary := "queued declared game client bridge command"
|
|
||||||
if declaration.ProtectedRequest != nil {
|
|
||||||
summary = protectedGameClientBridgeAuditSummary(declaration.ProtectedRequest, request.Payload)
|
|
||||||
}
|
|
||||||
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, summary)
|
|
||||||
if err != nil {
|
|
||||||
return domain.GameClientBridgeCommand{}, err
|
|
||||||
}
|
|
||||||
command.AuditReferences = []string{auditID}
|
|
||||||
if err := svc.store.GameClientBridgeCommands().Create(command); err != nil {
|
if err := svc.store.GameClientBridgeCommands().Create(command); err != nil {
|
||||||
return domain.GameClientBridgeCommand{}, err
|
return domain.GameClientBridgeCommand{}, err
|
||||||
}
|
}
|
||||||
if declaration.ProtectedRequest != nil && command.RunJobID != "" {
|
|
||||||
if err := svc.dispatchProtectedRequest(command, declaration, request.Payload); err != nil {
|
|
||||||
if deleteErr := svc.store.GameClientBridgeCommands().Delete(command.ID); deleteErr != nil {
|
|
||||||
return domain.GameClientBridgeCommand{}, deleteErr
|
|
||||||
}
|
|
||||||
return domain.GameClientBridgeCommand{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return domain.CopyGameClientBridgeCommand(command), nil
|
return domain.CopyGameClientBridgeCommand(command), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,11 +358,6 @@ func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridge
|
|||||||
command.State = domain.GameClientBridgeCommandClaimed
|
command.State = domain.GameClientBridgeCommandClaimed
|
||||||
command.Claim = domain.GameClientBridgeClaim{SessionID: component.Session.ID, InstallationID: component.Installation.ID, DeploymentGeneration: component.Session.DeploymentGeneration, FencingToken: fencingToken, LeaseExpiresAt: gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt), ClaimedAt: stamp}
|
command.Claim = domain.GameClientBridgeClaim{SessionID: component.Session.ID, InstallationID: component.Installation.ID, DeploymentGeneration: component.Session.DeploymentGeneration, FencingToken: fencingToken, LeaseExpiresAt: gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt), ClaimedAt: stamp}
|
||||||
command.UpdatedAt = stamp
|
command.UpdatedAt = stamp
|
||||||
auditID, auditErr := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.claim", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion claimed bridge command")
|
|
||||||
if auditErr != nil {
|
|
||||||
return nil, auditErr
|
|
||||||
}
|
|
||||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
||||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -440,11 +380,6 @@ func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeCom
|
|||||||
command.Claim.AcknowledgedAt = stamp
|
command.Claim.AcknowledgedAt = stamp
|
||||||
command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt)
|
command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt)
|
||||||
command.UpdatedAt = stamp
|
command.UpdatedAt = stamp
|
||||||
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.ack", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion acknowledged bridge command")
|
|
||||||
if err != nil {
|
|
||||||
return domain.GameClientBridgeCommand{}, err
|
|
||||||
}
|
|
||||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
||||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||||
return domain.GameClientBridgeCommand{}, err
|
return domain.GameClientBridgeCommand{}, err
|
||||||
}
|
}
|
||||||
@@ -486,17 +421,9 @@ func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBrid
|
|||||||
command.Result = domain.GameClientBridgeResult{Status: request.Status, Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload), CompletedBy: component.Session.ID, CompletedAt: stamp}
|
command.Result = domain.GameClientBridgeResult{Status: request.Status, Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload), CompletedBy: component.Session.ID, CompletedAt: stamp}
|
||||||
command.CompletedAt = stamp
|
command.CompletedAt = stamp
|
||||||
command.UpdatedAt = stamp
|
command.UpdatedAt = stamp
|
||||||
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion recorded terminal bridge command result")
|
|
||||||
if err != nil {
|
|
||||||
return domain.GameClientBridgeCommand{}, err
|
|
||||||
}
|
|
||||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
||||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||||
return domain.GameClientBridgeCommand{}, err
|
return domain.GameClientBridgeCommand{}, err
|
||||||
}
|
}
|
||||||
if command.RunJobID != "" {
|
|
||||||
svc.protectedRequests.Delete(command.RunJobID)
|
|
||||||
}
|
|
||||||
return domain.CopyGameClientBridgeCommand(command), nil
|
return domain.CopyGameClientBridgeCommand(command), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,17 +466,9 @@ func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string
|
|||||||
command.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled by operator", CompletedAt: stamp}
|
command.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled by operator", CompletedAt: stamp}
|
||||||
command.CompletedAt = stamp
|
command.CompletedAt = stamp
|
||||||
command.UpdatedAt = stamp
|
command.UpdatedAt = stamp
|
||||||
auditID, err := svc.recordAuditEventWithID(user.ID, "game-client-bridge.command.cancel", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "operator cancelled bridge command")
|
|
||||||
if err != nil {
|
|
||||||
return domain.GameClientBridgeCommand{}, err
|
|
||||||
}
|
|
||||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
||||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||||
return domain.GameClientBridgeCommand{}, err
|
return domain.GameClientBridgeCommand{}, err
|
||||||
}
|
}
|
||||||
if command.RunJobID != "" {
|
|
||||||
svc.protectedRequests.Delete(command.RunJobID)
|
|
||||||
}
|
|
||||||
return domain.CopyGameClientBridgeCommand(command), nil
|
return domain.CopyGameClientBridgeCommand(command), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,11 +601,6 @@ func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) err
|
|||||||
command.State = domain.GameClientBridgeCommandPending
|
command.State = domain.GameClientBridgeCommandPending
|
||||||
command.Claim = domain.GameClientBridgeClaim{FencingToken: fencingToken}
|
command.Claim = domain.GameClientBridgeClaim{FencingToken: fencingToken}
|
||||||
command.UpdatedAt = stamp
|
command.UpdatedAt = stamp
|
||||||
auditID, auditErr := svc.recordAuditEventWithID("platform", "game-client-bridge.command.lease-expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "expired bridge claim returned to pending")
|
|
||||||
if auditErr != nil {
|
|
||||||
return auditErr
|
|
||||||
}
|
|
||||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
||||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -722,11 +636,6 @@ func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameC
|
|||||||
command.State = domain.GameClientBridgeCommandExpired
|
command.State = domain.GameClientBridgeCommandExpired
|
||||||
command.CompletedAt = stamp
|
command.CompletedAt = stamp
|
||||||
command.UpdatedAt = stamp
|
command.UpdatedAt = stamp
|
||||||
auditID, err := svc.recordAuditEventWithID("platform", "game-client-bridge.command.expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "bridge command expired before completion")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
||||||
return svc.store.GameClientBridgeCommands().Update(command)
|
return svc.store.GameClientBridgeCommands().Update(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,11 +111,6 @@ func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClient
|
|||||||
CreatedAt: stamp,
|
CreatedAt: stamp,
|
||||||
ExpiresAt: stamp.Add(time.Duration(request.Retention.KeepForSeconds) * time.Second),
|
ExpiresAt: stamp.Add(time.Duration(request.Retention.KeepForSeconds) * time.Second),
|
||||||
}
|
}
|
||||||
auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.snapshot.ingest", "game-client-bridge-snapshot", snapshot.ID, domain.AuditResultSuccess, "companion uploaded typed bridge snapshot")
|
|
||||||
if err != nil {
|
|
||||||
return domain.GameClientBridgeSnapshot{}, err
|
|
||||||
}
|
|
||||||
snapshot.AuditReferences = []string{auditID}
|
|
||||||
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||||
return domain.GameClientBridgeSnapshot{}, err
|
return domain.GameClientBridgeSnapshot{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,7 +12,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||||
store := repo.NewMemoryStore()
|
store := repo.NewMemoryStore()
|
||||||
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: "announcement.send", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, 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", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, 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 {
|
if err := store.GamePlugins().Create(plugin); err != nil {
|
||||||
t.Fatalf("seed bridge plugin: %v", err)
|
t.Fatalf("seed bridge plugin: %v", err)
|
||||||
}
|
}
|
||||||
@@ -23,7 +21,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func bridgeQueueRequest(now time.Time, key string) domain.GameClientBridgeQueueRequest {
|
func bridgeQueueRequest(now time.Time, key string) domain.GameClientBridgeQueueRequest {
|
||||||
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)}
|
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func bridgeComponent() gameClientBridgeComponentSession {
|
func bridgeComponent() gameClientBridgeComponentSession {
|
||||||
@@ -45,8 +43,8 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
|||||||
t.Fatalf("idempotency reuse: command=%#v err=%v", duplicate, err)
|
t.Fatalf("idempotency reuse: command=%#v err=%v", duplicate, err)
|
||||||
}
|
}
|
||||||
commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||||
if len(commands) != 1 || len(command.AuditReferences) != 1 {
|
if len(commands) != 1 {
|
||||||
t.Fatalf("expected one durable audited command: %#v", commands)
|
t.Fatalf("expected one durable command: %#v", commands)
|
||||||
}
|
}
|
||||||
|
|
||||||
component := bridgeComponent()
|
component := bridgeComponent()
|
||||||
@@ -71,12 +69,8 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
|||||||
if err != nil || completed.State != domain.GameClientBridgeCommandSucceeded || completed.Result.Status != domain.GameClientBridgeResultSucceeded || completed.CompletedAt.IsZero() {
|
if err != nil || completed.State != domain.GameClientBridgeCommandSucceeded || completed.Result.Status != domain.GameClientBridgeResultSucceeded || completed.CompletedAt.IsZero() {
|
||||||
t.Fatalf("complete bridge command: %#v err=%v", completed, err)
|
t.Fatalf("complete bridge command: %#v err=%v", completed, err)
|
||||||
}
|
}
|
||||||
if len(completed.AuditReferences) < 3 {
|
|
||||||
t.Fatalf("expected queue, claim, and result audit references: %#v", completed.AuditReferences)
|
|
||||||
}
|
|
||||||
auditReferenceCount := len(completed.AuditReferences)
|
|
||||||
replayed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
|
replayed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
|
||||||
if err != nil || replayed.ID != completed.ID || replayed.State != completed.State || !replayed.CompletedAt.Equal(completed.CompletedAt) || len(replayed.AuditReferences) != auditReferenceCount {
|
if err != nil || replayed.ID != completed.ID || replayed.State != completed.State || !replayed.CompletedAt.Equal(completed.CompletedAt) {
|
||||||
t.Fatalf("exact terminal result retry was not idempotent: replayed=%#v err=%v", replayed, err)
|
t.Fatalf("exact terminal result retry was not idempotent: replayed=%#v err=%v", replayed, err)
|
||||||
}
|
}
|
||||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "conflict"}); err == nil {
|
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "conflict"}); err == nil {
|
||||||
@@ -84,111 +78,6 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProtectedGameClientBridgeRequestIsScopedAndRedacted(t *testing.T) {
|
|
||||||
svc, clock := newGameClientBridgeService(t)
|
|
||||||
plugin, err := svc.store.GamePlugins().Get("game.scum")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "database", Kind: "sqlite", TargetKey: "database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}}
|
|
||||||
plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "database.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 60, MaxPayloadBytes: 4096, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "sql", TransportKey: "database", TargetKey: "database", TextField: "requestText", MaxTextBytes: 1024}})
|
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
text := "UPDATE players SET rank = 2 WHERE id = 7"
|
|
||||||
request := domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text}, IdempotencyKey: "protected-1", ExpiresAt: clock.Add(time.Minute)}
|
|
||||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("queue protected request: %v", err)
|
|
||||||
}
|
|
||||||
if command.ApprovalState != domain.GameClientBridgeApprovalPending {
|
|
||||||
t.Fatalf("protected request bypassed approval: %#v", command)
|
|
||||||
}
|
|
||||||
if _, err := svc.queueGameClientBridgeCommand("user-1", domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text, "unexpected": true}, IdempotencyKey: "protected-extra", ExpiresAt: clock.Add(time.Minute)}); err == nil {
|
|
||||||
t.Fatal("protected request accepted undeclared payload field")
|
|
||||||
}
|
|
||||||
events, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: command.ID})
|
|
||||||
if err != nil || len(events) != 1 {
|
|
||||||
t.Fatalf("protected request audit: events=%#v err=%v", events, err)
|
|
||||||
}
|
|
||||||
if strings.Contains(events[0].Summary, text) || !strings.Contains(events[0].Summary, "text=redacted") {
|
|
||||||
t.Fatalf("audit leaked protected request: %#v", events[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProtectedGameClientBridgeRequestDispatchesOneTimeRunInput(t *testing.T) {
|
|
||||||
svc, clock := newGameClientBridgeService(t)
|
|
||||||
plugin, err := svc.store.GamePlugins().Get("game.scum")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
plugin.RequiredRunCapabilities = []string{domain.JobCapabilityRemoteRunProtectedSQL}
|
|
||||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}}
|
|
||||||
plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "database.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 120, MaxPayloadBytes: 4096, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "sql", TransportKey: "scum-database", TargetKey: "scum-database", TextField: "requestText", MaxTextBytes: 1024}})
|
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := svc.store.Users().Create(domain.User{ID: "platform-admin", Email: "admin@example.test", Roles: []string{"platform-admin"}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: "run-local", Name: "Protected Bridge", State: domain.ServerInstanceStateRunning}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
hello := validRunControlHello()
|
|
||||||
hello.CapabilityReport.Capabilities = []string{domain.JobCapabilityRemoteRunProtectedSQL}
|
|
||||||
hello.CapabilityReport.Fingerprint = "protected-request-capabilities"
|
|
||||||
run, err := svc.RegisterRunHello(hello)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("register Run: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := "SELECT player_id, position FROM players WHERE player_id = 7"
|
|
||||||
command, err := svc.queueGameClientBridgeCommand("platform-admin", domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: plugin.ID, ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text}, IdempotencyKey: "protected-run-1", ExpiresAt: clock.Add(time.Minute)})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("queue protected request: %v", err)
|
|
||||||
}
|
|
||||||
if command.RunJobID == "" || command.Payload["requestText"] != "redacted" || command.ApprovalState != domain.GameClientBridgeApprovalApproved {
|
|
||||||
t.Fatalf("protected command was not redacted and dispatched: %#v", command)
|
|
||||||
}
|
|
||||||
commandJSON, _ := json.Marshal(command)
|
|
||||||
if strings.Contains(string(commandJSON), text) {
|
|
||||||
t.Fatalf("protected bridge command persisted request text: %s", commandJSON)
|
|
||||||
}
|
|
||||||
if claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 10); err != nil || len(claimed) != 0 {
|
|
||||||
t.Fatalf("protected request must not be exposed to the Companion: commands=%#v err=%v", claimed, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: run.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
|
||||||
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != command.RunJobID || claim.Job.FencingToken == 0 {
|
|
||||||
t.Fatalf("claim protected Run job: claim=%#v err=%v", claim, err)
|
|
||||||
}
|
|
||||||
assignmentJSON, _ := json.Marshal(claim.Job)
|
|
||||||
if strings.Contains(string(assignmentJSON), text) {
|
|
||||||
t.Fatalf("Run assignment exposed protected request text: %s", assignmentJSON)
|
|
||||||
}
|
|
||||||
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
|
|
||||||
if err != nil || !ack.Accepted {
|
|
||||||
t.Fatalf("ack protected Run job: ack=%#v err=%v", ack, err)
|
|
||||||
}
|
|
||||||
if _, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken + 1}); err == nil {
|
|
||||||
t.Fatal("expected fencing mismatch rejection")
|
|
||||||
}
|
|
||||||
input, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken})
|
|
||||||
if err != nil || input.RequestText != text || input.Kind != "sql" || input.TransportKey != "scum-database" || !input.Authorized {
|
|
||||||
t.Fatalf("read protected Run input: input=%#v err=%v", input, err)
|
|
||||||
}
|
|
||||||
if _, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken}); err == nil {
|
|
||||||
t.Fatal("expected one-time protected input rejection")
|
|
||||||
}
|
|
||||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100, Message: "unknown request"}, ErrorCode: "protected_request_unknown", ExecutionResult: domain.JobExecutionResult{Kind: "protected.sql.unknown", AuditSummary: "protected request outcome is unknown"}}); err != nil {
|
|
||||||
t.Fatalf("complete protected Run job: %v", err)
|
|
||||||
}
|
|
||||||
completed, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
|
||||||
if err != nil || completed.State != domain.GameClientBridgeCommandUnknown || completed.Result.Status != domain.GameClientBridgeResultUnknown || strings.Contains(completed.Result.Summary, text) {
|
|
||||||
t.Fatalf("project protected Run result: command=%#v err=%v", completed, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
|
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
|
||||||
svc, clock := newGameClientBridgeService(t)
|
svc, clock := newGameClientBridgeService(t)
|
||||||
request := bridgeQueueRequest(*clock, "scope-key")
|
request := bridgeQueueRequest(*clock, "scope-key")
|
||||||
@@ -266,7 +155,7 @@ func TestGameClientBridgePendingCommandExpiresBeforeFirstClaim(t *testing.T) {
|
|||||||
t.Fatalf("expired pending command was claimable: %#v err=%v", claimed, err)
|
t.Fatalf("expired pending command was claimable: %#v err=%v", claimed, err)
|
||||||
}
|
}
|
||||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Claim.FencingToken != 0 || len(expired.AuditReferences) != 2 {
|
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Claim.FencingToken != 0 {
|
||||||
t.Fatalf("first claim did not persist pending command expiry: %#v err=%v", expired, err)
|
t.Fatalf("first claim did not persist pending command expiry: %#v err=%v", expired, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,7 +182,7 @@ func TestGameClientBridgeExpiredLeaseRejectsMutationsBeforeReclaim(t *testing.T)
|
|||||||
}
|
}
|
||||||
for _, command := range claimed {
|
for _, command := range claimed {
|
||||||
protected, getErr := svc.store.GameClientBridgeCommands().Get(command.ID)
|
protected, getErr := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
if getErr != nil || protected.State != domain.GameClientBridgeCommandClaimed || !protected.Claim.AcknowledgedAt.IsZero() || protected.Result.Status != "" || !protected.CompletedAt.IsZero() || protected.Claim.FencingToken != command.Claim.FencingToken || len(protected.AuditReferences) != 2 {
|
if getErr != nil || protected.State != domain.GameClientBridgeCommandClaimed || !protected.Claim.AcknowledgedAt.IsZero() || protected.Result.Status != "" || !protected.CompletedAt.IsZero() || protected.Claim.FencingToken != command.Claim.FencingToken {
|
||||||
t.Fatalf("expired lease mutation changed protected command: %#v err=%v", protected, getErr)
|
t.Fatalf("expired lease mutation changed protected command: %#v err=%v", protected, getErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,8 +230,8 @@ func TestGameClientBridgeClaimMutationsExpireAtCommandDeadline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, command := range commands {
|
for _, command := range commands {
|
||||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || len(expired.AuditReferences) < 3 {
|
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() {
|
||||||
t.Fatalf("deadline mutation did not persist audited expiry: %#v err=%v", expired, err)
|
t.Fatalf("deadline mutation did not persist expiry: %#v err=%v", expired, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,11 +249,11 @@ func TestGameClientBridgeFailedResultIsPersisted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
request := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "game window unavailable", Payload: map[string]any{"retryable": true}}
|
request := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "game window unavailable", Payload: map[string]any{"retryable": true}}
|
||||||
failed, err := svc.completeGameClientBridgeCommand(component, request)
|
failed, err := svc.completeGameClientBridgeCommand(component, request)
|
||||||
if err != nil || failed.State != domain.GameClientBridgeCommandFailed || failed.Result.Status != domain.GameClientBridgeResultFailed || failed.Result.Summary != request.Summary || failed.Result.CompletedBy != component.Session.ID || failed.CompletedAt.IsZero() || len(failed.AuditReferences) != 3 {
|
if err != nil || failed.State != domain.GameClientBridgeCommandFailed || failed.Result.Status != domain.GameClientBridgeResultFailed || failed.Result.Summary != request.Summary || failed.Result.CompletedBy != component.Session.ID || failed.CompletedAt.IsZero() {
|
||||||
t.Fatalf("record failed result: %#v err=%v", failed, err)
|
t.Fatalf("record failed result: %#v err=%v", failed, err)
|
||||||
}
|
}
|
||||||
persisted, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
persisted, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
if err != nil || persisted.State != domain.GameClientBridgeCommandFailed || persisted.Result.Status != domain.GameClientBridgeResultFailed || persisted.Result.Payload["retryable"] != true || !persisted.CompletedAt.Equal(failed.CompletedAt) || len(persisted.AuditReferences) != len(failed.AuditReferences) {
|
if err != nil || persisted.State != domain.GameClientBridgeCommandFailed || persisted.Result.Status != domain.GameClientBridgeResultFailed || persisted.Result.Payload["retryable"] != true || !persisted.CompletedAt.Equal(failed.CompletedAt) {
|
||||||
t.Fatalf("failed result was not persisted: %#v err=%v", persisted, err)
|
t.Fatalf("failed result was not persisted: %#v err=%v", persisted, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,8 +282,8 @@ func TestGameClientBridgeOperatorCancellationExpiresAtCommandDeadline(t *testing
|
|||||||
t.Fatal("expected cancellation at command deadline to be rejected")
|
t.Fatal("expected cancellation at command deadline to be rejected")
|
||||||
}
|
}
|
||||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Cancellation.RequestedBy != "" || len(expired.AuditReferences) != 2 {
|
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Cancellation.RequestedBy != "" {
|
||||||
t.Fatalf("deadline cancellation did not preserve audited expiry: %#v err=%v", expired, err)
|
t.Fatalf("deadline cancellation did not preserve expiry: %#v err=%v", expired, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,9 +313,8 @@ func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
|
|||||||
if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID {
|
if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID {
|
||||||
t.Fatalf("cancel bridge command: %#v err=%v", cancelled, err)
|
t.Fatalf("cancel bridge command: %#v err=%v", cancelled, err)
|
||||||
}
|
}
|
||||||
auditReferenceCount := len(cancelled.AuditReferences)
|
|
||||||
repeated, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
|
repeated, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
|
||||||
if err != nil || repeated.State != domain.GameClientBridgeCommandCancelled || !repeated.Cancellation.CancelledAt.Equal(cancelled.Cancellation.CancelledAt) || len(repeated.AuditReferences) != auditReferenceCount {
|
if err != nil || repeated.State != domain.GameClientBridgeCommandCancelled || !repeated.Cancellation.CancelledAt.Equal(cancelled.Cancellation.CancelledAt) {
|
||||||
t.Fatalf("repeated cancellation was not idempotent: %#v err=%v", repeated, err)
|
t.Fatalf("repeated cancellation was not idempotent: %#v err=%v", repeated, err)
|
||||||
}
|
}
|
||||||
remaining, err := svc.claimGameClientBridgeCommands(component, 1)
|
remaining, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||||
|
|||||||
@@ -269,9 +269,6 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
|||||||
if err := svc.updateScheduledJob(job); err != nil {
|
if err := svc.updateScheduledJob(job); err != nil {
|
||||||
return domain.RunJobResultResult{}, err
|
return domain.RunJobResultResult{}, err
|
||||||
}
|
}
|
||||||
if err := svc.projectProtectedRequestJobResult(job, result, stamp); err != nil {
|
|
||||||
return domain.RunJobResultResult{}, err
|
|
||||||
}
|
|
||||||
if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
|
if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
|
||||||
return domain.RunJobResultResult{}, err
|
return domain.RunJobResultResult{}, err
|
||||||
}
|
}
|
||||||
@@ -345,7 +342,7 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
|
|||||||
return validationError("client-manager deploy result type is invalid")
|
return validationError("client-manager deploy result type is invalid")
|
||||||
}
|
}
|
||||||
case domain.JobCapabilityClientManagerControl:
|
case domain.JobCapabilityClientManagerControl:
|
||||||
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.controlled" {
|
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.control" {
|
||||||
return validationError("client-manager control result type is invalid")
|
return validationError("client-manager control result type is invalid")
|
||||||
}
|
}
|
||||||
case domain.JobCapabilityClientManagerUpdate:
|
case domain.JobCapabilityClientManagerUpdate:
|
||||||
@@ -630,10 +627,6 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignment {
|
func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignment {
|
||||||
fencingToken := uint64(0)
|
|
||||||
if isProtectedRequestCapability(job.Capability) {
|
|
||||||
fencingToken = uint64(job.Attempt)
|
|
||||||
}
|
|
||||||
return domain.RunJobAssignment{
|
return domain.RunJobAssignment{
|
||||||
JobID: job.ID,
|
JobID: job.ID,
|
||||||
ServerInstanceID: job.ServerInstanceID,
|
ServerInstanceID: job.ServerInstanceID,
|
||||||
@@ -648,7 +641,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
|||||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
|
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
|
||||||
LeaseToken: leaseToken,
|
LeaseToken: leaseToken,
|
||||||
Attempt: job.Attempt,
|
Attempt: job.Attempt,
|
||||||
FencingToken: fencingToken,
|
FencingToken: 0,
|
||||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||||
AckDeadlineAt: job.AckDeadlineAt,
|
AckDeadlineAt: job.AckDeadlineAt,
|
||||||
LeaseExpiresAt: job.LeaseExpiresAt,
|
LeaseExpiresAt: job.LeaseExpiresAt,
|
||||||
|
|||||||
@@ -132,13 +132,6 @@ func (svc *CoreService) CreateBackupForSession(sessionID string, record domain.B
|
|||||||
if err := svc.store.Backups().Create(record); err != nil {
|
if err := svc.store.Backups().Create(record); err != nil {
|
||||||
return domain.BackupRecord{}, err
|
return domain.BackupRecord{}, err
|
||||||
}
|
}
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.BackupRecord{}, err
|
|
||||||
}
|
|
||||||
if err := svc.recordAuditEvent(user.ID, "backup.create", "server-instance", instance.ID, domain.AuditResultQueued, "created bounded backup record with artifact checksum"); err != nil {
|
|
||||||
return domain.BackupRecord{}, err
|
|
||||||
}
|
|
||||||
if err := svc.pruneBackups(instance.ID); err != nil {
|
if err := svc.pruneBackups(instance.ID); err != nil {
|
||||||
return domain.BackupRecord{}, err
|
return domain.BackupRecord{}, err
|
||||||
}
|
}
|
||||||
@@ -189,9 +182,6 @@ func (svc *CoreService) RecoverIncompleteBackups() error {
|
|||||||
if err := svc.store.Backups().Update(record); err != nil {
|
if err := svc.store.Backups().Update(record); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := svc.recordAuditEvent("platform-recovery", "backup.recover", "server-instance", record.ServerInstanceID, domain.AuditResultFailed, "marked interrupted backup recoverable without exposing storage details"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -207,7 +197,7 @@ func (svc *CoreService) pruneMetricSamples(serverInstanceID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return svc.recordAuditEvent("platform-retention", "metrics.retention", "server-instance", serverInstanceID, domain.AuditResultSuccess, "pruned oldest metric samples to bounded retention")
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) pruneBackups(serverInstanceID string) error {
|
func (svc *CoreService) pruneBackups(serverInstanceID string) error {
|
||||||
@@ -222,7 +212,6 @@ func (svc *CoreService) pruneBackups(serverInstanceID string) error {
|
|||||||
total += record.SizeBytes
|
total += record.SizeBytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pruned := false
|
|
||||||
for len(items) > maxBackupsPerServer || total > maxBackupBytesPerServer {
|
for len(items) > maxBackupsPerServer || total > maxBackupBytesPerServer {
|
||||||
record := items[0]
|
record := items[0]
|
||||||
items = items[1:]
|
items = items[1:]
|
||||||
@@ -235,10 +224,6 @@ func (svc *CoreService) pruneBackups(serverInstanceID string) error {
|
|||||||
if err := svc.store.Backups().Update(record); err != nil {
|
if err := svc.store.Backups().Update(record); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pruned = true
|
|
||||||
}
|
|
||||||
if pruned {
|
|
||||||
return svc.recordAuditEvent("platform-retention", "backup.retention", "server-instance", serverInstanceID, domain.AuditResultSuccess, "expired oldest backup records to bounded retention")
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,17 +184,6 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance,
|
|||||||
if insideWindow && !sameObservation {
|
if insideWindow && !sameObservation {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
announcementAlreadyQueued := false
|
|
||||||
announcementIdempotencyKey := ""
|
|
||||||
if projection.Presence != nil {
|
|
||||||
announcementIdempotencyKey = fmt.Sprintf("log-projection:%s:%s:%d", projection.Key, key, observedAt.Unix()/int64(projection.Presence.ActiveWindowSeconds))
|
|
||||||
_, commandErr := svc.store.GameClientBridgeCommands().GetByIdempotency(instance.ID, "system:log-projection", projection.Presence.Announcement.CommandType, announcementIdempotencyKey)
|
|
||||||
if commandErr == nil {
|
|
||||||
announcementAlreadyQueued = true
|
|
||||||
} else if !errors.Is(commandErr, repo.ErrNotFound) {
|
|
||||||
return commandErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !isNew {
|
if !isNew {
|
||||||
value = mergePluginDataValues(existing.Value, value)
|
value = mergePluginDataValues(existing.Value, value)
|
||||||
}
|
}
|
||||||
@@ -211,39 +200,9 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance,
|
|||||||
return applyErr
|
return applyErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if projection.Presence != nil && !announcementAlreadyQueued {
|
|
||||||
announcement := projection.Presence.Announcement
|
|
||||||
template := announcement.ReturningTextTemplate
|
|
||||||
if isNew || sameObservation {
|
|
||||||
template = announcement.NewTextTemplate
|
|
||||||
}
|
|
||||||
requestText := renderLogProjectionTemplate(template, captures)
|
|
||||||
expiresAt := svc.now().Add(gameClientBridgeCommandTimeout(plugin, announcement.CommandType))
|
|
||||||
if _, err := svc.queueGameClientBridgeCommand("system:log-projection", domain.GameClientBridgeQueueRequest{
|
|
||||||
ServerInstanceID: instance.ID,
|
|
||||||
PluginID: plugin.ID,
|
|
||||||
ProfileKey: announcement.ProfileKey,
|
|
||||||
CommandType: announcement.CommandType,
|
|
||||||
Payload: map[string]any{announcement.TextField: requestText},
|
|
||||||
IdempotencyKey: announcementIdempotencyKey,
|
|
||||||
Priority: 100,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func gameClientBridgeCommandTimeout(plugin domain.GamePlugin, commandType string) time.Duration {
|
|
||||||
for _, declaration := range plugin.GameClientBridge.Commands {
|
|
||||||
if declaration.Type == commandType && declaration.TimeoutSeconds > 0 {
|
|
||||||
return time.Duration(declaration.TimeoutSeconds) * time.Second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return time.Minute
|
|
||||||
}
|
|
||||||
|
|
||||||
func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any {
|
func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any {
|
||||||
value := make(map[string]any, len(target.CaptureMappings)+len(target.FixedValues)+1)
|
value := make(map[string]any, len(target.CaptureMappings)+len(target.FixedValues)+1)
|
||||||
for destination, capture := range target.CaptureMappings {
|
for destination, capture := range target.CaptureMappings {
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnouncesReturns(t *testing.T) {
|
func TestDurableStdoutProjectionCreatesUsersAndSuppressesRapidDuplicates(t *testing.T) {
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
capability := domain.JobCapabilityRemoteRunProtectedRCON
|
|
||||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
|
||||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{capability}})
|
|
||||||
plugin.RuntimeProfiles.ClientManagers = append(plugin.RuntimeProfiles.ClientManagers, domain.RuntimeClientManagerProfile{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}})
|
plugin.RuntimeProfiles.ClientManagers = append(plugin.RuntimeProfiles.ClientManagers, domain.RuntimeClientManagerProfile{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}})
|
||||||
plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{
|
|
||||||
Type: "presence.announce", Title: "Presence announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator,
|
|
||||||
PayloadSchemaRef: "schemas/presence-announcement.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096,
|
|
||||||
ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "rcon", TransportKey: "scum-management", TargetKey: "scum-management", TextField: "requestText", MaxTextBytes: 1024},
|
|
||||||
}}
|
|
||||||
plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{
|
plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{
|
||||||
Key: "player.login", StreamKeys: []string{"stdout"}, CorrelationFields: []string{"playerSlot"}, MaxInterveningLines: 4,
|
Key: "player.login", StreamKeys: []string{"stdout"}, CorrelationFields: []string{"playerSlot"}, MaxInterveningLines: 4,
|
||||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
||||||
@@ -37,25 +28,16 @@ func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnounce
|
|||||||
Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt"},
|
Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt"},
|
||||||
CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "login"}, ObservedAtField: "observedAt",
|
CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "login"}, ObservedAtField: "observedAt",
|
||||||
},
|
},
|
||||||
Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{
|
|
||||||
ProfileKey: "scum-client", CommandType: "presence.announce", TextField: "requestText",
|
|
||||||
NewTextTemplate: "#announce Welcome {{displayName}}", ReturningTextTemplate: "#announce Welcome back {{displayName}}",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
t.Fatalf("update plugin projection: %v", err)
|
t.Fatalf("update plugin projection: %v", err)
|
||||||
}
|
}
|
||||||
endpoint.Capabilities = append(endpoint.Capabilities, capability)
|
|
||||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
||||||
t.Fatalf("update Run capability: %v", err)
|
|
||||||
}
|
|
||||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-log-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM projection", State: domain.ServerInstanceStateRunning})
|
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-log-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM projection", State: domain.ServerInstanceStateRunning})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create server: %v", err)
|
t.Fatalf("create server: %v", err)
|
||||||
}
|
}
|
||||||
helloRequest := validRunControlHello()
|
helloRequest := validRunControlHello()
|
||||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, capability)
|
|
||||||
helloRequest.CapabilityReport.Fingerprint = "cap-log-projection"
|
helloRequest.CapabilityReport.Fingerprint = "cap-log-projection"
|
||||||
hello, err := svc.RegisterRunHello(helloRequest)
|
hello, err := svc.RegisterRunHello(helloRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,29 +56,19 @@ func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnounce
|
|||||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 3, base.Add(2*time.Second), []string{
|
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 3, base.Add(2*time.Second), []string{
|
||||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
||||||
})
|
})
|
||||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 1)
|
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 0)
|
||||||
|
|
||||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 4, base.Add(5*time.Minute), []string{
|
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 4, base.Add(5*time.Minute), []string{
|
||||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
||||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
||||||
})
|
})
|
||||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 1)
|
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 0)
|
||||||
|
|
||||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 6, base.Add(11*time.Minute), []string{
|
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 6, base.Add(11*time.Minute), []string{
|
||||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
||||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
||||||
})
|
})
|
||||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 2)
|
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 0)
|
||||||
|
|
||||||
svc.protectedRequests.mu.Lock()
|
|
||||||
texts := make([]string, 0, len(svc.protectedRequests.payloads))
|
|
||||||
for _, payload := range svc.protectedRequests.payloads {
|
|
||||||
texts = append(texts, payload.requestText)
|
|
||||||
}
|
|
||||||
svc.protectedRequests.mu.Unlock()
|
|
||||||
if len(texts) != 2 || !containsText(texts, "#announce Welcome love_fitting") || !containsText(texts, "#announce Welcome back love_fitting") {
|
|
||||||
t.Fatalf("unexpected plugin-declared announcement requests: %v", texts)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) {
|
func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) {
|
||||||
@@ -124,15 +96,6 @@ func assertPresenceProjectionCounts(t *testing.T, svc *CoreService, pluginID, se
|
|||||||
}
|
}
|
||||||
queued, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: serverID, PluginID: pluginID})
|
queued, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: serverID, PluginID: pluginID})
|
||||||
if err != nil || len(queued) != commands {
|
if err != nil || len(queued) != commands {
|
||||||
t.Fatalf("presence announcements=%+v err=%v", queued, err)
|
t.Fatalf("presence bridge commands=%+v err=%v", queued, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func containsText(values []string, expected string) bool {
|
|
||||||
for _, value := range values {
|
|
||||||
if strings.Contains(value, expected) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,297 +14,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
capacityHeartbeatStaleAfter = 2 * time.Minute
|
aiConfigDiffTTL = 30 * time.Minute
|
||||||
capacityRetryAfterSeconds = 30
|
|
||||||
capacityLogBacklogLimit = 256
|
|
||||||
capacityArtifactBacklogLimit = 128
|
|
||||||
aiConfigDiffTTL = 30 * time.Minute
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (svc *CoreService) GetProductionCapacityForSession(sessionID string) (domain.ProductionCapacitySummary, error) {
|
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.ProductionCapacitySummary{}, err
|
|
||||||
}
|
|
||||||
endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{})
|
|
||||||
if err != nil {
|
|
||||||
return domain.ProductionCapacitySummary{}, err
|
|
||||||
}
|
|
||||||
visibleEndpointIDs, err := svc.visibleEndpointIDs(user)
|
|
||||||
if err != nil {
|
|
||||||
return domain.ProductionCapacitySummary{}, err
|
|
||||||
}
|
|
||||||
alerts, err := svc.store.Alerts().List(domain.AlertFilter{})
|
|
||||||
if err != nil {
|
|
||||||
return domain.ProductionCapacitySummary{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
summary := domain.ProductionCapacitySummary{GeneratedAt: svc.now()}
|
|
||||||
for _, endpoint := range endpoints {
|
|
||||||
if !isPlatformAdmin(user) {
|
|
||||||
if _, visible := visibleEndpointIDs[endpoint.ID]; !visible {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
running, queued, err := svc.capacityJobCounts(endpoint.ID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.ProductionCapacitySummary{}, err
|
|
||||||
}
|
|
||||||
projection := svc.capacityProjection(endpoint, running, queued)
|
|
||||||
for _, alert := range alerts {
|
|
||||||
if alert.SourceKind == "run-endpoint" && alert.SourceID == endpoint.ID && alert.RuleKey == "capacity.pressure" && alert.State != domain.AlertStateResolved {
|
|
||||||
projection.LastAdmissionDecision = domain.CapacityAdmissionDeferred
|
|
||||||
projection.LastAdmissionReason = alert.Message
|
|
||||||
projection.LastAdmissionCheckedAt = alert.LastSeenAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
summary.Endpoints = append(summary.Endpoints, projection)
|
|
||||||
summary.TotalMaxJobs += projection.MaxJobs
|
|
||||||
summary.TotalRunningJobs += projection.RunningJobs
|
|
||||||
summary.TotalQueuedJobs += projection.QueuedJobs
|
|
||||||
}
|
|
||||||
for _, alert := range alerts {
|
|
||||||
if alert.State != domain.AlertStateResolved && svc.canAccessAlert(user, alert) {
|
|
||||||
summary.ActiveAlerts++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(summary.Endpoints, func(i, j int) bool { return summary.Endpoints[i].RunEndpointID < summary.Endpoints[j].RunEndpointID })
|
|
||||||
return domain.CopyProductionCapacitySummary(summary), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) CheckCapacityAdmissionForSession(sessionID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) {
|
|
||||||
if err := validator.ValidateCapacityAdmissionRequest(request); err != nil {
|
|
||||||
return domain.CapacityAdmissionDecision{}, err
|
|
||||||
}
|
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.CapacityAdmissionDecision{}, err
|
|
||||||
}
|
|
||||||
request, err = svc.authorizeCapacityRequest(user, request)
|
|
||||||
if err != nil {
|
|
||||||
return domain.CapacityAdmissionDecision{}, err
|
|
||||||
}
|
|
||||||
svc.productionMu.Lock()
|
|
||||||
defer svc.productionMu.Unlock()
|
|
||||||
return svc.checkCapacityAdmission(user.ID, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) checkCapacityAdmission(actorID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) {
|
|
||||||
endpoint, err := svc.store.RunEndpoints().Get(request.RunEndpointID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.CapacityAdmissionDecision{}, err
|
|
||||||
}
|
|
||||||
running, queued, err := svc.capacityJobCounts(endpoint.ID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.CapacityAdmissionDecision{}, err
|
|
||||||
}
|
|
||||||
projection := svc.capacityProjection(endpoint, running, queued)
|
|
||||||
decision := domain.CapacityAdmissionDecision{
|
|
||||||
Accepted: true, State: domain.CapacityAdmissionAccepted, Reason: "capacity available",
|
|
||||||
ServerInstanceID: request.ServerInstanceID, RunEndpointID: endpoint.ID, Capability: request.Capability,
|
|
||||||
TargetKey: request.TargetKey, MaxJobs: projection.MaxJobs, RunningJobs: projection.RunningJobs,
|
|
||||||
QueuedJobs: projection.QueuedJobs, CheckedAt: svc.now(),
|
|
||||||
}
|
|
||||||
pressure := append([]domain.CapacityPressureCode(nil), projection.PressureCodes...)
|
|
||||||
if len(validator.MissingCapabilities(endpoint.Capabilities, []string{request.Capability})) > 0 {
|
|
||||||
pressure = appendCapacityPressure(pressure, domain.CapacityPressureCapabilityGap)
|
|
||||||
}
|
|
||||||
decision.PressureCodes = pressure
|
|
||||||
|
|
||||||
hardDenied := containsCapacityPressure(pressure, domain.CapacityPressureEndpointOffline) || containsCapacityPressure(pressure, domain.CapacityPressureCapabilityGap)
|
|
||||||
if hardDenied {
|
|
||||||
decision.Accepted = false
|
|
||||||
decision.State = domain.CapacityAdmissionDenied
|
|
||||||
decision.Reason = "endpoint is unavailable or missing the required capability"
|
|
||||||
} else if len(pressure) > 0 {
|
|
||||||
decision.Accepted = false
|
|
||||||
decision.State = domain.CapacityAdmissionDeferred
|
|
||||||
decision.Reason = "endpoint capacity is temporarily under pressure"
|
|
||||||
decision.RetryAfterSeconds = capacityRetryAfterSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
auditResult := domain.AuditResultSuccess
|
|
||||||
auditAction := "capacity.admission.accepted"
|
|
||||||
if !decision.Accepted {
|
|
||||||
auditResult = domain.AuditResultDenied
|
|
||||||
auditAction = "capacity.admission.denied"
|
|
||||||
}
|
|
||||||
auditID, err := svc.recordAuditEventWithID(actorID, auditAction, "run-endpoint", endpoint.ID, auditResult, decision.Reason)
|
|
||||||
if err != nil {
|
|
||||||
return domain.CapacityAdmissionDecision{}, err
|
|
||||||
}
|
|
||||||
decision.AuditEventID = auditID
|
|
||||||
if !decision.Accepted {
|
|
||||||
severity := domain.AlertSeverityWarning
|
|
||||||
if hardDenied {
|
|
||||||
severity = domain.AlertSeverityCritical
|
|
||||||
}
|
|
||||||
alert, err := svc.upsertAlert(domain.AlertRecord{
|
|
||||||
SourceKind: "run-endpoint", SourceID: endpoint.ID, RuleKey: "capacity.pressure", Severity: severity,
|
|
||||||
Title: "Run endpoint capacity admission blocked", Message: decision.Reason, Retryable: true,
|
|
||||||
RetryAfterSeconds: decision.RetryAfterSeconds, LastAuditEventID: auditID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return domain.CapacityAdmissionDecision{}, err
|
|
||||||
}
|
|
||||||
decision.AlertID = alert.ID
|
|
||||||
} else if err := svc.resolveAlertForSource("run-endpoint", endpoint.ID, "capacity.pressure", actorID, "capacity returned to an admissible state", auditID); err != nil {
|
|
||||||
return domain.CapacityAdmissionDecision{}, err
|
|
||||||
}
|
|
||||||
if err := validator.ValidateCapacityAdmissionDecision(decision); err != nil {
|
|
||||||
return domain.CapacityAdmissionDecision{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyCapacityAdmissionDecision(decision), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) ListAlertsForSession(sessionID string, filter domain.AlertFilter) ([]domain.AlertRecord, error) {
|
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
alerts, err := svc.store.Alerts().List(filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
visible := make([]domain.AlertRecord, 0, len(alerts))
|
|
||||||
for _, alert := range alerts {
|
|
||||||
if svc.canAccessAlert(user, alert) {
|
|
||||||
visible = append(visible, alert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(visible, func(i, j int) bool { return visible[i].UpdatedAt.After(visible[j].UpdatedAt) })
|
|
||||||
return domain.CopyAlertRecords(visible), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) AcknowledgeAlertForSession(sessionID string, request domain.AlertAcknowledgeRequest) (domain.AlertRecord, error) {
|
|
||||||
if err := validator.ValidateAlertAcknowledgeRequest(request); err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
svc.productionMu.Lock()
|
|
||||||
defer svc.productionMu.Unlock()
|
|
||||||
alert, err := svc.store.Alerts().Get(request.AlertID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
if !svc.canAccessAlert(user, alert) {
|
|
||||||
return domain.AlertRecord{}, ErrForbidden
|
|
||||||
}
|
|
||||||
if alert.State == domain.AlertStateResolved {
|
|
||||||
return domain.AlertRecord{}, validationError("resolved alerts cannot be acknowledged")
|
|
||||||
}
|
|
||||||
stamp := svc.now()
|
|
||||||
auditID, err := svc.recordAuditEventWithID(user.ID, "alert.acknowledge", "alert", alert.ID, domain.AuditResultSuccess, defaultAlertNote(request.Note, "alert acknowledged"))
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
alert.State = domain.AlertStateAcknowledged
|
|
||||||
alert.AcknowledgedBy = user.ID
|
|
||||||
alert.AcknowledgedAt = stamp
|
|
||||||
alert.LastAuditEventID = auditID
|
|
||||||
alert.UpdatedAt = stamp
|
|
||||||
if err := validator.ValidateAlertRecord(alert); err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
if err := svc.store.Alerts().Update(alert); err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyAlertRecord(alert), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) ResolveAlertForSession(sessionID string, request domain.AlertResolveRequest) (domain.AlertRecord, error) {
|
|
||||||
if err := validator.ValidateAlertResolveRequest(request); err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
svc.productionMu.Lock()
|
|
||||||
defer svc.productionMu.Unlock()
|
|
||||||
alert, err := svc.store.Alerts().Get(request.AlertID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
if !svc.canAccessAlert(user, alert) {
|
|
||||||
return domain.AlertRecord{}, ErrForbidden
|
|
||||||
}
|
|
||||||
if alert.State == domain.AlertStateResolved {
|
|
||||||
return domain.CopyAlertRecord(alert), nil
|
|
||||||
}
|
|
||||||
stamp := svc.now()
|
|
||||||
note := defaultAlertNote(request.Note, "alert resolved after operator review")
|
|
||||||
auditID, err := svc.recordAuditEventWithID(user.ID, "alert.resolve", "alert", alert.ID, domain.AuditResultSuccess, note)
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
alert.State = domain.AlertStateResolved
|
|
||||||
alert.ResolvedBy = user.ID
|
|
||||||
alert.ResolvedAt = stamp
|
|
||||||
alert.ResolutionNote = note
|
|
||||||
alert.LastAuditEventID = auditID
|
|
||||||
alert.UpdatedAt = stamp
|
|
||||||
if err := validator.ValidateAlertRecord(alert); err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
if err := svc.store.Alerts().Update(alert); err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyAlertRecord(alert), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) RetryAlertForSession(sessionID string, request domain.AlertRetryRequest) (domain.AlertRetryResult, error) {
|
|
||||||
if err := validator.ValidateAlertRetryRequest(request); err != nil {
|
|
||||||
return domain.AlertRetryResult{}, err
|
|
||||||
}
|
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRetryResult{}, err
|
|
||||||
}
|
|
||||||
alert, err := svc.store.Alerts().Get(request.AlertID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRetryResult{}, err
|
|
||||||
}
|
|
||||||
if !svc.canAccessAlert(user, alert) {
|
|
||||||
return domain.AlertRetryResult{}, ErrForbidden
|
|
||||||
}
|
|
||||||
if !alert.Retryable {
|
|
||||||
return domain.AlertRetryResult{}, validationError("alert source is not retryable")
|
|
||||||
}
|
|
||||||
switch alert.SourceKind {
|
|
||||||
case "run-endpoint":
|
|
||||||
endpoint, err := svc.store.RunEndpoints().Get(alert.SourceID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRetryResult{}, err
|
|
||||||
}
|
|
||||||
capability := firstCapacityCapability(endpoint.Capabilities)
|
|
||||||
decision, err := svc.CheckCapacityAdmissionForSession(sessionID, domain.CapacityAdmissionRequest{RunEndpointID: endpoint.ID, Capability: capability, IdempotencyKey: request.IdempotencyKey})
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRetryResult{}, err
|
|
||||||
}
|
|
||||||
updated, err := svc.store.Alerts().Get(alert.ID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRetryResult{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: updated, Decision: decision, Status: string(decision.State)}), nil
|
|
||||||
case "plugin-lifecycle":
|
|
||||||
installation, err := svc.store.PluginLifecycles().Get(alert.SourceID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRetryResult{}, err
|
|
||||||
}
|
|
||||||
result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, Operation: installation.LastOperation, TargetVersion: installation.TargetVersion, IdempotencyKey: request.IdempotencyKey, Confirmed: true})
|
|
||||||
if err != nil {
|
|
||||||
return domain.AlertRetryResult{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: alert, Decision: result.Decision, Status: result.Status}), nil
|
|
||||||
default:
|
|
||||||
return domain.AlertRetryResult{}, validationError("alert source does not support scoped retry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) {
|
func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) {
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -383,13 +95,6 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d
|
|||||||
if !errors.Is(jobErr, repo.ErrNotFound) {
|
if !errors.Is(jobErr, repo.ErrNotFound) {
|
||||||
return domain.PluginLifecycleResult{}, jobErr
|
return domain.PluginLifecycleResult{}, jobErr
|
||||||
}
|
}
|
||||||
decision, err := svc.checkCapacityAdmission(user.ID, domain.CapacityAdmissionRequest{ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey})
|
|
||||||
if err != nil {
|
|
||||||
return domain.PluginLifecycleResult{}, err
|
|
||||||
}
|
|
||||||
if !decision.Accepted {
|
|
||||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Decision: decision, Status: string(decision.State)}), nil
|
|
||||||
}
|
|
||||||
job, err := svc.CreateJob(domain.Job{
|
job, err := svc.CreateJob(domain.Job{
|
||||||
ID: jobIDFromParts("job-plugin-lifecycle", installation.ID, request.IdempotencyKey), ServerInstanceID: instance.ID,
|
ID: jobIDFromParts("job-plugin-lifecycle", installation.ID, request.IdempotencyKey), ServerInstanceID: instance.ID,
|
||||||
RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey,
|
RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey,
|
||||||
@@ -409,11 +114,6 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d
|
|||||||
installation.IdempotencyKey = request.IdempotencyKey
|
installation.IdempotencyKey = request.IdempotencyKey
|
||||||
installation.FailureReason = ""
|
installation.FailureReason = ""
|
||||||
installation.UpdatedAt = stamp
|
installation.UpdatedAt = stamp
|
||||||
auditID, err := svc.recordAuditEventWithID(user.ID, "plugin.lifecycle."+string(request.Operation), "plugin-lifecycle", installation.ID, domain.AuditResultQueued, "plugin lifecycle operation admitted and queued")
|
|
||||||
if err != nil {
|
|
||||||
return domain.PluginLifecycleResult{}, err
|
|
||||||
}
|
|
||||||
installation.AuditEventID = auditID
|
|
||||||
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
|
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
|
||||||
return domain.PluginLifecycleResult{}, err
|
return domain.PluginLifecycleResult{}, err
|
||||||
}
|
}
|
||||||
@@ -425,7 +125,7 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PluginLifecycleResult{}, err
|
return domain.PluginLifecycleResult{}, err
|
||||||
}
|
}
|
||||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: job, Decision: decision, Status: "queued"}), nil
|
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: job, Status: "queued"}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) ListAIConfigDiffsForSession(sessionID string, filter domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error) {
|
func (svc *CoreService) ListAIConfigDiffsForSession(sessionID string, filter domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error) {
|
||||||
@@ -503,9 +203,6 @@ func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request
|
|||||||
if err := svc.store.AIConfigDiffs().Update(preview); err != nil {
|
if err := svc.store.AIConfigDiffs().Update(preview); err != nil {
|
||||||
return domain.AIConfigDiffApprovalResult{}, err
|
return domain.AIConfigDiffApprovalResult{}, err
|
||||||
}
|
}
|
||||||
if _, err := svc.recordAuditEventWithID(user.ID, "ai.config-diff.approve", "ai-config-diff", preview.ID, domain.AuditResultQueued, "approved reviewed AI config diff and queued one config write job"); err != nil {
|
|
||||||
return domain.AIConfigDiffApprovalResult{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
|
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,17 +222,9 @@ func (svc *CoreService) projectProductionOpsJobResult(job domain.Job, stamp time
|
|||||||
if job.State == domain.JobStateSucceeded {
|
if job.State == domain.JobStateSucceeded {
|
||||||
applyPluginLifecycleSuccess(&installation)
|
applyPluginLifecycleSuccess(&installation)
|
||||||
installation.FailureReason = ""
|
installation.FailureReason = ""
|
||||||
if err := svc.resolveAlertForSource("plugin-lifecycle", installation.ID, "plugin.lifecycle.failed", "run:"+job.RunEndpointID, "plugin lifecycle job completed", installation.AuditEventID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
installation.CurrentState = domain.PluginLifecycleStateFailed
|
installation.CurrentState = domain.PluginLifecycleStateFailed
|
||||||
installation.FailureReason = "plugin lifecycle job did not complete successfully"
|
installation.FailureReason = "plugin lifecycle job did not complete successfully"
|
||||||
alert, err := svc.upsertAlert(domain.AlertRecord{SourceKind: "plugin-lifecycle", SourceID: installation.ID, RuleKey: "plugin.lifecycle.failed", Severity: domain.AlertSeverityWarning, Title: "Plugin lifecycle operation failed", Message: installation.FailureReason, Retryable: true, LastJobID: job.ID, LastAuditEventID: installation.AuditEventID})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
installation.AlertID = alert.ID
|
|
||||||
}
|
}
|
||||||
installation.UpdatedAt = stamp
|
installation.UpdatedAt = stamp
|
||||||
return svc.store.PluginLifecycles().Update(installation)
|
return svc.store.PluginLifecycles().Update(installation)
|
||||||
@@ -598,193 +287,6 @@ func (svc *CoreService) getServerConfigForUser(userID, serverInstanceID string)
|
|||||||
return config, validator.ValidateServerConfig(config)
|
return config, validator.ValidateServerConfig(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) authorizeCapacityRequest(user domain.User, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionRequest, error) {
|
|
||||||
if request.ServerInstanceID != "" {
|
|
||||||
instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID)
|
|
||||||
if err != nil {
|
|
||||||
return request, err
|
|
||||||
}
|
|
||||||
if !canAccessServer(user, instance) {
|
|
||||||
return request, ErrForbidden
|
|
||||||
}
|
|
||||||
if request.RunEndpointID != "" && request.RunEndpointID != instance.RunEndpointID {
|
|
||||||
return request, validationError("runEndpointId must match server instance")
|
|
||||||
}
|
|
||||||
request.RunEndpointID = instance.RunEndpointID
|
|
||||||
return request, nil
|
|
||||||
}
|
|
||||||
if request.RunEndpointID == "" {
|
|
||||||
return request, validationError("serverInstanceId or runEndpointId is required")
|
|
||||||
}
|
|
||||||
if isPlatformAdmin(user) {
|
|
||||||
return request, nil
|
|
||||||
}
|
|
||||||
visible, err := svc.visibleEndpointIDs(user)
|
|
||||||
if err != nil {
|
|
||||||
return request, err
|
|
||||||
}
|
|
||||||
if _, ok := visible[request.RunEndpointID]; !ok {
|
|
||||||
return request, ErrForbidden
|
|
||||||
}
|
|
||||||
return request, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) visibleEndpointIDs(user domain.User) (map[string]struct{}, error) {
|
|
||||||
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids := map[string]struct{}{}
|
|
||||||
for _, instance := range instances {
|
|
||||||
if isPlatformAdmin(user) || canAccessServer(user, instance) {
|
|
||||||
ids[instance.RunEndpointID] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) capacityJobCounts(endpointID string) (int, int, error) {
|
|
||||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpointID})
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
running, queued := 0, 0
|
|
||||||
for _, job := range jobs {
|
|
||||||
switch job.State {
|
|
||||||
case domain.JobStateAccepted, domain.JobStateRunning:
|
|
||||||
running++
|
|
||||||
case domain.JobStateQueued, domain.JobStateRetrying:
|
|
||||||
queued++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return running, queued, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) capacityProjection(endpoint domain.RunEndpoint, durableRunning, durableQueued int) domain.EndpointCapacityProjection {
|
|
||||||
running := maxInt(endpoint.Capacity.RunningJobs, durableRunning)
|
|
||||||
queued := maxInt(endpoint.Capacity.QueuedJobs, durableQueued)
|
|
||||||
projection := domain.EndpointCapacityProjection{RunEndpointID: endpoint.ID, DisplayName: endpoint.DisplayName, Status: endpoint.Status, Capabilities: endpoint.Capabilities, MaxJobs: endpoint.Capacity.MaxJobs, RunningJobs: running, QueuedJobs: queued, LogBacklogBatches: endpoint.Capacity.LogBacklogBatches, ArtifactBacklogChunks: endpoint.Capacity.ArtifactBacklogChunks, Summary: safeBridgeReason(endpoint.Capacity.Summary), LastHeartbeatAt: endpoint.LastHeartbeatAt}
|
|
||||||
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
|
|
||||||
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureEndpointOffline)
|
|
||||||
}
|
|
||||||
if endpoint.LastHeartbeatAt.IsZero() || svc.now().Sub(endpoint.LastHeartbeatAt) > capacityHeartbeatStaleAfter {
|
|
||||||
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureEndpointStale)
|
|
||||||
}
|
|
||||||
if projection.MaxJobs <= 0 || running >= projection.MaxJobs {
|
|
||||||
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureJobLimit)
|
|
||||||
}
|
|
||||||
queueLimit := maxInt(4, projection.MaxJobs*2)
|
|
||||||
if queued >= queueLimit {
|
|
||||||
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureQueueLimit)
|
|
||||||
}
|
|
||||||
if projection.LogBacklogBatches >= capacityLogBacklogLimit || projection.ArtifactBacklogChunks >= capacityArtifactBacklogLimit || len(endpoint.Capacity.PressureCodes) > 0 {
|
|
||||||
projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureBacklog)
|
|
||||||
}
|
|
||||||
return projection
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) upsertAlert(candidate domain.AlertRecord) (domain.AlertRecord, error) {
|
|
||||||
stamp := svc.now()
|
|
||||||
candidate.ID = alertIDForSource(candidate.SourceKind, candidate.SourceID, candidate.RuleKey)
|
|
||||||
existing, err := svc.store.Alerts().Get(candidate.ID)
|
|
||||||
if err == nil {
|
|
||||||
existing.Severity = candidate.Severity
|
|
||||||
existing.State = domain.AlertStateActive
|
|
||||||
existing.Title = candidate.Title
|
|
||||||
existing.Message = safeBridgeReason(candidate.Message)
|
|
||||||
existing.OccurrenceCount++
|
|
||||||
existing.Retryable = candidate.Retryable
|
|
||||||
existing.RetryAfterSeconds = candidate.RetryAfterSeconds
|
|
||||||
existing.LastJobID = candidate.LastJobID
|
|
||||||
existing.LastAuditEventID = candidate.LastAuditEventID
|
|
||||||
existing.LastSeenAt = stamp
|
|
||||||
existing.ResolvedBy = ""
|
|
||||||
existing.ResolvedAt = time.Time{}
|
|
||||||
existing.ResolutionNote = ""
|
|
||||||
existing.UpdatedAt = stamp
|
|
||||||
if err := validator.ValidateAlertRecord(existing); err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
if err := svc.store.Alerts().Update(existing); err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, repo.ErrNotFound) {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
candidate.State = domain.AlertStateActive
|
|
||||||
candidate.Message = safeBridgeReason(candidate.Message)
|
|
||||||
candidate.OccurrenceCount = 1
|
|
||||||
candidate.LastSeenAt = stamp
|
|
||||||
candidate.CreatedAt = stamp
|
|
||||||
candidate.UpdatedAt = stamp
|
|
||||||
if err := validator.ValidateAlertRecord(candidate); err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
if err := svc.store.Alerts().Create(candidate); err != nil {
|
|
||||||
return domain.AlertRecord{}, err
|
|
||||||
}
|
|
||||||
return candidate, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) resolveAlertForSource(sourceKind, sourceID, ruleKey, actorID, note, auditID string) error {
|
|
||||||
alert, err := svc.store.Alerts().Get(alertIDForSource(sourceKind, sourceID, ruleKey))
|
|
||||||
if errors.Is(err, repo.ErrNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if alert.State == domain.AlertStateResolved {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
stamp := svc.now()
|
|
||||||
alert.State = domain.AlertStateResolved
|
|
||||||
alert.ResolvedBy = actorID
|
|
||||||
alert.ResolvedAt = stamp
|
|
||||||
alert.ResolutionNote = note
|
|
||||||
alert.LastAuditEventID = auditID
|
|
||||||
alert.UpdatedAt = stamp
|
|
||||||
return svc.store.Alerts().Update(alert)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) canAccessAlert(user domain.User, alert domain.AlertRecord) bool {
|
|
||||||
if isPlatformAdmin(user) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
switch alert.SourceKind {
|
|
||||||
case "server-instance":
|
|
||||||
instance, err := svc.store.ServerInstances().Get(alert.SourceID)
|
|
||||||
return err == nil && canAccessServer(user, instance)
|
|
||||||
case "run-endpoint":
|
|
||||||
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: alert.SourceID})
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, instance := range instances {
|
|
||||||
if canAccessServer(user, instance) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "plugin-lifecycle":
|
|
||||||
installation, err := svc.store.PluginLifecycles().Get(alert.SourceID)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
|
||||||
return err == nil && canAccessServer(user, instance)
|
|
||||||
case "ai-config-diff":
|
|
||||||
preview, err := svc.store.AIConfigDiffs().Get(alert.SourceID)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
|
||||||
return err == nil && canAccessServer(user, instance)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
|
func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
|
||||||
svc.productionMu.Lock()
|
svc.productionMu.Lock()
|
||||||
defer svc.productionMu.Unlock()
|
defer svc.productionMu.Unlock()
|
||||||
@@ -798,21 +300,11 @@ func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.Se
|
|||||||
|
|
||||||
func (svc *CoreService) pluginLifecycleDeniedLocked(actorID string, installation domain.PluginLifecycleInstallation, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
|
func (svc *CoreService) pluginLifecycleDeniedLocked(actorID string, installation domain.PluginLifecycleInstallation, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
|
||||||
stamp := svc.now()
|
stamp := svc.now()
|
||||||
auditID, err := svc.recordAuditEventWithID(actorID, "plugin.lifecycle.denied", "plugin-lifecycle", installation.ID, domain.AuditResultDenied, reason)
|
|
||||||
if err != nil {
|
|
||||||
return domain.PluginLifecycleResult{}, err
|
|
||||||
}
|
|
||||||
installation.CurrentState = domain.PluginLifecycleStateFailed
|
installation.CurrentState = domain.PluginLifecycleStateFailed
|
||||||
installation.LastOperation = request.Operation
|
installation.LastOperation = request.Operation
|
||||||
installation.TargetVersion = request.TargetVersion
|
installation.TargetVersion = request.TargetVersion
|
||||||
installation.FailureReason = safeBridgeReason(reason)
|
installation.FailureReason = safeBridgeReason(reason)
|
||||||
installation.AuditEventID = auditID
|
|
||||||
installation.UpdatedAt = stamp
|
installation.UpdatedAt = stamp
|
||||||
alert, err := svc.upsertAlert(domain.AlertRecord{SourceKind: "plugin-lifecycle", SourceID: installation.ID, RuleKey: "plugin.lifecycle.compatibility", Severity: domain.AlertSeverityWarning, Title: "Plugin lifecycle compatibility check failed", Message: installation.FailureReason, Retryable: true, LastAuditEventID: auditID})
|
|
||||||
if err != nil {
|
|
||||||
return domain.PluginLifecycleResult{}, err
|
|
||||||
}
|
|
||||||
installation.AlertID = alert.ID
|
|
||||||
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
|
if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil {
|
||||||
return domain.PluginLifecycleResult{}, err
|
return domain.PluginLifecycleResult{}, err
|
||||||
}
|
}
|
||||||
@@ -824,7 +316,7 @@ func (svc *CoreService) pluginLifecycleDeniedLocked(actorID string, installation
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PluginLifecycleResult{}, err
|
return domain.PluginLifecycleResult{}, err
|
||||||
}
|
}
|
||||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Alert: &alert, Status: "denied"}), nil
|
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Status: "denied"}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pluginLifecycleDispatchMetadata(plugin domain.GamePlugin, operation domain.PluginLifecycleOperation) (string, string, error) {
|
func pluginLifecycleDispatchMetadata(plugin domain.GamePlugin, operation domain.PluginLifecycleOperation) (string, string, error) {
|
||||||
@@ -937,11 +429,6 @@ func applyPluginLifecycleSuccess(installation *domain.PluginLifecycleInstallatio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func alertIDForSource(sourceKind, sourceID, ruleKey string) string {
|
|
||||||
sum := sha256.Sum256([]byte(sourceKind + "\x00" + sourceID + "\x00" + ruleKey))
|
|
||||||
return "alert-" + hex.EncodeToString(sum[:12])
|
|
||||||
}
|
|
||||||
|
|
||||||
func pluginLifecycleInstallationID(pluginID, serverInstanceID string) string {
|
func pluginLifecycleInstallationID(pluginID, serverInstanceID string) string {
|
||||||
sum := sha256.Sum256([]byte(pluginID + "\x00" + serverInstanceID))
|
sum := sha256.Sum256([]byte(pluginID + "\x00" + serverInstanceID))
|
||||||
return "plugin-lifecycle-" + hex.EncodeToString(sum[:12])
|
return "plugin-lifecycle-" + hex.EncodeToString(sum[:12])
|
||||||
@@ -951,42 +438,3 @@ func aiConfigDiffID(requestID, serverInstanceID string) string {
|
|||||||
sum := sha256.Sum256([]byte(requestID + "\x00" + serverInstanceID))
|
sum := sha256.Sum256([]byte(requestID + "\x00" + serverInstanceID))
|
||||||
return "ai-config-diff-" + hex.EncodeToString(sum[:12])
|
return "ai-config-diff-" + hex.EncodeToString(sum[:12])
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendCapacityPressure(codes []domain.CapacityPressureCode, code domain.CapacityPressureCode) []domain.CapacityPressureCode {
|
|
||||||
if !containsCapacityPressure(codes, code) {
|
|
||||||
return append(codes, code)
|
|
||||||
}
|
|
||||||
return codes
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsCapacityPressure(codes []domain.CapacityPressureCode, target domain.CapacityPressureCode) bool {
|
|
||||||
for _, code := range codes {
|
|
||||||
if code == target {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstCapacityCapability(capabilities []string) string {
|
|
||||||
for _, capability := range capabilities {
|
|
||||||
if strings.TrimSpace(capability) != "" {
|
|
||||||
return capability
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "control.heartbeat"
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultAlertNote(note, fallback string) string {
|
|
||||||
if strings.TrimSpace(note) == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return safeBridgeReason(note)
|
|
||||||
}
|
|
||||||
|
|
||||||
func maxInt(a, b int) int {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,38 +9,6 @@ import (
|
|||||||
"browser.local/platform/repo"
|
"browser.local/platform/repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProductionCapacityCreatesDurableAlertAndSupportsClosure(t *testing.T) {
|
|
||||||
svc, session, instance := newProductionOpsFixture(t)
|
|
||||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get endpoint: %v", err)
|
|
||||||
}
|
|
||||||
endpoint.Capacity.RunningJobs = endpoint.Capacity.MaxJobs
|
|
||||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
||||||
t.Fatalf("update endpoint pressure: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
decision, err := svc.CheckCapacityAdmissionForSession(session, domain.CapacityAdmissionRequest{ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityInstall, IdempotencyKey: "capacity-pressure"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("check capacity: %v", err)
|
|
||||||
}
|
|
||||||
if decision.Accepted || decision.State != domain.CapacityAdmissionDeferred || decision.AlertID == "" || decision.AuditEventID == "" {
|
|
||||||
t.Fatalf("expected durable deferred decision, got %+v", decision)
|
|
||||||
}
|
|
||||||
alerts, err := svc.ListAlertsForSession(session, domain.AlertFilter{State: domain.AlertStateActive})
|
|
||||||
if err != nil || len(alerts) != 1 || alerts[0].OccurrenceCount != 1 {
|
|
||||||
t.Fatalf("expected one active alert, got %+v err=%v", alerts, err)
|
|
||||||
}
|
|
||||||
acknowledged, err := svc.AcknowledgeAlertForSession(session, domain.AlertAcknowledgeRequest{AlertID: decision.AlertID, Note: "operator reviewing queue pressure"})
|
|
||||||
if err != nil || acknowledged.State != domain.AlertStateAcknowledged || acknowledged.AcknowledgedBy == "" {
|
|
||||||
t.Fatalf("acknowledge alert: %+v err=%v", acknowledged, err)
|
|
||||||
}
|
|
||||||
resolved, err := svc.ResolveAlertForSession(session, domain.AlertResolveRequest{AlertID: decision.AlertID, Note: "capacity policy reviewed"})
|
|
||||||
if err != nil || resolved.State != domain.AlertStateResolved || resolved.ResolvedBy == "" {
|
|
||||||
t.Fatalf("resolve alert: %+v err=%v", resolved, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) {
|
func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) {
|
||||||
svc, session, instance := newProductionOpsFixture(t)
|
svc, session, instance := newProductionOpsFixture(t)
|
||||||
request := domain.PluginLifecycleRequest{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Operation: domain.PluginLifecycleOperationInstall, TargetVersion: "1.0.0", IdempotencyKey: "plugin-install-v1"}
|
request := domain.PluginLifecycleRequest{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Operation: domain.PluginLifecycleOperationInstall, TargetVersion: "1.0.0", IdempotencyKey: "plugin-install-v1"}
|
||||||
@@ -66,7 +34,7 @@ func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPluginLifecycleBridgeDispatchesOnlyPlatformGovernedJob(t *testing.T) {
|
func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) {
|
||||||
svc, session, instance := newProductionOpsFixture(t)
|
svc, session, instance := newProductionOpsFixture(t)
|
||||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,13 +58,12 @@ func TestPluginLifecycleBridgeDispatchesOnlyPlatformGovernedJob(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("execute lifecycle bridge: %v", err)
|
t.Fatalf("execute lifecycle bridge: %v", err)
|
||||||
}
|
}
|
||||||
if response.Status != "queued" || response.Result["jobId"] == "" || response.Result["installationId"] == "" || response.Result["admissionState"] != string(domain.CapacityAdmissionAccepted) {
|
if response.Status != "queued" || response.Result["jobId"] == "" || response.Result["installationId"] == "" {
|
||||||
t.Fatalf("expected Platform-governed lifecycle job, got %+v", response)
|
t.Fatalf("expected plugin lifecycle job, got %+v", response)
|
||||||
}
|
}
|
||||||
serialized := strings.ToLower(strings.Join([]string{
|
serialized := strings.ToLower(strings.Join([]string{
|
||||||
response.Result["jobId"], response.Result["installationId"], response.Result["currentState"],
|
response.Result["jobId"], response.Result["installationId"], response.Result["currentState"],
|
||||||
response.Result["desiredState"], response.Result["alertId"], response.Result["auditEventId"],
|
response.Result["desiredState"],
|
||||||
response.Result["admissionState"], response.Result["admissionReason"],
|
|
||||||
}, " "))
|
}, " "))
|
||||||
for _, forbidden := range []string{"password", "apikey", "token", "secret://", "baseurl", "hostpath", "socket", "pid", "dsn", "rcon", "runendpoint"} {
|
for _, forbidden := range []string{"password", "apikey", "token", "secret://", "baseurl", "hostpath", "socket", "pid", "dsn", "rcon", "runendpoint"} {
|
||||||
if strings.Contains(serialized, forbidden) {
|
if strings.Contains(serialized, forbidden) {
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
|
||||||
"browser.local/platform/validator"
|
|
||||||
)
|
|
||||||
|
|
||||||
const protectedRequestMaxTimeoutSeconds = 120
|
|
||||||
|
|
||||||
type protectedRequestPayload struct {
|
|
||||||
commandID string
|
|
||||||
kind string
|
|
||||||
transportKey string
|
|
||||||
targetKey string
|
|
||||||
requestText string
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// protectedRequestBroker keeps opaque request text out of durable jobs and
|
|
||||||
// bridge records. It releases a payload exactly once to a current Run lease.
|
|
||||||
type protectedRequestBroker struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
now func() time.Time
|
|
||||||
payloads map[string]protectedRequestPayload
|
|
||||||
}
|
|
||||||
|
|
||||||
func newProtectedRequestBroker(now func() time.Time) *protectedRequestBroker {
|
|
||||||
return &protectedRequestBroker{now: now, payloads: map[string]protectedRequestPayload{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (broker *protectedRequestBroker) Put(jobID string, payload protectedRequestPayload) error {
|
|
||||||
broker.mu.Lock()
|
|
||||||
defer broker.mu.Unlock()
|
|
||||||
broker.pruneLocked()
|
|
||||||
if _, exists := broker.payloads[jobID]; exists {
|
|
||||||
return validationError("protected request idempotency key is already pending")
|
|
||||||
}
|
|
||||||
broker.payloads[jobID] = payload
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (broker *protectedRequestBroker) Consume(jobID string) (protectedRequestPayload, error) {
|
|
||||||
broker.mu.Lock()
|
|
||||||
defer broker.mu.Unlock()
|
|
||||||
broker.pruneLocked()
|
|
||||||
payload, exists := broker.payloads[jobID]
|
|
||||||
if !exists {
|
|
||||||
return protectedRequestPayload{}, validationError("protected request input is unavailable")
|
|
||||||
}
|
|
||||||
delete(broker.payloads, jobID)
|
|
||||||
return payload, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (broker *protectedRequestBroker) Delete(jobID string) {
|
|
||||||
broker.mu.Lock()
|
|
||||||
defer broker.mu.Unlock()
|
|
||||||
delete(broker.payloads, jobID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (broker *protectedRequestBroker) pruneLocked() {
|
|
||||||
stamp := broker.now()
|
|
||||||
for jobID, payload := range broker.payloads {
|
|
||||||
if !stamp.Before(payload.expiresAt) {
|
|
||||||
delete(broker.payloads, jobID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func protectedRequestCapability(kind string) (string, string, error) {
|
|
||||||
switch kind {
|
|
||||||
case "sql":
|
|
||||||
return domain.JobCapabilityRemoteRunProtectedSQL, "protected-sql", nil
|
|
||||||
case "rcon":
|
|
||||||
return domain.JobCapabilityRemoteRunProtectedRCON, "protected-rcon", nil
|
|
||||||
case "program":
|
|
||||||
return domain.JobCapabilityRemoteRunProgram, "protected-program", nil
|
|
||||||
default:
|
|
||||||
return "", "", validationError("protected request kind is unsupported")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func redactedProtectedRequestPayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration) map[string]any {
|
|
||||||
return map[string]any{declaration.TextField: "redacted"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) dispatchProtectedRequest(command domain.GameClientBridgeCommand, declaration domain.GameClientBridgeCommandDeclaration, payload map[string]any) error {
|
|
||||||
if declaration.ProtectedRequest == nil || declaration.TimeoutSeconds < 1 || declaration.TimeoutSeconds > protectedRequestMaxTimeoutSeconds {
|
|
||||||
return validationError("protected request timeout is out of bounds")
|
|
||||||
}
|
|
||||||
requestText, _ := payload[declaration.ProtectedRequest.TextField].(string)
|
|
||||||
capability, adapterKind, err := protectedRequestCapability(declaration.ProtectedRequest.Kind)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
jobID := command.RunJobID
|
|
||||||
if jobID == "" {
|
|
||||||
return validationError("protected request job binding is missing")
|
|
||||||
}
|
|
||||||
executionInput := domain.JobExecutionInput{
|
|
||||||
WorkspaceScope: command.ProfileKey,
|
|
||||||
RemoteAdapterKey: declaration.ProtectedRequest.TransportKey,
|
|
||||||
RemoteAdapterKind: adapterKind,
|
|
||||||
TimeoutSeconds: declaration.TimeoutSeconds,
|
|
||||||
PluginID: command.PluginID,
|
|
||||||
}
|
|
||||||
if declaration.ProtectedRequest.Kind == "rcon" {
|
|
||||||
if resolution, resolveErr := svc.resolveProtectedSourceRCONDispatch(command.ServerInstanceID, declaration.ProtectedRequest); resolveErr == nil {
|
|
||||||
executionInput.WorkspaceScope = resolution.binding.ProfileKey
|
|
||||||
executionInput.SourceRCON = resolution.plan
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := svc.protectedRequests.Put(jobID, protectedRequestPayload{commandID: command.ID, kind: declaration.ProtectedRequest.Kind, transportKey: declaration.ProtectedRequest.TransportKey, targetKey: declaration.ProtectedRequest.TargetKey, requestText: requestText, expiresAt: command.ExpiresAt}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
job := domain.Job{
|
|
||||||
ID: jobID,
|
|
||||||
ServerInstanceID: command.ServerInstanceID,
|
|
||||||
RunEndpointID: mustProtectedRequestRunEndpoint(svc, command.ServerInstanceID),
|
|
||||||
Capability: capability,
|
|
||||||
TargetKey: declaration.ProtectedRequest.TargetKey,
|
|
||||||
InputRef: "input://protected-request/" + command.ID,
|
|
||||||
IdempotencyKey: "protected-request:" + command.ID,
|
|
||||||
Progress: domain.JobProgress{Percent: 0, Message: "protected request queued"},
|
|
||||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1},
|
|
||||||
ExecutionInput: executionInput,
|
|
||||||
}
|
|
||||||
if job.RunEndpointID == "" {
|
|
||||||
svc.protectedRequests.Delete(jobID)
|
|
||||||
return validationError("protected request server binding is unavailable")
|
|
||||||
}
|
|
||||||
created, err := svc.CreateJob(job)
|
|
||||||
if err != nil {
|
|
||||||
svc.protectedRequests.Delete(jobID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if created.ID != jobID || created.ServerInstanceID != job.ServerInstanceID || created.Capability != capability || created.TargetKey != job.TargetKey || created.ExecutionInput.RemoteAdapterKey != job.ExecutionInput.RemoteAdapterKey || created.ExecutionInput.RemoteAdapterKind != adapterKind {
|
|
||||||
svc.protectedRequests.Delete(jobID)
|
|
||||||
return validationError("protected request idempotency key is already bound")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustProtectedRequestRunEndpoint(svc *CoreService, serverInstanceID string) string {
|
|
||||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return instance.RunEndpointID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) GetProtectedRequestExecutionInput(request domain.ProtectedRequestExecutionInputRequest) (domain.ProtectedRequestExecutionInput, error) {
|
|
||||||
if err := validator.ValidateProtectedRequestExecutionInputRequest(request); err != nil {
|
|
||||||
return domain.ProtectedRequestExecutionInput{}, err
|
|
||||||
}
|
|
||||||
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
|
||||||
if err != nil {
|
|
||||||
return domain.ProtectedRequestExecutionInput{}, err
|
|
||||||
}
|
|
||||||
if request.FencingToken != uint64(job.Attempt) || !isProtectedRequestCapability(job.Capability) || job.RetryPolicy.MaxAttempts != 1 || !strings.HasPrefix(job.InputRef, "input://protected-request/") {
|
|
||||||
return domain.ProtectedRequestExecutionInput{}, validationError("job is not a fenced protected request")
|
|
||||||
}
|
|
||||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: job.ServerInstanceID})
|
|
||||||
if err != nil {
|
|
||||||
return domain.ProtectedRequestExecutionInput{}, err
|
|
||||||
}
|
|
||||||
var command domain.GameClientBridgeCommand
|
|
||||||
for _, candidate := range commands {
|
|
||||||
if candidate.RunJobID == job.ID {
|
|
||||||
command = candidate
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if command.ID == "" || command.ApprovalState != domain.GameClientBridgeApprovalApproved || command.State != domain.GameClientBridgeCommandPending || !command.ExpiresAt.After(svc.now()) {
|
|
||||||
return domain.ProtectedRequestExecutionInput{}, validationError("protected request is not currently authorized")
|
|
||||||
}
|
|
||||||
payload, err := svc.protectedRequests.Consume(job.ID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.ProtectedRequestExecutionInput{}, err
|
|
||||||
}
|
|
||||||
capability, adapterKind, capabilityErr := protectedRequestCapability(payload.kind)
|
|
||||||
if capabilityErr != nil || capability != job.Capability || payload.targetKey != job.TargetKey || payload.transportKey != job.ExecutionInput.RemoteAdapterKey || adapterKind != job.ExecutionInput.RemoteAdapterKind {
|
|
||||||
return domain.ProtectedRequestExecutionInput{}, validationError("protected request logical binding is invalid")
|
|
||||||
}
|
|
||||||
return domain.CopyProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, FencingToken: request.FencingToken, Authorized: true, ApprovalState: "approved", QueueState: "claimed", ExpiresAt: payload.expiresAt, Kind: payload.kind, TransportKey: payload.transportKey, TargetKey: payload.targetKey, RequestText: payload.requestText}), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isProtectedRequestCapability(capability string) bool {
|
|
||||||
_, _, err := protectedRequestCapabilityForCapability(capability)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func protectedRequestCapabilityForCapability(capability string) (string, string, error) {
|
|
||||||
switch capability {
|
|
||||||
case domain.JobCapabilityRemoteRunProtectedSQL:
|
|
||||||
return "sql", "protected-sql", nil
|
|
||||||
case domain.JobCapabilityRemoteRunProtectedRCON:
|
|
||||||
return "rcon", "protected-rcon", nil
|
|
||||||
case domain.JobCapabilityRemoteRunProgram:
|
|
||||||
return "program", "protected-program", nil
|
|
||||||
default:
|
|
||||||
return "", "", validationError("job is not a protected request")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) projectProtectedRequestJobResult(job domain.Job, result domain.RunJobResult, stamp time.Time) error {
|
|
||||||
if !isProtectedRequestCapability(job.Capability) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
svc.protectedRequests.Delete(job.ID)
|
|
||||||
svc.bridgeMu.Lock()
|
|
||||||
defer svc.bridgeMu.Unlock()
|
|
||||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: job.ServerInstanceID})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, command := range commands {
|
|
||||||
if command.RunJobID != job.ID || isTerminalGameClientBridgeCommandState(command.State) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch result.State {
|
|
||||||
case domain.JobStateSucceeded:
|
|
||||||
command.State = domain.GameClientBridgeCommandSucceeded
|
|
||||||
command.Result.Status = domain.GameClientBridgeResultSucceeded
|
|
||||||
case domain.JobStateCancelled:
|
|
||||||
command.State = domain.GameClientBridgeCommandCancelled
|
|
||||||
command.Result.Status = domain.GameClientBridgeResultCancelled
|
|
||||||
case domain.JobStateFailed:
|
|
||||||
command.State = domain.GameClientBridgeCommandFailed
|
|
||||||
command.Result.Status = domain.GameClientBridgeResultFailed
|
|
||||||
if result.ErrorCode == "protected_request_unknown" || strings.HasSuffix(result.ExecutionResult.Kind, ".unknown") {
|
|
||||||
command.State = domain.GameClientBridgeCommandUnknown
|
|
||||||
command.Result.Status = domain.GameClientBridgeResultUnknown
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
command.Result.Summary = "protected request completed by Run"
|
|
||||||
command.Result.CompletedBy = "run"
|
|
||||||
command.Result.CompletedAt = stamp
|
|
||||||
command.CompletedAt = stamp
|
|
||||||
command.UpdatedAt = stamp
|
|
||||||
auditID, auditErr := svc.recordAuditEventWithID("run", "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "Run recorded protected bridge command result")
|
|
||||||
if auditErr != nil {
|
|
||||||
return auditErr
|
|
||||||
}
|
|
||||||
command.AuditReferences = append(command.AuditReferences, auditID)
|
|
||||||
return svc.store.GameClientBridgeCommands().Update(command)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -66,8 +66,6 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request
|
|||||||
selected = domain.RemoteAdapterDeclaration{Key: "legacy-" + string(remoteAdapterKindForCapability(request.Capability)), Kind: remoteAdapterKindForCapability(request.Capability), TargetKeys: []string{request.TargetKey}, Capabilities: []string{request.Capability}, TimeoutSeconds: 30, MaxAttempts: 3}
|
selected = domain.RemoteAdapterDeclaration{Key: "legacy-" + string(remoteAdapterKindForCapability(request.Capability)), Kind: remoteAdapterKindForCapability(request.Capability), TargetKeys: []string{request.TargetKey}, Capabilities: []string{request.Capability}, TimeoutSeconds: 30, MaxAttempts: 3}
|
||||||
}
|
}
|
||||||
if selected.Key == "" {
|
if selected.Key == "" {
|
||||||
user, _ := svc.GetCurrentUser(sessionID)
|
|
||||||
_ = svc.recordAuditEvent(user.ID, "remote-adapter.authorize", "server-instance", instance.ID, domain.AuditResultDenied, "remote adapter declaration, target, or capability was not approved")
|
|
||||||
return domain.RemoteAdapterResult{}, ErrForbidden
|
return domain.RemoteAdapterResult{}, ErrForbidden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,15 +100,7 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.RemoteAdapterResult{}, err
|
return domain.RemoteAdapterResult{}, err
|
||||||
}
|
}
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
return domain.RemoteAdapterResult{RequestID: created.ID, ServerInstanceID: instance.ID, DeclarationKey: selected.Key, TargetKey: request.TargetKey, Kind: selected.Kind, Status: string(created.State), Retryable: attempts > 1, Message: "scoped remote adapter queued", ResultRef: "job://" + created.ID}, nil
|
||||||
if err != nil {
|
|
||||||
return domain.RemoteAdapterResult{}, err
|
|
||||||
}
|
|
||||||
auditID, err := svc.recordAuditEventWithID(user.ID, "remote-adapter.authorize", "server-instance", instance.ID, domain.AuditResultQueued, "authorized declared remote adapter target with bounded timeout and retry")
|
|
||||||
if err != nil {
|
|
||||||
return domain.RemoteAdapterResult{}, err
|
|
||||||
}
|
|
||||||
return domain.RemoteAdapterResult{RequestID: created.ID, ServerInstanceID: instance.ID, DeclarationKey: selected.Key, TargetKey: request.TargetKey, Kind: selected.Kind, Status: string(created.State), Retryable: attempts > 1, Message: "scoped remote adapter queued", ResultRef: "job://" + created.ID, AuditEventID: auditID}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func intersectRemoteCapabilities(profile []string, declared []string, endpoint []string) []string {
|
func intersectRemoteCapabilities(profile []string, declared []string, endpoint []string) []string {
|
||||||
|
|||||||
@@ -107,12 +107,6 @@ type Core interface {
|
|||||||
DeleteServerInstanceForSession(string, string, domain.ServerDeletionRequest) (domain.ServerInstance, error)
|
DeleteServerInstanceForSession(string, string, domain.ServerDeletionRequest) (domain.ServerInstance, error)
|
||||||
GetPlatformResourceUsage() (domain.PlatformResourceUsage, error)
|
GetPlatformResourceUsage() (domain.PlatformResourceUsage, error)
|
||||||
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
||||||
GetProductionCapacityForSession(string) (domain.ProductionCapacitySummary, error)
|
|
||||||
CheckCapacityAdmissionForSession(string, domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error)
|
|
||||||
ListAlertsForSession(string, domain.AlertFilter) ([]domain.AlertRecord, error)
|
|
||||||
AcknowledgeAlertForSession(string, domain.AlertAcknowledgeRequest) (domain.AlertRecord, error)
|
|
||||||
ResolveAlertForSession(string, domain.AlertResolveRequest) (domain.AlertRecord, error)
|
|
||||||
RetryAlertForSession(string, domain.AlertRetryRequest) (domain.AlertRetryResult, error)
|
|
||||||
ListPluginLifecyclesForSession(string, domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
|
ListPluginLifecyclesForSession(string, domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
|
||||||
RunPluginLifecycleForSession(string, domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error)
|
RunPluginLifecycleForSession(string, domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error)
|
||||||
ListAIConfigDiffsForSession(string, domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
ListAIConfigDiffsForSession(string, domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
||||||
@@ -144,7 +138,6 @@ type Core interface {
|
|||||||
GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error)
|
GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error)
|
||||||
DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error)
|
DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error)
|
||||||
GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error)
|
GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error)
|
||||||
GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest) (domain.ProtectedRequestExecutionInput, error)
|
|
||||||
GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error)
|
GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error)
|
||||||
ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
|
ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
|
||||||
ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error)
|
ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error)
|
||||||
@@ -215,9 +208,6 @@ type Core interface {
|
|||||||
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
||||||
GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, error)
|
GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, error)
|
||||||
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
|
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
|
||||||
CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
|
|
||||||
GetAuditEvent(string) (domain.AuditEvent, error)
|
|
||||||
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)
|
|
||||||
SeedPlatformAdmin(string, string) error
|
SeedPlatformAdmin(string, string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +234,8 @@ type CoreService struct {
|
|||||||
artifactTransfers map[string]domain.ArtifactTransferSession
|
artifactTransfers map[string]domain.ArtifactTransferSession
|
||||||
artifactPayloads map[string][]byte
|
artifactPayloads map[string][]byte
|
||||||
artifactTransferSeq uint64
|
artifactTransferSeq uint64
|
||||||
auditMu sync.Mutex
|
|
||||||
auditSeq uint64
|
|
||||||
productionMu sync.Mutex
|
productionMu sync.Mutex
|
||||||
sourceRCONCommands *sourceRCONCommandBroker
|
sourceRCONCommands *sourceRCONCommandBroker
|
||||||
protectedRequests *protectedRequestBroker
|
|
||||||
aiProviderClient AIProviderClient
|
aiProviderClient AIProviderClient
|
||||||
secretEnvelope SecretEnvelope
|
secretEnvelope SecretEnvelope
|
||||||
networkFingerprintKey []byte
|
networkFingerprintKey []byte
|
||||||
@@ -288,7 +275,6 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
|
|||||||
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
||||||
artifactPayloads: map[string][]byte{},
|
artifactPayloads: map[string][]byte{},
|
||||||
sourceRCONCommands: newSourceRCONCommandBroker(now),
|
sourceRCONCommands: newSourceRCONCommandBroker(now),
|
||||||
protectedRequests: newProtectedRequestBroker(now),
|
|
||||||
aiProviderClient: MockAIProviderClient{},
|
aiProviderClient: MockAIProviderClient{},
|
||||||
secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey),
|
secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey),
|
||||||
networkFingerprintKey: []byte(developmentSecretEnvelopeKey),
|
networkFingerprintKey: []byte(developmentSecretEnvelopeKey),
|
||||||
@@ -703,23 +689,6 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult,
|
|||||||
result.Success = false
|
result.Success = false
|
||||||
result.Message = "provider invocation failed safely"
|
result.Message = "provider invocation failed safely"
|
||||||
result.Violations = []string{"provider invocation failed safely"}
|
result.Violations = []string{"provider invocation failed safely"}
|
||||||
auditID, auditErr := svc.recordAuditEventWithID("platform", "ai.provider.test.failed", "ai-provider", provider.ID, domain.AuditResultFailed, result.Message)
|
|
||||||
if auditErr != nil {
|
|
||||||
return domain.AIProviderTestResult{}, auditErr
|
|
||||||
}
|
|
||||||
svc.productionMu.Lock()
|
|
||||||
_, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider health check failed", Message: result.Message, Retryable: false, LastAuditEventID: auditID})
|
|
||||||
svc.productionMu.Unlock()
|
|
||||||
if alertErr != nil {
|
|
||||||
return domain.AIProviderTestResult{}, alertErr
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
svc.productionMu.Lock()
|
|
||||||
resolveErr := svc.resolveAlertForSource("ai-provider", provider.ID, "ai.provider.failed", "platform", "AI provider health check passed", "")
|
|
||||||
svc.productionMu.Unlock()
|
|
||||||
if resolveErr != nil {
|
|
||||||
return domain.AIProviderTestResult{}, resolveErr
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return domain.CopyAIProviderTestResult(result), nil
|
return domain.CopyAIProviderTestResult(result), nil
|
||||||
}
|
}
|
||||||
@@ -1020,7 +989,7 @@ func (svc *CoreService) executeBridgePluginLifecycle(sessionID string, base doma
|
|||||||
return bridgeExecutionError(base, err)
|
return bridgeExecutionError(base, err)
|
||||||
}
|
}
|
||||||
base.Status = result.Status
|
base.Status = result.Status
|
||||||
base.Result = map[string]string{"installationId": result.Installation.ID, "currentState": string(result.Installation.CurrentState), "desiredState": string(result.Installation.DesiredState), "jobId": result.Job.ID, "alertId": result.Installation.AlertID, "auditEventId": result.Installation.AuditEventID, "admissionState": string(result.Decision.State), "admissionReason": result.Decision.Reason}
|
base.Result = map[string]string{"installationId": result.Installation.ID, "currentState": string(result.Installation.CurrentState), "desiredState": string(result.Installation.DesiredState), "jobId": result.Job.ID}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2668,27 +2637,6 @@ func (svc *CoreService) ListLogStreams(filter domain.LogStreamFilter) ([]domain.
|
|||||||
return svc.store.LogStreams().List(filter)
|
return svc.store.LogStreams().List(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) CreateAuditEvent(event domain.AuditEvent) (domain.AuditEvent, error) {
|
|
||||||
if event.CreatedAt.IsZero() {
|
|
||||||
event.CreatedAt = svc.now()
|
|
||||||
}
|
|
||||||
if err := validator.ValidateAuditEvent(event); err != nil {
|
|
||||||
return domain.AuditEvent{}, err
|
|
||||||
}
|
|
||||||
if err := svc.store.AuditEvents().Create(event); err != nil {
|
|
||||||
return domain.AuditEvent{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyAuditEvent(event), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) GetAuditEvent(id string) (domain.AuditEvent, error) {
|
|
||||||
return svc.store.AuditEvents().Get(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) ListAuditEvents(filter domain.AuditEventFilter) ([]domain.AuditEvent, error) {
|
|
||||||
return svc.store.AuditEvents().List(filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) validateRunnableEndpoint(endpoint domain.RunEndpoint, capability string) error {
|
func (svc *CoreService) validateRunnableEndpoint(endpoint domain.RunEndpoint, capability string) error {
|
||||||
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
|
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
|
||||||
return validationError("run endpoint must be online or degraded")
|
return validationError("run endpoint must be online or degraded")
|
||||||
|
|||||||
@@ -163,28 +163,6 @@ func TestCoreServiceCreateListGetWorkflows(t *testing.T) {
|
|||||||
t.Fatalf("list log streams: len=%d err=%v", len(streams), err)
|
t.Fatalf("list log streams: len=%d err=%v", len(streams), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
audit, err := svc.CreateAuditEvent(domain.AuditEvent{
|
|
||||||
ID: "audit-1",
|
|
||||||
ActorID: user.ID,
|
|
||||||
Action: "server.create",
|
|
||||||
ResourceKind: "server-instance",
|
|
||||||
ResourceID: instance.ID,
|
|
||||||
Result: domain.AuditResultSuccess,
|
|
||||||
Summary: "created server instance",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create audit event: %v", err)
|
|
||||||
}
|
|
||||||
if !audit.CreatedAt.Equal(fixedTime) {
|
|
||||||
t.Fatalf("expected audit timestamp default, got %+v", audit)
|
|
||||||
}
|
|
||||||
if _, err := svc.GetAuditEvent(audit.ID); err != nil {
|
|
||||||
t.Fatalf("get audit event: %v", err)
|
|
||||||
}
|
|
||||||
auditEvents, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID})
|
|
||||||
if err != nil || len(auditEvents) != 1 {
|
|
||||||
t.Fatalf("list audit events: len=%d err=%v", len(auditEvents), err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreServiceCreateRemoteProgramJobCreatesManagementLogStreams(t *testing.T) {
|
func TestCoreServiceCreateRemoteProgramJobCreatesManagementLogStreams(t *testing.T) {
|
||||||
@@ -1070,7 +1048,7 @@ func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) {
|
|||||||
t.Fatalf("claim typed config job: claim=%+v err=%v", claim, err)
|
t.Fatalf("claim typed config job: claim=%+v err=%v", claim, err)
|
||||||
}
|
}
|
||||||
checksum := validator.BytesChecksum([]byte(proposed))
|
checksum := validator.BytesChecksum([]byte(proposed))
|
||||||
if _, 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, Message: "config write completed"}, Message: "config write completed", ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: dispatch.Job.ExecutionInput.ExpectedVersion + 1, Checksum: checksum, SizeBytes: int64(len(proposed)), AuditSummary: "atomic compare-and-swap file write"}}); err != nil {
|
if _, 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, Message: "config write completed"}, Message: "config write completed", ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: dispatch.Job.ExecutionInput.ExpectedVersion + 1, Checksum: checksum, SizeBytes: int64(len(proposed)), Summary: "atomic compare-and-swap file write"}}); err != nil {
|
||||||
t.Fatalf("complete typed config job: %v", err)
|
t.Fatalf("complete typed config job: %v", err)
|
||||||
}
|
}
|
||||||
updated, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
updated, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t
|
|||||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{
|
if _, err := svc.CompleteRunJob(domain.RunJobResult{
|
||||||
RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt,
|
RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt,
|
||||||
State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"}, Message: "done", ResultRef: "artifact://jobs/guided-success/lifecycle-result",
|
State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"}, Message: "done", ResultRef: "artifact://jobs/guided-success/lifecycle-result",
|
||||||
ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", AuditSummary: "bounded process state"},
|
ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", Summary: "bounded process state"},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("guided plugin lifecycle success without receipt should be terminal: %v", err)
|
t.Fatalf("guided plugin lifecycle success without receipt should be terminal: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,13 +52,6 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do
|
|||||||
}
|
}
|
||||||
svc.publishLogProcessState(instance)
|
svc.publishLogProcessState(instance)
|
||||||
}
|
}
|
||||||
auditResult := domain.AuditResultSuccess
|
|
||||||
if report.State == domain.JobStateFailed || report.State == domain.JobStateCancelled {
|
|
||||||
auditResult = domain.AuditResultFailed
|
|
||||||
}
|
|
||||||
if err := svc.recordAuditEvent("run:"+report.RunEndpointID, "lifecycle.report", "server-instance", instance.ID, auditResult, lifecycleReportSummary(report, nextState, projected)); err != nil {
|
|
||||||
return domain.RunLifecycleReportResult{}, err
|
|
||||||
}
|
|
||||||
return domain.CopyRunLifecycleReportResult(domain.RunLifecycleReportResult{Accepted: true, RunEndpointID: report.RunEndpointID, ServerInstanceID: report.ServerInstanceID, ProjectedState: nextState, ServerTime: stamp}), nil
|
return domain.CopyRunLifecycleReportResult(domain.RunLifecycleReportResult{Accepted: true, RunEndpointID: report.RunEndpointID, ServerInstanceID: report.ServerInstanceID, ProjectedState: nextState, ServerTime: stamp}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,40 +65,17 @@ func lifecycleObservationIsStale(instance domain.ServerInstance, report domain.R
|
|||||||
return !report.ObservedAt.IsZero() && !instance.LifecycleObservedAt.IsZero() && report.ObservedAt.Before(instance.LifecycleObservedAt)
|
return !report.ObservedAt.IsZero() && !instance.LifecycleObservedAt.IsZero() && report.ObservedAt.Before(instance.LifecycleObservedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func lifecycleReportSummary(report domain.RunLifecycleReport, projectedState domain.ServerInstanceState, projected bool) string {
|
|
||||||
for _, candidate := range []string{report.ExecutionResult.AuditSummary, report.Progress.Message, report.Message, report.ErrorCode} {
|
|
||||||
if strings.TrimSpace(candidate) != "" {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if projected {
|
|
||||||
return "run reported " + report.Capability + " " + string(report.State) + "; projected server state " + string(projectedState)
|
|
||||||
}
|
|
||||||
return "run reported " + report.Capability + " " + string(report.State)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time.Time) error {
|
func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time.Time) error {
|
||||||
if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) {
|
if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
result := domain.AuditResultSuccess
|
return nil
|
||||||
if job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled {
|
|
||||||
result = domain.AuditResultFailed
|
|
||||||
}
|
|
||||||
summary := "remote adapter " + job.Capability + " completed with bounded result reference"
|
|
||||||
if job.State == domain.JobStateFailed {
|
|
||||||
summary = "remote adapter " + job.Capability + " failed or timed out; retry/fencing remained platform-owned"
|
|
||||||
}
|
|
||||||
if job.State == domain.JobStateCancelled {
|
|
||||||
summary = "remote adapter " + job.Capability + " was cancelled before terminal projection"
|
|
||||||
}
|
|
||||||
return svc.recordAuditEvent("run:"+job.RunEndpointID, "remote-adapter.result", "server-instance", job.ServerInstanceID, result, summary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error {
|
func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error {
|
||||||
if job.Capability == domain.JobCapabilityConfigWrite {
|
if job.Capability == domain.JobCapabilityConfigWrite {
|
||||||
if job.State != domain.JobStateSucceeded {
|
if job.State != domain.JobStateSucceeded {
|
||||||
return svc.recordAuditEvent("run:"+job.RunEndpointID, "config.write.result", "server-instance", job.ServerInstanceID, domain.AuditResultFailed, job.ExecutionResult.AuditSummary)
|
return nil
|
||||||
}
|
}
|
||||||
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,7 +93,7 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
|
|||||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return svc.recordAuditEvent("run:"+job.RunEndpointID, "config.write.result", "server-instance", instance.ID, domain.AuditResultSuccess, job.ExecutionResult.AuditSummary)
|
return nil
|
||||||
}
|
}
|
||||||
nextState, ok := lifecycleProjectedState(job.Capability, job.State, job.ExecutionResult)
|
nextState, ok := lifecycleProjectedState(job.Capability, job.State, job.ExecutionResult)
|
||||||
if !ok || job.ServerInstanceID == "" {
|
if !ok || job.ServerInstanceID == "" {
|
||||||
@@ -142,11 +112,7 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
svc.publishLogProcessState(instance)
|
svc.publishLogProcessState(instance)
|
||||||
auditResult := domain.AuditResultSuccess
|
return nil
|
||||||
if job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled {
|
|
||||||
auditResult = domain.AuditResultFailed
|
|
||||||
}
|
|
||||||
return svc.recordAuditEvent("run:"+job.RunEndpointID, "lifecycle.result", "server-instance", instance.ID, auditResult, job.Progress.Message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
|
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
|
||||||
|
|||||||
@@ -515,9 +515,9 @@ func claimAndCompleteLifecycleJobForServer(t *testing.T, svc *CoreService, sessi
|
|||||||
if state == domain.JobStateSucceeded {
|
if state == domain.JobStateSucceeded {
|
||||||
switch capability {
|
switch capability {
|
||||||
case domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStatus:
|
case domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStatus:
|
||||||
executionResult = domain.JobExecutionResult{Kind: "process", ProcessState: "running", AuditSummary: "bounded process state"}
|
executionResult = domain.JobExecutionResult{Kind: "process", ProcessState: "running", Summary: "bounded process state"}
|
||||||
case domain.LifecycleCapabilityStop:
|
case domain.LifecycleCapabilityStop:
|
||||||
executionResult = domain.JobExecutionResult{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop", AuditSummary: "bounded process state"}
|
executionResult = domain.JobExecutionResult{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop", Summary: "bounded process state"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{
|
if _, err := svc.CompleteRunJob(domain.RunJobResult{
|
||||||
|
|||||||
@@ -235,62 +235,6 @@ func sourceRCONTransportForCapability(profiles domain.GamePluginRuntimeProfiles,
|
|||||||
return selected, nil
|
return selected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) resolveProtectedSourceRCONDispatch(serverInstanceID string, request *domain.GameClientBridgeProtectedRequestDeclaration) (sourceRCONDispatchResolution, error) {
|
|
||||||
if request == nil || request.Kind != "rcon" {
|
|
||||||
return sourceRCONDispatchResolution{}, validationError("protected request is not RCON")
|
|
||||||
}
|
|
||||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
|
||||||
if err != nil {
|
|
||||||
return sourceRCONDispatchResolution{}, err
|
|
||||||
}
|
|
||||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
||||||
if err != nil {
|
|
||||||
return sourceRCONDispatchResolution{}, err
|
|
||||||
}
|
|
||||||
capability := domain.JobCapabilityRemoteRunProtectedRCON
|
|
||||||
if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion || !plugin.Permissions.RemoteAccess || !plugin.RemoteAccess.RCON || !containsString(plugin.RequiredRunCapabilities, capability) || !containsString(plugin.RemoteAccess.RunCapabilities, capability) {
|
|
||||||
return sourceRCONDispatchResolution{}, forbiddenError("plugin does not declare protected SCUM RCON access")
|
|
||||||
}
|
|
||||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
|
||||||
if err != nil {
|
|
||||||
return sourceRCONDispatchResolution{}, err
|
|
||||||
}
|
|
||||||
if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil {
|
|
||||||
return sourceRCONDispatchResolution{}, err
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(endpoint.Platform, "windows") || !strings.EqualFold(endpoint.Architecture, "amd64") {
|
|
||||||
return sourceRCONDispatchResolution{}, validationError("unsupported_extension_platform: SCUM Source RCON requires windows/amd64")
|
|
||||||
}
|
|
||||||
binding, err := svc.runtimeBindingForServer(instance.ID)
|
|
||||||
if err != nil {
|
|
||||||
return sourceRCONDispatchResolution{}, err
|
|
||||||
}
|
|
||||||
binding, err = normalizeRuntimeBinding(plugin, binding)
|
|
||||||
if err != nil {
|
|
||||||
return sourceRCONDispatchResolution{}, err
|
|
||||||
}
|
|
||||||
if binding.Status != domain.RuntimeBindingStatusComplete || binding.PluginVersion != plugin.Version {
|
|
||||||
return sourceRCONDispatchResolution{}, validationError("runtime binding is incomplete or stale")
|
|
||||||
}
|
|
||||||
profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey)
|
|
||||||
if !exists || !containsString(profile.Capabilities, capability) || !runtimePlatformsContain(profile.Platforms, "windows") {
|
|
||||||
return sourceRCONDispatchResolution{}, validationError("selected runtime profile does not support protected SCUM RCON")
|
|
||||||
}
|
|
||||||
transport, err := sourceRCONTransportForCapability(plugin.RuntimeProfiles, profile, request.TransportKey, capability)
|
|
||||||
if err != nil {
|
|
||||||
return sourceRCONDispatchResolution{}, err
|
|
||||||
}
|
|
||||||
if transport.TargetKey != request.TargetKey {
|
|
||||||
return sourceRCONDispatchResolution{}, validationError("protected RCON transport target is invalid")
|
|
||||||
}
|
|
||||||
extension, err := sourceRCONExtension(plugin.RuntimeProfiles, profile, endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return sourceRCONDispatchResolution{}, err
|
|
||||||
}
|
|
||||||
plan := &domain.RuntimeSourceRCONPlan{Protocol: "source-rcon", ExtensionKey: extension.Key, ModKey: extension.ModKey, ConfigRef: "ue4ss/Mods/" + extension.ModKey + "/config.ini", DeploymentStateRef: "runtime/ue4ss-dll/" + extension.TargetKey + "/release.json", Port: extension.RCONPort}
|
|
||||||
return sourceRCONDispatchResolution{plugin: plugin, binding: binding, transport: transport, plan: plan}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceRCONExtension(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) (domain.RuntimeDLLExtensionProfile, error) {
|
func sourceRCONExtension(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) (domain.RuntimeDLLExtensionProfile, error) {
|
||||||
byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(profiles.DLLExtensions))
|
byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(profiles.DLLExtensions))
|
||||||
for _, extension := range profiles.DLLExtensions {
|
for _, extension := range profiles.DLLExtensions {
|
||||||
|
|||||||
@@ -80,68 +80,6 @@ func TestSourceRCONDispatchUsesOneTimeRedactedInput(t *testing.T) {
|
|||||||
if strings.Contains(string(storedJSON), input.Command) || strings.Contains(string(storedJSON), request.Message) {
|
if strings.Contains(string(storedJSON), input.Command) || strings.Contains(string(storedJSON), request.Message) {
|
||||||
t.Fatalf("consumed command was persisted: %s", storedJSON)
|
t.Fatalf("consumed command was persisted: %s", storedJSON)
|
||||||
}
|
}
|
||||||
if events, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: instance.ID}); err != nil || len(events) != 0 {
|
|
||||||
t.Fatalf("RCON command must not add an audit event, events=%+v err=%v", events, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProtectedRCONBridgeDispatchCarriesSourceRCONPlan(t *testing.T) {
|
|
||||||
svc, _, _, instance := newSourceRCONFixture(t)
|
|
||||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
protectedCapability := domain.JobCapabilityRemoteRunProtectedRCON
|
|
||||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, protectedCapability)
|
|
||||||
plugin.RemoteAccess.RunCapabilities = append(plugin.RemoteAccess.RunCapabilities, protectedCapability)
|
|
||||||
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}
|
|
||||||
plugin.RuntimeProfiles.LifecycleProfiles[0].Capabilities = append(plugin.RuntimeProfiles.LifecycleProfiles[0].Capabilities, protectedCapability)
|
|
||||||
plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys = append(plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys, "scum-management")
|
|
||||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{protectedCapability}})
|
|
||||||
plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "management.rcon.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 120, MaxPayloadBytes: 8192, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "rcon", TransportKey: "scum-management", TargetKey: "scum-management", TextField: "requestText", MaxTextBytes: 8192}})
|
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon": "runtime-rcon", "scum-management": "runtime-rcon"}}, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("refresh protected RCON binding: %v", err)
|
|
||||||
}
|
|
||||||
if err := svc.store.RuntimeBindings().Update(binding); err != nil {
|
|
||||||
t.Fatalf("store protected RCON binding: %v", err)
|
|
||||||
}
|
|
||||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
endpoint.Capabilities = append(endpoint.Capabilities, protectedCapability)
|
|
||||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := svc.resolveProtectedSourceRCONDispatch(instance.ID, plugin.GameClientBridge.Commands[len(plugin.GameClientBridge.Commands)-1].ProtectedRequest); err != nil {
|
|
||||||
t.Fatalf("resolve protected Source RCON plan: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
command, err := svc.queueGameClientBridgeCommand("user-rcon-owner", domain.GameClientBridgeQueueRequest{ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: "scum-client-manager", CommandType: "management.rcon.request", Payload: map[string]any{"requestText": "#ListPlayers"}, IdempotencyKey: "protected-rcon-1", ExpiresAt: fixedTime.Add(time.Minute)})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("queue protected RCON: %v", err)
|
|
||||||
}
|
|
||||||
job, err := svc.store.Jobs().Get(command.RunJobID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get protected RCON job: %v", err)
|
|
||||||
}
|
|
||||||
if job.Capability != protectedCapability || job.InputRef == "" || !strings.HasPrefix(job.InputRef, "input://protected-request/") || job.ExecutionInput.SourceRCON == nil {
|
|
||||||
t.Fatalf("expected protected RCON job with frozen Source RCON plan, got %+v", job)
|
|
||||||
}
|
|
||||||
if job.ExecutionInput.WorkspaceScope != "local" || job.ExecutionInput.RemoteAdapterKey != "scum-management" || job.ExecutionInput.RemoteAdapterKind != "protected-rcon" || job.ExecutionInput.SourceRCON.Port != 27015 {
|
|
||||||
t.Fatalf("protected RCON plan did not preserve logical runtime binding: %+v", job.ExecutionInput)
|
|
||||||
}
|
|
||||||
serialized, err := json.Marshal(job)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(serialized), "#ListPlayers") || strings.Contains(string(serialized), "password=") {
|
|
||||||
t.Fatalf("protected RCON job leaked transient input: %s", serialized)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSourceRCONDispatchRejectsUnsafeOrIncompatibleState(t *testing.T) {
|
func TestSourceRCONDispatchRejectsUnsafeOrIncompatibleState(t *testing.T) {
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import (
|
|||||||
|
|
||||||
func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
||||||
bridge := domain.GameClientBridgeManifest{
|
bridge := domain.GameClientBridgeManifest{
|
||||||
Commands: []domain.GameClientBridgeCommandDeclaration{{
|
|
||||||
Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone,
|
|
||||||
PayloadSchemaRef: "schemas/bridge/announcement.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096,
|
|
||||||
}},
|
|
||||||
LogProjections: []domain.GameClientBridgeLogProjectionDeclaration{{
|
LogProjections: []domain.GameClientBridgeLogProjectionDeclaration{{
|
||||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 16,
|
Key: "player.login", StreamKeys: []string{"process.stdout"}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 16,
|
||||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
||||||
@@ -23,7 +19,6 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
|||||||
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||||
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
|
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
|
||||||
ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, ObservedAtField: "observedAt"},
|
ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, ObservedAtField: "observedAt"},
|
||||||
Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{ProfileKey: "scum-client", CommandType: "announcement.send", TextField: "message", NewTextTemplate: "welcome {{name}}", ReturningTextTemplate: "welcome back {{name}}"},
|
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||||
@@ -44,11 +39,11 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
|||||||
{name: "missing capture", expected: "references undeclared capture missing", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
{name: "missing capture", expected: "references undeclared capture missing", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||||
value.LogProjections[0].Target.CaptureMappings["steamId"] = "missing"
|
value.LogProjections[0].Target.CaptureMappings["steamId"] = "missing"
|
||||||
}},
|
}},
|
||||||
{name: "missing profile", expected: "must reference a declared game-client bridge profile", mutate: func(_ *domain.GameClientBridgeManifest, value *domain.GamePluginRuntimeProfiles) {
|
{name: "invalid timestamp field", expected: "presence.timestampField must reference", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||||
value.ClientManagers = nil
|
value.LogProjections[0].Presence.TimestampField = "missingAt"
|
||||||
}},
|
}},
|
||||||
{name: "missing command", expected: "must reference a declared command", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
{name: "invalid activity target", expected: "presence.activityTarget.upsertKeys field missing is not projected", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||||
value.LogProjections[0].Presence.Announcement.CommandType = "missing.command"
|
value.LogProjections[0].Presence.ActivityTarget.UpsertKeys = []string{"missing"}
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func validBridgeQueueRequest() domain.GameClientBridgeQueueRequest {
|
func validBridgeQueueRequest() domain.GameClientBridgeQueueRequest {
|
||||||
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "announce-1", ExpiresAt: time.Now().UTC().Add(time.Minute)}
|
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "diag-1", ExpiresAt: time.Now().UTC().Add(time.Minute)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateGameClientBridgeRequests(t *testing.T) {
|
func TestValidateGameClientBridgeRequests(t *testing.T) {
|
||||||
|
|||||||
@@ -98,14 +98,6 @@ func ValidateSourceRCONExecutionInputRequest(request domain.SourceRCONExecutionI
|
|||||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateProtectedRequestExecutionInputRequest(request domain.ProtectedRequestExecutionInputRequest) error {
|
|
||||||
violations := appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
|
||||||
if request.FencingToken == 0 {
|
|
||||||
violations = append(violations, "fencingToken is required")
|
|
||||||
}
|
|
||||||
return finish(violations)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateRunUpdateInputRequest(request domain.RunUpdateInputRequest) error {
|
func ValidateRunUpdateInputRequest(request domain.RunUpdateInputRequest) error {
|
||||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +1,11 @@
|
|||||||
package validator
|
package validator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ValidateCapacityAdmissionRequest(request domain.CapacityAdmissionRequest) error {
|
|
||||||
var violations []string
|
|
||||||
violations = appendRequired(violations, "capability", request.Capability)
|
|
||||||
if request.ServerInstanceID != "" && !safeIdentifier(request.ServerInstanceID) {
|
|
||||||
violations = append(violations, "serverInstanceId is invalid")
|
|
||||||
}
|
|
||||||
if request.RunEndpointID != "" && !safeIdentifier(request.RunEndpointID) {
|
|
||||||
violations = append(violations, "runEndpointId is invalid")
|
|
||||||
}
|
|
||||||
if request.TargetKey != "" && !validLogicalFileKey(request.TargetKey) {
|
|
||||||
violations = append(violations, "targetKey is invalid")
|
|
||||||
}
|
|
||||||
if unsafeProductionText(request.Capability) || unsafeProductionText(request.IdempotencyKey) {
|
|
||||||
violations = append(violations, "capacity request contains unsafe content")
|
|
||||||
}
|
|
||||||
return finish(violations)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateCapacityAdmissionDecision(decision domain.CapacityAdmissionDecision) error {
|
|
||||||
var violations []string
|
|
||||||
if !validCapacityAdmissionState(decision.State) {
|
|
||||||
violations = append(violations, "state is invalid")
|
|
||||||
}
|
|
||||||
violations = appendRequired(violations, "reason", decision.Reason)
|
|
||||||
if len(decision.Reason) > maxProductionMessageLength || unsafeProductionText(decision.Reason) {
|
|
||||||
violations = append(violations, "reason is unsafe")
|
|
||||||
}
|
|
||||||
for i, code := range decision.PressureCodes {
|
|
||||||
if !validCapacityPressureCode(code) {
|
|
||||||
violations = append(violations, fmt.Sprintf("pressureCodes[%d] is invalid", i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if decision.RunningJobs < 0 || decision.QueuedJobs < 0 || decision.MaxJobs < 0 {
|
|
||||||
violations = append(violations, "capacity counts must not be negative")
|
|
||||||
}
|
|
||||||
return finish(violations)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateAlertRecord(alert domain.AlertRecord) error {
|
|
||||||
var violations []string
|
|
||||||
violations = appendRequired(violations, "id", alert.ID)
|
|
||||||
violations = appendRequired(violations, "sourceKind", alert.SourceKind)
|
|
||||||
violations = appendRequired(violations, "sourceId", alert.SourceID)
|
|
||||||
violations = appendRequired(violations, "ruleKey", alert.RuleKey)
|
|
||||||
violations = appendRequired(violations, "title", alert.Title)
|
|
||||||
violations = appendRequired(violations, "message", alert.Message)
|
|
||||||
if !validAlertSeverity(alert.Severity) {
|
|
||||||
violations = append(violations, "severity is invalid")
|
|
||||||
}
|
|
||||||
if !validAlertState(alert.State) {
|
|
||||||
violations = append(violations, "state is invalid")
|
|
||||||
}
|
|
||||||
if alert.OccurrenceCount <= 0 {
|
|
||||||
violations = append(violations, "occurrenceCount must be positive")
|
|
||||||
}
|
|
||||||
for _, value := range []fieldString{{field: "title", value: alert.Title}, {field: "message", value: alert.Message}, {field: "resolutionNote", value: alert.ResolutionNote}} {
|
|
||||||
if len(value.value) > maxProductionMessageLength || unsafeProductionText(value.value) {
|
|
||||||
violations = append(violations, value.field+" is unsafe")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return finish(violations)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateAlertAcknowledgeRequest(request domain.AlertAcknowledgeRequest) error {
|
|
||||||
return validateAlertNoteRequest(request.AlertID, request.Note)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateAlertResolveRequest(request domain.AlertResolveRequest) error {
|
|
||||||
return validateAlertNoteRequest(request.AlertID, request.Note)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateAlertRetryRequest(request domain.AlertRetryRequest) error {
|
|
||||||
var violations []string
|
|
||||||
violations = appendRequired(violations, "alertId", request.AlertID)
|
|
||||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
|
||||||
if unsafeProductionText(request.AlertID) || unsafeProductionText(request.IdempotencyKey) {
|
|
||||||
violations = append(violations, "alert retry request is unsafe")
|
|
||||||
}
|
|
||||||
return finish(violations)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidatePluginLifecycleInstallation(installation domain.PluginLifecycleInstallation) error {
|
func ValidatePluginLifecycleInstallation(installation domain.PluginLifecycleInstallation) error {
|
||||||
var violations []string
|
var violations []string
|
||||||
violations = appendRequired(violations, "id", installation.ID)
|
violations = appendRequired(violations, "id", installation.ID)
|
||||||
@@ -165,51 +83,6 @@ func ValidateAIConfigDiffApprovalRequest(request domain.AIConfigDiffApprovalRequ
|
|||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAlertNoteRequest(alertID string, note string) error {
|
|
||||||
var violations []string
|
|
||||||
violations = appendRequired(violations, "alertId", alertID)
|
|
||||||
if unsafeProductionText(alertID) || len(note) > maxProductionMessageLength || unsafeProductionText(note) {
|
|
||||||
violations = append(violations, "alert note request is unsafe")
|
|
||||||
}
|
|
||||||
return finish(violations)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCapacityAdmissionState(state domain.CapacityAdmissionState) bool {
|
|
||||||
switch state {
|
|
||||||
case domain.CapacityAdmissionAccepted, domain.CapacityAdmissionDeferred, domain.CapacityAdmissionDenied:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCapacityPressureCode(code domain.CapacityPressureCode) bool {
|
|
||||||
switch code {
|
|
||||||
case domain.CapacityPressureEndpointOffline, domain.CapacityPressureEndpointStale, domain.CapacityPressureCapabilityGap, domain.CapacityPressureJobLimit, domain.CapacityPressureQueueLimit, domain.CapacityPressureBacklog:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validAlertSeverity(severity domain.AlertSeverity) bool {
|
|
||||||
switch severity {
|
|
||||||
case domain.AlertSeverityInfo, domain.AlertSeverityWarning, domain.AlertSeverityCritical:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validAlertState(state domain.AlertState) bool {
|
|
||||||
switch state {
|
|
||||||
case domain.AlertStateActive, domain.AlertStateAcknowledged, domain.AlertStateResolved:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validPluginLifecycleState(state domain.PluginLifecycleState) bool {
|
func validPluginLifecycleState(state domain.PluginLifecycleState) bool {
|
||||||
switch state {
|
switch state {
|
||||||
case domain.PluginLifecycleStatePending, domain.PluginLifecycleStateInstalled, domain.PluginLifecycleStateEnabled, domain.PluginLifecycleStateDisabled, domain.PluginLifecycleStateUpgrading, domain.PluginLifecycleStateRollingBack, domain.PluginLifecycleStateRetired, domain.PluginLifecycleStateFailed:
|
case domain.PluginLifecycleStatePending, domain.PluginLifecycleStateInstalled, domain.PluginLifecycleStateEnabled, domain.PluginLifecycleStateDisabled, domain.PluginLifecycleStateUpgrading, domain.PluginLifecycleStateRollingBack, domain.PluginLifecycleStateRetired, domain.PluginLifecycleStateFailed:
|
||||||
|
|||||||
+16
-133
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxAuditSummaryLength = 512
|
maxSummaryLength = 512
|
||||||
maxContactNoteLength = 160
|
maxContactNoteLength = 160
|
||||||
maxMarketplaceKeywordSize = 80
|
maxMarketplaceKeywordSize = 80
|
||||||
maxMarketplaceListSize = 500
|
maxMarketplaceListSize = 500
|
||||||
@@ -500,7 +500,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
commandTypes := map[string]struct{}{}
|
commandTypes := map[string]struct{}{}
|
||||||
for index, command := range bridge.Commands {
|
for index, command := range bridge.Commands {
|
||||||
prefix := fmt.Sprintf("%s.commands[%d]", field, index)
|
prefix := fmt.Sprintf("%s.commands[%d]", field, index)
|
||||||
if !clientManagerIdentifierPattern.MatchString(command.Type) || command.ProtectedRequest == nil && unsafeGameClientBridgeCommandType(command.Type) {
|
if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) {
|
||||||
violations = append(violations, prefix+".type is invalid or unsafe")
|
violations = append(violations, prefix+".type is invalid or unsafe")
|
||||||
}
|
}
|
||||||
if _, exists := commandTypes[command.Type]; exists {
|
if _, exists := commandTypes[command.Type]; exists {
|
||||||
@@ -525,7 +525,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
|
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
|
||||||
violations = append(violations, prefix+".maxPayloadBytes is invalid")
|
violations = append(violations, prefix+".maxPayloadBytes is invalid")
|
||||||
}
|
}
|
||||||
violations = append(violations, validateGameClientBridgeProtectedRequest(prefix+".protectedRequest", command.ProtectedRequest, transports)...)
|
|
||||||
}
|
}
|
||||||
snapshotTypes := map[string]struct{}{}
|
snapshotTypes := map[string]struct{}{}
|
||||||
for index, snapshot := range bridge.Snapshots {
|
for index, snapshot := range bridge.Snapshots {
|
||||||
@@ -625,7 +624,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
violations = append(violations, prefix+".key is duplicated")
|
violations = append(violations, prefix+".key is duplicated")
|
||||||
}
|
}
|
||||||
logProjectionKeys[projection.Key] = struct{}{}
|
logProjectionKeys[projection.Key] = struct{}{}
|
||||||
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection, bridge.Commands, runtimeProfiles.ClientManagers)...)
|
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection)...)
|
||||||
}
|
}
|
||||||
dataPackKeys := map[string]struct{}{}
|
dataPackKeys := map[string]struct{}{}
|
||||||
for index, dataPack := range bridge.DataPacks {
|
for index, dataPack := range bridge.DataPacks {
|
||||||
@@ -640,9 +639,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
if dataPack.DatabaseUserVersion < 1 || len(dataPack.LogParserRefs) == 0 || len(dataPack.ConfigMapRefs) == 0 {
|
if dataPack.DatabaseUserVersion < 1 || len(dataPack.LogParserRefs) == 0 || len(dataPack.ConfigMapRefs) == 0 {
|
||||||
violations = append(violations, prefix+" must declare a database version and parser/config assets")
|
violations = append(violations, prefix+" must declare a database version and parser/config assets")
|
||||||
}
|
}
|
||||||
refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...)
|
refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...)
|
||||||
refs = append(refs, dataPack.DataRefs...)
|
refs = append(refs, dataPack.DataRefs...)
|
||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
if !safeRelativeJSONRef(ref) {
|
if !safeRelativeJSONRef(ref) {
|
||||||
violations = append(violations, prefix+" asset reference is invalid")
|
violations = append(violations, prefix+" asset reference is invalid")
|
||||||
}
|
}
|
||||||
@@ -664,8 +663,8 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
if !containsString(permissions, template.Permission) {
|
if !containsString(permissions, template.Permission) {
|
||||||
violations = append(violations, prefix+".permission must be declared by the plugin")
|
violations = append(violations, prefix+".permission must be declared by the plugin")
|
||||||
}
|
}
|
||||||
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelNone && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||||
violations = append(violations, prefix+".approvalLevel must require operator or platform-admin approval")
|
violations = append(violations, prefix+".approvalLevel is invalid")
|
||||||
}
|
}
|
||||||
if template.Kind != domain.GameClientBridgeOperationKindRCON && template.Kind != domain.GameClientBridgeOperationKindSQLiteMutation {
|
if template.Kind != domain.GameClientBridgeOperationKindRCON && template.Kind != domain.GameClientBridgeOperationKindSQLiteMutation {
|
||||||
violations = append(violations, prefix+".kind is invalid")
|
violations = append(violations, prefix+".kind is invalid")
|
||||||
@@ -689,8 +688,8 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
}
|
}
|
||||||
switch template.Kind {
|
switch template.Kind {
|
||||||
case domain.GameClientBridgeOperationKindRCON:
|
case domain.GameClientBridgeOperationKindRCON:
|
||||||
if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedRCON) {
|
if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunRCONCommand) {
|
||||||
violations = append(violations, prefix+" transport must be rcon with remote.run.protected.rcon capability")
|
violations = append(violations, prefix+" transport must be rcon with remote.run.rcon.command capability")
|
||||||
}
|
}
|
||||||
if template.MaxRowsAffected != 0 {
|
if template.MaxRowsAffected != 0 {
|
||||||
violations = append(violations, prefix+".maxRowsAffected is only valid for sqlite-mutation")
|
violations = append(violations, prefix+".maxRowsAffected is only valid for sqlite-mutation")
|
||||||
@@ -822,7 +821,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration, commands []domain.GameClientBridgeCommandDeclaration, clientManagers []domain.RuntimeClientManagerProfile) []string {
|
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration) []string {
|
||||||
var violations []string
|
var violations []string
|
||||||
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
|
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
|
||||||
violations = append(violations, prefix+".streamKeys must contain between 1 and 64 streams")
|
violations = append(violations, prefix+".streamKeys must contain between 1 and 64 streams")
|
||||||
@@ -887,43 +886,6 @@ func validateGameClientBridgeLogProjection(prefix string, projection domain.Game
|
|||||||
if presence.ActivityTarget != nil {
|
if presence.ActivityTarget != nil {
|
||||||
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".presence.activityTarget", *presence.ActivityTarget, captures)...)
|
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".presence.activityTarget", *presence.ActivityTarget, captures)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
announcement := presence.Announcement
|
|
||||||
if !clientManagerIdentifierPattern.MatchString(announcement.ProfileKey) {
|
|
||||||
violations = append(violations, prefix+".presence.announcement.profileKey is invalid")
|
|
||||||
} else {
|
|
||||||
profileFound := false
|
|
||||||
for _, profile := range clientManagers {
|
|
||||||
if profile.Key == announcement.ProfileKey && containsString(profile.Health.RequiredCapabilities, "game-client.bridge") {
|
|
||||||
profileFound = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !profileFound {
|
|
||||||
violations = append(violations, prefix+".presence.announcement.profileKey must reference a declared game-client bridge profile")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var command *domain.GameClientBridgeCommandDeclaration
|
|
||||||
for index := range commands {
|
|
||||||
if commands[index].Type == announcement.CommandType {
|
|
||||||
command = &commands[index]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if command == nil {
|
|
||||||
violations = append(violations, prefix+".presence.announcement.commandType must reference a declared command")
|
|
||||||
}
|
|
||||||
if !gameClientBridgeFieldPattern.MatchString(announcement.TextField) {
|
|
||||||
violations = append(violations, prefix+".presence.announcement.textField is invalid")
|
|
||||||
} else if command != nil && command.ProtectedRequest != nil && command.ProtectedRequest.TextField != announcement.TextField {
|
|
||||||
violations = append(violations, prefix+".presence.announcement.textField must match the command protected request")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(announcement.NewTextTemplate) == "" || len([]rune(announcement.NewTextTemplate)) > 4096 {
|
|
||||||
violations = append(violations, prefix+".presence.announcement.newTextTemplate is empty or too large")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(announcement.ReturningTextTemplate) == "" || len([]rune(announcement.ReturningTextTemplate)) > 4096 {
|
|
||||||
violations = append(violations, prefix+".presence.announcement.returningTextTemplate is empty or too large")
|
|
||||||
}
|
|
||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,50 +994,6 @@ func unsafeGameClientBridgeCommandType(value string) bool {
|
|||||||
return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run")
|
return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run")
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateGameClientBridgeProtectedRequest(prefix string, request *domain.GameClientBridgeProtectedRequestDeclaration, transports map[string]domain.RuntimeTransportProfile) []string {
|
|
||||||
if request == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var violations []string
|
|
||||||
if !oneOf(request.Kind, "sql", "rcon", "program") {
|
|
||||||
violations = append(violations, prefix+".kind is invalid")
|
|
||||||
}
|
|
||||||
for field, value := range map[string]string{"transportKey": request.TransportKey, "targetKey": request.TargetKey, "textField": request.TextField} {
|
|
||||||
if !validDistributionLogicalKey(value) || unsafeGameClientBridgePayloadKey(value) {
|
|
||||||
violations = append(violations, prefix+"."+field+" is invalid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if request.MaxTextBytes < 1 || request.MaxTextBytes > maxGameClientBridgePayloadString {
|
|
||||||
violations = append(violations, prefix+".maxTextBytes is invalid")
|
|
||||||
}
|
|
||||||
transport, exists := transports[request.TransportKey]
|
|
||||||
if !exists {
|
|
||||||
return append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
|
||||||
}
|
|
||||||
if transport.TargetKey != request.TargetKey {
|
|
||||||
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
|
|
||||||
}
|
|
||||||
wantKind, wantCapability := "", ""
|
|
||||||
switch request.Kind {
|
|
||||||
case "sql":
|
|
||||||
wantCapability = domain.JobCapabilityRemoteRunProtectedSQL
|
|
||||||
case "rcon":
|
|
||||||
wantKind, wantCapability = "rcon", domain.JobCapabilityRemoteRunProtectedRCON
|
|
||||||
case "program":
|
|
||||||
wantKind, wantCapability = "program", domain.JobCapabilityRemoteRunProgram
|
|
||||||
}
|
|
||||||
if request.Kind == "sql" && transport.Kind != "mysql" && transport.Kind != "sqlite" {
|
|
||||||
violations = append(violations, prefix+".transportKey must use mysql or sqlite for sql requests")
|
|
||||||
}
|
|
||||||
if wantKind != "" && transport.Kind != wantKind {
|
|
||||||
violations = append(violations, prefix+".transportKey does not match protected request kind")
|
|
||||||
}
|
|
||||||
if wantCapability != "" && !containsString(transport.Capabilities, wantCapability) {
|
|
||||||
violations = append(violations, prefix+".transportKey is missing required protected transport capability")
|
|
||||||
}
|
|
||||||
return violations
|
|
||||||
}
|
|
||||||
|
|
||||||
func emptyGameClientBridgeOperationMutation(value domain.GameClientBridgeOperationMutationDeclaration) bool {
|
func emptyGameClientBridgeOperationMutation(value domain.GameClientBridgeOperationMutationDeclaration) bool {
|
||||||
return value.FieldKey == "" && value.TableKey == "" && value.IdentityKey == "" && value.ValueKey == "" && value.ConfirmationQueryKey == "" && value.AllowedValueType == "" && value.MinValue == 0 && value.MaxValue == 0
|
return value.FieldKey == "" && value.TableKey == "" && value.IdentityKey == "" && value.ValueKey == "" && value.ConfirmationQueryKey == "" && value.AllowedValueType == "" && value.MinValue == 0 && value.MaxValue == 0
|
||||||
}
|
}
|
||||||
@@ -1744,12 +1662,7 @@ func ValidateJob(job domain.Job) error {
|
|||||||
if job.ExecutionInput.SourceRCON != nil {
|
if job.ExecutionInput.SourceRCON != nil {
|
||||||
violations = append(violations, validateRuntimeSourceRCONPlan("executionInput.sourceRcon", job.ExecutionInput.SourceRCON)...)
|
violations = append(violations, validateRuntimeSourceRCONPlan("executionInput.sourceRcon", job.ExecutionInput.SourceRCON)...)
|
||||||
isSourceCommand := job.Capability == domain.JobCapabilityRemoteRunRCONCommand
|
isSourceCommand := job.Capability == domain.JobCapabilityRemoteRunRCONCommand
|
||||||
isProtectedRCON := job.Capability == domain.JobCapabilityRemoteRunProtectedRCON
|
if !isSourceCommand || job.ExecutionInput.RemoteAdapterKind != "rcon" {
|
||||||
wantAdapterKind := "rcon"
|
|
||||||
if isProtectedRCON {
|
|
||||||
wantAdapterKind = "protected-rcon"
|
|
||||||
}
|
|
||||||
if (!isSourceCommand && !isProtectedRCON) || job.ExecutionInput.RemoteAdapterKind != wantAdapterKind {
|
|
||||||
violations = append(violations, "executionInput.sourceRcon is allowed only for rcon jobs")
|
violations = append(violations, "executionInput.sourceRcon is allowed only for rcon jobs")
|
||||||
}
|
}
|
||||||
if job.RetryPolicy.MaxAttempts != 1 {
|
if job.RetryPolicy.MaxAttempts != 1 {
|
||||||
@@ -1766,8 +1679,8 @@ func ValidateJob(job domain.Job) error {
|
|||||||
if len([]byte(job.ExecutionResult.Content)) > maxJobExecutionContentSize {
|
if len([]byte(job.ExecutionResult.Content)) > maxJobExecutionContentSize {
|
||||||
violations = append(violations, "executionResult.content is too large")
|
violations = append(violations, "executionResult.content is too large")
|
||||||
}
|
}
|
||||||
if len(job.ExecutionResult.AuditSummary) > maxAuditSummaryLength {
|
if len(job.ExecutionResult.Summary) > maxSummaryLength {
|
||||||
violations = append(violations, "executionResult.auditSummary is too long")
|
violations = append(violations, "executionResult.summary is too long")
|
||||||
}
|
}
|
||||||
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
||||||
if job.ServerInstanceID == "" {
|
if job.ServerInstanceID == "" {
|
||||||
@@ -1875,26 +1788,6 @@ func ValidateLogStream(stream domain.LogStream) error {
|
|||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateAuditEvent(event domain.AuditEvent) error {
|
|
||||||
var violations []string
|
|
||||||
violations = appendRequired(violations, "id", event.ID)
|
|
||||||
violations = appendRequired(violations, "actorId", event.ActorID)
|
|
||||||
violations = appendRequired(violations, "action", event.Action)
|
|
||||||
violations = appendRequired(violations, "resourceKind", event.ResourceKind)
|
|
||||||
violations = appendRequired(violations, "resourceId", event.ResourceID)
|
|
||||||
violations = appendRequired(violations, "summary", event.Summary)
|
|
||||||
if !validAuditResult(event.Result) {
|
|
||||||
violations = append(violations, "result is invalid")
|
|
||||||
}
|
|
||||||
if len(event.Summary) > maxAuditSummaryLength {
|
|
||||||
violations = append(violations, "summary is too long")
|
|
||||||
}
|
|
||||||
if looksLikeRawSecret(event.Summary) {
|
|
||||||
violations = append(violations, "summary must be redacted")
|
|
||||||
}
|
|
||||||
return finish(violations)
|
|
||||||
}
|
|
||||||
|
|
||||||
func MissingCapabilities(actual []string, required []string) []string {
|
func MissingCapabilities(actual []string, required []string) []string {
|
||||||
actualSet := make(map[string]struct{}, len(actual))
|
actualSet := make(map[string]struct{}, len(actual))
|
||||||
for _, capability := range actual {
|
for _, capability := range actual {
|
||||||
@@ -2446,7 +2339,7 @@ func validPluginRunCapability(capability string) bool {
|
|||||||
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
||||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||||
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
||||||
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram,
|
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProgram,
|
||||||
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
||||||
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
|
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
|
||||||
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
|
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
|
||||||
@@ -2479,8 +2372,7 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
|
|||||||
domain.JobCapabilityRemoteRunFilesWrite,
|
domain.JobCapabilityRemoteRunFilesWrite,
|
||||||
domain.JobCapabilityRemoteRunDBMySQLQuery,
|
domain.JobCapabilityRemoteRunDBMySQLQuery,
|
||||||
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||||
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL,
|
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProgram:
|
||||||
domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram:
|
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -2781,12 +2673,3 @@ func validLogStorageBackend(backend domain.LogStorageBackend) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validAuditResult(result domain.AuditResult) bool {
|
|
||||||
switch result {
|
|
||||||
case domain.AuditResultSuccess, domain.AuditResultDenied, domain.AuditResultFailed, domain.AuditResultQueued:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -130,24 +130,24 @@ func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testi
|
|||||||
func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(t *testing.T) {
|
func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(t *testing.T) {
|
||||||
registration := validGamePluginManifestRegistration()
|
registration := validGamePluginManifestRegistration()
|
||||||
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
|
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
|
||||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL)
|
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL)
|
||||||
registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
|
registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
|
||||||
registration.Manifest.Pages[0].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
registration.Manifest.Pages[0].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
||||||
registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{
|
registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{
|
||||||
{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}},
|
{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}},
|
||||||
{Key: "scum-rcon", Kind: "rcon", TargetKey: "scum-rcon", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedRCON}},
|
{Key: "scum-rcon", Kind: "rcon", TargetKey: "scum-rcon", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}},
|
||||||
{Key: "scum-mutation-db", Kind: "sqlite", TargetKey: "scum-mutation-db", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}},
|
{Key: "scum-mutation-db", Kind: "sqlite", TargetKey: "scum-mutation-db", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}},
|
||||||
}
|
}
|
||||||
registration.Manifest.GameClientBridge = domain.GameClientBridgeManifest{
|
registration.Manifest.GameClientBridge = domain.GameClientBridgeManifest{
|
||||||
Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}},
|
Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, PayloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", ResultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}},
|
||||||
Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", SchemaRef: "schemas/bridge/players.schema.json", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}},
|
Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", SchemaRef: "schemas/bridge/players.schema.json", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}},
|
||||||
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}},
|
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}},
|
||||||
OperationTemplates: []domain.GameClientBridgeOperationTemplateDeclaration{
|
OperationTemplates: []domain.GameClientBridgeOperationTemplateDeclaration{
|
||||||
{Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-rcon", TargetKey: "scum-rcon", PayloadSchemaRef: "schemas/bridge/operations/player-fame-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-fame-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-fame-set.confirmation.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
|
{Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-rcon", TargetKey: "scum-rcon", PayloadSchemaRef: "schemas/bridge/operations/player-fame-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-fame-set.result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048},
|
||||||
{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db", PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "player.lookup", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}},
|
{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db", PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "player.lookup", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}},
|
||||||
},
|
},
|
||||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||||
Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set", "player.attribute.855.set"}}},
|
Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"diagnostic.ping"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set", "player.attribute.855.set"}}},
|
||||||
}
|
}
|
||||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||||
t.Fatalf("expected bridge catalog to validate, got %v", err)
|
t.Fatalf("expected bridge catalog to validate, got %v", err)
|
||||||
@@ -252,8 +252,8 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(
|
|||||||
{name: "unsafe key", expected: "key is invalid or unsafe", mutate: func(value *domain.GamePluginManifestRegistration) {
|
{name: "unsafe key", expected: "key is invalid or unsafe", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
value.Manifest.GameClientBridge.OperationTemplates[0].Key = "raw.sql.execute"
|
value.Manifest.GameClientBridge.OperationTemplates[0].Key = "raw.sql.execute"
|
||||||
}},
|
}},
|
||||||
{name: "missing approval", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) {
|
{name: "mutation missing approval metadata", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
value.Manifest.GameClientBridge.OperationTemplates[0].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone
|
value.Manifest.GameClientBridge.OperationTemplates[1].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone
|
||||||
}},
|
}},
|
||||||
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
|
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||||
value.Manifest.GameClientBridge.OperationTemplates[0].PayloadSchemaRef = "/etc/operation.json"
|
value.Manifest.GameClientBridge.OperationTemplates[0].PayloadSchemaRef = "/etc/operation.json"
|
||||||
@@ -381,7 +381,7 @@ func TestValidateJobBoundsProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateArtifactLogAndAudit(t *testing.T) {
|
func TestValidateArtifactLogAndRuntimeMetadata(t *testing.T) {
|
||||||
artifact := domain.Artifact{
|
artifact := domain.Artifact{
|
||||||
ID: "artifact-1",
|
ID: "artifact-1",
|
||||||
OwnerKind: domain.ArtifactOwnerKindJob,
|
OwnerKind: domain.ArtifactOwnerKindJob,
|
||||||
@@ -406,23 +406,6 @@ func TestValidateArtifactLogAndAudit(t *testing.T) {
|
|||||||
t.Fatalf("expected log stream to validate, got %v", err)
|
t.Fatalf("expected log stream to validate, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
audit := domain.AuditEvent{
|
|
||||||
ID: "audit-1",
|
|
||||||
ActorID: "user-1",
|
|
||||||
Action: "server.create",
|
|
||||||
ResourceKind: "server-instance",
|
|
||||||
ResourceID: "server-1",
|
|
||||||
Result: domain.AuditResultSuccess,
|
|
||||||
Summary: "created server instance",
|
|
||||||
}
|
|
||||||
if err := ValidateAuditEvent(audit); err != nil {
|
|
||||||
t.Fatalf("expected audit event to validate, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
audit.Summary = "bearer raw-secret"
|
|
||||||
if err := ValidateAuditEvent(audit); err == nil || !strings.Contains(err.Error(), "summary must be redacted") {
|
|
||||||
t.Fatalf("expected audit redaction error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validAIProvider() domain.AIProvider {
|
func validAIProvider() domain.AIProvider {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
- API handlers must use named DTOs from `platform/dto`.
|
- API handlers must use named DTOs from `platform/dto`.
|
||||||
- Platform services must not accept raw plugin-provided host paths.
|
- Platform services must not accept raw plugin-provided host paths.
|
||||||
- AI provider secrets must be stored by reference and redacted from logs, audit, and plugin bridge responses.
|
- AI provider secrets must be stored by reference and redacted from logs and plugin bridge responses.
|
||||||
- Game management plugin installation must validate manifest identity, server type, required run capabilities, pages, permissions, and schema references.
|
- Game management plugin installation must validate manifest identity, server type, required run capabilities, pages, permissions, and schema references.
|
||||||
- Server instance creation must validate plugin installation state and run endpoint capability compatibility.
|
- Server instance creation must validate plugin installation state and run endpoint capability compatibility.
|
||||||
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress/audit summaries, artifact metadata, log stream cursors, and run capability compatibility.
|
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress summaries, artifact metadata, log stream cursors, and run capability compatibility.
|
||||||
- `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing.
|
- `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing.
|
||||||
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
|
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
|
||||||
# Client Manager lifecycle validation
|
# Client Manager lifecycle validation
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ func TestValidateGamePluginRuntimeProfilesRejectsUnsafeLogEventSemantics(t *test
|
|||||||
unsafeEventTypes := []string{
|
unsafeEventTypes := []string{
|
||||||
"ops.shell.execute",
|
"ops.shell.execute",
|
||||||
"ops.execute",
|
"ops.execute",
|
||||||
"audit.sql.query",
|
"ops.sql.query",
|
||||||
"audit.raw-host-path",
|
"ops.raw-host-path",
|
||||||
"run.socket.open",
|
"run.socket.open",
|
||||||
"auth.credential.exposed",
|
"auth.credential.exposed",
|
||||||
"auth.api-key.exposed",
|
"auth.api-key.exposed",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function main() {
|
|||||||
assertIncludes(plugin.bridgeActions, "artifacts.open", "plugin exposes artifacts.open bridge action");
|
assertIncludes(plugin.bridgeActions, "artifacts.open", "plugin exposes artifacts.open bridge action");
|
||||||
assertIncludes(plugin.bridgeActions, "plugin-lifecycle.request", "plugin exposes Platform lifecycle bridge action");
|
assertIncludes(plugin.bridgeActions, "plugin-lifecycle.request", "plugin exposes Platform lifecycle bridge action");
|
||||||
assertIncludes(plugin.bridgeActions, "ai.invoke", "plugin exposes Platform AI bridge action");
|
assertIncludes(plugin.bridgeActions, "ai.invoke", "plugin exposes Platform AI bridge action");
|
||||||
assertIncludes(plugin.productionLifecycle?.operations, "rollback", "plugin declares rollback governance");
|
assertIncludes(plugin.productionLifecycle?.operations, "rollback", "plugin declares rollback operation");
|
||||||
assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability");
|
assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability");
|
||||||
assertEqual(aiProvider.apiKeyConfigured, true, "AI provider key presence projection");
|
assertEqual(aiProvider.apiKeyConfigured, true, "AI provider key presence projection");
|
||||||
assertEqual(aiProvider.baseUrlConfigured, true, "AI provider base URL presence projection");
|
assertEqual(aiProvider.baseUrlConfigured, true, "AI provider base URL presence projection");
|
||||||
@@ -170,7 +170,7 @@ async function main() {
|
|||||||
{
|
{
|
||||||
name: "系统维护",
|
name: "系统维护",
|
||||||
hash: "#/maintenance",
|
hash: "#/maintenance",
|
||||||
markers: ["系统维护", "容量治理与告警闭环", "运行槽位", productionSeed.alert.title]
|
markers: ["系统维护", "容量与告警闭环", "运行槽位", productionSeed.alert.title]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "服务器详情",
|
name: "服务器详情",
|
||||||
@@ -598,7 +598,7 @@ async function prepareProductionOperations(headers, server, plugin) {
|
|||||||
},
|
},
|
||||||
headers
|
headers
|
||||||
);
|
);
|
||||||
if (admission.accepted || admission.state !== "denied" || !admission.alertId || !admission.auditEventId) {
|
if (admission.accepted || admission.state !== "denied" || !admission.alertId) {
|
||||||
throw new Error(`capacity admission did not create durable denied evidence: ${JSON.stringify(admission)}`);
|
throw new Error(`capacity admission did not create durable denied evidence: ${JSON.stringify(admission)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,13 +622,13 @@ async function prepareProductionOperations(headers, server, plugin) {
|
|||||||
alert,
|
alert,
|
||||||
diff,
|
diff,
|
||||||
apiProof: {
|
apiProof: {
|
||||||
admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId", "auditEventId"]),
|
admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId"]),
|
||||||
capacity: {
|
capacity: {
|
||||||
...pick(capacity, ["totalMaxJobs", "totalRunningJobs", "totalQueuedJobs", "activeAlerts", "generatedAt"]),
|
...pick(capacity, ["totalMaxJobs", "totalRunningJobs", "totalQueuedJobs", "activeAlerts", "generatedAt"]),
|
||||||
endpoints: capacity.endpoints.map((item) => pick(item, ["runEndpointId", "status", "maxJobs", "runningJobs", "queuedJobs", "logBacklogBatches", "artifactBacklogChunks", "pressureCodes"]))
|
endpoints: capacity.endpoints.map((item) => pick(item, ["runEndpointId", "status", "maxJobs", "runningJobs", "queuedJobs", "logBacklogBatches", "artifactBacklogChunks", "pressureCodes"]))
|
||||||
},
|
},
|
||||||
alert: pick(alert, ["id", "sourceKind", "sourceId", "ruleKey", "severity", "state", "occurrenceCount", "lastAuditEventId"]),
|
alert: pick(alert, ["id", "sourceKind", "sourceId", "ruleKey", "severity", "state", "occurrenceCount"]),
|
||||||
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId", "auditEventId"]),
|
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId"]),
|
||||||
aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"])
|
aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"])
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -676,7 +676,7 @@ async function verifyAlertInteraction(chrome, headers, seededAlert) {
|
|||||||
assertNoForbiddenProjection(acknowledged, "acknowledged alert response");
|
assertNoForbiddenProjection(acknowledged, "acknowledged alert response");
|
||||||
return {
|
return {
|
||||||
cancelPreservedState: stillActive.state,
|
cancelPreservedState: stillActive.state,
|
||||||
persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt", "lastAuditEventId"]),
|
persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt"]),
|
||||||
forbiddenFragmentScan: "passed",
|
forbiddenFragmentScan: "passed",
|
||||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||||
};
|
};
|
||||||
@@ -714,12 +714,12 @@ async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server)
|
|||||||
const response = await getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
const response = await getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
||||||
const installation = findRequired(response.items, (item) => item.pluginId === plugin.id && item.serverInstanceId === server.id, "plugin lifecycle after browser dispatch");
|
const installation = findRequired(response.items, (item) => item.pluginId === plugin.id && item.serverInstanceId === server.id, "plugin lifecycle after browser dispatch");
|
||||||
assertEqual(installation.lastOperation, "enable", "browser lifecycle operation persisted");
|
assertEqual(installation.lastOperation, "enable", "browser lifecycle operation persisted");
|
||||||
if (!installation.jobId || !installation.auditEventId) {
|
if (!installation.jobId) {
|
||||||
throw new Error(`browser lifecycle dispatch missed job/audit linkage: ${JSON.stringify(installation)}`);
|
throw new Error(`browser lifecycle dispatch missed job linkage: ${JSON.stringify(installation)}`);
|
||||||
}
|
}
|
||||||
assertNoForbiddenProjection(installation, "browser plugin lifecycle response");
|
assertNoForbiddenProjection(installation, "browser plugin lifecycle response");
|
||||||
return {
|
return {
|
||||||
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "auditEventId", "alertId"]),
|
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "alertId"]),
|
||||||
forbiddenFragmentScan: "passed",
|
forbiddenFragmentScan: "passed",
|
||||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } });
|
return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } });
|
||||||
}
|
}
|
||||||
if (url.endsWith("/api/v1/server-instances/server-1/process/status") && init?.method === "POST") {
|
if (url.endsWith("/api/v1/server-instances/server-1/process/status") && init?.method === "POST") {
|
||||||
return jsonResponse({ accepted: true, action: "status", instance: server, job: { ...job, capability: "process.status", executionResult: { kind: "process", processState: "running", auditSummary: "private supervised process identity" } } });
|
return jsonResponse({ accepted: true, action: "status", instance: server, job: { ...job, capability: "process.status", executionResult: { kind: "process", processState: "running", summary: "private supervised process identity" } } });
|
||||||
}
|
}
|
||||||
if (url.endsWith("/api/v1/server-instances/server-1/administrators/candidates") && (!init?.method || init.method === "GET")) {
|
if (url.endsWith("/api/v1/server-instances/server-1/administrators/candidates") && (!init?.method || init.method === "GET")) {
|
||||||
return jsonResponse({ items: [{ id: "user-2", displayName: "Helper", status: "active", roles: ["server-admin"] }], count: 1 });
|
return jsonResponse({ items: [{ id: "user-2", displayName: "Helper", status: "active", roles: ["server-admin"] }], count: 1 });
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import type {
|
|||||||
ArtifactFilterRequest,
|
ArtifactFilterRequest,
|
||||||
ArtifactListResponse,
|
ArtifactListResponse,
|
||||||
AuthSessionResponse,
|
AuthSessionResponse,
|
||||||
AuditEventListResponse,
|
|
||||||
ClientManagerBuildRequest,
|
ClientManagerBuildRequest,
|
||||||
ClientManagerControlRequest,
|
ClientManagerControlRequest,
|
||||||
ClientManagerDeployRequest,
|
ClientManagerDeployRequest,
|
||||||
@@ -95,6 +94,8 @@ import type {
|
|||||||
ServerMemberListResponse,
|
ServerMemberListResponse,
|
||||||
ServerMemberRequest,
|
ServerMemberRequest,
|
||||||
ServerMetricsListResponse,
|
ServerMetricsListResponse,
|
||||||
|
SourceRCONCommandRequest,
|
||||||
|
SourceRCONCommandResponse,
|
||||||
MetricSampleListResponse,
|
MetricSampleListResponse,
|
||||||
BackupListResponse,
|
BackupListResponse,
|
||||||
BackupResponse,
|
BackupResponse,
|
||||||
@@ -405,6 +406,10 @@ export class PlatformApiClient {
|
|||||||
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands`, { method: "POST", body: request }));
|
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands`, { method: "POST", body: request }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async dispatchSourceRCONCommand(id: string, request: SourceRCONCommandRequest): Promise<SourceRCONCommandResponse> {
|
||||||
|
return this.request<SourceRCONCommandResponse>(`/server-instances/${encodeURIComponent(id)}/rcon/commands`, { method: "POST", body: request });
|
||||||
|
}
|
||||||
|
|
||||||
async getGameClientBridgeCommand(id: string, commandId: string): Promise<GameClientBridgeCommandResponse> {
|
async getGameClientBridgeCommand(id: string, commandId: string): Promise<GameClientBridgeCommandResponse> {
|
||||||
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}`));
|
return parseSafeGameClientBridgeCommand(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}`));
|
||||||
}
|
}
|
||||||
@@ -627,10 +632,6 @@ export class PlatformApiClient {
|
|||||||
return this.request<LogStreamCursorResponse>("/log-streams/query", { method: "POST", body: request });
|
return this.request<LogStreamCursorResponse>("/log-streams/query", { method: "POST", body: request });
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAuditEvents(): Promise<AuditEventListResponse> {
|
|
||||||
return this.request<AuditEventListResponse>("/audit-events");
|
|
||||||
}
|
|
||||||
|
|
||||||
async suggestServerConfig(request: LlmConfigSuggestionRequest): Promise<LlmConfigSuggestionResponse> {
|
async suggestServerConfig(request: LlmConfigSuggestionRequest): Promise<LlmConfigSuggestionResponse> {
|
||||||
return this.request<LlmConfigSuggestionResponse>("/ai/config-suggestions", { method: "POST", body: request });
|
return this.request<LlmConfigSuggestionResponse>("/ai/config-suggestions", { method: "POST", body: request });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
|||||||
- `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Responses carry redacted recommendations, usage metadata, optional reviewable config suggestions, and safe errors; they must not include provider base URLs, key refs, raw keys, or direct provider transport details.
|
- `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Responses carry redacted recommendations, usage metadata, optional reviewable config suggestions, and safe errors; they must not include provider base URLs, key refs, raw keys, or direct provider transport details.
|
||||||
- `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and durable lifecycle status. Job projections include `retrying`, attempt/max-attempt counts, next retry timing, safe ack/lease deadlines, cancellation timestamps/reason, terminal time, and reconciliation outcome/count.
|
- `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and durable lifecycle status. Job projections include `retrying`, attempt/max-attempt counts, next retry timing, safe ack/lease deadlines, cancellation timestamps/reason, terminal time, and reconciliation outcome/count.
|
||||||
- `getDependencyCatalog` reads `GET /server-instances/{id}/dependencies` and returns only target-matched probe state/evidence, typed plan step summaries, approved download hosts, and immutable SHA-256 `planDigest` values. Install requests must submit the selected digest; the browser never receives bindings, commands, paths, credentials, tokens, or private download refs.
|
- `getDependencyCatalog` reads `GET /server-instances/{id}/dependencies` and returns only target-matched probe state/evidence, typed plan step summaries, approved download hosts, and immutable SHA-256 `planDigest` values. Install requests must submit the selected digest; the browser never receives bindings, commands, paths, credentials, tokens, or private download refs.
|
||||||
- `listRunUpdates` reads `GET /server-instances/{id}/run/update` and returns only target, artifact checksum, release identity, phase, bounded audit message, rollback flag, and timestamps. The UI treats `restart-requested`/`activating` as non-terminal until a later safe projection confirms health.
|
- `listRunUpdates` reads `GET /server-instances/{id}/run/update` and returns only target, artifact checksum, release identity, phase, bounded status message, rollback flag, and timestamps. The UI treats `restart-requested`/`activating` as non-terminal until a later safe projection confirms health.
|
||||||
- `listMetricHistory`, `listBackups`, and `getBackup` read bounded owner-scoped metric and backup projections. Backup responses contain artifact IDs/checksums and recovery/retention state only; they never include body bytes or storage paths.
|
- `listMetricHistory`, `listBackups`, and `getBackup` read bounded owner-scoped metric and backup projections. Backup responses contain artifact IDs/checksums and recovery/retention state only; they never include body bytes or storage paths.
|
||||||
- `listRemoteAdapters` and `requestRemoteAdapter` use declaration-backed logical target keys and return queued status/result references. The browser never receives adapter credentials, host addresses, sockets, Run tokens, leases, session hashes, or secret refs.
|
- `listRemoteAdapters` and `requestRemoteAdapter` use declaration-backed logical target keys and return queued status/result references. The browser never receives adapter credentials, host addresses, sockets, Run tokens, leases, session hashes, or secret refs.
|
||||||
- Server management DTOs may include bounded `ownerUserId` and `adminUserIds` metadata, but must not include raw run credentials, host paths, direct socket details, user password hashes, or AI provider keys.
|
- Server management DTOs may include bounded `ownerUserId` and `adminUserIds` metadata, but must not include raw run credentials, host paths, direct socket details, user password hashes, or AI provider keys.
|
||||||
@@ -43,7 +43,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
|||||||
|
|
||||||
## Redesign Contract Gaps (redesign-platform-web-interactions)
|
## Redesign Contract Gaps (redesign-platform-web-interactions)
|
||||||
|
|
||||||
Existing platform APIs already cover server lifecycle, jobs, log stream metadata and cursor query, audit events, users, run endpoints, game plugins, plugin bridge authorization, and AI provider health/test. The redesigned UI additionally declares the following frontend contracts; where the platform backend does not yet serve them, the UI must degrade to a clearly labeled local/unavailable state instead of failing silently:
|
Existing platform APIs already cover server lifecycle, jobs, log stream metadata and cursor query, users, run endpoints, game plugins, plugin bridge authorization, and AI provider health/test. The redesigned UI additionally declares the following frontend contracts; where the platform backend does not yet serve them, the UI must degrade to a clearly labeled local/unavailable state instead of failing silently:
|
||||||
|
|
||||||
- `POST /api/v1/auth/register` (`RegisterRequest`/`AuthSessionResponse`): visitor registration. Implemented: the first registered user becomes an active platform administrator; later self-registered users become pending server-scoped users and do not receive platform administrator privileges.
|
- `POST /api/v1/auth/register` (`RegisterRequest`/`AuthSessionResponse`): visitor registration. Implemented: the first registered user becomes an active platform administrator; later self-registered users become pending server-scoped users and do not receive platform administrator privileges.
|
||||||
- `POST /api/v1/auth/login` (`LoginRequest`/`AuthSessionResponse`), `POST /api/v1/auth/rotate`, and `POST /api/v1/auth/logout`: implemented bounded, durable bearer session lifecycle for authenticated workspace entry.
|
- `POST /api/v1/auth/login` (`LoginRequest`/`AuthSessionResponse`), `POST /api/v1/auth/rotate`, and `POST /api/v1/auth/logout`: implemented bounded, durable bearer session lifecycle for authenticated workspace entry.
|
||||||
@@ -56,7 +56,7 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
|
|||||||
- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only.
|
- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only.
|
||||||
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets.
|
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets.
|
||||||
- Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals.
|
- Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals.
|
||||||
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent.
|
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent.
|
||||||
|
|
||||||
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
|
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
|
||||||
- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted SSE history/live output. Raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients; internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
|
- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted SSE history/live output. Raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients; internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const status = {
|
|||||||
profileKey: "scum-client",
|
profileKey: "scum-client",
|
||||||
available: false,
|
available: false,
|
||||||
reason: "component heartbeat is unavailable",
|
reason: "component heartbeat is unavailable",
|
||||||
commandTypes: ["scum.announcement.send"],
|
commandTypes: ["scum.diagnostic.ping"],
|
||||||
snapshotTypes: ["scum.players"],
|
snapshotTypes: ["scum.players"],
|
||||||
queryTemplateKeys: ["scum.player.search"]
|
queryTemplateKeys: ["scum.player.search"]
|
||||||
}]
|
}]
|
||||||
@@ -27,12 +27,11 @@ const pendingCommand = {
|
|||||||
serverInstanceId: "server-1",
|
serverInstanceId: "server-1",
|
||||||
pluginId: "game.scum",
|
pluginId: "game.scum",
|
||||||
profileKey: "scum-client",
|
profileKey: "scum-client",
|
||||||
commandType: "scum.announcement.send",
|
commandType: "scum.diagnostic.ping",
|
||||||
priority: 20,
|
priority: 20,
|
||||||
state: "pending",
|
state: "pending",
|
||||||
approvalState: "pending",
|
approvalState: "pending",
|
||||||
requesterId: "user-1",
|
requesterId: "user-1",
|
||||||
auditReferences: ["audit-command-1"],
|
|
||||||
expiresAt: later,
|
expiresAt: later,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
@@ -42,10 +41,10 @@ const completedCommand = {
|
|||||||
...pendingCommand,
|
...pendingCommand,
|
||||||
state: "succeeded",
|
state: "succeeded",
|
||||||
approvalState: "approved",
|
approvalState: "approved",
|
||||||
resultSummary: "announcement delivered",
|
resultSummary: "diagnostic completed",
|
||||||
result: {
|
result: {
|
||||||
status: "succeeded",
|
status: "succeeded",
|
||||||
summary: "announcement delivered",
|
summary: "diagnostic completed",
|
||||||
payload: { delivered: true, recipientCount: 12 },
|
payload: { delivered: true, recipientCount: 12 },
|
||||||
completedAt: later
|
completedAt: later
|
||||||
},
|
},
|
||||||
@@ -57,7 +56,6 @@ const cancellation = {
|
|||||||
commandId: pendingCommand.id,
|
commandId: pendingCommand.id,
|
||||||
state: "cancelled",
|
state: "cancelled",
|
||||||
cancellation: { requestedBy: "user-1", reason: "maintenance window changed", cancelledAt: later },
|
cancellation: { requestedBy: "user-1", reason: "maintenance window changed", cancelledAt: later },
|
||||||
auditReferences: ["audit-command-1", "audit-command-cancel-1"],
|
|
||||||
updatedAt: later
|
updatedAt: later
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -73,19 +71,18 @@ const snapshot = {
|
|||||||
observedAt: now,
|
observedAt: now,
|
||||||
payload: { players: [{ playerId: "player-1", displayName: "Moonlight" }] },
|
payload: { players: [{ playerId: "player-1", displayName: "Moonlight" }] },
|
||||||
retention: { keepForSeconds: 3600, maxRecords: 24 },
|
retention: { keepForSeconds: 3600, maxRecords: 24 },
|
||||||
auditReferences: ["audit-snapshot-1"],
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
expiresAt: later
|
expiresAt: later
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const manifestDeclaration: GameClientBridgeManifestResponse = {
|
const manifestDeclaration: GameClientBridgeManifestResponse = {
|
||||||
commands: [{
|
commands: [{
|
||||||
type: "scum.announcement.send",
|
type: "scum.diagnostic.ping",
|
||||||
title: "Send announcement",
|
title: "Diagnostic ping",
|
||||||
permission: "server.game-client.command",
|
permission: "server.game-client.command",
|
||||||
approvalLevel: "operator",
|
approvalLevel: "operator",
|
||||||
payloadSchemaRef: "schemas/bridge/commands/announcement.request.json",
|
payloadSchemaRef: "schemas/bridge/commands/diagnostic-ping.request.json",
|
||||||
resultSchemaRef: "schemas/bridge/commands/announcement.result.json",
|
resultSchemaRef: "schemas/bridge/commands/diagnostic-ping.result.json",
|
||||||
timeoutSeconds: 30,
|
timeoutSeconds: 30,
|
||||||
maxPayloadBytes: 4096
|
maxPayloadBytes: 4096
|
||||||
}],
|
}],
|
||||||
@@ -104,7 +101,7 @@ const manifestDeclaration: GameClientBridgeManifestResponse = {
|
|||||||
}],
|
}],
|
||||||
commandRetentionSeconds: 86400,
|
commandRetentionSeconds: 86400,
|
||||||
maxCommands: 1000,
|
maxCommands: 1000,
|
||||||
pages: [{ pageKey: "operations", commandTypes: ["scum.announcement.send"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }],
|
pages: [{ pageKey: "operations", commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }],
|
||||||
companion: {
|
companion: {
|
||||||
profileKey: "scum-client-manager",
|
profileKey: "scum-client-manager",
|
||||||
configTemplateKey: "client-config",
|
configTemplateKey: "client-config",
|
||||||
@@ -151,15 +148,15 @@ describe("PlatformApiClient Game Client Bridge operator API", () => {
|
|||||||
const client = new PlatformApiClient("/api/v1", () => "operator-session");
|
const client = new PlatformApiClient("/api/v1", () => "operator-session");
|
||||||
const queueRequest: GameClientBridgeQueueRequest = {
|
const queueRequest: GameClientBridgeQueueRequest = {
|
||||||
profileKey: "scum-client",
|
profileKey: "scum-client",
|
||||||
commandType: "scum.announcement.send",
|
commandType: "scum.diagnostic.ping",
|
||||||
payload: { message: "Restart in ten minutes", channels: ["global"] },
|
payload: { message: "Restart in ten minutes", channels: ["global"] },
|
||||||
idempotencyKey: "announcement-1",
|
idempotencyKey: "diagnostic-1",
|
||||||
priority: 20,
|
priority: 20,
|
||||||
expiresAt: later
|
expiresAt: later
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(client.getGameClientBridgeStatus("server-1")).resolves.toMatchObject({ available: false, profiles: [{ profileKey: "scum-client" }] });
|
await expect(client.getGameClientBridgeStatus("server-1")).resolves.toMatchObject({ available: false, profiles: [{ profileKey: "scum-client" }] });
|
||||||
await expect(client.listGameClientBridgeCommands("server-1", { profileKey: "scum-client", state: "pending", commandType: "scum.announcement.send" })).resolves.toMatchObject({ count: 1 });
|
await expect(client.listGameClientBridgeCommands("server-1", { profileKey: "scum-client", state: "pending", commandType: "scum.diagnostic.ping" })).resolves.toMatchObject({ count: 1 });
|
||||||
await expect(client.queueGameClientBridgeCommand("server-1", queueRequest)).resolves.toMatchObject({ state: "pending", approvalState: "pending" });
|
await expect(client.queueGameClientBridgeCommand("server-1", queueRequest)).resolves.toMatchObject({ state: "pending", approvalState: "pending" });
|
||||||
await expect(client.getGameClientBridgeCommand("server-1", pendingCommand.id)).resolves.toMatchObject({ result: { status: "succeeded", payload: { delivered: true } } });
|
await expect(client.getGameClientBridgeCommand("server-1", pendingCommand.id)).resolves.toMatchObject({ result: { status: "succeeded", payload: { delivered: true } } });
|
||||||
await expect(client.cancelGameClientBridgeCommand("server-1", pendingCommand.id, { reason: "maintenance window changed" })).resolves.toMatchObject({ state: "cancelled" });
|
await expect(client.cancelGameClientBridgeCommand("server-1", pendingCommand.id, { reason: "maintenance window changed" })).resolves.toMatchObject({ state: "cancelled" });
|
||||||
@@ -167,7 +164,7 @@ describe("PlatformApiClient Game Client Bridge operator API", () => {
|
|||||||
|
|
||||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||||
"GET /api/v1/server-instances/server-1/game-client-bridge",
|
"GET /api/v1/server-instances/server-1/game-client-bridge",
|
||||||
"GET /api/v1/server-instances/server-1/game-client-bridge/commands?profileKey=scum-client&state=pending&commandType=scum.announcement.send",
|
"GET /api/v1/server-instances/server-1/game-client-bridge/commands?profileKey=scum-client&state=pending&commandType=scum.diagnostic.ping",
|
||||||
"POST /api/v1/server-instances/server-1/game-client-bridge/commands",
|
"POST /api/v1/server-instances/server-1/game-client-bridge/commands",
|
||||||
"GET /api/v1/server-instances/server-1/game-client-bridge/commands/command-1",
|
"GET /api/v1/server-instances/server-1/game-client-bridge/commands/command-1",
|
||||||
"POST /api/v1/server-instances/server-1/game-client-bridge/commands/command-1/cancel",
|
"POST /api/v1/server-instances/server-1/game-client-bridge/commands/command-1/cancel",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PlatformApiClient } from "./client";
|
|||||||
describe("PlatformApiClient production operations", () => {
|
describe("PlatformApiClient production operations", () => {
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
it("uses Platform-only governance routes and bounded request bodies", async () => {
|
it("uses Platform-only operations routes and bounded request bodies", async () => {
|
||||||
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
|
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
calls.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
calls.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ export interface GameClientBridgeCommandResponse {
|
|||||||
resultSummary?: string;
|
resultSummary?: string;
|
||||||
result?: GameClientBridgeCommandResultResponse;
|
result?: GameClientBridgeCommandResultResponse;
|
||||||
cancellation?: GameClientBridgeCommandCancellationResponse;
|
cancellation?: GameClientBridgeCommandCancellationResponse;
|
||||||
auditReferences?: string[];
|
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -181,7 +180,6 @@ export interface GameClientBridgeCancelResponse {
|
|||||||
commandId: string;
|
commandId: string;
|
||||||
state: GameClientBridgeCommandState;
|
state: GameClientBridgeCommandState;
|
||||||
cancellation: GameClientBridgeCommandCancellationResponse;
|
cancellation: GameClientBridgeCommandCancellationResponse;
|
||||||
auditReferences?: string[];
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +200,6 @@ export interface GameClientBridgeSnapshotResponse {
|
|||||||
observedAt: string;
|
observedAt: string;
|
||||||
payload: GameClientBridgeJsonObject;
|
payload: GameClientBridgeJsonObject;
|
||||||
retention: GameClientBridgeRetentionResponse;
|
retention: GameClientBridgeRetentionResponse;
|
||||||
auditReferences?: string[];
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
@@ -695,7 +692,7 @@ export interface JobExecutionResultResponse {
|
|||||||
version?: number;
|
version?: number;
|
||||||
checksum?: string;
|
checksum?: string;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
auditSummary?: string;
|
summary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobListResponse {
|
export interface JobListResponse {
|
||||||
@@ -1537,22 +1534,6 @@ export interface LogStreamEventOptions {
|
|||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditEventResponse {
|
|
||||||
id: string;
|
|
||||||
actorId: string;
|
|
||||||
action: string;
|
|
||||||
resourceKind: string;
|
|
||||||
resourceId: string;
|
|
||||||
result: string;
|
|
||||||
summary: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditEventListResponse {
|
|
||||||
items: AuditEventResponse[];
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobCreateRequest {
|
export interface JobCreateRequest {
|
||||||
id: string;
|
id: string;
|
||||||
serverInstanceId?: string;
|
serverInstanceId?: string;
|
||||||
@@ -1628,7 +1609,6 @@ export interface CapacityAdmissionDecisionResponse {
|
|||||||
pressureCodes?: string[];
|
pressureCodes?: string[];
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
alertId?: string;
|
alertId?: string;
|
||||||
auditEventId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EndpointCapacityProjectionResponse {
|
export interface EndpointCapacityProjectionResponse {
|
||||||
@@ -1672,7 +1652,6 @@ export interface AlertResponse {
|
|||||||
retryable: boolean;
|
retryable: boolean;
|
||||||
retryAfterSeconds?: number;
|
retryAfterSeconds?: number;
|
||||||
lastJobId?: string;
|
lastJobId?: string;
|
||||||
lastAuditEventId?: string;
|
|
||||||
lastSeenAt: string;
|
lastSeenAt: string;
|
||||||
acknowledgedBy?: string;
|
acknowledgedBy?: string;
|
||||||
acknowledgedAt?: string;
|
acknowledgedAt?: string;
|
||||||
@@ -1700,7 +1679,6 @@ export interface PluginLifecycleInstallationResponse {
|
|||||||
dependencyState?: string;
|
dependencyState?: string;
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
alertId?: string;
|
alertId?: string;
|
||||||
auditEventId?: string;
|
|
||||||
failureReason?: string;
|
failureReason?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmComman
|
|||||||
{(item.retryable || item.requiresRedeploy || item.status === "failed") && (
|
{(item.retryable || item.requiresRedeploy || item.status === "failed") && (
|
||||||
<div className="client-manager-recovery">
|
<div className="client-manager-recovery">
|
||||||
<ShieldAlert size={16} />
|
<ShieldAlert size={16} />
|
||||||
<span>{item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 Platform 审计与 job 失败原因后选择重新部署、回滚或卸载。"}</span>
|
<span>{item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 job 失败原因后选择重新部署、回滚或卸载。"}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmComman
|
|||||||
<LifecycleButton icon={<RefreshCw size={14} />} label="重试" disabled={!item.retryable} reason="当前失败不可重试" onClick={() => void runCommand(item, "重试 Client Manager", () => platformApiClient.retryClientManagerLifecycle(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("retry") }))} />
|
<LifecycleButton icon={<RefreshCw size={14} />} label="重试" disabled={!item.retryable} reason="当前失败不可重试" onClick={() => void runCommand(item, "重试 Client Manager", () => platformApiClient.retryClientManagerLifecycle(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("retry") }))} />
|
||||||
<LifecycleButton icon={<Ban size={14} />} label="撤销会话" disabled={!item.activeArtifactId || item.status === "uninstalled"} reason="组件尚未安装" onClick={() => confirmCommand({ title: "撤销 Client Manager 会话", description: `撤销 ${item.profileKey} 的独立组件 session。Run session 与 job lease 不受影响,组件必须使用当前 key generation 重新注册。`, danger: true, execute: () => runCommand(item, "撤销 Client Manager 会话", () => platformApiClient.revokeClientManagerSession(item.serverInstanceId, { profileKey: item.profileKey, reason: "operator revoked component session" })) })} />
|
<LifecycleButton icon={<Ban size={14} />} label="撤销会话" disabled={!item.activeArtifactId || item.status === "uninstalled"} reason="组件尚未安装" onClick={() => confirmCommand({ title: "撤销 Client Manager 会话", description: `撤销 ${item.profileKey} 的独立组件 session。Run session 与 job lease 不受影响,组件必须使用当前 key generation 重新注册。`, danger: true, execute: () => runCommand(item, "撤销 Client Manager 会话", () => platformApiClient.revokeClientManagerSession(item.serverInstanceId, { profileKey: item.profileKey, reason: "operator revoked component session" })) })} />
|
||||||
<LifecycleButton icon={<KeyRound size={14} />} label="重置密钥" disabled={item.status === "uninstalled"} reason="已卸载" onClick={() => confirmCommand({ title: "重置 Client Manager 密钥", description: `重置 ${item.profileKey} 的 component key 会撤销旧 session/artifact generation。必须重新构建并重新部署,不会显示或导出原始密钥。`, danger: true, execute: async () => { await platformApiClient.resetClientManagerKey(item.serverInstanceId, { componentKind: "client-manager", componentKey: item.profileKey }); await runCommand(item, "刷新密钥重置状态", () => platformApiClient.getClientManagerLifecycle(item.serverInstanceId, item.profileKey)); } })} />
|
<LifecycleButton icon={<KeyRound size={14} />} label="重置密钥" disabled={item.status === "uninstalled"} reason="已卸载" onClick={() => confirmCommand({ title: "重置 Client Manager 密钥", description: `重置 ${item.profileKey} 的 component key 会撤销旧 session/artifact generation。必须重新构建并重新部署,不会显示或导出原始密钥。`, danger: true, execute: async () => { await platformApiClient.resetClientManagerKey(item.serverInstanceId, { componentKind: "client-manager", componentKey: item.profileKey }); await runCommand(item, "刷新密钥重置状态", () => platformApiClient.getClientManagerLifecycle(item.serverInstanceId, item.profileKey)); } })} />
|
||||||
<LifecycleButton icon={<Trash2 size={14} />} label="卸载" danger disabled={!available("uninstall")} reason={reason("uninstall")} onClick={() => confirmCommand({ title: "卸载 Client Manager", description: `确认停止并卸载 ${serverName} 的 ${item.profileKey}?Run 只会清理受控 Client Manager workspace,Platform 保留 build、artifact 与审计历史。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
|
<LifecycleButton icon={<Trash2 size={14} />} label="卸载" danger disabled={!available("uninstall")} reason={reason("uninstall")} onClick={() => confirmCommand({ title: "卸载 Client Manager", description: `确认停止并卸载 ${serverName} 的 ${item.profileKey}?Run 只会清理 Client Manager workspace,Platform 保留 build 与 artifact 记录。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe("shared operation dialogs", () => {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open
|
open
|
||||||
title="确认停用"
|
title="确认停用"
|
||||||
description="会保留审计记录"
|
description="该操作会更新用户状态"
|
||||||
confirmLabel="停用"
|
confirmLabel="停用"
|
||||||
busy
|
busy
|
||||||
onConfirm={() => undefined}
|
onConfirm={() => undefined}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function OperationsTray({ operations }: OperationsTrayProps) {
|
|||||||
<div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite">
|
<div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite">
|
||||||
<div className="operations-tray-heading">
|
<div className="operations-tray-heading">
|
||||||
<strong>当前浏览器会话</strong>
|
<strong>当前浏览器会话</strong>
|
||||||
<span>持久任务与审计记录以 Platform 页面为准</span>
|
<span>持久任务与告警记录以 Platform 页面为准</span>
|
||||||
</div>
|
</div>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="operations-tray-empty">本会话尚未提交资源变更。</p>
|
<p className="operations-tray-empty">本会话尚未提交资源变更。</p>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
|
|||||||
idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}`,
|
idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}`,
|
||||||
confirmed: disruptiveOperations.includes(operation)
|
confirmed: disruptiveOperations.includes(operation)
|
||||||
});
|
});
|
||||||
const evidence = [response.job?.id && `任务 ${response.job.id}`, response.installation.auditEventId && `审计 ${response.installation.auditEventId}`, response.installation.alertId && `告警 ${response.installation.alertId}`].filter(Boolean).join(" · ");
|
const evidence = [response.job?.id && `任务 ${response.job.id}`, response.installation.alertId && `告警 ${response.installation.alertId}`].filter(Boolean).join(" · ");
|
||||||
setResult({ status: response.status === "queued" || response.status === "accepted" ? "succeeded" : response.status === "deferred" ? "pending" : "failed", label: `${lifecycleOperationLabel(operation)}:${response.status}${evidence ? ` · ${evidence}` : ""}` });
|
setResult({ status: response.status === "queued" || response.status === "accepted" ? "succeeded" : response.status === "deferred" ? "pending" : "failed", label: `${lifecycleOperationLabel(operation)}:${response.status}${evidence ? ` · ${evidence}` : ""}` });
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -102,7 +102,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
|
|||||||
<div className="console-record-head"><strong>{installation.currentState} → {installation.desiredState}</strong><span className="status-pill status-active">{installation.compatibility || "pending"}</span></div>
|
<div className="console-record-head"><strong>{installation.currentState} → {installation.desiredState}</strong><span className="status-pill status-active">{installation.compatibility || "pending"}</span></div>
|
||||||
<div className="console-record-meta">
|
<div className="console-record-meta">
|
||||||
<span>当前 {installation.currentVersion || "--"}</span><span>目标 {installation.targetVersion || "--"}</span><span>依赖 {installation.dependencyState || "unknown"}</span>
|
<span>当前 {installation.currentVersion || "--"}</span><span>目标 {installation.targetVersion || "--"}</span><span>依赖 {installation.dependencyState || "unknown"}</span>
|
||||||
{installation.jobId && <span>任务 {installation.jobId}</span>}{installation.auditEventId && <span>审计 {installation.auditEventId}</span>}{installation.alertId && <span>告警 {installation.alertId}</span>}
|
{installation.jobId && <span>任务 {installation.jobId}</span>}{installation.alertId && <span>告警 {installation.alertId}</span>}
|
||||||
</div>
|
</div>
|
||||||
{installation.failureReason && <p className="operation-error">{installation.failureReason}</p>}
|
{installation.failureReason && <p className="operation-error">{installation.failureReason}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel";
|
import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel";
|
||||||
import { ProductionGovernancePanel } from "./ProductionGovernancePanel";
|
import { ProductionOperationsPanel } from "./ProductionOperationsPanel";
|
||||||
import governanceSource from "./ProductionGovernancePanel.tsx?raw";
|
import operationsSource from "./ProductionOperationsPanel.tsx?raw";
|
||||||
import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw";
|
import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw";
|
||||||
import diffSource from "./AIConfigDiffReviewPanel.tsx?raw";
|
import diffSource from "./AIConfigDiffReviewPanel.tsx?raw";
|
||||||
|
|
||||||
describe("production operations components", () => {
|
describe("production operations components", () => {
|
||||||
it("renders persisted loading states without optimistic terminal success", () => {
|
it("renders persisted loading states without optimistic terminal success", () => {
|
||||||
expect(renderToStaticMarkup(<ProductionGovernancePanel />)).toContain("正在同步容量与告警");
|
expect(renderToStaticMarkup(<ProductionOperationsPanel />)).toContain("正在同步容量与告警");
|
||||||
expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异");
|
expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异");
|
||||||
for (const source of [governanceSource, lifecycleSource, diffSource]) {
|
for (const source of [operationsSource, lifecycleSource, diffSource]) {
|
||||||
expect(source).not.toContain("setTimeout");
|
expect(source).not.toContain("setTimeout");
|
||||||
expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
|
expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
|
||||||
expect(source).toContain("disabled=");
|
expect(source).toContain("disabled=");
|
||||||
}
|
}
|
||||||
expect(governanceSource).toContain("if (!intent || busyKey) return");
|
expect(operationsSource).toContain("if (!intent || busyKey) return");
|
||||||
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
|
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
|
||||||
expect(diffSource).toContain("if (!selected || busyId) return");
|
expect(diffSource).toContain("if (!selected || busyId) return");
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-6
@@ -9,12 +9,12 @@ import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
|||||||
|
|
||||||
type AlertAction = "acknowledge" | "resolve" | "retry";
|
type AlertAction = "acknowledge" | "resolve" | "retry";
|
||||||
|
|
||||||
interface ProductionGovernancePanelProps {
|
interface ProductionOperationsPanelProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProductionGovernancePanel({ compact = false, title = "容量与告警" }: ProductionGovernancePanelProps) {
|
export function ProductionOperationsPanel({ compact = false, title = "容量与告警" }: ProductionOperationsPanelProps) {
|
||||||
const [capacity, setCapacity] = useState<ProductionCapacitySummaryResponse | null>(null);
|
const [capacity, setCapacity] = useState<ProductionCapacitySummaryResponse | null>(null);
|
||||||
const [alerts, setAlerts] = useState<AlertResponse[]>([]);
|
const [alerts, setAlerts] = useState<AlertResponse[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -31,7 +31,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
|
|||||||
setCapacity(capacityResponse);
|
setCapacity(capacityResponse);
|
||||||
setAlerts(alertResponse.items);
|
setAlerts(alertResponse.items);
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(caught instanceof Error ? caught.message : "生产治理状态加载失败");
|
setError(caught instanceof Error ? caught.message : "生产状态加载失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
|
|||||||
const visibleEndpoints = compact ? capacity?.endpoints.slice(0, 3) ?? [] : capacity?.endpoints ?? [];
|
const visibleEndpoints = compact ? capacity?.endpoints.slice(0, 3) ?? [] : capacity?.endpoints ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="console-panel console-module production-governance-panel" aria-label="production capacity and alerts">
|
<section className="console-panel console-module production-operations-panel" aria-label="production capacity and alerts">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2><AlertTriangle size={16} /> {title}</h2>
|
<h2><AlertTriangle size={16} /> {title}</h2>
|
||||||
<button type="button" className="icon-command" disabled={loading || Boolean(busyKey)} onClick={() => void refresh()} title="刷新容量与告警">
|
<button type="button" className="icon-command" disabled={loading || Boolean(busyKey)} onClick={() => void refresh()} title="刷新容量与告警">
|
||||||
@@ -79,7 +79,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
|
|||||||
</div>
|
</div>
|
||||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||||
{loading && <LoadingState label="正在同步容量与告警…" compact />}
|
{loading && <LoadingState label="正在同步容量与告警…" compact />}
|
||||||
{!loading && error && <ErrorState title="生产治理状态不可用" reason={error} diagnosticId="production-governance" onRetry={() => void refresh()} compact />}
|
{!loading && error && <ErrorState title="生产状态不可用" reason={error} diagnosticId="production-operations" onRetry={() => void refresh()} compact />}
|
||||||
{!loading && !error && capacity && (
|
{!loading && !error && capacity && (
|
||||||
<>
|
<>
|
||||||
<dl className="console-stat-strip console-stat-strip-spaced">
|
<dl className="console-stat-strip console-stat-strip-spaced">
|
||||||
@@ -111,7 +111,6 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与
|
|||||||
<span>{alert.sourceKind} · {alert.sourceId}</span>
|
<span>{alert.sourceKind} · {alert.sourceId}</span>
|
||||||
<span>发生 {alert.occurrenceCount} 次</span>
|
<span>发生 {alert.occurrenceCount} 次</span>
|
||||||
{alert.lastJobId && <span>任务 {alert.lastJobId}</span>}
|
{alert.lastJobId && <span>任务 {alert.lastJobId}</span>}
|
||||||
{alert.lastAuditEventId && <span>审计 {alert.lastAuditEventId}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
{alert.state !== "resolved" && (
|
{alert.state !== "resolved" && (
|
||||||
<div className="row-actions console-row-actions production-alert-actions">
|
<div className="row-actions console-row-actions production-alert-actions">
|
||||||
@@ -87,7 +87,7 @@ export const runtimeKeyResetStages: RuntimeTaskStage[] = [
|
|||||||
export const runtimeDependencyStages: RuntimeTaskStage[] = [
|
export const runtimeDependencyStages: RuntimeTaskStage[] = [
|
||||||
{ key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" },
|
{ key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" },
|
||||||
{ key: "env_probe", label: "环境检查", description: "让 run 节点评估当前运行环境。" },
|
{ key: "env_probe", label: "环境检查", description: "让 run 节点评估当前运行环境。" },
|
||||||
{ key: "install_prepare", label: "安装环境", description: "准备安全、可审计的依赖安装任务。" },
|
{ key: "install_prepare", label: "安装环境", description: "准备依赖安装任务。" },
|
||||||
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
|
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -111,14 +111,14 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
|
|||||||
<div className="workflow-hint-grid"><div className="workflow-hint-card"><strong>创建基础信息</strong><span>插件决定下一步显示哪些部署方式和游戏参数。</span></div><div className="workflow-hint-card"><strong>配置启动项</strong><span>新建安装、接管已有和自定义启动分别填写自己的字段。</span></div><div className="workflow-hint-card"><strong>平台构建 Run 包</strong><span>平台在自有构建器中打包,Run 启动后自动上报心跳。</span></div></div>
|
<div className="workflow-hint-grid"><div className="workflow-hint-card"><strong>创建基础信息</strong><span>插件决定下一步显示哪些部署方式和游戏参数。</span></div><div className="workflow-hint-card"><strong>配置启动项</strong><span>新建安装、接管已有和自定义启动分别填写自己的字段。</span></div><div className="workflow-hint-card"><strong>平台构建 Run 包</strong><span>平台在自有构建器中打包,Run 启动后自动上报心跳。</span></div></div>
|
||||||
<div className="form-grid"><label>插件类型<select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label>服务器名称<input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label></div>
|
<div className="form-grid"><label>插件类型<select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label>服务器名称<input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label></div>
|
||||||
</div>}
|
</div>}
|
||||||
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy">选择这台服务器的创建方式;下一步只显示该方式需要的启动项。</p>{isScum && <div className="form-guidance"><strong>SCUM 受控链路</strong><span>Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。</span></div>}<div className="deployment-mode-grid">
|
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy">选择这台服务器的创建方式;下一步只显示该方式需要的启动项。</p>{isScum && <div className="form-guidance"><strong>SCUM 自动部署链路</strong><span>Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。</span></div>}<div className="deployment-mode-grid">
|
||||||
<ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} />
|
<ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} />
|
||||||
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
|
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
|
||||||
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由 Run 策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
|
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由 Run 策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
|
||||||
</div></div>}
|
</div></div>}
|
||||||
{step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong>已读取受保护配置</strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}>重试读取</button></>}</div>}<div className="form-grid">
|
{step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong>已读取受保护配置</strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}>重试读取</button></>}</div>}<div className="form-grid">
|
||||||
{kind === "edit" && <label>部署方式<select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install">新建并安装</option><option value="existing-server">接管已有服务器</option><option value="custom-command">自定义启动方式</option></select><small className="field-help">可在此调整部署方式;Run 会按心跳自动识别服务器。</small></label>}
|
{kind === "edit" && <label>部署方式<select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install">新建并安装</option><option value="existing-server">接管已有服务器</option><option value="custom-command">自定义启动方式</option></select><small className="field-help">可在此调整部署方式;Run 会按心跳自动识别服务器。</small></label>}
|
||||||
{form.deploymentMode === "guided-install" && <label>安装目录{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">新建服务器的安装目标;SCUM 受控模板必须明确安装目录。</small></label>}
|
{form.deploymentMode === "guided-install" && <label>安装目录{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">新建服务器的安装目标;SCUM 部署模板必须明确安装目录。</small></label>}
|
||||||
{form.deploymentMode === "existing-server" && <label>已有服务器目录<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run 会先预检目录、插件与端口;不会重装或覆盖现有游戏配置。</small></label>}
|
{form.deploymentMode === "existing-server" && <label>已有服务器目录<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run 会先预检目录、插件与端口;不会重装或覆盖现有游戏配置。</small></label>}
|
||||||
{form.deploymentMode === "custom-command" && <label>服务器目录<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help">服务器文件、数据与配置的主目录。</small></label>}
|
{form.deploymentMode === "custom-command" && <label>服务器目录<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help">服务器文件、数据与配置的主目录。</small></label>}
|
||||||
{form.deploymentMode === "guided-install" && pluginFields.map((field) => (
|
{form.deploymentMode === "guided-install" && pluginFields.map((field) => (
|
||||||
@@ -150,7 +150,7 @@ function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum:
|
|||||||
] : [
|
] : [
|
||||||
{ icon: ScanSearch, title: "预检目录与 Run", copy: "确认安装目录、权限、端口与 Run 环境可用。" },
|
{ icon: ScanSearch, title: "预检目录与 Run", copy: "确认安装目录、权限、端口与 Run 环境可用。" },
|
||||||
{ icon: Download, title: "安装游戏服务端", copy: "按插件声明的推荐方案安装到该目录。" },
|
{ icon: Download, title: "安装游戏服务端", copy: "按插件声明的推荐方案安装到该目录。" },
|
||||||
{ icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给受控部署流程。" },
|
{ icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给自动部署流程。" },
|
||||||
{ icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" }
|
{ icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -162,15 +162,15 @@ function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string
|
|||||||
{ icon: FolderCog, title: "定位服务端根目录", copy: "填写包含 SCUM 服务端文件、数据与配置的目录,不是 Steam 库或 SteamCMD 目录。" },
|
{ icon: FolderCog, title: "定位服务端根目录", copy: "填写包含 SCUM 服务端文件、数据与配置的目录,不是 Steam 库或 SteamCMD 目录。" },
|
||||||
{ icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、可执行文件、版本、Steam App 标记和所需端口。" },
|
{ icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、可执行文件、版本、Steam App 标记和所需端口。" },
|
||||||
{ icon: SlidersHorizontal, title: "只读扫描配置", copy: "识别 ServerSettings.ini 与现有参数;接管不会写入或覆盖它们。" },
|
{ icon: SlidersHorizontal, title: "只读扫描配置", copy: "识别 ServerSettings.ini 与现有参数;接管不会写入或覆盖它们。" },
|
||||||
{ icon: ServerCog, title: "建立受控生命周期", copy: "Run 自动识别这台实例,后续启动、停止和日志仍走受控通道。" },
|
{ icon: ServerCog, title: "建立生命周期", copy: "Run 自动识别这台实例,后续启动、停止和日志仍走平台通道。" },
|
||||||
{ icon: HeartPulse, title: "健康验证", copy: "确认端口、进程与配置可读后,才标记为接管成功。" }
|
{ icon: HeartPulse, title: "健康验证", copy: "确认端口、进程与配置可读后,才标记为接管成功。" }
|
||||||
] : [
|
] : [
|
||||||
{ icon: FolderCog, title: "定位服务端根目录", copy: "填写已有服务端文件、数据与配置所在的主目录。" },
|
{ icon: FolderCog, title: "定位服务端根目录", copy: "填写已有服务端文件、数据与配置所在的主目录。" },
|
||||||
{ icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、插件识别和端口是否可用。" },
|
{ icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、插件识别和端口是否可用。" },
|
||||||
{ icon: SlidersHorizontal, title: "只读扫描配置", copy: "读取插件需要的现有状态,不把新建默认值写进服务器。" },
|
{ icon: SlidersHorizontal, title: "只读扫描配置", copy: "读取插件需要的现有状态,不把新建默认值写进服务器。" },
|
||||||
{ icon: ServerCog, title: "建立受控生命周期", copy: "后续运行操作由自动识别的 Run 通过平台通道执行。" },
|
{ icon: ServerCog, title: "建立生命周期", copy: "后续运行操作由自动识别的 Run 通过平台通道执行。" },
|
||||||
{ icon: HeartPulse, title: "健康验证", copy: "验证通过后才标记为接管成功。" }
|
{ icon: HeartPulse, title: "健康验证", copy: "验证通过后才标记为接管成功。" }
|
||||||
];
|
];
|
||||||
|
|
||||||
return <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>确认后,{pluginName} 会这样接管</strong><span>目录只会交给 Run 在本机使用;平台、浏览器和日志都不会显示原始路径。</span></div><small>先扫描,后自动识别</small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol>{isScum ? <p><strong>SCUM 与 SteamCMD:</strong>接管只需要服务端根目录,不需要填写 SteamCMD 目录。Run 可能按本机策略检查 SteamCMD 是否可用,但它不是接管输入。<br /><strong>升级:</strong>接管不会升级游戏;当前平台尚未提供 SCUM 服务端的受控升级任务,不能承诺自动升级。升级能力需要单独的 SteamCMD 更新任务与备份/健康验证流程。</p> : <p><strong>不会做:</strong>不会重新安装、覆盖已有游戏配置,或把受保护路径回显给浏览器。</p>}</section>;
|
return <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>确认后,{pluginName} 会这样接管</strong><span>目录只会交给 Run 在本机使用;平台、浏览器和日志都不会显示原始路径。</span></div><small>先扫描,后自动识别</small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol>{isScum ? <p><strong>SCUM 与 SteamCMD:</strong>接管只需要服务端根目录,不需要填写 SteamCMD 目录。Run 可能按本机策略检查 SteamCMD 是否可用,但它不是接管输入。<br /><strong>升级:</strong>接管不会升级游戏;当前平台尚未提供 SCUM 服务端的自动升级任务,不能承诺自动升级。升级能力需要单独的 SteamCMD 更新任务与备份/健康验证流程。</p> : <p><strong>不会做:</strong>不会重新安装、覆盖已有游戏配置,或把受保护路径回显给浏览器。</p>}</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
|
import type { JobResponse, LogEntryBody, LogStreamResponse, SourceRCONCommandResponse } from "../api/types";
|
||||||
import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer";
|
import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer";
|
||||||
|
|
||||||
const apiMocks = vi.hoisted(() => ({
|
const apiMocks = vi.hoisted(() => ({
|
||||||
getGameClientBridgeCommand: vi.fn(),
|
dispatchSourceRCONCommand: vi.fn(),
|
||||||
|
getJob: vi.fn(),
|
||||||
listLogStreams: vi.fn(),
|
listLogStreams: vi.fn(),
|
||||||
openServerLogEvents: vi.fn(),
|
openServerLogEvents: vi.fn(),
|
||||||
queryLogStream: vi.fn(),
|
queryLogStream: vi.fn()
|
||||||
queueGameClientBridgeCommand: vi.fn()
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../api/client", () => ({ platformApiClient: apiMocks }));
|
vi.mock("../api/client", () => ({ platformApiClient: apiMocks }));
|
||||||
@@ -85,7 +85,7 @@ describe("ServerManagementTerminalDrawer", () => {
|
|||||||
expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A current replay")).toHaveLength(1);
|
expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A current replay")).toHaveLength(1);
|
||||||
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
|
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
|
||||||
expect(apiMocks.listLogStreams).not.toHaveBeenCalled();
|
expect(apiMocks.listLogStreams).not.toHaveBeenCalled();
|
||||||
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
|
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders an empty current session without accepting unrelated or sessionless logs", async () => {
|
it("renders an empty current session without accepting unrelated or sessionless logs", async () => {
|
||||||
@@ -97,7 +97,7 @@ describe("ServerManagementTerminalDrawer", () => {
|
|||||||
|
|
||||||
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
|
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
|
||||||
expect(container?.textContent).not.toContain("legacy output must stay historical");
|
expect(container?.textContent).not.toContain("legacy output must stay historical");
|
||||||
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
|
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears generation A on a new session and rejects late generation A events", async () => {
|
it("clears generation A on a new session and rejects late generation A events", async () => {
|
||||||
@@ -166,18 +166,18 @@ describe("ServerManagementTerminalDrawer", () => {
|
|||||||
expect(container?.textContent).not.toContain("selected historical output");
|
expect(container?.textContent).not.toContain("selected historical output");
|
||||||
expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1");
|
expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1");
|
||||||
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: oldStream.id, afterSeq: 400, limit: 500 });
|
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: oldStream.id, afterSeq: 400, limit: 500 });
|
||||||
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
|
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses RCON only after an operator submits a command", async () => {
|
it("uses RCON only after an operator submits a command", async () => {
|
||||||
const pending = bridgeCommand("pending");
|
const pending = sourceRCONDispatch("queued");
|
||||||
const succeeded = bridgeCommand("succeeded");
|
const succeeded = jobResponse("succeeded");
|
||||||
apiMocks.queueGameClientBridgeCommand.mockResolvedValue(pending);
|
apiMocks.dispatchSourceRCONCommand.mockResolvedValue(pending);
|
||||||
apiMocks.getGameClientBridgeCommand.mockResolvedValue(succeeded);
|
apiMocks.getJob.mockResolvedValue(succeeded);
|
||||||
await renderDrawer();
|
await renderDrawer();
|
||||||
await emitSession("session-current");
|
await emitSession("session-current");
|
||||||
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "ordinary live output"));
|
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "ordinary live output"));
|
||||||
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
|
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const input = container?.querySelector<HTMLInputElement>('.terminal-command-form input');
|
const input = container?.querySelector<HTMLInputElement>('.terminal-command-form input');
|
||||||
const form = container?.querySelector<HTMLFormElement>('.terminal-command-form');
|
const form = container?.querySelector<HTMLFormElement>('.terminal-command-form');
|
||||||
@@ -186,8 +186,9 @@ describe("ServerManagementTerminalDrawer", () => {
|
|||||||
await act(async () => form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })));
|
await act(async () => form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(apiMocks.queueGameClientBridgeCommand).toHaveBeenCalledTimes(1);
|
expect(apiMocks.dispatchSourceRCONCommand).toHaveBeenCalledTimes(1);
|
||||||
expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", pending.id);
|
expect(apiMocks.dispatchSourceRCONCommand).toHaveBeenCalledWith("server-1", expect.objectContaining({ kind: "command", command: "#ListPlayers" }));
|
||||||
|
expect(apiMocks.getJob).toHaveBeenCalledWith(pending.jobId);
|
||||||
expect(container?.textContent).toContain("ordinary live output");
|
expect(container?.textContent).toContain("ordinary live output");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -246,10 +247,24 @@ function logEntry(seq: number, line: string, timestamp = `2026-08-14T00:00:0${se
|
|||||||
return { seq, timestamp, line, redacted: true };
|
return { seq, timestamp, line, redacted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function bridgeCommand(state: GameClientBridgeCommandResponse["state"]): GameClientBridgeCommandResponse {
|
function sourceRCONDispatch(status: SourceRCONCommandResponse["status"]): SourceRCONCommandResponse {
|
||||||
|
return { jobId: "job-rcon-1", serverInstanceId: "server-1", status, message: "queued" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobResponse(state: JobResponse["state"]): JobResponse {
|
||||||
return {
|
return {
|
||||||
id: "command-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-rcon", commandType: "management.command", priority: 50, state, approvalState: "not_required",
|
id: "job-rcon-1",
|
||||||
result: state === "succeeded" ? { status: "succeeded", summary: "command completed", completedAt: "2026-08-14T00:00:03Z" } : undefined,
|
serverInstanceId: "server-1",
|
||||||
expiresAt: "2026-08-14T00:01:00Z", createdAt: "2026-08-14T00:00:00Z", updatedAt: "2026-08-14T00:00:03Z", completedAt: state === "succeeded" ? "2026-08-14T00:00:03Z" : undefined
|
runEndpointId: "run-1",
|
||||||
|
capability: "remote.run.rcon.command",
|
||||||
|
targetKey: "source-rcon/command",
|
||||||
|
idempotencyKey: "idem-rcon-1",
|
||||||
|
state,
|
||||||
|
progress: { percent: state === "succeeded" ? 100 : 50, message: state === "succeeded" ? "command completed" : "running" },
|
||||||
|
retryPolicy: { maxAttempts: 1, initialBackoffSeconds: 1, maxBackoffSeconds: 1 },
|
||||||
|
attempt: 1,
|
||||||
|
reconcileCount: 0,
|
||||||
|
createdAt: "2026-08-14T00:00:00Z",
|
||||||
|
updatedAt: "2026-08-14T00:00:03Z"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { History, ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide
|
|||||||
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
|
import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types";
|
||||||
import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
|
import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon";
|
||||||
import { cx } from "../utils/classes";
|
import { cx } from "../utils/classes";
|
||||||
import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
|
import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
|
||||||
import { EmptyState, ResultBadge } from "./StateViews";
|
import { EmptyState, ResultBadge } from "./StateViews";
|
||||||
@@ -13,8 +13,8 @@ type HistoryLineState = { status: "idle" } | LoadState<TerminalLine[]>;
|
|||||||
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
|
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
|
||||||
type TerminalQuickCommand = { label: string; command: string; hint: string };
|
type TerminalQuickCommand = { label: string; command: string; hint: string };
|
||||||
|
|
||||||
const terminalBridgeResultPollMs = 1000;
|
const terminalJobResultPollMs = 1000;
|
||||||
const terminalBridgeResultPollAttempts = 30;
|
const terminalJobResultPollAttempts = 30;
|
||||||
const terminalInitialHistoryWindow = 500;
|
const terminalInitialHistoryWindow = 500;
|
||||||
const maxTerminalLines = 10000;
|
const maxTerminalLines = 10000;
|
||||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||||
@@ -285,17 +285,17 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
setResult({ status: "pending", label: "正在提交命令" });
|
setResult({ status: "pending", label: "正在提交命令" });
|
||||||
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
|
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
|
||||||
try {
|
try {
|
||||||
const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted));
|
const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted));
|
||||||
const label = bridgeCommandDispatchLabel(response.state, response.id);
|
const label = rconJobDispatchLabel(response.status, response.jobId);
|
||||||
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
|
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
|
||||||
appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]);
|
appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`)]);
|
||||||
const finalCommand = await waitForBridgeCommandTerminal(response.id);
|
const finalJob = await waitForRCONJobTerminal(response.jobId);
|
||||||
if (finalCommand) {
|
if (finalJob) {
|
||||||
const outcome = terminalLineFromBridgeCommand(finalCommand);
|
const outcome = terminalLineFromJob(finalJob);
|
||||||
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
|
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
|
||||||
appendLines([outcome]);
|
appendLines([outcome]);
|
||||||
} else {
|
} else {
|
||||||
const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`);
|
const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`);
|
||||||
setResult({ status: "pending", label: "等待 Run 返回结果" });
|
setResult({ status: "pending", label: "等待 Run 返回结果" });
|
||||||
appendLines([timeoutLine]);
|
appendLines([timeoutLine]);
|
||||||
}
|
}
|
||||||
@@ -308,11 +308,11 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForBridgeCommandTerminal(commandId: string): Promise<GameClientBridgeCommandResponse | null> {
|
async function waitForRCONJobTerminal(jobId: string): Promise<JobResponse | null> {
|
||||||
for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) {
|
for (let attempt = 0; attempt < terminalJobResultPollAttempts; attempt += 1) {
|
||||||
const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId);
|
const current = await platformApiClient.getJob(jobId);
|
||||||
if (isTerminalBridgeCommandState(current.state)) return current;
|
if (isTerminalJobState(current.state)) return current;
|
||||||
await delay(terminalBridgeResultPollMs);
|
await delay(terminalJobResultPollMs);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -401,8 +401,8 @@ function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[
|
|||||||
return terminalQuickCommandCatalog[pluginId] ?? [];
|
return terminalQuickCommandCatalog[pluginId] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function bridgeCommandDispatchLabel(state: string, commandId: string): string {
|
function rconJobDispatchLabel(state: string, jobId: string): string {
|
||||||
return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
|
return `已${state === "queued" ? "排队" : "提交"} · RCON 任务 ${jobId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
|
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
|
||||||
@@ -433,7 +433,6 @@ function terminalSourceClass(value?: string): string {
|
|||||||
if (key.includes("stdout")) return "stdout";
|
if (key.includes("stdout")) return "stdout";
|
||||||
if (key.includes("command")) return "command";
|
if (key.includes("command")) return "command";
|
||||||
if (key.includes("platform")) return "platform";
|
if (key.includes("platform")) return "platform";
|
||||||
if (key.includes("bridge")) return "bridge";
|
|
||||||
if (key.includes("system")) return "system";
|
if (key.includes("system")) return "system";
|
||||||
return "log";
|
return "log";
|
||||||
}
|
}
|
||||||
@@ -448,27 +447,27 @@ function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessi
|
|||||||
return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId);
|
return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
|
function isTerminalJobState(state: JobResponse["state"]): boolean {
|
||||||
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
|
return state === "succeeded" || state === "failed" || state === "cancelled";
|
||||||
}
|
}
|
||||||
|
|
||||||
function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine {
|
function terminalLineFromJob(job: JobResponse): TerminalLine {
|
||||||
const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state);
|
const summary = job.progress.message || job.executionResult?.summary || job.cancelReason || jobStateLabel(job.state);
|
||||||
const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt;
|
const completed = job.updatedAt;
|
||||||
const sortKey = Date.parse(completed) || Date.now();
|
const sortKey = Date.parse(completed) || Date.now();
|
||||||
const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn";
|
const tone: TerminalLine["tone"] = job.state === "succeeded" ? "success" : job.state === "failed" ? "error" : "warn";
|
||||||
return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" };
|
return { id: `rcon-job-${job.id}-${job.state}`, tone, text: `RCON 任务 ${job.id} · ${jobStateLabel(job.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "PLATFORM" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string {
|
function jobStateLabel(state: JobResponse["state"]): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "succeeded": return "已成功";
|
case "succeeded": return "已成功";
|
||||||
case "failed": return "已失败";
|
case "failed": return "已失败";
|
||||||
case "cancelled": return "已取消";
|
case "cancelled": return "已取消";
|
||||||
case "expired": return "已过期";
|
case "accepted": return "Run 已领取";
|
||||||
case "unknown": return "状态未知";
|
case "running": return "运行中";
|
||||||
case "claimed": return "Run 已领取";
|
case "retrying": return "等待重试";
|
||||||
case "pending": return "已排队";
|
case "queued": return "已排队";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ describe("operations console contracts", () => {
|
|||||||
expect(summarizeEndpointOperations(endpoints)).toEqual({ total: 2, online: 1, degraded: 1, offline: 0, disabled: 0, activeJobs: 3, queuedJobs: 4 });
|
expect(summarizeEndpointOperations(endpoints)).toEqual({ total: 2, online: 1, degraded: 1, offline: 0, disabled: 0, activeJobs: 3, queuedJobs: 4 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("projects an allowlisted operation tray shape and protects unsafe target strings", () => {
|
it("projects a declared operation tray shape and protects unsafe target strings", () => {
|
||||||
const operation = {
|
const operation = {
|
||||||
id: "op-1",
|
id: "op-1",
|
||||||
intent: "更新配置",
|
intent: "更新配置",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ All first-party pages inherit the platform_web game-operations style with black-
|
|||||||
|
|
||||||
## 平台概览(原首页)
|
## 平台概览(原首页)
|
||||||
|
|
||||||
Platform-administrator-only first screen. Shows online/offline server counts, abnormal instance count, run endpoint health, game type distribution, CPU/memory/disk load, LLM provider connectivity, and recent operational signals (faults, failed jobs, provider errors, audit events) that link to the relevant server, plugin, or AI provider context. Each module loads independently with scoped loading/empty/error states.
|
Platform-administrator-only first screen. Shows online/offline server counts, abnormal instance count, run endpoint health, game type distribution, CPU/memory/disk load, LLM provider connectivity, and recent operational signals (faults, failed jobs, provider errors) that link to the relevant server, plugin, or AI provider context. Each module loads independently with scoped loading/empty/error states.
|
||||||
|
|
||||||
## 服务器管理
|
## 服务器管理
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Shows available and installed game management plugins, versions, capabilities, a
|
|||||||
|
|
||||||
## 用户管理
|
## 用户管理
|
||||||
|
|
||||||
Shows users, roles, permissions, status, and audit entry points. Platform administrators only.
|
Shows users, roles, permissions, status, and access-management entry points. Platform administrators only.
|
||||||
|
|
||||||
## AI 提供商管理
|
## AI 提供商管理
|
||||||
|
|
||||||
@@ -39,4 +39,4 @@ Shows configured model providers, base URL, model list, relay mode, status, and
|
|||||||
|
|
||||||
## 系统维护
|
## 系统维护
|
||||||
|
|
||||||
Shows run endpoint health/capacity and audit events. Platform administrators only.
|
Shows run endpoint health/capacity and operational events. Platform administrators only.
|
||||||
|
|||||||
@@ -103,15 +103,13 @@ describe("first-party console pages", () => {
|
|||||||
},
|
},
|
||||||
metrics: { status: "error", reason: "metrics unavailable", diagnosticId: "metrics-1" },
|
metrics: { status: "error", reason: "metrics unavailable", diagnosticId: "metrics-1" },
|
||||||
usage: { status: "error", reason: "usage unavailable", diagnosticId: "usage-1" },
|
usage: { status: "error", reason: "usage unavailable", diagnosticId: "usage-1" },
|
||||||
providers: { status: "error", reason: "providers unavailable", diagnosticId: "providers-1" },
|
providers: { status: "error", reason: "providers unavailable", diagnosticId: "providers-1" }
|
||||||
signals: { status: "error", reason: "audit unavailable", diagnosticId: "audit-1" }
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("4 个模块不可用");
|
expect(html).toContain("3 个模块不可用");
|
||||||
expect(html).toContain("资源指标不可用");
|
expect(html).toContain("资源指标不可用");
|
||||||
expect(html).toContain("审计信号不可用");
|
|
||||||
expect(html).toContain("AI 提供商信号不可用");
|
expect(html).toContain("AI 提供商信号不可用");
|
||||||
expect(html).toContain("server.lifecycle.start");
|
expect(html).toContain("server.lifecycle.start");
|
||||||
expect(html).not.toContain("暂无异常信号");
|
expect(html).not.toContain("暂无异常信号");
|
||||||
@@ -131,10 +129,9 @@ describe("first-party console pages", () => {
|
|||||||
core: { status: "ready", data: { instances: [], endpoints: [], jobs: [] }, refreshedAt: "2026-07-18T10:00:00Z" },
|
core: { status: "ready", data: { instances: [], endpoints: [], jobs: [] }, refreshedAt: "2026-07-18T10:00:00Z" },
|
||||||
metrics: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" },
|
metrics: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" },
|
||||||
usage: { status: "error", reason: "unavailable", diagnosticId: "usage" },
|
usage: { status: "error", reason: "unavailable", diagnosticId: "usage" },
|
||||||
providers: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" },
|
providers: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" }
|
||||||
signals: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" }
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("没有创建服务器的权限");
|
expect(html).toContain("没有创建服务器的权限");
|
||||||
@@ -196,7 +193,7 @@ describe("first-party console pages", () => {
|
|||||||
expect(serverDeploymentWorkflowSource).toContain("不会重装或覆盖现有游戏配置");
|
expect(serverDeploymentWorkflowSource).toContain("不会重装或覆盖现有游戏配置");
|
||||||
expect(serverDeploymentWorkflowSource).toContain("接管已有服务器执行流程");
|
expect(serverDeploymentWorkflowSource).toContain("接管已有服务器执行流程");
|
||||||
expect(serverDeploymentWorkflowSource).toContain("不需要填写 SteamCMD 目录");
|
expect(serverDeploymentWorkflowSource).toContain("不需要填写 SteamCMD 目录");
|
||||||
expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的受控升级任务");
|
expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的自动升级任务");
|
||||||
expect(serverDeploymentWorkflowSource).toContain("Run 会按心跳自动识别服务器");
|
expect(serverDeploymentWorkflowSource).toContain("Run 会按心跳自动识别服务器");
|
||||||
expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })');
|
expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })');
|
||||||
expect(serverDetailPageSource).not.toContain("运行配置绑定");
|
expect(serverDetailPageSource).not.toContain("运行配置绑定");
|
||||||
@@ -229,7 +226,7 @@ describe("first-party console pages", () => {
|
|||||||
expect(serverDetailPageSource).not.toContain("操作历史");
|
expect(serverDetailPageSource).not.toContain("操作历史");
|
||||||
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
||||||
expect(serverManagementTerminalSource).toContain("openServerLogEvents");
|
expect(serverManagementTerminalSource).toContain("openServerLogEvents");
|
||||||
expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand");
|
expect(serverManagementTerminalSource).toContain("dispatchSourceRCONCommand");
|
||||||
expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog");
|
expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog");
|
||||||
expect(serverManagementTerminalSource).not.toContain("password");
|
expect(serverManagementTerminalSource).not.toContain("password");
|
||||||
expect(serverManagementTerminalSource).not.toContain("direct socket");
|
expect(serverManagementTerminalSource).not.toContain("direct socket");
|
||||||
@@ -342,7 +339,7 @@ describe("first-party console pages", () => {
|
|||||||
expect(serverDetailPageSource).toContain("打开终端");
|
expect(serverDetailPageSource).toContain("打开终端");
|
||||||
expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog");
|
expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog");
|
||||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||||
expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand");
|
expect(serverManagementTerminalSource).toContain("dispatchSourceRCONCommand");
|
||||||
expect(serverDetailPageSource).not.toContain("commandHistory");
|
expect(serverDetailPageSource).not.toContain("commandHistory");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -379,7 +376,7 @@ describe("first-party console pages", () => {
|
|||||||
expect(html).toContain("维护排障入口");
|
expect(html).toContain("维护排障入口");
|
||||||
expect(html).toContain("节点详情");
|
expect(html).toContain("节点详情");
|
||||||
expect(html).toContain("最近失败任务");
|
expect(html).toContain("最近失败任务");
|
||||||
expect(html).toContain("审计异常");
|
expect(html).toContain("最近失败任务");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders profile settings as a full page", () => {
|
it("renders profile settings as a full page", () => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type {
|
import type {
|
||||||
AiProviderResponse,
|
AiProviderResponse,
|
||||||
AuditEventResponse,
|
|
||||||
JobResponse,
|
JobResponse,
|
||||||
PlatformResourceUsageResponse,
|
PlatformResourceUsageResponse,
|
||||||
RunEndpointResponse,
|
RunEndpointResponse,
|
||||||
@@ -25,7 +24,7 @@ import type {
|
|||||||
ServerMetricsResponse
|
ServerMetricsResponse
|
||||||
} from "../api/types";
|
} from "../api/types";
|
||||||
import { UsageMeter } from "../components/OperationControls";
|
import { UsageMeter } from "../components/OperationControls";
|
||||||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||||
import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole";
|
import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole";
|
||||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||||
@@ -45,7 +44,6 @@ export interface HomePageInitialState {
|
|||||||
metrics?: OperationsModuleState<ServerMetricsResponse[]>;
|
metrics?: OperationsModuleState<ServerMetricsResponse[]>;
|
||||||
usage?: OperationsModuleState<PlatformResourceUsageResponse>;
|
usage?: OperationsModuleState<PlatformResourceUsageResponse>;
|
||||||
providers?: OperationsModuleState<AiProviderResponse[]>;
|
providers?: OperationsModuleState<AiProviderResponse[]>;
|
||||||
signals?: OperationsModuleState<AuditEventResponse[]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HomePageProps extends PageComponentProps {
|
interface HomePageProps extends PageComponentProps {
|
||||||
@@ -57,7 +55,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
|||||||
const [metrics, setMetrics] = useState<OperationsModuleState<ServerMetricsResponse[]>>(initialState?.metrics ?? { status: "loading" });
|
const [metrics, setMetrics] = useState<OperationsModuleState<ServerMetricsResponse[]>>(initialState?.metrics ?? { status: "loading" });
|
||||||
const [usage, setUsage] = useState<OperationsModuleState<PlatformResourceUsageResponse>>(initialState?.usage ?? { status: "loading" });
|
const [usage, setUsage] = useState<OperationsModuleState<PlatformResourceUsageResponse>>(initialState?.usage ?? { status: "loading" });
|
||||||
const [providers, setProviders] = useState<OperationsModuleState<AiProviderResponse[]>>(initialState?.providers ?? { status: "loading" });
|
const [providers, setProviders] = useState<OperationsModuleState<AiProviderResponse[]>>(initialState?.providers ?? { status: "loading" });
|
||||||
const [signals, setSignals] = useState<OperationsModuleState<AuditEventResponse[]>>(initialState?.signals ?? { status: "loading" });
|
|
||||||
|
|
||||||
const refreshCore = useCallback(async () => {
|
const refreshCore = useCallback(async () => {
|
||||||
setCore({ status: "loading" });
|
setCore({ status: "loading" });
|
||||||
@@ -103,23 +100,12 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshSignals = useCallback(async () => {
|
|
||||||
setSignals({ status: "loading" });
|
|
||||||
try {
|
|
||||||
const response = await platformApiClient.listAuditEvents();
|
|
||||||
setSignals({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
|
|
||||||
} catch (error) {
|
|
||||||
setSignals({ status: "error", reason: errorMessage(error, "审计事件加载失败"), diagnosticId: "overview-audit-events" });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
void refreshCore();
|
void refreshCore();
|
||||||
void refreshMetrics();
|
void refreshMetrics();
|
||||||
void refreshUsage();
|
void refreshUsage();
|
||||||
void refreshProviders();
|
void refreshProviders();
|
||||||
void refreshSignals();
|
}, [refreshCore, refreshMetrics, refreshProviders, refreshUsage]);
|
||||||
}, [refreshCore, refreshMetrics, refreshProviders, refreshSignals, refreshUsage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialState) {
|
if (initialState) {
|
||||||
@@ -139,14 +125,14 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
|||||||
return [...counts.entries()].map(([serverType, count]) => ({ serverType, label: serverType, count }));
|
return [...counts.entries()].map(([serverType, count]) => ({ serverType, label: serverType, count }));
|
||||||
}, [core]);
|
}, [core]);
|
||||||
|
|
||||||
const overviewSignals = useMemo<PlatformOverviewSignal[]>(() => buildOverviewSignals(core, providers, signals), [core, providers, signals]);
|
const overviewSignals = useMemo<PlatformOverviewSignal[]>(() => buildOverviewSignals(core, providers), [core, providers]);
|
||||||
const jobs = core.status === "ready" ? jobBuckets(core.data.jobs) : null;
|
const jobs = core.status === "ready" ? jobBuckets(core.data.jobs) : null;
|
||||||
const endpointSummary = core.status === "ready" ? summarizeEndpointOperations(core.data.endpoints) : null;
|
const endpointSummary = core.status === "ready" ? summarizeEndpointOperations(core.data.endpoints) : null;
|
||||||
const onlineCount = core.status === "ready" ? core.data.instances.filter((item) => serverIsOnline(item.state)).length : 0;
|
const onlineCount = core.status === "ready" ? core.data.instances.filter((item) => serverIsOnline(item.state)).length : 0;
|
||||||
const offlineCount = core.status === "ready" ? core.data.instances.length - onlineCount : 0;
|
const offlineCount = core.status === "ready" ? core.data.instances.length - onlineCount : 0;
|
||||||
const activeProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "active").length : 0;
|
const activeProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "active").length : 0;
|
||||||
const errorProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "error").length : 0;
|
const errorProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "error").length : 0;
|
||||||
const moduleFailureCount = [core, metrics, usage, providers, signals].filter((module) => module.status === "error").length;
|
const moduleFailureCount = [core, metrics, usage, providers].filter((module) => module.status === "error").length;
|
||||||
const canManageServers = session.capabilities.includes("servers.manage");
|
const canManageServers = session.capabilities.includes("servers.manage");
|
||||||
|
|
||||||
const metricAverages = useMemo(() => {
|
const metricAverages = useMemo(() => {
|
||||||
@@ -293,7 +279,7 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ProductionGovernancePanel compact title="生产容量与告警" />
|
<ProductionOperationsPanel compact title="生产容量与告警" />
|
||||||
|
|
||||||
<section className="overview-two-col">
|
<section className="overview-two-col">
|
||||||
<article className="console-panel" aria-label="resource usage">
|
<article className="console-panel" aria-label="resource usage">
|
||||||
@@ -342,15 +328,14 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
|||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2><MoonStar size={16} /> 最近运营信号</h2>
|
<h2><MoonStar size={16} /> 最近运营信号</h2>
|
||||||
<div className="panel-header-actions">
|
<div className="panel-header-actions">
|
||||||
<span className="page-status">{moduleFreshnessLabel(signals.status === "ready" ? signals.refreshedAt : signals.refreshedAt)}</span>
|
<span className="page-status">{moduleFreshnessLabel(core.status === "ready" ? core.refreshedAt : providers.status === "ready" ? providers.refreshedAt : undefined)}</span>
|
||||||
<button type="button" className="icon-command" onClick={() => void refreshSignals()}><Sparkles size={14} /> 刷新审计</button>
|
<button type="button" className="icon-command" onClick={() => { void refreshCore(); void refreshProviders(); }}><Sparkles size={14} /> 刷新信号</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{signals.status === "loading" && core.status === "loading" && <LoadingState label="正在收集信号…" compact />}
|
{core.status === "loading" && providers.status === "loading" && <LoadingState label="正在收集信号…" compact />}
|
||||||
{signals.status === "error" && <ErrorState title="审计信号不可用" reason={signals.reason} diagnosticId={signals.diagnosticId} onRetry={() => void refreshSignals()} compact />}
|
|
||||||
{providers.status === "error" && <ErrorState title="AI 提供商信号不可用" reason={providers.reason} diagnosticId={providers.diagnosticId} onRetry={() => void refreshProviders()} compact />}
|
{providers.status === "error" && <ErrorState title="AI 提供商信号不可用" reason={providers.reason} diagnosticId={providers.diagnosticId} onRetry={() => void refreshProviders()} compact />}
|
||||||
{overviewSignals.length === 0 && signals.status === "ready" && core.status === "ready" && providers.status === "ready" ? (
|
{overviewSignals.length === 0 && core.status === "ready" && providers.status === "ready" ? (
|
||||||
<EmptyState title="暂无异常信号" description="最近没有故障、失败任务或需要关注的审计事件。" />
|
<EmptyState title="暂无异常信号" description="最近没有故障、失败任务或 AI 提供商异常。" />
|
||||||
) : (
|
) : (
|
||||||
<div className="signal-list">
|
<div className="signal-list">
|
||||||
{overviewSignals.map((signal) => (
|
{overviewSignals.map((signal) => (
|
||||||
@@ -374,8 +359,7 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
|
|||||||
|
|
||||||
function buildOverviewSignals(
|
function buildOverviewSignals(
|
||||||
core: OperationsModuleState<OverviewData>,
|
core: OperationsModuleState<OverviewData>,
|
||||||
providers: OperationsModuleState<AiProviderResponse[]>,
|
providers: OperationsModuleState<AiProviderResponse[]>
|
||||||
signals: OperationsModuleState<AuditEventResponse[]>
|
|
||||||
): PlatformOverviewSignal[] {
|
): PlatformOverviewSignal[] {
|
||||||
const collected: PlatformOverviewSignal[] = [];
|
const collected: PlatformOverviewSignal[] = [];
|
||||||
if (core.status === "ready") {
|
if (core.status === "ready") {
|
||||||
@@ -391,11 +375,6 @@ function buildOverviewSignals(
|
|||||||
collected.push({ id: `ai-${provider.id}`, kind: "aiProvider", summary: `AI 提供商 ${provider.name} 连接异常`, detail: "LLM 辅助暂不可用,请在提供商管理中检查已保存配置。", targetPage: "aiProviders", targetId: provider.id, tone: "warning", at: "" });
|
collected.push({ id: `ai-${provider.id}`, kind: "aiProvider", summary: `AI 提供商 ${provider.name} 连接异常`, detail: "LLM 辅助暂不可用,请在提供商管理中检查已保存配置。", targetPage: "aiProviders", targetId: provider.id, tone: "warning", at: "" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (signals.status === "ready") {
|
|
||||||
for (const event of signals.data.slice(0, 5)) {
|
|
||||||
collected.push({ id: `audit-${event.id}`, kind: "log", summary: event.summary || `${event.action} ${event.resourceKind}`, detail: `${event.resourceKind}/${event.resourceId}:${event.result}`, targetPage: event.resourceKind === "server-instance" ? "servers" : "maintenance", targetId: event.resourceId, tone: event.result === "failure" ? "warning" : "info", at: event.createdAt });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return collected.sort((left, right) => Date.parse(right.at || "") - Date.parse(left.at || "")).slice(0, 8);
|
return collected.sort((left, right) => Date.parse(right.at || "") - Date.parse(left.at || "")).slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Activity, ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react";
|
import { ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
|
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
|
||||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel";
|
||||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import { cx } from "../utils/classes";
|
import { cx } from "../utils/classes";
|
||||||
@@ -13,7 +13,6 @@ type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string
|
|||||||
|
|
||||||
export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) {
|
export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) {
|
||||||
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
|
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
|
||||||
const [events, setEvents] = useState<ModuleState<AuditEventResponse[]>>({ status: "loading" });
|
|
||||||
const [jobs, setJobs] = useState<ModuleState<JobResponse[]>>({ status: "loading" });
|
const [jobs, setJobs] = useState<ModuleState<JobResponse[]>>({ status: "loading" });
|
||||||
const [servers, setServers] = useState<ModuleState<ServerInstanceResponse[]>>({ status: "loading" });
|
const [servers, setServers] = useState<ModuleState<ServerInstanceResponse[]>>({ status: "loading" });
|
||||||
const [triageResult, setTriageResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
const [triageResult, setTriageResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||||
@@ -28,16 +27,6 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshEvents = useCallback(async () => {
|
|
||||||
setEvents({ status: "loading" });
|
|
||||||
try {
|
|
||||||
const response = await platformApiClient.listAuditEvents();
|
|
||||||
setEvents({ status: "ready", data: response.items });
|
|
||||||
} catch (error) {
|
|
||||||
setEvents({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshJobs = useCallback(async () => {
|
const refreshJobs = useCallback(async () => {
|
||||||
setJobs({ status: "loading" });
|
setJobs({ status: "loading" });
|
||||||
try {
|
try {
|
||||||
@@ -60,23 +49,20 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
|||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
void refreshEndpoints();
|
void refreshEndpoints();
|
||||||
void refreshEvents();
|
|
||||||
void refreshJobs();
|
void refreshJobs();
|
||||||
void refreshServers();
|
void refreshServers();
|
||||||
}, [refreshEndpoints, refreshEvents, refreshJobs, refreshServers]);
|
}, [refreshEndpoints, refreshJobs, refreshServers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshAll();
|
refreshAll();
|
||||||
}, [refreshAll]);
|
}, [refreshAll]);
|
||||||
|
|
||||||
const endpointItems = endpoints.status === "ready" ? endpoints.data : [];
|
const endpointItems = endpoints.status === "ready" ? endpoints.data : [];
|
||||||
const eventItems = events.status === "ready" ? events.data : [];
|
|
||||||
const jobItems = jobs.status === "ready" ? jobs.data : [];
|
const jobItems = jobs.status === "ready" ? jobs.data : [];
|
||||||
const serverItems = servers.status === "ready" ? servers.data : [];
|
const serverItems = servers.status === "ready" ? servers.data : [];
|
||||||
const failedJobs = useMemo(() => jobItems.filter((job) => job.state === "failed").slice(0, 8), [jobItems]);
|
const failedJobs = useMemo(() => jobItems.filter((job) => job.state === "failed").slice(0, 8), [jobItems]);
|
||||||
const serverById = useMemo(() => new Map(serverItems.map((server) => [server.id, server])), [serverItems]);
|
const serverById = useMemo(() => new Map(serverItems.map((server) => [server.id, server])), [serverItems]);
|
||||||
const endpointById = useMemo(() => new Map(endpointItems.map((endpoint) => [endpoint.id, endpoint])), [endpointItems]);
|
const endpointById = useMemo(() => new Map(endpointItems.map((endpoint) => [endpoint.id, endpoint])), [endpointItems]);
|
||||||
const failedAuditCount = eventItems.filter((event) => event.result !== "success").length;
|
|
||||||
const unhealthyEndpointCount = endpointItems.filter((endpoint) => endpoint.status !== "online" || heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5).length;
|
const unhealthyEndpointCount = endpointItems.filter((endpoint) => endpoint.status !== "online" || heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5).length;
|
||||||
|
|
||||||
async function retryJob(job: JobResponse) {
|
async function retryJob(job: JobResponse) {
|
||||||
@@ -121,7 +107,7 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
|||||||
|
|
||||||
<div className="form-guidance maintenance-triage-intro">
|
<div className="form-guidance maintenance-triage-intro">
|
||||||
<strong>维护排障入口</strong>
|
<strong>维护排障入口</strong>
|
||||||
<span>从节点详情、最近失败任务和审计异常进入重试、查看相关服务器、查看日志链路,不需要直接接触 run 端。</span>
|
<span>从节点详情和最近失败任务进入重试、查看相关服务器、查看日志链路,不需要直接接触 run 端。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="maintenance-triage-grid" aria-label="维护排障入口">
|
<section className="maintenance-triage-grid" aria-label="维护排障入口">
|
||||||
@@ -137,17 +123,11 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
|||||||
<strong>{jobs.status === "ready" ? `${failedJobs.length} 个失败` : "加载中"}</strong>
|
<strong>{jobs.status === "ready" ? `${failedJobs.length} 个失败` : "加载中"}</strong>
|
||||||
<small>从失败任务进入重试、查看相关服务器和查看日志链路。</small>
|
<small>从失败任务进入重试、查看相关服务器和查看日志链路。</small>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="triage-card" onClick={() => void refreshEvents()}>
|
|
||||||
<Activity size={18} />
|
|
||||||
<span>审计异常</span>
|
|
||||||
<strong>{events.status === "ready" ? `${failedAuditCount} 条异常` : "加载中"}</strong>
|
|
||||||
<small>按资源定位失败操作和平台拒绝原因。</small>
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
|
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
|
||||||
|
|
||||||
<ProductionGovernancePanel title="容量治理与告警闭环" />
|
<ProductionOperationsPanel title="容量与告警闭环" />
|
||||||
|
|
||||||
<section className="console-panel" aria-label="run endpoints">
|
<section className="console-panel" aria-label="run endpoints">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
@@ -262,53 +242,6 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="console-panel" aria-label="audit events">
|
|
||||||
<div className="panel-header">
|
|
||||||
<h2>审计事件</h2>
|
|
||||||
</div>
|
|
||||||
{events.status === "loading" && <LoadingState label="正在加载审计事件…" compact />}
|
|
||||||
{events.status === "error" && (
|
|
||||||
<ErrorState title="审计事件加载失败" reason={events.reason} diagnosticId="maintenance-audit" onRetry={() => void refreshEvents()} compact />
|
|
||||||
)}
|
|
||||||
{events.status === "ready" && events.data.length === 0 && (
|
|
||||||
<EmptyState title="暂无审计事件" description="平台还没有记录任何审计事件。" actionLabel="刷新" onAction={() => void refreshEvents()} />
|
|
||||||
)}
|
|
||||||
{events.status === "ready" && events.data.length > 0 && (
|
|
||||||
<div className="console-record-list">
|
|
||||||
{events.data.slice(0, 30).map((event) => (
|
|
||||||
<div key={event.id} className="console-record">
|
|
||||||
<div className="console-record-head">
|
|
||||||
<strong>{event.summary || `${event.action} ${event.resourceKind}`}</strong>
|
|
||||||
<span className={cx("status-pill", auditResultClass(event))}>{event.result}</span>
|
|
||||||
</div>
|
|
||||||
<div className="console-record-meta">
|
|
||||||
<span>
|
|
||||||
事件 <code>{event.id}</code>
|
|
||||||
</span>
|
|
||||||
<span>操作者 {event.actorId}</span>
|
|
||||||
<span>
|
|
||||||
资源 {event.resourceKind}/{event.resourceId}
|
|
||||||
</span>
|
|
||||||
<span>{formatTimestamp(event.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="console-row-actions">
|
|
||||||
<button type="button" className="theme-upload" onClick={() => void refreshEvents()}>
|
|
||||||
查看审计链路
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="theme-upload"
|
|
||||||
disabled={event.resourceKind !== "server-instance"}
|
|
||||||
onClick={() => event.resourceKind === "server-instance" && onNavigate("serverDetail", { serverId: event.resourceId })}
|
|
||||||
>
|
|
||||||
查看相关服务器
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -355,10 +288,6 @@ function heartbeatAgeMinutes(value: string): number {
|
|||||||
return (Date.now() - timestamp) / 60000;
|
return (Date.now() - timestamp) / 60000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function auditResultClass(event: AuditEventResponse): string {
|
|
||||||
return event.result === "success" ? "status-active" : "status-error";
|
|
||||||
}
|
|
||||||
|
|
||||||
function jobStateLabel(state: JobResponse["state"]): string {
|
function jobStateLabel(state: JobResponse["state"]): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "queued":
|
case "queued":
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ const plugin: GamePluginResponse = {
|
|||||||
aiPurposes: [],
|
aiPurposes: [],
|
||||||
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] },
|
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] },
|
||||||
gameClientBridge: {
|
gameClientBridge: {
|
||||||
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }],
|
commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/diagnostic-ping.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }],
|
||||||
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
|
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
|
||||||
commandRetentionSeconds: 86400,
|
commandRetentionSeconds: 86400,
|
||||||
maxCommands: 1000,
|
maxCommands: 1000,
|
||||||
pages: [{ pageKey: "players", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }]
|
pages: [{ pageKey: "players", commandTypes: ["diagnostic.ping"], snapshotTypes: ["companion.health"] }]
|
||||||
},
|
},
|
||||||
status: "installed"
|
status: "installed"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
|
|
||||||
it("uses the historical terminal drawer without restoring separate raw command panels", () => {
|
it("uses the historical terminal drawer without restoring separate raw command panels", () => {
|
||||||
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
|
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
|
||||||
expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand");
|
expect(serverManagementTerminalSource).toContain("dispatchSourceRCONCommand");
|
||||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||||
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
||||||
expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer");
|
expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer");
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSecti
|
|||||||
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> 部署定义</h2><span className="page-status">{view.mode || "未配置"} · 修订 {view.revision}</span></div>
|
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> 部署定义</h2><span className="page-status">{view.mode || "未配置"} · 修订 {view.revision}</span></div>
|
||||||
<p className="section-copy">服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。</p>
|
<p className="section-copy">服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。</p>
|
||||||
<div className="console-row-list"><div className="console-row"><span>服务器目录</span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span>高级执行目录</span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span>启动设置</span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span>最近 Run 调度</span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run 执行确认</span><strong>已按 r{view.latestDispatch.deploymentRevision} 确认执行</strong></div>}</div>
|
<div className="console-row-list"><div className="console-row"><span>服务器目录</span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span>高级执行目录</span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span>启动设置</span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span>最近 Run 调度</span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run 执行确认</span><strong>已按 r{view.latestDispatch.deploymentRevision} 确认执行</strong></div>}</div>
|
||||||
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM 受控模板</span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span>预检 / 扫描</span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span>配置映射 / 健康验证</span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span>失败原因</span><strong>{projection.failureCode}</strong></div>}</div>}
|
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM 部署模板</span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span>预检 / 扫描</span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span>配置映射 / 健康验证</span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span>失败原因</span><strong>{projection.failureCode}</strong></div>}</div>}
|
||||||
</article>;
|
</article>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user