Remove pre-1.0 audit and protected request scaffolding
This commit is contained in:
+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 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.
|
||||
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ Backend control plane for the game server management platform.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Users, roles, permissions, sessions, and audit.
|
||||
- Users, roles, permissions, and sessions.
|
||||
- Game management plugin installation metadata and marketplace views.
|
||||
- Server instance records and lifecycle orchestration.
|
||||
- AI provider configuration and platform-mediated AI invocation.
|
||||
@@ -83,7 +83,7 @@ export PLATFORM_LOG_DIR=.platform-data/logs
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
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/") ||
|
||||
path == "/api/v1/ai-providers" || strings.HasPrefix(path, "/api/v1/ai-providers/") ||
|
||||
path == "/api/v1/metrics/platform" ||
|
||||
path == "/api/v1/run/endpoints" || strings.HasPrefix(path, "/api/v1/run/endpoints/") ||
|
||||
path == "/api/v1/audit-events" || strings.HasPrefix(path, "/api/v1/audit-events/") {
|
||||
path == "/api/v1/run/endpoints" || strings.HasPrefix(path, "/api/v1/run/endpoints/") {
|
||||
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/")) {
|
||||
|
||||
@@ -142,13 +142,12 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
|
||||
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
|
||||
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/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/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-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
|
||||
"/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/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-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"},
|
||||
}
|
||||
nonce := 10
|
||||
for path, request := range privateUpdateBodies {
|
||||
|
||||
@@ -206,7 +206,7 @@ func (h *coreHandlers) serverClientManagerRevokeSession(w http.ResponseWriter, r
|
||||
|
||||
// serverClientManagerUninstall godoc
|
||||
// @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
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestRunLifecycleReportAPIProjectsServerState(t *testing.T) {
|
||||
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)
|
||||
|
||||
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)
|
||||
response := decodeBody[dto.RunLifecycleReportResponse](t, recorder)
|
||||
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.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.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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
assertStatus(t, queuedRecorder, http.StatusAccepted)
|
||||
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/metrics/platform", h.platformMetrics)
|
||||
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/{pluginId}/actions", h.pluginLifecycleAction)
|
||||
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", h.serverDependencies)
|
||||
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", h.serverAdministrators)
|
||||
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/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/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-chunk", h.requireRunSignature(h.runJobUpdateChunk))
|
||||
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/query", h.logStreamQuery)
|
||||
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/heartbeat", h.clientManagerHeartbeat)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// @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
|
||||
// @Produce json
|
||||
// @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))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -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) {
|
||||
@@ -1538,31 +1513,19 @@ func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionOperationsGovernanceRoutesAreDurableAndRedacted(t *testing.T) {
|
||||
func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
serverID := createRuntimeAPIFixtures(t, router, adminSession)
|
||||
createAIProviderFixture(t, router, adminSession)
|
||||
|
||||
capacity := getJSONWithAuth[dto.ProductionCapacitySummaryResponse](t, router, "/api/v1/production/capacity", adminSession)
|
||||
if len(capacity.Endpoints) == 0 {
|
||||
t.Fatalf("expected persisted capacity endpoints, got %+v", capacity)
|
||||
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 lifecycle.Status != "queued" || lifecycle.Job.ID == "" || lifecycle.Installation.ID == "" {
|
||||
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)
|
||||
if decision.Accepted || decision.AlertID == "" || decision.AuditEventID == "" {
|
||||
t.Fatalf("expected unavailable capability admission to create durable evidence, got %+v", decision)
|
||||
}
|
||||
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)
|
||||
lifecycles := getJSONWithAuth[dto.PluginLifecycleListResponse](t, router, "/api/v1/plugin-lifecycles?serverInstanceId="+serverID, adminSession)
|
||||
if lifecycles.Count != 1 || lifecycles.Items[0].JobID != lifecycle.Job.ID {
|
||||
t.Fatalf("expected persisted plugin lifecycle, got %+v", lifecycles)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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"} {
|
||||
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` |
|
||||
| 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` |
|
||||
| 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.
|
||||
|
||||
@@ -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/artifacts?ownerKind=job&ownerId=job-1&state=uploading`
|
||||
- `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
|
||||
|
||||
@@ -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/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
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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
|
||||
|
||||
@@ -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/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`.
|
||||
- `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/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`.
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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/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/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.
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"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) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
|
||||
@@ -40,17 +40,6 @@ type GameClientBridgeCommandDeclaration struct {
|
||||
ResultSchemaRef string
|
||||
TimeoutSeconds 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 {
|
||||
@@ -100,19 +89,10 @@ type GameClientBridgeLogProjectionTargetDeclaration struct {
|
||||
ObservedAtField string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionAnnouncementDeclaration struct {
|
||||
ProfileKey string
|
||||
CommandType string
|
||||
TextField string
|
||||
NewTextTemplate string
|
||||
ReturningTextTemplate string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionPresenceDeclaration struct {
|
||||
TimestampField string
|
||||
ActiveWindowSeconds int
|
||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration
|
||||
Announcement GameClientBridgeLogProjectionAnnouncementDeclaration
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionDeclaration struct {
|
||||
@@ -130,7 +110,7 @@ type GameClientBridgeDataPackDeclaration struct {
|
||||
DatabaseUserVersion int
|
||||
LogParserRefs []string
|
||||
ConfigMapRefs []string
|
||||
DataRefs []string
|
||||
DataRefs []string
|
||||
}
|
||||
|
||||
type GameClientBridgeOperationKind string
|
||||
@@ -249,7 +229,6 @@ type GameClientBridgeCommand struct {
|
||||
Claim GameClientBridgeClaim
|
||||
Cancellation GameClientBridgeCancellation
|
||||
Result GameClientBridgeResult
|
||||
AuditReferences []string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
@@ -293,7 +272,6 @@ type GameClientBridgeSnapshot struct {
|
||||
ObservedAt time.Time
|
||||
Payload map[string]any
|
||||
Retention GameClientBridgeRetention
|
||||
AuditReferences []string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
@@ -314,14 +292,6 @@ type GameClientBridgeRetention struct {
|
||||
MaxRecords int
|
||||
}
|
||||
|
||||
type GameClientBridgeAuditReference struct {
|
||||
ID string
|
||||
CommandID string
|
||||
SnapshotID string
|
||||
AuditEventID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type GameClientBridgeCommandFilter struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
@@ -460,7 +430,6 @@ func CopyGameClientBridgeCommand(value GameClientBridgeCommand) GameClientBridge
|
||||
value.Claim = CopyGameClientBridgeClaim(value.Claim)
|
||||
value.Cancellation = CopyGameClientBridgeCancellation(value.Cancellation)
|
||||
value.Result = CopyGameClientBridgeResult(value.Result)
|
||||
value.AuditReferences = CopyStringSlice(value.AuditReferences)
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -478,7 +447,6 @@ func CopyGameClientBridgeResult(value GameClientBridgeResult) GameClientBridgeRe
|
||||
func CopyGameClientBridgeSnapshot(value GameClientBridgeSnapshot) GameClientBridgeSnapshot {
|
||||
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
||||
value.Retention = CopyGameClientBridgeRetention(value.Retention)
|
||||
value.AuditReferences = CopyStringSlice(value.AuditReferences)
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -490,10 +458,6 @@ func CopyGameClientBridgeRetention(value GameClientBridgeRetention) GameClientBr
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeAuditReference(value GameClientBridgeAuditReference) GameClientBridgeAuditReference {
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgePayload(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
@@ -507,12 +471,6 @@ func CopyGameClientBridgePayload(value map[string]any) map[string]any {
|
||||
|
||||
func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest {
|
||||
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.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
||||
for index := range value.QueryTemplates {
|
||||
@@ -529,7 +487,7 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
|
||||
for index := range value.DataPacks {
|
||||
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
|
||||
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.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"},
|
||||
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"}},
|
||||
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].Presence.ActivityTarget.CaptureMappings["steamId"] = "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.Pages[0].QueryTemplateKeys[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)
|
||||
}
|
||||
|
||||
|
||||
@@ -264,32 +264,6 @@ type SourceRCONExecutionInput struct {
|
||||
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 {
|
||||
RunEndpointID string
|
||||
SessionToken string
|
||||
@@ -510,10 +484,6 @@ func CopySourceRCONExecutionInput(input SourceRCONExecutionInput) SourceRCONExec
|
||||
return input
|
||||
}
|
||||
|
||||
func CopyProtectedRequestExecutionInput(input ProtectedRequestExecutionInput) ProtectedRequestExecutionInput {
|
||||
return input
|
||||
}
|
||||
|
||||
func CopyRunUpdateChunk(chunk RunUpdateChunk) RunUpdateChunk {
|
||||
chunk.Payload = append([]byte(nil), chunk.Payload...)
|
||||
return chunk
|
||||
|
||||
@@ -108,7 +108,6 @@ type RemoteAdapterResult struct {
|
||||
Retryable bool
|
||||
Message string
|
||||
ResultRef string
|
||||
AuditEventID string
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
|
||||
@@ -2,146 +2,6 @@ package domain
|
||||
|
||||
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
|
||||
|
||||
const (
|
||||
@@ -180,8 +40,6 @@ type PluginLifecycleInstallation struct {
|
||||
Compatibility string
|
||||
DependencyState DependencyState
|
||||
JobID string
|
||||
AlertID string
|
||||
AuditEventID string
|
||||
FailureReason string
|
||||
IdempotencyKey string
|
||||
CreatedAt time.Time
|
||||
@@ -206,8 +64,6 @@ type PluginLifecycleRequest struct {
|
||||
type PluginLifecycleResult struct {
|
||||
Installation PluginLifecycleInstallation
|
||||
Job Job
|
||||
Decision CapacityAdmissionDecision
|
||||
Alert *AlertRecord
|
||||
Status string
|
||||
}
|
||||
|
||||
@@ -261,53 +117,6 @@ type AIConfigDiffApprovalResult struct {
|
||||
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 {
|
||||
return installation
|
||||
}
|
||||
@@ -324,11 +133,6 @@ func CopyPluginLifecycleInstallations(installations []PluginLifecycleInstallatio
|
||||
func CopyPluginLifecycleResult(result PluginLifecycleResult) PluginLifecycleResult {
|
||||
result.Installation = CopyPluginLifecycleInstallation(result.Installation)
|
||||
result.Job = CopyJob(result.Job)
|
||||
result.Decision = CopyCapacityAdmissionDecision(result.Decision)
|
||||
if result.Alert != nil {
|
||||
alert := CopyAlertRecord(*result.Alert)
|
||||
result.Alert = &alert
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -187,15 +187,6 @@ const (
|
||||
LogStorageBackendElasticsearch LogStorageBackend = "elasticsearch"
|
||||
)
|
||||
|
||||
type AuditResult string
|
||||
|
||||
const (
|
||||
AuditResultSuccess AuditResult = "success"
|
||||
AuditResultDenied AuditResult = "denied"
|
||||
AuditResultFailed AuditResult = "failed"
|
||||
AuditResultQueued AuditResult = "queued"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
@@ -1078,7 +1069,6 @@ const (
|
||||
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||
JobCapabilityRemoteRunProtectedSQL = "remote.run.protected.sql"
|
||||
JobCapabilityRemoteRunProtectedRCON = "remote.run.protected.rcon"
|
||||
JobCapabilityRemoteRunProgram = "remote.run.program.command"
|
||||
JobCapabilityRunSelfUpdate = "run.self-update"
|
||||
JobCapabilityDistributionBuild = "distribution.build"
|
||||
@@ -1176,7 +1166,7 @@ type JobExecutionResult struct {
|
||||
Version int
|
||||
Checksum string
|
||||
SizeBytes int64
|
||||
AuditSummary string
|
||||
Summary string
|
||||
Content string
|
||||
ServerDeploymentEvidence *ServerDeploymentEvidence
|
||||
DeploymentReceipt *ServerDeploymentExecutionReceipt
|
||||
@@ -1515,17 +1505,6 @@ type LogStream struct {
|
||||
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 {
|
||||
Status UserStatus
|
||||
}
|
||||
@@ -1626,13 +1605,6 @@ type LogStreamFilter struct {
|
||||
StreamKey string
|
||||
}
|
||||
|
||||
type AuditEventFilter struct {
|
||||
ActorID string
|
||||
ResourceKind string
|
||||
ResourceID string
|
||||
Result AuditResult
|
||||
}
|
||||
|
||||
func CopyStringSlice(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
@@ -2145,7 +2117,3 @@ func CopyLogBackfillRequest(request LogBackfillRequest) LogBackfillRequest {
|
||||
func CopyLogStream(stream LogStream) LogStream {
|
||||
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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -193,16 +193,6 @@ Failed or cancelled lifecycle jobs project the server instance to `failed`. Acti
|
||||
- `storageBackend`: `local-segments`, `loki`, `clickhouse`, `opensearch`, or `elasticsearch`.
|
||||
- `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
|
||||
|
||||
`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,
|
||||
ObservationSeq: request.ObservationSeq,
|
||||
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"`
|
||||
Result *GameClientBridgeCommandResultResponse `json:"result,omitempty"`
|
||||
Cancellation *GameClientBridgeCommandCancellationResponse `json:"cancellation,omitempty"`
|
||||
AuditReferences []string `json:"auditReferences,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
@@ -111,7 +110,6 @@ type GameClientBridgeSnapshotResponse struct {
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Retention GameClientBridgeRetentionResponse `json:"retention"`
|
||||
AuditReferences []string `json:"auditReferences,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
@@ -163,11 +161,10 @@ type GameClientBridgeResultResponse struct {
|
||||
}
|
||||
|
||||
type GameClientBridgeCancelResponse struct {
|
||||
CommandID string `json:"commandId"`
|
||||
State string `json:"state"`
|
||||
Cancellation GameClientBridgeCommandCancellationResponse `json:"cancellation"`
|
||||
AuditReferences []string `json:"auditReferences,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CommandID string `json:"commandId"`
|
||||
State string `json:"state"`
|
||||
Cancellation GameClientBridgeCommandCancellationResponse `json:"cancellation"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotIngestResponse struct {
|
||||
@@ -181,19 +178,6 @@ type GameClientBridgeSnapshotIngestResponse struct {
|
||||
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 {
|
||||
PluginID string `json:"pluginId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
@@ -263,7 +247,6 @@ func GameClientBridgeCommandFromDomain(value domain.GameClientBridgeCommand) Gam
|
||||
ApprovalState: string(value.ApprovalState),
|
||||
RequesterID: value.RequesterID,
|
||||
ResultSummary: value.Result.Summary,
|
||||
AuditReferences: copyStrings(value.AuditReferences),
|
||||
ExpiresAt: value.ExpiresAt,
|
||||
CreatedAt: value.CreatedAt,
|
||||
UpdatedAt: value.UpdatedAt,
|
||||
@@ -294,7 +277,6 @@ func GameClientBridgeSnapshotFromDomain(value domain.GameClientBridgeSnapshot) G
|
||||
ObservedAt: value.ObservedAt,
|
||||
Payload: value.Payload,
|
||||
Retention: gameClientBridgeRetentionFromDomain(value.Retention),
|
||||
AuditReferences: copyStrings(value.AuditReferences),
|
||||
CreatedAt: value.CreatedAt,
|
||||
ExpiresAt: value.ExpiresAt,
|
||||
}
|
||||
@@ -344,25 +326,13 @@ func GameClientBridgeResultFromDomain(value domain.GameClientBridgeCommand) Game
|
||||
|
||||
func GameClientBridgeCancelFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeCancelResponse {
|
||||
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 {
|
||||
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 {
|
||||
value = domain.CopyGameClientBridgeStatus(value)
|
||||
profiles := make([]GameClientBridgeProfileDeclarationResponse, len(value.Profiles))
|
||||
|
||||
@@ -33,29 +33,27 @@ func TestGameClientBridgeRequestConversionsInjectScopeAndCopyPayloads(t *testing
|
||||
func TestGameClientBridgeBrowserProjectionsAreCompleteAndOmitInternalData(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
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",
|
||||
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)},
|
||||
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),
|
||||
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)},
|
||||
Cancellation: domain.GameClientBridgeCancellation{RequestedBy: "operator-2", Reason: "operator request", CancelledAt: now.Add(2 * 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)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
commandProjection.Result.Payload["code"] = "changed"
|
||||
commandProjection.AuditReferences[0] = "changed"
|
||||
snapshotProjection.Payload["players"].([]any)[0].(map[string]any)["name"] = "changed"
|
||||
snapshotProjection.AuditReferences[0] = "changed"
|
||||
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" {
|
||||
if command.Result.Payload["code"] != "cancelled" || snapshot.Payload["players"].([]any)[0].(map[string]any)["name"] != "Alice" {
|
||||
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,
|
||||
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)},
|
||||
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)}
|
||||
|
||||
@@ -91,8 +89,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) {
|
||||
ingest := GameClientBridgeSnapshotIngestFromDomain(snapshot)
|
||||
commands := GameClientBridgeCommandsFromDomain([]domain.GameClientBridgeCommand{command})
|
||||
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 || audits.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 {
|
||||
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"
|
||||
@@ -100,7 +97,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) {
|
||||
if command.Payload["scope"] != "health" || command.Result.Payload["healthy"] != true {
|
||||
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)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ type RunJobExecutionResultBody struct {
|
||||
Version int `json:"version,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
AuditSummary string `json:"auditSummary,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
|
||||
DeploymentReceipt *ServerDeploymentExecutionReceiptBody `json:"deploymentReceipt,omitempty"`
|
||||
@@ -267,30 +267,6 @@ type SourceRCONExecutionInputResponse struct {
|
||||
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 {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
@@ -448,7 +424,7 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
|
||||
Message: request.Message,
|
||||
ErrorCode: request.ErrorCode,
|
||||
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}
|
||||
}
|
||||
|
||||
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 {
|
||||
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}
|
||||
}
|
||||
|
||||
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 {
|
||||
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"`
|
||||
Message string `json:"message"`
|
||||
ResultRef string `json:"resultRef,omitempty"`
|
||||
AuditEventID string `json:"auditEventId,omitempty"`
|
||||
CompletedAt time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
@@ -171,7 +170,7 @@ func (request RemoteAdapterRequestBody) ToDomain(serverInstanceID string) domain
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -6,106 +6,6 @@ import (
|
||||
"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 {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Operation string `json:"operation"`
|
||||
@@ -127,8 +27,6 @@ type PluginLifecycleInstallationResponse struct {
|
||||
Compatibility string `json:"compatibility,omitempty"`
|
||||
DependencyState string `json:"dependencyState,omitempty"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
AlertID string `json:"alertId,omitempty"`
|
||||
AuditEventID string `json:"auditEventId,omitempty"`
|
||||
FailureReason string `json:"failureReason,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
@@ -143,8 +41,6 @@ type PluginLifecycleActionResponse struct {
|
||||
Status string `json:"status"`
|
||||
Installation PluginLifecycleInstallationResponse `json:"installation"`
|
||||
Job JobResponse `json:"job"`
|
||||
Decision CapacityAdmissionDecisionResponse `json:"decision"`
|
||||
Alert *AlertResponse `json:"alert,omitempty"`
|
||||
}
|
||||
|
||||
type AIConfigDiffPreviewResponse struct {
|
||||
@@ -185,49 +81,13 @@ type AIConfigDiffApprovalResponse struct {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
@@ -240,12 +100,7 @@ func PluginLifecycleListFromDomain(installations []domain.PluginLifecycleInstall
|
||||
|
||||
func PluginLifecycleResultFromDomain(result domain.PluginLifecycleResult) PluginLifecycleActionResponse {
|
||||
result = domain.CopyPluginLifecycleResult(result)
|
||||
var alert *AlertResponse
|
||||
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}
|
||||
return PluginLifecycleActionResponse{Status: result.Status, Installation: PluginLifecycleFromDomain(result.Installation), Job: JobFromDomain(result.Job)}
|
||||
}
|
||||
|
||||
func AIConfigDiffFromDomain(preview domain.AIConfigDiffPreview) AIConfigDiffPreviewResponse {
|
||||
@@ -269,14 +124,3 @@ func AIConfigDiffApprovalFromDomain(result domain.AIConfigDiffApprovalResult) AI
|
||||
result = domain.CopyAIConfigDiffApprovalResult(result)
|
||||
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 string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Permission string `json:"permission"`
|
||||
ApprovalLevel string `json:"approvalLevel"`
|
||||
PayloadSchemaRef string `json:"payloadSchemaRef"`
|
||||
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
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 string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Permission string `json:"permission"`
|
||||
ApprovalLevel string `json:"approvalLevel"`
|
||||
PayloadSchemaRef string `json:"payloadSchemaRef"`
|
||||
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
MaxPayloadBytes int `json:"maxPayloadBytes"`
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotDeclarationBody struct {
|
||||
@@ -324,19 +315,10 @@ type GameClientBridgeLogProjectionTargetDeclarationBody struct {
|
||||
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 {
|
||||
TimestampField string `json:"timestampField"`
|
||||
ActiveWindowSeconds int `json:"activeWindowSeconds"`
|
||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
|
||||
Announcement GameClientBridgeLogProjectionAnnouncementDeclarationBody `json:"announcement"`
|
||||
TimestampField string `json:"timestampField"`
|
||||
ActiveWindowSeconds int `json:"activeWindowSeconds"`
|
||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionDeclarationBody struct {
|
||||
@@ -895,7 +877,7 @@ type JobExecutionResultResponse struct {
|
||||
Version int `json:"version,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
AuditSummary string `json:"auditSummary,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
|
||||
}
|
||||
|
||||
@@ -958,32 +940,6 @@ type LogStreamListResponse struct {
|
||||
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 {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
@@ -1223,7 +1179,7 @@ func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAcces
|
||||
func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManifest {
|
||||
commands := make([]domain.GameClientBridgeCommandDeclaration, len(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))
|
||||
for index, snapshot := range body.Snapshots {
|
||||
@@ -1276,13 +1232,6 @@ func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDe
|
||||
TimestampField: value.Presence.TimestampField,
|
||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||
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{
|
||||
@@ -1308,13 +1257,6 @@ func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeL
|
||||
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 {
|
||||
return domain.PluginLifecycleActions{
|
||||
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 {
|
||||
user = domain.CopyUser(user)
|
||||
return UserResponse{
|
||||
@@ -1710,7 +1640,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
value = domain.CopyGameClientBridgeManifest(value)
|
||||
commands := make([]GameClientBridgeCommandDeclarationBody, len(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))
|
||||
for index, snapshot := range value.Snapshots {
|
||||
@@ -1762,13 +1692,6 @@ func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogPro
|
||||
TimestampField: value.Presence.TimestampField,
|
||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||
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{
|
||||
@@ -1794,13 +1717,6 @@ func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClie
|
||||
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 {
|
||||
items := make([]MarketplacePluginResponse, len(plugins))
|
||||
for i, plugin := range plugins {
|
||||
@@ -2012,7 +1928,7 @@ func JobFromDomain(job domain.Job) JobResponse {
|
||||
State: job.State,
|
||||
Progress: progressFromDomain(job.Progress),
|
||||
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{
|
||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||
InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds,
|
||||
@@ -2095,27 +2011,6 @@ func LogStreamListFromDomain(streams []domain.LogStream) LogStreamListResponse {
|
||||
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 {
|
||||
return PluginPermissionsResponse{
|
||||
AI: permissions.AI,
|
||||
|
||||
@@ -143,16 +143,16 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{
|
||||
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"},
|
||||
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,
|
||||
MaxCommands: 1000,
|
||||
Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
domainManifest.Pages[0].QueryTemplateKeys[0] = "player.lookup"
|
||||
domainManifest.DataPacks[0].DataRefs[0] = "mutated"
|
||||
if body.DataPacks[0].DataRefs[0] != "data/items.json" {
|
||||
t.Fatal("data pack data refs alias request DTO data")
|
||||
}
|
||||
domainManifest.DataPacks[0].DataRefs[0] = "data/items.json"
|
||||
domainManifest.DataPacks[0].DataRefs[0] = "mutated"
|
||||
if body.DataPacks[0].DataRefs[0] != "data/items.json" {
|
||||
t.Fatal("data pack data refs alias request DTO data")
|
||||
}
|
||||
domainManifest.DataPacks[0].DataRefs[0] = "data/items.json"
|
||||
|
||||
response := gameClientBridgeManifestFromDomain(domainManifest)
|
||||
response.LogProjections[0].Target.FixedValues["source"] = "mutated"
|
||||
|
||||
@@ -12,12 +12,12 @@ Required model groups:
|
||||
- jobs and job events.
|
||||
- artifacts and chunks.
|
||||
- log streams and ingestion cursors.
|
||||
- audit events.
|
||||
- durable alerts and their acknowledgement/resolution audit links.
|
||||
- operational events.
|
||||
- durable alerts and their acknowledgement/resolution metadata.
|
||||
- server-bound plugin lifecycle installations and linked jobs.
|
||||
- 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.
|
||||
# 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"`
|
||||
// AIPurposes stores platform-mediated AI usage 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"`
|
||||
// RemoteAccess stores plugin-declared remote access metadata.
|
||||
RemoteAccess GamePluginRemoteAccess `json:"remoteAccess" db:"remote_access"`
|
||||
@@ -319,7 +319,7 @@ type JobExecutionResult struct {
|
||||
Version int `json:"version,omitempty" db:"version"`
|
||||
Checksum string `json:"checksum,omitempty" db:"checksum"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -433,53 +433,6 @@ type LogStream struct {
|
||||
|
||||
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 {
|
||||
ID string `json:"id" db:"id"`
|
||||
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||
@@ -493,8 +446,6 @@ type PluginLifecycleInstallation struct {
|
||||
Compatibility string `json:"compatibility" db:"compatibility"`
|
||||
DependencyState domain.DependencyState `json:"dependencyState" db:"dependency_state"`
|
||||
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"`
|
||||
IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
@@ -930,11 +881,11 @@ func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
@@ -1020,29 +971,3 @@ func (stream LogStream) ToDomain() domain.LogStream {
|
||||
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",
|
||||
Artifact{}.TableName(): "artifacts",
|
||||
LogStream{}.TableName(): "log_streams",
|
||||
AuditEvent{}.TableName(): "audit_events",
|
||||
Alert{}.TableName(): "alerts",
|
||||
PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations",
|
||||
AIConfigDiff{}.TableName(): "ai_config_diffs",
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
- `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 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.
|
||||
@@ -25,6 +25,6 @@ The canonical payload is `METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
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/cancel`
|
||||
- `POST /api/v1/run/jobs/reconcile`
|
||||
- `POST /api/v1/run/jobs/protected-request-input`
|
||||
|
||||
Named job DTOs:
|
||||
|
||||
@@ -44,8 +43,6 @@ Named job DTOs:
|
||||
- `RunJobProgressRequest`
|
||||
- `RunJobResultRequest`
|
||||
- `RunJobCancelPollRequest`
|
||||
- `ProtectedRequestExecutionInputRequest`
|
||||
- `ProtectedRequestExecutionInputResponse`
|
||||
- `RunJobReconcileRequest`
|
||||
- `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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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 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
|
||||
|
||||
|
||||
@@ -33,10 +33,8 @@ type StoreSnapshot struct {
|
||||
ClientManagerBuildJobs []domain.ClientManagerBuildJob `json:"clientManagerBuildJobs"`
|
||||
RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"`
|
||||
LogStreams []domain.LogStream `json:"logStreams"`
|
||||
AuditEvents []domain.AuditEvent `json:"auditEvents"`
|
||||
MetricSamples []domain.MetricSample `json:"metricSamples"`
|
||||
Backups []domain.BackupRecord `json:"backups"`
|
||||
Alerts []domain.AlertRecord `json:"alerts"`
|
||||
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
|
||||
AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"`
|
||||
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}
|
||||
}
|
||||
|
||||
func (store *FileStore) AuditEvents() AuditEventRepository {
|
||||
return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) MetricSamples() MetricSampleRepository {
|
||||
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}
|
||||
}
|
||||
|
||||
func (store *FileStore) Alerts() AlertRepository {
|
||||
return &persistentRepository[domain.AlertRecord, domain.AlertFilter]{repository: store.MemoryStore.alerts, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) PluginLifecycles() PluginLifecycleRepository {
|
||||
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),
|
||||
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
|
||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
|
||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||
Alerts: snapshotRepository(store.MemoryStore.alerts),
|
||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
||||
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.updateJobs, snapshot.RunUpdateJobs)
|
||||
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
|
||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||
loadRepository(store.MemoryStore.alerts, snapshot.Alerts)
|
||||
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
||||
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||
|
||||
@@ -12,19 +12,19 @@ import (
|
||||
func TestGameClientBridgeCommandRepositoryIdempotencyAndRetention(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
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 {
|
||||
t.Fatalf("create command: %v", err)
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("get by idempotency: %v", err)
|
||||
}
|
||||
if loaded.Payload["nested"].(map[string]any)["value"] != "original" {
|
||||
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) {
|
||||
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}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) AuditEvents() AuditEventRepository {
|
||||
return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) MetricSamples() MetricSampleRepository {
|
||||
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}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) Alerts() AlertRepository {
|
||||
return &persistentRepository[domain.AlertRecord, domain.AlertFilter]{repository: store.MemoryStore.alerts, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) PluginLifecycles() PluginLifecycleRepository {
|
||||
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),
|
||||
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
|
||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
|
||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||
Alerts: snapshotRepository(store.MemoryStore.alerts),
|
||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
||||
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.updateJobs, snapshot.RunUpdateJobs)
|
||||
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
|
||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||
loadRepository(store.MemoryStore.alerts, snapshot.Alerts)
|
||||
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
||||
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||
|
||||
@@ -155,13 +155,6 @@ type LogStreamRepository interface {
|
||||
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 {
|
||||
Create(domain.MetricSample) error
|
||||
Get(id string) (domain.MetricSample, error)
|
||||
@@ -178,13 +171,6 @@ type BackupRepository interface {
|
||||
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 {
|
||||
Create(domain.PluginLifecycleInstallation) error
|
||||
Get(id string) (domain.PluginLifecycleInstallation, error)
|
||||
@@ -254,10 +240,8 @@ type Store interface {
|
||||
ClientManagerBuildJobs() ClientManagerBuildJobRepository
|
||||
RunUpdateJobs() RunUpdateJobRepository
|
||||
LogStreams() LogStreamRepository
|
||||
AuditEvents() AuditEventRepository
|
||||
MetricSamples() MetricSampleRepository
|
||||
Backups() BackupRepository
|
||||
Alerts() AlertRepository
|
||||
PluginLifecycles() PluginLifecycleRepository
|
||||
AIConfigDiffs() AIConfigDiffRepository
|
||||
GameClientBridgeCommands() GameClientBridgeCommandRepository
|
||||
@@ -287,10 +271,8 @@ type MemoryStore struct {
|
||||
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
||||
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
||||
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
||||
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
|
||||
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
||||
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||
@@ -397,11 +379,6 @@ func NewMemoryStore() *MemoryStore {
|
||||
domain.CopyLogStream,
|
||||
matchLogStream,
|
||||
),
|
||||
auditEvents: newMemoryRepository(
|
||||
func(event domain.AuditEvent) string { return event.ID },
|
||||
domain.CopyAuditEvent,
|
||||
matchAuditEvent,
|
||||
),
|
||||
metricSamples: newMemoryRepository(
|
||||
func(sample domain.MetricSample) string { return sample.ID },
|
||||
domain.CopyMetricSample,
|
||||
@@ -412,11 +389,6 @@ func NewMemoryStore() *MemoryStore {
|
||||
domain.CopyBackupRecord,
|
||||
matchBackup,
|
||||
),
|
||||
alerts: newMemoryRepository(
|
||||
func(alert domain.AlertRecord) string { return alert.ID },
|
||||
domain.CopyAlertRecord,
|
||||
matchAlert,
|
||||
),
|
||||
pluginLifecycle: newMemoryRepository(
|
||||
func(installation domain.PluginLifecycleInstallation) string { return installation.ID },
|
||||
domain.CopyPluginLifecycleInstallation,
|
||||
@@ -470,10 +442,8 @@ func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobReposito
|
||||
}
|
||||
func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs }
|
||||
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) Backups() BackupRepository { return store.backups }
|
||||
func (store *MemoryStore) Alerts() AlertRepository { return store.alerts }
|
||||
func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository {
|
||||
return store.pluginLifecycle
|
||||
}
|
||||
@@ -737,13 +707,6 @@ func matchLogStream(stream domain.LogStream, filter domain.LogStreamFilter) bool
|
||||
(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 {
|
||||
return (filter.ServerInstanceID == "" || sample.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return (filter.PluginID == "" || installation.PluginID == filter.PluginID) &&
|
||||
(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"}}},
|
||||
},
|
||||
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 {
|
||||
t.Fatalf("create job: %v", err)
|
||||
@@ -277,7 +277,7 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("create source job: %v", err)
|
||||
@@ -313,20 +313,13 @@ func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) {
|
||||
t.Fatalf("create file store: %v", err)
|
||||
}
|
||||
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{
|
||||
ID: "plugin-lifecycle-1", PluginID: "game.scum", ServerInstanceID: "server-1",
|
||||
CurrentVersion: "1.0.0", TargetVersion: "2.0.0", PreviousVersion: "0.9.0",
|
||||
DesiredState: domain.PluginLifecycleStateEnabled, CurrentState: domain.PluginLifecycleStateUpgrading,
|
||||
LastOperation: domain.PluginLifecycleOperationUpgrade, Compatibility: "compatible",
|
||||
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade", AlertID: alert.ID,
|
||||
AuditEventID: "audit-2", IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
|
||||
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade",
|
||||
IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
|
||||
}
|
||||
diff := domain.AIConfigDiffPreview{
|
||||
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",
|
||||
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 {
|
||||
t.Fatalf("create plugin lifecycle: %v", err)
|
||||
}
|
||||
@@ -349,10 +339,6 @@ func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -91,16 +91,6 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
}
|
||||
result, err := svc.aiProviderClient.Invoke(provider, request)
|
||||
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{
|
||||
RequestID: request.RequestID,
|
||||
Purpose: request.Purpose,
|
||||
|
||||
@@ -58,9 +58,6 @@ func (svc *CoreService) OpenArtifactDownloadForSession(sessionID string, request
|
||||
if err := validator.ValidateArtifactDownloadReference(reference); err != nil {
|
||||
return domain.ArtifactDownloadReference{}, err
|
||||
}
|
||||
if err := svc.auditArtifactDownload(sessionID, artifact); err != nil {
|
||||
return domain.ArtifactDownloadReference{}, err
|
||||
}
|
||||
return domain.CopyArtifactDownloadReference(reference), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -28,13 +28,12 @@ func (svc *CoreService) DeployClientManagerForSession(sessionID string, request
|
||||
if err := validator.ValidateClientManagerDeployRequest(request); err != nil {
|
||||
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 {
|
||||
return domain.ClientManagerLifecycleView{}, err
|
||||
}
|
||||
distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID)
|
||||
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
|
||||
}
|
||||
installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution)
|
||||
@@ -93,9 +92,6 @@ func (svc *CoreService) DeployClientManagerForSession(sessionID string, request
|
||||
if job.ID != installation.CurrentJobID {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -107,12 +103,11 @@ func (svc *CoreService) ControlClientManagerForSession(sessionID string, request
|
||||
if request.Operation == domain.ClientManagerOperationRollback {
|
||||
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 {
|
||||
return domain.ClientManagerLifecycleView{}, err
|
||||
}
|
||||
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
|
||||
}
|
||||
installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key)
|
||||
@@ -175,7 +170,6 @@ func (svc *CoreService) ControlClientManagerForSession(sessionID string, request
|
||||
if job.ID != installation.CurrentJobID {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -183,7 +177,7 @@ func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request
|
||||
if err := validator.ValidateClientManagerUpdateRequest(request); err != nil {
|
||||
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 {
|
||||
return domain.ClientManagerLifecycleView{}, err
|
||||
}
|
||||
@@ -199,11 +193,9 @@ func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request
|
||||
}
|
||||
distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID)
|
||||
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
|
||||
}
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -254,7 +245,7 @@ func (svc *CoreService) UninstallClientManagerForSession(sessionID string, reque
|
||||
if err := validator.ValidateClientManagerUninstallRequest(request); err != nil {
|
||||
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 {
|
||||
return domain.ClientManagerLifecycleView{}, err
|
||||
}
|
||||
@@ -304,13 +295,11 @@ func (svc *CoreService) UninstallClientManagerForSession(sessionID string, reque
|
||||
if job.ID != installation.CurrentJobID {
|
||||
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)
|
||||
}
|
||||
|
||||
func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, request domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
if _, err := svc.GetCurrentUser(sessionID); err != nil {
|
||||
return domain.ClientManagerLifecycleView{}, err
|
||||
}
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
||||
@@ -334,7 +323,6 @@ func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, r
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -360,7 +348,7 @@ func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string,
|
||||
case domain.ClientManagerOperationUninstall:
|
||||
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 {
|
||||
return domain.ClientManagerLifecycleView{}, err
|
||||
}
|
||||
@@ -402,7 +390,6 @@ func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string,
|
||||
if job.ID != installation.CurrentJobID {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -472,7 +459,6 @@ func (svc *CoreService) authorizeClientManagerLifecycle(sessionID, serverInstanc
|
||||
}
|
||||
profile, err := findRuntimeClientManagerProfile(plugin, profileKey)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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 user, instance, plugin, profile, endpoint, nil
|
||||
@@ -974,7 +959,7 @@ func (svc *CoreService) projectClientManagerLifecycleResult(job domain.Job, stam
|
||||
installation.LastHeartbeatSequence = 0
|
||||
case domain.ClientManagerOperationUninstall:
|
||||
installation.Status = domain.ClientManagerLifecycleUninstalled
|
||||
installation.Phase = "controlled workspace removed"
|
||||
installation.Phase = "managed workspace removed"
|
||||
installation.Health = domain.ClientManagerHealthOffline
|
||||
installation.HealthReason = "uninstalled"
|
||||
installation.ActiveArtifactID = ""
|
||||
@@ -994,11 +979,7 @@ func (svc *CoreService) projectClientManagerLifecycleResult(job domain.Job, stam
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
return err
|
||||
}
|
||||
result := domain.AuditResultSuccess
|
||||
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 nil
|
||||
}
|
||||
return repo.ErrNotFound
|
||||
}
|
||||
@@ -1134,7 +1115,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist
|
||||
}
|
||||
stamp := svc.now()
|
||||
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
|
||||
}
|
||||
installation, err := svc.store.ClientManagerInstallations().Get(request.InstallationID)
|
||||
@@ -1142,7 +1122,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist
|
||||
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 {
|
||||
_ = 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
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
nonceID := clientManagerNonceID(request.InstallationID, request.Nonce)
|
||||
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
|
||||
} else if !errors.Is(err, repo.ErrNotFound) {
|
||||
return domain.ClientManagerRegisterResult{}, err
|
||||
@@ -1209,7 +1185,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
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 {
|
||||
t.Fatalf("complete lifecycle job: %v", err)
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ func TestCoreServiceRunLifecycleReportProjectsGeneratedRunFacts(t *testing.T) {
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
return svc.recordAuditEvent("run", "dependency.result", "server-instance", job.ServerInstanceID, auditResultForJob(job), status.Message)
|
||||
return nil
|
||||
}
|
||||
if job.Capability != domain.JobCapabilityRunSelfUpdate {
|
||||
return nil
|
||||
@@ -494,7 +494,7 @@ func (svc *CoreService) projectDependencyAndRunUpdateResult(job domain.Job, stam
|
||||
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
|
||||
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 {
|
||||
@@ -589,26 +589,9 @@ func (svc *CoreService) ReportRunUpdateHealth(report domain.RunUpdateHealthRepor
|
||||
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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.IdempotencyKey = "dependency-current-digest"
|
||||
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"})
|
||||
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)
|
||||
}
|
||||
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"})
|
||||
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)
|
||||
}
|
||||
updates, err := svc.ListRunUpdateJobsForSession(session, instance.ID)
|
||||
|
||||
@@ -38,7 +38,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
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
|
||||
}
|
||||
if deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
|
||||
@@ -47,7 +46,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -114,9 +112,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
return domain.RunDistribution{}, validationError("distribution build idempotency key conflicts with another 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
|
||||
}
|
||||
|
||||
@@ -144,26 +139,22 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
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
|
||||
}
|
||||
profile, err := findRuntimeClientManagerProfile(plugin, request.ProfileKey)
|
||||
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
|
||||
}
|
||||
if strings.TrimSpace(request.SourceRevision) == "" {
|
||||
request.SourceRevision = clientManagerProfileRevision(profile)
|
||||
}
|
||||
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")
|
||||
}
|
||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -271,9 +262,6 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another 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
|
||||
}
|
||||
|
||||
@@ -376,9 +364,6 @@ func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request do
|
||||
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
|
||||
}
|
||||
|
||||
@@ -404,7 +389,6 @@ func (svc *CoreService) AuthenticateComponent(request domain.ComponentAuthentica
|
||||
}
|
||||
if key.Generation != request.Generation {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
result.Allowed = true
|
||||
@@ -523,14 +506,12 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain
|
||||
return domain.RunUpdateJob{}, err
|
||||
}
|
||||
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")
|
||||
}
|
||||
if request.Checksum == "" {
|
||||
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")
|
||||
}
|
||||
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)
|
||||
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")
|
||||
}
|
||||
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"},
|
||||
})
|
||||
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
|
||||
}
|
||||
stamp := svc.now()
|
||||
@@ -598,9 +577,6 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -625,7 +601,6 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d
|
||||
return domain.Job{}, err
|
||||
}
|
||||
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")
|
||||
}
|
||||
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)
|
||||
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")
|
||||
}
|
||||
request.PlanDigest = expectedDigest
|
||||
capability := domain.JobCapabilityDependenciesCheck
|
||||
targetKey := "dependencies/" + request.ProbeKey
|
||||
message := "dependency check queued"
|
||||
auditAction := "dependency.check"
|
||||
state := domain.DependencyStateUnknown
|
||||
if request.Install {
|
||||
capability = domain.JobCapabilityDependenciesInstall
|
||||
targetKey = "dependencies/install/" + request.InstallPlanKey
|
||||
message = "dependency install queued"
|
||||
auditAction = "dependency.install"
|
||||
state = domain.DependencyStateInstalling
|
||||
}
|
||||
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},
|
||||
})
|
||||
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
|
||||
}
|
||||
if err := svc.upsertDependencyStatus(instance, request, job.ID, probe.Required, state, "queued through platform job"); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -715,12 +683,10 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
|
||||
return domain.Job{}, err
|
||||
}
|
||||
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
|
||||
}
|
||||
source, err := declaredFileLogSource(plugin, request.SourceKey)
|
||||
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
|
||||
}
|
||||
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},
|
||||
})
|
||||
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.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 {
|
||||
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")
|
||||
}
|
||||
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 nil
|
||||
@@ -1033,63 +993,6 @@ func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, r
|
||||
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 {
|
||||
if len(plugin.SupportedOS) == 0 || containsString(plugin.SupportedOS, targetOS) {
|
||||
return nil
|
||||
@@ -1251,7 +1154,6 @@ func (svc *CoreService) requireCompleteRuntimeBindings(actorID string, serverIns
|
||||
if complete {
|
||||
return nil
|
||||
}
|
||||
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "operation denied: "+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)
|
||||
runDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -578,24 +578,6 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
|
||||
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) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
@@ -252,30 +251,6 @@ func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQue
|
||||
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) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
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)) {
|
||||
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)
|
||||
if err == nil {
|
||||
return domain.CopyGameClientBridgeCommand(existing), nil
|
||||
@@ -343,32 +311,9 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
||||
CreatedAt: 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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -413,11 +358,6 @@ func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridge
|
||||
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.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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -440,11 +380,6 @@ func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeCom
|
||||
command.Claim.AcknowledgedAt = stamp
|
||||
command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt)
|
||||
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 {
|
||||
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.CompletedAt = 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 {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if command.RunJobID != "" {
|
||||
svc.protectedRequests.Delete(command.RunJobID)
|
||||
}
|
||||
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.CompletedAt = 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 {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if command.RunJobID != "" {
|
||||
svc.protectedRequests.Delete(command.RunJobID)
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
@@ -682,11 +601,6 @@ func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) err
|
||||
command.State = domain.GameClientBridgeCommandPending
|
||||
command.Claim = domain.GameClientBridgeClaim{FencingToken: fencingToken}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -722,11 +636,6 @@ func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameC
|
||||
command.State = domain.GameClientBridgeCommandExpired
|
||||
command.CompletedAt = 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -111,11 +111,6 @@ func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClient
|
||||
CreatedAt: stamp,
|
||||
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 {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,7 +12,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := repo.NewMemoryStore()
|
||||
plugin := domain.GamePlugin{ID: "game.scum", 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 {
|
||||
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 {
|
||||
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 {
|
||||
@@ -45,8 +43,8 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
||||
t.Fatalf("idempotency reuse: command=%#v err=%v", duplicate, err)
|
||||
}
|
||||
commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||
if len(commands) != 1 || len(command.AuditReferences) != 1 {
|
||||
t.Fatalf("expected one durable audited command: %#v", commands)
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("expected one durable command: %#v", commands)
|
||||
}
|
||||
|
||||
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() {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -293,7 +182,7 @@ func TestGameClientBridgeExpiredLeaseRejectsMutationsBeforeReclaim(t *testing.T)
|
||||
}
|
||||
for _, command := range claimed {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -341,8 +230,8 @@ func TestGameClientBridgeClaimMutationsExpireAtCommandDeadline(t *testing.T) {
|
||||
}
|
||||
for _, command := range commands {
|
||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || len(expired.AuditReferences) < 3 {
|
||||
t.Fatalf("deadline mutation did not persist audited expiry: %#v err=%v", expired, err)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() {
|
||||
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}}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -393,8 +282,8 @@ func TestGameClientBridgeOperatorCancellationExpiresAtCommandDeadline(t *testing
|
||||
t.Fatal("expected cancellation at command deadline to be rejected")
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("deadline cancellation did not preserve audited expiry: %#v err=%v", expired, err)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Cancellation.RequestedBy != "" {
|
||||
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 {
|
||||
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"})
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
fencingToken := uint64(0)
|
||||
if isProtectedRequestCapability(job.Capability) {
|
||||
fencingToken = uint64(job.Attempt)
|
||||
}
|
||||
return domain.RunJobAssignment{
|
||||
JobID: job.ID,
|
||||
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)},
|
||||
LeaseToken: leaseToken,
|
||||
Attempt: job.Attempt,
|
||||
FencingToken: fencingToken,
|
||||
FencingToken: 0,
|
||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||
AckDeadlineAt: job.AckDeadlineAt,
|
||||
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 {
|
||||
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 {
|
||||
return domain.BackupRecord{}, err
|
||||
}
|
||||
@@ -189,9 +182,6 @@ func (svc *CoreService) RecoverIncompleteBackups() error {
|
||||
if err := svc.store.Backups().Update(record); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -207,7 +197,7 @@ func (svc *CoreService) pruneMetricSamples(serverInstanceID string) error {
|
||||
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 {
|
||||
@@ -222,7 +212,6 @@ func (svc *CoreService) pruneBackups(serverInstanceID string) error {
|
||||
total += record.SizeBytes
|
||||
}
|
||||
}
|
||||
pruned := false
|
||||
for len(items) > maxBackupsPerServer || total > maxBackupBytesPerServer {
|
||||
record := items[0]
|
||||
items = items[1:]
|
||||
@@ -235,10 +224,6 @@ func (svc *CoreService) pruneBackups(serverInstanceID string) error {
|
||||
if err := svc.store.Backups().Update(record); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -184,17 +184,6 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance,
|
||||
if insideWindow && !sameObservation {
|
||||
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 {
|
||||
value = mergePluginDataValues(existing.Value, value)
|
||||
}
|
||||
@@ -211,39 +200,9 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance,
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
value := make(map[string]any, len(target.CaptureMappings)+len(target.FixedValues)+1)
|
||||
for destination, capture := range target.CaptureMappings {
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnouncesReturns(t *testing.T) {
|
||||
func TestDurableStdoutProjectionCreatesUsersAndSuppressesRapidDuplicates(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
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.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{{
|
||||
Key: "player.login", StreamKeys: []string{"stdout"}, CorrelationFields: []string{"playerSlot"}, MaxInterveningLines: 4,
|
||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
||||
@@ -37,25 +28,16 @@ func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnounce
|
||||
Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "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 {
|
||||
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})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, capability)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-log-projection"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
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{
|
||||
`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{
|
||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
||||
`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{
|
||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
||||
})
|
||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 2)
|
||||
|
||||
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)
|
||||
}
|
||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 0)
|
||||
}
|
||||
|
||||
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})
|
||||
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 (
|
||||
capacityHeartbeatStaleAfter = 2 * time.Minute
|
||||
capacityRetryAfterSeconds = 30
|
||||
capacityLogBacklogLimit = 256
|
||||
capacityArtifactBacklogLimit = 128
|
||||
aiConfigDiffTTL = 30 * time.Minute
|
||||
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) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
@@ -383,13 +95,6 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d
|
||||
if !errors.Is(jobErr, repo.ErrNotFound) {
|
||||
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{
|
||||
ID: jobIDFromParts("job-plugin-lifecycle", installation.ID, request.IdempotencyKey), ServerInstanceID: instance.ID,
|
||||
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.FailureReason = ""
|
||||
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 {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
@@ -425,7 +125,7 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -503,9 +203,6 @@ func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request
|
||||
if err := svc.store.AIConfigDiffs().Update(preview); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -525,17 +222,9 @@ func (svc *CoreService) projectProductionOpsJobResult(job domain.Job, stamp time
|
||||
if job.State == domain.JobStateSucceeded {
|
||||
applyPluginLifecycleSuccess(&installation)
|
||||
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 {
|
||||
installation.CurrentState = domain.PluginLifecycleStateFailed
|
||||
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
|
||||
return svc.store.PluginLifecycles().Update(installation)
|
||||
@@ -598,193 +287,6 @@ func (svc *CoreService) getServerConfigForUser(userID, serverInstanceID string)
|
||||
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) {
|
||||
svc.productionMu.Lock()
|
||||
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) {
|
||||
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.LastOperation = request.Operation
|
||||
installation.TargetVersion = request.TargetVersion
|
||||
installation.FailureReason = safeBridgeReason(reason)
|
||||
installation.AuditEventID = auditID
|
||||
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 {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
@@ -824,7 +316,7 @@ func (svc *CoreService) pluginLifecycleDeniedLocked(actorID string, installation
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -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 {
|
||||
sum := sha256.Sum256([]byte(pluginID + "\x00" + serverInstanceID))
|
||||
return "plugin-lifecycle-" + hex.EncodeToString(sum[:12])
|
||||
@@ -951,42 +438,3 @@ func aiConfigDiffID(requestID, serverInstanceID string) string {
|
||||
sum := sha256.Sum256([]byte(requestID + "\x00" + serverInstanceID))
|
||||
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"
|
||||
)
|
||||
|
||||
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) {
|
||||
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"}
|
||||
@@ -66,7 +34,7 @@ func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginLifecycleBridgeDispatchesOnlyPlatformGovernedJob(t *testing.T) {
|
||||
func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) {
|
||||
svc, session, instance := newProductionOpsFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
@@ -90,13 +58,12 @@ func TestPluginLifecycleBridgeDispatchesOnlyPlatformGovernedJob(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("execute lifecycle bridge: %v", err)
|
||||
}
|
||||
if response.Status != "queued" || response.Result["jobId"] == "" || response.Result["installationId"] == "" || response.Result["admissionState"] != string(domain.CapacityAdmissionAccepted) {
|
||||
t.Fatalf("expected Platform-governed lifecycle job, got %+v", response)
|
||||
if response.Status != "queued" || response.Result["jobId"] == "" || response.Result["installationId"] == "" {
|
||||
t.Fatalf("expected plugin lifecycle job, got %+v", response)
|
||||
}
|
||||
serialized := strings.ToLower(strings.Join([]string{
|
||||
response.Result["jobId"], response.Result["installationId"], response.Result["currentState"],
|
||||
response.Result["desiredState"], response.Result["alertId"], response.Result["auditEventId"],
|
||||
response.Result["admissionState"], response.Result["admissionReason"],
|
||||
response.Result["desiredState"],
|
||||
}, " "))
|
||||
for _, forbidden := range []string{"password", "apikey", "token", "secret://", "baseurl", "hostpath", "socket", "pid", "dsn", "rcon", "runendpoint"} {
|
||||
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}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -102,15 +100,7 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request
|
||||
if err != nil {
|
||||
return domain.RemoteAdapterResult{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
func intersectRemoteCapabilities(profile []string, declared []string, endpoint []string) []string {
|
||||
|
||||
@@ -107,12 +107,6 @@ type Core interface {
|
||||
DeleteServerInstanceForSession(string, string, domain.ServerDeletionRequest) (domain.ServerInstance, error)
|
||||
GetPlatformResourceUsage() (domain.PlatformResourceUsage, 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)
|
||||
RunPluginLifecycleForSession(string, domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error)
|
||||
ListAIConfigDiffsForSession(string, domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
||||
@@ -144,7 +138,6 @@ type Core interface {
|
||||
GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error)
|
||||
DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error)
|
||||
GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error)
|
||||
GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest) (domain.ProtectedRequestExecutionInput, error)
|
||||
GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error)
|
||||
ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
|
||||
ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error)
|
||||
@@ -215,9 +208,6 @@ type Core interface {
|
||||
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
||||
GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, 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
|
||||
}
|
||||
|
||||
@@ -244,11 +234,8 @@ type CoreService struct {
|
||||
artifactTransfers map[string]domain.ArtifactTransferSession
|
||||
artifactPayloads map[string][]byte
|
||||
artifactTransferSeq uint64
|
||||
auditMu sync.Mutex
|
||||
auditSeq uint64
|
||||
productionMu sync.Mutex
|
||||
sourceRCONCommands *sourceRCONCommandBroker
|
||||
protectedRequests *protectedRequestBroker
|
||||
aiProviderClient AIProviderClient
|
||||
secretEnvelope SecretEnvelope
|
||||
networkFingerprintKey []byte
|
||||
@@ -288,7 +275,6 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
|
||||
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
||||
artifactPayloads: map[string][]byte{},
|
||||
sourceRCONCommands: newSourceRCONCommandBroker(now),
|
||||
protectedRequests: newProtectedRequestBroker(now),
|
||||
aiProviderClient: MockAIProviderClient{},
|
||||
secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey),
|
||||
networkFingerprintKey: []byte(developmentSecretEnvelopeKey),
|
||||
@@ -703,23 +689,6 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult,
|
||||
result.Success = false
|
||||
result.Message = "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
|
||||
}
|
||||
@@ -1020,7 +989,7 @@ func (svc *CoreService) executeBridgePluginLifecycle(sessionID string, base doma
|
||||
return bridgeExecutionError(base, err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2668,27 +2637,6 @@ func (svc *CoreService) ListLogStreams(filter domain.LogStreamFilter) ([]domain.
|
||||
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 {
|
||||
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1070,7 +1048,7 @@ func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) {
|
||||
t.Fatalf("claim typed config job: claim=%+v err=%v", claim, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
updated, err := svc.GetServerConfigForSession(ownerSession, instance.ID)
|
||||
|
||||
@@ -202,7 +202,7 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t
|
||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{
|
||||
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",
|
||||
ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", AuditSummary: "bounded process state"},
|
||||
ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", Summary: "bounded process state"},
|
||||
}); err != nil {
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -72,40 +65,17 @@ func lifecycleObservationIsStale(instance domain.ServerInstance, report domain.R
|
||||
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 {
|
||||
if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) {
|
||||
return nil
|
||||
}
|
||||
result := domain.AuditResultSuccess
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error {
|
||||
if job.Capability == domain.JobCapabilityConfigWrite {
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
if !ok || job.ServerInstanceID == "" {
|
||||
@@ -142,11 +112,7 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
|
||||
return err
|
||||
}
|
||||
svc.publishLogProcessState(instance)
|
||||
auditResult := domain.AuditResultSuccess
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
switch capability {
|
||||
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:
|
||||
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{
|
||||
|
||||
@@ -235,62 +235,6 @@ func sourceRCONTransportForCapability(profiles domain.GamePluginRuntimeProfiles,
|
||||
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) {
|
||||
byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(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) {
|
||||
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) {
|
||||
|
||||
@@ -9,10 +9,6 @@ import (
|
||||
|
||||
func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
||||
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{{
|
||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 16,
|
||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
||||
@@ -23,7 +19,6 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
||||
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
|
||||
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},
|
||||
@@ -44,11 +39,11 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
||||
{name: "missing capture", expected: "references undeclared capture missing", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
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) {
|
||||
value.ClientManagers = nil
|
||||
{name: "invalid timestamp field", expected: "presence.timestampField must reference", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Presence.TimestampField = "missingAt"
|
||||
}},
|
||||
{name: "missing command", expected: "must reference a declared command", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Presence.Announcement.CommandType = "missing.command"
|
||||
{name: "invalid activity target", expected: "presence.activityTarget.upsertKeys field missing is not projected", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Presence.ActivityTarget.UpsertKeys = []string{"missing"}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
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) {
|
||||
|
||||
@@ -98,14 +98,6 @@ func ValidateSourceRCONExecutionInputRequest(request domain.SourceRCONExecutionI
|
||||
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 {
|
||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||
}
|
||||
|
||||
@@ -1,93 +1,11 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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 {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", installation.ID)
|
||||
@@ -165,51 +83,6 @@ func ValidateAIConfigDiffApprovalRequest(request domain.AIConfigDiffApprovalRequ
|
||||
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 {
|
||||
switch state {
|
||||
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 (
|
||||
maxAuditSummaryLength = 512
|
||||
maxSummaryLength = 512
|
||||
maxContactNoteLength = 160
|
||||
maxMarketplaceKeywordSize = 80
|
||||
maxMarketplaceListSize = 500
|
||||
@@ -500,7 +500,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
commandTypes := map[string]struct{}{}
|
||||
for index, command := range bridge.Commands {
|
||||
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")
|
||||
}
|
||||
if _, exists := commandTypes[command.Type]; exists {
|
||||
@@ -525,7 +525,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
|
||||
violations = append(violations, prefix+".maxPayloadBytes is invalid")
|
||||
}
|
||||
violations = append(violations, validateGameClientBridgeProtectedRequest(prefix+".protectedRequest", command.ProtectedRequest, transports)...)
|
||||
}
|
||||
snapshotTypes := map[string]struct{}{}
|
||||
for index, snapshot := range bridge.Snapshots {
|
||||
@@ -625,7 +624,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
violations = append(violations, prefix+".key is duplicated")
|
||||
}
|
||||
logProjectionKeys[projection.Key] = struct{}{}
|
||||
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection, bridge.Commands, runtimeProfiles.ClientManagers)...)
|
||||
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection)...)
|
||||
}
|
||||
dataPackKeys := map[string]struct{}{}
|
||||
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 {
|
||||
violations = append(violations, prefix+" must declare a database version and parser/config assets")
|
||||
}
|
||||
refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...)
|
||||
refs = append(refs, dataPack.DataRefs...)
|
||||
for _, ref := range refs {
|
||||
refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...)
|
||||
refs = append(refs, dataPack.DataRefs...)
|
||||
for _, ref := range refs {
|
||||
if !safeRelativeJSONRef(ref) {
|
||||
violations = append(violations, prefix+" asset reference is invalid")
|
||||
}
|
||||
@@ -664,8 +663,8 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if !containsString(permissions, template.Permission) {
|
||||
violations = append(violations, prefix+".permission must be declared by the plugin")
|
||||
}
|
||||
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||
violations = append(violations, prefix+".approvalLevel must require operator or platform-admin approval")
|
||||
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelNone && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||
violations = append(violations, prefix+".approvalLevel is invalid")
|
||||
}
|
||||
if template.Kind != domain.GameClientBridgeOperationKindRCON && template.Kind != domain.GameClientBridgeOperationKindSQLiteMutation {
|
||||
violations = append(violations, prefix+".kind is invalid")
|
||||
@@ -689,8 +688,8 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
}
|
||||
switch template.Kind {
|
||||
case domain.GameClientBridgeOperationKindRCON:
|
||||
if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedRCON) {
|
||||
violations = append(violations, prefix+" transport must be rcon with remote.run.protected.rcon capability")
|
||||
if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunRCONCommand) {
|
||||
violations = append(violations, prefix+" transport must be rcon with remote.run.rcon.command capability")
|
||||
}
|
||||
if template.MaxRowsAffected != 0 {
|
||||
violations = append(violations, prefix+".maxRowsAffected is only valid for sqlite-mutation")
|
||||
@@ -822,7 +821,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
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
|
||||
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
violations = append(violations, validateRuntimeSourceRCONPlan("executionInput.sourceRcon", job.ExecutionInput.SourceRCON)...)
|
||||
isSourceCommand := job.Capability == domain.JobCapabilityRemoteRunRCONCommand
|
||||
isProtectedRCON := job.Capability == domain.JobCapabilityRemoteRunProtectedRCON
|
||||
wantAdapterKind := "rcon"
|
||||
if isProtectedRCON {
|
||||
wantAdapterKind = "protected-rcon"
|
||||
}
|
||||
if (!isSourceCommand && !isProtectedRCON) || job.ExecutionInput.RemoteAdapterKind != wantAdapterKind {
|
||||
if !isSourceCommand || job.ExecutionInput.RemoteAdapterKind != "rcon" {
|
||||
violations = append(violations, "executionInput.sourceRcon is allowed only for rcon jobs")
|
||||
}
|
||||
if job.RetryPolicy.MaxAttempts != 1 {
|
||||
@@ -1766,8 +1679,8 @@ func ValidateJob(job domain.Job) error {
|
||||
if len([]byte(job.ExecutionResult.Content)) > maxJobExecutionContentSize {
|
||||
violations = append(violations, "executionResult.content is too large")
|
||||
}
|
||||
if len(job.ExecutionResult.AuditSummary) > maxAuditSummaryLength {
|
||||
violations = append(violations, "executionResult.auditSummary is too long")
|
||||
if len(job.ExecutionResult.Summary) > maxSummaryLength {
|
||||
violations = append(violations, "executionResult.summary is too long")
|
||||
}
|
||||
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
||||
if job.ServerInstanceID == "" {
|
||||
@@ -1875,26 +1788,6 @@ func ValidateLogStream(stream domain.LogStream) error {
|
||||
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 {
|
||||
actualSet := make(map[string]struct{}, len(actual))
|
||||
for _, capability := range actual {
|
||||
@@ -2446,7 +2339,7 @@ func validPluginRunCapability(capability string) bool {
|
||||
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
||||
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram,
|
||||
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProgram,
|
||||
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
|
||||
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
|
||||
@@ -2479,8 +2372,7 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
|
||||
domain.JobCapabilityRemoteRunFilesWrite,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery,
|
||||
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL,
|
||||
domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram:
|
||||
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProgram:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -2781,12 +2673,3 @@ func validLogStorageBackend(backend domain.LogStorageBackend) bool {
|
||||
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) {
|
||||
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.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].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
||||
registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{
|
||||
{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}},
|
||||
}
|
||||
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}}},
|
||||
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{
|
||||
{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}},
|
||||
},
|
||||
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 {
|
||||
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) {
|
||||
value.Manifest.GameClientBridge.OperationTemplates[0].Key = "raw.sql.execute"
|
||||
}},
|
||||
{name: "missing approval", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.OperationTemplates[0].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone
|
||||
{name: "mutation missing approval metadata", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.OperationTemplates[1].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone
|
||||
}},
|
||||
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
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{
|
||||
ID: "artifact-1",
|
||||
OwnerKind: domain.ArtifactOwnerKindJob,
|
||||
@@ -406,23 +406,6 @@ func TestValidateArtifactLogAndAudit(t *testing.T) {
|
||||
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 {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
- API handlers must use named DTOs from `platform/dto`.
|
||||
- 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.
|
||||
- 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.
|
||||
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
|
||||
# Client Manager lifecycle validation
|
||||
|
||||
@@ -87,8 +87,8 @@ func TestValidateGamePluginRuntimeProfilesRejectsUnsafeLogEventSemantics(t *test
|
||||
unsafeEventTypes := []string{
|
||||
"ops.shell.execute",
|
||||
"ops.execute",
|
||||
"audit.sql.query",
|
||||
"audit.raw-host-path",
|
||||
"ops.sql.query",
|
||||
"ops.raw-host-path",
|
||||
"run.socket.open",
|
||||
"auth.credential.exposed",
|
||||
"auth.api-key.exposed",
|
||||
|
||||
Reference in New Issue
Block a user