Remove legacy client-manager workflows

This commit is contained in:
npc0-hue
2026-09-03 13:08:08 +08:00
parent bf3c382d15
commit fe09d21a56
56 changed files with 304 additions and 4121 deletions
-6
View File
@@ -101,10 +101,4 @@ 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 for advanced logical transports. Server creation requires only the plugin type and server name, and plugin-declared deployment/lifecycle actions must be enough for user-facing start/stop and generated Run package flows without forcing operators through a manual runtime-profile binding screen. Browser and plugin-facing responses expose readiness only, not binding values. Platform-owned Docker builds need no registered Run endpoint with `distribution.build`; component keys remain in platform-held per-job input. This change uses scoped secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, plugin lifecycle dispatch, and transactional Run self-update are implemented. Production signing/fleet rollout, external provider/storage adapters, and real AI-provider integration remain separate tasks.
+1 -4
View File
@@ -29,10 +29,7 @@ func (h *coreHandlers) requireAuthorizedAPI(next http.Handler) http.Handler {
func publicAPIRequest(r *http.Request) bool {
path := r.URL.Path
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/") {
if path == "/api/v1/auth/login" || path == "/api/v1/auth/register" {
return true
}
if r.Method != http.MethodGet {
@@ -1,352 +0,0 @@
package api
import (
"net/http"
"browser.local/platform/dto"
)
// serverClientManagerLifecycles godoc
// @Summary List Client Manager lifecycle installations
// @Description Returns safe durable lifecycle, health, real job progress, and action availability for the authorized server without component secrets or machine details.
// @Tags client-managers
// @Produce json
// @Param id path string true "Server instance ID"
// @Success 200 {object} dto.ClientManagerInstallationListResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers [get]
func (h *coreHandlers) serverClientManagerLifecycles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
views, err := h.core.ListClientManagerLifecyclesForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewsFromDomain(views))
}
// serverClientManagerLifecycleDetail godoc
// @Summary Get one Client Manager lifecycle installation
// @Tags client-managers
// @Produce json
// @Param id path string true "Server instance ID"
// @Param profileKey path string true "Client Manager profile key"
// @Success 200 {object} dto.ClientManagerInstallationResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/{profileKey} [get]
func (h *coreHandlers) serverClientManagerLifecycleDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
view, err := h.core.GetClientManagerLifecycleForSession(bearerToken(r), r.PathValue("id"), r.PathValue("profileKey"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerDeploy godoc
// @Summary Deploy an available Client Manager distribution
// @Description Queues a typed Run deployment after server, endpoint, artifact, target, revision, and key-generation authorization.
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerDeployRequest true "Deployment request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/deploy [post]
func (h *coreHandlers) serverClientManagerDeploy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerDeployRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.DeployClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerControl godoc
// @Summary Control a deployed Client Manager
// @Description Queues a declared typed start, stop, restart, status, or rollback operation.
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerControlRequest true "Control request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/control [post]
func (h *coreHandlers) serverClientManagerControl(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerControlRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.ControlClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerUpdateLifecycle godoc
// @Summary Update a Client Manager through staged activation
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerUpdateRequest true "Approved staged update request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/update [post]
func (h *coreHandlers) serverClientManagerUpdateLifecycle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerUpdateRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.UpdateClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerRetry godoc
// @Summary Retry a failed Client Manager lifecycle intent
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerRetryRequest true "Retry request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/retry [post]
func (h *coreHandlers) serverClientManagerRetry(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerRetryRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.RetryClientManagerLifecycleForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerRevokeSession godoc
// @Summary Revoke the active Client Manager component session
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerRevokeSessionRequest true "Session revoke request"
// @Success 200 {object} dto.ClientManagerInstallationResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/revoke-session [post]
func (h *coreHandlers) serverClientManagerRevokeSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerRevokeSessionRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.RevokeClientManagerSessionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerUninstall godoc
// @Summary Safely uninstall a Client Manager
// @Description Stops the supervised process and removes the Client Manager installation workspace while retaining build and distribution records.
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerUninstallRequest true "Confirmed uninstall request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/uninstall [post]
func (h *coreHandlers) serverClientManagerUninstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerUninstallRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.UninstallClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// runClientManagerLifecycleInput godoc
// @Summary Get fenced Client Manager lifecycle input
// @Description Returns a safe typed lifecycle contract only to the authenticated Run endpoint holding the active job lease.
// @Tags run-job-channel
// @Accept json
// @Produce json
// @Param body body dto.ClientManagerLifecycleInputRequest true "Fenced lifecycle input request"
// @Success 200 {object} dto.ClientManagerLifecycleInputResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/run/jobs/client-manager-input [post]
func (h *coreHandlers) runClientManagerLifecycleInput(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerLifecycleInputRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
input, err := h.core.GetClientManagerLifecycleInput(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleInputFromDomain(input))
}
// runClientManagerLifecycleChunk godoc
// @Summary Read one fenced Client Manager artifact chunk
// @Description Streams a checksummed artifact chunk only to the active typed lifecycle job lease.
// @Tags run-job-channel
// @Accept json
// @Produce json
// @Param body body dto.RunUpdateChunkRequest true "Fenced chunk request"
// @Success 200 {object} dto.RunUpdateChunkResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/run/jobs/client-manager-chunk [post]
func (h *coreHandlers) runClientManagerLifecycleChunk(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.RunUpdateChunkRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
chunk, err := h.core.ReadClientManagerLifecycleChunk(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(chunk))
}
// clientManagerRegister godoc
// @Summary Register an installed Client Manager component
// @Description Verifies a current component-key HMAC, nonce, deployment fence, target, revision, and capabilities before issuing an isolated expiring component session.
// @Tags client-manager-component
// @Accept json
// @Produce json
// @Param body body dto.ClientManagerRegisterRequest true "Signed component registration"
// @Success 200 {object} dto.ClientManagerRegisterResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/client-managers/register [post]
func (h *coreHandlers) clientManagerRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerRegisterRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.RegisterClientManager(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerRegisterFromDomain(result))
}
// clientManagerHeartbeat godoc
// @Summary Accept a Client Manager component heartbeat
// @Description Accepts monotonic health reports using the isolated component session; Run control credentials are not valid here.
// @Tags client-manager-component
// @Accept json
// @Produce json
// @Param body body dto.ClientManagerHeartbeatRequest true "Component heartbeat"
// @Success 200 {object} dto.ClientManagerHeartbeatResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/client-managers/heartbeat [post]
func (h *coreHandlers) clientManagerHeartbeat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerHeartbeatRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.AcceptClientManagerHeartbeat(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerHeartbeatFromDomain(result))
}
@@ -1,101 +0,0 @@
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))
}
@@ -12,32 +12,6 @@ import (
"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)
@@ -46,15 +20,14 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
}
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.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, 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: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", PayloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", ResultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", TimeoutSeconds: 3600, MaxPayloadBytes: 4096}}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}
if _, err := coreService.CreateGamePlugin(plugin); err != nil {
t.Fatalf("create bridge plugin: %v", err)
}
endpoint := validRunEndpointRequest().ToDomain()
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery)
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
if _, err := coreService.CreateRunEndpoint(endpoint); err != nil {
t.Fatalf("create run endpoint: %v", err)
}
@@ -65,14 +38,14 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
}
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 {
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" {
if len(status.Profiles) != 1 || status.Profiles[0].ProfileKey != "plugin-owned" || 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: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "diag-1", ExpiresAt: time.Now().UTC().Add(time.Hour)}
queue := dto.GameClientBridgeQueueRequest{ProfileKey: "plugin-owned", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "diag-1", ExpiresAt: time.Now().UTC().Add(time.Hour)}
queuedRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", queue, adminSession)
assertStatus(t, queuedRecorder, http.StatusAccepted)
queued := decodeBody[dto.GameClientBridgeCommandResponse](t, queuedRecorder)
@@ -112,40 +85,3 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
}
}
}
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)
}
@@ -1,30 +0,0 @@
package api
import (
"net/http"
"browser.local/platform/dto"
)
// gameClientBridgeCompanionLogEvents authorizes a component session and
// forwards the current opaque log channel. Platform does not parse, redact,
// filter, or derive records from the log body.
func (h *coreHandlers) gameClientBridgeCompanionLogEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.GameClientBridgeLogStreamRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
instance, err := h.core.AuthorizeGameClientBridgeLogStream(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
forward := r.Clone(withComponentLogServer(r, instance.ID).Context())
forward.Method = http.MethodGet
h.serverLogEvents(w, forward)
}
+86 -27
View File
@@ -74,17 +74,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload)
mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset)
mux.HandleFunc("/api/v1/server-instances/{id}/run/update", h.serverRunUpdate)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/generate", h.serverClientManagerGenerate)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/download", h.serverClientManagerDownload)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/key/reset", h.serverClientManagerKeyReset)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers", h.serverClientManagerLifecycles)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/{profileKey}", h.serverClientManagerLifecycleDetail)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/deploy", h.serverClientManagerDeploy)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/control", h.serverClientManagerControl)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/update", h.serverClientManagerUpdateLifecycle)
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)
@@ -125,8 +114,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
mux.HandleFunc("/api/v1/run/files/input-chunk", h.requireRunSignature(h.runFileInputChunk))
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput))
mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk))
mux.HandleFunc("/api/v1/run/jobs/cancel", h.requireRunSignature(h.runJobCancelPoll))
mux.HandleFunc("/api/v1/run/jobs/reconcile", h.requireRunSignature(h.runJobReconcile))
mux.HandleFunc("/api/v1/run/logs/batches", h.requireRunSignature(h.runLogBatchIngest))
@@ -148,14 +135,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/log-streams", h.logStreams)
mux.HandleFunc("/api/v1/log-streams/query", h.logStreamQuery)
mux.HandleFunc("/api/v1/log-streams/{id}", h.logStreamDetail)
mux.HandleFunc("/api/v1/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/logs/events", h.gameClientBridgeCompanionLogEvents)
mux.HandleFunc("/api/v1/game-client-bridge/companion/snapshots", h.gameClientBridgeCompanionSnapshot)
mux.HandleFunc("/api/v1/game-client-bridge/companion/diagnostics", h.gameClientBridgeCompanionDiagnostics)
}
// pluginLifecycles godoc
@@ -2208,9 +2187,15 @@ func (h *coreHandlers) runArtifactOpen(w http.ResponseWriter, r *http.Request) {
// @Summary Upload run artifact chunk
// @Description Accepts one bounded artifact chunk from a registered run endpoint and returns resumable acknowledgement state.
// @Tags run-artifacts
// @Accept json
// @Accept octet-stream
// @Produce json
// @Param body body dto.ArtifactChunkUploadRequest true "Artifact chunk upload request"
// @Param X-Artifact-Transfer-Id header string true "Artifact transfer ID"
// @Param X-Artifact-Id header string true "Artifact ID"
// @Param X-Artifact-Chunk-Index header int true "Chunk index"
// @Param X-Artifact-Offset header int true "Byte offset"
// @Param X-Artifact-Size header int true "Chunk size"
// @Param X-Artifact-Checksum header string true "Chunk sha256 checksum"
// @Param body body file true "Artifact chunk bytes"
// @Success 200 {object} dto.ArtifactChunkUploadResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
@@ -2221,9 +2206,9 @@ func (h *coreHandlers) runArtifactChunkUpload(w http.ResponseWriter, r *http.Req
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ArtifactChunkUploadRequest](r)
request, err := decodeArtifactChunkUploadRequest(r)
if err != nil {
writeDecodeError(w, err)
writeServiceError(w, err)
return
}
result, err := h.core.UploadArtifactChunk(request.ToDomain())
@@ -2234,6 +2219,78 @@ func (h *coreHandlers) runArtifactChunkUpload(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusOK, dto.ArtifactChunkUploadFromDomain(result))
}
const (
artifactTransferIDHeader = "X-Artifact-Transfer-Id"
artifactIDHeader = "X-Artifact-Id"
artifactChunkIndexHeader = "X-Artifact-Chunk-Index"
artifactChunkOffsetHeader = "X-Artifact-Offset"
artifactChunkSizeHeader = "X-Artifact-Size"
artifactChunkHashHeader = "X-Artifact-Checksum"
)
func decodeArtifactChunkUploadRequest(r *http.Request) (dto.ArtifactChunkUploadRequest, error) {
if !isOctetStream(r.Header.Get("Content-Type")) {
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{"contentType must be application/octet-stream"}}
}
chunkIndex, err := parseRequiredIntHeader(r, artifactChunkIndexHeader)
if err != nil {
return dto.ArtifactChunkUploadRequest{}, err
}
offset, err := parseRequiredInt64Header(r, artifactChunkOffsetHeader)
if err != nil {
return dto.ArtifactChunkUploadRequest{}, err
}
sizeBytes, err := parseRequiredIntHeader(r, artifactChunkSizeHeader)
if err != nil {
return dto.ArtifactChunkUploadRequest{}, err
}
if sizeBytes <= 0 || sizeBytes > validator.MaxArtifactChunkBytes {
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{fmt.Sprintf("%s must be between 1 and %d", artifactChunkSizeHeader, validator.MaxArtifactChunkBytes)}}
}
payload, err := io.ReadAll(io.LimitReader(r.Body, int64(validator.MaxArtifactChunkBytes)+1))
if err != nil {
return dto.ArtifactChunkUploadRequest{}, err
}
if len(payload) != sizeBytes {
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{"request body size must match artifact chunk size"}}
}
return dto.ArtifactChunkUploadRequest{
RunEndpointID: strings.TrimSpace(r.Header.Get(runEndpointHeader)),
SessionToken: strings.TrimSpace(r.Header.Get(runSessionTokenHeader)),
TransferID: strings.TrimSpace(r.Header.Get(artifactTransferIDHeader)),
ArtifactID: strings.TrimSpace(r.Header.Get(artifactIDHeader)),
ChunkIndex: chunkIndex,
Offset: offset,
SizeBytes: sizeBytes,
Checksum: strings.TrimSpace(r.Header.Get(artifactChunkHashHeader)),
Payload: payload,
}, nil
}
func parseRequiredIntHeader(r *http.Request, name string) (int, error) {
value := strings.TrimSpace(r.Header.Get(name))
if value == "" {
return 0, validator.ValidationError{Violations: []string{name + " is required"}}
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, validator.ValidationError{Violations: []string{name + " must be a number"}}
}
return parsed, nil
}
func parseRequiredInt64Header(r *http.Request, name string) (int64, error) {
value := strings.TrimSpace(r.Header.Get(name))
if value == "" {
return 0, validator.ValidationError{Violations: []string{name + " is required"}}
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, validator.ValidationError{Violations: []string{name + " must be a number"}}
}
return parsed, nil
}
// runArtifactStatus godoc
// @Summary Query run artifact upload status
// @Description Returns resumable chunk acknowledgement state for one artifact transfer.
@@ -2597,11 +2654,13 @@ func (h *coreHandlers) artifactContent(w http.ResponseWriter, r *http.Request) {
writeServiceError(w, err)
return
}
content, err := h.core.ReadArtifactContentForSession(bearerToken(r), domain.ArtifactContentRequest{ArtifactID: r.PathValue("id"), Offset: offset, Limit: limit})
stream, err := h.core.OpenArtifactContentStreamForSession(bearerToken(r), domain.ArtifactContentRequest{ArtifactID: r.PathValue("id"), Offset: offset, Limit: limit})
if err != nil {
writeServiceError(w, err)
return
}
defer stream.Body.Close()
content := stream.Content
w.Header().Set("Content-Type", content.ContentType)
w.Header().Set("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(content.Filename, "\"", "")+"\"")
w.Header().Set("Accept-Ranges", "bytes")
@@ -2617,7 +2676,7 @@ func (h *coreHandlers) artifactContent(w http.ResponseWriter, r *http.Request) {
} else {
w.WriteHeader(http.StatusOK)
}
_, _ = w.Write(content.Payload)
_, _ = io.Copy(w, stream.Body)
}
func artifactRangeRequest(r *http.Request) (int64, int, error) {
+8 -38
View File
@@ -320,11 +320,14 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
for _, action := range actions.Actions {
availability[action.Key] = action.Available
}
for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install"} {
for _, key := range []string{"generate-run", "push-run-update", "dependencies-check", "dependencies-install"} {
if !availability[key] {
t.Fatalf("expected action %q available in %+v", key, actions.Actions)
}
}
if availability["generate-client-manager"] {
t.Fatalf("legacy client-manager action should not be advertised in %+v", actions.Actions)
}
runDistribution := postJSONWithAuth[dto.RunDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/generate", dto.RunDistributionGenerateRequest{TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "api-run-generate"}, adminSession)
if runDistribution.ArtifactID == "" || runDistribution.BuildJobID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" || runDistribution.Status != string(domain.DistributionStatusBuilding) {
@@ -333,25 +336,8 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef {
t.Fatalf("unexpected client distribution: %+v", clientDistribution)
}
clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession)
lifecycleList := getJSONWithAuth[dto.ClientManagerInstallationListResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers", adminSession)
if lifecycleList.Count != 1 || lifecycleList.Items[0].Status != string(domain.ClientManagerLifecycleBuilding) || lifecycleList.Items[0].Distribution == nil {
t.Fatalf("expected safe client-manager lifecycle projection, got %+v", lifecycleList)
}
deployRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/deploy", dto.ClientManagerDeployRequest{ProfileKey: "scum-client-manager", DistributionID: clientLinux.ID, IdempotencyKey: "api-client-manager-deploy"}, adminSession)
assertErrorResponse(t, deployRecorder, http.StatusBadRequest, errorCodeValidation)
detail := getJSONWithAuth[dto.ClientManagerInstallationResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/scum-client-manager", adminSession)
if detail.CurrentJobID != "" || detail.KeyGeneration <= 0 {
t.Fatalf("unexpected client-manager lifecycle detail: %+v", detail)
}
unauthorizedLifecycle := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/client-managers", "", "")
assertErrorResponse(t, unauthorizedLifecycle, http.StatusUnauthorized, errorCodeUnauthorized)
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/generate", map[string]string{"profileKey": "scum-client-manager"}, adminSession), http.StatusNotFound)
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/client-managers", "", adminSession), http.StatusNotFound)
dependencyCheckRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/check", dto.DependencyJobRequest{ProbeKey: "java-runtime", IdempotencyKey: "api-dependency-check"}, adminSession)
assertStatus(t, dependencyCheckRecorder, http.StatusAccepted)
@@ -379,12 +365,9 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
if runReset.Generation != 2 || runReset.SecretRef == "" {
t.Fatalf("unexpected run key reset: %+v", runReset)
}
clientReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/key/reset", dto.ComponentKeyResetRequest{ComponentKey: "scum-client-manager"}, adminSession)
if clientReset.Generation != 2 || clientReset.SecretRef == runReset.SecretRef {
t.Fatalf("unexpected client key reset: %+v", clientReset)
}
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/key/reset", dto.ComponentKeyResetRequest{ComponentKey: "scum-client-manager"}, adminSession), http.StatusNotFound)
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall)} {
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, runReset), mustJSON(t, dependencyInstall)} {
for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
if strings.Contains(body, forbidden) {
t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body)
@@ -1841,30 +1824,22 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
}
pluginRequest.DeclaredPermissions = []string{
"server.read",
"server.logs.read",
"server.run.distribution",
"server.client-manager.manage",
"server.dependencies.manage",
"server.artifacts.read",
}
pluginRequest.BridgeActions = []string{
string(domain.PluginBridgeActionRunDistribution),
string(domain.PluginBridgeActionClientManager),
string(domain.PluginBridgeActionDependenciesRequest),
string(domain.PluginBridgeActionLogsBackfillRequest),
}
pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
pluginRequest.RuntimeProfiles.LogSources = []dto.RuntimeLogSourceBody{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
endpoint := validRunEndpointRequest()
@@ -1877,11 +1852,6 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
+6 -12
View File
@@ -14,7 +14,7 @@ Routes use JSON request and response bodies unless a route explicitly accepts fi
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
| Server file manager | `GET /api/v1/server-instances/{id}/files/workspace`, `POST /api/v1/server-instances/{id}/files/browse`, `GET /api/v1/server-instances/{id}/files/list`, `POST /api/v1/server-instances/{id}/files/refresh`, `POST /api/v1/server-instances/{id}/files/read`, `POST /api/v1/server-instances/{id}/files/write`, `POST /api/v1/server-instances/{id}/files/upload`, `POST /api/v1/server-instances/{id}/files/download` | `GET /api/v1/server-instances/{id}/files/read-snapshot` | `ServerFileWorkspaceResponse`, `ServerFileListResponse`, `DeclaredFileReadSnapshotResponse`, `ServerFileReadRequest`, `ServerFileWriteRequest`, `ServerFileUploadResponse`, `ServerFileDownloadRequest`, `ServerFileDownloadResponse` |
@@ -27,8 +27,6 @@ Routes use JSON request and response bodies unless a route explicitly accepts fi
Plugin-owned data is an independent, server-scoped plugin store. It is not a projection of the game-server database and never aliases platform user/auth storage.
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`
@@ -138,7 +136,7 @@ Artifact bridge execution returns safe metadata and platform content routes only
Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies.
## Implemented Runtime Distribution And Client Manager Actions
## Implemented Runtime Distribution Actions
- `GET /api/v1/server-instances/{id}/runtime-binding`: returns the visible server's selected profile and redacted logical binding readiness. Values are represented only by configured/secret-backed flags.
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs for non-deleted servers. Undeclared keys, unsafe paths/sockets/credentials, and plaintext secrets are rejected.
@@ -148,15 +146,12 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
- `POST /api/v1/server-instances/{id}/run/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`.
- `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`.
- `GET /api/v1/server-instances/{id}/run/update`: lists safe update phase, target, progress message, artifact checksum, release identity, rollback, and summary for the authorized server.
- `POST /api/v1/server-instances/{id}/client-managers/generate`: accepts `ClientManagerBuildRequest`, validates the plugin-declared client-manager profile and target platform, injects a distinct current client-manager key into the package config, publishes a downloadable artifact, and returns `ClientManagerDistributionResponse`.
- `POST /api/v1/server-instances/{id}/client-managers/download`: accepts `ClientManagerDownloadRequest` and opens the latest authorized client-manager artifact through `ArtifactDownloadReferenceResponse`.
- `POST /api/v1/server-instances/{id}/client-managers/key/reset`: accepts `ComponentKeyResetRequest`, resets only the named client-manager component key, increments generation, revokes older client-manager packages, and returns `ComponentKeyResponse`.
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. The component stream (`POST /api/v1/game-client-bridge/companion/logs/events`) forwards the current declared streams to the bound plugin companion. Neither route replays retained log entries or interprets their body; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. It does not replay retained log entries or interpret their body; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
Runtime distribution APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw request payloads, run sockets, host paths, or credentials.
@@ -198,7 +193,6 @@ Run file input chunks are used only for browser-staged file uploads that produce
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries verbatim, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
- `POST /api/v1/game-client-bridge/companion/logs/events`: authorize the component session and forward the current declared log channel as SSE. The payload is opaque; parsing, redaction, and user/business projections belong to the plugin companion.
Server-scoped SSE log streaming remains available for the terminal drawer. `POST /api/v1/log-streams/query` remains the bounded cursor contract for internal maintenance/debug reads.
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
@@ -209,11 +203,11 @@ Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file`
## Implemented Run Artifact Actions
- `POST /api/v1/run/artifacts/open`: accept `ArtifactTransferOpenRequest`, validate active run session and scoped artifact owner, create or reuse uploading artifact metadata, and return `ArtifactTransferOpenResponse` with transfer resume state.
- `POST /api/v1/run/artifacts/chunks`: accept `ArtifactChunkUploadRequest`, validate chunk range and checksum, store idempotent chunk state, and return `ArtifactChunkUploadResponse` with acknowledged chunk indexes.
- `POST /api/v1/run/artifacts/chunks`: accept `application/octet-stream` chunk bytes with transfer metadata in headers, validate chunk range and checksum, store idempotent chunk state, and return `ArtifactChunkUploadResponse` with acknowledged chunk indexes.
- `POST /api/v1/run/artifacts/status`: accept `ArtifactTransferStatusRequest` and return `ArtifactTransferStatusResponse` with received chunks and next missing chunk index.
- `POST /api/v1/run/artifacts/complete`: accept `ArtifactTransferCompleteRequest`, verify all chunks and final checksum, mark the artifact available, and return `ArtifactTransferCompleteResponse`.
Run artifact actions carry bounded upload metadata and chunk payloads only: run endpoint ID, session token, transfer ID, artifact ID, owner metadata, chunk indexes, byte ranges, checksums, and JSON chunk payload bytes. They do not carry control heartbeat metadata beyond session identity, job result bodies, logs, host paths, raw credentials, direct sockets, or plugin/browser storage credentials.
Run artifact actions carry bounded upload metadata and raw chunk bodies only: run endpoint ID, session token, transfer ID, artifact ID, owner metadata, chunk indexes, byte ranges, checksums, and `application/octet-stream` chunk bytes. They do not carry control heartbeat metadata beyond session identity, job result bodies, logs, host paths, raw credentials, direct sockets, or plugin/browser storage credentials.
Artifact/file transfer is lower priority than control, job lifecycle metadata, and durable log ingest. Slow or retrying chunks must not block heartbeat, job ack/result delivery, cancellation/reconcile calls, or log batch acknowledgement; lightweight routes reject heavy transfer payloads rather than storing them.
## Implemented Browser Artifact Download Actions
-56
View File
@@ -155,62 +155,6 @@ func (h *coreHandlers) serverDependencies(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, dto.DependencyCatalogFromDomain(catalog))
}
func (h *coreHandlers) serverClientManagerGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerBuildRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
distribution, err := h.core.GenerateClientManagerDistributionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, dto.ClientManagerDistributionFromDomain(distribution))
}
func (h *coreHandlers) serverClientManagerDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerDownloadRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
reference, err := h.core.OpenLatestClientManagerDistributionDownloadForSession(bearerToken(r), r.PathValue("id"), request.ProfileKey)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ArtifactDownloadReferenceFromDomain(reference))
}
func (h *coreHandlers) serverClientManagerKeyReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ComponentKeyResetRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
reset := request.ToDomain(r.PathValue("id"))
reset.ComponentKind = domain.DistributionComponentClientManager
key, err := h.core.ResetComponentKeyForSession(bearerToken(r), reset)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ComponentKeyFromDomain(key))
}
func (h *coreHandlers) serverDependenciesCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
+1 -1
View File
@@ -1,6 +1,6 @@
# Platform Distribution Builder
平台使用此专用镜像在一次性、只读 Docker 容器中构建 Run 和 client-manager distribution。镜像只提供固定版本的 Go 工具链、Git 和 CA certificates;源码、每个 job 的输入与输出均由平台在运行时挂载。
平台使用此专用镜像在一次性、只读 Docker 容器中构建 Run distribution。镜像只提供固定版本的 Go 工具链、Git 和 CA certificates;源码、每个 job 的输入与输出均由平台在运行时挂载。
构建本地固定标签:
-1
View File
@@ -701,7 +701,6 @@ const (
PluginBridgeActionRunDistribution PluginBridgeAction = "run.distribution.request"
PluginBridgeActionDependenciesRequest PluginBridgeAction = "dependencies.request"
PluginBridgeActionLogsBackfillRequest PluginBridgeAction = "logs.backfill.request"
PluginBridgeActionClientManager PluginBridgeAction = "client-manager.request"
PluginBridgeActionPluginLifecycle PluginBridgeAction = "plugin-lifecycle.request"
PluginBridgeActionAIInvoke PluginBridgeAction = "ai.invoke"
)
+3 -9
View File
@@ -62,7 +62,7 @@ Manifest registration uses `GamePluginManifestRegistrationRequest` at `POST /api
Remote access jobs are enabled only when both the selected run endpoint reports the capability and the server instance's installed plugin declares it. Plugin pages must use `remote.access.request` with `server.remote.access`; platform rejects undeclared database, RCON, log transfer, or remote file capabilities before creating jobs.
Runtime profile and distribution permissions are declared by plugins, then gated again by platform routes and services. `server.run.distribution` enables run package generation/download/reset/update operations, `server.dependencies.manage` enables dependency check/install jobs, and `server.client-manager.manage` enables plugin-declared companion client-manager generation/download/reset operations. Plugin metadata stores only declarations and safe refs; raw run/client-manager keys and transport credentials are stored through platform secret resources, never in plugin records.
Runtime profile and distribution permissions are declared by plugins, then gated again by platform routes and services. `server.run.distribution` enables run package generation/download/reset/update operations, and `server.dependencies.manage` enables dependency check/install jobs. Plugin metadata stores only declarations and safe refs; raw run keys and transport credentials are stored through platform secret resources, never in plugin records.
## ServerInstance
@@ -109,7 +109,7 @@ Run sessions persist only a token hash, generation, status, expiry, capability f
- `missingKeys`: logical keys that must be completed before dependent actions are available.
- `status`: `complete` or `incomplete`.
Installed `GamePlugin` records persist the validated manifest `runtimeProfiles` contract, including discovery, lifecycle, dependency/install, log, transport, and client-manager declarations. One server binding selects one declared lifecycle profile. Platform derives allowed and required logical keys; clients cannot assert `missingKeys` or `status`.
Installed `GamePlugin` records persist the validated manifest `runtimeProfiles` contract, including discovery, lifecycle, dependency/install, log, and transport declarations. One server binding selects one declared lifecycle profile. Platform derives allowed and required logical keys; clients cannot assert `missingKeys` or `status`.
Plugin lifecycle assets are registered as manifest-declared files plus a
content-bearing registration payload. Platform packages those assets into
@@ -128,11 +128,9 @@ Bindings are used for action gating and future run-side profile resolution. File
- `EncryptedComponentKey`: stores exactly one active encrypted key per server/component plus hash, fingerprint, redacted secret ref, generation, status, and reset time.
- `RunDistribution`: records a generated run package for one server, target OS/architecture, package format, artifact ID, checksum, key generation, secret ref, and status.
- `ClientManagerDistribution`: records a generated plugin-declared client-manager package with profile key, repository/source revision metadata, build job ID, artifact ID, checksum, key generation, secret ref, and status.
- `ClientManagerBuildJob`: records source checkout/build status, target platform, artifact ID, checksum, redacted build log ref, key generation, and status.
- `RunUpdateJob`: records platform-created Run self-update orchestration with server, endpoint, artifact ID/checksum, target and previous release, job/idempotency identity, `queued/downloading/staged/restart-requested/activating/succeeded/rolled-back/failed` phase, bounded message, rollback flag, and timestamps. Platform only projects success after a signed current-session post-reconciliation health report; terminal staging alone remains `restart-requested`.
Run and client-manager keys are isolated singleton credentials. Reset replaces the encrypted database value, increments generation, marks older distributions revoked, and requires regenerating and redeploying that component. API DTOs may expose key generation, fingerprint, status, artifact ID, checksum, job ID, and `secret://runtime-keys/.../current` refs, but never the raw key.
Run keys are isolated singleton credentials. Reset replaces the encrypted database value, increments generation, marks older distributions revoked, and requires regenerating and redeploying that component. API DTOs may expose key generation, fingerprint, status, artifact ID, checksum, job ID, and `secret://runtime-keys/.../current` refs, but never the raw key.
## DependencyStatus
@@ -192,7 +190,3 @@ Failed or cancelled lifecycle jobs project the server instance to `failed`. Acti
- `latestSeq`: latest accepted sequence.
- `storageBackend`: `local-segments`, `loki`, `clickhouse`, `opensearch`, or `elasticsearch`.
- `retentionPolicy`: retention key.
# 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.
+1 -21
View File
@@ -280,7 +280,6 @@ type GamePluginRuntimeProfilesResponseBody struct {
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"`
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
DLLExtensions []RuntimeDLLExtensionProfileResponseBody `json:"dllExtensions,omitempty"`
}
@@ -366,7 +365,7 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
body.Discovery = append(body.Discovery, RuntimeDiscoveryProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, Expected: item.Expected, Platforms: item.Platforms})
}
for _, item := range profiles.LifecycleProfiles {
body.LifecycleProfiles = append(body.LifecycleProfiles, RuntimeLifecycleProfileBody{Key: item.Key, Mode: item.Mode, Capabilities: item.Capabilities, ActionRefs: lifecycleActionsFromDomain(item.ActionRefs), TransportKeys: item.TransportKeys, ClientManagerRef: item.ClientManagerRef, DLLExtensionRefs: item.DLLExtensionRefs, Platforms: item.Platforms})
body.LifecycleProfiles = append(body.LifecycleProfiles, RuntimeLifecycleProfileBody{Key: item.Key, Mode: item.Mode, Capabilities: item.Capabilities, ActionRefs: lifecycleActionsFromDomain(item.ActionRefs), TransportKeys: item.TransportKeys, DLLExtensionRefs: item.DLLExtensionRefs, Platforms: item.Platforms})
}
for _, item := range profiles.DependencyProbes {
body.DependencyProbes = append(body.DependencyProbes, RuntimeDependencyProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: item.Platforms})
@@ -406,25 +405,6 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
for _, item := range profiles.DataTargets {
body.DataTargets = append(body.DataTargets, RuntimeDataTargetBody{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: item.Platforms})
}
for _, item := range profiles.ClientManagers {
manager := RuntimeClientManagerProfileBody{
Key: item.Key, DisplayName: item.DisplayName, Version: item.Version,
Repository: RuntimeRepositoryBody{URL: item.RepositoryURL, RevisionPolicy: item.RevisionPolicy, Branch: item.Branch, Tag: item.Tag, Revision: item.Revision},
Build: RuntimeBuildBody{System: item.BuildSystem, WorkspaceRef: item.WorkspaceRef, EntryRef: item.EntryRef}, OutputArtifacts: item.OutputArtifacts,
Deployment: RuntimeClientManagerDeploymentBody{Mode: item.Deployment.Mode, ExecutableRef: item.Deployment.ExecutableRef, Arguments: item.Deployment.Arguments, AutoStart: item.Deployment.AutoStart, RequiredRunCapabilities: item.Deployment.RequiredRunCapabilities},
Lifecycle: RuntimeClientManagerLifecycleBody{Actions: item.Lifecycle.Actions, StartupTimeoutSeconds: item.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: item.Lifecycle.StopTimeoutSeconds},
Health: RuntimeClientManagerHealthBody{Mode: item.Health.Mode, IntervalSeconds: item.Health.IntervalSeconds, DegradedAfterSeconds: item.Health.DegradedAfterSeconds, OfflineAfterSeconds: item.Health.OfflineAfterSeconds, RequiredCapabilities: item.Health.RequiredCapabilities},
Compatibility: RuntimeClientManagerCompatibilityBody{MinimumVersion: item.Compatibility.MinimumVersion, MaximumVersion: item.Compatibility.MaximumVersion, AllowDowngrade: item.Compatibility.AllowDowngrade},
UpdatePolicy: RuntimeClientManagerUpdatePolicyBody{Strategy: item.UpdatePolicy.Strategy, RequireApproval: item.UpdatePolicy.RequireApproval, HealthConfirmationSeconds: item.UpdatePolicy.HealthConfirmationSeconds, RetainPrevious: item.UpdatePolicy.RetainPrevious},
}
for _, target := range item.SupportedTargets {
manager.SupportedTargets = append(manager.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch})
}
for _, config := range item.ConfigTemplates {
manager.ConfigTemplates = append(manager.ConfigTemplates, RuntimeConfigTemplateBody{Key: config.Key, TemplateRef: config.TemplateRef, OutputRef: config.OutputRef})
}
body.ClientManagers = append(body.ClientManagers, manager)
}
for _, item := range profiles.DLLExtensions {
host, filename := safeDLLReleaseLocation(item.ReleaseURL)
extension := RuntimeDLLExtensionProfileResponseBody{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseHost: host, ReleaseFilename: filename, Checksum: safeDLLChecksumPrefix(item.Checksum), SizeBytes: item.SizeBytes, SCUMExecutableChecksum: safeDLLChecksumPrefix(item.SCUMExecutableChecksum), UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart}
+3 -11
View File
@@ -93,7 +93,7 @@ The platform stores log stream metadata through `repo.Store` and stores log bodi
## Artifact
Implemented HTTP JSON routes:
Implemented HTTP routes:
- `POST /api/v1/run/artifacts/open`
- `POST /api/v1/run/artifacts/chunks`
@@ -112,7 +112,7 @@ Named artifact DTOs:
- `ArtifactTransferCompleteResponse`
- `ArtifactResponse`
Artifact upload supports active run session validation, job/server-instance owner scoping, bounded JSON chunk payloads, per-chunk checksum validation, duplicate chunk acknowledgement, resume status, and final checksum verification before an artifact becomes available. Artifact transport is separate from control, job result, log ingest, plugin bridge, and browser file APIs.
Artifact upload supports active run session validation, job/server-instance owner scoping, bounded octet-stream chunk bodies, per-chunk checksum validation, duplicate chunk acknowledgement, resume status, and final checksum verification before an artifact becomes available. Artifact transport is separate from control, job result, log ingest, plugin bridge, and browser file APIs.
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
@@ -130,14 +130,6 @@ Browser uploads are first staged as server-instance artifacts. Platform then que
File-manager transfer is a separate, low-priority heavy path. Slow uploads, downloads, retries, or file input chunk pulls must not block control heartbeat, job claim/ack/progress/result/cancel/reconcile, durable log batch ingest, or artifact upload acknowledgements. Control, jobs, logs, artifacts, file transfer, and optional game-client bridge remain independently backpressured channels.
## Client Manager lifecycle channel
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.control`, `client-manager.updated`, `client-manager.rolled-back`, `client-manager.rollback.restored`, or `client-manager.uninstalled` with logical process/health state only. A stalled Client Manager download must not delay Run heartbeat, job ack/result/cancel, or log spool acknowledgement.
## Game Client Bridge
The optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport.
The optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport. Platform exposes operator-scoped command queues, snapshots, query templates, and plugin-owned typed records; it does not expose a component-session companion channel.
@@ -1,131 +0,0 @@
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)
}
}
@@ -21,7 +21,13 @@ const (
clientManagerSessionTTL = 15 * time.Minute
)
func legacyClientManagerUnsupported() error {
return validationError("client-manager lifecycle is no longer supported")
}
func (svc *CoreService) DeployClientManagerForSession(sessionID string, request domain.ClientManagerDeployRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if strings.TrimSpace(request.IdempotencyKey) == "" {
request.IdempotencyKey = "client-manager-deploy-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.DistributionID
}
@@ -96,6 +102,8 @@ func (svc *CoreService) DeployClientManagerForSession(sessionID string, request
}
func (svc *CoreService) ControlClientManagerForSession(sessionID string, request domain.ClientManagerControlRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if err := validator.ValidateClientManagerControlRequest(request); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -174,6 +182,8 @@ func (svc *CoreService) ControlClientManagerForSession(sessionID string, request
}
func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request domain.ClientManagerUpdateRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if err := validator.ValidateClientManagerUpdateRequest(request); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -242,6 +252,8 @@ func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request
}
func (svc *CoreService) UninstallClientManagerForSession(sessionID string, request domain.ClientManagerUninstallRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if err := validator.ValidateClientManagerUninstallRequest(request); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -299,6 +311,8 @@ func (svc *CoreService) UninstallClientManagerForSession(sessionID string, reque
}
func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, request domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if _, err := svc.GetCurrentUser(sessionID); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -327,6 +341,8 @@ func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, r
}
func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string, request domain.ClientManagerRetryRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if err := validator.ValidateClientManagerRetryRequest(request); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -394,6 +410,8 @@ func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string,
}
func (svc *CoreService) GetClientManagerLifecycleForSession(sessionID, serverInstanceID, profileKey string) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -408,6 +426,8 @@ func (svc *CoreService) GetClientManagerLifecycleForSession(sessionID, serverIns
}
func (svc *CoreService) ListClientManagerLifecyclesForSession(sessionID, serverInstanceID string) ([]domain.ClientManagerLifecycleView, error) {
return nil, legacyClientManagerUnsupported()
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
return nil, err
}
@@ -645,6 +665,8 @@ func (svc *CoreService) ensureClientManagerInstallationFromDistribution(instance
}
func (svc *CoreService) ProjectClientManagerDistribution(distribution domain.ClientManagerDistribution) error {
return legacyClientManagerUnsupported()
instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID)
if err != nil {
return err
@@ -750,6 +772,8 @@ func compareClientManagerVersion(left, right [3]int) int {
}
func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientManagerLifecycleInputRequest) (domain.ClientManagerLifecycleInput, error) {
return domain.ClientManagerLifecycleInput{}, legacyClientManagerUnsupported()
if err := validator.ValidateClientManagerLifecycleInputRequest(request); err != nil {
return domain.ClientManagerLifecycleInput{}, err
}
@@ -806,6 +830,8 @@ func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientMana
}
func (svc *CoreService) ReadClientManagerLifecycleChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
return domain.RunUpdateChunk{}, legacyClientManagerUnsupported()
if err := validator.ValidateRunUpdateChunkRequest(request); err != nil {
return domain.RunUpdateChunk{}, err
}
@@ -1109,6 +1135,8 @@ func (svc *CoreService) clientManagerRuntimeActionProjection(instance domain.Ser
}
func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error) {
return domain.ClientManagerRegisterResult{}, legacyClientManagerUnsupported()
request = domain.CopyClientManagerRegisterRequest(request)
if err := validator.ValidateClientManagerRegisterRequest(request); err != nil {
return domain.ClientManagerRegisterResult{}, err
@@ -1189,6 +1217,8 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist
}
func (svc *CoreService) AcceptClientManagerHeartbeat(heartbeat domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error) {
return domain.ClientManagerHeartbeatResult{}, legacyClientManagerUnsupported()
heartbeat = domain.CopyClientManagerHeartbeat(heartbeat)
if err := validator.ValidateClientManagerHeartbeat(heartbeat); err != nil {
return domain.ClientManagerHeartbeatResult{}, err
@@ -1298,6 +1328,8 @@ func (svc *CoreService) revokeClientManagerSessions(installationID, reason strin
}
func (svc *CoreService) ReconcileClientManagerLifecycle() error {
return nil
stamp := svc.now()
nonces, err := svc.store.ClientManagerNonces().List(domain.ClientManagerNonceFilter{ExpiresBefore: stamp})
if err != nil {
@@ -1,289 +0,0 @@
package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
func TestClientManagerLifecycleBuildDeployRegisterHealthUpdateRollbackAndUninstall(t *testing.T) {
svc, ownerSession, instance := newDistributionTestFixture(t)
baseTime := svc.now()
svc.now = func() time.Time { return baseTime }
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "lifecycle-build-v1")
view, err := svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleAvailable {
t.Fatalf("expected available build projection, view=%+v err=%v", view, err)
}
view, err = svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-deploy-v1"})
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleDeploying || view.Job.State != domain.JobStateQueued {
t.Fatalf("queue deployment: view=%+v err=%v", view, err)
}
if _, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-deploy-v1"}); err != nil {
t.Fatalf("idempotent deployment: %v", err)
}
runSession := registerClientManagerRun(t, svc)
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
input, err := svc.GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil || input.ArtifactID != distribution.ArtifactID || input.KeyGeneration != distribution.KeyGeneration || input.DeploymentGeneration != view.Installation.DeploymentGeneration || strings.Contains(strings.Join(input.Arguments, " "), "/Users/") {
t.Fatalf("get fenced deployment input: input=%+v err=%v", input, err)
}
chunk, err := svc.ReadClientManagerLifecycleChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 0, Length: 7})
if err != nil || len(chunk.Payload) == 0 || chunk.ArtifactID != distribution.ArtifactID {
t.Fatalf("read deployment chunk: chunk=%+v err=%v", chunk, err)
}
if _, err := svc.GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt + 1}); err == nil {
t.Fatal("expected stale attempt to be rejected")
}
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.deployed", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleRegistering || view.Installation.ActiveArtifactID != distribution.ArtifactID {
t.Fatalf("expected deployed registration state, got %+v", view.Installation)
}
componentKey := currentClientManagerPlainKey(t, svc, instance.ID, "scum-client-manager")
registerRequest := lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, baseTime)
registerRequest.Signature = clientManagerRegistrationSignature(componentKey, registerRequest)
registration, err := svc.RegisterClientManager(registerRequest)
if err != nil || !registration.Accepted || registration.SessionToken == "" {
t.Fatalf("register client manager: result=%+v err=%v", registration, err)
}
if _, err := svc.RegisterClientManager(registerRequest); err == nil {
t.Fatal("expected registration nonce replay rejection")
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: runSession, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: registerRequest.Capabilities, SentAt: baseTime}); err == nil {
t.Fatal("Run control session must not authenticate as a Client Manager session")
}
heartbeat, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "ready", Capabilities: registerRequest.Capabilities, SentAt: baseTime})
if err != nil || heartbeat.Status != domain.ClientManagerLifecycleOnline {
t.Fatalf("accept heartbeat: result=%+v err=%v", heartbeat, err)
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: registerRequest.Capabilities, SentAt: baseTime}); err == nil {
t.Fatal("expected replayed heartbeat sequence rejection")
}
baseTime = baseTime.Add(50 * time.Second)
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
t.Fatalf("reconcile degraded health: %v", err)
}
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleDegraded {
t.Fatalf("expected degraded heartbeat timeout, got %+v", view.Installation)
}
baseTime = baseTime.Add(80 * time.Second)
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
t.Fatalf("reconcile offline health: %v", err)
}
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleOffline {
t.Fatalf("expected offline heartbeat timeout, got %+v", view.Installation)
}
updatedDistribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.1.0", "lifecycle-build-v2")
view, err = svc.UpdateClientManagerForSession(ownerSession, domain.ClientManagerUpdateRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: updatedDistribution.ID, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, Approved: true, IdempotencyKey: "lifecycle-update-v2"})
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleUpdating {
t.Fatalf("queue staged update: view=%+v err=%v", view, err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUpdate)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateFailed, "client-manager.rollback.restored", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.ActiveArtifactID != distribution.ArtifactID || !strings.Contains(view.Installation.Phase, "previous deployment restored") || !view.Installation.Retryable {
t.Fatalf("expected failed update to retain previous active slot, got %+v", view.Installation)
}
view, err = svc.RetryClientManagerLifecycleForSession(ownerSession, domain.ClientManagerRetryRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, IdempotencyKey: "lifecycle-update-v2-retry"})
if err != nil {
t.Fatalf("retry staged update: %v", err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUpdate)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.updated", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.ActiveArtifactID != updatedDistribution.ArtifactID || view.Installation.PreviousArtifactID != distribution.ArtifactID || view.Installation.Status != domain.ClientManagerLifecycleRegistering {
t.Fatalf("expected successful update slot commit, got %+v", view.Installation)
}
view, err = svc.ControlClientManagerForSession(ownerSession, domain.ClientManagerControlRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", Operation: domain.ClientManagerOperationRollback, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, IdempotencyKey: "lifecycle-rollback-v1"})
if err != nil {
t.Fatalf("queue explicit rollback: %v", err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerRollback)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.rolled-back", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.ActiveArtifactID != distribution.ArtifactID || view.Installation.PreviousArtifactID != updatedDistribution.ArtifactID {
t.Fatalf("expected rollback slot swap, got %+v", view.Installation)
}
view, err = svc.UninstallClientManagerForSession(ownerSession, domain.ClientManagerUninstallRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, Confirmed: true, IdempotencyKey: "lifecycle-uninstall"})
if err != nil {
t.Fatalf("queue uninstall: %v", err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUninstall)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.uninstalled", "stopped")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleUninstalled || view.Installation.ActiveArtifactID != "" {
t.Fatalf("expected durable uninstalled history, got %+v", view.Installation)
}
if _, err := svc.store.ClientManagerDistributions().Get(distribution.ID); err != nil {
t.Fatalf("uninstall must retain distribution history: %v", err)
}
}
func TestClientManagerLifecycleRejectsCrossScopeStaleAndRevokedIdentity(t *testing.T) {
svc, ownerSession, instance := newDistributionTestFixture(t)
now := svc.now()
svc.now = func() time.Time { return now }
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "lifecycle-scope-build")
view, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-scope-deploy"})
if err != nil {
t.Fatalf("queue deploy: %v", err)
}
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "other-owner", DisplayName: "Other", Email: "other-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
if _, err := svc.GetClientManagerLifecycleForSession(otherSession, instance.ID, "scum-client-manager"); err == nil {
t.Fatal("expected cross-owner lifecycle read denial")
}
if _, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration + 1, IdempotencyKey: "lifecycle-stale-deploy"}); err == nil {
t.Fatal("expected stale deployment generation denial")
}
runSession := registerClientManagerRun(t, svc)
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.deployed", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
plain := currentClientManagerPlainKey(t, svc, instance.ID, "scum-client-manager")
request := lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
request.ArtifactID = "cross-server-artifact"
request.Signature = clientManagerRegistrationSignature(plain, request)
if _, err := svc.RegisterClientManager(request); err == nil {
t.Fatal("expected cross-artifact registration rejection")
}
request = lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
request.KeyGeneration++
request.Nonce = "nonce-stale-key-generation"
request.Signature = clientManagerRegistrationSignature(plain, request)
if _, err := svc.RegisterClientManager(request); err == nil {
t.Fatal("expected stale key generation registration rejection")
}
request = lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
request.Nonce = "nonce-valid-component-identity"
request.Signature = clientManagerRegistrationSignature(plain, request)
registration, err := svc.RegisterClientManager(request)
if err != nil {
t.Fatalf("register valid component: %v", err)
}
now = now.Add(16 * time.Minute)
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
t.Fatalf("expire component session: %v", err)
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: request.Capabilities, SentAt: now}); err == nil {
t.Fatal("expected expired component session rejection")
}
request.Timestamp = now
request.Nonce = "nonce-replacement-after-expiry"
request.Signature = clientManagerRegistrationSignature(plain, request)
registration, err = svc.RegisterClientManager(request)
if err != nil {
t.Fatalf("register replacement component session: %v", err)
}
if _, err := svc.ResetComponentKeyForSession(ownerSession, domain.ComponentKeyResetRequest{ServerInstanceID: instance.ID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: "scum-client-manager"}); err != nil {
t.Fatalf("reset component key: %v", err)
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: request.Capabilities, SentAt: now}); err == nil {
t.Fatal("expected reset to revoke component session")
}
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if !view.Installation.RequiresRedeploy || view.Installation.Status != domain.ClientManagerLifecycleFailed {
t.Fatalf("expected key reset recovery projection, got %+v", view.Installation)
}
for _, forbidden := range []string{plain, registration.SessionToken, "secret://", "/Users/", "tcp://"} {
payload := strings.Join([]string{view.Installation.Phase, view.Installation.HealthReason}, " ")
if strings.Contains(payload, forbidden) {
t.Fatalf("safe lifecycle view leaked %q: %s", forbidden, payload)
}
}
}
func buildLifecycleDistribution(t *testing.T, svc *CoreService, session string, instance domain.ServerInstance, version, idempotency string) domain.ClientManagerDistribution {
t.Helper()
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
t.Fatalf("get lifecycle Run endpoint: %v", err)
}
endpoint.LastHeartbeatAt = svc.now()
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("refresh lifecycle Run heartbeat: %v", err)
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get lifecycle plugin: %v", err)
}
for i := range plugin.RuntimeProfiles.ClientManagers {
if plugin.RuntimeProfiles.ClientManagers[i].Key == "scum-client-manager" {
plugin.RuntimeProfiles.ClientManagers[i].Version = version
}
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update lifecycle version: %v", err)
}
payload := []byte("client-manager-package-" + version)
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: idempotency})
if err != nil {
t.Fatalf("generate lifecycle distribution: %v", err)
}
return completeClientDistributionBuild(t, svc, distribution, payload)
}
func registerClientManagerRun(t *testing.T, svc *CoreService) string {
t.Helper()
hello := validRunControlHello()
hello.Platform = "linux"
hello.Architecture = "amd64"
hello.CapabilityReport.Capabilities = append(hello.CapabilityReport.Capabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall)
result, err := svc.RegisterRunHello(hello)
if err != nil {
t.Fatalf("register lifecycle Run: %v", err)
}
return result.SessionToken
}
func claimClientManagerJob(t *testing.T, svc *CoreService, sessionToken, capability string) domain.RunJobClaimResult {
t.Helper()
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.Capability != capability {
t.Fatalf("claim %s job: claim=%+v err=%v", capability, claim, err)
}
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "typed lifecycle work started"}); err != nil {
t.Fatalf("ack lifecycle job: %v", err)
}
return claim
}
func completeClientManagerJob(t *testing.T, svc *CoreService, sessionToken string, claim domain.RunJobClaimResult, state domain.JobState, kind, processState string) {
t.Helper()
_, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: state, Progress: domain.RunJobProgressReport{Percent: 100, Message: "client-manager lifecycle terminal"}, Message: "client-manager lifecycle terminal", ExecutionResult: domain.JobExecutionResult{Kind: kind, ProcessState: processState, Summary: "bounded lifecycle result"}})
if err != nil {
t.Fatalf("complete lifecycle job: %v", err)
}
}
func currentClientManagerPlainKey(t *testing.T, svc *CoreService, serverID, profileKey string) string {
t.Helper()
key, err := svc.activeComponentKey(serverID, domain.DistributionComponentClientManager, profileKey)
if err != nil {
t.Fatalf("get active component key: %v", err)
}
plain, err := svc.decryptRuntimeKey(key.EncryptedKey)
if err != nil {
t.Fatalf("decrypt component key: %v", err)
}
return plain
}
func lifecycleRegisterRequest(installation domain.ClientManagerInstallation, capabilities []string, stamp time.Time) domain.ClientManagerRegisterRequest {
return domain.ClientManagerRegisterRequest{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, ArtifactID: installation.ActiveArtifactID, Version: installation.ActiveVersion, SourceRevision: installation.ActiveRevision, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, Capabilities: capabilities, Timestamp: stamp, Nonce: "nonce-client-manager-registration"}
}
@@ -632,7 +632,7 @@ func TestCoreServiceReportsPlatformBuilderUnavailable(t *testing.T) {
}
seen := 0
for _, action := range actions.Actions {
if action.Key != "generate-run" && action.Key != "generate-client-manager" {
if action.Key != "generate-run" {
continue
}
seen++
@@ -640,8 +640,8 @@ func TestCoreServiceReportsPlatformBuilderUnavailable(t *testing.T) {
t.Fatalf("expected explicit platform builder unavailable reason, got %+v", action)
}
}
if seen != 2 {
t.Fatalf("expected both build actions, got %+v", actions.Actions)
if seen != 1 {
t.Fatalf("expected run build action, got %+v", actions.Actions)
}
_, err = svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
+4 -22
View File
@@ -116,6 +116,8 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
}
func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID string, request domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error) {
return domain.ClientManagerDistribution{}, legacyClientManagerUnsupported()
request = domain.CopyClientManagerBuildRequest(request)
if strings.TrimSpace(request.IdempotencyKey) == "" {
request.IdempotencyKey = "client-manager-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.TargetOS + "-" + request.TargetArch
@@ -287,6 +289,8 @@ func (svc *CoreService) OpenLatestRunDistributionDownloadForSession(sessionID st
}
func (svc *CoreService) OpenLatestClientManagerDistributionDownloadForSession(sessionID string, serverInstanceID string, profileKey string) (domain.ArtifactDownloadReference, error) {
return domain.ArtifactDownloadReference{}, legacyClientManagerUnsupported()
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.ArtifactDownloadReference{}, err
@@ -354,11 +358,6 @@ func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request do
if err := svc.revokeComponentDistributions(instance.ID, request.ComponentKind, normalizedComponentKey(request.ComponentKind, request.ComponentKey), nextGeneration); err != nil {
return domain.EncryptedComponentKey{}, err
}
if request.ComponentKind == domain.DistributionComponentClientManager {
if err := svc.fenceClientManagerAfterKeyReset(instance.ID, normalizedComponentKey(request.ComponentKind, request.ComponentKey), nextGeneration); err != nil {
return domain.EncryptedComponentKey{}, err
}
}
if request.ComponentKind == domain.DistributionComponentRun {
if err := svc.revokeRunControlSessionForInstance(instance); err != nil {
return domain.EncryptedComponentKey{}, err
@@ -429,17 +428,6 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
break
}
}
hasAvailableClientPackage := false
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable})
if err != nil {
return domain.ServerRuntimeActions{}, err
}
for _, distribution := range clientDistributions {
if distribution.ArtifactID != "" {
hasAvailableClientPackage = true
break
}
}
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
runPackageInputsComplete, runPackageReason := bindingsComplete, bindingReason
if !deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
@@ -462,18 +450,12 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"),
runtimeAction("push-run-update", "Push run update", runRegistered && pluginDeclares(plugin, "server.run.distribution") && svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && runPackageInputsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", runPackageReason))),
runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"),
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && builderReady && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared", fallbackReason(!builderReady, builderReason, 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", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)))),
runtimeAction("dependencies-install", "Install dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason)))),
runtimeAction("live-logs", "Live logs", runRegistered && pluginSupports(plugin, "logs.read"), fallbackReason(!runRegistered, "Run heartbeat has not been observed", "plugin does not declare live logs")),
runtimeAction("historical-logs", "Historical logs", runRegistered && svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason))),
},
}
if runRegistered {
actions.Actions = append(actions.Actions, svc.clientManagerRuntimeActionProjection(instance, plugin, endpoint, bindingsComplete, bindingReason)...)
}
return domain.CopyServerRuntimeActions(actions), nil
}
+16 -118
View File
@@ -511,24 +511,9 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test
}
}
func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperations(t *testing.T) {
func TestCoreServiceRejectsClientManagerDistributionRequests(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
runDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "idem-run-for-client",
})
if err != nil {
t.Fatalf("generate run distribution: %v", err)
}
runDistribution = completeDistributionBuild(t, svc, runDistribution, []byte("compiled run archive"))
if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: runDistribution.ArtifactID}); err != nil {
t.Fatalf("open run download: %v", err)
}
runConfig := readGeneratedPackageConfig(t, svc, session, runDistribution.ArtifactID)
clientDistribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
_, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
ServerInstanceID: instance.ID,
ProfileKey: "scum-client-manager",
TargetOS: "windows",
@@ -537,62 +522,26 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
SourceRevision: "main",
IdempotencyKey: "idem-client-manager",
})
if err != nil {
t.Fatalf("generate client-manager distribution: %v", err)
if err == nil {
t.Fatal("expected client-manager distribution request to be rejected")
}
clientConfig := readGeneratedPackageConfig(t, svc, session, clientDistribution.ArtifactID)
if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusBuilding {
t.Fatalf("unexpected client-manager distribution: %+v", clientDistribution)
}
if clientConfig.AuthKey == runConfig.AuthKey || clientDistribution.SecretRef == runDistribution.SecretRef {
t.Fatalf("client-manager must use a distinct key/ref, run=%+v client=%+v", runConfig, clientConfig)
}
build, err := svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
if err != nil {
t.Fatalf("get build job: %v", err)
}
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
t.Fatalf("unexpected build job: %+v", build)
}
clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
build, err = svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
if err != nil || build.Status != domain.DistributionJobStatusSucceeded || build.Checksum == "" {
t.Fatalf("expected uploaded client build to project as succeeded, build=%+v err=%v", build, err)
}
_, err = svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
ServerInstanceID: instance.ID,
ProfileKey: "scum-client-manager",
TargetOS: "darwin",
TargetArch: "amd64",
RepositoryURL: "https://github.com/F88888/scum_client.git",
IdempotencyKey: "idem-client-manager-denied",
})
if err == nil || !strings.Contains(err.Error(), "targetOs") {
t.Fatalf("expected unsupported target denial, got %v", err)
}
if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentClientManager,
ComponentKey: "scum-client-manager",
}); err != nil {
t.Fatalf("reset client-manager key: %v", err)
}); err == nil || !strings.Contains(err.Error(), "componentKind") {
t.Fatalf("expected client-manager key reset to be rejected, got %v", err)
}
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentClientManager,
ComponentKey: "scum-client-manager",
Generation: clientConfig.KeyGeneration,
Key: clientConfig.AuthKey,
Generation: 1,
Key: "legacy-client-manager-key",
})
if err != nil {
t.Fatalf("authenticate old client key: %v", err)
if err == nil || auth.Allowed {
t.Fatalf("expected client-manager component authentication to be rejected, auth=%+v err=%v", auth, err)
}
if auth.Allowed {
t.Fatalf("expected old client-manager key to be denied after reset, got %+v", auth)
}
}
func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
@@ -602,43 +551,30 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
plugin.SupportedOS = []string{"linux", "windows"}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions,
"server.run.distribution",
"server.client-manager.manage",
"server.dependencies.manage",
)
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities,
domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
)
plugin.BridgeActions = append(plugin.BridgeActions,
string(domain.PluginBridgeActionRunDistribution),
string(domain.PluginBridgeActionClientManager),
string(domain.PluginBridgeActionDependenciesRequest),
string(domain.PluginBridgeActionLogsBackfillRequest),
)
plugin.RuntimeProfiles.DependencyProbes = []domain.RuntimeDependencyProbe{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
plugin.RuntimeProfiles.InstallPlans = []domain.RuntimeInstallPlan{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []domain.RuntimeInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin fixture: %v", err)
}
endpoint.Capabilities = append(endpoint.Capabilities,
domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
)
endpoint.Platform = "linux"
endpoint.Architecture = "amd64"
@@ -712,24 +648,6 @@ func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string,
}
return generatedPackageConfig{Kind: "run", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, RunEndpointID: distribution.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
}
clients, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
if err != nil {
t.Fatalf("list client distributions: %v", err)
}
for _, distribution := range clients {
if distribution.ArtifactID != artifactID {
continue
}
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
if err != nil {
t.Fatalf("get client key: %v", err)
}
plain, err := decryptRuntimeKey(key.EncryptedKey)
if err != nil {
t.Fatalf("decrypt client key: %v", err)
}
return generatedPackageConfig{Kind: "client-manager", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, ProfileKey: distribution.ProfileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
}
t.Fatalf("distribution for artifact %s was not found", artifactID)
return generatedPackageConfig{}
}
@@ -754,26 +672,6 @@ func completeDistributionBuild(t *testing.T, svc *CoreService, distribution doma
return domain.RunDistribution{}
}
func completeClientDistributionBuild(t *testing.T, svc *CoreService, distribution domain.ClientManagerDistribution, _ []byte) domain.ClientManagerDistribution {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
updated, err := svc.store.ClientManagerDistributions().Get(distribution.ID)
if err != nil {
t.Fatalf("get client distribution: %v", err)
}
if updated.Status == domain.DistributionStatusAvailable {
return updated
}
if updated.Status == domain.DistributionStatusFailed {
t.Fatalf("platform client build failed: %+v", updated)
}
time.Sleep(time.Millisecond)
}
t.Fatalf("platform client build did not complete")
return domain.ClientManagerDistribution{}
}
func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
+51 -194
View File
@@ -3,7 +3,6 @@ package service
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"time"
@@ -13,15 +12,8 @@ import (
"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 {
@@ -56,40 +48,20 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
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{}, Features: []domain.GameClientBridgeFeatureAvailability{}}
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
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", Profiles: []domain.GameClientBridgeProfileDeclaration{}, Features: []domain.GameClientBridgeFeatureAvailability{}}
if !gameClientBridgeDeclared(plugin.GameClientBridge) {
return domain.CopyGameClientBridgeStatus(status), nil
}
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 = ""
declaration.HandlerTypes = append(declaration.HandlerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "handler.")...)
declaration.EventProducerTypes = append(declaration.EventProducerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "event-producer.")...)
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"
commandTypes := gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands)
snapshotTypes := gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots)
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: "plugin-owned", Available: true, CommandTypes: commandTypes, SnapshotTypes: snapshotTypes, QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates), HandlerTypes: commandTypes, EventProducerTypes: snapshotTypes}
if ready, reason := svc.gameClientBridgeRuntimeReadiness(instance, plugin); !ready {
declaration.Available = false
declaration.Reason = reason
status.Reason = reason
}
status.Profiles = append(status.Profiles, declaration)
status.Available = declaration.Available
if status.Available {
status.Reason = ""
}
@@ -97,15 +69,37 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
return domain.CopyGameClientBridgeStatus(status), nil
}
func gameClientBridgeSessionCapabilityValues(capabilities []string, prefix string) []string {
values := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
if value, found := strings.CutPrefix(capability, prefix); found && value != "" {
values = append(values, value)
func gameClientBridgeDeclared(bridge domain.GameClientBridgeManifest) bool {
return len(bridge.Commands) > 0 || len(bridge.Snapshots) > 0 || len(bridge.QueryTemplates) > 0 || len(bridge.LifecycleProjections) > 0 || len(bridge.DataPacks) > 0 || len(bridge.Pages) > 0 || len(bridge.Features) > 0
}
func (svc *CoreService) gameClientBridgeRuntimeReadiness(instance domain.ServerInstance, plugin domain.GamePlugin) (bool, string) {
if len(plugin.GameClientBridge.QueryTemplates) == 0 {
return true, ""
}
endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID)
if err != nil {
return false, "Run endpoint is not registered"
}
if endpoint.Status == domain.RunEndpointStatusOffline || endpoint.Status == domain.RunEndpointStatusDisabled {
return false, "Run heartbeat has not been observed"
}
transports := map[string]domain.RuntimeTransportProfile{}
for _, transport := range plugin.RuntimeProfiles.TransportProfiles {
transports[transport.Key] = transport
}
for _, template := range plugin.GameClientBridge.QueryTemplates {
transport, ok := transports[template.TransportKey]
if !ok {
return false, "bridge query transport is not declared"
}
for _, capability := range transport.Capabilities {
if !svc.endpointSupports(endpoint, capability) {
return false, "Run endpoint is missing bridge transport capability"
}
}
}
sort.Strings(values)
return values
return true, ""
}
func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatureDeclaration, profiles []domain.GameClientBridgeProfileDeclaration) []domain.GameClientBridgeFeatureAvailability {
@@ -204,14 +198,7 @@ func (svc *CoreService) QueryGameClientBridgeSnapshotsForSession(sessionID strin
}
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 {
if strings.TrimSpace(profileKey) == "" {
return domain.GameClientBridgeCommandDeclaration{}, false
}
for _, declaration := range plugin.GameClientBridge.Commands {
@@ -243,6 +230,15 @@ func gameClientBridgeSnapshotTypes(declarations []domain.GameClientBridgeSnapsho
return values
}
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 gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQueryTemplateDeclaration) []string {
values := make([]string, len(declarations))
for index, declaration := range declarations {
@@ -306,113 +302,6 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
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.RunJobID != "" {
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
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
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.GameClientBridgeResultUnknown:
command.State = domain.GameClientBridgeCommandUnknown
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
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
@@ -595,26 +484,6 @@ func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) 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
@@ -625,18 +494,6 @@ func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameC
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.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
@@ -1,205 +0,0 @@
package service
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const gameClientBridgeLogStreamCapability = "logs.stream"
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),
}
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
}
// AuthorizeGameClientBridgeLogStream authenticates a component session and
// returns only its bound server identity. Log entries are forwarded verbatim;
// this method intentionally performs no content inspection or transformation.
func (svc *CoreService) AuthorizeGameClientBridgeLogStream(request domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error) {
if err := validator.ValidateGameClientBridgeLogStreamRequest(request); err != nil {
return domain.ServerInstance{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.ServerInstance{}, err
}
if !containsString(component.Session.Capabilities, gameClientBridgeLogStreamCapability) {
return domain.ServerInstance{}, ErrForbidden
}
instance, err := svc.store.ServerInstances().Get(component.Session.ServerInstanceID)
if err != nil {
return domain.ServerInstance{}, err
}
if instance.PluginID != component.Installation.PluginID {
return domain.ServerInstance{}, ErrUnauthorized
}
return domain.CopyServerInstance(instance), 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])
}
@@ -1,194 +0,0 @@
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)
}
}
+14 -198
View File
@@ -12,7 +12,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
t.Helper()
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
store := repo.NewMemoryStore()
plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
plugin := domain.GamePlugin{ID: "game.scum", GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
if err := store.GamePlugins().Create(plugin); err != nil {
t.Fatalf("seed bridge plugin: %v", err)
}
@@ -21,23 +21,19 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
}
func bridgeQueueRequest(now time.Time, key string) domain.GameClientBridgeQueueRequest {
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)}
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "plugin-owned", CommandType: "diagnostic.ping", 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) {
func TestGameClientBridgeCommandQueueAndIdempotency(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)
}
if command.State != domain.GameClientBridgeCommandPending || command.Claim.SessionID != "" {
t.Fatalf("queued command should remain unclaimed: %#v", command)
}
duplicate, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil || duplicate.ID != command.ID {
t.Fatalf("idempotency reuse: command=%#v err=%v", duplicate, err)
@@ -46,36 +42,6 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
if len(commands) != 1 {
t.Fatalf("expected one durable 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)
}
replayed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
if err != nil || replayed.ID != completed.ID || replayed.State != completed.State || !replayed.CompletedAt.Equal(completed.CompletedAt) {
t.Fatalf("exact terminal result retry was not idempotent: replayed=%#v err=%v", replayed, err)
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "conflict"}); err == nil {
t.Fatal("expected conflicting terminal result rejection")
}
}
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
@@ -107,154 +73,19 @@ func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
}
}
func TestGameClientBridgeLeaseReclaimAndExpiry(t *testing.T) {
func TestGameClientBridgeReconciliationExpiresPendingCommand(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "lease-1"))
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "expires-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 {
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 {
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() {
t.Fatalf("deadline mutation did not persist 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() {
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) {
t.Fatalf("failed result was not persisted: %#v err=%v", persisted, err)
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.Claim.SessionID != "" {
t.Fatalf("expected expired unclaimed command: %#v err=%v", expired, err)
}
}
@@ -287,7 +118,7 @@ func TestGameClientBridgeOperatorCancellationExpiresAtCommandDeadline(t *testing
}
}
func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
func TestGameClientBridgeOperatorCancellationIsIdempotentAndTerminal(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 {
@@ -304,29 +135,14 @@ func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
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 {
if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID || cancelled.Result.Status != domain.GameClientBridgeResultCancelled {
t.Fatalf("cancel bridge command: %#v err=%v", cancelled, err)
}
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) {
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) {
@@ -345,7 +161,7 @@ func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequ
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)}
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-" + string(rune('0'+sequence)), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "plugin-owned", 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)
}
@@ -353,7 +169,7 @@ func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequ
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}
stream := domain.GameClientBridgeSnapshotStream{ID: "stream-players-current", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "plugin-owned", Type: "players", StreamKey: "current", LatestSequence: 4}
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
t.Fatal(err)
}
+2 -107
View File
@@ -166,35 +166,16 @@ type Core interface {
GetArtifactForSession(string, string) (domain.Artifact, error)
OpenArtifactDownloadForSession(string, domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error)
ReadArtifactContentForSession(string, domain.ArtifactContentRequest) (domain.ArtifactContent, error)
OpenArtifactContentStreamForSession(string, domain.ArtifactContentRequest) (ArtifactContentStream, error)
GetServerRuntimeActionsForSession(string, string) (domain.ServerRuntimeActions, error)
GetServerRuntimeBindingForSession(string, string) (domain.RuntimeBindingView, error)
UpdateServerRuntimeBindingForSession(string, string, domain.RuntimeBindingUpdate) (domain.RuntimeBindingView, error)
GenerateRunDistributionForSession(string, domain.RunDistributionGenerateRequest) (domain.RunDistribution, error)
GenerateClientManagerDistributionForSession(string, domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error)
OpenLatestRunDistributionDownloadForSession(string, string) (domain.ArtifactDownloadReference, error)
OpenLatestClientManagerDistributionDownloadForSession(string, string, string) (domain.ArtifactDownloadReference, error)
ResetComponentKeyForSession(string, domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error)
AuthenticateComponent(domain.ComponentAuthenticationRequest) (domain.ComponentAuthenticationResult, error)
DeployClientManagerForSession(string, domain.ClientManagerDeployRequest) (domain.ClientManagerLifecycleView, error)
ControlClientManagerForSession(string, domain.ClientManagerControlRequest) (domain.ClientManagerLifecycleView, error)
UpdateClientManagerForSession(string, domain.ClientManagerUpdateRequest) (domain.ClientManagerLifecycleView, error)
UninstallClientManagerForSession(string, domain.ClientManagerUninstallRequest) (domain.ClientManagerLifecycleView, error)
RetryClientManagerLifecycleForSession(string, domain.ClientManagerRetryRequest) (domain.ClientManagerLifecycleView, error)
RevokeClientManagerSessionForSession(string, domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error)
GetClientManagerLifecycleForSession(string, string, string) (domain.ClientManagerLifecycleView, error)
ListClientManagerLifecyclesForSession(string, string) ([]domain.ClientManagerLifecycleView, error)
GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest) (domain.ClientManagerLifecycleInput, error)
ReadClientManagerLifecycleChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
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)
AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error)
ReconcileGameClientBridgeCommands() error
GetGameClientBridgeStatusForSession(string, string) (domain.GameClientBridgeStatus, error)
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
@@ -351,9 +332,6 @@ func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, ar
if err := service.RecoverIncompleteBackups(); err != nil {
return nil, err
}
if err := service.ReconcileClientManagerLifecycle(); err != nil {
return nil, err
}
if err := service.ReconcileGameClientBridgeCommands(); err != nil {
return nil, err
}
@@ -956,8 +934,6 @@ func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request doma
base = svc.executeBridgeDependenciesRequest(sessionID, base, plugin, instance, request.Payload)
case domain.PluginBridgeActionLogsBackfillRequest:
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:
@@ -1364,87 +1340,6 @@ func (svc *CoreService) executeBridgeRunDistribution(sessionID string, base doma
return base
}
func (svc *CoreService) executeBridgeClientManager(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
profileKey := request.Payload["profileKey"]
operation := defaultBridgeValue(request.Payload["operation"], "status")
idempotencyKey := defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID)
generation, _ := strconv.Atoi(request.Payload["expectedDeploymentGeneration"])
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
if err != nil {
return bridgeExecutionError(base, err)
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return bridgeExecutionError(base, err)
}
profile, err := findRuntimeClientManagerProfile(plugin, profileKey)
if err != nil {
return bridgeExecutionError(base, ErrForbidden)
}
var view domain.ClientManagerLifecycleView
switch operation {
case "generate":
distribution, err := svc.GenerateClientManagerDistributionForSession(sessionID, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, TargetOS: defaultBridgeValue(request.Payload["targetOs"], "windows"), TargetArch: defaultBridgeValue(request.Payload["targetArch"], "amd64"), RepositoryURL: profile.RepositoryURL, SourceRevision: clientManagerProfileRevision(profile), IdempotencyKey: idempotencyKey})
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "queued"
base.Result = map[string]string{"distributionId": distribution.ID, "buildJobId": distribution.BuildJobID, "artifactId": distribution.ArtifactID, "checksum": distribution.Checksum, "keyGeneration": strconv.Itoa(distribution.KeyGeneration), "version": distribution.Version, "status": string(distribution.Status)}
return base
case "download":
reference, err := svc.OpenLatestClientManagerDistributionDownloadForSession(sessionID, instance.ID, profileKey)
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "ok"
base.Result = map[string]string{"artifactId": reference.ArtifactID, "downloadUrl": reference.DownloadURL, "checksum": reference.Checksum, "sizeBytes": strconv.FormatInt(reference.SizeBytes, 10), "expiresAt": reference.ExpiresAt.Format(time.RFC3339), "rangeSupported": strconv.FormatBool(reference.RangeSupported), "chunkSizeBytes": strconv.Itoa(reference.ChunkSizeBytes)}
return base
case "reset-key":
key, err := svc.ResetComponentKeyForSession(sessionID, domain.ComponentKeyResetRequest{ServerInstanceID: instance.ID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: profileKey})
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "ok"
base.Result = map[string]string{"profileKey": profileKey, "keyGeneration": strconv.Itoa(key.Generation), "status": string(key.Status), "requiresRedeploy": "true"}
return base
case "status":
view, err = svc.GetClientManagerLifecycleForSession(sessionID, instance.ID, profileKey)
case "deploy":
distributionID, resolveErr := svc.resolveClientManagerDistributionID(instance.ID, profileKey, request.Payload["artifactId"])
if resolveErr != nil {
return bridgeExecutionError(base, resolveErr)
}
view, err = svc.DeployClientManagerForSession(sessionID, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, DistributionID: distributionID, ExpectedDeploymentGeneration: generation, IdempotencyKey: idempotencyKey})
case "start", "stop", "restart", "rollback":
view, err = svc.ControlClientManagerForSession(sessionID, domain.ClientManagerControlRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, Operation: domain.ClientManagerLifecycleOperation(operation), ExpectedDeploymentGeneration: generation, IdempotencyKey: idempotencyKey})
case "update":
distributionID, resolveErr := svc.resolveClientManagerDistributionID(instance.ID, profileKey, request.Payload["artifactId"])
if resolveErr != nil {
return bridgeExecutionError(base, resolveErr)
}
view, err = svc.UpdateClientManagerForSession(sessionID, domain.ClientManagerUpdateRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, DistributionID: distributionID, ExpectedDeploymentGeneration: generation, Approved: true, IdempotencyKey: idempotencyKey})
case "retry":
view, err = svc.RetryClientManagerLifecycleForSession(sessionID, domain.ClientManagerRetryRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, ExpectedDeploymentGeneration: generation, IdempotencyKey: idempotencyKey})
case "revoke-session":
view, err = svc.RevokeClientManagerSessionForSession(sessionID, domain.ClientManagerRevokeSessionRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, Reason: "plugin bridge operator request"})
case "uninstall":
view, err = svc.UninstallClientManagerForSession(sessionID, domain.ClientManagerUninstallRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, ExpectedDeploymentGeneration: generation, Confirmed: true, IdempotencyKey: idempotencyKey})
default:
base.Status = "denied"
base.Error = &domain.PluginBridgeSafeError{Code: "invalid_client_manager_operation", Message: "client-manager operation is not supported"}
return base
}
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "ok"
if view.Job.ID != "" && !isTerminalJobState(view.Job.State) {
base.Status = "queued"
}
base.Result = safeClientManagerBridgeResult(view)
return base
}
func (svc *CoreService) executeBridgeDependenciesRequest(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
action := defaultBridgeValue(payload["operation"], "check")
capability := domain.JobCapabilityDependenciesCheck
@@ -1544,7 +1439,7 @@ func pluginPermissionsFromManifest(permissions []string) domain.PluginPermission
aggregate.Artifacts = true
case "server.remote.access":
aggregate.RemoteAccess = true
case "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage":
case "server.run.distribution", "server.dependencies.manage":
aggregate.Jobs = true
aggregate.Artifacts = true
}
@@ -8,33 +8,19 @@ import (
"browser.local/platform/domain"
)
func TestValidateClientManagerLifecycleContractsAndTransitions(t *testing.T) {
profile := domain.RuntimeClientManagerProfile{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.2.3", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"client-manager"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "client-manager", Arguments: []string{"--config", "config.json"}, RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0", MaximumVersion: "2.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}
if err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{profile}}); err != nil {
t.Fatalf("validate safe lifecycle profile: %v", err)
}
unsafe := profile
unsafe.Deployment.ExecutableRef = "/Users/operator/client-manager"
unsafe.Deployment.Arguments = []string{"bash -c", "curl | bash"}
unsafe.Health.OfflineAfterSeconds = 30
unsafe.Compatibility.MinimumVersion = "3.0.0"
err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{unsafe}})
if err == nil || !strings.Contains(err.Error(), "safe relative path") || !strings.Contains(err.Error(), "health") || !strings.Contains(err.Error(), "compatibility") {
t.Fatalf("expected unsafe lifecycle rejection, got %v", err)
}
if err := ValidateClientManagerLifecycleTransition(domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleDeploying); err != nil {
t.Fatalf("valid lifecycle transition rejected: %v", err)
}
if err := ValidateClientManagerLifecycleTransition(domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleOnline); err == nil {
t.Fatal("expected evidence-skipping lifecycle transition rejection")
func TestValidateRuntimeProfilesRejectsLegacyClientManagers(t *testing.T) {
profile := domain.RuntimeClientManagerProfile{Key: "scum-client-manager", DisplayName: "SCUM Client Manager"}
err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{profile}})
if err == nil || !strings.Contains(err.Error(), "runtimeProfiles.clientManagers is no longer supported") {
t.Fatalf("expected legacy client-manager profile rejection, got %v", err)
}
}
func TestValidateClientManagerSessionHeartbeatAndRedaction(t *testing.T) {
func TestValidateClientManagerPersistenceContractsFailClosed(t *testing.T) {
stamp := time.Date(2026, 7, 18, 4, 0, 0, 0, time.UTC)
installation := domain.ClientManagerInstallation{ID: "cm-install-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", TargetOS: "linux", TargetArch: "amd64", Status: domain.ClientManagerLifecycleOnline, Phase: "healthy", KeyGeneration: 1, DeploymentGeneration: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "ready", CreatedAt: stamp, UpdatedAt: stamp}
if err := ValidateClientManagerInstallation(installation); err != nil {
t.Fatalf("validate installation: %v", err)
t.Fatalf("validate historical installation projection: %v", err)
}
installation.HealthReason = "Bearer stolen-session"
if err := ValidateClientManagerInstallation(installation); err == nil {
@@ -42,7 +28,7 @@ func TestValidateClientManagerSessionHeartbeatAndRedaction(t *testing.T) {
}
session := domain.ClientManagerSession{ID: "cm-session-1", InstallationID: "cm-install-1", ServerInstanceID: "server-1", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", ArtifactID: "artifact-1", KeyGeneration: 1, DeploymentGeneration: 1, TokenHash: strings.Repeat("a", 64), Capabilities: []string{"component.heartbeat"}, Status: domain.ClientManagerSessionActive, ExpiresAt: stamp.Add(time.Minute), CreatedAt: stamp, UpdatedAt: stamp}
if err := ValidateClientManagerSession(session); err != nil {
t.Fatalf("validate hashed session: %v", err)
t.Fatalf("validate historical hashed session: %v", err)
}
if err := ValidateClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: "cm-install-1", SessionToken: "component-session", Sequence: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "password=leak", Capabilities: []string{"component.heartbeat"}, SentAt: stamp}); err == nil {
t.Fatal("expected unsafe heartbeat reason rejection")
+1 -1
View File
@@ -31,7 +31,7 @@ func ValidateRunControlHello(hello domain.RunControlHello) error {
if hello.ServerInstanceID != "" || hello.PluginID != "" || hello.ComponentKind != "" || hello.ComponentKey != "" || hello.KeyGeneration != 0 {
violations = appendRequired(violations, "serverInstanceId", hello.ServerInstanceID)
violations = appendRequired(violations, "pluginId", hello.PluginID)
if hello.ComponentKind != domain.DistributionComponentRun && hello.ComponentKind != domain.DistributionComponentClientManager {
if hello.ComponentKind != domain.DistributionComponentRun {
violations = append(violations, "componentKind is invalid")
}
if hello.KeyGeneration <= 0 {
+3 -3
View File
@@ -325,8 +325,8 @@ func ValidateComponentKeyResetRequest(request domain.ComponentKeyResetRequest) e
if !validDistributionComponentKind(request.ComponentKind) {
violations = append(violations, "componentKind is invalid")
}
if request.ComponentKind == domain.DistributionComponentClientManager && strings.TrimSpace(request.ComponentKey) == "" {
violations = append(violations, "componentKey is required for client-manager")
if request.ComponentKind != domain.DistributionComponentRun {
violations = append(violations, "componentKind must be run")
}
if request.ComponentKey != "" && !validDistributionLogicalKey(request.ComponentKey) {
violations = append(violations, "componentKey is invalid")
@@ -389,7 +389,7 @@ func validateRepositoryURL(field string, value string) []string {
func validDistributionComponentKind(kind domain.DistributionComponentKind) bool {
switch kind {
case domain.DistributionComponentRun, domain.DistributionComponentClientManager:
case domain.DistributionComponentRun:
return true
default:
return false
@@ -7,7 +7,7 @@ import (
"browser.local/platform/domain"
)
func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest, domain.GamePluginRuntimeProfiles) {
func TestValidateGameClientBridgeCompanionDeclarationIsUnsupported(t *testing.T) {
bridge := domain.GameClientBridgeManifest{
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
Companion: domain.GameClientBridgeCompanionDeclaration{
@@ -26,107 +26,8 @@ func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest,
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, 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, 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)
}
})
violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, nil, domain.GamePluginRuntimeProfiles{})
if !strings.Contains(strings.Join(violations, "; "), "gameClientBridge.companion is no longer supported") {
t.Fatalf("expected companion unsupported violation, got %v", violations)
}
}
+4 -55
View File
@@ -478,53 +478,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
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")
}
violations = append(violations, field+".companion is no longer supported")
}
transports := map[string]domain.RuntimeTransportProfile{}
for _, transport := range runtimeProfiles.TransportProfiles {
@@ -2395,7 +2349,7 @@ func validPluginMarketplaceStateAction(action domain.PluginMarketplaceStateActio
func validPluginRunCapability(capability string) bool {
switch capability {
case "process.install", "process.start", "process.stop", "process.restart", "process.status",
case "process.install", "process.start", "process.stop", "process.restart", "process.status",
"config.write",
"files.list", "files.read", "files.write", "files.patch",
"file.list", "file.read", "file.write", "file.patch",
@@ -2408,9 +2362,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRemoteRunProgram,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall,
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
"ai.invoke":
return true
@@ -2528,7 +2480,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", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "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.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke":
return true
default:
return false
@@ -2546,7 +2498,6 @@ func validPluginBridgeAction(action domain.PluginBridgeAction) bool {
domain.PluginBridgeActionRunDistribution,
domain.PluginBridgeActionDependenciesRequest,
domain.PluginBridgeActionLogsBackfillRequest,
domain.PluginBridgeActionClientManager,
domain.PluginBridgeActionPluginLifecycle,
domain.PluginBridgeActionAIInvoke:
return true
@@ -2575,8 +2526,6 @@ func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
return []string{"server.dependencies.manage"}
case domain.PluginBridgeActionLogsBackfillRequest:
return []string{"server.logs.read"}
case domain.PluginBridgeActionClientManager:
return []string{"server.client-manager.manage"}
case domain.PluginBridgeActionPluginLifecycle:
return []string{"server.lifecycle"}
case domain.PluginBridgeActionAIInvoke:
+2 -2
View File
@@ -8,6 +8,6 @@
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress summaries, artifact metadata, log stream cursors, and run capability compatibility.
- `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing.
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
# Client Manager lifecycle validation
# Runtime and bridge 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.
Runtime validation rejects undeclared operations, stale attempt/key generations, cross-owner/server/target artifacts, unavailable endpoints, raw secrets, endpoint/socket values, traversal or absolute executable references, shell metacharacters, and unbounded timeouts. Game-client bridge validation rejects legacy companion declarations and keeps operator-facing DTOs redacted before they cross the Platform boundary.
+5 -139
View File
@@ -24,7 +24,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
transportProfiles := map[string]domain.RuntimeTransportProfile{}
dataTargetKeys := map[string]struct{}{}
dataTargetWorkspaces := map[string]struct{}{}
managerKeys := map[string]struct{}{}
dllExtensionKeys := map[string]struct{}{}
dllExtensionStates := map[string]string{}
discoveryKeys := map[string]struct{}{}
@@ -66,7 +65,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
}
violations = append(violations, duplicateViolations(prefix+".transportKeys", profile.TransportKeys)...)
if profile.ClientManagerRef != "" {
violations = append(violations, validateProfileKey(prefix+".clientManagerRef", profile.ClientManagerRef)...)
violations = append(violations, prefix+".clientManagerRef is no longer supported")
}
for j, key := range profile.DLLExtensionRefs {
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.dllExtensionRefs[%d]", prefix, j), key)...)
@@ -235,7 +234,7 @@ 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 !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") {
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query") {
violations = append(violations, prefix+".kind is invalid")
}
if source.TargetKey != "" {
@@ -299,133 +298,8 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
violations = append(violations, prefix+".transportKey must reference a declared SQLite query transport")
}
}
for i, manager := range profiles.ClientManagers {
prefix := fmt.Sprintf("runtimeProfiles.clientManagers[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", manager.Key)...)
violations = append(violations, recordRuntimeProfileKey(managerKeys, prefix+".key", manager.Key)...)
parsed, err := url.Parse(manager.RepositoryURL)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || !strings.HasSuffix(parsed.Path, ".git") {
violations = append(violations, prefix+".repository.url must be a credential-free HTTPS .git URL")
}
if !oneOf(manager.RevisionPolicy, "pinned", "branch", "tag") {
violations = append(violations, prefix+".repository.revisionPolicy is invalid")
}
switch manager.RevisionPolicy {
case "pinned":
if manager.Revision == "" {
violations = append(violations, prefix+".repository.revision is required for pinned policy")
}
case "branch":
if manager.Branch == "" {
violations = append(violations, prefix+".repository.branch is required for branch policy")
}
case "tag":
if manager.Tag == "" {
violations = append(violations, prefix+".repository.tag is required for tag policy")
}
}
if !oneOf(manager.BuildSystem, "go", "npm", "cargo", "make") {
violations = append(violations, prefix+".build.system is invalid")
}
if len(manager.SupportedTargets) == 0 {
violations = append(violations, prefix+".supportedTargets must not be empty")
}
if len(manager.OutputArtifacts) == 0 {
violations = append(violations, prefix+".outputArtifacts must not be empty")
}
for field, value := range map[string]string{"displayName": manager.DisplayName, "branch": manager.Branch, "tag": manager.Tag, "revision": manager.Revision, "workspaceRef": manager.WorkspaceRef, "entryRef": manager.EntryRef} {
violations = append(violations, validateSafeRuntimeValue(prefix+"."+field, value)...)
}
targets := map[string]struct{}{}
for j, target := range manager.SupportedTargets {
if !validPluginSupportedOS(target.OS) || !oneOf(target.Arch, "amd64", "arm64") {
violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is invalid", prefix, j))
}
targetKey := target.OS + "/" + target.Arch
if _, exists := targets[targetKey]; exists {
violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is duplicated", prefix, j))
}
targets[targetKey] = struct{}{}
}
configKeys := map[string]struct{}{}
for j, config := range manager.ConfigTemplates {
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.configTemplates[%d].key", prefix, j), config.Key)...)
violations = append(violations, recordRuntimeProfileKey(configKeys, fmt.Sprintf("%s.configTemplates[%d].key", prefix, j), config.Key)...)
violations = append(violations, validateSafeRuntimeValue(prefix+".configTemplates.templateRef", config.TemplateRef)...)
violations = append(violations, validateSafeRuntimeValue(prefix+".configTemplates.outputRef", config.OutputRef)...)
}
for j, output := range manager.OutputArtifacts {
violations = append(violations, validateSafeRuntimeValue(fmt.Sprintf("%s.outputArtifacts[%d]", prefix, j), output)...)
}
violations = append(violations, duplicateViolations(prefix+".outputArtifacts", manager.OutputArtifacts)...)
if manager.Deployment.Mode != "" {
if manager.Deployment.Mode != "run-supervised" {
violations = append(violations, prefix+".deployment.mode is invalid")
}
if !validSemanticVersion(manager.Version) {
violations = append(violations, prefix+".version must be semantic when deployment is declared")
}
violations = append(violations, validateSafeRelativeRuntimePath(prefix+".deployment.executableRef", manager.Deployment.ExecutableRef)...)
if !containsString(manager.OutputArtifacts, manager.Deployment.ExecutableRef) {
violations = append(violations, prefix+".deployment.executableRef must name an output artifact")
}
for j, argument := range manager.Deployment.Arguments {
if !regexp.MustCompile(`^[A-Za-z0-9_./:=@+-]{1,120}$`).MatchString(argument) {
violations = append(violations, fmt.Sprintf("%s.deployment.arguments[%d] is invalid", prefix, j))
}
violations = append(violations, validateSafeRuntimeValue(fmt.Sprintf("%s.deployment.arguments[%d]", prefix, j), argument)...)
}
if len(manager.Deployment.RequiredRunCapabilities) == 0 || !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy) {
violations = append(violations, prefix+".deployment.requiredRunCapabilities must include client-manager.deploy")
}
for j, capability := range manager.Deployment.RequiredRunCapabilities {
if !oneOf(capability, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall) {
violations = append(violations, fmt.Sprintf("%s.deployment.requiredRunCapabilities[%d] is invalid", prefix, j))
}
}
violations = append(violations, duplicateViolations(prefix+".deployment.requiredRunCapabilities", manager.Deployment.RequiredRunCapabilities)...)
if len(manager.Lifecycle.Actions) == 0 || manager.Lifecycle.StartupTimeoutSeconds < 1 || manager.Lifecycle.StartupTimeoutSeconds > 300 || manager.Lifecycle.StopTimeoutSeconds < 1 || manager.Lifecycle.StopTimeoutSeconds > 120 {
violations = append(violations, prefix+".lifecycle actions and bounded timeouts are required")
}
for j, action := range manager.Lifecycle.Actions {
if !oneOf(action, "start", "stop", "restart", "status", "update", "rollback", "uninstall") {
violations = append(violations, fmt.Sprintf("%s.lifecycle.actions[%d] is invalid", prefix, j))
}
}
violations = append(violations, duplicateViolations(prefix+".lifecycle.actions", manager.Lifecycle.Actions)...)
if containsAny(manager.Lifecycle.Actions, []string{"start", "stop", "restart", "status"}) && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerControl) {
violations = append(violations, prefix+".lifecycle control actions require client-manager.control")
}
if containsString(manager.Lifecycle.Actions, "update") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerUpdate) {
violations = append(violations, prefix+".lifecycle update requires client-manager.update")
}
if containsString(manager.Lifecycle.Actions, "rollback") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerRollback) {
violations = append(violations, prefix+".lifecycle rollback requires client-manager.rollback")
}
if containsString(manager.Lifecycle.Actions, "uninstall") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerUninstall) {
violations = append(violations, prefix+".lifecycle uninstall requires client-manager.uninstall")
}
if !oneOf(manager.Health.Mode, "component-heartbeat", "process") || manager.Health.IntervalSeconds < 5 || manager.Health.IntervalSeconds > 300 || manager.Health.DegradedAfterSeconds < manager.Health.IntervalSeconds*2 || manager.Health.OfflineAfterSeconds <= manager.Health.DegradedAfterSeconds || manager.Health.OfflineAfterSeconds > 3600 {
violations = append(violations, prefix+".health mode and thresholds are invalid")
}
for j, capability := range manager.Health.RequiredCapabilities {
if !oneOf(capability, "component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream") {
violations = append(violations, fmt.Sprintf("%s.health.requiredCapabilities[%d] is invalid", prefix, j))
}
}
if manager.Health.Mode == "component-heartbeat" && !containsAny(manager.Health.RequiredCapabilities, []string{"component.register"}) || manager.Health.Mode == "component-heartbeat" && !containsString(manager.Health.RequiredCapabilities, "component.heartbeat") || manager.Health.Mode == "component-heartbeat" && !containsString(manager.Health.RequiredCapabilities, "component.health") {
violations = append(violations, prefix+".health component-heartbeat requires register, heartbeat, and health capabilities")
}
minimum, minimumOK := semanticVersionTuple(manager.Compatibility.MinimumVersion)
maximum, maximumOK := semanticVersionTuple(manager.Compatibility.MaximumVersion)
version, _ := semanticVersionTuple(manager.Version)
if manager.Compatibility.MinimumVersion != "" && !minimumOK || manager.Compatibility.MaximumVersion != "" && !maximumOK || minimumOK && maximumOK && compareSemanticVersion(minimum, maximum) > 0 || minimumOK && compareSemanticVersion(version, minimum) < 0 || maximumOK && compareSemanticVersion(version, maximum) > 0 {
violations = append(violations, prefix+".compatibility version bounds are invalid")
}
if manager.UpdatePolicy.Strategy != "manual-staged" || !manager.UpdatePolicy.RequireApproval || !manager.UpdatePolicy.RetainPrevious || manager.UpdatePolicy.HealthConfirmationSeconds < manager.Health.IntervalSeconds || manager.UpdatePolicy.HealthConfirmationSeconds > 600 {
violations = append(violations, prefix+".updatePolicy must be approved, staged, health checked, and retain previous")
}
}
if len(profiles.ClientManagers) > 0 {
violations = append(violations, "runtimeProfiles.clientManagers is no longer supported")
}
for i, extension := range profiles.DLLExtensions {
prefix := fmt.Sprintf("runtimeProfiles.dllExtensions[%d]", i)
@@ -442,11 +316,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].transportKeys references undeclared transport %q", i, key))
}
}
if profile.ClientManagerRef != "" {
if _, ok := managerKeys[profile.ClientManagerRef]; !ok {
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].clientManagerRef references undeclared client manager", i))
}
}
if len(profile.DLLExtensionRefs) > 0 {
if profile.Mode != "local-process" || !containsString(profile.Capabilities, domain.LifecycleCapabilityStart) || len(profile.Platforms) != 1 || profile.Platforms[0] != "windows" {
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d] DLL extensions require a windows local-process start profile", i))
@@ -626,11 +495,8 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt
for i, transport := range profiles.TransportProfiles {
check(fmt.Sprintf("runtimeProfiles.transportProfiles[%d].capabilities", i), transport.Capabilities)
}
for i, manager := range profiles.ClientManagers {
check(fmt.Sprintf("runtimeProfiles.clientManagers[%d].deployment.requiredRunCapabilities", i), manager.Deployment.RequiredRunCapabilities)
return violations
}
return violations
}
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
var violations []string