功能修改
This commit is contained in:
@@ -79,4 +79,10 @@ For Docker, the root `docker-compose.yml` sets platform data under `/data/platfo
|
||||
|
||||
Current executable behavior includes the platform API, durable hashed auth/Run sessions with expiry/revocation/rotation, strict production route authorization, durable file-backed metadata, segmented log bodies, authenticated run control/job/log/artifact routes, plugin bridge dispatch, platform-mediated AI invocation, real typed dependency execution orchestration with reviewed plan digests, and target-fenced transactional Run self-update staging/health/rollback projections.
|
||||
|
||||
### Client Manager lifecycle
|
||||
|
||||
Client Manager installations are durable aggregates separate from Run distributions and sessions. Their safe state projection is `requested -> building -> available -> deploying -> installed -> registering -> online`, with `degraded`, `offline`, `updating`, `rolling_back`, `stopping`, `failed`, and `uninstalled` recovery states. Deploy, control, update, rollback, revoke-session, retry, and uninstall are typed jobs; Platform persists intent before dispatch and gates each action by actor/server ownership, plugin profile, binding, endpoint capability, artifact target/revision, key generation, and lifecycle state.
|
||||
|
||||
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. Server creation selects a declared profile, saves complete logical bindings before install dispatch, and existing lifecycle/runtime actions are gated when the binding is absent or incomplete. Browser and plugin-facing responses expose readiness only, not binding values. 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.
|
||||
|
||||
@@ -32,6 +32,9 @@ func publicAPIRequest(r *http.Request) bool {
|
||||
if path == "/api/v1/auth/login" || path == "/api/v1/auth/register" || path == "/api/v1/client-managers/register" || path == "/api/v1/client-managers/heartbeat" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/v1/game-client-bridge/companion/") {
|
||||
return true
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeClaimRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
commands, err := h.core.ClaimGameClientBridgeCommands(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeClaimResponseFromDomain(commands))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionAck(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeAckRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
command, err := h.core.AckGameClientBridgeCommand(request.ToDomain(r.PathValue("commandId")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeAckFromDomain(command))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionResult(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeResultRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
command, err := h.core.CompleteGameClientBridgeCommand(request.ToDomain(r.PathValue("commandId")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeResultFromDomain(command))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeSnapshotIngestRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
snapshot, err := h.core.UploadGameClientBridgeSnapshot(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeSnapshotIngestFromDomain(snapshot))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeSnapshotIngestRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
if request.Type != "companion.health" && request.Type != "bridge.diagnostics" {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", []string{"diagnostic snapshot type is invalid"})
|
||||
return
|
||||
}
|
||||
snapshot, err := h.core.UploadGameClientBridgeSnapshot(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeSnapshotIngestFromDomain(snapshot))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func (h *coreHandlers) serverGameClientBridgeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
status, err := h.core.GetGameClientBridgeStatusForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeStatusFromDomain(status))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverGameClientBridgeCommands(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
commands, err := h.core.ListGameClientBridgeCommandsForSession(bearerToken(r), domain.GameClientBridgeCommandFilter{ServerInstanceID: r.PathValue("id"), ProfileKey: r.URL.Query().Get("profileKey"), State: domain.GameClientBridgeCommandState(r.URL.Query().Get("state")), CommandType: r.URL.Query().Get("commandType")})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeCommandsFromDomain(commands))
|
||||
case http.MethodPost:
|
||||
request, err := decodeJSON[dto.GameClientBridgeQueueRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
command, err := h.core.QueueGameClientBridgeCommandForSession(bearerToken(r), request.ToDomain(instance.ID, instance.PluginID))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeCommandFromDomain(command))
|
||||
default:
|
||||
w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
|
||||
writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverGameClientBridgeCommandDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
command, err := h.core.GetGameClientBridgeCommandForSession(bearerToken(r), r.PathValue("commandId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if command.ServerInstanceID != r.PathValue("id") {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeCommandFromDomain(command))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverGameClientBridgeCommandCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeCancelRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
command, err := h.core.GetGameClientBridgeCommandForSession(bearerToken(r), r.PathValue("commandId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if command.ServerInstanceID != r.PathValue("id") {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
cancelled, err := h.core.CancelGameClientBridgeCommandForSession(bearerToken(r), request.ToDomain(command.ID))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeCancelFromDomain(cancelled))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverGameClientBridgeSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
limit, err := optionalPositiveInt(r.URL.Query().Get("limit"))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid snapshot limit", nil)
|
||||
return
|
||||
}
|
||||
var observedAfter time.Time
|
||||
if value := r.URL.Query().Get("observedAfter"); value != "" {
|
||||
observedAfter, err = time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid observedAfter timestamp", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
query := dto.GameClientBridgeSnapshotQuery{ProfileKey: r.URL.Query().Get("profileKey"), Type: r.URL.Query().Get("type"), StreamKey: r.URL.Query().Get("streamKey"), ObservedAfter: observedAfter, Limit: limit}
|
||||
snapshots, err := h.core.QueryGameClientBridgeSnapshotsForSession(bearerToken(r), query.ToDomain(instance.ID, instance.PluginID))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeSnapshotsFromDomain(snapshots))
|
||||
}
|
||||
|
||||
func optionalPositiveInt(value string) (int, error) {
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
return strconv.Atoi(value)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/service"
|
||||
)
|
||||
|
||||
type gameClientBridgeCompanionCore struct {
|
||||
service.Core
|
||||
command domain.GameClientBridgeCommand
|
||||
snapshot domain.GameClientBridgeSnapshot
|
||||
}
|
||||
|
||||
func (core *gameClientBridgeCompanionCore) ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
|
||||
return []domain.GameClientBridgeCommand{domain.CopyGameClientBridgeCommand(core.command)}, nil
|
||||
}
|
||||
|
||||
func (core *gameClientBridgeCompanionCore) AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||
return domain.CopyGameClientBridgeCommand(core.command), nil
|
||||
}
|
||||
|
||||
func (core *gameClientBridgeCompanionCore) CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||
value := domain.CopyGameClientBridgeCommand(core.command)
|
||||
value.State = domain.GameClientBridgeCommandSucceeded
|
||||
value.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultSucceeded, Summary: "done", CompletedBy: "internal-session", CompletedAt: time.Now().UTC()}
|
||||
value.CompletedAt = value.Result.CompletedAt
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (core *gameClientBridgeCompanionCore) UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
|
||||
return domain.CopyGameClientBridgeSnapshot(core.snapshot), nil
|
||||
}
|
||||
|
||||
func TestGameClientBridgeOperatorRoutes(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
coreService := service.NewCoreService(store)
|
||||
if err := coreService.SeedLocalPlatformAdmin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin := validGamePluginRequest().ToDomain()
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read")
|
||||
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}}
|
||||
if _, err := coreService.CreateGamePlugin(plugin); err != nil {
|
||||
t.Fatalf("create bridge plugin: %v", err)
|
||||
}
|
||||
endpoint := validRunEndpointRequest().ToDomain()
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
if _, err := coreService.CreateRunEndpoint(endpoint); err != nil {
|
||||
t.Fatalf("create run endpoint: %v", err)
|
||||
}
|
||||
router := NewTestRouterWithCore(coreService)
|
||||
adminSession := createAdminSession(t, router)
|
||||
if _, err := coreService.CreateServerInstanceForSession(adminSession, domain.ServerInstance{ID: "server-bridge", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Bridge Server"}); err != nil {
|
||||
t.Fatalf("create bridge server: %v", err)
|
||||
}
|
||||
|
||||
status := getJSONWithAuth[dto.GameClientBridgeStatusResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge", adminSession)
|
||||
if status.ServerInstanceID != "server-bridge" || status.PluginID != "server.scum" || status.Available || status.Profiles == nil {
|
||||
t.Fatalf("unexpected bridge status: %#v", status)
|
||||
}
|
||||
if len(status.Profiles) != 1 || len(status.Profiles[0].QueryTemplateKeys) != 1 || status.Profiles[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
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)}
|
||||
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)
|
||||
if queued.ID == "" || queued.CommandType != queue.CommandType || queued.State != "pending" {
|
||||
t.Fatalf("unexpected queued command: %#v", queued)
|
||||
}
|
||||
undeclared := queue
|
||||
undeclared.CommandType = "undeclared.command"
|
||||
undeclared.IdempotencyKey = "undeclared-1"
|
||||
undeclaredRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", undeclared, adminSession)
|
||||
assertErrorResponse(t, undeclaredRecorder, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
commands := getJSONWithAuth[dto.GameClientBridgeCommandListResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/commands?state=pending", adminSession)
|
||||
if commands.Count != 1 || commands.Items[0].ID != queued.ID {
|
||||
t.Fatalf("unexpected command list: %#v", commands)
|
||||
}
|
||||
detail := getJSONWithAuth[dto.GameClientBridgeCommandResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/commands/"+queued.ID, adminSession)
|
||||
if detail.ID != queued.ID {
|
||||
t.Fatalf("unexpected command detail: %#v", detail)
|
||||
}
|
||||
|
||||
cancelRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands/"+queued.ID+"/cancel", dto.GameClientBridgeCancelRequest{Reason: "operator requested"}, adminSession)
|
||||
assertStatus(t, cancelRecorder, http.StatusOK)
|
||||
cancelled := decodeBody[dto.GameClientBridgeCancelResponse](t, cancelRecorder)
|
||||
if cancelled.State != "cancelled" || cancelled.CommandID != queued.ID {
|
||||
t.Fatalf("unexpected cancellation: %#v", cancelled)
|
||||
}
|
||||
|
||||
snapshots := getJSONWithAuth[dto.GameClientBridgeSnapshotListResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/snapshots?limit=20", adminSession)
|
||||
if snapshots.Count != 0 || snapshots.Items == nil {
|
||||
t.Fatalf("unexpected snapshot list: %#v", snapshots)
|
||||
}
|
||||
|
||||
for _, serialized := range []string{queued.ResultSummary, status.Reason} {
|
||||
if strings.Contains(strings.ToLower(serialized), "token") || strings.Contains(strings.ToLower(serialized), "secret") {
|
||||
t.Fatalf("safe operator response leaked sensitive text: %q", serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeCompanionRoutesAreComponentSessionMediated(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
core := &gameClientBridgeCompanionCore{
|
||||
Core: service.NewCoreService(repo.NewMemoryStore()),
|
||||
command: domain.GameClientBridgeCommand{ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, State: domain.GameClientBridgeCommandClaimed, Claim: domain.GameClientBridgeClaim{SessionID: "internal-session-secret", InstallationID: "internal-installation", FencingToken: 9, ClaimedAt: now, LeaseExpiresAt: now.Add(time.Minute)}, ExpiresAt: now.Add(time.Hour)},
|
||||
snapshot: domain.GameClientBridgeSnapshot{ID: "snapshot-1", ProfileKey: "scum-client", Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, SourceSessionID: "internal-source-session", CreatedAt: now, ExpiresAt: now.Add(time.Hour)},
|
||||
}
|
||||
router := NewAuthorizedRouterWithCore(core)
|
||||
|
||||
claim := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/claim", dto.GameClientBridgeClaimRequest{SessionToken: "component-token", Limit: 5})
|
||||
assertStatus(t, claim, http.StatusOK)
|
||||
if strings.Contains(claim.Body.String(), "internal-session-secret") || strings.Contains(claim.Body.String(), "internal-installation") {
|
||||
t.Fatalf("claim response leaked component identity: %s", claim.Body.String())
|
||||
}
|
||||
claimed := decodeBody[dto.GameClientBridgeClaimResponse](t, claim)
|
||||
if claimed.Count != 1 || claimed.Items[0].FencingToken != 9 {
|
||||
t.Fatalf("unexpected claim response: %#v", claimed)
|
||||
}
|
||||
|
||||
ack := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/command-1/ack", dto.GameClientBridgeAckRequest{SessionToken: "component-token", FencingToken: 9})
|
||||
assertStatus(t, ack, http.StatusOK)
|
||||
result := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/command-1/result", dto.GameClientBridgeResultRequest{SessionToken: "component-token", FencingToken: 9, Status: "succeeded", Summary: "done"})
|
||||
assertStatus(t, result, http.StatusOK)
|
||||
if strings.Contains(result.Body.String(), "internal-session") {
|
||||
t.Fatalf("result response leaked completing session: %s", result.Body.String())
|
||||
}
|
||||
|
||||
snapshotRequest := dto.GameClientBridgeSnapshotIngestRequest{SessionToken: "component-token", Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: now, Payload: map[string]any{"healthy": true}, KeepForSeconds: 3600}
|
||||
snapshot := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/snapshots", snapshotRequest)
|
||||
assertStatus(t, snapshot, http.StatusAccepted)
|
||||
if strings.Contains(snapshot.Body.String(), "internal-source-session") || strings.Contains(snapshot.Body.String(), "component-token") {
|
||||
t.Fatalf("snapshot response leaked component material: %s", snapshot.Body.String())
|
||||
}
|
||||
diagnostic := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/diagnostics", snapshotRequest)
|
||||
assertStatus(t, diagnostic, http.StatusAccepted)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
@@ -60,11 +61,18 @@ func writeMethodNotAllowed(w http.ResponseWriter, allow string) {
|
||||
|
||||
func writeServiceError(w http.ResponseWriter, err error) {
|
||||
var validationErr validator.ValidationError
|
||||
var forbiddenErr service.ForbiddenError
|
||||
switch {
|
||||
case errors.As(err, &validationErr):
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", validationErr.Violations)
|
||||
case errors.Is(err, service.ErrUnauthorized):
|
||||
writeAPIError(w, http.StatusUnauthorized, errorCodeUnauthorized, "authentication required", nil)
|
||||
case errors.As(err, &forbiddenErr):
|
||||
message := strings.TrimSpace(forbiddenErr.Reason)
|
||||
if message == "" {
|
||||
message = "account is not allowed to access this resource"
|
||||
}
|
||||
writeAPIError(w, http.StatusForbidden, errorCodeForbidden, message, nil)
|
||||
case errors.Is(err, service.ErrForbidden):
|
||||
writeAPIError(w, http.StatusForbidden, errorCodeForbidden, "account is not allowed to access this resource", nil)
|
||||
case errors.Is(err, repo.ErrDuplicate):
|
||||
|
||||
@@ -47,6 +47,16 @@ 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)
|
||||
mux.HandleFunc("/api/v1/ai/config-diffs/{id}/approve", h.aiConfigDiffApprove)
|
||||
mux.HandleFunc("/api/v1/metrics/server-instances/history", h.metricHistory)
|
||||
mux.HandleFunc("/api/v1/run/metrics/batches", h.requireRunSignature(h.runMetricBatchIngest))
|
||||
mux.HandleFunc("/api/v1/backups", h.backups)
|
||||
@@ -74,6 +84,11 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/retry", h.serverClientManagerRetry)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/revoke-session", h.serverClientManagerRevokeSession)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/uninstall", h.serverClientManagerUninstall)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge", h.serverGameClientBridgeStatus)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands", h.serverGameClientBridgeCommands)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}/cancel", h.serverGameClientBridgeCommandCancel)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}", h.serverGameClientBridgeCommandDetail)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
||||
@@ -123,6 +138,290 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
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)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/{commandId}/ack", h.gameClientBridgeCompanionAck)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/{commandId}/result", h.gameClientBridgeCompanionResult)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/snapshots", h.gameClientBridgeCompanionSnapshot)
|
||||
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.
|
||||
// @Tags production-operations
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.PluginLifecycleListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/plugin-lifecycles [get]
|
||||
func (h *coreHandlers) pluginLifecycles(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListPluginLifecyclesForSession(bearerToken(r), domain.PluginLifecycleFilter{PluginID: r.URL.Query().Get("pluginId"), ServerInstanceID: r.URL.Query().Get("serverInstanceId"), CurrentState: domain.PluginLifecycleState(r.URL.Query().Get("currentState"))})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.PluginLifecycleListFromDomain(items))
|
||||
}
|
||||
|
||||
// pluginLifecycleAction godoc
|
||||
// @Summary Dispatch a platform-mediated plugin lifecycle action
|
||||
// @Description Runs compatibility and capacity gates before creating one durable bounded Run job.
|
||||
// @Tags production-operations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pluginId path string true "Plugin ID"
|
||||
// @Param body body dto.PluginLifecycleActionRequest true "Plugin lifecycle action"
|
||||
// @Success 202 {object} dto.PluginLifecycleActionResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/plugin-lifecycles/{pluginId}/actions [post]
|
||||
func (h *coreHandlers) pluginLifecycleAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.PluginLifecycleActionRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.RunPluginLifecycleForSession(bearerToken(r), request.ToDomain(r.PathValue("pluginId")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.PluginLifecycleResultFromDomain(result))
|
||||
}
|
||||
|
||||
// aiConfigDiffs godoc
|
||||
// @Summary List reviewable AI config diffs
|
||||
// @Description Lists persisted AI recommendations visible to the current operator without provider credentials or transport configuration.
|
||||
// @Tags production-operations
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.AIConfigDiffListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai/config-diffs [get]
|
||||
func (h *coreHandlers) aiConfigDiffs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListAIConfigDiffsForSession(bearerToken(r), domain.AIConfigDiffFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), PluginID: r.URL.Query().Get("pluginId"), State: domain.AIConfigDiffState(r.URL.Query().Get("state"))})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.AIConfigDiffListFromDomain(items))
|
||||
}
|
||||
|
||||
// aiConfigDiffApprove godoc
|
||||
// @Summary Approve one reviewable AI config diff
|
||||
// @Description Revalidates actor/server/config revision fences before dispatching one bounded config write job.
|
||||
// @Tags production-operations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "AI config diff ID"
|
||||
// @Param body body dto.AIConfigDiffApprovalRequest true "AI config diff approval"
|
||||
// @Success 202 {object} dto.AIConfigDiffApprovalResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai/config-diffs/{id}/approve [post]
|
||||
func (h *coreHandlers) aiConfigDiffApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.AIConfigDiffApprovalRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.ApproveAIConfigDiffForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.AIConfigDiffApprovalFromDomain(result))
|
||||
}
|
||||
|
||||
// authRegister godoc
|
||||
@@ -554,7 +853,7 @@ func (h *coreHandlers) aiProviders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// aiProviderDetail godoc
|
||||
// @Summary Get AI provider
|
||||
// @Description Returns one platform-managed AI provider by ID without raw key material.
|
||||
// @Description Returns one platform-managed AI provider by ID without raw key or base URL material.
|
||||
// @Tags ai-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "AI provider ID"
|
||||
@@ -589,6 +888,9 @@ func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
update := request.ToDomain(r.PathValue("id"), existing.Status)
|
||||
if strings.TrimSpace(update.BaseURL) == "" {
|
||||
update.BaseURL = existing.BaseURL
|
||||
}
|
||||
if strings.TrimSpace(update.APIKeyRef) == "" {
|
||||
update.APIKeyRef = existing.APIKeyRef
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -391,6 +392,37 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreAPIRunDistributionDenialNamesMissingPluginPermission(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest())
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-plugin-denied",
|
||||
PluginID: "game.example",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "Plugin Denied",
|
||||
}, adminSession)
|
||||
|
||||
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+server.ID+"/run/generate", dto.RunDistributionGenerateRequest{
|
||||
TargetOS: "linux",
|
||||
TargetArch: "amd64",
|
||||
IdempotencyKey: "missing-plugin-permission",
|
||||
}, adminSession)
|
||||
assertStatus(t, recorder, http.StatusForbidden)
|
||||
body := decodeBody[dto.ErrorResponse](t, recorder)
|
||||
if body.Code != errorCodeForbidden || body.Message != "plugin does not declare required permission: server.run.distribution" {
|
||||
t.Fatalf("expected plugin permission denial, got %+v", body)
|
||||
}
|
||||
|
||||
dependenciesRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/dependencies", "", adminSession)
|
||||
assertStatus(t, dependenciesRecorder, http.StatusForbidden)
|
||||
dependenciesBody := decodeBody[dto.ErrorResponse](t, dependenciesRecorder)
|
||||
if dependenciesBody.Code != errorCodeForbidden || dependenciesBody.Message != "plugin does not declare required permission: server.dependencies.manage" {
|
||||
t.Fatalf("expected dependency permission denial, got %+v", dependenciesBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreAPIErrorResponses(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
@@ -817,7 +849,7 @@ func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
|
||||
assertErrorResponse(t, forbiddenDetail, http.StatusForbidden, errorCodeForbidden)
|
||||
}
|
||||
|
||||
func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
|
||||
func TestAIProviderAPIResponseDoesNotExposeRawSecretFields(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest(), adminSession)
|
||||
@@ -836,6 +868,9 @@ func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
|
||||
if _, exists := body["apiKeyRef"]; exists || body["apiKeyConfigured"] != true {
|
||||
t.Fatalf("expected API key presence only, got %+v", body)
|
||||
}
|
||||
if _, exists := body["baseUrl"]; exists || body["baseUrlConfigured"] != true {
|
||||
t.Fatalf("expected base URL presence only, got %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIProviderManagementAPI(t *testing.T) {
|
||||
@@ -844,13 +879,13 @@ func TestAIProviderManagementAPI(t *testing.T) {
|
||||
createAIProviderFixture(t, router, adminSession)
|
||||
|
||||
update := validAIProviderUpdateRequest()
|
||||
update.BaseURL = ""
|
||||
updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession)
|
||||
assertStatus(t, updatedRecorder, http.StatusOK)
|
||||
updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder)
|
||||
if updated.Name != "OpenAI Relay" || !updated.APIKeyConfigured || updated.Status != domain.AIProviderStatusActive {
|
||||
if updated.Name != "OpenAI Relay" || !updated.BaseURLConfigured || !updated.APIKeyConfigured || updated.Status != domain.AIProviderStatusActive {
|
||||
t.Fatalf("unexpected updated provider: %+v", updated)
|
||||
}
|
||||
|
||||
statusRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession)
|
||||
assertStatus(t, statusRecorder, http.StatusOK)
|
||||
disabled := decodeBody[dto.AIProviderResponse](t, statusRecorder)
|
||||
@@ -861,7 +896,7 @@ func TestAIProviderManagementAPI(t *testing.T) {
|
||||
testRecorder := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession)
|
||||
assertStatus(t, testRecorder, http.StatusOK)
|
||||
testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder)
|
||||
if testResult.Success || testResult.Mode != "metadata" {
|
||||
if testResult.Success || testResult.Mode != "provider" {
|
||||
t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult)
|
||||
}
|
||||
|
||||
@@ -1408,6 +1443,56 @@ func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionOperationsGovernanceRoutesAreDurableAndRedacted(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)
|
||||
}
|
||||
decision := postOKJSONWithAuth[dto.CapacityAdmissionDecisionResponse](t, router, "/api/v1/production/capacity/admission", dto.CapacityAdmissionRequest{ServerInstanceID: serverID, Capability: domain.JobCapabilityConfigWrite, IdempotencyKey: "api-capacity-check"}, adminSession)
|
||||
if decision.Accepted || decision.AlertID == "" || decision.AuditEventID == "" {
|
||||
t.Fatalf("expected stale endpoint 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)
|
||||
}
|
||||
|
||||
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)
|
||||
if invocation.ConfigRecommendation == nil || invocation.ConfigRecommendation.DiffID == "" {
|
||||
t.Fatalf("expected persisted AI config diff, got %+v", invocation)
|
||||
}
|
||||
diffs := getJSONWithAuth[dto.AIConfigDiffListResponse](t, router, "/api/v1/ai/config-diffs?serverInstanceId="+serverID, adminSession)
|
||||
if diffs.Count != 1 || diffs.Items[0].State != string(domain.AIConfigDiffStatePending) {
|
||||
t.Fatalf("expected one pending AI diff, got %+v", diffs)
|
||||
}
|
||||
approvalRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-diffs/"+invocation.ConfigRecommendation.DiffID+"/approve", dto.AIConfigDiffApprovalRequest{IdempotencyKey: "api-ai-diff-approve"}, adminSession)
|
||||
assertStatus(t, approvalRecorder, http.StatusAccepted)
|
||||
approval := decodeBody[dto.AIConfigDiffApprovalResponse](t, approvalRecorder)
|
||||
if approval.Preview.State != string(domain.AIConfigDiffStateApproved) || approval.Dispatch.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRouter() http.Handler {
|
||||
core := service.NewCoreService(repo.NewMemoryStore())
|
||||
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
||||
@@ -1613,6 +1698,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
||||
"process.start",
|
||||
"process.stop",
|
||||
"logs.read",
|
||||
domain.JobCapabilityConfigWrite,
|
||||
domain.JobCapabilityRunSelfUpdate,
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
@@ -1771,8 +1857,9 @@ func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistra
|
||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||
},
|
||||
},
|
||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}},
|
||||
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||
ProductionLifecycle: dto.GamePluginProductionLifecycleBody{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
||||
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ func NewRouterFromConfig(cfg config.Config) (http.Handler, error) {
|
||||
if err := core.ConfigureSecretEnvelopeKey(cfg.SecretEnvelopeKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := core.ConfigureAIProviderMode(cfg.AIProviderMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.BootstrapAdminPassword) != "" {
|
||||
if err := core.SeedPlatformAdmin(cfg.BootstrapAdminEmail, cfg.BootstrapAdminPassword); err != nil {
|
||||
return nil, err
|
||||
|
||||
+20
-2
@@ -25,6 +25,8 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
||||
| 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.
|
||||
|
||||
## Implemented Query Filters
|
||||
|
||||
- `GET /api/v1/users?status=active`
|
||||
@@ -83,12 +85,13 @@ Config write and file dispatch responses expose only logical target keys, scoped
|
||||
## Implemented AI Provider Management Actions
|
||||
|
||||
- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`.
|
||||
- `POST /api/v1/ai-providers/{id}/test`: run local metadata validation using `AIProviderTestResponse`; this does not call external AI services.
|
||||
- `POST /api/v1/ai-providers/{id}/test`: invoke the configured provider client with a bounded health request and return only a redacted `AIProviderTestResponse`.
|
||||
- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`.
|
||||
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional reviewable config recommendation, and safe errors.
|
||||
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` for the existing review/approval workflow.
|
||||
|
||||
AI invocation is platform-mediated. Tests and local verification use a deterministic mock provider client; live external provider calls are deferred behind the same interface and are not required for this change. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions are recommendations only and never dispatch run-side writes directly.
|
||||
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.
|
||||
|
||||
@@ -108,6 +111,21 @@ Marketplace responses include game management plugin identity, version, display
|
||||
|
||||
Marketplace state actions are metadata-only in this change. `install` and `enable` mark the registered plugin `installed`; `disable` marks it `disabled`. These actions do not download external packages, create run jobs, execute plugin code, write files, or contact external services.
|
||||
|
||||
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
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
## Implemented Plugin Bridge Actions
|
||||
|
||||
- `POST /api/v1/plugin-bridge/authorize`: accepts `PluginBridgeAuthorizeRequest` and returns whether an installed plugin page may use one declared bridge action with the effective route permissions.
|
||||
|
||||
@@ -23,6 +23,7 @@ type Config struct {
|
||||
BootstrapAdminEmail string
|
||||
BootstrapAdminPassword string
|
||||
SecretEnvelopeKey string
|
||||
AIProviderMode string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -66,9 +67,17 @@ func Load() Config {
|
||||
BootstrapAdminEmail: strings.TrimSpace(os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL")),
|
||||
BootstrapAdminPassword: os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
SecretEnvelopeKey: os.Getenv("PLATFORM_SECRET_ENVELOPE_KEY"),
|
||||
AIProviderMode: defaultString(strings.TrimSpace(os.Getenv("PLATFORM_AI_PROVIDER_MODE")), "live"),
|
||||
}
|
||||
}
|
||||
|
||||
func defaultString(value, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func loadLocalEnvFiles() {
|
||||
candidates := []string{".env", filepath.Join("platform", ".env")}
|
||||
for _, path := range candidates {
|
||||
|
||||
@@ -25,6 +25,8 @@ type AIConfigRecommendation struct {
|
||||
Key string
|
||||
SuggestedConfig string
|
||||
DiffSummary string
|
||||
DiffID string
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
type AIInvocationSafeError struct {
|
||||
|
||||
@@ -52,6 +52,8 @@ const (
|
||||
JobCapabilityClientManagerUninstall = "client-manager.uninstall"
|
||||
)
|
||||
|
||||
const ClientManagerCompanionConfigSchemaVersion = 1
|
||||
|
||||
type ClientManagerSessionStatus string
|
||||
|
||||
const (
|
||||
@@ -246,6 +248,37 @@ type ClientManagerLifecycleInput struct {
|
||||
StopTimeoutSeconds int
|
||||
HealthConfirmationSeconds int
|
||||
IdempotencyKey string
|
||||
CompanionConfig *ClientManagerCompanionConfigInput
|
||||
}
|
||||
|
||||
type ClientManagerCompanionConfigInput struct {
|
||||
SchemaVersion int
|
||||
ConfigTemplateKey string
|
||||
ConfigTemplateRef string
|
||||
ConfigOutputRef string
|
||||
ConfigSchemaRef string
|
||||
ConfigFormat string
|
||||
PlatformBaseURLSource string
|
||||
InstallationID string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
ArtifactID string
|
||||
Version string
|
||||
SourceRevision string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
KeyGeneration int
|
||||
DeploymentGeneration int
|
||||
Capabilities []string
|
||||
RegistrationProof string
|
||||
ProofMaterialSource string
|
||||
ProofMaterialEnv string
|
||||
SessionMode string
|
||||
TLSPolicy string
|
||||
HeartbeatIntervalSeconds int
|
||||
CommandPollIntervalSeconds int
|
||||
RequestTimeoutSeconds int
|
||||
}
|
||||
|
||||
type ClientManagerRegisterRequest struct {
|
||||
@@ -336,6 +369,15 @@ func CopyClientManagerLifecycleView(value ClientManagerLifecycleView) ClientMana
|
||||
|
||||
func CopyClientManagerLifecycleInput(value ClientManagerLifecycleInput) ClientManagerLifecycleInput {
|
||||
value.Arguments = CopyStringSlice(value.Arguments)
|
||||
if value.CompanionConfig != nil {
|
||||
companion := CopyClientManagerCompanionConfigInput(*value.CompanionConfig)
|
||||
value.CompanionConfig = &companion
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyClientManagerCompanionConfigInput(value ClientManagerCompanionConfigInput) ClientManagerCompanionConfigInput {
|
||||
value.Capabilities = CopyStringSlice(value.Capabilities)
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCopyClientManagerLifecycleInputDeepCopiesCompanionCapabilities(t *testing.T) {
|
||||
original := ClientManagerLifecycleInput{
|
||||
Arguments: []string{"--foreground"},
|
||||
CompanionConfig: &ClientManagerCompanionConfigInput{
|
||||
Capabilities: []string{"component.register", "game-client.bridge"},
|
||||
},
|
||||
}
|
||||
cloned := CopyClientManagerLifecycleInput(original)
|
||||
cloned.Arguments[0] = "--changed"
|
||||
cloned.CompanionConfig.Capabilities[0] = "changed"
|
||||
|
||||
if original.Arguments[0] != "--foreground" {
|
||||
t.Fatalf("arguments were not deeply copied: %+v", original.Arguments)
|
||||
}
|
||||
if original.CompanionConfig.Capabilities[0] != "component.register" {
|
||||
t.Fatalf("companion capabilities were not deeply copied: %+v", original.CompanionConfig.Capabilities)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type GameClientBridgeCommandState string
|
||||
|
||||
const (
|
||||
GameClientBridgeCommandPending GameClientBridgeCommandState = "pending"
|
||||
GameClientBridgeCommandClaimed GameClientBridgeCommandState = "claimed"
|
||||
GameClientBridgeCommandSucceeded GameClientBridgeCommandState = "succeeded"
|
||||
GameClientBridgeCommandFailed GameClientBridgeCommandState = "failed"
|
||||
GameClientBridgeCommandCancelled GameClientBridgeCommandState = "cancelled"
|
||||
GameClientBridgeCommandExpired GameClientBridgeCommandState = "expired"
|
||||
)
|
||||
|
||||
type GameClientBridgeApprovalState string
|
||||
|
||||
const (
|
||||
GameClientBridgeApprovalNotRequired GameClientBridgeApprovalState = "not_required"
|
||||
GameClientBridgeApprovalPending GameClientBridgeApprovalState = "pending"
|
||||
GameClientBridgeApprovalApproved GameClientBridgeApprovalState = "approved"
|
||||
GameClientBridgeApprovalRejected GameClientBridgeApprovalState = "rejected"
|
||||
)
|
||||
|
||||
type GameClientBridgeApprovalLevel string
|
||||
|
||||
const (
|
||||
GameClientBridgeApprovalLevelNone GameClientBridgeApprovalLevel = "none"
|
||||
GameClientBridgeApprovalLevelOperator GameClientBridgeApprovalLevel = "operator"
|
||||
GameClientBridgeApprovalLevelPlatformAdmin GameClientBridgeApprovalLevel = "platform-admin"
|
||||
)
|
||||
|
||||
type GameClientBridgeCommandDeclaration struct {
|
||||
Type string
|
||||
Title string
|
||||
Permission string
|
||||
ApprovalLevel GameClientBridgeApprovalLevel
|
||||
PayloadSchemaRef string
|
||||
ResultSchemaRef string
|
||||
TimeoutSeconds int
|
||||
MaxPayloadBytes int
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotDeclaration struct {
|
||||
Type string
|
||||
SchemaVersion string
|
||||
SchemaRef string
|
||||
Retention GameClientBridgeRetention
|
||||
}
|
||||
|
||||
type GameClientBridgeQueryTemplateDeclaration struct {
|
||||
Key string
|
||||
Title string
|
||||
Permission string
|
||||
Engine string
|
||||
TransportKey string
|
||||
TargetKey string
|
||||
ParameterSchemaRef string
|
||||
ResultSchemaRef string
|
||||
MaxRows int
|
||||
TimeoutSeconds int
|
||||
}
|
||||
|
||||
type GameClientBridgePageContract struct {
|
||||
PageKey string
|
||||
CommandTypes []string
|
||||
SnapshotTypes []string
|
||||
QueryTemplateKeys []string
|
||||
}
|
||||
|
||||
type GameClientBridgeCompanionDeclaration struct {
|
||||
ProfileKey string
|
||||
ConfigTemplateKey string
|
||||
ConfigSchemaRef string
|
||||
ConfigFormat string
|
||||
PlatformBaseURLSource string
|
||||
RegistrationProof string
|
||||
ProofMaterialSource string
|
||||
ProofMaterialEnv string
|
||||
SessionMode string
|
||||
TLSPolicy string
|
||||
HeartbeatIntervalSeconds int
|
||||
CommandPollIntervalSeconds int
|
||||
RequestTimeoutSeconds int
|
||||
}
|
||||
|
||||
type GameClientBridgeManifest struct {
|
||||
Commands []GameClientBridgeCommandDeclaration
|
||||
Snapshots []GameClientBridgeSnapshotDeclaration
|
||||
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
|
||||
Retention GameClientBridgeRetention
|
||||
Pages []GameClientBridgePageContract
|
||||
Companion GameClientBridgeCompanionDeclaration
|
||||
}
|
||||
|
||||
type GameClientBridgeResultStatus string
|
||||
|
||||
const (
|
||||
GameClientBridgeResultSucceeded GameClientBridgeResultStatus = "succeeded"
|
||||
GameClientBridgeResultFailed GameClientBridgeResultStatus = "failed"
|
||||
GameClientBridgeResultCancelled GameClientBridgeResultStatus = "cancelled"
|
||||
)
|
||||
|
||||
type GameClientBridgeCommand struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
CommandType string
|
||||
Payload map[string]any
|
||||
IdempotencyKey string
|
||||
Priority int
|
||||
State GameClientBridgeCommandState
|
||||
ApprovalState GameClientBridgeApprovalState
|
||||
RequesterID string
|
||||
Claim GameClientBridgeClaim
|
||||
Cancellation GameClientBridgeCancellation
|
||||
Result GameClientBridgeResult
|
||||
AuditReferences []string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
type GameClientBridgeClaim struct {
|
||||
SessionID string
|
||||
InstallationID string
|
||||
DeploymentGeneration int
|
||||
FencingToken uint64
|
||||
LeaseExpiresAt time.Time
|
||||
ClaimedAt time.Time
|
||||
AcknowledgedAt time.Time
|
||||
}
|
||||
|
||||
type GameClientBridgeCancellation struct {
|
||||
RequestedBy string
|
||||
Reason string
|
||||
CancelledAt time.Time
|
||||
}
|
||||
|
||||
type GameClientBridgeResult struct {
|
||||
Status GameClientBridgeResultStatus
|
||||
Summary string
|
||||
Payload map[string]any
|
||||
CompletedBy string
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshot struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
Type string
|
||||
SchemaVersion string
|
||||
StreamKey string
|
||||
Sequence uint64
|
||||
SourceSessionID string
|
||||
ObservedAt time.Time
|
||||
Payload map[string]any
|
||||
Retention GameClientBridgeRetention
|
||||
AuditReferences []string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotStream struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
Type string
|
||||
StreamKey string
|
||||
LatestSequence uint64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type GameClientBridgeRetention struct {
|
||||
KeepForSeconds int
|
||||
MaxRecords int
|
||||
}
|
||||
|
||||
type GameClientBridgeAuditReference struct {
|
||||
ID string
|
||||
CommandID string
|
||||
SnapshotID string
|
||||
AuditEventID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type GameClientBridgeCommandFilter struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
State GameClientBridgeCommandState
|
||||
RequesterID string
|
||||
CommandType string
|
||||
IdempotencyKey string
|
||||
ExpiresBefore time.Time
|
||||
CompletedBefore time.Time
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotStreamFilter struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
Type string
|
||||
StreamKey string
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotFilter struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
Type string
|
||||
StreamKey string
|
||||
ObservedAfter time.Time
|
||||
ExpiresBefore time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type GameClientBridgeQueueRequest struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
CommandType string
|
||||
Payload map[string]any
|
||||
IdempotencyKey string
|
||||
Priority int
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type GameClientBridgeClaimRequest struct {
|
||||
SessionToken string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type GameClientBridgeAckRequest struct {
|
||||
SessionToken string
|
||||
CommandID string
|
||||
FencingToken uint64
|
||||
}
|
||||
|
||||
type GameClientBridgeResultRequest struct {
|
||||
SessionToken string
|
||||
CommandID string
|
||||
FencingToken uint64
|
||||
Status GameClientBridgeResultStatus
|
||||
Summary string
|
||||
Payload map[string]any
|
||||
}
|
||||
|
||||
type GameClientBridgeCancelRequest struct {
|
||||
CommandID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotIngestRequest struct {
|
||||
SessionToken string
|
||||
Type string
|
||||
SchemaVersion string
|
||||
StreamKey string
|
||||
Sequence uint64
|
||||
ObservedAt time.Time
|
||||
Payload map[string]any
|
||||
Retention GameClientBridgeRetention
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotQuery struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
Type string
|
||||
StreamKey string
|
||||
ObservedAfter time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type GameClientBridgeProfileDeclaration struct {
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
Available bool
|
||||
Reason string
|
||||
CommandTypes []string
|
||||
SnapshotTypes []string
|
||||
QueryTemplateKeys []string
|
||||
}
|
||||
|
||||
type GameClientBridgeStatus struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
Available bool
|
||||
Reason string
|
||||
Profiles []GameClientBridgeProfileDeclaration
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeProfileDeclaration(value GameClientBridgeProfileDeclaration) GameClientBridgeProfileDeclaration {
|
||||
value.CommandTypes = CopyStringSlice(value.CommandTypes)
|
||||
value.SnapshotTypes = CopyStringSlice(value.SnapshotTypes)
|
||||
value.QueryTemplateKeys = CopyStringSlice(value.QueryTemplateKeys)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeStatus(value GameClientBridgeStatus) GameClientBridgeStatus {
|
||||
value.Profiles = append([]GameClientBridgeProfileDeclaration(nil), value.Profiles...)
|
||||
for index := range value.Profiles {
|
||||
value.Profiles[index] = CopyGameClientBridgeProfileDeclaration(value.Profiles[index])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeCommand(value GameClientBridgeCommand) GameClientBridgeCommand {
|
||||
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
||||
value.Claim = CopyGameClientBridgeClaim(value.Claim)
|
||||
value.Cancellation = CopyGameClientBridgeCancellation(value.Cancellation)
|
||||
value.Result = CopyGameClientBridgeResult(value.Result)
|
||||
value.AuditReferences = CopyStringSlice(value.AuditReferences)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeClaim(value GameClientBridgeClaim) GameClientBridgeClaim { return value }
|
||||
|
||||
func CopyGameClientBridgeCancellation(value GameClientBridgeCancellation) GameClientBridgeCancellation {
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeResult(value GameClientBridgeResult) GameClientBridgeResult {
|
||||
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeSnapshot(value GameClientBridgeSnapshot) GameClientBridgeSnapshot {
|
||||
value.Payload = CopyGameClientBridgePayload(value.Payload)
|
||||
value.Retention = CopyGameClientBridgeRetention(value.Retention)
|
||||
value.AuditReferences = CopyStringSlice(value.AuditReferences)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeSnapshotStream(value GameClientBridgeSnapshotStream) GameClientBridgeSnapshotStream {
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeRetention(value GameClientBridgeRetention) GameClientBridgeRetention {
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeAuditReference(value GameClientBridgeAuditReference) GameClientBridgeAuditReference {
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgePayload(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := make(map[string]any, len(value))
|
||||
for key, item := range value {
|
||||
copy[key] = copyGameClientBridgePayloadValue(item)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest {
|
||||
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
|
||||
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
||||
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
||||
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
|
||||
for index := range value.Pages {
|
||||
value.Pages[index].CommandTypes = CopyStringSlice(value.Pages[index].CommandTypes)
|
||||
value.Pages[index].SnapshotTypes = CopyStringSlice(value.Pages[index].SnapshotTypes)
|
||||
value.Pages[index].QueryTemplateKeys = CopyStringSlice(value.Pages[index].QueryTemplateKeys)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func copyGameClientBridgePayloadValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return CopyGameClientBridgePayload(typed)
|
||||
case []any:
|
||||
copy := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
copy[index] = copyGameClientBridgePayloadValue(item)
|
||||
}
|
||||
return copy
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
|
||||
manifest := GameClientBridgeManifest{
|
||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup"}},
|
||||
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||
}
|
||||
manifestCopy := CopyGameClientBridgeManifest(manifest)
|
||||
manifestCopy.QueryTemplates[0].Key = "mutated"
|
||||
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
|
||||
}
|
||||
|
||||
status := GameClientBridgeStatus{Profiles: []GameClientBridgeProfileDeclaration{{QueryTemplateKeys: []string{"player.lookup"}}}}
|
||||
statusCopy := CopyGameClientBridgeStatus(status)
|
||||
statusCopy.Profiles[0].QueryTemplateKeys[0] = "mutated"
|
||||
if status.Profiles[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatalf("status copy aliases query template keys: source=%#v copy=%#v", status, statusCopy)
|
||||
}
|
||||
}
|
||||
@@ -276,6 +276,7 @@ type RunJobReconcileResult struct {
|
||||
}
|
||||
|
||||
func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment {
|
||||
assignment.ExecutionInput.Inputs = CopyStringMap(assignment.ExecutionInput.Inputs)
|
||||
return assignment
|
||||
}
|
||||
|
||||
@@ -315,7 +316,9 @@ func CopyRunJobAssignments(assignments []RunJobAssignment) []RunJobAssignment {
|
||||
return nil
|
||||
}
|
||||
out := make([]RunJobAssignment, len(assignments))
|
||||
copy(out, assignments)
|
||||
for index, assignment := range assignments {
|
||||
out[index] = CopyRunJobAssignment(assignment)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ type RemoteAdapterRequest struct {
|
||||
TimeoutSeconds int
|
||||
MaxAttempts int
|
||||
IdempotencyKey string
|
||||
InputRef string
|
||||
Inputs map[string]string
|
||||
}
|
||||
|
||||
type RemoteAdapterResult struct {
|
||||
@@ -181,5 +183,8 @@ func CopyRemoteAdapterDeclarations(declarations []RemoteAdapterDeclaration) []Re
|
||||
return out
|
||||
}
|
||||
|
||||
func CopyRemoteAdapterRequest(request RemoteAdapterRequest) RemoteAdapterRequest { return request }
|
||||
func CopyRemoteAdapterResult(result RemoteAdapterResult) RemoteAdapterResult { return result }
|
||||
func CopyRemoteAdapterRequest(request RemoteAdapterRequest) RemoteAdapterRequest {
|
||||
request.Inputs = CopyStringMap(request.Inputs)
|
||||
return request
|
||||
}
|
||||
func CopyRemoteAdapterResult(result RemoteAdapterResult) RemoteAdapterResult { return result }
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
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 (
|
||||
PluginLifecycleStatePending PluginLifecycleState = "pending"
|
||||
PluginLifecycleStateInstalled PluginLifecycleState = "installed"
|
||||
PluginLifecycleStateEnabled PluginLifecycleState = "enabled"
|
||||
PluginLifecycleStateDisabled PluginLifecycleState = "disabled"
|
||||
PluginLifecycleStateUpgrading PluginLifecycleState = "upgrading"
|
||||
PluginLifecycleStateRollingBack PluginLifecycleState = "rolling_back"
|
||||
PluginLifecycleStateRetired PluginLifecycleState = "retired"
|
||||
PluginLifecycleStateFailed PluginLifecycleState = "failed"
|
||||
)
|
||||
|
||||
type PluginLifecycleOperation string
|
||||
|
||||
const (
|
||||
PluginLifecycleOperationInstall PluginLifecycleOperation = "install"
|
||||
PluginLifecycleOperationEnable PluginLifecycleOperation = "enable"
|
||||
PluginLifecycleOperationDisable PluginLifecycleOperation = "disable"
|
||||
PluginLifecycleOperationUpgrade PluginLifecycleOperation = "upgrade"
|
||||
PluginLifecycleOperationRollback PluginLifecycleOperation = "rollback"
|
||||
PluginLifecycleOperationRetire PluginLifecycleOperation = "retire"
|
||||
PluginLifecycleOperationDependencyCheck PluginLifecycleOperation = "dependency-check"
|
||||
)
|
||||
|
||||
type PluginLifecycleInstallation struct {
|
||||
ID string
|
||||
PluginID string
|
||||
ServerInstanceID string
|
||||
CurrentVersion string
|
||||
TargetVersion string
|
||||
PreviousVersion string
|
||||
DesiredState PluginLifecycleState
|
||||
CurrentState PluginLifecycleState
|
||||
LastOperation PluginLifecycleOperation
|
||||
Compatibility string
|
||||
DependencyState DependencyState
|
||||
JobID string
|
||||
AlertID string
|
||||
AuditEventID string
|
||||
FailureReason string
|
||||
IdempotencyKey string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PluginLifecycleFilter struct {
|
||||
PluginID string
|
||||
ServerInstanceID string
|
||||
CurrentState PluginLifecycleState
|
||||
}
|
||||
|
||||
type PluginLifecycleRequest struct {
|
||||
PluginID string
|
||||
ServerInstanceID string
|
||||
Operation PluginLifecycleOperation
|
||||
TargetVersion string
|
||||
IdempotencyKey string
|
||||
Confirmed bool
|
||||
}
|
||||
|
||||
type PluginLifecycleResult struct {
|
||||
Installation PluginLifecycleInstallation
|
||||
Job Job
|
||||
Decision CapacityAdmissionDecision
|
||||
Alert *AlertRecord
|
||||
Status string
|
||||
}
|
||||
|
||||
type AIConfigDiffState string
|
||||
|
||||
const (
|
||||
AIConfigDiffStatePending AIConfigDiffState = "pending"
|
||||
AIConfigDiffStateApproved AIConfigDiffState = "approved"
|
||||
AIConfigDiffStateCancelled AIConfigDiffState = "cancelled"
|
||||
AIConfigDiffStateExpired AIConfigDiffState = "expired"
|
||||
)
|
||||
|
||||
type AIConfigDiffPreview struct {
|
||||
ID string
|
||||
RequestID string
|
||||
CreatedBy string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProviderID string
|
||||
Model string
|
||||
Key string
|
||||
ConfigVersion int
|
||||
CurrentConfigChecksum string
|
||||
ProposedConfig string
|
||||
DiffSummary string
|
||||
State AIConfigDiffState
|
||||
ExpiresAt time.Time
|
||||
ApprovedBy string
|
||||
ApprovedAt time.Time
|
||||
ApprovalIdempotencyKey string
|
||||
CancelledBy string
|
||||
CancelledAt time.Time
|
||||
JobID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AIConfigDiffFilter struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
State AIConfigDiffState
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalRequest struct {
|
||||
DiffID string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalResult struct {
|
||||
Preview AIConfigDiffPreview
|
||||
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
|
||||
}
|
||||
|
||||
func CopyPluginLifecycleInstallations(installations []PluginLifecycleInstallation) []PluginLifecycleInstallation {
|
||||
if installations == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]PluginLifecycleInstallation, len(installations))
|
||||
copy(out, installations)
|
||||
return out
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func CopyAIConfigDiffPreview(preview AIConfigDiffPreview) AIConfigDiffPreview {
|
||||
return preview
|
||||
}
|
||||
|
||||
func CopyAIConfigDiffPreviews(previews []AIConfigDiffPreview) []AIConfigDiffPreview {
|
||||
if previews == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]AIConfigDiffPreview, len(previews))
|
||||
copy(out, previews)
|
||||
return out
|
||||
}
|
||||
|
||||
func CopyAIConfigDiffApprovalResult(result AIConfigDiffApprovalResult) AIConfigDiffApprovalResult {
|
||||
result.Preview = CopyAIConfigDiffPreview(result.Preview)
|
||||
result.Dispatch = CopyServerConfigWriteDispatch(result.Dispatch)
|
||||
return result
|
||||
}
|
||||
@@ -328,7 +328,15 @@ type GamePluginManifestServer struct {
|
||||
}
|
||||
|
||||
type GamePluginManifestAI struct {
|
||||
Purposes []string
|
||||
Purposes []string
|
||||
Mediation string
|
||||
ConfigWritePolicy string
|
||||
}
|
||||
|
||||
type GamePluginProductionLifecycle struct {
|
||||
Operations []string
|
||||
DependencyPolicy string
|
||||
ApprovalRequired []string
|
||||
}
|
||||
|
||||
type GamePluginRemoteAccess struct {
|
||||
@@ -398,6 +406,26 @@ type RuntimeLogSource struct {
|
||||
RetentionDays int
|
||||
}
|
||||
|
||||
type RuntimeLogEventSeverity string
|
||||
|
||||
const (
|
||||
RuntimeLogEventSeverityInfo RuntimeLogEventSeverity = "info"
|
||||
RuntimeLogEventSeverityNotice RuntimeLogEventSeverity = "notice"
|
||||
RuntimeLogEventSeverityWarning RuntimeLogEventSeverity = "warning"
|
||||
RuntimeLogEventSeverityCritical RuntimeLogEventSeverity = "critical"
|
||||
)
|
||||
|
||||
type RuntimeLogEvent struct {
|
||||
Key string
|
||||
Title string
|
||||
SourceKey string
|
||||
EventType string
|
||||
Permission string
|
||||
SchemaRef string
|
||||
RetentionDays int
|
||||
Severity RuntimeLogEventSeverity
|
||||
}
|
||||
|
||||
type RuntimeTransportProfile struct {
|
||||
Key string
|
||||
Kind string
|
||||
@@ -439,26 +467,29 @@ type GamePluginRuntimeProfiles struct {
|
||||
DependencyProbes []RuntimeDependencyProbe
|
||||
InstallPlans []RuntimeInstallPlan
|
||||
LogSources []RuntimeLogSource
|
||||
LogEvents []RuntimeLogEvent
|
||||
TransportProfiles []RuntimeTransportProfile
|
||||
ClientManagers []RuntimeClientManagerProfile
|
||||
}
|
||||
|
||||
type GamePluginManifest struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Version string
|
||||
Kind string
|
||||
Tags []string
|
||||
Server GamePluginManifestServer
|
||||
Bridge GamePluginBridge
|
||||
Capabilities []string
|
||||
Permissions []string
|
||||
Actions PluginLifecycleActions
|
||||
Pages []GamePluginPage
|
||||
AI GamePluginManifestAI
|
||||
RemoteAccess GamePluginRemoteAccess
|
||||
RuntimeProfiles GamePluginRuntimeProfiles
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Version string
|
||||
Kind string
|
||||
Tags []string
|
||||
Server GamePluginManifestServer
|
||||
Bridge GamePluginBridge
|
||||
Capabilities []string
|
||||
Permissions []string
|
||||
Actions PluginLifecycleActions
|
||||
Pages []GamePluginPage
|
||||
AI GamePluginManifestAI
|
||||
ProductionLifecycle GamePluginProductionLifecycle
|
||||
RemoteAccess GamePluginRemoteAccess
|
||||
RuntimeProfiles GamePluginRuntimeProfiles
|
||||
GameClientBridge GameClientBridgeManifest
|
||||
}
|
||||
|
||||
type GamePluginManifestRegistration struct {
|
||||
@@ -484,8 +515,10 @@ type GamePlugin struct {
|
||||
Pages []GamePluginPage
|
||||
Tags []string
|
||||
AIPurposes []string
|
||||
ProductionLifecycle GamePluginProductionLifecycle
|
||||
RemoteAccess GamePluginRemoteAccess
|
||||
RuntimeProfiles GamePluginRuntimeProfiles
|
||||
GameClientBridge GameClientBridgeManifest
|
||||
ValidationViolations []string
|
||||
Status GamePluginStatus
|
||||
}
|
||||
@@ -508,8 +541,10 @@ type PluginMarketplacePlugin struct {
|
||||
Pages []GamePluginPage
|
||||
Tags []string
|
||||
AIPurposes []string
|
||||
ProductionLifecycle GamePluginProductionLifecycle
|
||||
RemoteAccess GamePluginRemoteAccess
|
||||
RuntimeProfiles GamePluginRuntimeProfiles
|
||||
GameClientBridge GameClientBridgeManifest
|
||||
ValidationViolations []string
|
||||
Status GamePluginStatus
|
||||
Source string
|
||||
@@ -528,6 +563,7 @@ const (
|
||||
PluginBridgeActionDependenciesRequest PluginBridgeAction = "dependencies.request"
|
||||
PluginBridgeActionLogsBackfillRequest PluginBridgeAction = "logs.backfill.request"
|
||||
PluginBridgeActionClientManager PluginBridgeAction = "client-manager.request"
|
||||
PluginBridgeActionPluginLifecycle PluginBridgeAction = "plugin-lifecycle.request"
|
||||
PluginBridgeActionAIInvoke PluginBridgeAction = "ai.invoke"
|
||||
)
|
||||
|
||||
@@ -708,10 +744,13 @@ type FileOperationDispatchResult struct {
|
||||
}
|
||||
|
||||
type RunCapacity struct {
|
||||
MaxJobs int
|
||||
RunningJobs int
|
||||
QueuedJobs int
|
||||
Summary string
|
||||
MaxJobs int
|
||||
RunningJobs int
|
||||
QueuedJobs int
|
||||
LogBacklogBatches int
|
||||
ArtifactBacklogChunks int
|
||||
PressureCodes []string
|
||||
Summary string
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -761,14 +800,18 @@ type JobRetryPolicy struct {
|
||||
}
|
||||
|
||||
type JobExecutionInput struct {
|
||||
WorkspaceScope string
|
||||
Content string
|
||||
ExpectedVersion int
|
||||
ExpectedChecksum string
|
||||
MaxReadBytes int
|
||||
RemoteAdapterKey string
|
||||
RemoteAdapterKind string
|
||||
TimeoutSeconds int
|
||||
WorkspaceScope string
|
||||
Content string
|
||||
ExpectedVersion int
|
||||
ExpectedChecksum string
|
||||
MaxReadBytes int
|
||||
RemoteAdapterKey string
|
||||
RemoteAdapterKind string
|
||||
TimeoutSeconds int
|
||||
PluginID string
|
||||
LifecycleOperation string
|
||||
TargetVersion string
|
||||
Inputs map[string]string
|
||||
}
|
||||
|
||||
type JobExecutionResult struct {
|
||||
@@ -1284,8 +1327,10 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
|
||||
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
|
||||
plugin.Tags = CopyStringSlice(plugin.Tags)
|
||||
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
|
||||
plugin.ProductionLifecycle = CopyGamePluginProductionLifecycle(plugin.ProductionLifecycle)
|
||||
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
||||
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
|
||||
plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge)
|
||||
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
||||
return plugin
|
||||
}
|
||||
@@ -1298,8 +1343,10 @@ func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketpla
|
||||
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
|
||||
plugin.Tags = CopyStringSlice(plugin.Tags)
|
||||
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
|
||||
plugin.ProductionLifecycle = CopyGamePluginProductionLifecycle(plugin.ProductionLifecycle)
|
||||
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
||||
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
|
||||
plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge)
|
||||
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
|
||||
return plugin
|
||||
}
|
||||
@@ -1328,11 +1375,19 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
|
||||
manifest.Permissions = CopyStringSlice(manifest.Permissions)
|
||||
manifest.Pages = CopyGamePluginPageSlice(manifest.Pages)
|
||||
manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes)
|
||||
manifest.ProductionLifecycle = CopyGamePluginProductionLifecycle(manifest.ProductionLifecycle)
|
||||
manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
|
||||
manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles)
|
||||
manifest.GameClientBridge = CopyGameClientBridgeManifest(manifest.GameClientBridge)
|
||||
return manifest
|
||||
}
|
||||
|
||||
func CopyGamePluginProductionLifecycle(lifecycle GamePluginProductionLifecycle) GamePluginProductionLifecycle {
|
||||
lifecycle.Operations = CopyStringSlice(lifecycle.Operations)
|
||||
lifecycle.ApprovalRequired = CopyStringSlice(lifecycle.ApprovalRequired)
|
||||
return lifecycle
|
||||
}
|
||||
|
||||
func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePluginRuntimeProfiles {
|
||||
profiles.Discovery = append([]RuntimeDiscoveryProbe(nil), profiles.Discovery...)
|
||||
for i := range profiles.Discovery {
|
||||
@@ -1354,6 +1409,7 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
|
||||
profiles.InstallPlans[i].Steps = append([]RuntimeInstallStep(nil), profiles.InstallPlans[i].Steps...)
|
||||
}
|
||||
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
|
||||
profiles.LogEvents = append([]RuntimeLogEvent(nil), profiles.LogEvents...)
|
||||
profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...)
|
||||
for i := range profiles.TransportProfiles {
|
||||
profiles.TransportProfiles[i].Capabilities = CopyStringSlice(profiles.TransportProfiles[i].Capabilities)
|
||||
@@ -1465,10 +1521,12 @@ func CopyFileOperationDispatchResult(result FileOperationDispatchResult) FileOpe
|
||||
|
||||
func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint {
|
||||
endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities)
|
||||
endpoint.Capacity.PressureCodes = CopyStringSlice(endpoint.Capacity.PressureCodes)
|
||||
return endpoint
|
||||
}
|
||||
|
||||
func CopyJob(job Job) Job {
|
||||
job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs)
|
||||
return job
|
||||
}
|
||||
|
||||
|
||||
@@ -192,3 +192,6 @@ Failed or cancelled lifecycle jobs project the server instance to `failed`. Acti
|
||||
- `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.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCopyGamePluginRuntimeProfilesCopiesLogEvents(t *testing.T) {
|
||||
profiles := GamePluginRuntimeProfiles{
|
||||
LogEvents: []RuntimeLogEvent{{
|
||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||
}},
|
||||
}
|
||||
|
||||
copied := CopyGamePluginRuntimeProfiles(profiles)
|
||||
copied.LogEvents[0].Title = "Mutated"
|
||||
|
||||
if profiles.LogEvents[0].Title != "Chat message" {
|
||||
t.Fatalf("expected log event declarations to be copied, got %+v", profiles.LogEvents)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ type AIConfigRecommendationResponse struct {
|
||||
Key string `json:"key"`
|
||||
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
||||
DiffSummary string `json:"diffSummary"`
|
||||
DiffID string `json:"diffId,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type AIInvocationSafeErrorResponse struct {
|
||||
@@ -82,6 +84,8 @@ func AIInvocationFromDomain(response domain.AIInvocationResponse) AIInvocationRe
|
||||
Key: response.ConfigRecommendation.Key,
|
||||
SuggestedConfig: response.ConfigRecommendation.SuggestedConfig,
|
||||
DiffSummary: response.ConfigRecommendation.DiffSummary,
|
||||
DiffID: response.ConfigRecommendation.DiffID,
|
||||
ExpiresAt: response.ConfigRecommendation.ExpiresAt,
|
||||
}
|
||||
}
|
||||
var safeError *AIInvocationSafeErrorResponse
|
||||
|
||||
@@ -122,25 +122,56 @@ type ClientManagerLifecycleInputRequest struct {
|
||||
}
|
||||
|
||||
type ClientManagerLifecycleInputResponse struct {
|
||||
InstallationID string `json:"installationId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Operation string `json:"operation"`
|
||||
ArtifactID string `json:"artifactId,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration"`
|
||||
ExecutableRef string `json:"executableRef"`
|
||||
Arguments []string `json:"arguments"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
StartupTimeoutSeconds int `json:"startupTimeoutSeconds"`
|
||||
StopTimeoutSeconds int `json:"stopTimeoutSeconds"`
|
||||
HealthConfirmationSeconds int `json:"healthConfirmationSeconds"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
InstallationID string `json:"installationId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Operation string `json:"operation"`
|
||||
ArtifactID string `json:"artifactId,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration"`
|
||||
ExecutableRef string `json:"executableRef"`
|
||||
Arguments []string `json:"arguments"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
StartupTimeoutSeconds int `json:"startupTimeoutSeconds"`
|
||||
StopTimeoutSeconds int `json:"stopTimeoutSeconds"`
|
||||
HealthConfirmationSeconds int `json:"healthConfirmationSeconds"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
CompanionConfig *ClientManagerCompanionConfigInputResponse `json:"companionConfig,omitempty"`
|
||||
}
|
||||
|
||||
type ClientManagerCompanionConfigInputResponse struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ConfigTemplateKey string `json:"configTemplateKey"`
|
||||
ConfigTemplateRef string `json:"configTemplateRef"`
|
||||
ConfigOutputRef string `json:"configOutputRef"`
|
||||
ConfigSchemaRef string `json:"configSchemaRef"`
|
||||
ConfigFormat string `json:"configFormat"`
|
||||
PlatformBaseURLSource string `json:"platformBaseUrlSource"`
|
||||
InstallationID string `json:"installationId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Version string `json:"version"`
|
||||
SourceRevision string `json:"sourceRevision"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
RegistrationProof string `json:"registrationProof"`
|
||||
ProofMaterialSource string `json:"proofMaterialSource"`
|
||||
ProofMaterialEnv string `json:"proofMaterialEnv"`
|
||||
SessionMode string `json:"sessionMode"`
|
||||
TLSPolicy string `json:"tlsPolicy"`
|
||||
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
|
||||
CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds"`
|
||||
RequestTimeoutSeconds int `json:"requestTimeoutSeconds"`
|
||||
}
|
||||
|
||||
type ClientManagerRegisterRequest struct {
|
||||
@@ -219,7 +250,17 @@ func (request ClientManagerLifecycleInputRequest) ToDomain() domain.ClientManage
|
||||
|
||||
func ClientManagerLifecycleInputFromDomain(value domain.ClientManagerLifecycleInput) ClientManagerLifecycleInputResponse {
|
||||
value = domain.CopyClientManagerLifecycleInput(value)
|
||||
return ClientManagerLifecycleInputResponse{InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, ProfileKey: value.ProfileKey, Operation: string(value.Operation), ArtifactID: value.ArtifactID, Checksum: value.Checksum, TargetOS: value.TargetOS, TargetArch: value.TargetArch, Version: value.Version, SourceRevision: value.SourceRevision, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, ExecutableRef: value.ExecutableRef, Arguments: value.Arguments, AutoStart: value.AutoStart, StartupTimeoutSeconds: value.StartupTimeoutSeconds, StopTimeoutSeconds: value.StopTimeoutSeconds, HealthConfirmationSeconds: value.HealthConfirmationSeconds, IdempotencyKey: value.IdempotencyKey}
|
||||
response := ClientManagerLifecycleInputResponse{InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, ProfileKey: value.ProfileKey, Operation: string(value.Operation), ArtifactID: value.ArtifactID, Checksum: value.Checksum, TargetOS: value.TargetOS, TargetArch: value.TargetArch, Version: value.Version, SourceRevision: value.SourceRevision, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, ExecutableRef: value.ExecutableRef, Arguments: value.Arguments, AutoStart: value.AutoStart, StartupTimeoutSeconds: value.StartupTimeoutSeconds, StopTimeoutSeconds: value.StopTimeoutSeconds, HealthConfirmationSeconds: value.HealthConfirmationSeconds, IdempotencyKey: value.IdempotencyKey}
|
||||
if value.CompanionConfig != nil {
|
||||
companion := ClientManagerCompanionConfigInputFromDomain(*value.CompanionConfig)
|
||||
response.CompanionConfig = &companion
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func ClientManagerCompanionConfigInputFromDomain(value domain.ClientManagerCompanionConfigInput) ClientManagerCompanionConfigInputResponse {
|
||||
value = domain.CopyClientManagerCompanionConfigInput(value)
|
||||
return ClientManagerCompanionConfigInputResponse{SchemaVersion: value.SchemaVersion, ConfigTemplateKey: value.ConfigTemplateKey, ConfigTemplateRef: value.ConfigTemplateRef, ConfigOutputRef: value.ConfigOutputRef, ConfigSchemaRef: value.ConfigSchemaRef, ConfigFormat: value.ConfigFormat, PlatformBaseURLSource: value.PlatformBaseURLSource, InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, ProfileKey: value.ProfileKey, ArtifactID: value.ArtifactID, Version: value.Version, SourceRevision: value.SourceRevision, TargetOS: value.TargetOS, TargetArch: value.TargetArch, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, Capabilities: value.Capabilities, RegistrationProof: value.RegistrationProof, ProofMaterialSource: value.ProofMaterialSource, ProofMaterialEnv: value.ProofMaterialEnv, SessionMode: value.SessionMode, TLSPolicy: value.TLSPolicy, HeartbeatIntervalSeconds: value.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.RequestTimeoutSeconds}
|
||||
}
|
||||
|
||||
func (request ClientManagerRegisterRequest) ToDomain() domain.ClientManagerRegisterRequest {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestClientManagerLifecycleInputProjectsRunOnlyCompanionConfigWithoutRawMaterial(t *testing.T) {
|
||||
response := ClientManagerLifecycleInputFromDomain(domain.ClientManagerLifecycleInput{
|
||||
InstallationID: "installation-1",
|
||||
ServerInstanceID: "server-1",
|
||||
ProfileKey: "scum-client-manager",
|
||||
Operation: domain.ClientManagerOperationDeploy,
|
||||
DeploymentGeneration: 2,
|
||||
CompanionConfig: &domain.ClientManagerCompanionConfigInput{
|
||||
SchemaVersion: 1,
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigTemplateRef: "config.yaml.example",
|
||||
ConfigOutputRef: "config.yaml",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
InstallationID: "installation-1",
|
||||
ServerInstanceID: "server-1",
|
||||
PluginID: "game.scum",
|
||||
ProfileKey: "scum-client-manager",
|
||||
ArtifactID: "artifact-1",
|
||||
Version: "1.0.0",
|
||||
SourceRevision: "revision-1",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
KeyGeneration: 3,
|
||||
DeploymentGeneration: 2,
|
||||
Capabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"},
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
},
|
||||
})
|
||||
if response.CompanionConfig == nil || response.CompanionConfig.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" {
|
||||
t.Fatalf("expected run-only companion projection, got %+v", response.CompanionConfig)
|
||||
}
|
||||
encoded, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal lifecycle input: %v", err)
|
||||
}
|
||||
serialized := string(encoded)
|
||||
for _, forbidden := range []string{`"proofMaterial":`, `"authKey":`, `"componentKey":`, `"sessionToken":`, `"secretRef":`, "/Users/", "run.socket"} {
|
||||
if strings.Contains(serialized, forbidden) {
|
||||
t.Fatalf("run lifecycle input leaked %q: %s", forbidden, serialized)
|
||||
}
|
||||
}
|
||||
|
||||
browserProjection, err := json.Marshal(ClientManagerLifecycleViewFromDomain(domain.ClientManagerLifecycleView{Installation: domain.ClientManagerInstallation{ID: "installation-1"}}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal browser lifecycle projection: %v", err)
|
||||
}
|
||||
if strings.Contains(string(browserProjection), "companionConfig") || strings.Contains(string(browserProjection), "proofMaterialEnv") {
|
||||
t.Fatalf("browser lifecycle projection exposed run-only config: %s", browserProjection)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
type GameClientBridgeQueueRequest struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
CommandType string `json:"commandType"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type GameClientBridgeClaimRequest struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeAckRequest struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
}
|
||||
|
||||
type GameClientBridgeResultRequest struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotIngestRequest struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Type string `json:"type"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
KeepForSeconds int `json:"keepForSeconds"`
|
||||
MaxRecords int `json:"maxRecords,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeCancelRequest struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotQuery struct {
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
StreamKey string `json:"streamKey,omitempty"`
|
||||
ObservedAfter time.Time `json:"observedAfter,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// GameClientBridgeCommandResultResponse is the browser-safe projection of a
|
||||
// terminal command result. Result payloads pass the same bounded JSON
|
||||
// validation as command inputs before they can be persisted.
|
||||
type GameClientBridgeCommandResultResponse struct {
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
CompletedAt time.Time `json:"completedAt"`
|
||||
}
|
||||
|
||||
type GameClientBridgeCommandCancellationResponse struct {
|
||||
RequestedBy string `json:"requestedBy,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CancelledAt time.Time `json:"cancelledAt"`
|
||||
}
|
||||
|
||||
type GameClientBridgeCommandResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
CommandType string `json:"commandType"`
|
||||
Priority int `json:"priority"`
|
||||
State string `json:"state"`
|
||||
ApprovalState string `json:"approvalState"`
|
||||
RequesterID string `json:"requesterId,omitempty"`
|
||||
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"`
|
||||
CompletedAt time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeRetentionResponse struct {
|
||||
KeepForSeconds int `json:"keepForSeconds"`
|
||||
MaxRecords int `json:"maxRecords,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Type string `json:"type"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type GameClientBridgeCommandListResponse struct {
|
||||
Items []GameClientBridgeCommandResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotListResponse struct {
|
||||
Items []GameClientBridgeSnapshotResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type GameClientBridgeClaimedCommandResponse struct {
|
||||
ID string `json:"id"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
CommandType string `json:"commandType"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Priority int `json:"priority"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
ClaimedAt time.Time `json:"claimedAt"`
|
||||
LeaseExpiresAt time.Time `json:"leaseExpiresAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type GameClientBridgeClaimResponse struct {
|
||||
Items []GameClientBridgeClaimedCommandResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// GameClientBridgeClaimBatchResponse is kept as a descriptive alias for API
|
||||
// handlers that name the operation after its bounded batch semantics.
|
||||
type GameClientBridgeClaimBatchResponse = GameClientBridgeClaimResponse
|
||||
|
||||
type GameClientBridgeAckResponse struct {
|
||||
CommandID string `json:"commandId"`
|
||||
State string `json:"state"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
AcknowledgedAt time.Time `json:"acknowledgedAt"`
|
||||
}
|
||||
|
||||
type GameClientBridgeResultResponse struct {
|
||||
CommandID string `json:"commandId"`
|
||||
State string `json:"state"`
|
||||
Result GameClientBridgeCommandResultResponse `json:"result"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CompletedAt time.Time `json:"completedAt"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotIngestResponse struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Type string `json:"type"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
AcceptedAt time.Time `json:"acceptedAt"`
|
||||
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"`
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CommandTypes []string `json:"commandTypes"`
|
||||
SnapshotTypes []string `json:"snapshotTypes"`
|
||||
QueryTemplateKeys []string `json:"queryTemplateKeys"`
|
||||
}
|
||||
|
||||
type GameClientBridgeStatusResponse struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Profiles []GameClientBridgeProfileDeclarationResponse `json:"profiles"`
|
||||
}
|
||||
|
||||
func (request GameClientBridgeQueueRequest) ToDomain(serverID, pluginID string) domain.GameClientBridgeQueueRequest {
|
||||
return domain.GameClientBridgeQueueRequest{ServerInstanceID: serverID, PluginID: pluginID, ProfileKey: request.ProfileKey, CommandType: request.CommandType, Payload: domain.CopyGameClientBridgePayload(request.Payload), IdempotencyKey: request.IdempotencyKey, Priority: request.Priority, ExpiresAt: request.ExpiresAt}
|
||||
}
|
||||
|
||||
func (request GameClientBridgeClaimRequest) ToDomain() domain.GameClientBridgeClaimRequest {
|
||||
return domain.GameClientBridgeClaimRequest{SessionToken: request.SessionToken, Limit: request.Limit}
|
||||
}
|
||||
|
||||
func (request GameClientBridgeAckRequest) ToDomain(commandID string) domain.GameClientBridgeAckRequest {
|
||||
return domain.GameClientBridgeAckRequest{SessionToken: request.SessionToken, CommandID: commandID, FencingToken: request.FencingToken}
|
||||
}
|
||||
|
||||
func (request GameClientBridgeResultRequest) ToDomain(commandID string) domain.GameClientBridgeResultRequest {
|
||||
return domain.GameClientBridgeResultRequest{SessionToken: request.SessionToken, CommandID: commandID, FencingToken: request.FencingToken, Status: domain.GameClientBridgeResultStatus(request.Status), Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload)}
|
||||
}
|
||||
|
||||
func (request GameClientBridgeCancelRequest) ToDomain(commandID string) domain.GameClientBridgeCancelRequest {
|
||||
return domain.GameClientBridgeCancelRequest{CommandID: commandID, Reason: request.Reason}
|
||||
}
|
||||
|
||||
func (request GameClientBridgeSnapshotIngestRequest) ToDomain() domain.GameClientBridgeSnapshotIngestRequest {
|
||||
return domain.GameClientBridgeSnapshotIngestRequest{SessionToken: request.SessionToken, Type: request.Type, SchemaVersion: request.SchemaVersion, StreamKey: request.StreamKey, Sequence: request.Sequence, ObservedAt: request.ObservedAt, Payload: domain.CopyGameClientBridgePayload(request.Payload), Retention: domain.GameClientBridgeRetention{KeepForSeconds: request.KeepForSeconds, MaxRecords: request.MaxRecords}}
|
||||
}
|
||||
|
||||
func (query GameClientBridgeSnapshotQuery) ToDomain(serverID, pluginID string) domain.GameClientBridgeSnapshotQuery {
|
||||
return domain.GameClientBridgeSnapshotQuery{ServerInstanceID: serverID, PluginID: pluginID, ProfileKey: query.ProfileKey, Type: query.Type, StreamKey: query.StreamKey, ObservedAfter: query.ObservedAfter, Limit: query.Limit}
|
||||
}
|
||||
|
||||
func GameClientBridgeCommandFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeCommandResponse {
|
||||
value = domain.CopyGameClientBridgeCommand(value)
|
||||
response := GameClientBridgeCommandResponse{
|
||||
ID: value.ID,
|
||||
ServerInstanceID: value.ServerInstanceID,
|
||||
PluginID: value.PluginID,
|
||||
ProfileKey: value.ProfileKey,
|
||||
CommandType: value.CommandType,
|
||||
Priority: value.Priority,
|
||||
State: string(value.State),
|
||||
ApprovalState: string(value.ApprovalState),
|
||||
RequesterID: value.RequesterID,
|
||||
ResultSummary: value.Result.Summary,
|
||||
AuditReferences: copyStrings(value.AuditReferences),
|
||||
ExpiresAt: value.ExpiresAt,
|
||||
CreatedAt: value.CreatedAt,
|
||||
UpdatedAt: value.UpdatedAt,
|
||||
CompletedAt: value.CompletedAt,
|
||||
}
|
||||
if hasGameClientBridgeResult(value.Result) {
|
||||
result := gameClientBridgeCommandResultFromDomain(value.Result)
|
||||
response.Result = &result
|
||||
}
|
||||
if hasGameClientBridgeCancellation(value.Cancellation) {
|
||||
cancellation := gameClientBridgeCommandCancellationFromDomain(value.Cancellation)
|
||||
response.Cancellation = &cancellation
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func GameClientBridgeSnapshotFromDomain(value domain.GameClientBridgeSnapshot) GameClientBridgeSnapshotResponse {
|
||||
value = domain.CopyGameClientBridgeSnapshot(value)
|
||||
return GameClientBridgeSnapshotResponse{
|
||||
ID: value.ID,
|
||||
ServerInstanceID: value.ServerInstanceID,
|
||||
PluginID: value.PluginID,
|
||||
ProfileKey: value.ProfileKey,
|
||||
Type: value.Type,
|
||||
SchemaVersion: value.SchemaVersion,
|
||||
StreamKey: value.StreamKey,
|
||||
Sequence: value.Sequence,
|
||||
ObservedAt: value.ObservedAt,
|
||||
Payload: value.Payload,
|
||||
Retention: gameClientBridgeRetentionFromDomain(value.Retention),
|
||||
AuditReferences: copyStrings(value.AuditReferences),
|
||||
CreatedAt: value.CreatedAt,
|
||||
ExpiresAt: value.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func GameClientBridgeClaimedCommandFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeClaimedCommandResponse {
|
||||
value = domain.CopyGameClientBridgeCommand(value)
|
||||
return GameClientBridgeClaimedCommandResponse{ID: value.ID, ProfileKey: value.ProfileKey, CommandType: value.CommandType, Payload: value.Payload, Priority: value.Priority, FencingToken: value.Claim.FencingToken, ClaimedAt: value.Claim.ClaimedAt, LeaseExpiresAt: value.Claim.LeaseExpiresAt, ExpiresAt: value.ExpiresAt}
|
||||
}
|
||||
|
||||
func GameClientBridgeCommandsFromDomain(values []domain.GameClientBridgeCommand) GameClientBridgeCommandListResponse {
|
||||
items := make([]GameClientBridgeCommandResponse, len(values))
|
||||
for index, value := range values {
|
||||
items[index] = GameClientBridgeCommandFromDomain(value)
|
||||
}
|
||||
return GameClientBridgeCommandListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func GameClientBridgeSnapshotsFromDomain(values []domain.GameClientBridgeSnapshot) GameClientBridgeSnapshotListResponse {
|
||||
items := make([]GameClientBridgeSnapshotResponse, len(values))
|
||||
for index, value := range values {
|
||||
items[index] = GameClientBridgeSnapshotFromDomain(value)
|
||||
}
|
||||
return GameClientBridgeSnapshotListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func GameClientBridgeClaimResponseFromDomain(values []domain.GameClientBridgeCommand) GameClientBridgeClaimResponse {
|
||||
items := make([]GameClientBridgeClaimedCommandResponse, len(values))
|
||||
for index, value := range values {
|
||||
items[index] = GameClientBridgeClaimedCommandFromDomain(value)
|
||||
}
|
||||
return GameClientBridgeClaimResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func GameClientBridgeClaimBatchFromDomain(values []domain.GameClientBridgeCommand) GameClientBridgeClaimBatchResponse {
|
||||
return GameClientBridgeClaimResponseFromDomain(values)
|
||||
}
|
||||
|
||||
func GameClientBridgeAckFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeAckResponse {
|
||||
return GameClientBridgeAckResponse{CommandID: value.ID, State: string(value.State), FencingToken: value.Claim.FencingToken, AcknowledgedAt: value.Claim.AcknowledgedAt}
|
||||
}
|
||||
|
||||
func GameClientBridgeResultFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeResultResponse {
|
||||
value = domain.CopyGameClientBridgeCommand(value)
|
||||
return GameClientBridgeResultResponse{CommandID: value.ID, State: string(value.State), Result: gameClientBridgeCommandResultFromDomain(value.Result), UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt}
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
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))
|
||||
for index, profile := range value.Profiles {
|
||||
profiles[index] = GameClientBridgeProfileDeclarationResponse{PluginID: profile.PluginID, ProfileKey: profile.ProfileKey, Available: profile.Available, Reason: profile.Reason, CommandTypes: nonNilStrings(profile.CommandTypes), SnapshotTypes: nonNilStrings(profile.SnapshotTypes), QueryTemplateKeys: nonNilStrings(profile.QueryTemplateKeys)}
|
||||
}
|
||||
return GameClientBridgeStatusResponse{ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Available: value.Available, Reason: value.Reason, Profiles: profiles}
|
||||
}
|
||||
|
||||
func gameClientBridgeCommandResultFromDomain(value domain.GameClientBridgeResult) GameClientBridgeCommandResultResponse {
|
||||
value = domain.CopyGameClientBridgeResult(value)
|
||||
return GameClientBridgeCommandResultResponse{Status: string(value.Status), Summary: value.Summary, Payload: value.Payload, CompletedAt: value.CompletedAt}
|
||||
}
|
||||
|
||||
func gameClientBridgeCommandCancellationFromDomain(value domain.GameClientBridgeCancellation) GameClientBridgeCommandCancellationResponse {
|
||||
return GameClientBridgeCommandCancellationResponse{RequestedBy: value.RequestedBy, Reason: value.Reason, CancelledAt: value.CancelledAt}
|
||||
}
|
||||
|
||||
func gameClientBridgeRetentionFromDomain(value domain.GameClientBridgeRetention) GameClientBridgeRetentionResponse {
|
||||
return GameClientBridgeRetentionResponse{KeepForSeconds: value.KeepForSeconds, MaxRecords: value.MaxRecords}
|
||||
}
|
||||
|
||||
func hasGameClientBridgeResult(value domain.GameClientBridgeResult) bool {
|
||||
return value.Status != "" || value.Summary != "" || value.Payload != nil || value.CompletedBy != "" || !value.CompletedAt.IsZero()
|
||||
}
|
||||
|
||||
func hasGameClientBridgeCancellation(value domain.GameClientBridgeCancellation) bool {
|
||||
return value.RequestedBy != "" || value.Reason != "" || !value.CancelledAt.IsZero()
|
||||
}
|
||||
|
||||
func copyStrings(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, len(values))
|
||||
copy(result, values)
|
||||
return result
|
||||
}
|
||||
|
||||
func nonNilStrings(values []string) []string {
|
||||
if values == nil {
|
||||
return []string{}
|
||||
}
|
||||
return copyStrings(values)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGameClientBridgeCompanionDeclarationRoundTripsWithoutMaterial(t *testing.T) {
|
||||
body := GameClientBridgeManifestBody{
|
||||
CommandRetentionSeconds: 86400,
|
||||
MaxCommands: 1000,
|
||||
Companion: &GameClientBridgeCompanionDeclarationBody{
|
||||
ProfileKey: "scum-client-manager",
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
},
|
||||
}
|
||||
domainValue := body.ToDomain()
|
||||
if domainValue.Companion.ProfileKey != "scum-client-manager" || domainValue.Companion.TLSPolicy != "verify-system-roots" {
|
||||
t.Fatalf("companion declaration conversion lost fields: %+v", domainValue.Companion)
|
||||
}
|
||||
projection := gameClientBridgeManifestFromDomain(domainValue)
|
||||
encoded, err := json.Marshal(projection)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal companion declaration: %v", err)
|
||||
}
|
||||
serialized := string(encoded)
|
||||
for _, forbidden := range []string{"authKey", "componentKey", "sessionToken", "credential", "secretRef", "hostPath", "runSocket"} {
|
||||
if strings.Contains(serialized, forbidden) {
|
||||
t.Fatalf("companion declaration exposed %q: %s", forbidden, serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestGameClientBridgeRequestConversionsInjectScopeAndCopyPayloads(t *testing.T) {
|
||||
payload := map[string]any{"players": []any{map[string]any{"name": "Alice"}}}
|
||||
queue := GameClientBridgeQueueRequest{ProfileKey: "scum-client", CommandType: "player.lookup", Payload: payload, IdempotencyKey: "lookup-1", Priority: 5, ExpiresAt: time.Now().UTC().Add(time.Minute)}.ToDomain("server-1", "game.scum")
|
||||
claim := GameClientBridgeClaimRequest{SessionToken: "session", Limit: 5}.ToDomain()
|
||||
ack := GameClientBridgeAckRequest{SessionToken: "session", FencingToken: 7}.ToDomain("command-1")
|
||||
result := GameClientBridgeResultRequest{SessionToken: "session", FencingToken: 7, Status: "succeeded", Payload: payload}.ToDomain("command-1")
|
||||
cancel := GameClientBridgeCancelRequest{Reason: "operator request"}.ToDomain("command-1")
|
||||
snapshot := GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 2, Payload: payload, KeepForSeconds: 60, MaxRecords: 4}.ToDomain()
|
||||
query := GameClientBridgeSnapshotQuery{ProfileKey: "scum-client", Type: "players", StreamKey: "current", Limit: 10}.ToDomain("server-1", "game.scum")
|
||||
|
||||
if queue.ServerInstanceID != "server-1" || queue.PluginID != "game.scum" || claim.Limit != 5 || ack.CommandID != "command-1" || result.CommandID != "command-1" || cancel.CommandID != "command-1" || snapshot.Retention.MaxRecords != 4 || query.ServerInstanceID != "server-1" || query.PluginID != "game.scum" {
|
||||
t.Fatalf("request conversion lost route or body fields: queue=%#v claim=%#v ack=%#v result=%#v cancel=%#v snapshot=%#v query=%#v", queue, claim, ack, result, cancel, snapshot, query)
|
||||
}
|
||||
queue.Payload["players"].([]any)[0].(map[string]any)["name"] = "queue"
|
||||
result.Payload["players"].([]any)[0].(map[string]any)["name"] = "result"
|
||||
snapshot.Payload["players"].([]any)[0].(map[string]any)["name"] = "snapshot"
|
||||
if got := payload["players"].([]any)[0].(map[string]any)["name"]; got != "Alice" {
|
||||
t.Fatalf("request conversion aliases source payload: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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),
|
||||
}
|
||||
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)}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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" {
|
||||
t.Fatal("browser projection aliases domain data")
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(struct {
|
||||
Command GameClientBridgeCommandResponse `json:"command"`
|
||||
Snapshot GameClientBridgeSnapshotResponse `json:"snapshot"`
|
||||
}{commandProjection, snapshotProjection})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal projections: %v", err)
|
||||
}
|
||||
text := string(encoded)
|
||||
for _, forbidden := range []string{"internal-session-secret", "internal-installation", "internal-completing-session", "internal-source-session", "internal-idempotency", "internal command payload", "fencingToken", "deploymentGeneration", "sourceSessionId", "completedBy", "idempotencyKey", "leaseExpiresAt"} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("browser projection leaked %q: %s", forbidden, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
command := domain.GameClientBridgeCommand{
|
||||
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),
|
||||
}
|
||||
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 4, CreatedAt: now, ExpiresAt: now.Add(time.Hour)}
|
||||
|
||||
claim := GameClientBridgeClaimBatchFromDomain([]domain.GameClientBridgeCommand{command})
|
||||
ack := GameClientBridgeAckFromDomain(command)
|
||||
result := GameClientBridgeResultFromDomain(command)
|
||||
cancel := GameClientBridgeCancelFromDomain(command)
|
||||
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 {
|
||||
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"
|
||||
result.Result.Payload["healthy"] = false
|
||||
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} {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s response: %v", name, err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "private-session") || strings.Contains(string(encoded), "sessionId") || strings.Contains(string(encoded), "completedBy") {
|
||||
t.Fatalf("%s response exposes a private session: %s", name, encoded)
|
||||
}
|
||||
}
|
||||
|
||||
if GameClientBridgeCommandsFromDomain(nil).Items == nil || GameClientBridgeSnapshotsFromDomain(nil).Items == nil || GameClientBridgeClaimBatchFromDomain(nil).Items == nil || GameClientBridgeAuditReferencesFromDomain(nil).Items == nil {
|
||||
t.Fatal("list converters must serialize empty items as [] instead of null")
|
||||
}
|
||||
}
|
||||
@@ -92,14 +92,18 @@ type RunJobResultRequest struct {
|
||||
}
|
||||
|
||||
type RunJobExecutionInputBody struct {
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty"`
|
||||
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
|
||||
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty"`
|
||||
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
|
||||
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
|
||||
TargetVersion string `json:"targetVersion,omitempty"`
|
||||
Inputs map[string]string `json:"inputs,omitempty"`
|
||||
}
|
||||
|
||||
type RunJobExecutionResultBody struct {
|
||||
@@ -525,7 +529,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
|
||||
State: assignment.State,
|
||||
Progress: progressReportFromDomain(assignment.Progress),
|
||||
ResultRef: assignment.ResultRef,
|
||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds},
|
||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs)},
|
||||
LeaseToken: assignment.LeaseToken,
|
||||
Attempt: assignment.Attempt,
|
||||
MaxAttempts: assignment.MaxAttempts,
|
||||
|
||||
@@ -82,12 +82,14 @@ type RemoteAdapterDeclarationListResponse struct {
|
||||
}
|
||||
|
||||
type RemoteAdapterRequestBody struct {
|
||||
DeclarationKey string `json:"declarationKey"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
Capability string `json:"capability"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
MaxAttempts int `json:"maxAttempts,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
DeclarationKey string `json:"declarationKey"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
Capability string `json:"capability"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
MaxAttempts int `json:"maxAttempts,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
InputRef string `json:"inputRef,omitempty"`
|
||||
Inputs map[string]string `json:"inputs,omitempty"`
|
||||
}
|
||||
|
||||
type RemoteAdapterResponse struct {
|
||||
@@ -149,7 +151,7 @@ func RemoteAdapterDeclarationsFromDomain(declarations []domain.RemoteAdapterDecl
|
||||
}
|
||||
|
||||
func (request RemoteAdapterRequestBody) ToDomain(serverInstanceID string) domain.RemoteAdapterRequest {
|
||||
return domain.RemoteAdapterRequest{ServerInstanceID: serverInstanceID, DeclarationKey: request.DeclarationKey, TargetKey: request.TargetKey, Capability: request.Capability, TimeoutSeconds: request.TimeoutSeconds, MaxAttempts: request.MaxAttempts, IdempotencyKey: request.IdempotencyKey}
|
||||
return domain.RemoteAdapterRequest{ServerInstanceID: serverInstanceID, DeclarationKey: request.DeclarationKey, TargetKey: request.TargetKey, Capability: request.Capability, TimeoutSeconds: request.TimeoutSeconds, MaxAttempts: request.MaxAttempts, IdempotencyKey: request.IdempotencyKey, InputRef: request.InputRef, Inputs: domain.CopyStringMap(request.Inputs)}
|
||||
}
|
||||
|
||||
func RemoteAdapterFromDomain(result domain.RemoteAdapterResult) RemoteAdapterResponse {
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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"`
|
||||
TargetVersion string `json:"targetVersion,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
type PluginLifecycleInstallationResponse struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
CurrentVersion string `json:"currentVersion,omitempty"`
|
||||
TargetVersion string `json:"targetVersion,omitempty"`
|
||||
PreviousVersion string `json:"previousVersion,omitempty"`
|
||||
DesiredState string `json:"desiredState"`
|
||||
CurrentState string `json:"currentState"`
|
||||
LastOperation string `json:"lastOperation,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type PluginLifecycleListResponse struct {
|
||||
Items []PluginLifecycleInstallationResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
RequestID string `json:"requestId"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
ProviderID string `json:"providerId,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Key string `json:"key"`
|
||||
ConfigVersion int `json:"configVersion"`
|
||||
CurrentConfigChecksum string `json:"currentConfigChecksum,omitempty"`
|
||||
ProposedConfig string `json:"proposedConfig,omitempty"`
|
||||
DiffSummary string `json:"diffSummary"`
|
||||
State string `json:"state"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ApprovedBy string `json:"approvedBy,omitempty"`
|
||||
ApprovedAt time.Time `json:"approvedAt,omitempty"`
|
||||
CancelledBy string `json:"cancelledBy,omitempty"`
|
||||
CancelledAt time.Time `json:"cancelledAt,omitempty"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AIConfigDiffListResponse struct {
|
||||
Items []AIConfigDiffPreviewResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalRequest struct {
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalResponse struct {
|
||||
Preview AIConfigDiffPreviewResponse `json:"preview"`
|
||||
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}
|
||||
}
|
||||
|
||||
func PluginLifecycleListFromDomain(installations []domain.PluginLifecycleInstallation) PluginLifecycleListResponse {
|
||||
items := make([]PluginLifecycleInstallationResponse, len(installations))
|
||||
for i, installation := range installations {
|
||||
items[i] = PluginLifecycleFromDomain(installation)
|
||||
}
|
||||
return PluginLifecycleListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
func AIConfigDiffFromDomain(preview domain.AIConfigDiffPreview) AIConfigDiffPreviewResponse {
|
||||
preview = domain.CopyAIConfigDiffPreview(preview)
|
||||
return AIConfigDiffPreviewResponse{ID: preview.ID, RequestID: preview.RequestID, CreatedBy: preview.CreatedBy, ServerInstanceID: preview.ServerInstanceID, PluginID: preview.PluginID, ProviderID: preview.ProviderID, Model: preview.Model, Key: preview.Key, ConfigVersion: preview.ConfigVersion, CurrentConfigChecksum: preview.CurrentConfigChecksum, ProposedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, State: string(preview.State), ExpiresAt: preview.ExpiresAt, ApprovedBy: preview.ApprovedBy, ApprovedAt: preview.ApprovedAt, CancelledBy: preview.CancelledBy, CancelledAt: preview.CancelledAt, JobID: preview.JobID, CreatedAt: preview.CreatedAt, UpdatedAt: preview.UpdatedAt}
|
||||
}
|
||||
|
||||
func AIConfigDiffListFromDomain(previews []domain.AIConfigDiffPreview) AIConfigDiffListResponse {
|
||||
items := make([]AIConfigDiffPreviewResponse, len(previews))
|
||||
for i, preview := range previews {
|
||||
items[i] = AIConfigDiffFromDomain(preview)
|
||||
}
|
||||
return AIConfigDiffListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func (request AIConfigDiffApprovalRequest) ToDomain(diffID string) domain.AIConfigDiffApprovalRequest {
|
||||
return domain.AIConfigDiffApprovalRequest{DiffID: diffID, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func AIConfigDiffApprovalFromDomain(result domain.AIConfigDiffApprovalResult) AIConfigDiffApprovalResponse {
|
||||
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
|
||||
}
|
||||
+285
-129
@@ -126,17 +126,17 @@ type AIProviderStatusRequest struct {
|
||||
}
|
||||
|
||||
type AIProviderResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind domain.AIProviderKind `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKeyConfigured bool `json:"apiKeyConfigured"`
|
||||
Models []string `json:"models"`
|
||||
DefaultModel string `json:"defaultModel,omitempty"`
|
||||
RelayMode domain.AIRelayMode `json:"relayMode"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
Status domain.AIProviderStatus `json:"status"`
|
||||
RedactionPolicy string `json:"redactionPolicy"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind domain.AIProviderKind `json:"kind"`
|
||||
BaseURLConfigured bool `json:"baseUrlConfigured"`
|
||||
APIKeyConfigured bool `json:"apiKeyConfigured"`
|
||||
Models []string `json:"models"`
|
||||
DefaultModel string `json:"defaultModel,omitempty"`
|
||||
RelayMode domain.AIRelayMode `json:"relayMode"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
Status domain.AIProviderStatus `json:"status"`
|
||||
RedactionPolicy string `json:"redactionPolicy"`
|
||||
}
|
||||
|
||||
type AIProviderListResponse struct {
|
||||
@@ -195,7 +195,15 @@ type GamePluginManifestServerBody struct {
|
||||
}
|
||||
|
||||
type GamePluginManifestAIBody struct {
|
||||
Purposes []string `json:"purposes,omitempty"`
|
||||
Purposes []string `json:"purposes,omitempty"`
|
||||
Mediation string `json:"mediation,omitempty"`
|
||||
ConfigWritePolicy string `json:"configWritePolicy,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginProductionLifecycleBody struct {
|
||||
Operations []string `json:"operations"`
|
||||
DependencyPolicy string `json:"dependencyPolicy"`
|
||||
ApprovalRequired []string `json:"approvalRequired"`
|
||||
}
|
||||
|
||||
type GamePluginRemoteAccessBody struct {
|
||||
@@ -206,22 +214,89 @@ type GamePluginRemoteAccessBody struct {
|
||||
LogTransfer bool `json:"logTransfer,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotDeclarationBody struct {
|
||||
Type string `json:"type"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
SchemaRef string `json:"schemaRef"`
|
||||
KeepForSeconds int `json:"keepForSeconds"`
|
||||
MaxRecords int `json:"maxRecords"`
|
||||
}
|
||||
|
||||
type GameClientBridgeQueryTemplateDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Permission string `json:"permission"`
|
||||
Engine string `json:"engine"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ParameterSchemaRef string `json:"parameterSchemaRef"`
|
||||
ResultSchemaRef string `json:"resultSchemaRef"`
|
||||
MaxRows int `json:"maxRows"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
}
|
||||
|
||||
type GameClientBridgePageContractBody struct {
|
||||
PageKey string `json:"pageKey"`
|
||||
CommandTypes []string `json:"commandTypes,omitempty"`
|
||||
SnapshotTypes []string `json:"snapshotTypes,omitempty"`
|
||||
QueryTemplateKeys []string `json:"queryTemplateKeys,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeCompanionDeclarationBody struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
ConfigTemplateKey string `json:"configTemplateKey"`
|
||||
ConfigSchemaRef string `json:"configSchemaRef"`
|
||||
ConfigFormat string `json:"configFormat"`
|
||||
PlatformBaseURLSource string `json:"platformBaseUrlSource"`
|
||||
RegistrationProof string `json:"registrationProof"`
|
||||
ProofMaterialSource string `json:"proofMaterialSource"`
|
||||
ProofMaterialEnv string `json:"proofMaterialEnv"`
|
||||
SessionMode string `json:"sessionMode"`
|
||||
TLSPolicy string `json:"tlsPolicy"`
|
||||
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
|
||||
CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds"`
|
||||
RequestTimeoutSeconds int `json:"requestTimeoutSeconds"`
|
||||
}
|
||||
|
||||
type GameClientBridgeManifestBody struct {
|
||||
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
|
||||
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
|
||||
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
|
||||
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
|
||||
MaxCommands int `json:"maxCommands"`
|
||||
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
|
||||
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginManifestBody struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Server GamePluginManifestServerBody `json:"server"`
|
||||
Bridge GamePluginBridgeBody `json:"bridge,omitempty"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Actions PluginLifecycleActionsBody `json:"actions"`
|
||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||
AI GamePluginManifestAIBody `json:"ai,omitempty"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Server GamePluginManifestServerBody `json:"server"`
|
||||
Bridge GamePluginBridgeBody `json:"bridge,omitempty"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Actions PluginLifecycleActionsBody `json:"actions"`
|
||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||
AI GamePluginManifestAIBody `json:"ai,omitempty"`
|
||||
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginManifestRegistrationRequest struct {
|
||||
@@ -230,50 +305,54 @@ type GamePluginManifestRegistrationRequest struct {
|
||||
}
|
||||
|
||||
type GamePluginCreateRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions,omitempty"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions,omitempty"`
|
||||
BridgeActions []string `json:"bridgeActions,omitempty"`
|
||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
AIPurposes []string `json:"aiPurposes,omitempty"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions,omitempty"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions,omitempty"`
|
||||
BridgeActions []string `json:"bridgeActions,omitempty"`
|
||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
AIPurposes []string `json:"aiPurposes,omitempty"`
|
||||
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle,omitempty"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
|
||||
BridgeActions []string `json:"bridgeActions"`
|
||||
Pages []GamePluginPageBody `json:"pages"`
|
||||
Tags []string `json:"tags"`
|
||||
AIPurposes []string `json:"aiPurposes"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
Status domain.GamePluginStatus `json:"status"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
|
||||
BridgeActions []string `json:"bridgeActions"`
|
||||
Pages []GamePluginPageBody `json:"pages"`
|
||||
Tags []string `json:"tags"`
|
||||
AIPurposes []string `json:"aiPurposes"`
|
||||
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
Status domain.GamePluginStatus `json:"status"`
|
||||
}
|
||||
|
||||
type GamePluginListResponse struct {
|
||||
@@ -282,28 +361,30 @@ type GamePluginListResponse struct {
|
||||
}
|
||||
|
||||
type MarketplacePluginResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
|
||||
BridgeActions []string `json:"bridgeActions"`
|
||||
Pages []GamePluginPageBody `json:"pages"`
|
||||
Tags []string `json:"tags"`
|
||||
AIPurposes []string `json:"aiPurposes"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
Status domain.GamePluginStatus `json:"status"`
|
||||
Source string `json:"source"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
|
||||
BridgeActions []string `json:"bridgeActions"`
|
||||
Pages []GamePluginPageBody `json:"pages"`
|
||||
Tags []string `json:"tags"`
|
||||
AIPurposes []string `json:"aiPurposes"`
|
||||
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
Status domain.GamePluginStatus `json:"status"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type MarketplacePluginListResponse struct {
|
||||
@@ -520,10 +601,13 @@ type FileOperationDispatchResponse struct {
|
||||
}
|
||||
|
||||
type RunCapacityResponse struct {
|
||||
MaxJobs int `json:"maxJobs"`
|
||||
RunningJobs int `json:"runningJobs"`
|
||||
QueuedJobs int `json:"queuedJobs"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type RunEndpointCreateRequest struct {
|
||||
@@ -816,21 +900,23 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
|
||||
return domain.GamePluginManifestRegistration{
|
||||
ManifestRef: request.ManifestRef,
|
||||
Manifest: domain.GamePluginManifest{
|
||||
ID: request.Manifest.ID,
|
||||
Name: request.Manifest.Name,
|
||||
Description: request.Manifest.Description,
|
||||
Version: request.Manifest.Version,
|
||||
Kind: request.Manifest.Kind,
|
||||
Tags: domain.CopyStringSlice(request.Manifest.Tags),
|
||||
Server: request.Manifest.Server.ToDomain(),
|
||||
Bridge: request.Manifest.Bridge.ToDomain(),
|
||||
Capabilities: domain.CopyStringSlice(request.Manifest.Capabilities),
|
||||
Permissions: domain.CopyStringSlice(request.Manifest.Permissions),
|
||||
Actions: request.Manifest.Actions.ToDomain(),
|
||||
Pages: pagesToDomain(request.Manifest.Pages),
|
||||
AI: request.Manifest.AI.ToDomain(),
|
||||
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
|
||||
RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(),
|
||||
ID: request.Manifest.ID,
|
||||
Name: request.Manifest.Name,
|
||||
Description: request.Manifest.Description,
|
||||
Version: request.Manifest.Version,
|
||||
Kind: request.Manifest.Kind,
|
||||
Tags: domain.CopyStringSlice(request.Manifest.Tags),
|
||||
Server: request.Manifest.Server.ToDomain(),
|
||||
Bridge: request.Manifest.Bridge.ToDomain(),
|
||||
Capabilities: domain.CopyStringSlice(request.Manifest.Capabilities),
|
||||
Permissions: domain.CopyStringSlice(request.Manifest.Permissions),
|
||||
Actions: request.Manifest.Actions.ToDomain(),
|
||||
Pages: pagesToDomain(request.Manifest.Pages),
|
||||
AI: request.Manifest.AI.ToDomain(),
|
||||
ProductionLifecycle: request.Manifest.ProductionLifecycle.ToDomain(),
|
||||
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
|
||||
RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(),
|
||||
GameClientBridge: request.Manifest.GameClientBridge.ToDomain(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -849,7 +935,11 @@ func (server GamePluginManifestServerBody) ToDomain() domain.GamePluginManifestS
|
||||
}
|
||||
|
||||
func (ai GamePluginManifestAIBody) ToDomain() domain.GamePluginManifestAI {
|
||||
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes)}
|
||||
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes), Mediation: ai.Mediation, ConfigWritePolicy: ai.ConfigWritePolicy}
|
||||
}
|
||||
|
||||
func (lifecycle GamePluginProductionLifecycleBody) ToDomain() domain.GamePluginProductionLifecycle {
|
||||
return domain.GamePluginProductionLifecycle{Operations: domain.CopyStringSlice(lifecycle.Operations), DependencyPolicy: lifecycle.DependencyPolicy, ApprovalRequired: domain.CopyStringSlice(lifecycle.ApprovalRequired)}
|
||||
}
|
||||
|
||||
func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAccess {
|
||||
@@ -862,6 +952,30 @@ 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}
|
||||
}
|
||||
snapshots := make([]domain.GameClientBridgeSnapshotDeclaration, len(body.Snapshots))
|
||||
for index, snapshot := range body.Snapshots {
|
||||
snapshots[index] = domain.GameClientBridgeSnapshotDeclaration{Type: snapshot.Type, SchemaVersion: snapshot.SchemaVersion, SchemaRef: snapshot.SchemaRef, Retention: domain.GameClientBridgeRetention{KeepForSeconds: snapshot.KeepForSeconds, MaxRecords: snapshot.MaxRecords}}
|
||||
}
|
||||
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
|
||||
for index, template := range body.QueryTemplates {
|
||||
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
|
||||
}
|
||||
pages := make([]domain.GameClientBridgePageContract, len(body.Pages))
|
||||
for index, page := range body.Pages {
|
||||
pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys)}
|
||||
}
|
||||
companion := domain.GameClientBridgeCompanionDeclaration{}
|
||||
if body.Companion != nil {
|
||||
companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds}
|
||||
}
|
||||
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Companion: companion}
|
||||
}
|
||||
|
||||
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
||||
return domain.PluginLifecycleActions{
|
||||
Install: actions.Install,
|
||||
@@ -891,8 +1005,10 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
|
||||
Pages: pagesToDomain(request.Pages),
|
||||
Tags: domain.CopyStringSlice(request.Tags),
|
||||
AIPurposes: domain.CopyStringSlice(request.AIPurposes),
|
||||
ProductionLifecycle: request.ProductionLifecycle.ToDomain(),
|
||||
RemoteAccess: request.RemoteAccess.ToDomain(),
|
||||
RuntimeProfiles: request.RuntimeProfiles.ToDomain(),
|
||||
GameClientBridge: request.GameClientBridge.ToDomain(),
|
||||
ValidationViolations: domain.CopyStringSlice(request.ValidationViolations),
|
||||
}
|
||||
}
|
||||
@@ -1073,17 +1189,17 @@ func UserListFromDomain(users []domain.User) UserListResponse {
|
||||
func AIProviderFromDomain(provider domain.AIProvider) AIProviderResponse {
|
||||
provider = domain.CopyAIProvider(provider)
|
||||
return AIProviderResponse{
|
||||
ID: provider.ID,
|
||||
Name: provider.Name,
|
||||
Kind: provider.Kind,
|
||||
BaseURL: provider.BaseURL,
|
||||
APIKeyConfigured: provider.APIKeyRef != "",
|
||||
Models: provider.Models,
|
||||
DefaultModel: provider.DefaultModel,
|
||||
RelayMode: provider.RelayMode,
|
||||
TimeoutMS: provider.TimeoutMS,
|
||||
Status: provider.Status,
|
||||
RedactionPolicy: provider.RedactionPolicy,
|
||||
ID: provider.ID,
|
||||
Name: provider.Name,
|
||||
Kind: provider.Kind,
|
||||
BaseURLConfigured: provider.BaseURL != "",
|
||||
APIKeyConfigured: provider.APIKeyRef != "",
|
||||
Models: provider.Models,
|
||||
DefaultModel: provider.DefaultModel,
|
||||
RelayMode: provider.RelayMode,
|
||||
TimeoutMS: provider.TimeoutMS,
|
||||
Status: provider.Status,
|
||||
RedactionPolicy: provider.RedactionPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1135,8 +1251,10 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
|
||||
Pages: pagesFromDomain(plugin.Pages),
|
||||
Tags: plugin.Tags,
|
||||
AIPurposes: plugin.AIPurposes,
|
||||
ProductionLifecycle: productionLifecycleFromDomain(plugin.ProductionLifecycle),
|
||||
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
|
||||
GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge),
|
||||
ValidationViolations: plugin.ValidationViolations,
|
||||
Status: plugin.Status,
|
||||
}
|
||||
@@ -1224,14 +1342,46 @@ func MarketplacePluginFromDomain(plugin domain.PluginMarketplacePlugin) Marketpl
|
||||
Pages: pagesFromDomain(plugin.Pages),
|
||||
Tags: plugin.Tags,
|
||||
AIPurposes: plugin.AIPurposes,
|
||||
ProductionLifecycle: productionLifecycleFromDomain(plugin.ProductionLifecycle),
|
||||
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
|
||||
GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge),
|
||||
ValidationViolations: plugin.ValidationViolations,
|
||||
Status: plugin.Status,
|
||||
Source: plugin.Source,
|
||||
}
|
||||
}
|
||||
|
||||
func productionLifecycleFromDomain(lifecycle domain.GamePluginProductionLifecycle) GamePluginProductionLifecycleBody {
|
||||
lifecycle = domain.CopyGamePluginProductionLifecycle(lifecycle)
|
||||
return GamePluginProductionLifecycleBody{Operations: lifecycle.Operations, DependencyPolicy: lifecycle.DependencyPolicy, ApprovalRequired: lifecycle.ApprovalRequired}
|
||||
}
|
||||
|
||||
func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) GameClientBridgeManifestBody {
|
||||
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}
|
||||
}
|
||||
snapshots := make([]GameClientBridgeSnapshotDeclarationBody, len(value.Snapshots))
|
||||
for index, snapshot := range value.Snapshots {
|
||||
snapshots[index] = GameClientBridgeSnapshotDeclarationBody{Type: snapshot.Type, SchemaVersion: snapshot.SchemaVersion, SchemaRef: snapshot.SchemaRef, KeepForSeconds: snapshot.Retention.KeepForSeconds, MaxRecords: snapshot.Retention.MaxRecords}
|
||||
}
|
||||
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
|
||||
for index, template := range value.QueryTemplates {
|
||||
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
|
||||
}
|
||||
pages := make([]GameClientBridgePageContractBody, len(value.Pages))
|
||||
for index, page := range value.Pages {
|
||||
pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys}
|
||||
}
|
||||
var companion *GameClientBridgeCompanionDeclarationBody
|
||||
if value.Companion.ProfileKey != "" {
|
||||
companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds}
|
||||
}
|
||||
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Companion: companion}
|
||||
}
|
||||
|
||||
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
|
||||
items := make([]MarketplacePluginResponse, len(plugins))
|
||||
for i, plugin := range plugins {
|
||||
@@ -1606,19 +1756,25 @@ func pagesFromDomain(pages []domain.GamePluginPage) []GamePluginPageBody {
|
||||
|
||||
func capacityFromDomain(capacity domain.RunCapacity) RunCapacityResponse {
|
||||
return RunCapacityResponse{
|
||||
MaxJobs: capacity.MaxJobs,
|
||||
RunningJobs: capacity.RunningJobs,
|
||||
QueuedJobs: capacity.QueuedJobs,
|
||||
Summary: capacity.Summary,
|
||||
MaxJobs: capacity.MaxJobs,
|
||||
RunningJobs: capacity.RunningJobs,
|
||||
QueuedJobs: capacity.QueuedJobs,
|
||||
LogBacklogBatches: capacity.LogBacklogBatches,
|
||||
ArtifactBacklogChunks: capacity.ArtifactBacklogChunks,
|
||||
PressureCodes: domain.CopyStringSlice(capacity.PressureCodes),
|
||||
Summary: capacity.Summary,
|
||||
}
|
||||
}
|
||||
|
||||
func capacityToDomain(capacity RunCapacityResponse) domain.RunCapacity {
|
||||
return domain.RunCapacity{
|
||||
MaxJobs: capacity.MaxJobs,
|
||||
RunningJobs: capacity.RunningJobs,
|
||||
QueuedJobs: capacity.QueuedJobs,
|
||||
Summary: capacity.Summary,
|
||||
MaxJobs: capacity.MaxJobs,
|
||||
RunningJobs: capacity.RunningJobs,
|
||||
QueuedJobs: capacity.QueuedJobs,
|
||||
LogBacklogBatches: capacity.LogBacklogBatches,
|
||||
ArtifactBacklogChunks: capacity.ArtifactBacklogChunks,
|
||||
PressureCodes: domain.CopyStringSlice(capacity.PressureCodes),
|
||||
Summary: capacity.Summary,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestAIProviderResponseExposesOnlyKeyPresence(t *testing.T) {
|
||||
func TestAIProviderResponseExposesOnlySecretPresence(t *testing.T) {
|
||||
responseType := reflect.TypeOf(AIProviderResponse{})
|
||||
if _, ok := responseType.FieldByName("BaseURL"); ok {
|
||||
t.Fatal("AI provider response must not expose the provider base URL")
|
||||
}
|
||||
if _, ok := responseType.FieldByName("APIKey"); ok {
|
||||
t.Fatal("AI provider response must not expose raw API key")
|
||||
}
|
||||
@@ -21,6 +25,9 @@ func TestAIProviderResponseExposesOnlyKeyPresence(t *testing.T) {
|
||||
if _, ok := responseType.FieldByName("APIKeyConfigured"); !ok {
|
||||
t.Fatal("AI provider response must expose API key presence")
|
||||
}
|
||||
if _, ok := responseType.FieldByName("BaseURLConfigured"); !ok {
|
||||
t.Fatal("AI provider response must expose base URL presence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIProviderFromDomainCopiesModels(t *testing.T) {
|
||||
@@ -47,6 +54,9 @@ func TestAIProviderFromDomainCopiesModels(t *testing.T) {
|
||||
if !response.APIKeyConfigured {
|
||||
t.Fatal("expected configured API key presence")
|
||||
}
|
||||
if !response.BaseURLConfigured {
|
||||
t.Fatal("expected configured base URL presence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGamePluginManifestRegistrationToDomainCopiesSlices(t *testing.T) {
|
||||
@@ -113,3 +123,49 @@ func TestGamePluginFromDomainCopiesRegistryMetadata(t *testing.T) {
|
||||
t.Fatalf("expected plugin response registry metadata to be copied, got %+v", plugin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
body := GameClientBridgeManifestBody{
|
||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
|
||||
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,
|
||||
}},
|
||||
CommandRetentionSeconds: 86400,
|
||||
MaxCommands: 1000,
|
||||
Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||
}
|
||||
|
||||
domainManifest := body.ToDomain()
|
||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].TransportKey != "sqlite-db" || domainManifest.QueryTemplates[0].MaxRows != 50 || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
|
||||
}
|
||||
domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||
if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatal("query template page keys alias request DTO data")
|
||||
}
|
||||
domainManifest.Pages[0].QueryTemplateKeys[0] = "player.lookup"
|
||||
|
||||
response := gameClientBridgeManifestFromDomain(domainManifest)
|
||||
response.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||
if domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatal("query template page keys alias domain data")
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(response.QueryTemplates[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal safe query template projection: %v", err)
|
||||
}
|
||||
var projection map[string]any
|
||||
if err := json.Unmarshal(encoded, &projection); err != nil {
|
||||
t.Fatalf("decode safe query template projection: %v", err)
|
||||
}
|
||||
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutSeconds"}
|
||||
if len(projection) != len(expectedFields) {
|
||||
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
|
||||
}
|
||||
for _, field := range expectedFields {
|
||||
if _, exists := projection[field]; !exists {
|
||||
t.Fatalf("query template projection is missing %q: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestRuntimeLogEventDeclarationRoundTripUsesSafeProjection(t *testing.T) {
|
||||
body := GamePluginRuntimeProfilesBody{
|
||||
LogEvents: []RuntimeLogEventBody{{
|
||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||
}},
|
||||
}
|
||||
|
||||
profiles := body.ToDomain()
|
||||
if len(profiles.LogEvents) != 1 || profiles.LogEvents[0].EventType != "chat.message" || profiles.LogEvents[0].Severity != "info" {
|
||||
t.Fatalf("log event conversion lost declaration fields: %+v", profiles.LogEvents)
|
||||
}
|
||||
profiles.LogEvents[0].Title = "Mutated"
|
||||
if body.LogEvents[0].Title != "Chat message" {
|
||||
t.Fatal("log event domain conversion aliases request DTO data")
|
||||
}
|
||||
|
||||
projection := runtimeProfilesFromDomain(domain.GamePluginRuntimeProfiles{LogEvents: profiles.LogEvents})
|
||||
encoded, err := json.Marshal(projection.LogEvents[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal log event projection: %v", err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||
t.Fatalf("decode log event projection: %v", err)
|
||||
}
|
||||
expected := []string{"key", "title", "sourceKey", "eventType", "permission", "schemaRef", "retentionDays", "severity"}
|
||||
if len(fields) != len(expected) {
|
||||
t.Fatalf("log event projection contains unexpected fields: %s", encoded)
|
||||
}
|
||||
for _, field := range expected {
|
||||
if _, exists := fields[field]; !exists {
|
||||
t.Fatalf("log event projection is missing %q: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,17 @@ type RuntimeLogSourceBody struct {
|
||||
RetentionDays int `json:"retentionDays,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeLogEventBody struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
SourceKey string `json:"sourceKey"`
|
||||
EventType string `json:"eventType"`
|
||||
Permission string `json:"permission"`
|
||||
SchemaRef string `json:"schemaRef"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type RuntimeTransportProfileBody struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -145,6 +156,7 @@ type GamePluginRuntimeProfilesBody struct {
|
||||
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
|
||||
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
|
||||
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
||||
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
|
||||
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
|
||||
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
|
||||
}
|
||||
@@ -170,6 +182,9 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
|
||||
for _, item := range body.LogSources {
|
||||
profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
||||
}
|
||||
for _, item := range body.LogEvents {
|
||||
profiles.LogEvents = append(profiles.LogEvents, domain.RuntimeLogEvent{Key: item.Key, Title: item.Title, SourceKey: item.SourceKey, EventType: item.EventType, Permission: item.Permission, SchemaRef: item.SchemaRef, RetentionDays: item.RetentionDays, Severity: domain.RuntimeLogEventSeverity(item.Severity)})
|
||||
}
|
||||
for _, item := range body.TransportProfiles {
|
||||
profiles.TransportProfiles = append(profiles.TransportProfiles, domain.RuntimeTransportProfile{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: domain.CopyStringSlice(item.Capabilities)})
|
||||
}
|
||||
@@ -217,6 +232,9 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
|
||||
for _, item := range profiles.LogSources {
|
||||
body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
||||
}
|
||||
for _, item := range profiles.LogEvents {
|
||||
body.LogEvents = append(body.LogEvents, RuntimeLogEventBody{Key: item.Key, Title: item.Title, SourceKey: item.SourceKey, EventType: item.EventType, Permission: item.Permission, SchemaRef: item.SchemaRef, RetentionDays: item.RetentionDays, Severity: string(item.Severity)})
|
||||
}
|
||||
for _, item := range profiles.TransportProfiles {
|
||||
body.TransportProfiles = append(body.TransportProfiles, RuntimeTransportProfileBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: item.Capabilities})
|
||||
}
|
||||
|
||||
@@ -13,5 +13,11 @@ Required model groups:
|
||||
- artifacts and chunks.
|
||||
- log streams and ingestion cursors.
|
||||
- audit events.
|
||||
- durable alerts and their acknowledgement/resolution audit links.
|
||||
- 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.
|
||||
|
||||
+94
-10
@@ -181,6 +181,8 @@ 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 domain.GamePluginProductionLifecycle `json:"productionLifecycle" db:"production_lifecycle"`
|
||||
// RemoteAccess stores plugin-declared remote access metadata.
|
||||
RemoteAccess GamePluginRemoteAccess `json:"remoteAccess" db:"remote_access"`
|
||||
// RuntimeProfiles stores validated manifest-declared runtime contracts.
|
||||
@@ -279,14 +281,18 @@ type JobRetryPolicy struct {
|
||||
}
|
||||
|
||||
type JobExecutionInput struct {
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty" db:"workspace_scope"`
|
||||
Content string `json:"content,omitempty" db:"content"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty" db:"expected_version"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty" db:"expected_checksum"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty" db:"max_read_bytes"`
|
||||
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty" db:"remote_adapter_key"`
|
||||
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty" db:"remote_adapter_kind"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty" db:"timeout_seconds"`
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty" db:"workspace_scope"`
|
||||
Content string `json:"content,omitempty" db:"content"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty" db:"expected_version"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty" db:"expected_checksum"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty" db:"max_read_bytes"`
|
||||
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty" db:"remote_adapter_key"`
|
||||
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty" db:"remote_adapter_kind"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty" db:"timeout_seconds"`
|
||||
PluginID string `json:"pluginId,omitempty" db:"plugin_id"`
|
||||
LifecycleOperation string `json:"lifecycleOperation,omitempty" db:"lifecycle_operation"`
|
||||
TargetVersion string `json:"targetVersion,omitempty" db:"target_version"`
|
||||
Inputs map[string]string `json:"inputs,omitempty" db:"inputs"`
|
||||
}
|
||||
|
||||
type JobExecutionResult struct {
|
||||
@@ -428,6 +434,82 @@ type AuditEvent struct {
|
||||
|
||||
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"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
CurrentVersion string `json:"currentVersion,omitempty" db:"current_version"`
|
||||
TargetVersion string `json:"targetVersion,omitempty" db:"target_version"`
|
||||
PreviousVersion string `json:"previousVersion,omitempty" db:"previous_version"`
|
||||
DesiredState domain.PluginLifecycleState `json:"desiredState" db:"desired_state"`
|
||||
CurrentState domain.PluginLifecycleState `json:"currentState" db:"current_state"`
|
||||
LastOperation domain.PluginLifecycleOperation `json:"lastOperation,omitempty" db:"last_operation"`
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (PluginLifecycleInstallation) TableName() string { return "plugin_lifecycle_installations" }
|
||||
|
||||
type AIConfigDiff struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
RequestID string `json:"requestId" db:"request_id"`
|
||||
CreatedBy string `json:"createdBy" db:"created_by"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
PluginID string `json:"pluginId,omitempty" db:"plugin_id"`
|
||||
ProviderID string `json:"providerId,omitempty" db:"provider_id"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
Key string `json:"key" db:"key"`
|
||||
ConfigVersion int `json:"configVersion" db:"config_version"`
|
||||
CurrentConfigChecksum string `json:"currentConfigChecksum" db:"current_config_checksum"`
|
||||
ProposedConfig string `json:"proposedConfig" db:"proposed_config"`
|
||||
DiffSummary string `json:"diffSummary" db:"diff_summary"`
|
||||
State domain.AIConfigDiffState `json:"state" db:"state"`
|
||||
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
|
||||
ApprovedBy string `json:"approvedBy,omitempty" db:"approved_by"`
|
||||
ApprovedAt time.Time `json:"approvedAt,omitempty" db:"approved_at"`
|
||||
ApprovalIdempotencyKey string `json:"approvalIdempotencyKey,omitempty" db:"approval_idempotency_key"`
|
||||
CancelledBy string `json:"cancelledBy,omitempty" db:"cancelled_by"`
|
||||
CancelledAt time.Time `json:"cancelledAt,omitempty" db:"cancelled_at"`
|
||||
JobID string `json:"jobId,omitempty" db:"job_id"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (AIConfigDiff) TableName() string { return "ai_config_diffs" }
|
||||
|
||||
func UserFromDomain(user domain.User) User {
|
||||
user = domain.CopyUser(user)
|
||||
return User{
|
||||
@@ -511,6 +593,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePlugin {
|
||||
Pages: pagesFromDomain(plugin.Pages),
|
||||
Tags: plugin.Tags,
|
||||
AIPurposes: plugin.AIPurposes,
|
||||
ProductionLifecycle: domain.CopyGamePluginProductionLifecycle(plugin.ProductionLifecycle),
|
||||
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||
RuntimeProfiles: domain.CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles),
|
||||
ValidationViolations: plugin.ValidationViolations,
|
||||
@@ -536,6 +619,7 @@ func (plugin GamePlugin) ToDomain() domain.GamePlugin {
|
||||
Pages: pagesToDomain(plugin.Pages),
|
||||
Tags: domain.CopyStringSlice(plugin.Tags),
|
||||
AIPurposes: domain.CopyStringSlice(plugin.AIPurposes),
|
||||
ProductionLifecycle: domain.CopyGamePluginProductionLifecycle(plugin.ProductionLifecycle),
|
||||
RemoteAccess: plugin.RemoteAccess.ToDomain(),
|
||||
RuntimeProfiles: domain.CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles),
|
||||
ValidationViolations: domain.CopyStringSlice(plugin.ValidationViolations),
|
||||
@@ -798,11 +882,11 @@ func (job Job) ToDomain() domain.Job {
|
||||
}
|
||||
|
||||
func executionInputFromDomain(input domain.JobExecutionInput) JobExecutionInput {
|
||||
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds}
|
||||
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs)}
|
||||
}
|
||||
|
||||
func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
|
||||
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds}
|
||||
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs)}
|
||||
}
|
||||
|
||||
func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
@@ -17,6 +18,9 @@ func TestTableNames(t *testing.T) {
|
||||
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",
|
||||
ClientManagerSession{}.TableName(): "client_manager_sessions",
|
||||
ClientManagerRegistrationNonce{}.TableName(): "client_manager_registration_nonces",
|
||||
@@ -43,8 +47,13 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
|
||||
Pages: []domain.GamePluginPage{
|
||||
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
|
||||
},
|
||||
Tags: []string{"survival"},
|
||||
AIPurposes: []string{"logs.diagnose"},
|
||||
Tags: []string{"survival"},
|
||||
AIPurposes: []string{"logs.diagnose"},
|
||||
ProductionLifecycle: domain.GamePluginProductionLifecycle{
|
||||
Operations: []string{"install", "upgrade", "rollback"},
|
||||
DependencyPolicy: "required",
|
||||
ApprovalRequired: []string{"rollback"},
|
||||
},
|
||||
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}}}},
|
||||
Permissions: domain.PluginPermissions{
|
||||
Jobs: true,
|
||||
@@ -61,6 +70,8 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
|
||||
roundTrip.Pages[0].Permissions[0] = "ai.invoke"
|
||||
roundTrip.Tags[0] = "mutated"
|
||||
roundTrip.AIPurposes[0] = "config.suggest"
|
||||
roundTrip.ProductionLifecycle.Operations[0] = "retire"
|
||||
roundTrip.ProductionLifecycle.ApprovalRequired[0] = "disable"
|
||||
roundTrip.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] = "process.stop"
|
||||
|
||||
if source.RequiredRunCapabilities[0] != "process.start" {
|
||||
@@ -75,11 +86,45 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
|
||||
if row.DeclaredPermissions[0] != "server.logs.read" || row.Pages[0].Permissions[0] != "server.logs.read" || row.Tags[0] != "survival" || row.AIPurposes[0] != "logs.diagnose" {
|
||||
t.Fatalf("expected model plugin registry metadata to remain unchanged, got %+v", row)
|
||||
}
|
||||
if source.ProductionLifecycle.Operations[0] != "install" || row.ProductionLifecycle.Operations[0] != "install" || source.ProductionLifecycle.ApprovalRequired[0] != "rollback" || row.ProductionLifecycle.ApprovalRequired[0] != "rollback" {
|
||||
t.Fatalf("expected production lifecycle declaration to round-trip without aliasing, source=%+v row=%+v", source.ProductionLifecycle, row.ProductionLifecycle)
|
||||
}
|
||||
if source.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] != "process.start" || row.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] != "process.start" {
|
||||
t.Fatalf("expected runtime profiles to round-trip without aliasing, source=%+v row=%+v", source.RuntimeProfiles, row.RuntimeProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) {
|
||||
source := domain.JobExecutionInput{
|
||||
WorkspaceScope: "server-workspace",
|
||||
PluginID: "game.scum",
|
||||
LifecycleOperation: "upgrade",
|
||||
TargetVersion: "2.0.0",
|
||||
Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-123"},
|
||||
}
|
||||
|
||||
row := executionInputFromDomain(source)
|
||||
source.Inputs["playerId"] = "source-mutated"
|
||||
if row.Inputs["playerId"] != "steam-123" {
|
||||
t.Fatalf("expected model execution inputs to be isolated from source mutation, row=%+v", row.Inputs)
|
||||
}
|
||||
source.Inputs["playerId"] = "steam-123"
|
||||
row.Inputs["playerId"] = "row-mutated"
|
||||
if source.Inputs["playerId"] != "steam-123" {
|
||||
t.Fatalf("expected source execution inputs to be isolated from model mutation, source=%+v", source.Inputs)
|
||||
}
|
||||
row.Inputs["playerId"] = "steam-123"
|
||||
|
||||
roundTrip := row.ToDomain()
|
||||
if !reflect.DeepEqual(roundTrip, source) {
|
||||
t.Fatalf("expected lifecycle execution metadata to round-trip, got %+v", roundTrip)
|
||||
}
|
||||
roundTrip.Inputs["playerId"] = "mutated"
|
||||
if source.Inputs["playerId"] != "steam-123" || row.Inputs["playerId"] != "steam-123" {
|
||||
t.Fatalf("expected execution inputs to round-trip without aliasing, source=%+v row=%+v", source.Inputs, row.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIProviderModelUsesKeyReference(t *testing.T) {
|
||||
source := domain.AIProvider{
|
||||
ID: "ai.openai",
|
||||
|
||||
@@ -32,8 +32,10 @@ AI invocation responses must be bounded and must not include raw provider creden
|
||||
- `AIProviderCreateRequest`: create provider metadata with `apiKeyRef`, never raw key material.
|
||||
- `AIProviderUpdateRequest`: replace editable provider metadata while preserving status through the service layer.
|
||||
- `AIProviderStatusRequest`: set provider status to `active` or `disabled`.
|
||||
- `AIProviderResponse`: redacted provider response with `apiKeyConfigured` only; it does not expose the stored secret reference.
|
||||
- `AIProviderResponse`: redacted provider response with `apiKeyConfigured` and `baseUrlConfigured` only; it does not expose the stored secret reference or provider endpoint.
|
||||
- `AIProviderTestResponse`: local metadata validation result with `mode=metadata`; live external connectivity is deferred.
|
||||
- `AIProviderModelsResponse`: configured model list and default model, without credentials.
|
||||
|
||||
Management endpoints must reject raw key-shaped values in `apiKeyRef`. Platform-mediated AI invocation is implemented through the platform service boundary; live external connectivity tests and remote model discovery are deferred to later changes.
|
||||
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.
|
||||
|
||||
@@ -20,6 +20,8 @@ Named control DTOs:
|
||||
|
||||
Control payloads must remain small and must not include logs, artifact chunks, host paths, raw credentials, direct sockets, or long task results. Hello creates or updates run endpoint metadata and issues an in-memory platform session token. Heartbeat requires that active session token and may request capability refresh when the fingerprint changes.
|
||||
|
||||
Capacity reports include bounded `maxJobs`, `runningJobs`, `queuedJobs`, `logBacklogBatches`, `artifactBacklogChunks`, and enumerated pressure codes. They report queue and spool counts only, never log bodies, artifact chunks, machine paths, PIDs, sockets, credentials, or transport endpoints.
|
||||
|
||||
Control is the highest-priority run/platform path. Artifact/file transfer load must not delay heartbeat acceptance or mutate heartbeat capacity state through heavy payload fields.
|
||||
|
||||
## Job
|
||||
@@ -46,6 +48,10 @@ Named job DTOs:
|
||||
|
||||
Jobs must carry bounded metadata such as `jobId`, `runEndpointId`, `serverInstanceId`, `capability`, `idempotencyKey`, lease token, attempt, progress, terminal state, message, error code, and result reference. Job payloads must not carry logs, artifact chunks, host paths, raw credentials, direct sockets, or large inline result bodies.
|
||||
|
||||
Plugin lifecycle assignments may add only a validated plugin identifier, enumerated lifecycle operation, target version, and logical workspace scope. Install, enable, disable, upgrade, rollback, retire, and dependency-check remain Platform-authorized jobs; assignments cannot carry arbitrary shell, provider configuration, raw credentials, host paths, PIDs, sockets, DSNs, or RCON secrets.
|
||||
|
||||
Approved `config.write` and bounded `files.read`/`files.write` assignments carry logical keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local.
|
||||
|
||||
Job ack, progress, cancellation polling, reconciliation, and terminal result calls are lightweight lifecycle metadata. They must remain valid while artifact chunks or log retries are pending, and duplicate equivalent terminal results remain idempotent under channel pressure.
|
||||
|
||||
## Log Ingest
|
||||
@@ -94,6 +100,14 @@ Artifact upload supports active run session validation, job/server-instance owne
|
||||
|
||||
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
|
||||
|
||||
## Client Manager lifecycle channel
|
||||
|
||||
Client Manager lifecycle jobs use the independent capabilities `client-manager.deploy`, `client-manager.control`, `client-manager.update`, `client-manager.rollback`, and `client-manager.uninstall`. Run obtains a fenced logical contract from `POST /api/v1/run/jobs/client-manager-input` and reads resumable artifact chunks from `POST /api/v1/run/jobs/client-manager-chunk`; these routes are separate from artifact upload, Run control, logs, and optional game-client traffic. The contract carries installation/profile, target, version/revision, checksum, deployment/key generations, fixed executable reference, bounded arguments/timeouts, and idempotency. For a plugin-declared companion profile it also carries a generic `companionConfig` materialization contract: safe relative template/schema/output references, the fenced component identity, declared component capabilities, Platform URL source, proof environment-variable name, component-session/TLS policy, and bounded timing values.
|
||||
|
||||
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.
|
||||
|
||||
## Game Client Bridge
|
||||
|
||||
The optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport.
|
||||
|
||||
@@ -36,6 +36,12 @@ type StoreSnapshot struct {
|
||||
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"`
|
||||
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
|
||||
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
|
||||
}
|
||||
|
||||
type FileStore struct {
|
||||
@@ -158,6 +164,33 @@ 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}
|
||||
}
|
||||
|
||||
func (store *FileStore) AIConfigDiffs() AIConfigDiffRepository {
|
||||
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||
return &persistentGameClientBridgeCommandRepository{
|
||||
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
||||
repository: store.MemoryStore.bridgeCommands,
|
||||
}
|
||||
}
|
||||
|
||||
func (store *FileStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository {
|
||||
return &persistentRepository[domain.GameClientBridgeSnapshot, domain.GameClientBridgeSnapshotFilter]{repository: store.MemoryStore.bridgeSnapshots, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
|
||||
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) load() error {
|
||||
data, err := os.ReadFile(store.path)
|
||||
if err != nil {
|
||||
@@ -224,6 +257,12 @@ func (store *FileStore) snapshot() StoreSnapshot {
|
||||
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),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +290,12 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
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)
|
||||
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
||||
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
||||
}
|
||||
|
||||
type mutableRepository[T any, F any] interface {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
type memoryGameClientBridgeCommandRepository struct {
|
||||
*memoryRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]
|
||||
}
|
||||
|
||||
func newMemoryGameClientBridgeCommandRepository() *memoryGameClientBridgeCommandRepository {
|
||||
return &memoryGameClientBridgeCommandRepository{memoryRepository: newMemoryRepository(
|
||||
func(command domain.GameClientBridgeCommand) string { return command.ID },
|
||||
domain.CopyGameClientBridgeCommand,
|
||||
matchGameClientBridgeCommand,
|
||||
)}
|
||||
}
|
||||
|
||||
func (repository *memoryGameClientBridgeCommandRepository) GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey string) (domain.GameClientBridgeCommand, error) {
|
||||
values, err := repository.List(domain.GameClientBridgeCommandFilter{ServerInstanceID: serverInstanceID, RequesterID: requesterID, CommandType: commandType, IdempotencyKey: idempotencyKey})
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return domain.GameClientBridgeCommand{}, ErrNotFound
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
type persistentGameClientBridgeCommandRepository struct {
|
||||
*persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]
|
||||
repository GameClientBridgeCommandRepository
|
||||
}
|
||||
|
||||
func (repository *persistentGameClientBridgeCommandRepository) GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey string) (domain.GameClientBridgeCommand, error) {
|
||||
return repository.repository.GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey)
|
||||
}
|
||||
|
||||
type memoryGameClientBridgeSnapshotRepository struct {
|
||||
*memoryRepository[domain.GameClientBridgeSnapshot, domain.GameClientBridgeSnapshotFilter]
|
||||
}
|
||||
|
||||
func newMemoryGameClientBridgeSnapshotRepository() *memoryGameClientBridgeSnapshotRepository {
|
||||
return &memoryGameClientBridgeSnapshotRepository{memoryRepository: newMemoryRepository(
|
||||
func(snapshot domain.GameClientBridgeSnapshot) string { return snapshot.ID },
|
||||
domain.CopyGameClientBridgeSnapshot,
|
||||
matchGameClientBridgeSnapshot,
|
||||
)}
|
||||
}
|
||||
|
||||
func (repository *memoryGameClientBridgeSnapshotRepository) List(filter domain.GameClientBridgeSnapshotFilter) ([]domain.GameClientBridgeSnapshot, error) {
|
||||
limit := filter.Limit
|
||||
filter.Limit = 0
|
||||
values, err := repository.memoryRepository.List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(values, func(left, right int) bool {
|
||||
if !values[left].ObservedAt.Equal(values[right].ObservedAt) {
|
||||
return values[left].ObservedAt.After(values[right].ObservedAt)
|
||||
}
|
||||
if values[left].Sequence != values[right].Sequence {
|
||||
return values[left].Sequence > values[right].Sequence
|
||||
}
|
||||
return values[left].ID < values[right].ID
|
||||
})
|
||||
if limit > 0 && len(values) > limit {
|
||||
values = values[:limit]
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
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)}
|
||||
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")
|
||||
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"}} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
retained, err := store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{CompletedBefore: now})
|
||||
if err != nil || len(retained) != 1 {
|
||||
t.Fatalf("completed retention query: len=%d err=%v", len(retained), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeSnapshotRepositoryOrderingLimitAndStreams(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
now := time.Now().UTC()
|
||||
for _, snapshot := range []domain.GameClientBridgeSnapshot{
|
||||
{ID: "snapshot-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", Sequence: 1, ObservedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)},
|
||||
{ID: "snapshot-2", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", Sequence: 2, ObservedAt: now, ExpiresAt: now.Add(time.Hour)},
|
||||
{ID: "snapshot-expired", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "vehicles", StreamKey: "current", Sequence: 1, ObservedAt: now, ExpiresAt: now.Add(-time.Minute)},
|
||||
} {
|
||||
if err := store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||
t.Fatalf("create snapshot: %v", err)
|
||||
}
|
||||
}
|
||||
items, err := store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", Type: "players", Limit: 1})
|
||||
if err != nil || len(items) != 1 || items[0].Sequence != 2 {
|
||||
t.Fatalf("newest snapshot query: %#v err=%v", items, err)
|
||||
}
|
||||
expired, err := store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ExpiresBefore: now})
|
||||
if err != nil || len(expired) != 1 || expired[0].ID != "snapshot-expired" {
|
||||
t.Fatalf("snapshot retention query: %#v err=%v", expired, err)
|
||||
}
|
||||
for _, stream := range []domain.GameClientBridgeSnapshotStream{
|
||||
{ID: "stream-players", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", LatestSequence: 2},
|
||||
{ID: "stream-vehicles", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "vehicles", StreamKey: "current", LatestSequence: 7},
|
||||
} {
|
||||
if err := store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||
t.Fatalf("create stream: %v", err)
|
||||
}
|
||||
}
|
||||
streams, err := store.GameClientBridgeSnapshotStreams().List(domain.GameClientBridgeSnapshotStreamFilter{ServerInstanceID: "server-1"})
|
||||
if err != nil || len(streams) != 2 || streams[0].LatestSequence == streams[1].LatestSequence {
|
||||
t.Fatalf("independent stream sequences: %#v err=%v", streams, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeFileStorePersistsAndDeletes(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "metadata.json")
|
||||
store, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("new file store: %v", err)
|
||||
}
|
||||
command := domain.GameClientBridgeCommand{ID: "command-1", ServerInstanceID: "server-1", CommandType: "diagnostic.safe", RequesterID: "user-1", IdempotencyKey: "diag-1", State: domain.GameClientBridgeCommandClaimed, Claim: domain.GameClientBridgeClaim{FencingToken: 3}}
|
||||
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ServerInstanceID: "server-1", Type: "health", StreamKey: "current", Sequence: 4}
|
||||
stream := domain.GameClientBridgeSnapshotStream{ID: "stream-1", ServerInstanceID: "server-1", Type: "health", StreamKey: "current", LatestSequence: 4}
|
||||
if err := store.GameClientBridgeCommands().Create(command); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restarted, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("restart file store: %v", err)
|
||||
}
|
||||
if got, err := restarted.GameClientBridgeCommands().Get(command.ID); err != nil || got.Claim.FencingToken != 3 {
|
||||
t.Fatalf("persisted command: %#v err=%v", got, err)
|
||||
}
|
||||
if got, err := restarted.GameClientBridgeSnapshots().Get(snapshot.ID); err != nil || got.Sequence != 4 {
|
||||
t.Fatalf("persisted snapshot: %#v err=%v", got, err)
|
||||
}
|
||||
if got, err := restarted.GameClientBridgeSnapshotStreams().Get(stream.ID); err != nil || got.LatestSequence != 4 {
|
||||
t.Fatalf("persisted stream: %#v err=%v", got, err)
|
||||
}
|
||||
if err := restarted.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
|
||||
t.Fatalf("delete snapshot: %v", err)
|
||||
}
|
||||
reloaded, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload file store: %v", err)
|
||||
}
|
||||
if _, err := reloaded.GameClientBridgeSnapshots().Get(snapshot.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("deleted snapshot revived: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLSnapshotRoundTripIncludesGameClientBridge(t *testing.T) {
|
||||
source := &MySQLStore{MemoryStore: NewMemoryStore()}
|
||||
_ = source.MemoryStore.GameClientBridgeCommands().Create(domain.GameClientBridgeCommand{ID: "command-1"})
|
||||
_ = source.MemoryStore.GameClientBridgeSnapshots().Create(domain.GameClientBridgeSnapshot{ID: "snapshot-1"})
|
||||
_ = source.MemoryStore.GameClientBridgeSnapshotStreams().Create(domain.GameClientBridgeSnapshotStream{ID: "stream-1", LatestSequence: 8})
|
||||
target := &MySQLStore{MemoryStore: NewMemoryStore()}
|
||||
target.loadSnapshot(source.snapshot())
|
||||
if _, err := target.MemoryStore.GameClientBridgeCommands().Get("command-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := target.MemoryStore.GameClientBridgeSnapshots().Get("snapshot-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stream, err := target.MemoryStore.GameClientBridgeSnapshotStreams().Get("stream-1"); err != nil || stream.LatestSequence != 8 {
|
||||
t.Fatalf("stream round trip: %#v err=%v", stream, err)
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,33 @@ 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}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) AIConfigDiffs() AIConfigDiffRepository {
|
||||
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||
return &persistentGameClientBridgeCommandRepository{
|
||||
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
||||
repository: store.MemoryStore.bridgeCommands,
|
||||
}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository {
|
||||
return &persistentRepository[domain.GameClientBridgeSnapshot, domain.GameClientBridgeSnapshotFilter]{repository: store.MemoryStore.bridgeSnapshots, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
|
||||
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) initialize() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -228,6 +255,12 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
|
||||
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),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,4 +288,10 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
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)
|
||||
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
||||
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
||||
}
|
||||
|
||||
@@ -178,6 +178,52 @@ 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)
|
||||
List(domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
|
||||
Update(domain.PluginLifecycleInstallation) error
|
||||
}
|
||||
|
||||
type AIConfigDiffRepository interface {
|
||||
Create(domain.AIConfigDiffPreview) error
|
||||
Get(id string) (domain.AIConfigDiffPreview, error)
|
||||
List(domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
||||
Update(domain.AIConfigDiffPreview) error
|
||||
}
|
||||
|
||||
type GameClientBridgeCommandRepository interface {
|
||||
Create(domain.GameClientBridgeCommand) error
|
||||
Get(id string) (domain.GameClientBridgeCommand, error)
|
||||
GetByIdempotency(serverInstanceID, requesterID, commandType, idempotencyKey string) (domain.GameClientBridgeCommand, error)
|
||||
List(domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
|
||||
Update(domain.GameClientBridgeCommand) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotRepository interface {
|
||||
Create(domain.GameClientBridgeSnapshot) error
|
||||
Get(id string) (domain.GameClientBridgeSnapshot, error)
|
||||
List(domain.GameClientBridgeSnapshotFilter) ([]domain.GameClientBridgeSnapshot, error)
|
||||
Update(domain.GameClientBridgeSnapshot) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotStreamRepository interface {
|
||||
Create(domain.GameClientBridgeSnapshotStream) error
|
||||
Get(id string) (domain.GameClientBridgeSnapshotStream, error)
|
||||
List(domain.GameClientBridgeSnapshotStreamFilter) ([]domain.GameClientBridgeSnapshotStream, error)
|
||||
Update(domain.GameClientBridgeSnapshotStream) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Users() UserRepository
|
||||
AuthSessions() AuthSessionRepository
|
||||
@@ -202,6 +248,12 @@ type Store interface {
|
||||
AuditEvents() AuditEventRepository
|
||||
MetricSamples() MetricSampleRepository
|
||||
Backups() BackupRepository
|
||||
Alerts() AlertRepository
|
||||
PluginLifecycles() PluginLifecycleRepository
|
||||
AIConfigDiffs() AIConfigDiffRepository
|
||||
GameClientBridgeCommands() GameClientBridgeCommandRepository
|
||||
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
|
||||
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
@@ -228,6 +280,12 @@ type MemoryStore struct {
|
||||
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
|
||||
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
|
||||
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
@@ -343,6 +401,28 @@ 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,
|
||||
matchPluginLifecycle,
|
||||
),
|
||||
aiConfigDiffs: newMemoryRepository(
|
||||
func(preview domain.AIConfigDiffPreview) string { return preview.ID },
|
||||
domain.CopyAIConfigDiffPreview,
|
||||
matchAIConfigDiff,
|
||||
),
|
||||
bridgeCommands: newMemoryGameClientBridgeCommandRepository(),
|
||||
bridgeSnapshots: newMemoryGameClientBridgeSnapshotRepository(),
|
||||
bridgeStreams: newMemoryRepository(
|
||||
func(stream domain.GameClientBridgeSnapshotStream) string { return stream.ID },
|
||||
domain.CopyGameClientBridgeSnapshotStream,
|
||||
matchGameClientBridgeSnapshotStream,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +461,20 @@ func (store *MemoryStore) LogStreams() LogStreamRepository { return store.
|
||||
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
|
||||
}
|
||||
func (store *MemoryStore) AIConfigDiffs() AIConfigDiffRepository { return store.aiConfigDiffs }
|
||||
func (store *MemoryStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||
return store.bridgeCommands
|
||||
}
|
||||
func (store *MemoryStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository {
|
||||
return store.bridgeSnapshots
|
||||
}
|
||||
func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
|
||||
return store.bridgeStreams
|
||||
}
|
||||
|
||||
type memoryRepository[T any, F any] struct {
|
||||
mu sync.RWMutex
|
||||
@@ -632,3 +726,52 @@ func matchBackup(record domain.BackupRecord, filter domain.BackupFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || record.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(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) &&
|
||||
(filter.CurrentState == "" || installation.CurrentState == filter.CurrentState)
|
||||
}
|
||||
|
||||
func matchAIConfigDiff(preview domain.AIConfigDiffPreview, filter domain.AIConfigDiffFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || preview.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.PluginID == "" || preview.PluginID == filter.PluginID) &&
|
||||
(filter.State == "" || preview.State == filter.State)
|
||||
}
|
||||
|
||||
func matchGameClientBridgeCommand(command domain.GameClientBridgeCommand, filter domain.GameClientBridgeCommandFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || command.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.PluginID == "" || command.PluginID == filter.PluginID) &&
|
||||
(filter.ProfileKey == "" || command.ProfileKey == filter.ProfileKey) &&
|
||||
(filter.State == "" || command.State == filter.State) &&
|
||||
(filter.RequesterID == "" || command.RequesterID == filter.RequesterID) &&
|
||||
(filter.CommandType == "" || command.CommandType == filter.CommandType) &&
|
||||
(filter.IdempotencyKey == "" || command.IdempotencyKey == filter.IdempotencyKey) &&
|
||||
(filter.ExpiresBefore.IsZero() || !command.ExpiresAt.After(filter.ExpiresBefore)) &&
|
||||
(filter.CompletedBefore.IsZero() || (!command.CompletedAt.IsZero() && !command.CompletedAt.After(filter.CompletedBefore)))
|
||||
}
|
||||
|
||||
func matchGameClientBridgeSnapshot(snapshot domain.GameClientBridgeSnapshot, filter domain.GameClientBridgeSnapshotFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || snapshot.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.PluginID == "" || snapshot.PluginID == filter.PluginID) &&
|
||||
(filter.ProfileKey == "" || snapshot.ProfileKey == filter.ProfileKey) &&
|
||||
(filter.Type == "" || snapshot.Type == filter.Type) &&
|
||||
(filter.StreamKey == "" || snapshot.StreamKey == filter.StreamKey) &&
|
||||
(filter.ObservedAfter.IsZero() || snapshot.ObservedAt.After(filter.ObservedAfter)) &&
|
||||
(filter.ExpiresBefore.IsZero() || !snapshot.ExpiresAt.After(filter.ExpiresBefore))
|
||||
}
|
||||
|
||||
func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotStream, filter domain.GameClientBridgeSnapshotStreamFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || stream.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.PluginID == "" || stream.PluginID == filter.PluginID) &&
|
||||
(filter.ProfileKey == "" || stream.ProfileKey == filter.ProfileKey) &&
|
||||
(filter.Type == "" || stream.Type == filter.Type) &&
|
||||
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
|
||||
LastReconciledAt: stamp,
|
||||
ReconcileCount: 2,
|
||||
ReconcileOutcome: "confirmed active attempt",
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "name=approved\n", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("1", 64), MaxReadBytes: 64 * 1024},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "name=approved\n", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("1", 64), MaxReadBytes: 64 * 1024, Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-123"}},
|
||||
ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, AuditSummary: "bounded read", Content: "private-read"},
|
||||
}
|
||||
if err := store.Jobs().Create(job); err != nil {
|
||||
@@ -194,9 +194,17 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get reloaded job by idempotency: %v", err)
|
||||
}
|
||||
if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" || gotJob.Attempt != 2 || gotJob.RetryPolicy.MaxAttempts != 4 || gotJob.LeaseTokenHash != strings.Repeat("d", 64) || gotJob.ReconcileCount != 2 || gotJob.ExecutionInput.Content != "name=approved\n" || gotJob.ExecutionResult.Content != "private-read" {
|
||||
if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" || gotJob.Attempt != 2 || gotJob.RetryPolicy.MaxAttempts != 4 || gotJob.LeaseTokenHash != strings.Repeat("d", 64) || gotJob.ReconcileCount != 2 || gotJob.ExecutionInput.Content != "name=approved\n" || gotJob.ExecutionInput.Inputs["templateKey"] != "players.by-id" || gotJob.ExecutionInput.Inputs["playerId"] != "steam-123" || gotJob.ExecutionResult.Content != "private-read" {
|
||||
t.Fatalf("unexpected reloaded job: %+v", gotJob)
|
||||
}
|
||||
gotJob.ExecutionInput.Inputs["playerId"] = "mutated"
|
||||
againJob, err := reloaded.Jobs().GetByIdempotency("run-local", "idem-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get reloaded job again: %v", err)
|
||||
}
|
||||
if againJob.ExecutionInput.Inputs["playerId"] != "steam-123" {
|
||||
t.Fatalf("expected reloaded job inputs to be isolated, got %+v", againJob.ExecutionInput.Inputs)
|
||||
}
|
||||
gotPlugin, err := reloaded.GamePlugins().Get(plugin.ID)
|
||||
if err != nil || len(gotPlugin.RuntimeProfiles.LifecycleProfiles) != 1 || gotPlugin.RuntimeProfiles.LifecycleProfiles[0].Key != "local" {
|
||||
t.Fatalf("unexpected reloaded runtime profiles: plugin=%+v err=%v", gotPlugin, err)
|
||||
@@ -240,7 +248,7 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
||||
Attempt: 2, QueueEligibleAt: stamp, LeaseTokenHash: strings.Repeat("e", 64), LeaseSessionGen: 4,
|
||||
LeaseExpiresAt: stamp.Add(time.Minute), LastProgressSeq: 8, CancelReason: "stop", CancelRequestedAt: stamp,
|
||||
LastReconciledAt: stamp, ReconcileCount: 3, ReconcileOutcome: "confirmed active attempt", CreatedAt: stamp, UpdatedAt: stamp,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "mysql-approved", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("3", 64), MaxReadBytes: 64 * 1024},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "mysql-approved", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("3", 64), MaxReadBytes: 64 * 1024, Inputs: map[string]string{"templateKey": "players.by-id", "playerId": "steam-456"}},
|
||||
ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, AuditSummary: "atomic write"},
|
||||
}
|
||||
if err := source.MemoryStore.Jobs().Create(job); err != nil {
|
||||
@@ -257,9 +265,74 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
||||
target := &MySQLStore{MemoryStore: NewMemoryStore()}
|
||||
target.loadSnapshot(snapshot)
|
||||
got, err := target.MemoryStore.Jobs().Get(job.ID)
|
||||
if err != nil || got.Attempt != job.Attempt || got.LeaseTokenHash != job.LeaseTokenHash || got.LastProgressSeq != job.LastProgressSeq || got.ReconcileCount != job.ReconcileCount || got.ExecutionInput.Content != job.ExecutionInput.Content || got.ExecutionResult.Checksum != job.ExecutionResult.Checksum {
|
||||
if err != nil || got.Attempt != job.Attempt || got.LeaseTokenHash != job.LeaseTokenHash || got.LastProgressSeq != job.LastProgressSeq || got.ReconcileCount != job.ReconcileCount || got.ExecutionInput.Content != job.ExecutionInput.Content || got.ExecutionInput.Inputs["templateKey"] != "players.by-id" || got.ExecutionInput.Inputs["playerId"] != "steam-456" || got.ExecutionResult.Checksum != job.ExecutionResult.Checksum {
|
||||
t.Fatalf("unexpected MySQL snapshot job: job=%+v err=%v", got, err)
|
||||
}
|
||||
got.ExecutionInput.Inputs["playerId"] = "mutated"
|
||||
again, err := target.MemoryStore.Jobs().Get(job.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get MySQL snapshot job again: %v", err)
|
||||
}
|
||||
if again.ExecutionInput.Inputs["playerId"] != "steam-456" {
|
||||
t.Fatalf("expected MySQL snapshot job inputs to be isolated, got %+v", again.ExecutionInput.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "production-operations.json")
|
||||
store, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
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,
|
||||
}
|
||||
diff := domain.AIConfigDiffPreview{
|
||||
ID: "ai-config-diff-1", RequestID: "ai-request-1", CreatedBy: "operator-1",
|
||||
ServerInstanceID: "server-1", PluginID: "game.scum", ProviderID: "ai.openai", Model: "gpt-4.1",
|
||||
Key: "server.properties", ConfigVersion: 4, CurrentConfigChecksum: "sha256:" + strings.Repeat("a", 64),
|
||||
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)
|
||||
}
|
||||
if err := store.AIConfigDiffs().Create(diff); err != nil {
|
||||
t.Fatalf("create AI config diff: %v", err)
|
||||
}
|
||||
|
||||
restarted, err := NewFileStore(path)
|
||||
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)
|
||||
}
|
||||
gotDiff, diffErr := restarted.AIConfigDiffs().Get(diff.ID)
|
||||
if diffErr != nil || gotDiff.State != diff.State || gotDiff.CurrentConfigChecksum != diff.CurrentConfigChecksum || gotDiff.ProposedConfig != diff.ProposedConfig {
|
||||
t.Fatalf("unexpected durable AI config diff: diff=%+v err=%v", gotDiff, diffErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLStoreRequiresDSN(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
@@ -45,7 +46,8 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
if err := validator.ValidateAIInvocationRequest(request); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
if _, err := svc.GetCurrentUser(sessionID); err != nil {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
if request.ServerInstanceID != "" {
|
||||
@@ -56,6 +58,13 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
if request.PluginID != "" && instance.PluginID != request.PluginID {
|
||||
return safeAIDenial(request, "plugin scope does not match server instance"), nil
|
||||
}
|
||||
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||
config, err := svc.GetServerConfigForSession(sessionID, instance.ID)
|
||||
if err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
request.CurrentConfig = config.Content
|
||||
}
|
||||
}
|
||||
if request.PluginID != "" {
|
||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||
@@ -82,14 +91,24 @@ 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,
|
||||
ProviderID: provider.ID,
|
||||
Model: safeModel(request.Model, provider),
|
||||
Status: "error",
|
||||
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider), Mocked: true},
|
||||
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: safeBridgeReason(err.Error())},
|
||||
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider)},
|
||||
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: "AI provider invocation failed safely"},
|
||||
}), nil
|
||||
}
|
||||
response := domain.AIInvocationResponse{
|
||||
@@ -102,7 +121,16 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
Usage: result.Usage,
|
||||
}
|
||||
if result.SuggestedConfig != "" {
|
||||
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: "server.properties", SuggestedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch"}
|
||||
if request.ServerInstanceID == "" {
|
||||
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result)
|
||||
svc.productionMu.Unlock()
|
||||
if persistErr != nil {
|
||||
return domain.AIInvocationResponse{}, persistErr
|
||||
}
|
||||
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: preview.Key, SuggestedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, DiffID: preview.ID, ExpiresAt: preview.ExpiresAt.Format(time.RFC3339)}
|
||||
}
|
||||
if err := validator.ValidateAIInvocationResponse(response); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const maxAIProviderResponseBytes = 1024 * 1024
|
||||
|
||||
type AIProviderSecretResolver interface {
|
||||
Resolve(reference string) (string, error)
|
||||
}
|
||||
|
||||
type EnvironmentAIProviderSecretResolver struct{}
|
||||
|
||||
func (EnvironmentAIProviderSecretResolver) Resolve(reference string) (string, error) {
|
||||
reference = strings.TrimSpace(reference)
|
||||
if strings.HasPrefix(reference, "env://") {
|
||||
name := strings.TrimPrefix(reference, "env://")
|
||||
if !validEnvironmentName(name) {
|
||||
return "", errors.New("AI provider secret reference is invalid")
|
||||
}
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
return "", errors.New("AI provider secret is unavailable")
|
||||
}
|
||||
if strings.HasPrefix(reference, "secret://providers/") {
|
||||
name := strings.TrimPrefix(reference, "secret://providers/")
|
||||
name = strings.ToUpper(regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(name, "_"))
|
||||
name = strings.Trim(name, "_")
|
||||
if name == "" {
|
||||
return "", errors.New("AI provider secret reference is invalid")
|
||||
}
|
||||
if value := os.Getenv("PLATFORM_AI_PROVIDER_" + name + "_API_KEY"); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
return "", errors.New("AI provider secret is unavailable")
|
||||
}
|
||||
return "", errors.New("AI provider secret backend is unsupported")
|
||||
}
|
||||
|
||||
type HTTPAIProviderClient struct {
|
||||
HTTPClient *http.Client
|
||||
SecretResolver AIProviderSecretResolver
|
||||
}
|
||||
|
||||
type openAIChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openAIChatMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
}
|
||||
|
||||
type openAIChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type openAIChatResponse struct {
|
||||
Choices []struct {
|
||||
Message openAIChatMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
type claudeRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Messages []openAIChatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type claudeResponse struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
type geminiRequest struct {
|
||||
Contents []struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"contents"`
|
||||
}
|
||||
|
||||
type geminiResponse struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
type structuredAIRecommendation struct {
|
||||
Recommendation string `json:"recommendation"`
|
||||
SuggestedConfig string `json:"suggestedConfig"`
|
||||
}
|
||||
|
||||
func (client HTTPAIProviderClient) Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) {
|
||||
model := safeModel(request.Model, provider)
|
||||
endpoint, err := providerEndpoint(provider, model)
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, err
|
||||
}
|
||||
secret := ""
|
||||
if provider.RelayMode != domain.AIRelayModeLocal {
|
||||
resolver := client.SecretResolver
|
||||
if resolver == nil {
|
||||
resolver = EnvironmentAIProviderSecretResolver{}
|
||||
}
|
||||
secret, err = resolver.Resolve(provider.APIKeyRef)
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("AI provider credential is unavailable")
|
||||
}
|
||||
}
|
||||
prompt := boundedProviderPrompt(request)
|
||||
body, err := providerRequestBody(provider.Kind, model, prompt)
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("AI provider request could not be created")
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
setProviderAuthorization(httpRequest, provider.Kind, secret)
|
||||
httpClient := client.HTTPClient
|
||||
if httpClient == nil {
|
||||
timeout := time.Duration(provider.TimeoutMS) * time.Millisecond
|
||||
if timeout <= 0 || timeout > 2*time.Minute {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
httpClient = &http.Client{Timeout: timeout}
|
||||
}
|
||||
response, err := httpClient.Do(httpRequest)
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("AI provider transport failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return domain.AIProviderInvocationResult{}, fmt.Errorf("AI provider returned status class %dxx", response.StatusCode/100)
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, maxAIProviderResponseBytes+1))
|
||||
if err != nil || len(payload) > maxAIProviderResponseBytes {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("AI provider response was invalid or too large")
|
||||
}
|
||||
result, err := parseProviderResponse(provider.Kind, payload)
|
||||
if err != nil {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("AI provider response was invalid")
|
||||
}
|
||||
result.Usage.ProviderID = provider.ID
|
||||
result.Usage.Model = model
|
||||
result.Usage.Mocked = false
|
||||
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||
result = parseStructuredRecommendation(result)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ConfigureAIProviderMode(mode string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "mock", "test", "local":
|
||||
svc.aiProviderClient = MockAIProviderClient{}
|
||||
case "", "live", "http":
|
||||
svc.aiProviderClient = HTTPAIProviderClient{SecretResolver: EnvironmentAIProviderSecretResolver{}}
|
||||
default:
|
||||
return validationError("AI provider mode must be live or mock")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func providerEndpoint(provider domain.AIProvider, model string) (string, error) {
|
||||
base, err := url.Parse(strings.TrimRight(provider.BaseURL, "/"))
|
||||
if err != nil || base.Host == "" {
|
||||
return "", errors.New("AI provider endpoint is invalid")
|
||||
}
|
||||
host := base.Hostname()
|
||||
if base.Scheme != "https" && !(base.Scheme == "http" && provider.RelayMode == domain.AIRelayModeLocal && isLoopbackHost(host)) {
|
||||
return "", errors.New("AI provider endpoint requires HTTPS")
|
||||
}
|
||||
switch provider.Kind {
|
||||
case domain.AIProviderKindClaude:
|
||||
base.Path = strings.TrimRight(base.Path, "/") + "/messages"
|
||||
case domain.AIProviderKindGemini:
|
||||
base.Path = strings.TrimRight(base.Path, "/") + "/models/" + url.PathEscape(model) + ":generateContent"
|
||||
default:
|
||||
base.Path = strings.TrimRight(base.Path, "/") + "/chat/completions"
|
||||
}
|
||||
base.RawQuery = ""
|
||||
base.Fragment = ""
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
func providerRequestBody(kind domain.AIProviderKind, model, prompt string) ([]byte, error) {
|
||||
switch kind {
|
||||
case domain.AIProviderKindClaude:
|
||||
return json.Marshal(claudeRequest{Model: model, MaxTokens: 2048, Messages: []openAIChatMessage{{Role: "user", Content: prompt}}})
|
||||
case domain.AIProviderKindGemini:
|
||||
body := geminiRequest{}
|
||||
body.Contents = append(body.Contents, struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
}{Parts: []struct {
|
||||
Text string `json:"text"`
|
||||
}{{Text: prompt}}})
|
||||
return json.Marshal(body)
|
||||
default:
|
||||
return json.Marshal(openAIChatRequest{Model: model, Messages: []openAIChatMessage{{Role: "user", Content: prompt}}, Temperature: 0.2})
|
||||
}
|
||||
}
|
||||
|
||||
func parseProviderResponse(kind domain.AIProviderKind, payload []byte) (domain.AIProviderInvocationResult, error) {
|
||||
switch kind {
|
||||
case domain.AIProviderKindClaude:
|
||||
var response claudeResponse
|
||||
if err := json.Unmarshal(payload, &response); err != nil || len(response.Content) == 0 || strings.TrimSpace(response.Content[0].Text) == "" {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("invalid Claude response")
|
||||
}
|
||||
return domain.AIProviderInvocationResult{Recommendation: response.Content[0].Text, Usage: domain.AIInvocationUsage{InputTokens: response.Usage.InputTokens, OutputTokens: response.Usage.OutputTokens}}, nil
|
||||
case domain.AIProviderKindGemini:
|
||||
var response geminiResponse
|
||||
if err := json.Unmarshal(payload, &response); err != nil || len(response.Candidates) == 0 || len(response.Candidates[0].Content.Parts) == 0 || strings.TrimSpace(response.Candidates[0].Content.Parts[0].Text) == "" {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("invalid Gemini response")
|
||||
}
|
||||
return domain.AIProviderInvocationResult{Recommendation: response.Candidates[0].Content.Parts[0].Text, Usage: domain.AIInvocationUsage{InputTokens: response.UsageMetadata.PromptTokenCount, OutputTokens: response.UsageMetadata.CandidatesTokenCount}}, nil
|
||||
default:
|
||||
var response openAIChatResponse
|
||||
if err := json.Unmarshal(payload, &response); err != nil || len(response.Choices) == 0 || strings.TrimSpace(response.Choices[0].Message.Content) == "" {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("invalid chat completion response")
|
||||
}
|
||||
return domain.AIProviderInvocationResult{Recommendation: response.Choices[0].Message.Content, Usage: domain.AIInvocationUsage{InputTokens: response.Usage.PromptTokens, OutputTokens: response.Usage.CompletionTokens}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseStructuredRecommendation(result domain.AIProviderInvocationResult) domain.AIProviderInvocationResult {
|
||||
raw := strings.TrimSpace(result.Recommendation)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
var structured structuredAIRecommendation
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &structured) == nil && strings.TrimSpace(structured.SuggestedConfig) != "" {
|
||||
result.Recommendation = strings.TrimSpace(structured.Recommendation)
|
||||
result.SuggestedConfig = structured.SuggestedConfig
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func boundedProviderPrompt(request domain.AIInvocationRequest) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Purpose: ")
|
||||
builder.WriteString(request.Purpose)
|
||||
builder.WriteString("\nRequest: ")
|
||||
builder.WriteString(request.Prompt)
|
||||
if request.CurrentConfig != "" {
|
||||
builder.WriteString("\nCurrent configuration:\n")
|
||||
builder.WriteString(request.CurrentConfig)
|
||||
}
|
||||
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||
builder.WriteString("\nReturn JSON with recommendation and suggestedConfig. Configuration changes require separate operator approval.")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func setProviderAuthorization(request *http.Request, kind domain.AIProviderKind, secret string) {
|
||||
if secret == "" {
|
||||
return
|
||||
}
|
||||
switch kind {
|
||||
case domain.AIProviderKindClaude:
|
||||
request.Header.Set("x-api-key", secret)
|
||||
request.Header.Set("anthropic-version", "2023-06-01")
|
||||
case domain.AIProviderKindGemini:
|
||||
request.Header.Set("x-goog-api-key", secret)
|
||||
default:
|
||||
request.Header.Set("Authorization", "Bearer "+secret)
|
||||
}
|
||||
}
|
||||
|
||||
func validEnvironmentName(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestHTTPAIProviderClientReturnsBoundedStructuredRecommendation(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("unexpected provider path %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Fatal("local provider must not receive an authorization header")
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if strings.Contains(string(body), "apiKeyRef") {
|
||||
t.Fatal("provider request leaked secret reference metadata")
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"role":"assistant","content":"{\"recommendation\":\"Review PVP policy\",\"suggestedConfig\":\"pvp=false\\n\"}"}}],"usage":{"prompt_tokens":12,"completion_tokens":8}}`))}, nil
|
||||
})
|
||||
|
||||
client := HTTPAIProviderClient{HTTPClient: &http.Client{Transport: transport}}
|
||||
result, err := client.Invoke(domain.AIProvider{ID: "local", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:18000/v1", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, DefaultModel: "test-model"}, domain.AIInvocationRequest{RequestID: "http-ai-1", Purpose: "config.suggest", Prompt: "disable pvp", CurrentConfig: "pvp=true\n"})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke provider: %v", err)
|
||||
}
|
||||
if result.Recommendation != "Review PVP policy" || result.SuggestedConfig != "pvp=false\n" || result.Usage.Mocked {
|
||||
t.Fatalf("unexpected provider result %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAIProviderClientRedactsTransportFailure(t *testing.T) {
|
||||
client := HTTPAIProviderClient{HTTPClient: &http.Client{Transport: failingRoundTripper{}}}
|
||||
_, err := client.Invoke(domain.AIProvider{ID: "local", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:18000/v1", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, DefaultModel: "test-model"}, domain.AIInvocationRequest{RequestID: "http-ai-fail", Purpose: "logs.diagnose", Prompt: "inspect"})
|
||||
if err == nil || err.Error() != "AI provider transport failed" {
|
||||
t.Fatalf("expected redacted transport failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingRoundTripper struct{}
|
||||
|
||||
func (failingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return fn(request)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestClientManagerLifecycleInputIncludesGeneratedCompanionConfig(t *testing.T) {
|
||||
svc, ownerSession, instance := newDistributionTestFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
capabilities := []string{"component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"}
|
||||
for index := range plugin.RuntimeProfiles.ClientManagers {
|
||||
manager := &plugin.RuntimeProfiles.ClientManagers[index]
|
||||
if manager.Key != "scum-client-manager" {
|
||||
continue
|
||||
}
|
||||
manager.ConfigTemplates = []domain.RuntimeConfigTemplate{{Key: "client-config", TemplateRef: "config.yaml.example", OutputRef: "config.yaml"}}
|
||||
manager.Health.IntervalSeconds = 30
|
||||
manager.Health.RequiredCapabilities = domain.CopyStringSlice(capabilities)
|
||||
}
|
||||
plugin.GameClientBridge.Companion = domain.GameClientBridgeCompanionDeclaration{
|
||||
ProfileKey: "scum-client-manager",
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update companion declaration: %v", err)
|
||||
}
|
||||
|
||||
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "companion-config-build-v1")
|
||||
view, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "companion-config-deploy-v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("queue companion deployment: %v", err)
|
||||
}
|
||||
runSession := registerClientManagerRun(t, svc)
|
||||
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
|
||||
request := domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
|
||||
input, err := svc.GetClientManagerLifecycleInput(request)
|
||||
if err != nil {
|
||||
t.Fatalf("get companion lifecycle input: %v", err)
|
||||
}
|
||||
config := input.CompanionConfig
|
||||
if config == nil {
|
||||
t.Fatal("expected generated companion config input")
|
||||
}
|
||||
if config.SchemaVersion != 1 || config.ConfigTemplateRef != "config.yaml.example" || config.ConfigOutputRef != "config.yaml" || config.ConfigSchemaRef != "schemas/companion/config.schema.json" {
|
||||
t.Fatalf("unexpected companion template contract: %+v", config)
|
||||
}
|
||||
if config.InstallationID != view.Installation.ID || config.ServerInstanceID != instance.ID || config.PluginID != plugin.ID || config.ProfileKey != "scum-client-manager" || config.ArtifactID != distribution.ArtifactID || config.KeyGeneration != distribution.KeyGeneration || config.DeploymentGeneration != view.Installation.DeploymentGeneration {
|
||||
t.Fatalf("unexpected companion identity fence: %+v", config)
|
||||
}
|
||||
if strings.Join(config.Capabilities, ",") != strings.Join(capabilities, ",") || config.PlatformBaseURLSource != "run-control" || config.RegistrationProof != "hmac-sha256" || config.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" || config.SessionMode != "component-session" || config.TLSPolicy != "verify-system-roots" {
|
||||
t.Fatalf("unexpected companion registration policy: %+v", config)
|
||||
}
|
||||
if config.HeartbeatIntervalSeconds != 30 || config.CommandPollIntervalSeconds != 5 || config.RequestTimeoutSeconds != 15 {
|
||||
t.Fatalf("unexpected companion timing policy: %+v", config)
|
||||
}
|
||||
|
||||
plugin, err = svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload plugin: %v", err)
|
||||
}
|
||||
plugin.GameClientBridge.Companion.TLSPolicy = "skip-verification"
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("persist unsafe companion policy: %v", err)
|
||||
}
|
||||
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "security policy") {
|
||||
t.Fatalf("expected unsafe persisted policy to fail closed, got %v", err)
|
||||
}
|
||||
|
||||
plugin.GameClientBridge.Companion.TLSPolicy = "verify-system-roots"
|
||||
plugin.GameClientBridge.Companion.ProofMaterialEnv = "LD_PRELOAD"
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("persist reserved proof environment: %v", err)
|
||||
}
|
||||
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "proofMaterialEnv") {
|
||||
t.Fatalf("expected reserved proof environment to fail closed, got %v", err)
|
||||
}
|
||||
|
||||
plugin.GameClientBridge.Companion.ProofMaterialEnv = "SCUM_COMPONENT_PROOF"
|
||||
for index := range plugin.RuntimeProfiles.ClientManagers {
|
||||
plugin.RuntimeProfiles.ClientManagers[index].ConfigTemplates = nil
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("remove config template: %v", err)
|
||||
}
|
||||
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "config template") {
|
||||
t.Fatalf("expected missing template to fail safely, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientManagerCompanionConfigInputIsOptionalForOtherProfiles(t *testing.T) {
|
||||
config, err := clientManagerCompanionConfigInput(
|
||||
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ProfileKey: "bridge-client"}}},
|
||||
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
|
||||
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
|
||||
"artifact-1",
|
||||
"1.0.0",
|
||||
"revision-1",
|
||||
)
|
||||
if err != nil || config != nil {
|
||||
t.Fatalf("expected no companion config for another profile, config=%+v err=%v", config, err)
|
||||
}
|
||||
|
||||
config, err = clientManagerCompanionConfigInput(
|
||||
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ConfigFormat: "yaml"}}},
|
||||
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
|
||||
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
|
||||
"artifact-1",
|
||||
"1.0.0",
|
||||
"revision-1",
|
||||
)
|
||||
if err == nil || config != nil || !strings.Contains(err.Error(), "incomplete") {
|
||||
t.Fatalf("expected partial companion declaration to fail closed, config=%+v err=%v", config, err)
|
||||
}
|
||||
}
|
||||
@@ -525,6 +525,64 @@ func findRuntimeClientManagerProfile(plugin domain.GamePlugin, profileKey string
|
||||
return domain.RuntimeClientManagerProfile{}, repo.ErrNotFound
|
||||
}
|
||||
|
||||
func clientManagerCompanionConfigInput(plugin domain.GamePlugin, profile domain.RuntimeClientManagerProfile, installation domain.ClientManagerInstallation, artifactID, version, revision string) (*domain.ClientManagerCompanionConfigInput, error) {
|
||||
companion := plugin.GameClientBridge.Companion
|
||||
if companion == (domain.GameClientBridgeCompanionDeclaration{}) {
|
||||
return nil, nil
|
||||
}
|
||||
if companion.ProfileKey == "" {
|
||||
return nil, validationError("client-manager companion declaration is incomplete")
|
||||
}
|
||||
if companion.ProfileKey != installation.ProfileKey {
|
||||
return nil, nil
|
||||
}
|
||||
var configTemplate domain.RuntimeConfigTemplate
|
||||
found := false
|
||||
for _, candidate := range profile.ConfigTemplates {
|
||||
if candidate.Key == companion.ConfigTemplateKey {
|
||||
configTemplate = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found || strings.TrimSpace(configTemplate.TemplateRef) == "" || configTemplate.OutputRef != "config.yaml" {
|
||||
return nil, validationError("client-manager companion config template is unavailable")
|
||||
}
|
||||
config := domain.ClientManagerCompanionConfigInput{
|
||||
SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion,
|
||||
ConfigTemplateKey: companion.ConfigTemplateKey,
|
||||
ConfigTemplateRef: configTemplate.TemplateRef,
|
||||
ConfigOutputRef: configTemplate.OutputRef,
|
||||
ConfigSchemaRef: companion.ConfigSchemaRef,
|
||||
ConfigFormat: companion.ConfigFormat,
|
||||
PlatformBaseURLSource: companion.PlatformBaseURLSource,
|
||||
InstallationID: installation.ID,
|
||||
ServerInstanceID: installation.ServerInstanceID,
|
||||
PluginID: plugin.ID,
|
||||
ProfileKey: installation.ProfileKey,
|
||||
ArtifactID: artifactID,
|
||||
Version: version,
|
||||
SourceRevision: revision,
|
||||
TargetOS: installation.TargetOS,
|
||||
TargetArch: installation.TargetArch,
|
||||
KeyGeneration: installation.KeyGeneration,
|
||||
DeploymentGeneration: installation.DeploymentGeneration,
|
||||
Capabilities: domain.CopyStringSlice(profile.Health.RequiredCapabilities),
|
||||
RegistrationProof: companion.RegistrationProof,
|
||||
ProofMaterialSource: companion.ProofMaterialSource,
|
||||
ProofMaterialEnv: companion.ProofMaterialEnv,
|
||||
SessionMode: companion.SessionMode,
|
||||
TLSPolicy: companion.TLSPolicy,
|
||||
HeartbeatIntervalSeconds: companion.HeartbeatIntervalSeconds,
|
||||
CommandPollIntervalSeconds: companion.CommandPollIntervalSeconds,
|
||||
RequestTimeoutSeconds: companion.RequestTimeoutSeconds,
|
||||
}
|
||||
if err := validator.ValidateClientManagerCompanionConfigInput(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func clientManagerProfileSupportsTarget(profile domain.RuntimeClientManagerProfile, targetOS, targetArch string) bool {
|
||||
for _, target := range profile.SupportedTargets {
|
||||
if target.OS == targetOS && target.Arch == targetArch {
|
||||
@@ -755,7 +813,11 @@ func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientMana
|
||||
revision = installation.ActiveRevision
|
||||
checksum = ""
|
||||
}
|
||||
return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey}), nil
|
||||
companionConfig, err := clientManagerCompanionConfigInput(plugin, profile, installation, artifactID, version, revision)
|
||||
if err != nil {
|
||||
return domain.ClientManagerLifecycleInput{}, err
|
||||
}
|
||||
return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey, CompanionConfig: companionConfig}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ReadClientManagerLifecycleChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
|
||||
|
||||
@@ -23,9 +23,17 @@ type dependencyResolution struct {
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetDependencyCatalogForSession(sessionID, serverInstanceID string) (domain.DependencyCatalog, error) {
|
||||
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.DependencyCatalog{}, err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return domain.DependencyCatalog{}, err
|
||||
}
|
||||
if !pluginDeclares(plugin, "server.dependencies.manage") {
|
||||
return domain.DependencyCatalog{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
|
||||
}
|
||||
resolution, err := svc.resolveDependencyContext(serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.DependencyCatalog{}, err
|
||||
|
||||
@@ -76,6 +76,29 @@ func TestDependencyCatalogRequiresCurrentReviewedDigest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyCatalogNamesMissingPluginPermission(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
permissions := plugin.DeclaredPermissions[:0]
|
||||
for _, permission := range plugin.DeclaredPermissions {
|
||||
if permission != "server.dependencies.manage" {
|
||||
permissions = append(permissions, permission)
|
||||
}
|
||||
}
|
||||
plugin.DeclaredPermissions = permissions
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.GetDependencyCatalogForSession(session, instance.ID)
|
||||
if !errors.Is(err, ErrForbidden) || !strings.Contains(err.Error(), "server.dependencies.manage") {
|
||||
t.Fatalf("expected named dependency permission denial, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
||||
|
||||
@@ -455,6 +455,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
||||
}
|
||||
}
|
||||
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
|
||||
dependencyPermissionDeclared := pluginDeclares(plugin, "server.dependencies.manage")
|
||||
actions := domain.ServerRuntimeActions{
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
@@ -468,8 +469,8 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
||||
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
|
||||
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
|
||||
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
|
||||
runtimeAction("dependencies-check", "Check dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)),
|
||||
runtimeAction("dependencies-install", "Install dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason)),
|
||||
runtimeAction("dependencies-check", "Check dependencies", dependencyPermissionDeclared && endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason))),
|
||||
runtimeAction("dependencies-install", "Install dependencies", dependencyPermissionDeclared && endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason))),
|
||||
runtimeAction("live-logs", "Live logs", pluginSupports(plugin, "logs.read"), "plugin does not declare live logs"),
|
||||
runtimeAction("historical-logs", "Historical logs", endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason)),
|
||||
},
|
||||
@@ -608,7 +609,7 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d
|
||||
}
|
||||
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{}, ErrForbidden
|
||||
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 {
|
||||
return domain.Job{}, err
|
||||
@@ -726,11 +727,11 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
|
||||
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 ErrForbidden
|
||||
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 ErrForbidden
|
||||
return forbiddenError("plugin does not declare required permission: " + permission)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -96,6 +96,42 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRuntimeActionsGateDependenciesOnPluginPermission(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
permissions := plugin.DeclaredPermissions[:0]
|
||||
for _, permission := range plugin.DeclaredPermissions {
|
||||
if permission != "server.dependencies.manage" {
|
||||
permissions = append(permissions, permission)
|
||||
}
|
||||
}
|
||||
plugin.DeclaredPermissions = permissions
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin: %v", err)
|
||||
}
|
||||
|
||||
actions, err := svc.GetServerRuntimeActionsForSession(session, instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get runtime actions: %v", err)
|
||||
}
|
||||
seen := 0
|
||||
for _, action := range actions.Actions {
|
||||
if action.Key != "dependencies-check" && action.Key != "dependencies-install" {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if action.Available || action.Reason != "plugin permission is not declared" {
|
||||
t.Fatalf("expected dependency action gated by plugin permission, got %+v", action)
|
||||
}
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Fatalf("expected both dependency actions in %+v", actions.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUpload(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const defaultGameClientBridgeLeaseDuration = 60 * time.Second
|
||||
|
||||
const defaultGameClientBridgeCommandRetention = 30 * 24 * time.Hour
|
||||
|
||||
type gameClientBridgeComponentSession struct {
|
||||
Session domain.ClientManagerSession
|
||||
Installation domain.ClientManagerInstallation
|
||||
}
|
||||
|
||||
func (svc *CoreService) QueueGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return domain.GameClientBridgeCommand{}, ErrForbidden
|
||||
}
|
||||
if instance.PluginID != request.PluginID {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command plugin must match server instance")
|
||||
}
|
||||
return svc.queueGameClientBridgeCommand(user.ID, request)
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverInstanceID string) (domain.GameClientBridgeStatus, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, serverInstanceID); err != nil {
|
||||
return domain.GameClientBridgeStatus{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeStatus{}, err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeStatus{}, err
|
||||
}
|
||||
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}}
|
||||
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeStatus{}, err
|
||||
}
|
||||
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
||||
if !containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
||||
continue
|
||||
}
|
||||
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: profile.Key, Reason: "compatible companion session is offline", CommandTypes: gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands), SnapshotTypes: gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots), QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates)}
|
||||
for _, installation := range installations {
|
||||
if installation.ProfileKey != profile.Key || (installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded) || installation.RequiresRedeploy {
|
||||
continue
|
||||
}
|
||||
sessions, listErr := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
|
||||
if listErr != nil {
|
||||
return domain.GameClientBridgeStatus{}, listErr
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID {
|
||||
declaration.Available = true
|
||||
declaration.Reason = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
status.Profiles = append(status.Profiles, declaration)
|
||||
status.Available = status.Available || declaration.Available
|
||||
}
|
||||
if len(status.Profiles) > 0 {
|
||||
status.Reason = "no compatible companion session is online"
|
||||
}
|
||||
if status.Available {
|
||||
status.Reason = ""
|
||||
}
|
||||
return domain.CopyGameClientBridgeStatus(status), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(filter.ServerInstanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter.PluginID = instance.PluginID
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]domain.GameClientBridgeCommand, len(commands))
|
||||
for index, command := range commands {
|
||||
result[index] = domain.CopyGameClientBridgeCommand(command)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetGameClientBridgeCommandForSession(sessionID, commandID string) (domain.GameClientBridgeCommand, error) {
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) QueryGameClientBridgeSnapshotsForSession(sessionID string, query domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error) {
|
||||
if err := validator.ValidateGameClientBridgeSnapshotQuery(query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := svc.authorizeServerLifecycle(sessionID, query.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(query.ServerInstanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if query.PluginID != instance.PluginID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
limit := query.Limit
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: query.ServerInstanceID, PluginID: query.PluginID, ProfileKey: query.ProfileKey, Type: query.Type, StreamKey: query.StreamKey, ObservedAfter: query.ObservedAfter, Limit: limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]domain.GameClientBridgeSnapshot, len(snapshots))
|
||||
for index, snapshot := range snapshots {
|
||||
result[index] = domain.CopyGameClientBridgeSnapshot(snapshot)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func gameClientBridgeCommandDeclaration(plugin domain.GamePlugin, profileKey, commandType string) (domain.GameClientBridgeCommandDeclaration, bool) {
|
||||
profileDeclared := false
|
||||
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
||||
if profile.Key == profileKey && containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
||||
profileDeclared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !profileDeclared {
|
||||
return domain.GameClientBridgeCommandDeclaration{}, false
|
||||
}
|
||||
for _, declaration := range plugin.GameClientBridge.Commands {
|
||||
if declaration.Type == commandType {
|
||||
return declaration, true
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeCommandDeclaration{}, false
|
||||
}
|
||||
|
||||
func gameClientBridgeCommandTypes(declarations []domain.GameClientBridgeCommandDeclaration) []string {
|
||||
values := make([]string, len(declarations))
|
||||
for index, declaration := range declarations {
|
||||
values[index] = declaration.Type
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func gameClientBridgeSnapshotTypes(declarations []domain.GameClientBridgeSnapshotDeclaration) []string {
|
||||
seen := map[string]struct{}{}
|
||||
values := make([]string, 0, len(declarations))
|
||||
for _, declaration := range declarations {
|
||||
if _, exists := seen[declaration.Type]; exists {
|
||||
continue
|
||||
}
|
||||
seen[declaration.Type] = struct{}{}
|
||||
values = append(values, declaration.Type)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQueryTemplateDeclaration) []string {
|
||||
values := make([]string, len(declarations))
|
||||
for index, declaration := range declarations {
|
||||
values[index] = declaration.Key
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
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 {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
declaration, declared := gameClientBridgeCommandDeclaration(plugin, request.ProfileKey, request.CommandType)
|
||||
if !declared {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command type is not declared for the profile")
|
||||
}
|
||||
payload, err := json.Marshal(request.Payload)
|
||||
if err != nil || len(payload) > declaration.MaxPayloadBytes {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command payload exceeds declaration")
|
||||
}
|
||||
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
|
||||
}
|
||||
|
||||
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
|
||||
if err == nil {
|
||||
return domain.CopyGameClientBridgeCommand(existing), nil
|
||||
}
|
||||
if err != nil && err != repo.ErrNotFound {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if !request.ExpiresAt.After(stamp) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expiresAt must be in the future")
|
||||
}
|
||||
approvalState := domain.GameClientBridgeApprovalNotRequired
|
||||
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelOperator {
|
||||
approvalState = domain.GameClientBridgeApprovalApproved
|
||||
}
|
||||
if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||
approvalState = domain.GameClientBridgeApprovalPending
|
||||
if requester, requesterErr := svc.store.Users().Get(requesterID); requesterErr == nil && isPlatformAdmin(requester) {
|
||||
approvalState = domain.GameClientBridgeApprovalApproved
|
||||
}
|
||||
}
|
||||
svc.bridgeSeq++
|
||||
command := domain.GameClientBridgeCommand{
|
||||
ID: fmt.Sprintf("bridge-command-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
PluginID: request.PluginID,
|
||||
ProfileKey: request.ProfileKey,
|
||||
CommandType: request.CommandType,
|
||||
Payload: domain.CopyGameClientBridgePayload(request.Payload),
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
Priority: request.Priority,
|
||||
State: domain.GameClientBridgeCommandPending,
|
||||
ApprovalState: approvalState,
|
||||
RequesterID: requesterID,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, "queued declared game client bridge command")
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
command.AuditReferences = []string{auditID}
|
||||
if err := svc.store.GameClientBridgeCommands().Create(command); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridgeComponentSession, limit int) ([]domain.GameClientBridgeCommand, error) {
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit < 1 || limit > 50 {
|
||||
return nil, validationError("bridge claim limit must be between 1 and 50")
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, State: domain.GameClientBridgeCommandPending})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(commands, func(left, right int) bool {
|
||||
if commands[left].Priority != commands[right].Priority {
|
||||
return commands[left].Priority > commands[right].Priority
|
||||
}
|
||||
if !commands[left].CreatedAt.Equal(commands[right].CreatedAt) {
|
||||
return commands[left].CreatedAt.Before(commands[right].CreatedAt)
|
||||
}
|
||||
return commands[left].ID < commands[right].ID
|
||||
})
|
||||
claimed := make([]domain.GameClientBridgeCommand, 0, limit)
|
||||
for _, command := range commands {
|
||||
if len(claimed) == limit {
|
||||
break
|
||||
}
|
||||
if command.ApprovalState != domain.GameClientBridgeApprovalNotRequired && command.ApprovalState != domain.GameClientBridgeApprovalApproved {
|
||||
continue
|
||||
}
|
||||
fencingToken := command.Claim.FencingToken + 1
|
||||
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
|
||||
}
|
||||
claimed = append(claimed, domain.CopyGameClientBridgeCommand(command))
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
command, err := svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
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
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||
if command.Result.CompletedBy == component.Session.ID && gameClientBridgeClaimMatches(command, component, request.FencingToken) && command.Result.Status == request.Status && command.Result.Summary == request.Summary && reflect.DeepEqual(command.Result.Payload, request.Payload) {
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command already has a terminal result")
|
||||
}
|
||||
command, err = svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
switch request.Status {
|
||||
case domain.GameClientBridgeResultSucceeded:
|
||||
command.State = domain.GameClientBridgeCommandSucceeded
|
||||
case domain.GameClientBridgeResultFailed:
|
||||
command.State = domain.GameClientBridgeCommandFailed
|
||||
case domain.GameClientBridgeResultCancelled:
|
||||
command.State = domain.GameClientBridgeCommandCancelled
|
||||
}
|
||||
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
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeCancelRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return domain.GameClientBridgeCommand{}, ErrForbidden
|
||||
}
|
||||
if !isTerminalGameClientBridgeCommandState(command.State) && !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
||||
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
|
||||
}
|
||||
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||
if command.State == domain.GameClientBridgeCommandCancelled {
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command is already terminal")
|
||||
}
|
||||
command.State = domain.GameClientBridgeCommandCancelled
|
||||
command.Cancellation = domain.GameClientBridgeCancellation{RequestedBy: user.ID, Reason: request.Reason, CancelledAt: stamp}
|
||||
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
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ReconcileGameClientBridgeCommands() error {
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.pruneGameClientBridgeCommandsLocked(stamp); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.pruneGameClientBridgeSnapshotsLocked(stamp)
|
||||
}
|
||||
|
||||
func (svc *CoreService) pruneGameClientBridgeCommandsLocked(stamp time.Time) error {
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := map[string][]domain.GameClientBridgeCommand{}
|
||||
for _, command := range commands {
|
||||
if !isTerminalGameClientBridgeCommandState(command.State) || command.CompletedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
retention := defaultGameClientBridgeCommandRetention
|
||||
maxRecords := 0
|
||||
if plugin, pluginErr := svc.store.GamePlugins().Get(command.PluginID); pluginErr == nil {
|
||||
if plugin.GameClientBridge.Retention.KeepForSeconds > 0 {
|
||||
retention = time.Duration(plugin.GameClientBridge.Retention.KeepForSeconds) * time.Second
|
||||
}
|
||||
maxRecords = plugin.GameClientBridge.Retention.MaxRecords
|
||||
}
|
||||
if !command.CompletedAt.After(stamp.Add(-retention)) {
|
||||
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if maxRecords > 0 {
|
||||
key := command.ServerInstanceID + "\x00" + command.PluginID
|
||||
groups[key] = append(groups[key], command)
|
||||
}
|
||||
}
|
||||
for _, group := range groups {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(group[0].PluginID)
|
||||
if err != nil || plugin.GameClientBridge.Retention.MaxRecords <= 0 || len(group) <= plugin.GameClientBridge.Retention.MaxRecords {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(group, func(left, right int) bool {
|
||||
if !group[left].CompletedAt.Equal(group[right].CompletedAt) {
|
||||
return group[left].CompletedAt.After(group[right].CompletedAt)
|
||||
}
|
||||
return group[left].ID < group[right].ID
|
||||
})
|
||||
for _, command := range group[plugin.GameClientBridge.Retention.MaxRecords:] {
|
||||
if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) pruneGameClientBridgeSnapshotsLocked(stamp time.Time) error {
|
||||
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := map[string][]domain.GameClientBridgeSnapshot{}
|
||||
for _, snapshot := range snapshots {
|
||||
if !snapshot.ExpiresAt.IsZero() && !snapshot.ExpiresAt.After(stamp) {
|
||||
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
key := snapshot.ServerInstanceID + "\x00" + snapshot.PluginID + "\x00" + snapshot.ProfileKey + "\x00" + snapshot.Type + "\x00" + snapshot.StreamKey
|
||||
groups[key] = append(groups[key], snapshot)
|
||||
}
|
||||
for _, group := range groups {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
maxRecords := group[0].Retention.MaxRecords
|
||||
if plugin, pluginErr := svc.store.GamePlugins().Get(group[0].PluginID); pluginErr == nil {
|
||||
if declaration, ok := gameClientBridgeSnapshotDeclaration(plugin, group[0].Type, group[0].SchemaVersion); ok {
|
||||
maxRecords = declaration.Retention.MaxRecords
|
||||
}
|
||||
}
|
||||
if maxRecords <= 0 || len(group) <= maxRecords {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(group, func(left, right int) bool {
|
||||
if group[left].Sequence != group[right].Sequence {
|
||||
return group[left].Sequence > group[right].Sequence
|
||||
}
|
||||
return group[left].ID < group[right].ID
|
||||
})
|
||||
for _, snapshot := range group[maxRecords:] {
|
||||
if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) error {
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, command := range commands {
|
||||
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||
continue
|
||||
}
|
||||
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
||||
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if command.State == domain.GameClientBridgeCommandClaimed && !command.Claim.LeaseExpiresAt.IsZero() && !command.Claim.LeaseExpiresAt.After(stamp) {
|
||||
fencingToken := command.Claim.FencingToken
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) fencedGameClientBridgeCommand(component gameClientBridgeComponentSession, commandID string, fencingToken uint64, stamp time.Time) (domain.GameClientBridgeCommand, error) {
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if command.State != domain.GameClientBridgeCommandClaimed || !gameClientBridgeClaimMatches(command, component, fencingToken) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command claim is stale")
|
||||
}
|
||||
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
||||
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
|
||||
}
|
||||
if !command.Claim.LeaseExpiresAt.After(stamp) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command claim lease expired")
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameClientBridgeCommand, stamp time.Time) error {
|
||||
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func gameClientBridgeClaimLeaseExpiry(stamp, commandExpiry time.Time) time.Time {
|
||||
leaseExpiry := stamp.Add(defaultGameClientBridgeLeaseDuration)
|
||||
if !commandExpiry.IsZero() && commandExpiry.Before(leaseExpiry) {
|
||||
return commandExpiry
|
||||
}
|
||||
return leaseExpiry
|
||||
}
|
||||
|
||||
func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, component gameClientBridgeComponentSession, fencingToken uint64) bool {
|
||||
return command.Claim.SessionID == component.Session.ID && command.Claim.InstallationID == component.Installation.ID && command.Claim.DeploymentGeneration == component.Session.DeploymentGeneration && command.Claim.FencingToken == fencingToken
|
||||
}
|
||||
|
||||
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
|
||||
switch state {
|
||||
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const gameClientBridgeCapability = "game-client.bridge"
|
||||
|
||||
func (svc *CoreService) ClaimGameClientBridgeCommands(request domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeClaimRequest(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.claimGameClientBridgeCommands(component, request.Limit)
|
||||
}
|
||||
|
||||
func (svc *CoreService) AckGameClientBridgeCommand(request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return svc.ackGameClientBridgeCommand(component, request)
|
||||
}
|
||||
|
||||
func (svc *CoreService) CompleteGameClientBridgeCommand(request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return svc.completeGameClientBridgeCommand(component, request)
|
||||
}
|
||||
|
||||
func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeSnapshotIngestRequest(request); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
if request.ObservedAt.After(stamp.Add(5 * time.Minute)) {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("snapshot observedAt is too far in the future")
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(component.Installation.PluginID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
declaration, declared := gameClientBridgeSnapshotDeclaration(plugin, request.Type, request.SchemaVersion)
|
||||
if !declared {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot type and schema version are not declared")
|
||||
}
|
||||
if request.Retention != declaration.Retention {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot retention does not match declaration")
|
||||
}
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
|
||||
streamID := gameClientBridgeStreamID(component.Session.ServerInstanceID, component.Installation.PluginID, component.Session.ProfileKey, request.Type, request.StreamKey)
|
||||
latestSequence := uint64(0)
|
||||
stream, streamErr := svc.store.GameClientBridgeSnapshotStreams().Get(streamID)
|
||||
if streamErr == nil {
|
||||
latestSequence = stream.LatestSequence
|
||||
} else if streamErr != repo.ErrNotFound {
|
||||
return domain.GameClientBridgeSnapshot{}, streamErr
|
||||
}
|
||||
existing, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, Type: request.Type, StreamKey: request.StreamKey})
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
for _, snapshot := range existing {
|
||||
if snapshot.Sequence > latestSequence {
|
||||
latestSequence = snapshot.Sequence
|
||||
}
|
||||
}
|
||||
if request.Sequence <= latestSequence {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("snapshot sequence is stale")
|
||||
}
|
||||
svc.bridgeSeq++
|
||||
snapshot := domain.GameClientBridgeSnapshot{
|
||||
ID: fmt.Sprintf("bridge-snapshot-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
|
||||
ServerInstanceID: component.Session.ServerInstanceID,
|
||||
PluginID: component.Installation.PluginID,
|
||||
ProfileKey: component.Session.ProfileKey,
|
||||
Type: request.Type,
|
||||
SchemaVersion: request.SchemaVersion,
|
||||
StreamKey: request.StreamKey,
|
||||
Sequence: request.Sequence,
|
||||
SourceSessionID: component.Session.ID,
|
||||
ObservedAt: request.ObservedAt,
|
||||
Payload: domain.CopyGameClientBridgePayload(request.Payload),
|
||||
Retention: request.Retention,
|
||||
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
|
||||
}
|
||||
stream = domain.GameClientBridgeSnapshotStream{ID: streamID, ServerInstanceID: snapshot.ServerInstanceID, PluginID: snapshot.PluginID, ProfileKey: snapshot.ProfileKey, Type: snapshot.Type, StreamKey: snapshot.StreamKey, LatestSequence: snapshot.Sequence, UpdatedAt: stamp}
|
||||
if streamErr == repo.ErrNotFound {
|
||||
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
} else {
|
||||
if err := svc.store.GameClientBridgeSnapshotStreams().Update(stream); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
}
|
||||
return domain.CopyGameClientBridgeSnapshot(snapshot), nil
|
||||
}
|
||||
|
||||
func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) {
|
||||
for _, declaration := range plugin.GameClientBridge.Snapshots {
|
||||
if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion {
|
||||
return declaration, true
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeSnapshotDeclaration{}, false
|
||||
}
|
||||
|
||||
func (svc *CoreService) authorizeGameClientBridgeSession(sessionToken string) (gameClientBridgeComponentSession, error) {
|
||||
presentedHash := tokenHash(sessionToken)
|
||||
sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{Status: domain.ClientManagerSessionActive})
|
||||
if err != nil {
|
||||
return gameClientBridgeComponentSession{}, err
|
||||
}
|
||||
var session domain.ClientManagerSession
|
||||
for _, candidate := range sessions {
|
||||
if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(presentedHash)) == 1 {
|
||||
session = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
stamp := svc.now()
|
||||
if session.ID == "" || !stamp.Before(session.ExpiresAt) {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
if !containsString(session.Capabilities, gameClientBridgeCapability) {
|
||||
return gameClientBridgeComponentSession{}, ErrForbidden
|
||||
}
|
||||
installation, err := svc.store.ClientManagerInstallations().Get(session.InstallationID)
|
||||
if err != nil {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
if installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
if installation.ServerInstanceID != session.ServerInstanceID || installation.ProfileKey != session.ProfileKey || installation.RunEndpointID != session.RunEndpointID || installation.ActiveArtifactID != session.ArtifactID || installation.KeyGeneration != session.KeyGeneration || installation.DeploymentGeneration != session.DeploymentGeneration || installation.RequiresRedeploy {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
key, err := svc.activeComponentKey(session.ServerInstanceID, domain.DistributionComponentClientManager, session.ProfileKey)
|
||||
if err != nil || key.Generation != session.KeyGeneration {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
return gameClientBridgeComponentSession{Session: domain.CopyClientManagerSession(session), Installation: domain.CopyClientManagerInstallation(installation)}, nil
|
||||
}
|
||||
|
||||
func gameClientBridgeStreamID(serverInstanceID, pluginID, profileKey, snapshotType, streamKey string) string {
|
||||
digest := sha256.Sum256([]byte(serverInstanceID + "\x00" + pluginID + "\x00" + profileKey + "\x00" + snapshotType + "\x00" + streamKey))
|
||||
return "bridge-stream-" + hex.EncodeToString(digest[:16])
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func seedGameClientBridgeComponentSession(t *testing.T, svc *CoreService, now time.Time, token string) (domain.ClientManagerInstallation, domain.ClientManagerSession) {
|
||||
t.Helper()
|
||||
installation := domain.ClientManagerInstallation{ID: "installation-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", RunEndpointID: "run-1", Status: domain.ClientManagerLifecycleOnline, ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
key := domain.EncryptedComponentKey{ID: "key-1", ServerInstanceID: installation.ServerInstanceID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: installation.ProfileKey, Generation: installation.KeyGeneration, Status: domain.ComponentKeyStatusActive}
|
||||
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.ClientManagerSessions().Create(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.EncryptedComponentKeys().Create(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return installation, session
|
||||
}
|
||||
|
||||
func TestGameClientBridgeComponentSessionAuthorizesCommandsAndSnapshots(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const token = "component-session-token"
|
||||
_, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authorized-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token, Limit: 5})
|
||||
if err != nil || len(claimed) != 1 || claimed[0].ID != command.ID || claimed[0].Claim.SessionID != session.ID {
|
||||
t.Fatalf("authorized claim: %#v err=%v", claimed, err)
|
||||
}
|
||||
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
|
||||
t.Fatalf("authorized ack: %v", err)
|
||||
}
|
||||
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered"}); err != nil {
|
||||
t.Fatalf("authorized result: %v", err)
|
||||
}
|
||||
|
||||
snapshotRequest := domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"players": []any{}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}
|
||||
snapshot, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest)
|
||||
if err != nil || snapshot.SourceSessionID != session.ID || snapshot.Sequence != 1 {
|
||||
t.Fatalf("authorized snapshot: %#v err=%v", snapshot, err)
|
||||
}
|
||||
if snapshot.SourceSessionID == token {
|
||||
t.Fatal("raw component token persisted in snapshot")
|
||||
}
|
||||
higherSnapshotRequest := snapshotRequest
|
||||
higherSnapshotRequest.Sequence = 2
|
||||
higherSnapshot, err := svc.UploadGameClientBridgeSnapshot(higherSnapshotRequest)
|
||||
if err != nil || higherSnapshot.Sequence != 2 {
|
||||
t.Fatalf("higher snapshot sequence was not accepted: %#v err=%v", higherSnapshot, err)
|
||||
}
|
||||
equalSnapshotRequest := higherSnapshotRequest
|
||||
if _, err := svc.UploadGameClientBridgeSnapshot(equalSnapshotRequest); err == nil {
|
||||
t.Fatal("expected latest snapshot sequence to be rejected")
|
||||
}
|
||||
if _, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest); err == nil {
|
||||
t.Fatal("expected lower snapshot sequence rejection")
|
||||
}
|
||||
currentSnapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current"})
|
||||
if err != nil || len(currentSnapshots) != 2 {
|
||||
t.Fatalf("stale snapshot attempts changed current stream records: %#v err=%v", currentSnapshots, err)
|
||||
}
|
||||
isolatedStreamRequest := snapshotRequest
|
||||
isolatedStreamRequest.StreamKey = "secondary"
|
||||
if snapshot, err := svc.UploadGameClientBridgeSnapshot(isolatedStreamRequest); err != nil || snapshot.Sequence != 1 {
|
||||
t.Fatalf("independent snapshot stream did not start at sequence one: %#v err=%v", snapshot, err)
|
||||
}
|
||||
stream, err := svc.store.GameClientBridgeSnapshotStreams().Get(gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"))
|
||||
if err != nil || stream.LatestSequence != 2 {
|
||||
t.Fatalf("snapshot stream projection: %#v err=%v", stream, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeClaimCannotBeCompletedByAnotherCurrentSession(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const firstToken = "component-session-token-one"
|
||||
_, firstSession := seedGameClientBridgeComponentSession(t, svc, *clock, firstToken)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "session-owner-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: firstToken, Limit: 1})
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("first session claim: %#v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
const secondToken = "component-session-token-two"
|
||||
secondSession := firstSession
|
||||
secondSession.ID = "component-session-2"
|
||||
secondSession.TokenHash = tokenHash(secondToken)
|
||||
if err := svc.store.ClientManagerSessions().Create(secondSession); err != nil {
|
||||
t.Fatalf("create second current session: %v", err)
|
||||
}
|
||||
request := domain.GameClientBridgeAckRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}
|
||||
if _, err := svc.AckGameClientBridgeCommand(request); err == nil {
|
||||
t.Fatal("expected second session ack to be rejected")
|
||||
}
|
||||
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected second session result to be rejected")
|
||||
}
|
||||
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: firstToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
|
||||
t.Fatalf("claim owner could not ack after rejected second session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeComponentSessionRejectsMissingCapabilityAndStaleFences(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const token = "component-session-token"
|
||||
installation, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authz-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
session.Capabilities = []string{"component.heartbeat"}
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected missing capability rejection, got %v", err)
|
||||
}
|
||||
session.Capabilities = []string{"component.heartbeat", gameClientBridgeCapability}
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installation.DeploymentGeneration++
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected deployment fence rejection, got %v", err)
|
||||
}
|
||||
installation.DeploymentGeneration = session.DeploymentGeneration
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installation.ActiveArtifactID = "artifact-2"
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected active artifact fence rejection, got %v", err)
|
||||
}
|
||||
installation.ActiveArtifactID = session.ArtifactID
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installation.KeyGeneration++
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected installation key-generation fence rejection, got %v", err)
|
||||
}
|
||||
installation.KeyGeneration = session.KeyGeneration
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key, err := svc.store.EncryptedComponentKeys().Get("key-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key.Generation++
|
||||
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected active component key-generation rejection, got %v", err)
|
||||
}
|
||||
key.Generation = session.KeyGeneration
|
||||
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
session.ExpiresAt = *clock
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"healthy": true}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected expired session rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
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}}}
|
||||
if err := store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("seed bridge plugin: %v", err)
|
||||
}
|
||||
svc := newCoreService(store, func() time.Time { return now })
|
||||
return svc, &now
|
||||
}
|
||||
|
||||
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)}
|
||||
}
|
||||
|
||||
func bridgeComponent() gameClientBridgeComponentSession {
|
||||
return gameClientBridgeComponentSession{
|
||||
Session: domain.ClientManagerSession{ID: "component-session-1", ServerInstanceID: "server-1", ProfileKey: "scum-client", DeploymentGeneration: 3},
|
||||
Installation: domain.ClientManagerInstallation{ID: "installation-1", PluginID: "game.scum"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "announce-1")
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatalf("queue bridge command: %v", err)
|
||||
}
|
||||
duplicate, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil || duplicate.ID != command.ID {
|
||||
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)
|
||||
}
|
||||
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 10)
|
||||
if err != nil || len(claimed) != 1 || claimed[0].State != domain.GameClientBridgeCommandClaimed || claimed[0].Claim.FencingToken != 1 {
|
||||
t.Fatalf("claim bridge command: %#v err=%v", claimed, err)
|
||||
}
|
||||
initialLeaseExpiry := claimed[0].Claim.LeaseExpiresAt
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 2}); err == nil {
|
||||
t.Fatal("expected stale fencing token rejection")
|
||||
}
|
||||
*clock = clock.Add(10 * time.Second)
|
||||
acked, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1})
|
||||
if err != nil || acked.Claim.AcknowledgedAt.IsZero() || !acked.Claim.LeaseExpiresAt.Equal(clock.Add(defaultGameClientBridgeLeaseDuration)) || !acked.Claim.LeaseExpiresAt.After(initialLeaseExpiry) {
|
||||
t.Fatalf("ack bridge command: %#v err=%v", acked, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Payload: map[string]any{"sessionToken": "must-not-persist"}}); err == nil {
|
||||
t.Fatal("expected unsafe result material to be rejected")
|
||||
}
|
||||
resultRequest := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered", Payload: map[string]any{"delivered": true}}
|
||||
completed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
|
||||
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 {
|
||||
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 {
|
||||
t.Fatal("expected conflicting terminal result rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "scope-key")
|
||||
first, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Payload = map[string]any{"message": "changed but same idempotency scope"}
|
||||
reused, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil || reused.ID != first.ID {
|
||||
t.Fatalf("same service idempotency scope was not reused: first=%#v reused=%#v err=%v", first, reused, err)
|
||||
}
|
||||
otherRequester, err := svc.queueGameClientBridgeCommand("user-2", request)
|
||||
if err != nil || otherRequester.ID == first.ID {
|
||||
t.Fatalf("requester was omitted from idempotency scope: %#v err=%v", otherRequester, err)
|
||||
}
|
||||
request.IdempotencyKey = "scope-key-2"
|
||||
otherKey, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil || otherKey.ID == first.ID {
|
||||
t.Fatalf("idempotency key was omitted from service scope: %#v err=%v", otherKey, err)
|
||||
}
|
||||
request.ServerInstanceID = "server-2"
|
||||
request.IdempotencyKey = "scope-key"
|
||||
otherServer, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil || otherServer.ID == first.ID {
|
||||
t.Fatalf("server was omitted from service idempotency scope: %#v err=%v", otherServer, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeLeaseReclaimAndExpiry(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "lease-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
first, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(first) != 1 {
|
||||
t.Fatalf("first claim: %#v err=%v", first, err)
|
||||
}
|
||||
*clock = clock.Add(defaultGameClientBridgeLeaseDuration + time.Second)
|
||||
second, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(second) != 1 || second[0].Claim.FencingToken != 2 {
|
||||
t.Fatalf("reclaim expired lease: %#v err=%v", second, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected old claim fencing rejection")
|
||||
}
|
||||
*clock = command.ExpiresAt.Add(time.Second)
|
||||
if err := svc.ReconcileGameClientBridgeCommands(); err != nil {
|
||||
t.Fatalf("reconcile expired command: %v", err)
|
||||
}
|
||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired {
|
||||
t.Fatalf("expected expired command: %#v err=%v", expired, err)
|
||||
}
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(claimed) != 0 {
|
||||
t.Fatalf("expired command was claimable: %#v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgePendingCommandExpiresBeforeFirstClaim(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "pending-expiry")
|
||||
request.ExpiresAt = clock.Add(30 * time.Second)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
*clock = request.ExpiresAt
|
||||
claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
|
||||
if err != nil || len(claimed) != 0 {
|
||||
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 {
|
||||
t.Fatalf("first claim did not persist pending command expiry: %#v err=%v", expired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeExpiredLeaseRejectsMutationsBeforeReclaim(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
for _, key := range []string{"expired-lease-ack", "expired-lease-result"} {
|
||||
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, key)); err != nil {
|
||||
t.Fatalf("queue %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||
if err != nil || len(claimed) != 2 {
|
||||
t.Fatalf("claim lease-expiry commands: %#v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
*clock = claimed[0].Claim.LeaseExpiresAt
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||
t.Fatal("expected ack at claim lease expiry to be rejected")
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected result at claim lease expiry to be rejected")
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("expired lease mutation changed protected command: %#v err=%v", protected, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
reclaimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||
if err != nil || len(reclaimed) != 2 {
|
||||
t.Fatalf("reclaim protected commands after lease sweep: %#v err=%v", reclaimed, err)
|
||||
}
|
||||
for _, command := range reclaimed {
|
||||
if command.Claim.FencingToken != 2 {
|
||||
t.Fatalf("reclaimed command did not advance fencing token: %#v", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeClaimMutationsExpireAtCommandDeadline(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
deadline := clock.Add(30 * time.Second)
|
||||
commands := make([]domain.GameClientBridgeCommand, 0, 2)
|
||||
for _, key := range []string{"deadline-ack", "deadline-result"} {
|
||||
request := bridgeQueueRequest(*clock, key)
|
||||
request.ExpiresAt = deadline
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatalf("queue deadline command: %v", err)
|
||||
}
|
||||
commands = append(commands, command)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||
if err != nil || len(claimed) != 2 {
|
||||
t.Fatalf("claim deadline commands: %#v err=%v", claimed, err)
|
||||
}
|
||||
for _, command := range claimed {
|
||||
if !command.Claim.LeaseExpiresAt.Equal(deadline) {
|
||||
t.Fatalf("claim lease exceeded command deadline: %#v", command.Claim)
|
||||
}
|
||||
}
|
||||
*clock = deadline
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||
t.Fatal("expected ack at command deadline to be rejected")
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected result at command deadline to be rejected")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeFailedResultIsPersisted(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "failed-result"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim failed-result command: %#v err=%v", claimed, err)
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
t.Fatalf("failed result was not persisted: %#v err=%v", persisted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeOperatorCancellationExpiresAtCommandDeadline(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
user := domain.User{ID: "user-1", DisplayName: "Owner", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, CreatedAt: *clock, UpdatedAt: *clock}
|
||||
if err := svc.store.Users().Create(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := svc.issueAuthSession(user, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := bridgeQueueRequest(*clock, "cancel-deadline")
|
||||
request.ExpiresAt = clock.Add(30 * time.Second)
|
||||
command, err := svc.queueGameClientBridgeCommand(user.ID, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*clock = request.ExpiresAt
|
||||
if _, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "too late"}); err == nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
user := domain.User{ID: "user-1", DisplayName: "Owner", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, CreatedAt: *clock, UpdatedAt: *clock}
|
||||
if err := svc.store.Users().Create(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := svc.issueAuthSession(user, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command, err := svc.queueGameClientBridgeCommand(user.ID, bridgeQueueRequest(*clock, "cancel-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim: %#v err=%v", claimed, err)
|
||||
}
|
||||
cancelled, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
|
||||
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 {
|
||||
t.Fatalf("repeated cancellation was not idempotent: %#v err=%v", repeated, err)
|
||||
}
|
||||
remaining, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("cancelled command remained claimable: %#v err=%v", remaining, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "late success"}); err == nil {
|
||||
t.Fatal("expected late success after cancellation rejection")
|
||||
}
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||
t.Fatal("expected late ack after cancellation rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequence(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
plugin, err := svc.store.GamePlugins().Get("game.scum")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}
|
||||
plugin.GameClientBridge.Snapshots[0].Retention = domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldCommand := domain.GameClientBridgeCommand{ID: "old-command", ServerInstanceID: "server-1", PluginID: "game.scum", State: domain.GameClientBridgeCommandSucceeded, CompletedAt: clock.Add(-2 * time.Hour)}
|
||||
if err := svc.store.GameClientBridgeCommands().Create(oldCommand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for sequence := uint64(1); sequence <= 4; sequence++ {
|
||||
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-" + string(rune('0'+sequence)), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}, ExpiresAt: clock.Add(time.Hour)}
|
||||
if sequence == 1 {
|
||||
snapshot.ExpiresAt = clock.Add(-time.Second)
|
||||
}
|
||||
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stream := domain.GameClientBridgeSnapshotStream{ID: gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", LatestSequence: 4}
|
||||
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ReconcileGameClientBridgeCommands(); err != nil {
|
||||
t.Fatalf("reconcile bridge retention: %v", err)
|
||||
}
|
||||
if _, err := svc.store.GameClientBridgeCommands().Get(oldCommand.ID); err == nil {
|
||||
t.Fatal("old terminal command was not pruned")
|
||||
}
|
||||
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", Type: "players"})
|
||||
if err != nil || len(snapshots) != 2 || snapshots[0].Sequence != 4 || snapshots[1].Sequence != 3 {
|
||||
t.Fatalf("snapshot retention projection: %#v err=%v", snapshots, err)
|
||||
}
|
||||
retainedStream, err := svc.store.GameClientBridgeSnapshotStreams().Get(stream.ID)
|
||||
if err != nil || retainedStream.LatestSequence != 4 {
|
||||
t.Fatalf("stream sequence was reset by retention: %#v err=%v", retainedStream, err)
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectProductionOpsJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
@@ -597,7 +600,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
||||
State: job.State,
|
||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
|
||||
ResultRef: job.ResultRef,
|
||||
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},
|
||||
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, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs)},
|
||||
LeaseToken: leaseToken,
|
||||
Attempt: job.Attempt,
|
||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const (
|
||||
capacityHeartbeatStaleAfter = 2 * time.Minute
|
||||
capacityRetryAfterSeconds = 30
|
||||
capacityLogBacklogLimit = 256
|
||||
capacityArtifactBacklogLimit = 128
|
||||
aiConfigDiffTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
func (svc *CoreService) GetProductionCapacityForSession(sessionID string) (domain.ProductionCapacitySummary, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.ProductionCapacitySummary{}, err
|
||||
}
|
||||
endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{})
|
||||
if err != nil {
|
||||
return domain.ProductionCapacitySummary{}, err
|
||||
}
|
||||
visibleEndpointIDs, err := svc.visibleEndpointIDs(user)
|
||||
if err != nil {
|
||||
return domain.ProductionCapacitySummary{}, err
|
||||
}
|
||||
alerts, err := svc.store.Alerts().List(domain.AlertFilter{})
|
||||
if err != nil {
|
||||
return domain.ProductionCapacitySummary{}, err
|
||||
}
|
||||
|
||||
summary := domain.ProductionCapacitySummary{GeneratedAt: svc.now()}
|
||||
for _, endpoint := range endpoints {
|
||||
if !isPlatformAdmin(user) {
|
||||
if _, visible := visibleEndpointIDs[endpoint.ID]; !visible {
|
||||
continue
|
||||
}
|
||||
}
|
||||
running, queued, err := svc.capacityJobCounts(endpoint.ID)
|
||||
if err != nil {
|
||||
return domain.ProductionCapacitySummary{}, err
|
||||
}
|
||||
projection := svc.capacityProjection(endpoint, running, queued)
|
||||
for _, alert := range alerts {
|
||||
if alert.SourceKind == "run-endpoint" && alert.SourceID == endpoint.ID && alert.RuleKey == "capacity.pressure" && alert.State != domain.AlertStateResolved {
|
||||
projection.LastAdmissionDecision = domain.CapacityAdmissionDeferred
|
||||
projection.LastAdmissionReason = alert.Message
|
||||
projection.LastAdmissionCheckedAt = alert.LastSeenAt
|
||||
}
|
||||
}
|
||||
summary.Endpoints = append(summary.Endpoints, projection)
|
||||
summary.TotalMaxJobs += projection.MaxJobs
|
||||
summary.TotalRunningJobs += projection.RunningJobs
|
||||
summary.TotalQueuedJobs += projection.QueuedJobs
|
||||
}
|
||||
for _, alert := range alerts {
|
||||
if alert.State != domain.AlertStateResolved && svc.canAccessAlert(user, alert) {
|
||||
summary.ActiveAlerts++
|
||||
}
|
||||
}
|
||||
sort.Slice(summary.Endpoints, func(i, j int) bool { return summary.Endpoints[i].RunEndpointID < summary.Endpoints[j].RunEndpointID })
|
||||
return domain.CopyProductionCapacitySummary(summary), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) CheckCapacityAdmissionForSession(sessionID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) {
|
||||
if err := validator.ValidateCapacityAdmissionRequest(request); err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
request, err = svc.authorizeCapacityRequest(user, request)
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
return svc.checkCapacityAdmission(user.ID, request)
|
||||
}
|
||||
|
||||
func (svc *CoreService) checkCapacityAdmission(actorID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) {
|
||||
endpoint, err := svc.store.RunEndpoints().Get(request.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
running, queued, err := svc.capacityJobCounts(endpoint.ID)
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
projection := svc.capacityProjection(endpoint, running, queued)
|
||||
decision := domain.CapacityAdmissionDecision{
|
||||
Accepted: true, State: domain.CapacityAdmissionAccepted, Reason: "capacity available",
|
||||
ServerInstanceID: request.ServerInstanceID, RunEndpointID: endpoint.ID, Capability: request.Capability,
|
||||
TargetKey: request.TargetKey, MaxJobs: projection.MaxJobs, RunningJobs: projection.RunningJobs,
|
||||
QueuedJobs: projection.QueuedJobs, CheckedAt: svc.now(),
|
||||
}
|
||||
pressure := append([]domain.CapacityPressureCode(nil), projection.PressureCodes...)
|
||||
if len(validator.MissingCapabilities(endpoint.Capabilities, []string{request.Capability})) > 0 {
|
||||
pressure = appendCapacityPressure(pressure, domain.CapacityPressureCapabilityGap)
|
||||
}
|
||||
decision.PressureCodes = pressure
|
||||
|
||||
hardDenied := containsCapacityPressure(pressure, domain.CapacityPressureEndpointOffline) || containsCapacityPressure(pressure, domain.CapacityPressureCapabilityGap)
|
||||
if hardDenied {
|
||||
decision.Accepted = false
|
||||
decision.State = domain.CapacityAdmissionDenied
|
||||
decision.Reason = "endpoint is unavailable or missing the required capability"
|
||||
} else if len(pressure) > 0 {
|
||||
decision.Accepted = false
|
||||
decision.State = domain.CapacityAdmissionDeferred
|
||||
decision.Reason = "endpoint capacity is temporarily under pressure"
|
||||
decision.RetryAfterSeconds = capacityRetryAfterSeconds
|
||||
}
|
||||
|
||||
auditResult := domain.AuditResultSuccess
|
||||
auditAction := "capacity.admission.accepted"
|
||||
if !decision.Accepted {
|
||||
auditResult = domain.AuditResultDenied
|
||||
auditAction = "capacity.admission.denied"
|
||||
}
|
||||
auditID, err := svc.recordAuditEventWithID(actorID, auditAction, "run-endpoint", endpoint.ID, auditResult, decision.Reason)
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
decision.AuditEventID = auditID
|
||||
if !decision.Accepted {
|
||||
severity := domain.AlertSeverityWarning
|
||||
if hardDenied {
|
||||
severity = domain.AlertSeverityCritical
|
||||
}
|
||||
alert, err := svc.upsertAlert(domain.AlertRecord{
|
||||
SourceKind: "run-endpoint", SourceID: endpoint.ID, RuleKey: "capacity.pressure", Severity: severity,
|
||||
Title: "Run endpoint capacity admission blocked", Message: decision.Reason, Retryable: true,
|
||||
RetryAfterSeconds: decision.RetryAfterSeconds, LastAuditEventID: auditID,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
decision.AlertID = alert.ID
|
||||
} else if err := svc.resolveAlertForSource("run-endpoint", endpoint.ID, "capacity.pressure", actorID, "capacity returned to an admissible state", auditID); err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
if err := validator.ValidateCapacityAdmissionDecision(decision); err != nil {
|
||||
return domain.CapacityAdmissionDecision{}, err
|
||||
}
|
||||
return domain.CopyCapacityAdmissionDecision(decision), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListAlertsForSession(sessionID string, filter domain.AlertFilter) ([]domain.AlertRecord, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts, err := svc.store.Alerts().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := make([]domain.AlertRecord, 0, len(alerts))
|
||||
for _, alert := range alerts {
|
||||
if svc.canAccessAlert(user, alert) {
|
||||
visible = append(visible, alert)
|
||||
}
|
||||
}
|
||||
sort.Slice(visible, func(i, j int) bool { return visible[i].UpdatedAt.After(visible[j].UpdatedAt) })
|
||||
return domain.CopyAlertRecords(visible), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) AcknowledgeAlertForSession(sessionID string, request domain.AlertAcknowledgeRequest) (domain.AlertRecord, error) {
|
||||
if err := validator.ValidateAlertAcknowledgeRequest(request); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
alert, err := svc.store.Alerts().Get(request.AlertID)
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
if !svc.canAccessAlert(user, alert) {
|
||||
return domain.AlertRecord{}, ErrForbidden
|
||||
}
|
||||
if alert.State == domain.AlertStateResolved {
|
||||
return domain.AlertRecord{}, validationError("resolved alerts cannot be acknowledged")
|
||||
}
|
||||
stamp := svc.now()
|
||||
auditID, err := svc.recordAuditEventWithID(user.ID, "alert.acknowledge", "alert", alert.ID, domain.AuditResultSuccess, defaultAlertNote(request.Note, "alert acknowledged"))
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
alert.State = domain.AlertStateAcknowledged
|
||||
alert.AcknowledgedBy = user.ID
|
||||
alert.AcknowledgedAt = stamp
|
||||
alert.LastAuditEventID = auditID
|
||||
alert.UpdatedAt = stamp
|
||||
if err := validator.ValidateAlertRecord(alert); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
if err := svc.store.Alerts().Update(alert); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
return domain.CopyAlertRecord(alert), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ResolveAlertForSession(sessionID string, request domain.AlertResolveRequest) (domain.AlertRecord, error) {
|
||||
if err := validator.ValidateAlertResolveRequest(request); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
alert, err := svc.store.Alerts().Get(request.AlertID)
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
if !svc.canAccessAlert(user, alert) {
|
||||
return domain.AlertRecord{}, ErrForbidden
|
||||
}
|
||||
if alert.State == domain.AlertStateResolved {
|
||||
return domain.CopyAlertRecord(alert), nil
|
||||
}
|
||||
stamp := svc.now()
|
||||
note := defaultAlertNote(request.Note, "alert resolved after operator review")
|
||||
auditID, err := svc.recordAuditEventWithID(user.ID, "alert.resolve", "alert", alert.ID, domain.AuditResultSuccess, note)
|
||||
if err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
alert.State = domain.AlertStateResolved
|
||||
alert.ResolvedBy = user.ID
|
||||
alert.ResolvedAt = stamp
|
||||
alert.ResolutionNote = note
|
||||
alert.LastAuditEventID = auditID
|
||||
alert.UpdatedAt = stamp
|
||||
if err := validator.ValidateAlertRecord(alert); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
if err := svc.store.Alerts().Update(alert); err != nil {
|
||||
return domain.AlertRecord{}, err
|
||||
}
|
||||
return domain.CopyAlertRecord(alert), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) RetryAlertForSession(sessionID string, request domain.AlertRetryRequest) (domain.AlertRetryResult, error) {
|
||||
if err := validator.ValidateAlertRetryRequest(request); err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
alert, err := svc.store.Alerts().Get(request.AlertID)
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
if !svc.canAccessAlert(user, alert) {
|
||||
return domain.AlertRetryResult{}, ErrForbidden
|
||||
}
|
||||
if !alert.Retryable {
|
||||
return domain.AlertRetryResult{}, validationError("alert source is not retryable")
|
||||
}
|
||||
switch alert.SourceKind {
|
||||
case "run-endpoint":
|
||||
endpoint, err := svc.store.RunEndpoints().Get(alert.SourceID)
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
capability := firstCapacityCapability(endpoint.Capabilities)
|
||||
decision, err := svc.CheckCapacityAdmissionForSession(sessionID, domain.CapacityAdmissionRequest{RunEndpointID: endpoint.ID, Capability: capability, IdempotencyKey: request.IdempotencyKey})
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
updated, err := svc.store.Alerts().Get(alert.ID)
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: updated, Decision: decision, Status: string(decision.State)}), nil
|
||||
case "plugin-lifecycle":
|
||||
installation, err := svc.store.PluginLifecycles().Get(alert.SourceID)
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, Operation: installation.LastOperation, TargetVersion: installation.TargetVersion, IdempotencyKey: request.IdempotencyKey, Confirmed: true})
|
||||
if err != nil {
|
||||
return domain.AlertRetryResult{}, err
|
||||
}
|
||||
return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: alert, Decision: result.Decision, Status: result.Status}), nil
|
||||
default:
|
||||
return domain.AlertRetryResult{}, validationError("alert source does not support scoped retry")
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
installations, err := svc.store.PluginLifecycles().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := make([]domain.PluginLifecycleInstallation, 0, len(installations))
|
||||
for _, installation := range installations {
|
||||
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
||||
if err == nil && canAccessServer(user, instance) {
|
||||
visible = append(visible, installation)
|
||||
}
|
||||
}
|
||||
sort.Slice(visible, func(i, j int) bool { return visible[i].UpdatedAt.After(visible[j].UpdatedAt) })
|
||||
return domain.CopyPluginLifecycleInstallations(visible), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error) {
|
||||
if err := validator.ValidatePluginLifecycleRequest(request); err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
user, instance, err := svc.requireServerOwner(sessionID, request.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
if instance.PluginID != request.PluginID {
|
||||
return domain.PluginLifecycleResult{}, validationError("pluginId must match the server plugin")
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
if !containsString(plugin.ProductionLifecycle.Operations, string(request.Operation)) {
|
||||
return domain.PluginLifecycleResult{}, validationError("plugin lifecycle operation is not declared by the manifest")
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
capability, targetKey, err := pluginLifecycleDispatchMetadata(plugin, request.Operation)
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
if request.TargetVersion == "" {
|
||||
request.TargetVersion = plugin.Version
|
||||
}
|
||||
if !containsString(plugin.SupportedOS, endpoint.Platform) {
|
||||
return svc.pluginLifecycleDenied(user.ID, instance, plugin, request, "plugin is not compatible with the assigned endpoint platform")
|
||||
}
|
||||
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
installationID := pluginLifecycleInstallationID(request.PluginID, request.ServerInstanceID)
|
||||
installation, getErr := svc.store.PluginLifecycles().Get(installationID)
|
||||
if errors.Is(getErr, repo.ErrNotFound) {
|
||||
stamp := svc.now()
|
||||
installation = domain.PluginLifecycleInstallation{ID: installationID, PluginID: plugin.ID, ServerInstanceID: instance.ID, TargetVersion: request.TargetVersion, DesiredState: domain.PluginLifecycleStatePending, CurrentState: domain.PluginLifecycleStatePending, Compatibility: "compatible", DependencyState: domain.DependencyStateUnknown, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
} else if getErr != nil {
|
||||
return domain.PluginLifecycleResult{}, getErr
|
||||
}
|
||||
if err := validatePluginLifecycleTransition(installation, request); err != nil {
|
||||
return svc.pluginLifecycleDeniedLocked(user.ID, installation, request, err.Error())
|
||||
}
|
||||
|
||||
existingJob, jobErr := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey)
|
||||
if jobErr == nil {
|
||||
if existingJob.ServerInstanceID != instance.ID || existingJob.Capability != capability || existingJob.TargetKey != targetKey || existingJob.ExecutionInput.PluginID != plugin.ID || existingJob.ExecutionInput.LifecycleOperation != string(request.Operation) || existingJob.ExecutionInput.TargetVersion != request.TargetVersion {
|
||||
return domain.PluginLifecycleResult{}, validationError("idempotencyKey is already used for different plugin lifecycle inputs")
|
||||
}
|
||||
installation.JobID = existingJob.ID
|
||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: existingJob, Status: "queued"}), nil
|
||||
}
|
||||
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,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), PluginID: plugin.ID, LifecycleOperation: string(request.Operation), TargetVersion: request.TargetVersion},
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "plugin lifecycle operation queued"},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
installation.PreviousVersion = installation.CurrentVersion
|
||||
installation.TargetVersion = request.TargetVersion
|
||||
installation.DesiredState = desiredPluginLifecycleState(request.Operation, installation)
|
||||
installation.CurrentState = dispatchedPluginLifecycleState(request.Operation, installation.CurrentState)
|
||||
installation.LastOperation = request.Operation
|
||||
installation.JobID = job.ID
|
||||
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
|
||||
}
|
||||
if errors.Is(getErr, repo.ErrNotFound) {
|
||||
err = svc.store.PluginLifecycles().Create(installation)
|
||||
} else {
|
||||
err = svc.store.PluginLifecycles().Update(installation)
|
||||
}
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: job, Decision: decision, Status: "queued"}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListAIConfigDiffsForSession(sessionID string, filter domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previews, err := svc.store.AIConfigDiffs().List(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := make([]domain.AIConfigDiffPreview, 0, len(previews))
|
||||
for _, preview := range previews {
|
||||
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
||||
if err == nil && canAccessServer(user, instance) {
|
||||
visible = append(visible, preview)
|
||||
}
|
||||
}
|
||||
sort.Slice(visible, func(i, j int) bool { return visible[i].CreatedAt.After(visible[j].CreatedAt) })
|
||||
return domain.CopyAIConfigDiffPreviews(visible), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error) {
|
||||
if err := validator.ValidateAIConfigDiffApprovalRequest(request); err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
preview, err := svc.store.AIConfigDiffs().Get(request.DiffID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
if !canAccessServer(user, instance) || (!isPlatformAdmin(user) && preview.CreatedBy != user.ID) {
|
||||
return domain.AIConfigDiffApprovalResult{}, ErrForbidden
|
||||
}
|
||||
if preview.State == domain.AIConfigDiffStateApproved {
|
||||
if preview.ApprovalIdempotencyKey != request.IdempotencyKey {
|
||||
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is already approved with another idempotency key")
|
||||
}
|
||||
job, err := svc.store.Jobs().Get(preview.JobID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
dispatch := domain.ServerConfigWriteDispatch{Job: job, Status: "queued"}
|
||||
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
|
||||
}
|
||||
if preview.State != domain.AIConfigDiffStatePending {
|
||||
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is not pending approval")
|
||||
}
|
||||
if !preview.ExpiresAt.After(svc.now()) {
|
||||
preview.State = domain.AIConfigDiffStateExpired
|
||||
preview.UpdatedAt = svc.now()
|
||||
_ = svc.store.AIConfigDiffs().Update(preview)
|
||||
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff has expired")
|
||||
}
|
||||
dispatch, err := svc.ApproveServerConfigWriteForSession(sessionID, domain.ServerConfigWriteApproval{ServerInstanceID: preview.ServerInstanceID, ExpectedConfigVersion: preview.ConfigVersion, ExpectedChecksum: preview.CurrentConfigChecksum, Key: preview.Key, ProposedContent: preview.ProposedConfig, IdempotencyKey: request.IdempotencyKey})
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffApprovalResult{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
preview.State = domain.AIConfigDiffStateApproved
|
||||
preview.ApprovedBy = user.ID
|
||||
preview.ApprovedAt = stamp
|
||||
preview.ApprovalIdempotencyKey = request.IdempotencyKey
|
||||
preview.JobID = dispatch.Job.ID
|
||||
preview.UpdatedAt = stamp
|
||||
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
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectProductionOpsJobResult(job domain.Job, stamp time.Time) error {
|
||||
if job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
|
||||
return nil
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
defer svc.productionMu.Unlock()
|
||||
installation, err := svc.store.PluginLifecycles().Get(pluginLifecycleInstallationID(job.ExecutionInput.PluginID, job.ServerInstanceID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if installation.JobID != job.ID {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func (svc *CoreService) persistAIConfigDiff(actorID string, provider domain.AIProvider, request domain.AIInvocationRequest, result domain.AIProviderInvocationResult) (domain.AIConfigDiffPreview, error) {
|
||||
config, err := svc.getServerConfigForUser(actorID, request.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.AIConfigDiffPreview{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
preview := domain.AIConfigDiffPreview{ID: aiConfigDiffID(request.RequestID, request.ServerInstanceID), RequestID: request.RequestID, CreatedBy: actorID, ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, ProviderID: provider.ID, Model: result.Usage.Model, Key: config.Key, ConfigVersion: config.ConfigVersion, CurrentConfigChecksum: config.Checksum, ProposedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch", State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(aiConfigDiffTTL), CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if err := validator.ValidateAIConfigDiffPreview(preview); err != nil {
|
||||
return domain.AIConfigDiffPreview{}, err
|
||||
}
|
||||
if err := svc.store.AIConfigDiffs().Create(preview); err != nil {
|
||||
if !errors.Is(err, repo.ErrDuplicate) {
|
||||
return domain.AIConfigDiffPreview{}, err
|
||||
}
|
||||
existing, getErr := svc.store.AIConfigDiffs().Get(preview.ID)
|
||||
if getErr != nil {
|
||||
return domain.AIConfigDiffPreview{}, getErr
|
||||
}
|
||||
if existing.CreatedBy != actorID || existing.ServerInstanceID != request.ServerInstanceID || existing.PluginID != request.PluginID || existing.ProposedConfig != result.SuggestedConfig {
|
||||
return domain.AIConfigDiffPreview{}, validationError("requestId is already used for a different AI config recommendation")
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) getServerConfigForUser(userID, serverInstanceID string) (domain.ServerConfig, error) {
|
||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.ServerConfig{}, err
|
||||
}
|
||||
user, err := svc.store.Users().Get(userID)
|
||||
if err != nil {
|
||||
return domain.ServerConfig{}, err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return domain.ServerConfig{}, ErrForbidden
|
||||
}
|
||||
config := domain.ServerConfig{ServerInstanceID: instance.ID, ConfigVersion: instance.ConfigVersion, Format: "properties", Key: instance.ConfigKey, Content: instance.ConfigContent, Checksum: instance.ConfigChecksum, Source: "platform-derived", UpdatedAt: instance.ConfigUpdatedAt}
|
||||
if config.ConfigVersion <= 0 {
|
||||
config.ConfigVersion = 1
|
||||
}
|
||||
if config.Key == "" {
|
||||
config.Key = "server.properties"
|
||||
}
|
||||
if config.Content == "" {
|
||||
config.Content = buildLogicalServerConfig(instance)
|
||||
}
|
||||
if config.Checksum == "" {
|
||||
config.Checksum = validator.BytesChecksum([]byte(config.Content))
|
||||
}
|
||||
if config.UpdatedAt.IsZero() {
|
||||
config.UpdatedAt = svc.now()
|
||||
}
|
||||
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()
|
||||
stamp := svc.now()
|
||||
installation := domain.PluginLifecycleInstallation{ID: pluginLifecycleInstallationID(plugin.ID, instance.ID), PluginID: plugin.ID, ServerInstanceID: instance.ID, TargetVersion: request.TargetVersion, DesiredState: domain.PluginLifecycleStatePending, CurrentState: domain.PluginLifecycleStateFailed, LastOperation: request.Operation, Compatibility: "incompatible", DependencyState: domain.DependencyStateUnknown, FailureReason: safeBridgeReason(reason), IdempotencyKey: request.IdempotencyKey, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if existing, err := svc.store.PluginLifecycles().Get(installation.ID); err == nil {
|
||||
installation.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
return svc.pluginLifecycleDeniedLocked(actorID, installation, request, reason)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if _, err := svc.store.PluginLifecycles().Get(installation.ID); errors.Is(err, repo.ErrNotFound) {
|
||||
err = svc.store.PluginLifecycles().Create(installation)
|
||||
} else if err == nil {
|
||||
err = svc.store.PluginLifecycles().Update(installation)
|
||||
}
|
||||
if err != nil {
|
||||
return domain.PluginLifecycleResult{}, err
|
||||
}
|
||||
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Alert: &alert, Status: "denied"}), nil
|
||||
}
|
||||
|
||||
func pluginLifecycleDispatchMetadata(plugin domain.GamePlugin, operation domain.PluginLifecycleOperation) (string, string, error) {
|
||||
switch operation {
|
||||
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback:
|
||||
if plugin.LifecycleActions.Install == "" {
|
||||
return "", "", validationError("plugin install action is not declared")
|
||||
}
|
||||
return domain.LifecycleCapabilityInstall, plugin.LifecycleActions.Install, nil
|
||||
case domain.PluginLifecycleOperationEnable:
|
||||
if plugin.LifecycleActions.Start == "" {
|
||||
return "", "", validationError("plugin start action is not declared")
|
||||
}
|
||||
return domain.LifecycleCapabilityStart, plugin.LifecycleActions.Start, nil
|
||||
case domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationRetire:
|
||||
if plugin.LifecycleActions.Stop == "" {
|
||||
return "", "", validationError("plugin stop action is not declared")
|
||||
}
|
||||
return domain.LifecycleCapabilityStop, plugin.LifecycleActions.Stop, nil
|
||||
case domain.PluginLifecycleOperationDependencyCheck:
|
||||
return domain.JobCapabilityDependenciesCheck, "dependencies/plugin", nil
|
||||
default:
|
||||
return "", "", validationError("plugin lifecycle operation is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func validatePluginLifecycleTransition(installation domain.PluginLifecycleInstallation, request domain.PluginLifecycleRequest) error {
|
||||
switch request.Operation {
|
||||
case domain.PluginLifecycleOperationInstall:
|
||||
if installation.CurrentState == domain.PluginLifecycleStateInstalled || installation.CurrentState == domain.PluginLifecycleStateEnabled || installation.CurrentState == domain.PluginLifecycleStateDisabled {
|
||||
return validationError("plugin is already installed")
|
||||
}
|
||||
case domain.PluginLifecycleOperationEnable:
|
||||
if installation.CurrentState != domain.PluginLifecycleStateInstalled && installation.CurrentState != domain.PluginLifecycleStateDisabled {
|
||||
return validationError("plugin must be installed or disabled before enable")
|
||||
}
|
||||
case domain.PluginLifecycleOperationDisable:
|
||||
if installation.CurrentState != domain.PluginLifecycleStateEnabled {
|
||||
return validationError("plugin must be enabled before disable")
|
||||
}
|
||||
case domain.PluginLifecycleOperationUpgrade:
|
||||
if installation.CurrentVersion == "" || request.TargetVersion == installation.CurrentVersion {
|
||||
return validationError("upgrade requires a different target version")
|
||||
}
|
||||
case domain.PluginLifecycleOperationRollback:
|
||||
if installation.PreviousVersion == "" {
|
||||
return validationError("rollback requires a retained previous version")
|
||||
}
|
||||
case domain.PluginLifecycleOperationRetire:
|
||||
if installation.CurrentState == domain.PluginLifecycleStateRetired {
|
||||
return validationError("plugin is already retired")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func desiredPluginLifecycleState(operation domain.PluginLifecycleOperation, installation domain.PluginLifecycleInstallation) domain.PluginLifecycleState {
|
||||
switch operation {
|
||||
case domain.PluginLifecycleOperationInstall:
|
||||
return domain.PluginLifecycleStateInstalled
|
||||
case domain.PluginLifecycleOperationEnable:
|
||||
return domain.PluginLifecycleStateEnabled
|
||||
case domain.PluginLifecycleOperationDisable:
|
||||
return domain.PluginLifecycleStateDisabled
|
||||
case domain.PluginLifecycleOperationRetire:
|
||||
return domain.PluginLifecycleStateRetired
|
||||
default:
|
||||
return installation.DesiredState
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchedPluginLifecycleState(operation domain.PluginLifecycleOperation, current domain.PluginLifecycleState) domain.PluginLifecycleState {
|
||||
switch operation {
|
||||
case domain.PluginLifecycleOperationUpgrade:
|
||||
return domain.PluginLifecycleStateUpgrading
|
||||
case domain.PluginLifecycleOperationRollback:
|
||||
return domain.PluginLifecycleStateRollingBack
|
||||
case domain.PluginLifecycleOperationInstall:
|
||||
return domain.PluginLifecycleStatePending
|
||||
default:
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
func applyPluginLifecycleSuccess(installation *domain.PluginLifecycleInstallation) {
|
||||
switch installation.LastOperation {
|
||||
case domain.PluginLifecycleOperationInstall:
|
||||
installation.CurrentVersion = installation.TargetVersion
|
||||
installation.CurrentState = domain.PluginLifecycleStateInstalled
|
||||
case domain.PluginLifecycleOperationEnable:
|
||||
installation.CurrentState = domain.PluginLifecycleStateEnabled
|
||||
case domain.PluginLifecycleOperationDisable:
|
||||
installation.CurrentState = domain.PluginLifecycleStateDisabled
|
||||
case domain.PluginLifecycleOperationUpgrade:
|
||||
installation.CurrentVersion = installation.TargetVersion
|
||||
installation.CurrentState = installation.DesiredState
|
||||
if installation.CurrentState != domain.PluginLifecycleStateEnabled && installation.CurrentState != domain.PluginLifecycleStateDisabled {
|
||||
installation.CurrentState = domain.PluginLifecycleStateInstalled
|
||||
}
|
||||
case domain.PluginLifecycleOperationRollback:
|
||||
current := installation.CurrentVersion
|
||||
installation.CurrentVersion = installation.PreviousVersion
|
||||
installation.PreviousVersion = current
|
||||
installation.TargetVersion = installation.CurrentVersion
|
||||
installation.CurrentState = domain.PluginLifecycleStateInstalled
|
||||
case domain.PluginLifecycleOperationRetire:
|
||||
installation.CurrentState = domain.PluginLifecycleStateRetired
|
||||
case domain.PluginLifecycleOperationDependencyCheck:
|
||||
installation.DependencyState = domain.DependencyStatePresent
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"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"}
|
||||
first, err := svc.RunPluginLifecycleForSession(session, request)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch plugin install: %v", err)
|
||||
}
|
||||
second, err := svc.RunPluginLifecycleForSession(session, request)
|
||||
if err != nil {
|
||||
t.Fatalf("repeat plugin install: %v", err)
|
||||
}
|
||||
if first.Job.ID == "" || second.Job.ID != first.Job.ID {
|
||||
t.Fatalf("expected one idempotent job, got first=%+v second=%+v", first.Job, second.Job)
|
||||
}
|
||||
drift := request
|
||||
drift.TargetVersion = "1.1.0"
|
||||
if _, err := svc.RunPluginLifecycleForSession(session, drift); err == nil || !strings.Contains(err.Error(), "idempotencyKey") {
|
||||
t.Fatalf("expected immutable input conflict, got %v", err)
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(jobs) != 1 {
|
||||
t.Fatalf("expected exactly one lifecycle job, got %+v err=%v", jobs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginLifecycleBridgeDispatchesOnlyPlatformGovernedJob(t *testing.T) {
|
||||
svc, session, instance := newProductionOpsFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.lifecycle")
|
||||
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionPluginLifecycle))
|
||||
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{
|
||||
Key: "operations", Title: "Operations", Path: "/operations",
|
||||
Permissions: []string{"server.lifecycle"}, BridgeActions: []string{string(domain.PluginBridgeActionPluginLifecycle)},
|
||||
})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin bridge declaration: %v", err)
|
||||
}
|
||||
|
||||
response, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||
RequestID: "bridge-plugin-install", PluginID: plugin.ID, RouteKey: "operations",
|
||||
ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionPluginLifecycle,
|
||||
Payload: map[string]string{"operation": "install", "targetVersion": plugin.Version, "idempotencyKey": "bridge-plugin-install-v1", "confirmed": "false"},
|
||||
})
|
||||
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)
|
||||
}
|
||||
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"],
|
||||
}, " "))
|
||||
for _, forbidden := range []string{"password", "apikey", "token", "secret://", "baseurl", "hostpath", "socket", "pid", "dsn", "rcon", "runendpoint"} {
|
||||
if strings.Contains(serialized, forbidden) {
|
||||
t.Fatalf("bridge lifecycle result exposed forbidden fragment %q: %s", forbidden, serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testing.T) {
|
||||
svc, session, instance := newProductionOpsFixture(t)
|
||||
provider, err := svc.CreateAIProvider(domain.AIProvider{ID: "ai-local", Name: "Local AI", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:11434/v1", Models: []string{"test-model"}, DefaultModel: "test-model", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, Status: domain.AIProviderStatusActive, RedactionPolicy: "strict"})
|
||||
if err != nil {
|
||||
t.Fatalf("create provider: %v", err)
|
||||
}
|
||||
response, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-1", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke AI: %v", err)
|
||||
}
|
||||
if response.ConfigRecommendation == nil || response.ConfigRecommendation.DiffID == "" {
|
||||
t.Fatalf("expected persisted config recommendation, got %+v", response)
|
||||
}
|
||||
jobs, _ := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if len(jobs) != 0 {
|
||||
t.Fatalf("AI recommendation must not dispatch before approval: %+v", jobs)
|
||||
}
|
||||
approved, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("approve AI diff: %v", err)
|
||||
}
|
||||
if approved.Preview.State != domain.AIConfigDiffStateApproved || approved.Dispatch.Job.ID == "" {
|
||||
t.Fatalf("expected approved diff and queued job, got %+v", approved)
|
||||
}
|
||||
repeated, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
|
||||
if err != nil || repeated.Dispatch.Job.ID != approved.Dispatch.Job.ID {
|
||||
t.Fatalf("repeat approval must return original job: %+v err=%v", repeated, err)
|
||||
}
|
||||
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if len(jobs) != 1 {
|
||||
t.Fatalf("approval must dispatch exactly one job, got %+v", jobs)
|
||||
}
|
||||
|
||||
staleResponse, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-stale", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke stale AI candidate: %v", err)
|
||||
}
|
||||
stored, err := svc.store.ServerInstances().Get(instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get server: %v", err)
|
||||
}
|
||||
stored.ConfigVersion++
|
||||
if err := svc.store.ServerInstances().Update(stored); err != nil {
|
||||
t.Fatalf("advance config revision: %v", err)
|
||||
}
|
||||
if _, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: staleResponse.ConfigRecommendation.DiffID, IdempotencyKey: "approve-stale"}); err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") {
|
||||
t.Fatalf("expected stale revision rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newProductionOpsFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
plugin.SupportedOS = []string{"linux"}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin platform: %v", err)
|
||||
}
|
||||
endpoint.Platform = "linux"
|
||||
endpoint.Architecture = "amd64"
|
||||
endpoint.LastHeartbeatAt = fixedTime
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("update endpoint metadata: %v", err)
|
||||
}
|
||||
session := createServiceUserAndLogin(t, svc, domain.User{ID: "production-owner", DisplayName: "Production Owner", Email: "production-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "production-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Production Server", State: domain.ServerInstanceStateReady})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
binding := domain.RuntimeBinding{ID: "runtime-binding-" + instance.ID, ServerInstanceID: instance.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime}
|
||||
if err := svc.store.RuntimeBindings().Create(binding); err != nil && !errors.Is(err, repo.ErrDuplicate) {
|
||||
t.Fatalf("create runtime binding: %v", err)
|
||||
}
|
||||
return svc, session, instance
|
||||
}
|
||||
@@ -82,17 +82,21 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request
|
||||
if timeout > selected.TimeoutSeconds || attempts > selected.MaxAttempts {
|
||||
return domain.RemoteAdapterResult{}, validationError("remote adapter timeout or retry exceeds declaration")
|
||||
}
|
||||
inputRef := request.InputRef
|
||||
if inputRef == "" {
|
||||
inputRef = fmt.Sprintf("input://remote-adapters/%s/%s", instance.ID, request.DeclarationKey)
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: jobIDFromParts("job-remote-adapter", instance.ID, request.IdempotencyKey),
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
Capability: request.Capability,
|
||||
TargetKey: request.TargetKey,
|
||||
InputRef: fmt.Sprintf("input://remote-adapters/%s/%s", instance.ID, request.DeclarationKey),
|
||||
InputRef: inputRef,
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "scoped remote adapter queued"},
|
||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: attempts, InitialBackoffSeconds: 2, MaxBackoffSeconds: 30},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout, Inputs: domain.CopyStringMap(request.Inputs)},
|
||||
}
|
||||
created, err := svc.CreateJob(job)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||
plugin.Permissions.RemoteAccess = true
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
||||
plugin.RemoteAccess = domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, DatabaseEngines: []string{"sqlite"}}
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "player-lookup", Kind: "sqlite", TargetKey: "scum-db.player-lookup", Capabilities: []string{capability}})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, capability)
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := createServiceUserAndLogin(t, svc, domain.User{ID: "user-remote-owner", DisplayName: "Remote Owner", Email: "remote-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-remote-input", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Remote Input"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inputs := map[string]string{"playerId": "steam-123", "limit": "25"}
|
||||
result, err := svc.RequestRemoteAdapterForSession(session, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: "player-lookup", TargetKey: "scum-db.player-lookup", Capability: capability, IdempotencyKey: "lookup-1", InputRef: "input://scum-db/player-lookup/lookup-1", Inputs: inputs})
|
||||
if err != nil {
|
||||
t.Fatalf("request remote adapter: %v", err)
|
||||
}
|
||||
inputs["playerId"] = "mutated"
|
||||
job, err := svc.store.Jobs().Get(result.RequestID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.InputRef != "input://scum-db/player-lookup/lookup-1" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["limit"] != "25" {
|
||||
t.Fatalf("typed inputs were not propagated: %#v", job)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, capability)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-remote-inputs"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run hello: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil {
|
||||
t.Fatalf("claim remote adapter job: %v", err)
|
||||
}
|
||||
if !claim.HasJob || claim.Job == nil || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.Inputs["playerId"] != "steam-123" || claim.Job.ExecutionInput.Inputs["limit"] != "25" {
|
||||
t.Fatalf("typed inputs were not propagated to real Run claim: %#v", claim.Job)
|
||||
}
|
||||
|
||||
response := dto.RunJobAssignmentFromDomain(*claim.Job)
|
||||
payload, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Run assignment response: %v", err)
|
||||
}
|
||||
var wire struct {
|
||||
ExecutionInput struct {
|
||||
Inputs map[string]string `json:"inputs"`
|
||||
} `json:"executionInput"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &wire); err != nil {
|
||||
t.Fatalf("unmarshal Run assignment response: %v", err)
|
||||
}
|
||||
if wire.ExecutionInput.Inputs["playerId"] != "steam-123" || wire.ExecutionInput.Inputs["limit"] != "25" {
|
||||
t.Fatalf("typed inputs were not preserved in Run assignment JSON: %s", payload)
|
||||
}
|
||||
wire.ExecutionInput.Inputs["playerId"] = "wire-mutated"
|
||||
if claim.Job.ExecutionInput.Inputs["playerId"] != "steam-123" {
|
||||
t.Fatal("Run assignment DTO aliases domain remote inputs")
|
||||
}
|
||||
claim.Job.ExecutionInput.Inputs["playerId"] = "assignment-mutated"
|
||||
stored, err := svc.store.Jobs().Get(result.RequestID)
|
||||
if err != nil {
|
||||
t.Fatalf("get claimed remote adapter job: %v", err)
|
||||
}
|
||||
if stored.ExecutionInput.Inputs["playerId"] != "steam-123" {
|
||||
t.Fatal("real Run claim aliases persisted remote inputs")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,28 @@ var (
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
type ForbiddenError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (err ForbiddenError) Error() string {
|
||||
if strings.TrimSpace(err.Reason) == "" {
|
||||
return ErrForbidden.Error()
|
||||
}
|
||||
return ErrForbidden.Error() + ": " + err.Reason
|
||||
}
|
||||
|
||||
func (err ForbiddenError) Is(target error) bool {
|
||||
return target == ErrForbidden
|
||||
}
|
||||
|
||||
func forbiddenError(reason string) error {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
return ErrForbidden
|
||||
}
|
||||
return ForbiddenError{Reason: reason}
|
||||
}
|
||||
|
||||
type Core interface {
|
||||
CreateUser(domain.User) (domain.User, error)
|
||||
UpdateUser(string, domain.User) (domain.User, error)
|
||||
@@ -79,6 +101,16 @@ type Core interface {
|
||||
ArchiveServerInstanceForSession(string, string) (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)
|
||||
ApproveAIConfigDiffForSession(string, domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error)
|
||||
IngestMetricBatch(domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error)
|
||||
ListMetricSamplesForSession(string, domain.MetricSampleFilter) ([]domain.MetricSample, error)
|
||||
CreateBackupForSession(string, domain.BackupRecord) (domain.BackupRecord, error)
|
||||
@@ -137,6 +169,17 @@ type Core interface {
|
||||
RegisterClientManager(domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error)
|
||||
AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error)
|
||||
ReconcileClientManagerLifecycle() error
|
||||
QueueGameClientBridgeCommandForSession(string, domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error)
|
||||
ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error)
|
||||
AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error)
|
||||
CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error)
|
||||
CancelGameClientBridgeCommandForSession(string, domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error)
|
||||
UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error)
|
||||
ReconcileGameClientBridgeCommands() error
|
||||
GetGameClientBridgeStatusForSession(string, string) (domain.GameClientBridgeStatus, error)
|
||||
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
|
||||
GetGameClientBridgeCommandForSession(string, string) (domain.GameClientBridgeCommand, error)
|
||||
QueryGameClientBridgeSnapshotsForSession(string, domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error)
|
||||
PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error)
|
||||
ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error)
|
||||
GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error)
|
||||
@@ -169,6 +212,8 @@ type CoreService struct {
|
||||
runSessions map[string]domain.RunControlSession
|
||||
runSessionSeq uint64
|
||||
jobMu sync.Mutex
|
||||
bridgeMu sync.Mutex
|
||||
bridgeSeq uint64
|
||||
logStore LogBodyStore
|
||||
artifactStore ArtifactBodyStore
|
||||
artifactMu sync.Mutex
|
||||
@@ -177,6 +222,7 @@ type CoreService struct {
|
||||
artifactTransferSeq uint64
|
||||
auditMu sync.Mutex
|
||||
auditSeq uint64
|
||||
productionMu sync.Mutex
|
||||
aiProviderClient AIProviderClient
|
||||
secretEnvelope SecretEnvelope
|
||||
}
|
||||
@@ -237,6 +283,9 @@ func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, ar
|
||||
if err := service.ReconcileClientManagerLifecycle(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := service.ReconcileGameClientBridgeCommands(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
@@ -538,13 +587,13 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult,
|
||||
|
||||
result := domain.AIProviderTestResult{
|
||||
ProviderID: provider.ID,
|
||||
Mode: "metadata",
|
||||
Mode: "provider",
|
||||
Success: true,
|
||||
Message: "metadata validation passed",
|
||||
Message: "provider invocation passed",
|
||||
}
|
||||
if err := validator.ValidateAIProvider(provider); err != nil {
|
||||
result.Success = false
|
||||
result.Message = "metadata validation failed"
|
||||
result.Message = "provider validation failed"
|
||||
var validationErr validator.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
result.Violations = append(result.Violations, validationErr.Violations...)
|
||||
@@ -554,9 +603,35 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult,
|
||||
}
|
||||
if provider.Status != domain.AIProviderStatusActive {
|
||||
result.Success = false
|
||||
result.Message = "metadata validation failed"
|
||||
result.Message = "provider validation failed"
|
||||
result.Violations = append(result.Violations, "provider must be active")
|
||||
}
|
||||
if !result.Success {
|
||||
return domain.CopyAIProviderTestResult(result), nil
|
||||
}
|
||||
_, invokeErr := svc.aiProviderClient.Invoke(provider, domain.AIInvocationRequest{RequestID: "provider-test-" + provider.ID, Purpose: "provider.health", Prompt: "Return a short health acknowledgement.", Model: provider.DefaultModel})
|
||||
if invokeErr != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -584,6 +659,7 @@ func (svc *CoreService) CreateGamePlugin(plugin domain.GamePlugin) (domain.GameP
|
||||
if plugin.Status == "" {
|
||||
plugin.Status = domain.GamePluginStatusInstalled
|
||||
}
|
||||
plugin.ProductionLifecycle = normalizedProductionLifecycle(plugin.ProductionLifecycle)
|
||||
if err := validator.ValidateGamePlugin(plugin); err != nil {
|
||||
return domain.GamePlugin{}, err
|
||||
}
|
||||
@@ -621,8 +697,10 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
|
||||
Pages: manifest.Pages,
|
||||
Tags: manifest.Tags,
|
||||
AIPurposes: manifest.AI.Purposes,
|
||||
ProductionLifecycle: manifest.ProductionLifecycle,
|
||||
RemoteAccess: manifest.RemoteAccess,
|
||||
RuntimeProfiles: manifest.RuntimeProfiles,
|
||||
GameClientBridge: manifest.GameClientBridge,
|
||||
Status: domain.GamePluginStatusInstalled,
|
||||
}
|
||||
}
|
||||
@@ -714,6 +792,8 @@ func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request doma
|
||||
base = svc.executeBridgeLogsBackfillRequest(base, plugin, instance, request.Payload)
|
||||
case domain.PluginBridgeActionClientManager:
|
||||
base = svc.executeBridgeClientManager(sessionID, base, request)
|
||||
case domain.PluginBridgeActionPluginLifecycle:
|
||||
base = svc.executeBridgePluginLifecycle(sessionID, base, request)
|
||||
case domain.PluginBridgeActionArtifactsOpen:
|
||||
base = svc.executeBridgeArtifactOpen(sessionID, base, request)
|
||||
case domain.PluginBridgeActionAIInvoke:
|
||||
@@ -775,7 +855,6 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
||||
RouteKey: request.RouteKey,
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
Purpose: request.AIPurpose,
|
||||
ProviderID: request.Payload["providerId"],
|
||||
Model: request.Payload["model"],
|
||||
Prompt: defaultBridgeValue(request.Payload["prompt"], "Review the current server context and provide a safe recommendation."),
|
||||
CurrentConfig: request.Payload["currentConfig"],
|
||||
@@ -795,6 +874,9 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
||||
if response.ConfigRecommendation != nil {
|
||||
base.Result["suggestedConfig"] = response.ConfigRecommendation.SuggestedConfig
|
||||
base.Result["diffSummary"] = response.ConfigRecommendation.DiffSummary
|
||||
base.Result["diffId"] = response.ConfigRecommendation.DiffID
|
||||
base.Result["key"] = response.ConfigRecommendation.Key
|
||||
base.Result["expiresAt"] = response.ConfigRecommendation.ExpiresAt
|
||||
}
|
||||
if response.Error != nil {
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
||||
@@ -802,6 +884,22 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
||||
return base
|
||||
}
|
||||
|
||||
func (svc *CoreService) executeBridgePluginLifecycle(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
|
||||
confirmed, err := strconv.ParseBool(defaultBridgeValue(request.Payload["confirmed"], "false"))
|
||||
if err != nil {
|
||||
base.Status = "error"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "confirmed must be true or false"}
|
||||
return base
|
||||
}
|
||||
result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: request.PluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Payload["operation"]), TargetVersion: request.Payload["targetVersion"], IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID), Confirmed: confirmed})
|
||||
if err != nil {
|
||||
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}
|
||||
return base
|
||||
}
|
||||
|
||||
func (svc *CoreService) executeBridgeJobDispatch(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
|
||||
capability := strings.TrimSpace(payload["capability"])
|
||||
if capability == "" {
|
||||
@@ -951,7 +1049,53 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
|
||||
}
|
||||
timeoutSeconds, _ := strconv.Atoi(payload["timeoutSeconds"])
|
||||
maxAttempts, _ := strconv.Atoi(payload["maxAttempts"])
|
||||
result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID)})
|
||||
inputs := map[string]string{}
|
||||
for key, value := range payload {
|
||||
if strings.HasPrefix(key, "input.") {
|
||||
inputs[strings.TrimPrefix(key, "input.")] = value
|
||||
}
|
||||
}
|
||||
if capability == domain.JobCapabilityRemoteRunDBSQLiteQuery {
|
||||
templateKey := strings.TrimSpace(inputs["templateKey"])
|
||||
if templateKey == "" {
|
||||
base.Status = "error"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "input.templateKey is required for sqlite query requests"}
|
||||
return base
|
||||
}
|
||||
template, reason := findBridgeQueryTemplate(plugin, base.RouteKey, templateKey)
|
||||
if reason != "" {
|
||||
base.Status = "denied"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "query_template_denied", Message: reason}
|
||||
return base
|
||||
}
|
||||
if template.Engine != "sqlite" || template.TransportKey != declarationKey || template.TargetKey != payload["targetKey"] {
|
||||
base.Status = "denied"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "query_template_denied", Message: "query template transport or target is not approved"}
|
||||
return base
|
||||
}
|
||||
if timeoutSeconds == 0 {
|
||||
timeoutSeconds = template.TimeoutSeconds
|
||||
} else if timeoutSeconds > template.TimeoutSeconds {
|
||||
base.Status = "error"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "query template timeout limit exceeded"}
|
||||
return base
|
||||
}
|
||||
maxRows := template.MaxRows
|
||||
if requestedRows, ok := inputs["maxRows"]; ok && strings.TrimSpace(requestedRows) != "" {
|
||||
parsedRows, parseErr := strconv.Atoi(requestedRows)
|
||||
if parseErr != nil || parsedRows <= 0 {
|
||||
base.Status = "error"
|
||||
base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "input.maxRows must be a positive integer"}
|
||||
return base
|
||||
}
|
||||
if parsedRows < maxRows {
|
||||
maxRows = parsedRows
|
||||
}
|
||||
}
|
||||
inputs["templateKey"] = template.Key
|
||||
inputs["maxRows"] = strconv.Itoa(maxRows)
|
||||
}
|
||||
result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), InputRef: payload["inputRef"], Inputs: inputs})
|
||||
if err != nil {
|
||||
return bridgeExecutionError(base, err)
|
||||
}
|
||||
@@ -967,6 +1111,46 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
|
||||
return base
|
||||
}
|
||||
|
||||
func findBridgeQueryTemplate(plugin domain.GamePlugin, routeKey string, templateKey string) (domain.GameClientBridgeQueryTemplateDeclaration, string) {
|
||||
pageFound := false
|
||||
pageAllowsTemplate := false
|
||||
for _, page := range plugin.GameClientBridge.Pages {
|
||||
if page.PageKey != routeKey {
|
||||
continue
|
||||
}
|
||||
pageFound = true
|
||||
if containsString(page.QueryTemplateKeys, templateKey) {
|
||||
pageAllowsTemplate = true
|
||||
}
|
||||
}
|
||||
if !pageFound || !pageAllowsTemplate {
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template is not declared by the bridge page"
|
||||
}
|
||||
var selected domain.GameClientBridgeQueryTemplateDeclaration
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.Key == templateKey {
|
||||
selected = template
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected.Key == "" {
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template is not declared by the plugin"
|
||||
}
|
||||
for _, page := range plugin.Pages {
|
||||
if page.Key != routeKey {
|
||||
continue
|
||||
}
|
||||
if !containsString(page.Permissions, selected.Permission) {
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template permission is not declared by the plugin page"
|
||||
}
|
||||
if !containsString(page.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) {
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template page does not declare remote access"
|
||||
}
|
||||
return selected, ""
|
||||
}
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, "query template plugin page is not declared"
|
||||
}
|
||||
|
||||
func (svc *CoreService) executeBridgeRunDistribution(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
|
||||
distribution, err := svc.GenerateRunDistributionForSession(sessionID, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
@@ -1278,14 +1462,29 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark
|
||||
Pages: plugin.Pages,
|
||||
Tags: plugin.Tags,
|
||||
AIPurposes: plugin.AIPurposes,
|
||||
ProductionLifecycle: plugin.ProductionLifecycle,
|
||||
RemoteAccess: plugin.RemoteAccess,
|
||||
RuntimeProfiles: plugin.RuntimeProfiles,
|
||||
GameClientBridge: plugin.GameClientBridge,
|
||||
ValidationViolations: plugin.ValidationViolations,
|
||||
Status: plugin.Status,
|
||||
Source: "platform-registry",
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedProductionLifecycle(lifecycle domain.GamePluginProductionLifecycle) domain.GamePluginProductionLifecycle {
|
||||
if len(lifecycle.Operations) == 0 {
|
||||
lifecycle.Operations = []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}
|
||||
}
|
||||
if lifecycle.DependencyPolicy == "" {
|
||||
lifecycle.DependencyPolicy = "optional"
|
||||
}
|
||||
if len(lifecycle.ApprovalRequired) == 0 {
|
||||
lifecycle.ApprovalRequired = []string{"disable", "rollback", "retire"}
|
||||
}
|
||||
return lifecycle
|
||||
}
|
||||
|
||||
func marketplacePluginMatchesKeyword(plugin domain.PluginMarketplacePlugin, keyword string) bool {
|
||||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||
if keyword == "" {
|
||||
|
||||
@@ -757,7 +757,7 @@ func TestCoreServiceManagesAIProviderMetadata(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("test enabled provider: %v", err)
|
||||
}
|
||||
if !testResult.Success || testResult.Mode != "metadata" {
|
||||
if !testResult.Success || testResult.Mode != "provider" {
|
||||
t.Fatalf("expected metadata test success, got %+v", testResult)
|
||||
}
|
||||
|
||||
@@ -1022,6 +1022,147 @@ func TestCoreServiceRemoteAccessRequiresPluginDeclaration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceDispatchesDeclaredSQLiteQueryTemplate(t *testing.T) {
|
||||
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
|
||||
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||
RequestID: "query-template-dispatch-1",
|
||||
PluginID: plugin.ID,
|
||||
RouteKey: "remote",
|
||||
ServerInstanceID: instance.ID,
|
||||
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||
Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
"declarationKey": "scum-db-read",
|
||||
"targetKey": "scum-db.player-lookup",
|
||||
"idempotencyKey": "query-template-dispatch-1",
|
||||
"input.templateKey": "players.by-id",
|
||||
"input.playerId": "steam-123",
|
||||
"input.maxRows": "100",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute declared sqlite query template: %v", err)
|
||||
}
|
||||
if queued.Status != "queued" || queued.Result["jobId"] == "" {
|
||||
t.Fatalf("expected queued query template job, got %+v", queued)
|
||||
}
|
||||
job, err := svc.store.Jobs().Get(queued.Result["jobId"])
|
||||
if err != nil {
|
||||
t.Fatalf("get query template job: %v", err)
|
||||
}
|
||||
if job.ExecutionInput.TimeoutSeconds != 20 {
|
||||
t.Fatalf("expected template timeout 20, got %+v", job.ExecutionInput)
|
||||
}
|
||||
if job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || job.ExecutionInput.Inputs["playerId"] != "steam-123" || job.ExecutionInput.Inputs["maxRows"] != "25" {
|
||||
t.Fatalf("expected typed bounded query template inputs, got %#v", job.ExecutionInput.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceDeniesUndeclaredOrMismatchedSQLiteQueryTemplateBeforeJob(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
templateKey string
|
||||
declarationKey string
|
||||
targetKey string
|
||||
}{
|
||||
{name: "undeclared template", templateKey: "players.unknown", declarationKey: "scum-db-read", targetKey: "scum-db.player-lookup"},
|
||||
{name: "mismatched transport", templateKey: "players.by-id", declarationKey: "other-transport", targetKey: "scum-db.player-lookup"},
|
||||
{name: "mismatched target", templateKey: "players.by-id", declarationKey: "scum-db-read", targetKey: "scum-db.other"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||
RequestID: "query-template-denied-1",
|
||||
PluginID: plugin.ID,
|
||||
RouteKey: "remote",
|
||||
ServerInstanceID: instance.ID,
|
||||
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||
Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
"declarationKey": test.declarationKey,
|
||||
"targetKey": test.targetKey,
|
||||
"idempotencyKey": "query-template-denied-1",
|
||||
"input.templateKey": test.templateKey,
|
||||
"input.playerId": "steam-123",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute denied sqlite query template: %v", err)
|
||||
}
|
||||
if result.Status != "denied" || result.Error == nil || result.Error.Code != "query_template_denied" {
|
||||
t.Fatalf("expected query template denial, got %+v", result)
|
||||
}
|
||||
jobs, err := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("list jobs after denial: %v", err)
|
||||
}
|
||||
if len(jobs) != 0 {
|
||||
t.Fatalf("query template denial created jobs: %+v", jobs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBridgeQueryTemplateRequiresPagePermissionAndRemoteAction(t *testing.T) {
|
||||
_, plugin, _, _, _ := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].Permission = "server.game-client.read"
|
||||
|
||||
for index := range plugin.Pages {
|
||||
if plugin.Pages[index].Key == "remote" {
|
||||
plugin.Pages[index].Permissions = []string{"server.remote.access"}
|
||||
}
|
||||
}
|
||||
if _, reason := findBridgeQueryTemplate(plugin, "remote", "players.by-id"); !strings.Contains(reason, "permission") {
|
||||
t.Fatalf("expected query template permission denial, got %q", reason)
|
||||
}
|
||||
|
||||
for index := range plugin.Pages {
|
||||
if plugin.Pages[index].Key == "remote" {
|
||||
plugin.Pages[index].Permissions = []string{"server.remote.access"}
|
||||
plugin.Pages[index].BridgeActions = nil
|
||||
}
|
||||
}
|
||||
plugin.GameClientBridge.QueryTemplates[0].Permission = "server.remote.access"
|
||||
if _, reason := findBridgeQueryTemplate(plugin, "remote", "players.by-id"); !strings.Contains(reason, "remote access") {
|
||||
t.Fatalf("expected query template remote access denial, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsArbitrarySQLBridgeInputBeforeJob(t *testing.T) {
|
||||
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
|
||||
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||
RequestID: "query-template-sql-rejected-1",
|
||||
PluginID: plugin.ID,
|
||||
RouteKey: "remote",
|
||||
ServerInstanceID: instance.ID,
|
||||
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||
Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
"declarationKey": "scum-db-read",
|
||||
"targetKey": "scum-db.player-lookup",
|
||||
"idempotencyKey": "query-template-sql-rejected-1",
|
||||
"input.templateKey": "players.by-id",
|
||||
"input.sqlText": "SELECT * FROM users",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute arbitrary SQL bridge input: %v", err)
|
||||
}
|
||||
if result.Status != "error" || result.Error == nil || !strings.Contains(strings.ToLower(result.Error.Message), "unsafe") {
|
||||
t.Fatalf("expected arbitrary SQL input rejection, got %+v", result)
|
||||
}
|
||||
jobs, listErr := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if listErr != nil {
|
||||
t.Fatalf("list jobs after arbitrary SQL rejection: %v", listErr)
|
||||
}
|
||||
if len(jobs) != 0 {
|
||||
t.Fatalf("arbitrary SQL rejection created jobs: %+v", jobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsDuplicateGamePluginManifest(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
registration := validPluginManifestRegistration()
|
||||
@@ -1212,6 +1353,80 @@ func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlug
|
||||
return plugin, endpoint
|
||||
}
|
||||
|
||||
func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlugin, domain.RunEndpoint, string, domain.ServerInstance) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
|
||||
plugin.Permissions.RemoteAccess = true
|
||||
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
||||
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{
|
||||
Key: "remote",
|
||||
Title: "Remote",
|
||||
Path: "/remote",
|
||||
Permissions: []string{"server.remote.access"},
|
||||
BridgeActions: []string{string(domain.PluginBridgeActionRemoteAccessRequest)},
|
||||
})
|
||||
plugin.RemoteAccess = domain.GamePluginRemoteAccess{
|
||||
Methods: []string{"run"},
|
||||
RunCapabilities: []string{capability},
|
||||
DatabaseEngines: []string{"sqlite"},
|
||||
}
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
|
||||
Key: "scum-db-read",
|
||||
Kind: "sqlite",
|
||||
TargetKey: "scum-db.player-lookup",
|
||||
Capabilities: []string{capability},
|
||||
})
|
||||
plugin.GameClientBridge = domain.GameClientBridgeManifest{
|
||||
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{
|
||||
{
|
||||
Key: "players.by-id",
|
||||
Title: "Player lookup",
|
||||
Permission: "server.remote.access",
|
||||
Engine: "sqlite",
|
||||
TransportKey: "scum-db-read",
|
||||
TargetKey: "scum-db.player-lookup",
|
||||
ParameterSchemaRef: "schemas/queries/players.by-id.parameters.schema.json",
|
||||
ResultSchemaRef: "schemas/queries/players.by-id.result.schema.json",
|
||||
MaxRows: 25,
|
||||
TimeoutSeconds: 20,
|
||||
},
|
||||
},
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100},
|
||||
Pages: []domain.GameClientBridgePageContract{
|
||||
{PageKey: "remote", QueryTemplateKeys: []string{"players.by-id"}},
|
||||
},
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update sqlite query plugin fixture: %v", err)
|
||||
}
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, capability)
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("update sqlite query endpoint fixture: %v", err)
|
||||
}
|
||||
session := createServiceUserAndLogin(t, svc, domain.User{
|
||||
ID: "user-query-owner",
|
||||
DisplayName: "Query Owner",
|
||||
Email: "query-owner@example.test",
|
||||
Roles: []string{"server-owner"},
|
||||
PasswordHash: "secret-password",
|
||||
})
|
||||
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{
|
||||
ID: "server-query-template",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Name: "Query Template Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlite query server fixture: %v", err)
|
||||
}
|
||||
return svc, plugin, endpoint, session, instance
|
||||
}
|
||||
|
||||
func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domain.ServerInstance, profileKey string) domain.RuntimeBinding {
|
||||
t.Helper()
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
@@ -1269,8 +1484,9 @@ func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
|
||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||
},
|
||||
},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}},
|
||||
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
||||
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,68 @@ func ValidateClientManagerLifecycleInputRequest(value domain.ClientManagerLifecy
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerCompanionConfigInput(value domain.ClientManagerCompanionConfigInput) error {
|
||||
var violations []string
|
||||
if value.SchemaVersion != domain.ClientManagerCompanionConfigSchemaVersion {
|
||||
violations = append(violations, "schemaVersion is invalid")
|
||||
}
|
||||
for field, content := range map[string]string{
|
||||
"configTemplateKey": value.ConfigTemplateKey, "configTemplateRef": value.ConfigTemplateRef, "configOutputRef": value.ConfigOutputRef,
|
||||
"configSchemaRef": value.ConfigSchemaRef, "installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID,
|
||||
"pluginId": value.PluginID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version,
|
||||
"sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "proofMaterialEnv": value.ProofMaterialEnv,
|
||||
} {
|
||||
violations = appendRequired(violations, field, content)
|
||||
}
|
||||
if !clientManagerIdentifierPattern.MatchString(value.ConfigTemplateKey) {
|
||||
violations = append(violations, "configTemplateKey is invalid")
|
||||
}
|
||||
violations = append(violations, validateSafeRelativeRuntimePath("configTemplateRef", value.ConfigTemplateRef)...)
|
||||
if value.ConfigOutputRef != "config.yaml" {
|
||||
violations = append(violations, "configOutputRef must be config.yaml")
|
||||
}
|
||||
if !safeRelativeJSONRef(value.ConfigSchemaRef) {
|
||||
violations = append(violations, "configSchemaRef must be a safe relative JSON reference")
|
||||
}
|
||||
if value.ConfigFormat != "yaml" || value.PlatformBaseURLSource != "run-control" || value.RegistrationProof != "hmac-sha256" || value.ProofMaterialSource != "component-package" || value.SessionMode != "component-session" || value.TLSPolicy != "verify-system-roots" {
|
||||
violations = append(violations, "companion bootstrap security policy is invalid")
|
||||
}
|
||||
if !validCompanionProofEnvironment(value.ProofMaterialEnv) {
|
||||
violations = append(violations, "proofMaterialEnv is invalid")
|
||||
}
|
||||
for field, identifier := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "pluginId": value.PluginID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID} {
|
||||
if !clientManagerIdentifierPattern.MatchString(identifier) {
|
||||
violations = append(violations, field+" is invalid")
|
||||
}
|
||||
}
|
||||
violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch)
|
||||
if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 {
|
||||
violations = append(violations, "keyGeneration and deploymentGeneration must be positive")
|
||||
}
|
||||
if len(value.Capabilities) == 0 || len(value.Capabilities) > 32 {
|
||||
violations = append(violations, "capabilities must be bounded")
|
||||
}
|
||||
capabilities := make(map[string]struct{}, len(value.Capabilities))
|
||||
for _, capability := range value.Capabilities {
|
||||
if !clientManagerIdentifierPattern.MatchString(capability) {
|
||||
violations = append(violations, "capability is invalid")
|
||||
}
|
||||
if _, exists := capabilities[capability]; exists {
|
||||
violations = append(violations, "capabilities must be unique")
|
||||
}
|
||||
capabilities[capability] = struct{}{}
|
||||
}
|
||||
for _, required := range []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"} {
|
||||
if _, exists := capabilities[required]; !exists {
|
||||
violations = append(violations, "capabilities must include "+required)
|
||||
}
|
||||
}
|
||||
if value.HeartbeatIntervalSeconds < 5 || value.HeartbeatIntervalSeconds > 300 || value.CommandPollIntervalSeconds < 1 || value.CommandPollIntervalSeconds > 60 || value.RequestTimeoutSeconds < 1 || value.RequestTimeoutSeconds > 60 {
|
||||
violations = append(violations, "companion timing policy is invalid")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerRegisterRequest(value domain.ClientManagerRegisterRequest) error {
|
||||
var violations []string
|
||||
for field, content := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version, "sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "nonce": value.Nonce, "signature": value.Signature} {
|
||||
|
||||
@@ -60,12 +60,17 @@ func ValidateRunControlHeartbeat(heartbeat domain.RunControlHeartbeat) error {
|
||||
}
|
||||
|
||||
func appendCapacityViolations(violations []string, capacity domain.RunCapacity) []string {
|
||||
if capacity.MaxJobs < 0 || capacity.RunningJobs < 0 || capacity.QueuedJobs < 0 {
|
||||
if capacity.MaxJobs < 0 || capacity.RunningJobs < 0 || capacity.QueuedJobs < 0 || capacity.LogBacklogBatches < 0 || capacity.ArtifactBacklogChunks < 0 {
|
||||
violations = append(violations, "capacity counts must not be negative")
|
||||
}
|
||||
if capacity.MaxJobs > 0 && capacity.RunningJobs > capacity.MaxJobs {
|
||||
violations = append(violations, "runningJobs must not exceed maxJobs")
|
||||
}
|
||||
for i, code := range capacity.PressureCodes {
|
||||
if strings.TrimSpace(code) == "" || strings.ContainsAny(code, " \t\r\n") || containsUnsafeRuntimeSecret(code) || looksLikeRawHostPath(code) {
|
||||
violations = append(violations, fmt.Sprintf("pressureCodes[%d] is invalid", i))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
maxGameClientBridgeArrayItems = 4096
|
||||
maxGameClientBridgeIdentifierLength = 180
|
||||
maxGameClientBridgeObjectKeys = 64
|
||||
maxGameClientBridgePayloadDepth = 16
|
||||
maxGameClientBridgePayloadNodes = 8192
|
||||
maxGameClientBridgePayloadSize = 64 * 1024
|
||||
maxGameClientBridgePayloadString = 16 * 1024
|
||||
maxGameClientBridgeSessionLength = 4096
|
||||
)
|
||||
|
||||
var (
|
||||
gameClientBridgeAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`)
|
||||
gameClientBridgeCamelBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
||||
gameClientBridgeNonWord = regexp.MustCompile(`[^A-Za-z0-9]+`)
|
||||
)
|
||||
|
||||
func ValidateGameClientBridgeQueueRequest(request domain.GameClientBridgeQueueRequest) error {
|
||||
var violations []string
|
||||
violations = appendGameClientBridgeIdentifier(violations, "serverInstanceId", request.ServerInstanceID, true)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "pluginId", request.PluginID, true)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "profileKey", request.ProfileKey, true)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "commandType", request.CommandType, true)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "idempotencyKey", request.IdempotencyKey, true)
|
||||
if request.ExpiresAt.IsZero() {
|
||||
violations = append(violations, "expiresAt is required")
|
||||
}
|
||||
if request.Priority < 0 || request.Priority > 100 {
|
||||
violations = append(violations, "priority must be between 0 and 100")
|
||||
}
|
||||
violations = append(violations, validateGameClientBridgePayload(request.Payload)...)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateGameClientBridgeClaimRequest(request domain.GameClientBridgeClaimRequest) error {
|
||||
var violations []string
|
||||
violations = appendGameClientBridgeSession(violations, request.SessionToken)
|
||||
if request.Limit < 0 || request.Limit > 50 {
|
||||
violations = append(violations, "limit must be between 0 and 50")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateGameClientBridgeAckRequest(request domain.GameClientBridgeAckRequest) error {
|
||||
var violations []string
|
||||
violations = appendGameClientBridgeSession(violations, request.SessionToken)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "commandId", request.CommandID, true)
|
||||
if request.FencingToken == 0 {
|
||||
violations = append(violations, "fencingToken is required")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateGameClientBridgeResultRequest(request domain.GameClientBridgeResultRequest) error {
|
||||
var violations []string
|
||||
violations = appendGameClientBridgeSession(violations, request.SessionToken)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "commandId", request.CommandID, true)
|
||||
if request.FencingToken == 0 {
|
||||
violations = append(violations, "fencingToken is required")
|
||||
}
|
||||
if request.Status != domain.GameClientBridgeResultSucceeded && request.Status != domain.GameClientBridgeResultFailed && request.Status != domain.GameClientBridgeResultCancelled {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
violations = appendGameClientBridgeText(violations, "summary", request.Summary, 512)
|
||||
violations = append(violations, validateGameClientBridgePayload(request.Payload)...)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateGameClientBridgeCancelRequest(request domain.GameClientBridgeCancelRequest) error {
|
||||
var violations []string
|
||||
violations = appendGameClientBridgeIdentifier(violations, "commandId", request.CommandID, true)
|
||||
violations = appendGameClientBridgeText(violations, "reason", request.Reason, 256)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateGameClientBridgeSnapshotIngestRequest(request domain.GameClientBridgeSnapshotIngestRequest) error {
|
||||
var violations []string
|
||||
violations = appendGameClientBridgeSession(violations, request.SessionToken)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "type", request.Type, true)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "schemaVersion", request.SchemaVersion, true)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "streamKey", request.StreamKey, true)
|
||||
if request.Sequence == 0 {
|
||||
violations = append(violations, "sequence must be positive")
|
||||
}
|
||||
if request.ObservedAt.IsZero() {
|
||||
violations = append(violations, "observedAt is required")
|
||||
}
|
||||
if request.Retention.KeepForSeconds <= 0 || request.Retention.KeepForSeconds > 31*24*60*60 {
|
||||
violations = append(violations, "retention.keepForSeconds must be between 1 and 2678400")
|
||||
}
|
||||
if request.Retention.MaxRecords < 0 || request.Retention.MaxRecords > 10000 {
|
||||
violations = append(violations, "retention.maxRecords must be between 0 and 10000")
|
||||
}
|
||||
if request.Payload == nil {
|
||||
violations = append(violations, "payload is required")
|
||||
}
|
||||
violations = append(violations, validateGameClientBridgePayload(request.Payload)...)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateGameClientBridgeSnapshotQuery(query domain.GameClientBridgeSnapshotQuery) error {
|
||||
var violations []string
|
||||
violations = appendGameClientBridgeIdentifier(violations, "serverInstanceId", query.ServerInstanceID, true)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "pluginId", query.PluginID, true)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "profileKey", query.ProfileKey, false)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "type", query.Type, false)
|
||||
violations = appendGameClientBridgeIdentifier(violations, "streamKey", query.StreamKey, false)
|
||||
if query.Limit < 0 || query.Limit > 200 {
|
||||
violations = append(violations, "limit must be between 0 and 200")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func appendGameClientBridgeIdentifier(violations []string, field, value string, required bool) []string {
|
||||
if value == "" {
|
||||
if required {
|
||||
return append(violations, field+" is required")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || utf8.RuneCountInString(value) > maxGameClientBridgeIdentifierLength {
|
||||
return append(violations, field+" is invalid")
|
||||
}
|
||||
first, _ := utf8.DecodeRuneInString(value)
|
||||
last, _ := utf8.DecodeLastRuneInString(value)
|
||||
if !isGameClientBridgeASCIIAlphanumeric(first) || !isGameClientBridgeASCIIAlphanumeric(last) {
|
||||
return append(violations, field+" is invalid")
|
||||
}
|
||||
for _, character := range value {
|
||||
if !isGameClientBridgeIdentifierCharacter(character) {
|
||||
return append(violations, field+" is invalid")
|
||||
}
|
||||
}
|
||||
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(value, "://") || strings.Contains(value, "..") {
|
||||
return append(violations, field+" is invalid")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func isGameClientBridgeASCIIAlphanumeric(character rune) bool {
|
||||
return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9'
|
||||
}
|
||||
|
||||
func isGameClientBridgeIdentifierCharacter(character rune) bool {
|
||||
return character >= 'a' && character <= 'z' ||
|
||||
character >= 'A' && character <= 'Z' ||
|
||||
character >= '0' && character <= '9' ||
|
||||
character == '.' || character == '_' || character == '-' || character == ':'
|
||||
}
|
||||
|
||||
func appendGameClientBridgeSession(violations []string, value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return append(violations, "sessionToken is required")
|
||||
}
|
||||
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || len(value) > maxGameClientBridgeSessionLength || containsControlCharacter(value) {
|
||||
return append(violations, "sessionToken is invalid")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func appendGameClientBridgeText(violations []string, field, value string, maximum int) []string {
|
||||
if value == "" {
|
||||
return violations
|
||||
}
|
||||
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || utf8.RuneCountInString(value) > maximum || containsControlCharacter(value) {
|
||||
violations = append(violations, field+" is invalid")
|
||||
}
|
||||
lowered := strings.ToLower(strings.TrimSpace(value))
|
||||
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || hasUnsafeGameClientBridgeReference(lowered) || containsEmbeddedGameClientBridgeHostPath(lowered) {
|
||||
violations = append(violations, field+" contains unsafe connection or host material")
|
||||
}
|
||||
for _, reason := range unsafePluginStringReasons(value) {
|
||||
violations = append(violations, field+": "+reason)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func containsControlCharacter(value string) bool {
|
||||
for _, character := range value {
|
||||
if unicode.IsControl(character) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type gameClientBridgePayloadBudget struct {
|
||||
nodes int
|
||||
}
|
||||
|
||||
func validateGameClientBridgePayload(payload map[string]any) []string {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
budget := gameClientBridgePayloadBudget{}
|
||||
violations := validateGameClientBridgePayloadValue("payload", payload, 0, &budget)
|
||||
if len(violations) != 0 {
|
||||
return violations
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return []string{"payload must be valid JSON"}
|
||||
}
|
||||
if len(encoded) > maxGameClientBridgePayloadSize {
|
||||
return []string{"payload is too large"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGameClientBridgePayloadValue(field string, value any, depth int, budget *gameClientBridgePayloadBudget) []string {
|
||||
budget.nodes++
|
||||
if budget.nodes > maxGameClientBridgePayloadNodes {
|
||||
return []string{"payload has too many values"}
|
||||
}
|
||||
if depth > maxGameClientBridgePayloadDepth {
|
||||
return []string{"payload nesting is too deep"}
|
||||
}
|
||||
|
||||
switch typed := value.(type) {
|
||||
case nil, bool:
|
||||
return nil
|
||||
case string:
|
||||
return validateGameClientBridgePayloadString(field, typed)
|
||||
case float64:
|
||||
if math.IsInf(typed, 0) || math.IsNaN(typed) {
|
||||
return []string{field + " must be a finite JSON number"}
|
||||
}
|
||||
return nil
|
||||
case float32:
|
||||
if math.IsInf(float64(typed), 0) || math.IsNaN(float64(typed)) {
|
||||
return []string{field + " must be a finite JSON number"}
|
||||
}
|
||||
return nil
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return nil
|
||||
case json.Number:
|
||||
if _, err := json.Marshal(typed); err != nil {
|
||||
return []string{field + " must be a valid JSON number"}
|
||||
}
|
||||
return nil
|
||||
case map[string]any:
|
||||
if len(typed) > maxGameClientBridgeObjectKeys {
|
||||
return []string{field + " has too many keys"}
|
||||
}
|
||||
var violations []string
|
||||
for key, item := range typed {
|
||||
if !validGameClientBridgePayloadKey(key) {
|
||||
violations = append(violations, field+" key "+fmt.Sprintf("%q", key)+" is invalid")
|
||||
continue
|
||||
}
|
||||
if unsafeGameClientBridgePayloadKey(key) {
|
||||
violations = append(violations, field+" contains forbidden key "+key)
|
||||
continue
|
||||
}
|
||||
violations = append(violations, validateGameClientBridgePayloadValue(field+"."+key, item, depth+1, budget)...)
|
||||
}
|
||||
return violations
|
||||
case []any:
|
||||
if len(typed) > maxGameClientBridgeArrayItems {
|
||||
return []string{field + " has too many items"}
|
||||
}
|
||||
var violations []string
|
||||
for index, item := range typed {
|
||||
violations = append(violations, validateGameClientBridgePayloadValue(fmt.Sprintf("%s[%d]", field, index), item, depth+1, budget)...)
|
||||
}
|
||||
return violations
|
||||
default:
|
||||
return []string{fmt.Sprintf("%s uses non-JSON type %T", field, value)}
|
||||
}
|
||||
}
|
||||
|
||||
func validateGameClientBridgePayloadString(field, value string) []string {
|
||||
var violations []string
|
||||
if !utf8.ValidString(value) {
|
||||
violations = append(violations, field+" must be valid UTF-8")
|
||||
}
|
||||
if utf8.RuneCountInString(value) > maxGameClientBridgePayloadString {
|
||||
violations = append(violations, field+" is too long")
|
||||
}
|
||||
if containsControlCharacter(value) {
|
||||
violations = append(violations, field+" contains control characters")
|
||||
}
|
||||
for _, reason := range unsafePluginStringReasons(value) {
|
||||
violations = append(violations, field+": "+reason)
|
||||
}
|
||||
lowered := strings.ToLower(strings.TrimSpace(value))
|
||||
if containsUnsafeRuntimeSecret(value) || hasUnsafeGameClientBridgeReference(lowered) || containsEmbeddedGameClientBridgeHostPath(lowered) {
|
||||
violations = append(violations, field+" contains unsafe connection material")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validGameClientBridgePayloadKey(key string) bool {
|
||||
if key == "" || !utf8.ValidString(key) || strings.TrimSpace(key) != key || utf8.RuneCountInString(key) > 80 {
|
||||
return false
|
||||
}
|
||||
for _, character := range key {
|
||||
if !isGameClientBridgeIdentifierCharacter(character) || character == ':' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func unsafeGameClientBridgePayloadKey(key string) bool {
|
||||
tokens := gameClientBridgePayloadKeyTokens(key)
|
||||
if len(tokens) == 0 {
|
||||
return true
|
||||
}
|
||||
normalized := strings.Join(tokens, "")
|
||||
for _, exact := range []string{
|
||||
"absolutepath", "apikey", "commandline", "componentkey", "credential", "credentials", "directsocket", "dsn", "hostpath", "password", "passwd", "rawpath", "rawsql", "runendpoint", "runsocket", "script", "secret", "sessiontoken", "shell", "socket", "sql", "statement", "terminalcommand",
|
||||
} {
|
||||
if normalized == strings.ReplaceAll(exact, " ", "") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if last := tokens[len(tokens)-1]; last == "password" || last == "passwd" || last == "secret" || last == "credential" || last == "credentials" || last == "dsn" {
|
||||
return true
|
||||
}
|
||||
for _, sequence := range [][]string{
|
||||
{"api", "key"},
|
||||
{"access", "key"},
|
||||
{"private", "key"},
|
||||
{"auth", "token"},
|
||||
{"access", "token"},
|
||||
{"client", "secret"},
|
||||
{"storage", "credential"},
|
||||
{"component", "key"},
|
||||
{"session", "token"},
|
||||
{"host", "path"},
|
||||
{"raw", "path"},
|
||||
{"absolute", "path"},
|
||||
{"file", "system", "path"},
|
||||
{"direct", "socket"},
|
||||
{"socket", "path"},
|
||||
{"socket", "address"},
|
||||
{"socket", "url"},
|
||||
{"socket", "endpoint"},
|
||||
{"run", "endpoint"},
|
||||
{"run", "url"},
|
||||
{"run", "socket"},
|
||||
{"run", "token"},
|
||||
{"run", "credential"},
|
||||
{"raw", "sql"},
|
||||
{"raw", "query"},
|
||||
{"sql", "text"},
|
||||
{"sql", "query"},
|
||||
{"sql", "statement"},
|
||||
{"arbitrary", "sql"},
|
||||
{"shell", "command"},
|
||||
{"shell", "script"},
|
||||
{"script", "body"},
|
||||
{"terminal", "command"},
|
||||
{"command", "line"},
|
||||
{"arbitrary", "shell"},
|
||||
} {
|
||||
if gameClientBridgeContainsSensitiveSequence(tokens, sequence) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsEmbeddedGameClientBridgeHostPath(value string) bool {
|
||||
for _, marker := range []string{"/etc/", "/var/", "/tmp/", "/home/", "/root/", "/private/", "/users/", "/volumes/", "/opt/", `:\\`} {
|
||||
if strings.Contains(value, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gameClientBridgeContainsSensitiveSequence(tokens, sequence []string) bool {
|
||||
for start := 0; start+len(sequence) <= len(tokens); start++ {
|
||||
matched := true
|
||||
for index, expected := range sequence {
|
||||
if tokens[start+index] != expected {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
end := start + len(sequence)
|
||||
if end == len(tokens) {
|
||||
return true
|
||||
}
|
||||
switch tokens[end] {
|
||||
case "address", "body", "content", "material", "path", "raw", "ref", "text", "url", "value":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gameClientBridgePayloadKeyTokens(key string) []string {
|
||||
withAcronymBoundaries := gameClientBridgeAcronymBoundary.ReplaceAllString(key, `${1} ${2}`)
|
||||
withCamelBoundaries := gameClientBridgeCamelBoundary.ReplaceAllString(withAcronymBoundaries, `${1} ${2}`)
|
||||
return strings.Fields(strings.ToLower(gameClientBridgeNonWord.ReplaceAllString(withCamelBoundaries, " ")))
|
||||
}
|
||||
|
||||
func hasUnsafeGameClientBridgeReference(value string) bool {
|
||||
for _, fragment := range []string{
|
||||
"unix://", "tcp://", "mysql://", "postgres://", "postgresql://", "mongodb://", "redis://", "sqlite://", "sqlserver://", "mssql://", "odbc:", "secret://", "vault://", "env://", "http://127.", "https://127.", "http://localhost", "https://localhost",
|
||||
} {
|
||||
if strings.Contains(value, fragment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest, domain.GamePluginRuntimeProfiles) {
|
||||
bridge := domain.GameClientBridgeManifest{
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||
Companion: domain.GameClientBridgeCompanionDeclaration{
|
||||
ProfileKey: "scum-client-manager",
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
},
|
||||
}
|
||||
profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{
|
||||
Key: "scum-client-manager",
|
||||
ConfigTemplates: []domain.RuntimeConfigTemplate{{Key: "client-config", TemplateRef: "config.yaml.example", OutputRef: "config.yaml"}},
|
||||
Health: domain.RuntimeClientManagerHealth{IntervalSeconds: 30, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"}},
|
||||
}}}
|
||||
return bridge, profiles
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) {
|
||||
bridge, profiles := validGameClientBridgeCompanionManifest()
|
||||
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, profiles); len(violations) != 0 {
|
||||
t.Fatalf("expected valid companion declaration, got %v", violations)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
mutate func(*domain.GameClientBridgeManifest, *domain.GamePluginRuntimeProfiles)
|
||||
}{
|
||||
{name: "undeclared profile", expected: "profileKey must reference", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.ProfileKey = "missing"
|
||||
}},
|
||||
{name: "undeclared template", expected: "configTemplateKey must reference", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.ConfigTemplateKey = "missing"
|
||||
}},
|
||||
{name: "unsafe schema", expected: "configSchemaRef", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.ConfigSchemaRef = "/etc/config.json"
|
||||
}},
|
||||
{name: "insecure tls", expected: "security policy", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.TLSPolicy = "skip-verification"
|
||||
}},
|
||||
{name: "heartbeat mismatch", expected: "must match", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.HeartbeatIntervalSeconds = 31
|
||||
}},
|
||||
{name: "missing bridge capability", expected: "game-client.bridge", mutate: func(_ *domain.GameClientBridgeManifest, profiles *domain.GamePluginRuntimeProfiles) {
|
||||
profiles.ClientManagers[0].Health.RequiredCapabilities = []string{"component.register", "component.heartbeat", "component.health"}
|
||||
}},
|
||||
{name: "partial declaration", expected: "profile or config template key", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.ProfileKey = ""
|
||||
}},
|
||||
{name: "reserved proof environment", expected: "proofMaterialEnv", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.ProofMaterialEnv = "LD_PRELOAD"
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidateBridge, candidateProfiles := validGameClientBridgeCompanionManifest()
|
||||
test.mutate(&candidateBridge, &candidateProfiles)
|
||||
violations := validateGameClientBridgeManifest("gameClientBridge", candidateBridge, nil, nil, candidateProfiles)
|
||||
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
|
||||
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateClientManagerCompanionConfigInputFailsClosed(t *testing.T) {
|
||||
valid := domain.ClientManagerCompanionConfigInput{
|
||||
SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion,
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigTemplateRef: "config.yaml.example",
|
||||
ConfigOutputRef: "config.yaml",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
InstallationID: "installation-1",
|
||||
ServerInstanceID: "server-1",
|
||||
PluginID: "game.scum",
|
||||
ProfileKey: "scum-client-manager",
|
||||
ArtifactID: "artifact-1",
|
||||
Version: "1.0.0",
|
||||
SourceRevision: "revision-1",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
KeyGeneration: 1,
|
||||
DeploymentGeneration: 2,
|
||||
Capabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"},
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
}
|
||||
if err := ValidateClientManagerCompanionConfigInput(valid); err != nil {
|
||||
t.Fatalf("expected valid companion input, got %v", err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*domain.ClientManagerCompanionConfigInput){
|
||||
"insecure tls": func(value *domain.ClientManagerCompanionConfigInput) { value.TLSPolicy = "skip-verification" },
|
||||
"legacy session": func(value *domain.ClientManagerCompanionConfigInput) { value.SessionMode = "shared-token" },
|
||||
"reserved env": func(value *domain.ClientManagerCompanionConfigInput) { value.ProofMaterialEnv = "PATH" },
|
||||
"unsafe template": func(value *domain.ClientManagerCompanionConfigInput) { value.ConfigTemplateRef = "../config.yaml" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
candidate := domain.CopyClientManagerCompanionConfigInput(valid)
|
||||
mutate(&candidate)
|
||||
if err := ValidateClientManagerCompanionConfigInput(candidate); err == nil {
|
||||
t.Fatalf("expected invalid companion input: %+v", candidate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
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)}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgeRequests(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
checks := map[string]error{
|
||||
"queue": ValidateGameClientBridgeQueueRequest(validBridgeQueueRequest()),
|
||||
"claim": ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 50}),
|
||||
"ack": ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1}),
|
||||
"result": ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Summary: "completed"}),
|
||||
"cancel": ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "operator request"}),
|
||||
"snapshot": ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1.2.0", StreamKey: "current", Sequence: 1, ObservedAt: now, Payload: map[string]any{"players": []any{}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600}}),
|
||||
"query": ValidateGameClientBridgeSnapshotQuery(domain.GameClientBridgeSnapshotQuery{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", Limit: 200}),
|
||||
}
|
||||
for name, err := range checks {
|
||||
if err != nil {
|
||||
t.Fatalf("validate %s request: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgeRequestFieldBounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "queue identifier", err: func() error {
|
||||
request := validBridgeQueueRequest()
|
||||
request.CommandType = "bad/type"
|
||||
return ValidateGameClientBridgeQueueRequest(request)
|
||||
}(), want: "commandType"},
|
||||
{name: "queue priority", err: func() error {
|
||||
request := validBridgeQueueRequest()
|
||||
request.Priority = 101
|
||||
return ValidateGameClientBridgeQueueRequest(request)
|
||||
}(), want: "priority"},
|
||||
{name: "claim token", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: " session"}), want: "sessionToken"},
|
||||
{name: "claim limit", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 51}), want: "limit"},
|
||||
{name: "ack fence", err: ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1"}), want: "fencingToken"},
|
||||
{name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unknown"}), want: "status"},
|
||||
{name: "result text", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "read /etc/passwd"}), want: "unsafe"},
|
||||
{name: "cancel text", err: ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "Bearer private"}), want: "unsafe"},
|
||||
{name: "snapshot payload", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "payload"},
|
||||
{name: "snapshot sequence", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", ObservedAt: time.Now(), Payload: map[string]any{}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "sequence"},
|
||||
{name: "snapshot retention", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Payload: map[string]any{}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 31*24*60*60 + 1}}), want: "keepForSeconds"},
|
||||
{name: "query field", err: ValidateGameClientBridgeSnapshotQuery(domain.GameClientBridgeSnapshotQuery{ServerInstanceID: "server-1", PluginID: "game.scum", StreamKey: "tcp://host"}), want: "streamKey"},
|
||||
{name: "query limit", err: ValidateGameClientBridgeSnapshotQuery(domain.GameClientBridgeSnapshotQuery{ServerInstanceID: "server-1", PluginID: "game.scum", Limit: 201}), want: "limit"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if test.err == nil || !strings.Contains(test.err.Error(), test.want) {
|
||||
t.Fatalf("expected %q violation, got %v", test.want, test.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgePayloadAcceptsJSONValuesWithoutKeyFalsePositives(t *testing.T) {
|
||||
request := validBridgeQueueRequest()
|
||||
request.Payload = map[string]any{
|
||||
"nullValue": nil, "enabled": true, "count": int(2), "ratio": float64(1.5), "sequence": json.Number("9007199254740993"), "nested": []any{map[string]any{"value": "safe"}},
|
||||
"secretaryName": "Mina", "socketCount": 2, "apiKeyEnabled": false, "sqlQueryTemplateKey": "players.by-id", "shellStatus": "unavailable", "logicalPath": "snapshots.current", "monkey": "ordinary", "chatMessage": "Select your reward from the list",
|
||||
}
|
||||
if err := ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||
t.Fatalf("valid JSON payload with benign keys rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgePayloadRejectsUnsafeKeyPatterns(t *testing.T) {
|
||||
keys := []string{"sessionToken", "authToken", "accessKey", "privateKey", "component-key", "databasePassword", "clientSecret", "api_key", "databaseDSN", "storageCredential", "hostPath", "absolute_path", "directSocket", "socketAddress", "runEndpoint", "runUrl", "rawSQL", "rawQuery", "sqlText", "sql_statement", "shellCommand", "shell_script", "scriptBody", "terminalCommand", "commandLine"}
|
||||
for _, key := range keys {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
request := validBridgeQueueRequest()
|
||||
request.Payload = map[string]any{key: "value"}
|
||||
err := ValidateGameClientBridgeQueueRequest(request)
|
||||
if err == nil || !strings.Contains(err.Error(), "forbidden key") {
|
||||
t.Fatalf("expected forbidden key rejection, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgePayloadRejectsUnsafeStringMaterial(t *testing.T) {
|
||||
values := []string{"/etc/passwd", "prefix path=/var/run/run.sock", `C:\\Users\\operator\\secret.txt`, "tcp://127.0.0.1:9000", "unix:///var/run/run.sock", "http://localhost:9000", "mysql://user:password@host/db", "secret://component/key", "vault://runtime/token", "Bearer abc123", "password=leak"}
|
||||
for index, value := range values {
|
||||
request := validBridgeQueueRequest()
|
||||
request.IdempotencyKey = fmt.Sprintf("case-%d", index)
|
||||
request.Payload = map[string]any{"value": value}
|
||||
if err := ValidateGameClientBridgeQueueRequest(request); err == nil || !strings.Contains(err.Error(), "payload") {
|
||||
t.Fatalf("expected unsafe value %q rejection, got %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgePayloadRejectsNonJSONValuesAndInvalidNumbers(t *testing.T) {
|
||||
tests := map[string]any{"typed map": map[string]string{"key": "value"}, "typed slice": []string{"value"}, "time": time.Now(), "channel": make(chan int), "not a number": math.NaN(), "infinity": math.Inf(1), "invalid number": json.Number("01")}
|
||||
for name, value := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := validBridgeQueueRequest()
|
||||
request.Payload = map[string]any{"value": value}
|
||||
if err := ValidateGameClientBridgeQueueRequest(request); err == nil {
|
||||
t.Fatalf("expected %s rejection", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgePayloadStructuralBudgets(t *testing.T) {
|
||||
tests := map[string]map[string]any{}
|
||||
tooManyKeys := map[string]any{}
|
||||
for index := 0; index < maxGameClientBridgeObjectKeys+1; index++ {
|
||||
tooManyKeys[fmt.Sprintf("key%d", index)] = index
|
||||
}
|
||||
tests["object keys"] = tooManyKeys
|
||||
deep := map[string]any{"value": true}
|
||||
for index := 0; index < maxGameClientBridgePayloadDepth+2; index++ {
|
||||
deep = map[string]any{"nested": deep}
|
||||
}
|
||||
tests["depth"] = deep
|
||||
tests["array items"] = map[string]any{"items": make([]any, maxGameClientBridgeArrayItems+1)}
|
||||
tests["string"] = map[string]any{"message": strings.Repeat("x", maxGameClientBridgePayloadString+1)}
|
||||
tests["encoded size"] = map[string]any{"one": strings.Repeat("x", 15000), "two": strings.Repeat("x", 15000), "three": strings.Repeat("x", 15000), "four": strings.Repeat("x", 15000), "five": strings.Repeat("x", 15000)}
|
||||
tests["invalid key"] = map[string]any{"bad key": true}
|
||||
tests["invalid utf8"] = map[string]any{"value": string([]byte{0xff})}
|
||||
wide := map[string]any{}
|
||||
for index := 0; index < 64; index++ {
|
||||
wide[fmt.Sprintf("items%d", index)] = make([]any, 128)
|
||||
}
|
||||
tests["node budget"] = wide
|
||||
cycle := map[string]any{}
|
||||
cycle["self"] = cycle
|
||||
tests["cycle"] = cycle
|
||||
|
||||
for name, payload := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := validBridgeQueueRequest()
|
||||
request.Payload = payload
|
||||
if err := ValidateGameClientBridgeQueueRequest(request); err == nil {
|
||||
t.Fatalf("expected %s rejection", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -96,5 +96,28 @@ func ValidateRemoteAdapterRequest(request domain.RemoteAdapterRequest) error {
|
||||
if strings.ContainsAny(request.TargetKey, "\\\n\r") || strings.Contains(request.TargetKey, "://") || strings.ContainsAny(request.TargetKey, " ") {
|
||||
violations = append(violations, "targetKey must be a logical key")
|
||||
}
|
||||
if request.InputRef != "" && !validScopedInputRef(request.InputRef) {
|
||||
violations = append(violations, "inputRef is not allowed")
|
||||
}
|
||||
violations = append(violations, validateRemoteAdapterInputs("inputs", request.Inputs)...)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validateRemoteAdapterInputs(field string, inputs map[string]string) []string {
|
||||
if len(inputs) > 32 {
|
||||
return []string{field + " has too many values"}
|
||||
}
|
||||
var violations []string
|
||||
for key, value := range inputs {
|
||||
if !clientManagerIdentifierPattern.MatchString(key) || unsafeGameClientBridgePayloadKey(key) {
|
||||
violations = append(violations, field+" key is invalid or unsafe")
|
||||
}
|
||||
if len([]rune(value)) > 2048 {
|
||||
violations = append(violations, field+"."+key+" is too long")
|
||||
}
|
||||
for _, reason := range unsafePluginStringReasons(value) {
|
||||
violations = append(violations, field+"."+key+": "+reason)
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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)
|
||||
violations = appendRequired(violations, "pluginId", installation.PluginID)
|
||||
violations = appendRequired(violations, "serverInstanceId", installation.ServerInstanceID)
|
||||
if !validPluginLifecycleState(installation.DesiredState) {
|
||||
violations = append(violations, "desiredState is invalid")
|
||||
}
|
||||
if !validPluginLifecycleState(installation.CurrentState) {
|
||||
violations = append(violations, "currentState is invalid")
|
||||
}
|
||||
if installation.LastOperation != "" && !validPluginLifecycleOperation(installation.LastOperation) {
|
||||
violations = append(violations, "lastOperation is invalid")
|
||||
}
|
||||
for _, value := range []fieldString{{field: "compatibility", value: installation.Compatibility}, {field: "failureReason", value: installation.FailureReason}} {
|
||||
if len(value.value) > maxProductionMessageLength || unsafeProductionText(value.value) {
|
||||
violations = append(violations, value.field+" is unsafe")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidatePluginLifecycleRequest(request domain.PluginLifecycleRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "pluginId", request.PluginID)
|
||||
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if !validPluginLifecycleOperation(request.Operation) {
|
||||
violations = append(violations, "operation is invalid")
|
||||
}
|
||||
if (request.Operation == domain.PluginLifecycleOperationDisable || request.Operation == domain.PluginLifecycleOperationRollback || request.Operation == domain.PluginLifecycleOperationRetire) && !request.Confirmed {
|
||||
violations = append(violations, "confirmed is required for disruptive plugin lifecycle operation")
|
||||
}
|
||||
for _, value := range []fieldString{{field: "targetVersion", value: request.TargetVersion}, {field: "idempotencyKey", value: request.IdempotencyKey}} {
|
||||
if unsafeProductionText(value.value) {
|
||||
violations = append(violations, value.field+" is unsafe")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAIConfigDiffPreview(preview domain.AIConfigDiffPreview) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", preview.ID)
|
||||
violations = appendRequired(violations, "requestId", preview.RequestID)
|
||||
violations = appendRequired(violations, "createdBy", preview.CreatedBy)
|
||||
violations = appendRequired(violations, "serverInstanceId", preview.ServerInstanceID)
|
||||
violations = appendRequired(violations, "key", preview.Key)
|
||||
violations = appendRequired(violations, "diffSummary", preview.DiffSummary)
|
||||
if preview.ConfigVersion <= 0 {
|
||||
violations = append(violations, "configVersion must be positive")
|
||||
}
|
||||
if !validAIConfigDiffState(preview.State) {
|
||||
violations = append(violations, "state is invalid")
|
||||
}
|
||||
if !validLogicalFileKey(preview.Key) || !validConfigFileKey(preview.Key) {
|
||||
violations = append(violations, "key is invalid")
|
||||
}
|
||||
if len([]byte(preview.ProposedConfig)) > maxServerConfigContentSize || containsUnsafeRuntimeSecret(preview.ProposedConfig) || looksLikeRawHostPath(preview.ProposedConfig) {
|
||||
violations = append(violations, "proposedConfig is unsafe")
|
||||
}
|
||||
if len(preview.DiffSummary) > maxProductionMessageLength || unsafeProductionText(preview.DiffSummary) {
|
||||
violations = append(violations, "diffSummary is unsafe")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAIConfigDiffApprovalRequest(request domain.AIConfigDiffApprovalRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "diffId", request.DiffID)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if unsafeProductionText(request.DiffID) || unsafeProductionText(request.IdempotencyKey) {
|
||||
violations = append(violations, "AI config approval request is unsafe")
|
||||
}
|
||||
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:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validPluginLifecycleOperation(operation domain.PluginLifecycleOperation) bool {
|
||||
switch operation {
|
||||
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationEnable, domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback, domain.PluginLifecycleOperationRetire, domain.PluginLifecycleOperationDependencyCheck:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validAIConfigDiffState(state domain.AIConfigDiffState) bool {
|
||||
switch state {
|
||||
case domain.AIConfigDiffStatePending, domain.AIConfigDiffStateApproved, domain.AIConfigDiffStateCancelled, domain.AIConfigDiffStateExpired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func unsafeProductionText(value string) bool {
|
||||
lower := strings.ToLower(value)
|
||||
return containsUnsafeRuntimeSecret(value) ||
|
||||
looksLikeRawHostPath(value) ||
|
||||
strings.Contains(lower, "api key") ||
|
||||
strings.Contains(lower, "apikey") ||
|
||||
strings.Contains(lower, "provider base url") ||
|
||||
strings.Contains(lower, "direct run") ||
|
||||
strings.Contains(lower, "rcon password") ||
|
||||
strings.Contains(lower, "dsn=")
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
maxServerConfigContentSize = 64 * 1024
|
||||
maxJobExecutionContentSize = 64 * 1024
|
||||
maxLogicalFileKeyLength = 160
|
||||
maxProductionMessageLength = 320
|
||||
)
|
||||
|
||||
type ValidationError struct {
|
||||
@@ -146,11 +147,14 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
||||
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
||||
violations = append(violations, duplicateViolations("tags", plugin.Tags)...)
|
||||
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
||||
violations = append(violations, validateProductionLifecycle("productionLifecycle", plugin.ProductionLifecycle, true)...)
|
||||
violations = append(violations, validateRemoteAccess("remoteAccess", plugin.RemoteAccess, plugin.RequiredRunCapabilities)...)
|
||||
if err := ValidateGamePluginRuntimeProfiles(plugin.RuntimeProfiles); err != nil {
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
||||
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
|
||||
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
|
||||
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
||||
return finish(violations)
|
||||
}
|
||||
@@ -202,15 +206,262 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
||||
violations = append(violations, validatePluginPages(manifest.Pages)...)
|
||||
violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...)
|
||||
violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...)
|
||||
violations = append(violations, validateProductionLifecycle("manifest.productionLifecycle", manifest.ProductionLifecycle, true)...)
|
||||
if containsString(manifest.Permissions, "ai.invoke") || len(manifest.AI.Purposes) > 0 {
|
||||
if manifest.AI.Mediation != "platform" {
|
||||
violations = append(violations, "manifest.ai.mediation must be platform")
|
||||
}
|
||||
if manifest.AI.ConfigWritePolicy != "review-required" {
|
||||
violations = append(violations, "manifest.ai.configWritePolicy must be review-required")
|
||||
}
|
||||
}
|
||||
violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
||||
if err := ValidateGamePluginRuntimeProfiles(manifest.RuntimeProfiles); err != nil {
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
|
||||
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
|
||||
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...)
|
||||
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
|
||||
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
|
||||
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
||||
return nil
|
||||
}
|
||||
var violations []string
|
||||
if bridge.Retention.KeepForSeconds <= 0 || bridge.Retention.KeepForSeconds > 365*24*60*60 {
|
||||
violations = append(violations, field+".commandRetentionSeconds is invalid")
|
||||
}
|
||||
if bridge.Retention.MaxRecords <= 0 || bridge.Retention.MaxRecords > 100000 {
|
||||
violations = append(violations, field+".maxCommands is invalid")
|
||||
}
|
||||
if companionPresent {
|
||||
prefix := field + ".companion"
|
||||
companion := bridge.Companion
|
||||
if !clientManagerIdentifierPattern.MatchString(companion.ProfileKey) || !clientManagerIdentifierPattern.MatchString(companion.ConfigTemplateKey) {
|
||||
violations = append(violations, prefix+" profile or config template key is invalid")
|
||||
}
|
||||
if !safeRelativeJSONRef(companion.ConfigSchemaRef) {
|
||||
violations = append(violations, prefix+".configSchemaRef must be a safe relative JSON reference")
|
||||
}
|
||||
if companion.ConfigFormat != "yaml" || companion.PlatformBaseURLSource != "run-control" || companion.RegistrationProof != "hmac-sha256" || companion.ProofMaterialSource != "component-package" || companion.SessionMode != "component-session" || companion.TLSPolicy != "verify-system-roots" {
|
||||
violations = append(violations, prefix+" bootstrap security policy is invalid")
|
||||
}
|
||||
if !validCompanionProofEnvironment(companion.ProofMaterialEnv) {
|
||||
violations = append(violations, prefix+".proofMaterialEnv is invalid")
|
||||
}
|
||||
if companion.HeartbeatIntervalSeconds < 5 || companion.HeartbeatIntervalSeconds > 300 || companion.CommandPollIntervalSeconds < 1 || companion.CommandPollIntervalSeconds > 60 || companion.RequestTimeoutSeconds < 1 || companion.RequestTimeoutSeconds > 60 {
|
||||
violations = append(violations, prefix+" timing policy is invalid")
|
||||
}
|
||||
managerFound := false
|
||||
for _, manager := range runtimeProfiles.ClientManagers {
|
||||
if manager.Key != companion.ProfileKey {
|
||||
continue
|
||||
}
|
||||
managerFound = true
|
||||
if manager.Health.IntervalSeconds != companion.HeartbeatIntervalSeconds {
|
||||
violations = append(violations, prefix+".heartbeatIntervalSeconds must match the Client Manager health interval")
|
||||
}
|
||||
for _, capability := range []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"} {
|
||||
if !containsString(manager.Health.RequiredCapabilities, capability) {
|
||||
violations = append(violations, prefix+" requires Client Manager capability "+capability)
|
||||
}
|
||||
}
|
||||
templateFound := false
|
||||
for _, template := range manager.ConfigTemplates {
|
||||
if template.Key == companion.ConfigTemplateKey {
|
||||
templateFound = true
|
||||
if template.OutputRef != "config.yaml" {
|
||||
violations = append(violations, prefix+" config template must materialize config.yaml")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !templateFound {
|
||||
violations = append(violations, prefix+".configTemplateKey must reference the Client Manager profile")
|
||||
}
|
||||
}
|
||||
if !managerFound {
|
||||
violations = append(violations, prefix+".profileKey must reference a declared Client Manager profile")
|
||||
}
|
||||
}
|
||||
commandTypes := map[string]struct{}{}
|
||||
for index, command := range bridge.Commands {
|
||||
prefix := fmt.Sprintf("%s.commands[%d]", field, index)
|
||||
if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) {
|
||||
violations = append(violations, prefix+".type is invalid or unsafe")
|
||||
}
|
||||
if _, exists := commandTypes[command.Type]; exists {
|
||||
violations = append(violations, prefix+".type is duplicated")
|
||||
}
|
||||
commandTypes[command.Type] = struct{}{}
|
||||
if strings.TrimSpace(command.Title) == "" || len([]rune(command.Title)) > 80 {
|
||||
violations = append(violations, prefix+".title is invalid")
|
||||
}
|
||||
if !containsString(permissions, command.Permission) {
|
||||
violations = append(violations, prefix+".permission must be declared by the plugin")
|
||||
}
|
||||
if command.ApprovalLevel != domain.GameClientBridgeApprovalLevelNone && command.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && command.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||
violations = append(violations, prefix+".approvalLevel is invalid")
|
||||
}
|
||||
if !safeRelativeJSONRef(command.PayloadSchemaRef) || command.ResultSchemaRef != "" && !safeRelativeJSONRef(command.ResultSchemaRef) {
|
||||
violations = append(violations, prefix+" schema references must be safe relative JSON references")
|
||||
}
|
||||
if command.TimeoutSeconds <= 0 || command.TimeoutSeconds > 3600 {
|
||||
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
||||
}
|
||||
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
|
||||
violations = append(violations, prefix+".maxPayloadBytes is invalid")
|
||||
}
|
||||
}
|
||||
snapshotTypes := map[string]struct{}{}
|
||||
for index, snapshot := range bridge.Snapshots {
|
||||
prefix := fmt.Sprintf("%s.snapshots[%d]", field, index)
|
||||
key := snapshot.Type + "\x00" + snapshot.SchemaVersion
|
||||
if !clientManagerIdentifierPattern.MatchString(snapshot.Type) || !clientManagerIdentifierPattern.MatchString(snapshot.SchemaVersion) {
|
||||
violations = append(violations, prefix+" type or schemaVersion is invalid")
|
||||
}
|
||||
if _, exists := snapshotTypes[key]; exists {
|
||||
violations = append(violations, prefix+" type and schemaVersion are duplicated")
|
||||
}
|
||||
snapshotTypes[key] = struct{}{}
|
||||
if !safeRelativeJSONRef(snapshot.SchemaRef) {
|
||||
violations = append(violations, prefix+".schemaRef must be a safe relative JSON reference")
|
||||
}
|
||||
if snapshot.Retention.KeepForSeconds <= 0 || snapshot.Retention.KeepForSeconds > 31*24*60*60 || snapshot.Retention.MaxRecords <= 0 || snapshot.Retention.MaxRecords > 10000 {
|
||||
violations = append(violations, prefix+" retention is invalid")
|
||||
}
|
||||
}
|
||||
queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{}
|
||||
transports := map[string]domain.RuntimeTransportProfile{}
|
||||
for _, transport := range runtimeProfiles.TransportProfiles {
|
||||
transports[transport.Key] = transport
|
||||
}
|
||||
for index, template := range bridge.QueryTemplates {
|
||||
prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index)
|
||||
if !clientManagerIdentifierPattern.MatchString(template.Key) {
|
||||
violations = append(violations, prefix+".key is invalid")
|
||||
}
|
||||
if _, exists := queryTemplates[template.Key]; exists {
|
||||
violations = append(violations, prefix+".key is duplicated")
|
||||
}
|
||||
queryTemplates[template.Key] = template
|
||||
if strings.TrimSpace(template.Title) == "" || len([]rune(template.Title)) > 80 {
|
||||
violations = append(violations, prefix+".title is invalid")
|
||||
}
|
||||
if !containsString(permissions, template.Permission) {
|
||||
violations = append(violations, prefix+".permission must be declared by the plugin")
|
||||
}
|
||||
if template.Engine != "sqlite" {
|
||||
violations = append(violations, prefix+".engine must be sqlite")
|
||||
}
|
||||
if !safeRelativeJSONRef(template.ParameterSchemaRef) || !safeRelativeJSONRef(template.ResultSchemaRef) {
|
||||
violations = append(violations, prefix+" schema references must be safe relative JSON references")
|
||||
}
|
||||
if template.MaxRows < 1 || template.MaxRows > 500 {
|
||||
violations = append(violations, prefix+".maxRows is invalid")
|
||||
}
|
||||
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
|
||||
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
||||
}
|
||||
transport, exists := transports[template.TransportKey]
|
||||
if !exists {
|
||||
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
||||
continue
|
||||
}
|
||||
if transport.TargetKey != template.TargetKey || strings.TrimSpace(template.TargetKey) == "" {
|
||||
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
|
||||
}
|
||||
if transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
|
||||
}
|
||||
}
|
||||
pageDeclarations := map[string]domain.GamePluginPage{}
|
||||
for _, page := range pages {
|
||||
pageDeclarations[page.Key] = page
|
||||
}
|
||||
for index, page := range bridge.Pages {
|
||||
prefix := fmt.Sprintf("%s.pages[%d]", field, index)
|
||||
pageDeclaration, pageExists := pageDeclarations[page.PageKey]
|
||||
if !pageExists {
|
||||
violations = append(violations, prefix+".pageKey must reference a declared plugin page")
|
||||
}
|
||||
for _, commandType := range page.CommandTypes {
|
||||
if _, exists := commandTypes[commandType]; !exists {
|
||||
violations = append(violations, prefix+" references undeclared command "+commandType)
|
||||
}
|
||||
}
|
||||
for _, snapshotType := range page.SnapshotTypes {
|
||||
found := false
|
||||
for key := range snapshotTypes {
|
||||
if strings.HasPrefix(key, snapshotType+"\x00") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
violations = append(violations, prefix+" references undeclared snapshot "+snapshotType)
|
||||
}
|
||||
}
|
||||
for _, templateKey := range page.QueryTemplateKeys {
|
||||
template, exists := queryTemplates[templateKey]
|
||||
if !exists {
|
||||
violations = append(violations, prefix+" references undeclared query template "+templateKey)
|
||||
continue
|
||||
}
|
||||
if !containsString(pageDeclaration.Permissions, template.Permission) {
|
||||
violations = append(violations, prefix+" must declare query template permission "+template.Permission)
|
||||
}
|
||||
if !containsString(pageDeclaration.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) {
|
||||
violations = append(violations, prefix+" must declare remote.access.request for query templates")
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validCompanionProofEnvironment(value string) bool {
|
||||
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
||||
return false
|
||||
}
|
||||
for _, character := range value[1:] {
|
||||
if character != '_' && (character < 'A' || character > 'Z') && (character < '0' || character > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
reserved := map[string]struct{}{
|
||||
"COMSPEC": {}, "DYLD_INSERT_LIBRARIES": {}, "DYLD_LIBRARY_PATH": {}, "HOME": {}, "LD_LIBRARY_PATH": {}, "LD_PRELOAD": {},
|
||||
"PATH": {}, "PATHEXT": {}, "SHELL": {}, "SYSTEMROOT": {}, "TEMP": {}, "TMP": {}, "USERPROFILE": {}, "WINDIR": {},
|
||||
}
|
||||
if _, exists := reserved[value]; exists {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func unsafeGameClientBridgeCommandType(value string) bool {
|
||||
tokens := gameClientBridgePayloadKeyTokens(value)
|
||||
tokenSet := make(map[string]struct{}, len(tokens))
|
||||
for _, token := range tokens {
|
||||
tokenSet[token] = struct{}{}
|
||||
}
|
||||
has := func(values ...string) bool {
|
||||
for _, candidate := range values {
|
||||
if _, ok := tokenSet[candidate]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if has("sql") || has("database", "db") && has("execute", "exec", "eval", "run", "query", "statement") || has("query") && has("execute", "exec", "eval", "raw", "statement") {
|
||||
return true
|
||||
}
|
||||
return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run")
|
||||
}
|
||||
|
||||
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "pluginId", request.PluginID)
|
||||
@@ -701,12 +952,7 @@ func ValidateRunEndpoint(endpoint domain.RunEndpoint) error {
|
||||
if !validRunEndpointStatus(endpoint.Status) {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
if endpoint.Capacity.MaxJobs < 0 || endpoint.Capacity.RunningJobs < 0 || endpoint.Capacity.QueuedJobs < 0 {
|
||||
violations = append(violations, "capacity counts must not be negative")
|
||||
}
|
||||
if endpoint.Capacity.MaxJobs > 0 && endpoint.Capacity.RunningJobs > endpoint.Capacity.MaxJobs {
|
||||
violations = append(violations, "runningJobs must not exceed maxJobs")
|
||||
}
|
||||
violations = appendCapacityViolations(violations, endpoint.Capacity)
|
||||
for i, capability := range endpoint.Capabilities {
|
||||
if strings.TrimSpace(capability) == "" {
|
||||
violations = append(violations, fmt.Sprintf("capabilities[%d] is required", i))
|
||||
@@ -766,6 +1012,7 @@ func ValidateJob(job domain.Job) error {
|
||||
if job.ExecutionInput.ExpectedChecksum != "" && !validSHA256Checksum(job.ExecutionInput.ExpectedChecksum) {
|
||||
violations = append(violations, "executionInput.expectedChecksum must be sha256:<hex>")
|
||||
}
|
||||
violations = append(violations, validateRemoteAdapterInputs("executionInput.inputs", job.ExecutionInput.Inputs)...)
|
||||
if job.ExecutionResult.Checksum != "" && !validSHA256Checksum(job.ExecutionResult.Checksum) {
|
||||
violations = append(violations, "executionResult.checksum must be sha256:<hex>")
|
||||
}
|
||||
@@ -1065,6 +1312,9 @@ func pluginSafeStrings(plugin domain.GamePlugin) []fieldString {
|
||||
values = appendStringSliceFields(values, "declaredPermissions", plugin.DeclaredPermissions)
|
||||
values = appendStringSliceFields(values, "tags", plugin.Tags)
|
||||
values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes)
|
||||
values = appendStringSliceFields(values, "productionLifecycle.operations", plugin.ProductionLifecycle.Operations)
|
||||
values = appendStringSliceFields(values, "productionLifecycle.approvalRequired", plugin.ProductionLifecycle.ApprovalRequired)
|
||||
values = append(values, fieldString{field: "productionLifecycle.dependencyPolicy", value: plugin.ProductionLifecycle.DependencyPolicy})
|
||||
values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions)
|
||||
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
|
||||
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
|
||||
@@ -1105,6 +1355,9 @@ func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []f
|
||||
values = appendStringSliceFields(values, "capabilities", manifest.Capabilities)
|
||||
values = appendStringSliceFields(values, "permissions", manifest.Permissions)
|
||||
values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes)
|
||||
values = append(values, fieldString{field: "ai.mediation", value: manifest.AI.Mediation}, fieldString{field: "ai.configWritePolicy", value: manifest.AI.ConfigWritePolicy}, fieldString{field: "productionLifecycle.dependencyPolicy", value: manifest.ProductionLifecycle.DependencyPolicy})
|
||||
values = appendStringSliceFields(values, "productionLifecycle.operations", manifest.ProductionLifecycle.Operations)
|
||||
values = appendStringSliceFields(values, "productionLifecycle.approvalRequired", manifest.ProductionLifecycle.ApprovalRequired)
|
||||
values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
|
||||
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
|
||||
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines)
|
||||
@@ -1407,7 +1660,7 @@ func validScopedInputRef(ref string) bool {
|
||||
|
||||
func validPluginPermission(permission string) bool {
|
||||
switch permission {
|
||||
case "server.create", "server.read", "server.lifecycle", "server.files.read", "server.files.write", "server.logs.read", "server.artifacts.read", "server.artifacts.write", "server.remote.access", "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage", "ai.invoke":
|
||||
case "server.create", "server.read", "server.lifecycle", "server.files.read", "server.files.write", "server.logs.read", "server.artifacts.read", "server.artifacts.write", "server.remote.access", "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1426,6 +1679,7 @@ func validPluginBridgeAction(action domain.PluginBridgeAction) bool {
|
||||
domain.PluginBridgeActionDependenciesRequest,
|
||||
domain.PluginBridgeActionLogsBackfillRequest,
|
||||
domain.PluginBridgeActionClientManager,
|
||||
domain.PluginBridgeActionPluginLifecycle,
|
||||
domain.PluginBridgeActionAIInvoke:
|
||||
return true
|
||||
default:
|
||||
@@ -1455,6 +1709,8 @@ func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
|
||||
return []string{"server.logs.read"}
|
||||
case domain.PluginBridgeActionClientManager:
|
||||
return []string{"server.client-manager.manage"}
|
||||
case domain.PluginBridgeActionPluginLifecycle:
|
||||
return []string{"server.lifecycle"}
|
||||
case domain.PluginBridgeActionAIInvoke:
|
||||
return []string{"ai.invoke"}
|
||||
default:
|
||||
@@ -1462,6 +1718,35 @@ func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func validateProductionLifecycle(field string, lifecycle domain.GamePluginProductionLifecycle, required bool) []string {
|
||||
var violations []string
|
||||
if required && len(lifecycle.Operations) == 0 {
|
||||
violations = append(violations, field+".operations must not be empty")
|
||||
}
|
||||
for i, operation := range lifecycle.Operations {
|
||||
switch domain.PluginLifecycleOperation(operation) {
|
||||
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationEnable, domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback, domain.PluginLifecycleOperationRetire, domain.PluginLifecycleOperationDependencyCheck:
|
||||
default:
|
||||
violations = append(violations, fmt.Sprintf("%s.operations[%d] is invalid", field, i))
|
||||
}
|
||||
}
|
||||
violations = append(violations, duplicateViolations(field+".operations", lifecycle.Operations)...)
|
||||
if lifecycle.DependencyPolicy != "required" && lifecycle.DependencyPolicy != "optional" {
|
||||
violations = append(violations, field+".dependencyPolicy must be required or optional")
|
||||
}
|
||||
for i, operation := range lifecycle.ApprovalRequired {
|
||||
if operation != string(domain.PluginLifecycleOperationDisable) && operation != string(domain.PluginLifecycleOperationRollback) && operation != string(domain.PluginLifecycleOperationRetire) {
|
||||
violations = append(violations, fmt.Sprintf("%s.approvalRequired[%d] is invalid", field, i))
|
||||
}
|
||||
}
|
||||
for _, operation := range []string{string(domain.PluginLifecycleOperationDisable), string(domain.PluginLifecycleOperationRollback), string(domain.PluginLifecycleOperationRetire)} {
|
||||
if containsString(lifecycle.Operations, operation) && !containsString(lifecycle.ApprovalRequired, operation) {
|
||||
violations = append(violations, field+".approvalRequired must include "+operation)
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func effectivePagePermissions(plugin domain.GamePlugin, routeKey string) []string {
|
||||
declared := plugin.DeclaredPermissions
|
||||
page, found := findPluginPage(plugin.Pages, routeKey)
|
||||
|
||||
@@ -72,6 +72,106 @@ 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.remote.access")
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "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}}}
|
||||
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}},
|
||||
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}},
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||
Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}}},
|
||||
}
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||
t.Fatalf("expected bridge catalog to validate, got %v", err)
|
||||
}
|
||||
|
||||
for _, commandType := range []string{"sql.execute", "sqlExecute", "database.execute", "database.query", "shell.execute", "powershell.execute", "script.run", "terminal.execute", "command.run"} {
|
||||
t.Run("unsafe command type "+commandType, func(t *testing.T) {
|
||||
unsafeType := registration
|
||||
unsafeType.Manifest.GameClientBridge = domain.CopyGameClientBridgeManifest(registration.Manifest.GameClientBridge)
|
||||
unsafeType.Manifest.GameClientBridge.Commands[0].Type = commandType
|
||||
err := ValidateGamePluginManifestRegistration(unsafeType)
|
||||
if err == nil || !strings.Contains(err.Error(), "type is invalid or unsafe") {
|
||||
t.Fatalf("expected %q to be rejected, got %v", commandType, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unsafe := registration
|
||||
unsafe.Manifest.GameClientBridge = domain.CopyGameClientBridgeManifest(registration.Manifest.GameClientBridge)
|
||||
unsafe.Manifest.GameClientBridge.Commands[0].Type = "shell.execute"
|
||||
unsafe.Manifest.GameClientBridge.Commands[0].ApprovalLevel = ""
|
||||
unsafe.Manifest.GameClientBridge.Commands[0].PayloadSchemaRef = "/etc/command.json"
|
||||
unsafe.Manifest.GameClientBridge.Pages[0].CommandTypes = []string{"undeclared.command"}
|
||||
err := ValidateGamePluginManifestRegistration(unsafe)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe bridge catalog rejection")
|
||||
}
|
||||
for _, expected := range []string{"type is invalid or unsafe", "approvalLevel is invalid", "schema references", "undeclared command"} {
|
||||
if !strings.Contains(err.Error(), expected) {
|
||||
t.Fatalf("expected %q in validation error: %v", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
queryTemplateTests := []struct {
|
||||
name string
|
||||
expected string
|
||||
mutate func(*domain.GamePluginManifestRegistration)
|
||||
}{
|
||||
{name: "duplicate key", expected: "key is duplicated", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates = append(value.Manifest.GameClientBridge.QueryTemplates, value.Manifest.GameClientBridge.QueryTemplates[0])
|
||||
}},
|
||||
{name: "unsupported engine", expected: "engine must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].Engine = "mysql"
|
||||
}},
|
||||
{name: "undeclared permission", expected: "permission must be declared", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].Permission = "server.database.admin"
|
||||
}},
|
||||
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].ParameterSchemaRef = "/etc/query.json"
|
||||
}},
|
||||
{name: "row bound", expected: "maxRows is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].MaxRows = 501
|
||||
}},
|
||||
{name: "timeout bound", expected: "timeoutSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].TimeoutSeconds = 61
|
||||
}},
|
||||
{name: "unknown transport", expected: "transportKey must reference", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing"
|
||||
}},
|
||||
{name: "target mismatch", expected: "targetKey must match", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].TargetKey = "db/other"
|
||||
}},
|
||||
{name: "missing sqlite capability", expected: "transport must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.RuntimeProfiles.TransportProfiles[0].Capabilities = []string{"files.read"}
|
||||
}},
|
||||
{name: "undeclared page template", expected: "undeclared query template", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.Pages[0].QueryTemplateKeys = []string{"missing.lookup"}
|
||||
}},
|
||||
{name: "page missing template permission", expected: "must declare query template permission", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.Pages[0].Permissions = []string{"server.logs.read", "server.remote.access"}
|
||||
}},
|
||||
{name: "page missing remote action", expected: "must declare remote.access.request", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.Pages[0].BridgeActions = nil
|
||||
}},
|
||||
}
|
||||
for _, test := range queryTemplateTests {
|
||||
t.Run("query template "+test.name, func(t *testing.T) {
|
||||
invalid := domain.CopyGamePluginManifestRegistration(registration)
|
||||
test.mutate(&invalid)
|
||||
err := ValidateGamePluginManifestRegistration(invalid)
|
||||
if err == nil || !strings.Contains(err.Error(), test.expected) {
|
||||
t.Fatalf("expected %q rejection, got %v", test.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "run.socket")
|
||||
@@ -235,7 +335,8 @@ func validGamePluginManifestRegistration() domain.GamePluginManifestRegistration
|
||||
Pages: []domain.GamePluginPage{
|
||||
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
|
||||
},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,6 @@
|
||||
- `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/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
|
||||
|
||||
Lifecycle validation rejects undeclared operations, stale attempt/deployment/key generations, cross-owner/server/profile/target/revision artifacts, unavailable endpoints, raw secrets, endpoint/socket values, traversal or absolute executable references, shell metacharacters, and unbounded timeouts. Registration additionally requires a current component-key HMAC, fresh nonce/timestamp, matching artifact and capabilities, and a monotonic heartbeat sequence. Safe DTOs are redacted before they cross the Platform boundary.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func validRuntimeLogEventProfiles() domain.GamePluginRuntimeProfiles {
|
||||
return domain.GamePluginRuntimeProfiles{
|
||||
LogSources: []domain.RuntimeLogSource{{Key: "chat-log", Kind: "file.tail", StreamKey: "chat", CursorKind: "offset", RetentionDays: 30}},
|
||||
LogEvents: []domain.RuntimeLogEvent{{
|
||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesValidatesLogEvents(t *testing.T) {
|
||||
if err := ValidateGamePluginRuntimeProfiles(validRuntimeLogEventProfiles()); err != nil {
|
||||
t.Fatalf("expected valid runtime log event declaration, got %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
mutate func(*domain.GamePluginRuntimeProfiles)
|
||||
}{
|
||||
{name: "undeclared source", expected: "sourceKey must reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].SourceKey = "missing" }},
|
||||
{name: "invalid event type", expected: "eventType is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].EventType = "chat message" }},
|
||||
{name: "invalid permission", expected: "permission is not allowed", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Permission = "server.admin" }},
|
||||
{name: "unsafe schema", expected: "schemaRef must be a bounded safe relative JSON reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||
profiles.LogEvents[0].SchemaRef = "schemas/log events/chat.json"
|
||||
}},
|
||||
{name: "retention bound", expected: "retentionDays is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 366 }},
|
||||
{name: "retention exceeds source", expected: "retentionDays must not exceed the source retention", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 31 }},
|
||||
{name: "invalid severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "emergency" }},
|
||||
{name: "plugin error severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "error" }},
|
||||
{name: "duplicate key", expected: "key is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||
profiles.LogEvents = append(profiles.LogEvents, profiles.LogEvents[0])
|
||||
}},
|
||||
{name: "duplicate event type", expected: "eventType is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||
duplicate := profiles.LogEvents[0]
|
||||
duplicate.Key = "chat-message-copy"
|
||||
profiles.LogEvents = append(profiles.LogEvents, duplicate)
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
profiles := domain.CopyGamePluginRuntimeProfiles(validRuntimeLogEventProfiles())
|
||||
test.mutate(&profiles)
|
||||
err := ValidateGamePluginRuntimeProfiles(profiles)
|
||||
if err == nil || !strings.Contains(err.Error(), test.expected) {
|
||||
t.Fatalf("expected %q validation error, got %v", test.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesAcceptsDeclaredLogEventSeverities(t *testing.T) {
|
||||
for _, severity := range []domain.RuntimeLogEventSeverity{
|
||||
domain.RuntimeLogEventSeverityInfo,
|
||||
domain.RuntimeLogEventSeverityNotice,
|
||||
domain.RuntimeLogEventSeverityWarning,
|
||||
domain.RuntimeLogEventSeverityCritical,
|
||||
} {
|
||||
t.Run(string(severity), func(t *testing.T) {
|
||||
profiles := validRuntimeLogEventProfiles()
|
||||
profiles.LogEvents[0].Severity = severity
|
||||
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
||||
t.Fatalf("expected severity %q to validate, got %v", severity, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesAllowsEventRetentionWhenSourceUsesDefault(t *testing.T) {
|
||||
profiles := validRuntimeLogEventProfiles()
|
||||
profiles.LogSources[0].RetentionDays = 0
|
||||
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
||||
t.Fatalf("expected source default retention to allow bounded event retention, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesRejectsUnsafeLogEventSemantics(t *testing.T) {
|
||||
unsafeEventTypes := []string{
|
||||
"ops.shell.execute",
|
||||
"ops.execute",
|
||||
"audit.sql.query",
|
||||
"audit.raw-host-path",
|
||||
"run.socket.open",
|
||||
"auth.credential.exposed",
|
||||
"auth.api-key.exposed",
|
||||
}
|
||||
for _, eventType := range unsafeEventTypes {
|
||||
t.Run(eventType, func(t *testing.T) {
|
||||
profiles := validRuntimeLogEventProfiles()
|
||||
profiles.LogEvents[0].EventType = eventType
|
||||
err := ValidateGamePluginRuntimeProfiles(profiles)
|
||||
if err == nil || !strings.Contains(err.Error(), "eventType contains unsafe operation semantics") {
|
||||
t.Fatalf("expected unsafe event type %q to be rejected, got %v", eventType, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationRequiresDeclaredLogEventPermission(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.RuntimeProfiles = validRuntimeLogEventProfiles()
|
||||
registration.Manifest.RuntimeProfiles.LogEvents[0].Permission = "server.game-client.read"
|
||||
|
||||
err := ValidateGamePluginManifestRegistration(registration)
|
||||
if err == nil || !strings.Contains(err.Error(), "manifest.runtimeProfiles.logEvents[0].permission must be declared by the plugin") {
|
||||
t.Fatalf("expected undeclared log event permission rejection, got %v", err)
|
||||
}
|
||||
|
||||
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read")
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||
t.Fatalf("expected declared log event permission to validate, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import (
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
runtimeLogEventSchemaRefPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]+\.json$`)
|
||||
runtimeLogEventTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,119}$`)
|
||||
)
|
||||
|
||||
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
|
||||
profiles = domain.CopyGamePluginRuntimeProfiles(profiles)
|
||||
var violations []string
|
||||
@@ -21,6 +26,9 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
dependencyKeys := map[string]struct{}{}
|
||||
installPlanKeys := map[string]struct{}{}
|
||||
logSourceKeys := map[string]struct{}{}
|
||||
logSourceRetentions := map[string]int{}
|
||||
logEventKeys := map[string]struct{}{}
|
||||
logEventTypes := map[string]struct{}{}
|
||||
|
||||
for i, probe := range profiles.Discovery {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i)
|
||||
@@ -138,6 +146,9 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
||||
violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...)
|
||||
if source.Key != "" {
|
||||
logSourceRetentions[source.Key] = source.RetentionDays
|
||||
}
|
||||
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") {
|
||||
violations = append(violations, prefix+".kind is invalid")
|
||||
}
|
||||
@@ -152,6 +163,44 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
violations = append(violations, prefix+".retentionDays is invalid")
|
||||
}
|
||||
}
|
||||
if len(profiles.LogEvents) > 128 {
|
||||
violations = append(violations, "runtimeProfiles.logEvents must not exceed 128")
|
||||
}
|
||||
for i, event := range profiles.LogEvents {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.logEvents[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", event.Key)...)
|
||||
violations = append(violations, recordRuntimeProfileKey(logEventKeys, prefix+".key", event.Key)...)
|
||||
if strings.TrimSpace(event.Title) == "" || len([]rune(event.Title)) > 80 {
|
||||
violations = append(violations, prefix+".title is invalid")
|
||||
}
|
||||
violations = append(violations, validateSafeRuntimeValue(prefix+".title", event.Title)...)
|
||||
violations = append(violations, validateProfileKey(prefix+".sourceKey", event.SourceKey)...)
|
||||
if _, exists := logSourceKeys[event.SourceKey]; !exists {
|
||||
violations = append(violations, prefix+".sourceKey must reference a declared runtime log source")
|
||||
}
|
||||
if !runtimeLogEventTypePattern.MatchString(event.EventType) {
|
||||
violations = append(violations, prefix+".eventType is invalid")
|
||||
}
|
||||
violations = append(violations, recordRuntimeProfileKey(logEventTypes, prefix+".eventType", event.EventType)...)
|
||||
if hasUnsafeRuntimeLogEventSemantics(event.EventType) {
|
||||
violations = append(violations, prefix+".eventType contains unsafe operation semantics")
|
||||
}
|
||||
if !validPluginPermission(event.Permission) {
|
||||
violations = append(violations, prefix+".permission is not allowed")
|
||||
}
|
||||
if len(event.SchemaRef) > 240 || !runtimeLogEventSchemaRefPattern.MatchString(event.SchemaRef) || !safeRelativeJSONRef(event.SchemaRef) {
|
||||
violations = append(violations, prefix+".schemaRef must be a bounded safe relative JSON reference")
|
||||
}
|
||||
if event.RetentionDays < 1 || event.RetentionDays > 365 {
|
||||
violations = append(violations, prefix+".retentionDays is invalid")
|
||||
}
|
||||
if sourceRetention, exists := logSourceRetentions[event.SourceKey]; exists && sourceRetention > 0 && event.RetentionDays > sourceRetention {
|
||||
violations = append(violations, prefix+".retentionDays must not exceed the source retention")
|
||||
}
|
||||
if !oneOf(string(event.Severity), string(domain.RuntimeLogEventSeverityInfo), string(domain.RuntimeLogEventSeverityNotice), string(domain.RuntimeLogEventSeverityWarning), string(domain.RuntimeLogEventSeverityCritical)) {
|
||||
violations = append(violations, prefix+".severity is invalid")
|
||||
}
|
||||
}
|
||||
for i, transport := range profiles.TransportProfiles {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
|
||||
@@ -346,6 +395,42 @@ func validateSafeRuntimeValue(field, value string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasUnsafeRuntimeLogEventSemantics(eventType string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(eventType))
|
||||
tokens := strings.FieldsFunc(lowered, func(char rune) bool {
|
||||
return char == '.' || char == '_' || char == '-' || char == '/'
|
||||
})
|
||||
unsafeTokens := map[string]struct{}{
|
||||
"apikey": {}, "credential": {}, "credentials": {}, "eval": {}, "exec": {},
|
||||
"execute": {}, "password": {}, "powershell": {}, "script": {}, "secret": {},
|
||||
"shell": {}, "socket": {}, "terminal": {}, "token": {},
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if _, unsafe := unsafeTokens[token]; unsafe {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for index := 0; index+1 < len(tokens); index++ {
|
||||
pair := tokens[index] + "." + tokens[index+1]
|
||||
switch pair {
|
||||
case "absolute.path", "access.key", "api.key", "component.key", "database.query", "direct.socket", "file.path", "host.path", "private.key", "raw.path", "run.direct", "run.socket", "unix.socket":
|
||||
return true
|
||||
}
|
||||
}
|
||||
tokenSet := make(map[string]struct{}, len(tokens))
|
||||
for _, token := range tokens {
|
||||
tokenSet[token] = struct{}{}
|
||||
}
|
||||
if _, hasSQL := tokenSet["sql"]; hasSQL {
|
||||
for _, token := range []string{"query", "statement", "raw"} {
|
||||
if _, unsafe := tokenSet[token]; unsafe {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []string {
|
||||
if key == "" {
|
||||
return nil
|
||||
@@ -382,6 +467,20 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateRuntimeLogEventPermissionDeclarations(field string, profiles domain.GamePluginRuntimeProfiles, declared []string) []string {
|
||||
declaredSet := make(map[string]struct{}, len(declared))
|
||||
for _, permission := range declared {
|
||||
declaredSet[permission] = struct{}{}
|
||||
}
|
||||
var violations []string
|
||||
for i, event := range profiles.LogEvents {
|
||||
if _, exists := declaredSet[event.Permission]; !exists {
|
||||
violations = append(violations, fmt.Sprintf("%s[%d].permission must be declared by the plugin", field, i))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
|
||||
var violations []string
|
||||
for field, value := range map[string]string{"install": actions.Install, "start": actions.Start, "stop": actions.Stop, "restart": actions.Restart, "status": actions.Status} {
|
||||
|
||||
Reference in New Issue
Block a user