Remove legacy client-manager workflows
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# Platform Distribution Builder
|
||||
|
||||
平台使用此专用镜像在一次性、只读 Docker 容器中构建 Run 和 client-manager distribution。镜像只提供固定版本的 Go 工具链、Git 和 CA certificates;源码、每个 job 的输入与输出均由平台在运行时挂载。
|
||||
平台使用此专用镜像在一次性、只读 Docker 容器中构建 Run distribution。镜像只提供固定版本的 Go 工具链、Git 和 CA certificates;源码、每个 job 的输入与输出均由平台在运行时挂载。
|
||||
|
||||
构建本地固定标签:
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,7 +86,7 @@ Server list surfaces expose runtime package actions through platform APIs, while
|
||||
- request dependency checks and typed dependency install jobs.
|
||||
- open live server log stream metadata and request historical log backfill jobs.
|
||||
|
||||
These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run/client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies. Log bodies are displayed verbatim when the plugin declares them; the browser does not redact or reinterpret them.
|
||||
These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run or component keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies. Log bodies are displayed verbatim when the plugin declares them; the browser does not redact or reinterpret them.
|
||||
|
||||
Manual UI smoke checklist:
|
||||
|
||||
@@ -104,8 +104,8 @@ LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/priv
|
||||
```
|
||||
|
||||
This command verifies the API-backed local debug console path, first-party route markers, plugin/server operation proof, fallback rejection, and forbidden-fragment scans. It writes evidence under `<LOCAL_DEBUG_ROOT>/browser-acceptance/`.
|
||||
# Client Manager workspace
|
||||
# Runtime Workspace
|
||||
|
||||
Client Manager lifecycle remains a typed Platform installation projection, but it is not exposed as a generic server-detail tab. Dedicated package/dependency actions live in server-list runtime actions or plugin-declared pages, and the browser polls active jobs without fabricating later phases.
|
||||
Dedicated Run package, dependency, and plugin-declared operations live in server-list runtime actions or plugin pages, and the browser polls active jobs without fabricating later phases.
|
||||
|
||||
The UI keeps the black-mecha and magical-girl crystal-moonlight console materials and uses shared panel/command tokens. It renders no raw key, token, secret ref/value, host path, PID, socket, credential, DSN, RCON password, or Run endpoint address. 401/403 responses remain platform auth/capability errors, not local fallback success.
|
||||
|
||||
@@ -122,7 +122,6 @@ const runtimeActions = {
|
||||
{ key: "generate-run", label: "Generate run", available: true },
|
||||
{ key: "download-run", label: "Download run", available: true },
|
||||
{ key: "push-run-update", label: "Push run update", available: true },
|
||||
{ key: "generate-client-manager", label: "Generate client manager", available: true },
|
||||
{ key: "dependencies-check", label: "Check dependencies", available: true },
|
||||
{ key: "historical-logs", label: "Historical logs", available: true }
|
||||
]
|
||||
@@ -476,53 +475,6 @@ describe("PlatformApiClient AI providers", () => {
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/generate") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
profileKey: "example-client-manager",
|
||||
targetOs: "windows",
|
||||
targetArch: "amd64",
|
||||
repositoryUrl: "https://example.test/client-manager.git",
|
||||
sourceRevision: "main",
|
||||
idempotencyKey: "idem-client-generate"
|
||||
});
|
||||
return jsonResponse({
|
||||
id: "client-dist-1",
|
||||
serverInstanceId: server.id,
|
||||
pluginId: plugin.id,
|
||||
profileKey: "example-client-manager",
|
||||
targetOs: "windows",
|
||||
targetArch: "amd64",
|
||||
repositoryUrl: "https://example.test/client-manager.git",
|
||||
sourceRevision: "main",
|
||||
buildJobId: "client-build-1",
|
||||
artifactId: "artifact-client-1",
|
||||
checksum: "sha256:clientchecksum",
|
||||
keyGeneration: 1,
|
||||
secretRef: "secret://runtime-keys/server-1/client-manager/example-client-manager/current",
|
||||
status: "available",
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:00:00Z"
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/download") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ profileKey: "example-client-manager" });
|
||||
return jsonResponse({ ...runtimeDownload, artifactId: "artifact-client-1", filename: "example-client-manager.exe" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/key/reset") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ componentKind: "client-manager", componentKey: "example-client-manager" });
|
||||
return jsonResponse({
|
||||
id: "runtime-key-server-1-client-2",
|
||||
serverInstanceId: server.id,
|
||||
componentKind: "client-manager",
|
||||
componentKey: "example-client-manager",
|
||||
secretRef: "secret://runtime-keys/server-1/client-manager/example-client-manager/current",
|
||||
fingerprint: "def456abc123",
|
||||
generation: 2,
|
||||
status: "active",
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:00:00Z"
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/dependencies/check") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", idempotencyKey: "idem-dep-check" });
|
||||
return jsonResponse({ ...job, id: "job-dep-check", capability: "dependencies.check", targetKey: "dependencies/java-21" });
|
||||
@@ -652,18 +604,6 @@ describe("PlatformApiClient AI providers", () => {
|
||||
status: "queued"
|
||||
});
|
||||
await expect(client.listRunUpdates(server.id)).resolves.toMatchObject({ count: 1, items: [{ phase: "restart-requested", rollback: false }] });
|
||||
await expect(
|
||||
client.generateClientManager(server.id, {
|
||||
profileKey: "example-client-manager",
|
||||
targetOs: "windows",
|
||||
targetArch: "amd64",
|
||||
repositoryUrl: "https://example.test/client-manager.git",
|
||||
sourceRevision: "main",
|
||||
idempotencyKey: "idem-client-generate"
|
||||
})
|
||||
).resolves.toMatchObject({ artifactId: "artifact-client-1", profileKey: "example-client-manager" });
|
||||
await expect(client.downloadLatestClientManager(server.id, { profileKey: "example-client-manager" })).resolves.toMatchObject({ artifactId: "artifact-client-1" });
|
||||
await expect(client.resetClientManagerKey(server.id, { componentKind: "client-manager", componentKey: "example-client-manager" })).resolves.toMatchObject({ generation: 2 });
|
||||
await expect(client.checkDependencies(server.id, { probeKey: "java-21", idempotencyKey: "idem-dep-check" })).resolves.toMatchObject({ capability: "dependencies.check" });
|
||||
await expect(client.getDependencyCatalog(server.id)).resolves.toMatchObject({ targetOs: "linux", plans: [{ digest: runtimeDigest }] });
|
||||
await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({
|
||||
@@ -673,7 +613,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
||||
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(46);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(43);
|
||||
});
|
||||
|
||||
it("normalizes server file workspace null arrays from older platform responses", async () => {
|
||||
|
||||
@@ -16,19 +16,7 @@ import type {
|
||||
ArtifactFilterRequest,
|
||||
ArtifactListResponse,
|
||||
AuthSessionResponse,
|
||||
ClientManagerBuildRequest,
|
||||
ClientManagerControlRequest,
|
||||
ClientManagerDeployRequest,
|
||||
ClientManagerDistributionResponse,
|
||||
ClientManagerDownloadRequest,
|
||||
ClientManagerInstallationListResponse,
|
||||
ClientManagerInstallationResponse,
|
||||
ClientManagerRetryRequest,
|
||||
ClientManagerRevokeSessionRequest,
|
||||
ClientManagerUninstallRequest,
|
||||
ClientManagerUpdateRequest,
|
||||
ComponentKeyResponse,
|
||||
ComponentKeyResetRequest,
|
||||
CurrentUserResponse,
|
||||
DependencyCatalogResponse,
|
||||
DependencyJobRequest,
|
||||
@@ -116,7 +104,6 @@ import type {
|
||||
} from "./types";
|
||||
import { readWebRuntimeEnv } from "../schemas/env";
|
||||
import { parseSafeDependencyCatalog, parseSafeRunUpdate, parseSafeRunUpdateList } from "../schemas/runtimeUpdates";
|
||||
import { parseSafeClientManagerLifecycle, parseSafeClientManagerLifecycleList } from "../schemas/clientManagerLifecycle";
|
||||
import {
|
||||
parseSafeGameClientBridgeCancellation,
|
||||
parseSafeGameClientBridgeCommand,
|
||||
@@ -352,59 +339,6 @@ export class PlatformApiClient {
|
||||
return parseSafeRunUpdateList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/run/update`));
|
||||
}
|
||||
|
||||
async generateClientManager(id: string, request: ClientManagerBuildRequest): Promise<ClientManagerDistributionResponse> {
|
||||
return this.request<ClientManagerDistributionResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/generate`, {
|
||||
method: "POST",
|
||||
body: request
|
||||
});
|
||||
}
|
||||
|
||||
async downloadLatestClientManager(id: string, request: ClientManagerDownloadRequest = {}): Promise<ArtifactDownloadReferenceResponse> {
|
||||
return this.request<ArtifactDownloadReferenceResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/download`, {
|
||||
method: "POST",
|
||||
body: request
|
||||
});
|
||||
}
|
||||
|
||||
async resetClientManagerKey(id: string, request: ComponentKeyResetRequest): Promise<ComponentKeyResponse> {
|
||||
return this.request<ComponentKeyResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/key/reset`, {
|
||||
method: "POST",
|
||||
body: request
|
||||
});
|
||||
}
|
||||
|
||||
async listClientManagerLifecycles(id: string): Promise<ClientManagerInstallationListResponse> {
|
||||
return parseSafeClientManagerLifecycleList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers`));
|
||||
}
|
||||
|
||||
async getClientManagerLifecycle(id: string, profileKey: string): Promise<ClientManagerInstallationResponse> {
|
||||
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/${encodeURIComponent(profileKey)}`));
|
||||
}
|
||||
|
||||
async deployClientManager(id: string, request: ClientManagerDeployRequest): Promise<ClientManagerInstallationResponse> {
|
||||
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/deploy`, { method: "POST", body: request }));
|
||||
}
|
||||
|
||||
async controlClientManager(id: string, request: ClientManagerControlRequest): Promise<ClientManagerInstallationResponse> {
|
||||
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/control`, { method: "POST", body: request }));
|
||||
}
|
||||
|
||||
async updateClientManager(id: string, request: ClientManagerUpdateRequest): Promise<ClientManagerInstallationResponse> {
|
||||
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/update`, { method: "POST", body: request }));
|
||||
}
|
||||
|
||||
async retryClientManagerLifecycle(id: string, request: ClientManagerRetryRequest): Promise<ClientManagerInstallationResponse> {
|
||||
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/retry`, { method: "POST", body: request }));
|
||||
}
|
||||
|
||||
async revokeClientManagerSession(id: string, request: ClientManagerRevokeSessionRequest): Promise<ClientManagerInstallationResponse> {
|
||||
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/revoke-session`, { method: "POST", body: request }));
|
||||
}
|
||||
|
||||
async uninstallClientManager(id: string, request: ClientManagerUninstallRequest): Promise<ClientManagerInstallationResponse> {
|
||||
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/uninstall`, { method: "POST", body: request }));
|
||||
}
|
||||
|
||||
async getGameClientBridgeStatus(id: string): Promise<GameClientBridgeStatusResponse> {
|
||||
return parseSafeGameClientBridgeStatus(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge`));
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PlatformApiClient } from "./client";
|
||||
|
||||
const lifecycle = {
|
||||
id: "installation-1", serverInstanceId: "server-1", pluginId: "game.example", profileKey: "example-client-manager", targetOs: "windows", targetArch: "amd64",
|
||||
status: "available", phase: "artifact available", desiredVersion: "1.0.0", desiredRevision: "rev-1", desiredArtifactId: "artifact-1", keyGeneration: 1,
|
||||
deploymentGeneration: 0, health: "unknown", healthReason: "component is not installed", retryable: false, requiresRedeploy: false, updatedAt: "2026-07-18T08:00:00Z",
|
||||
distribution: { id: "distribution-1", artifactId: "artifact-1", sourceRevision: "rev-1", targetOs: "windows", targetArch: "amd64", checksum: `sha256:${"a".repeat(64)}`, keyGeneration: 1, status: "available" },
|
||||
actions: [{ operation: "deploy", available: true }, { operation: "uninstall", available: false, reason: "not installed" }]
|
||||
};
|
||||
|
||||
describe("PlatformApiClient Client Manager lifecycle", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("uses typed Platform lifecycle routes and preserves action bodies", async () => {
|
||||
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
||||
return new Response(JSON.stringify(url.endsWith("/client-managers") && (init?.method ?? "GET") === "GET" ? { items: [lifecycle], count: 1 } : lifecycle), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}));
|
||||
const client = new PlatformApiClient("/api/v1", () => "session-token");
|
||||
|
||||
await expect(client.listClientManagerLifecycles("server-1")).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.getClientManagerLifecycle("server-1", "example-client-manager")).resolves.toMatchObject({ profileKey: "example-client-manager" });
|
||||
await client.deployClientManager("server-1", { profileKey: "example-client-manager", distributionId: "distribution-1", expectedDeploymentGeneration: 0, idempotencyKey: "deploy-1" });
|
||||
await client.controlClientManager("server-1", { profileKey: "example-client-manager", operation: "start", expectedDeploymentGeneration: 1, idempotencyKey: "start-1" });
|
||||
await client.updateClientManager("server-1", { profileKey: "example-client-manager", distributionId: "distribution-2", expectedDeploymentGeneration: 1, approved: true, idempotencyKey: "update-1" });
|
||||
await client.retryClientManagerLifecycle("server-1", { profileKey: "example-client-manager", expectedDeploymentGeneration: 2, idempotencyKey: "retry-1" });
|
||||
await client.revokeClientManagerSession("server-1", { profileKey: "example-client-manager", reason: "operator revoked component session" });
|
||||
await client.uninstallClientManager("server-1", { profileKey: "example-client-manager", expectedDeploymentGeneration: 2, confirmed: true, idempotencyKey: "uninstall-1" });
|
||||
|
||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||
"GET /api/v1/server-instances/server-1/client-managers",
|
||||
"GET /api/v1/server-instances/server-1/client-managers/example-client-manager",
|
||||
"POST /api/v1/server-instances/server-1/client-managers/deploy",
|
||||
"POST /api/v1/server-instances/server-1/client-managers/control",
|
||||
"POST /api/v1/server-instances/server-1/client-managers/update",
|
||||
"POST /api/v1/server-instances/server-1/client-managers/retry",
|
||||
"POST /api/v1/server-instances/server-1/client-managers/revoke-session",
|
||||
"POST /api/v1/server-instances/server-1/client-managers/uninstall"
|
||||
]);
|
||||
expect(calls[4]?.body).toMatchObject({ approved: true, distributionId: "distribution-2" });
|
||||
expect(calls[7]?.body).toMatchObject({ confirmed: true });
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,3 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
|
||||
|
||||
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
|
||||
- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted live SSE output only. It does not replay retained log entries, and accepted batches whose source entry timestamps predate the current SSE connection after a small clock-skew allowance stay out of the terminal stream; raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients, and internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
|
||||
# Client Manager API projection
|
||||
|
||||
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
|
||||
|
||||
@@ -10,12 +10,12 @@ const status = {
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.scum",
|
||||
available: false,
|
||||
reason: "compatible companion is offline",
|
||||
reason: "plugin-owned bridge runtime is unavailable",
|
||||
profiles: [{
|
||||
pluginId: "game.scum",
|
||||
profileKey: "scum-client",
|
||||
available: false,
|
||||
reason: "component heartbeat is unavailable",
|
||||
reason: "declared runtime transport is unavailable",
|
||||
commandTypes: ["scum.diagnostic.ping"],
|
||||
snapshotTypes: ["scum.players"],
|
||||
queryTemplateKeys: ["scum.player.search"]
|
||||
@@ -98,22 +98,7 @@ const manifestDeclaration: GameClientBridgeManifestResponse = {
|
||||
}],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "operations", commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }],
|
||||
companion: {
|
||||
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
|
||||
}
|
||||
pages: [{ pageKey: "operations", commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }]
|
||||
};
|
||||
|
||||
const pluginBridgeProjection: Pick<GamePluginResponse, "gameClientBridge"> & Pick<MarketplacePluginResponse, "gameClientBridge"> = {
|
||||
@@ -124,7 +109,7 @@ describe("PlatformApiClient Game Client Bridge operator API", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("types plugin and marketplace manifest declarations", () => {
|
||||
expect(pluginBridgeProjection.gameClientBridge).toMatchObject({ commands: [{ type: "scum.diagnostic.ping" }], queryTemplates: [{ engine: "sqlite" }], companion: { tlsPolicy: "verify-system-roots", sessionMode: "component-session" } });
|
||||
expect(pluginBridgeProjection.gameClientBridge).toMatchObject({ commands: [{ type: "scum.diagnostic.ping" }], queryTemplates: [{ engine: "sqlite" }] });
|
||||
expect(JSON.stringify(pluginBridgeProjection)).not.toMatch(/authKey|componentKey|sessionToken|credential|secretRef/i);
|
||||
});
|
||||
|
||||
|
||||
+1
-194
@@ -79,22 +79,6 @@ export interface GameClientBridgePageContractResponse {
|
||||
|
||||
export interface GameClientBridgeFeatureDeclarationResponse { key: string; title: string; permission: string; requiredHandlers?: string[]; requiredEventProducers?: string[]; }
|
||||
|
||||
export interface GameClientBridgeCompanionDeclarationResponse {
|
||||
profileKey: string;
|
||||
configTemplateKey: string;
|
||||
configSchemaRef: string;
|
||||
configFormat: "yaml";
|
||||
platformBaseUrlSource: "run-control";
|
||||
registrationProof: "hmac-sha256";
|
||||
proofMaterialSource: "component-package";
|
||||
proofMaterialEnv: string;
|
||||
sessionMode: "component-session";
|
||||
tlsPolicy: "verify-system-roots";
|
||||
heartbeatIntervalSeconds: number;
|
||||
commandPollIntervalSeconds: number;
|
||||
requestTimeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeManifestResponse {
|
||||
commands: GameClientBridgeCommandDeclarationResponse[];
|
||||
snapshots: GameClientBridgeSnapshotDeclarationResponse[];
|
||||
@@ -104,7 +88,6 @@ export interface GameClientBridgeManifestResponse {
|
||||
maxCommands: number;
|
||||
pages?: GameClientBridgePageContractResponse[];
|
||||
features?: GameClientBridgeFeatureDeclarationResponse[];
|
||||
companion?: GameClientBridgeCompanionDeclarationResponse;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeProfileDeclarationResponse {
|
||||
@@ -262,7 +245,6 @@ export interface RuntimeLifecycleProfileResponse {
|
||||
capabilities: string[];
|
||||
actionRefs?: Record<string, string>;
|
||||
transportKeys?: string[];
|
||||
clientManagerRef?: string;
|
||||
dllExtensionRefs?: string[];
|
||||
platforms?: string[];
|
||||
}
|
||||
@@ -318,23 +300,6 @@ export interface RuntimeTransportProfileResponse {
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeClientManagerProfileResponse {
|
||||
key: string;
|
||||
displayName?: string;
|
||||
version?: string;
|
||||
revision?: string;
|
||||
repository: { url: string; revisionPolicy: string; branch?: string; tag?: string; revision?: string };
|
||||
supportedTargets: Array<{ os: string; arch: string }>;
|
||||
build: { system: string; workspaceRef?: string; entryRef?: string };
|
||||
configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>;
|
||||
outputArtifacts: string[];
|
||||
deployment?: { mode: string; executableRef: string; arguments: string[]; autoStart: boolean; requiredRunCapabilities: string[] };
|
||||
lifecycle?: { actions: string[]; startupTimeoutSeconds: number; stopTimeoutSeconds: number };
|
||||
health?: { mode: string; intervalSeconds: number; degradedAfterSeconds: number; offlineAfterSeconds: number; requiredCapabilities: string[] };
|
||||
compatibility?: { minimumVersion?: string; maximumVersion?: string; allowDowngrade: boolean };
|
||||
updatePolicy?: { strategy: string; requireApproval: boolean; healthConfirmationSeconds: number; retainPrevious: boolean };
|
||||
}
|
||||
|
||||
export interface RuntimeDLLExtensionProfileResponse {
|
||||
key: string;
|
||||
displayName: string;
|
||||
@@ -360,7 +325,6 @@ export interface GamePluginRuntimeProfilesResponse {
|
||||
serverDeployments?: RuntimeServerDeploymentProfileResponse[];
|
||||
logSources?: RuntimeLogSourceResponse[];
|
||||
transportProfiles?: RuntimeTransportProfileResponse[];
|
||||
clientManagers?: RuntimeClientManagerProfileResponse[];
|
||||
dllExtensions?: RuntimeDLLExtensionProfileResponse[];
|
||||
}
|
||||
|
||||
@@ -821,165 +785,8 @@ export interface RunUpdateJobListResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ClientManagerBuildRequest {
|
||||
profileKey: string;
|
||||
targetOs: string;
|
||||
targetArch: string;
|
||||
repositoryUrl: string;
|
||||
sourceRevision?: string;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export interface ClientManagerDistributionResponse {
|
||||
id: string;
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
profileKey: string;
|
||||
targetOs: string;
|
||||
targetArch: string;
|
||||
repositoryUrl: string;
|
||||
sourceRevision: string;
|
||||
buildJobId: string;
|
||||
artifactId: string;
|
||||
checksum: string;
|
||||
keyGeneration: number;
|
||||
secretRef: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ClientManagerDownloadRequest {
|
||||
profileKey?: string;
|
||||
}
|
||||
|
||||
export type ClientManagerLifecycleStatus =
|
||||
| "requested"
|
||||
| "building"
|
||||
| "available"
|
||||
| "deploying"
|
||||
| "installed"
|
||||
| "registering"
|
||||
| "online"
|
||||
| "degraded"
|
||||
| "offline"
|
||||
| "updating"
|
||||
| "rolling_back"
|
||||
| "stopping"
|
||||
| "uninstalled"
|
||||
| "failed";
|
||||
|
||||
export type ClientManagerLifecycleOperation = "deploy" | "start" | "stop" | "restart" | "status" | "update" | "rollback" | "uninstall";
|
||||
|
||||
export interface ClientManagerLifecycleActionResponse {
|
||||
operation: ClientManagerLifecycleOperation;
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ClientManagerLifecycleJobResponse {
|
||||
id: string;
|
||||
state: JobState;
|
||||
progress: JobProgressBody;
|
||||
attempt: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ClientManagerDistributionSummaryResponse {
|
||||
id: string;
|
||||
artifactId: string;
|
||||
sourceRevision: string;
|
||||
targetOs: string;
|
||||
targetArch: string;
|
||||
checksum: string;
|
||||
keyGeneration: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ClientManagerInstallationResponse {
|
||||
id: string;
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
profileKey: string;
|
||||
targetOs: string;
|
||||
targetArch: string;
|
||||
status: ClientManagerLifecycleStatus;
|
||||
phase: string;
|
||||
desiredVersion?: string;
|
||||
activeVersion?: string;
|
||||
previousVersion?: string;
|
||||
desiredRevision?: string;
|
||||
activeRevision?: string;
|
||||
previousRevision?: string;
|
||||
desiredArtifactId?: string;
|
||||
activeArtifactId?: string;
|
||||
previousArtifactId?: string;
|
||||
keyGeneration: number;
|
||||
deploymentGeneration: number;
|
||||
currentJobId?: string;
|
||||
lastSuccessfulJobId?: string;
|
||||
lastOperation?: ClientManagerLifecycleOperation;
|
||||
health: "unknown" | "healthy" | "degraded" | "unhealthy" | "offline";
|
||||
healthReason?: string;
|
||||
lastSeenAt?: string;
|
||||
retryable: boolean;
|
||||
requiresRedeploy: boolean;
|
||||
installedAt?: string;
|
||||
uninstalledAt?: string;
|
||||
updatedAt: string;
|
||||
distribution?: ClientManagerDistributionSummaryResponse;
|
||||
job?: ClientManagerLifecycleJobResponse;
|
||||
actions: ClientManagerLifecycleActionResponse[];
|
||||
}
|
||||
|
||||
export interface ClientManagerInstallationListResponse {
|
||||
items: ClientManagerInstallationResponse[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ClientManagerDeployRequest {
|
||||
profileKey: string;
|
||||
distributionId: string;
|
||||
expectedDeploymentGeneration?: number;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface ClientManagerControlRequest {
|
||||
profileKey: string;
|
||||
operation: "start" | "stop" | "restart" | "status" | "rollback";
|
||||
expectedDeploymentGeneration: number;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface ClientManagerUpdateRequest {
|
||||
profileKey: string;
|
||||
distributionId: string;
|
||||
expectedDeploymentGeneration: number;
|
||||
approved: boolean;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface ClientManagerRetryRequest {
|
||||
profileKey: string;
|
||||
expectedDeploymentGeneration: number;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface ClientManagerRevokeSessionRequest {
|
||||
profileKey: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ClientManagerUninstallRequest {
|
||||
profileKey: string;
|
||||
expectedDeploymentGeneration: number;
|
||||
confirmed: boolean;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface ComponentKeyResetRequest {
|
||||
componentKind: "run" | "client-manager" | string;
|
||||
componentKind: "run" | string;
|
||||
componentKey?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { Activity, Ban, KeyRound, PackageCheck, Play, RefreshCw, RotateCcw, ShieldAlert, Square, Trash2, UploadCloud } from "lucide-react";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { ClientManagerInstallationResponse, ClientManagerLifecycleOperation } from "../api/types";
|
||||
import type { CurrentUserView } from "../contracts/workspace";
|
||||
import type { OperationTracker } from "../stores/operations";
|
||||
import { cx } from "../utils/classes";
|
||||
import { ConfirmDialog } from "./OperationControls";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; items: ClientManagerInstallationResponse[] };
|
||||
|
||||
interface ClientManagerLifecyclePanelProps {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
session: CurrentUserView;
|
||||
operations: OperationTracker;
|
||||
}
|
||||
|
||||
interface PendingConfirmation {
|
||||
title: string;
|
||||
description: string;
|
||||
danger?: boolean;
|
||||
execute: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ClientManagerLifecyclePanel({ serverId, serverName, session, operations }: ClientManagerLifecyclePanelProps) {
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||
const [confirmation, setConfirmation] = useState<PendingConfirmation | null>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async (showLoading = false) => {
|
||||
if (showLoading) setState({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.listClientManagerLifecycles(serverId);
|
||||
setState({ status: "ready", items: response.items });
|
||||
} catch (error) {
|
||||
setState({ status: "error", reason: safeError(error, "Client Manager 状态加载失败") });
|
||||
}
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => { void refresh(true); }, [refresh]);
|
||||
|
||||
const hasActiveJob = state.status === "ready" && state.items.some((item) => item.job && ["queued", "accepted", "running", "retrying"].includes(item.job.state));
|
||||
useEffect(() => {
|
||||
if (!hasActiveJob) return undefined;
|
||||
const timer = window.setInterval(() => void refresh(), 2500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [hasActiveJob, refresh]);
|
||||
|
||||
async function runCommand(item: ClientManagerInstallationResponse, intent: string, execute: () => Promise<ClientManagerInstallationResponse>) {
|
||||
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${serverId}:client-manager:${item.profileKey}`, requester: session.displayName });
|
||||
setResult({ status: "pending", label: `${intent} 已提交,等待 Platform/Run 返回真实状态` });
|
||||
try {
|
||||
const next = await execute();
|
||||
setState((current) => current.status === "ready" ? { status: "ready", items: current.items.map((entry) => entry.id === next.id ? next : entry) } : current);
|
||||
const label = next.job ? `${intent} 已排队,job ${next.job.id}` : `${intent} 已完成状态更新`;
|
||||
operations.succeed(operationId, label);
|
||||
setResult({ status: "succeeded", label });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
const reason = safeError(error, `${intent} 失败`);
|
||||
operations.fail(operationId, reason, operationId);
|
||||
setResult({ status: "failed", label: reason });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmCommand(config: PendingConfirmation) {
|
||||
setConfirmation(config);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel client-manager-lifecycle-panel" aria-label="Client Manager 生命周期">
|
||||
<div className="panel-header">
|
||||
<h2><PackageCheck size={17} /> Client Manager 生命周期</h2>
|
||||
<div className="action-strip">
|
||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||
<button type="button" className="icon-command" title="刷新 Client Manager 状态" onClick={() => void refresh()}>
|
||||
<RefreshCw size={15} /><span>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && <LoadingState label="正在读取 Client Manager 部署与组件健康状态…" />}
|
||||
{state.status === "error" && <ErrorState title="Client Manager 状态不可用" reason={state.reason} diagnosticId={`client-manager:${serverId}`} onRetry={() => void refresh(true)} />}
|
||||
{state.status === "ready" && state.items.length === 0 && <EmptyState title="尚无 Client Manager 生命周期记录" description="先在运行分发区按插件声明构建 Client Manager;可用 artifact 会在这里进入部署闭环。" />}
|
||||
{state.status === "ready" && state.items.length > 0 && (
|
||||
<div className="client-manager-lifecycle-list">
|
||||
{state.items.map((item) => (
|
||||
<ClientManagerLifecycleRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
serverName={serverName}
|
||||
runCommand={runCommand}
|
||||
confirmCommand={confirmCommand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmation !== null}
|
||||
title={confirmation?.title ?? ""}
|
||||
description={confirmation?.description ?? ""}
|
||||
confirmLabel="确认执行"
|
||||
danger={confirmation?.danger}
|
||||
busy={confirmBusy}
|
||||
onCancel={() => setConfirmation(null)}
|
||||
onConfirm={() => {
|
||||
if (!confirmation) return;
|
||||
setConfirmBusy(true);
|
||||
void confirmation.execute().finally(() => {
|
||||
setConfirmBusy(false);
|
||||
setConfirmation(null);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientManagerLifecycleRowProps {
|
||||
item: ClientManagerInstallationResponse;
|
||||
serverName: string;
|
||||
runCommand: (item: ClientManagerInstallationResponse, intent: string, execute: () => Promise<ClientManagerInstallationResponse>) => Promise<void>;
|
||||
confirmCommand: (config: PendingConfirmation) => void;
|
||||
}
|
||||
|
||||
function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmCommand }: ClientManagerLifecycleRowProps) {
|
||||
const actionMap = useMemo(() => new Map(item.actions.map((action) => [action.operation, action])), [item.actions]);
|
||||
const available = (operation: ClientManagerLifecycleOperation) => actionMap.get(operation)?.available ?? false;
|
||||
const reason = (operation: ClientManagerLifecycleOperation) => actionMap.get(operation)?.reason ?? "Platform 当前状态不允许此操作";
|
||||
const distributionId = item.distribution?.id ?? "";
|
||||
const idempotency = (operation: string) => `client-manager.${operation}:${item.serverInstanceId}:${item.profileKey}:${Date.now()}`;
|
||||
const control = (operation: "start" | "stop" | "restart" | "status" | "rollback") =>
|
||||
platformApiClient.controlClientManager(item.serverInstanceId, { profileKey: item.profileKey, operation, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency(operation) });
|
||||
|
||||
const deploy = () => runCommand(item, item.requiresRedeploy ? "重新部署 Client Manager" : "部署 Client Manager", () => platformApiClient.deployClientManager(item.serverInstanceId, {
|
||||
profileKey: item.profileKey, distributionId, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("deploy")
|
||||
}));
|
||||
const update = () => runCommand(item, "更新 Client Manager", () => platformApiClient.updateClientManager(item.serverInstanceId, {
|
||||
profileKey: item.profileKey, distributionId, expectedDeploymentGeneration: item.deploymentGeneration, approved: true, idempotencyKey: idempotency("update")
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="client-manager-lifecycle-row" aria-label={`${item.profileKey} lifecycle`}>
|
||||
<div className="client-manager-lifecycle-head">
|
||||
<div>
|
||||
<strong>{item.profileKey}</strong>
|
||||
<span className="provider-id">{item.targetOs}/{item.targetArch} · deployment generation {item.deploymentGeneration} · key generation {item.keyGeneration}</span>
|
||||
</div>
|
||||
<div className="tag-list">
|
||||
<span className={cx("status-pill", lifecycleTone(item.status))}>{lifecycleLabel(item.status)}</span>
|
||||
<span className={cx("status-pill", healthTone(item.health))}><Activity size={12} /> {healthLabel(item.health)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="client-manager-version-grid">
|
||||
<VersionCell label="目标版本" version={item.desiredVersion} revision={item.desiredRevision} artifact={item.desiredArtifactId} />
|
||||
<VersionCell label="当前版本" version={item.activeVersion} revision={item.activeRevision} artifact={item.activeArtifactId} />
|
||||
<VersionCell label="回滚版本" version={item.previousVersion} revision={item.previousRevision} artifact={item.previousArtifactId} />
|
||||
<div className="client-manager-version-cell"><span>组件身份</span><strong>{item.lastSeenAt ? `最后心跳 ${formatTime(item.lastSeenAt)}` : "等待独立注册"}</strong><small>{item.healthReason || "未收到安全健康原因"}</small></div>
|
||||
</div>
|
||||
|
||||
<div className="client-manager-phase-line">
|
||||
<span><ShieldAlert size={14} /> {item.phase || "等待生命周期事件"}</span>
|
||||
{item.lastOperation && <span>最近操作 {item.lastOperation}</span>}
|
||||
{item.lastSuccessfulJobId && <span>最近成功 job {item.lastSuccessfulJobId}</span>}
|
||||
</div>
|
||||
|
||||
{item.job && (
|
||||
<div className="client-manager-job-progress" aria-label="Client Manager job progress">
|
||||
<div><span>job {item.job.id} · attempt {item.job.attempt} · {item.job.state}</span><strong>{item.job.progress.percent}%</strong></div>
|
||||
<progress max={100} value={item.job.progress.percent} />
|
||||
<small>{item.job.progress.message || "等待 Run 回报真实阶段"}</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(item.retryable || item.requiresRedeploy || item.status === "failed") && (
|
||||
<div className="client-manager-recovery">
|
||||
<ShieldAlert size={16} />
|
||||
<span>{item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 job 失败原因后选择重新部署、回滚或卸载。"}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="client-manager-command-grid">
|
||||
<LifecycleButton icon={<UploadCloud size={14} />} label={item.activeArtifactId ? "重新部署" : "部署"} disabled={!available("deploy") || !distributionId} reason={!distributionId ? "没有可用 distribution" : reason("deploy")} onClick={deploy} />
|
||||
<LifecycleButton icon={<Play size={14} />} label="启动" disabled={!available("start")} reason={reason("start")} onClick={() => void runCommand(item, "启动 Client Manager", () => control("start"))} />
|
||||
<LifecycleButton icon={<Square size={14} />} label="停止" disabled={!available("stop")} reason={reason("stop")} onClick={() => void runCommand(item, "停止 Client Manager", () => control("stop"))} />
|
||||
<LifecycleButton icon={<RefreshCw size={14} />} label="重启" disabled={!available("restart")} reason={reason("restart")} onClick={() => void runCommand(item, "重启 Client Manager", () => control("restart"))} />
|
||||
<LifecycleButton icon={<Activity size={14} />} label="检查状态" disabled={!available("status")} reason={reason("status")} onClick={() => void runCommand(item, "检查 Client Manager 状态", () => control("status"))} />
|
||||
<LifecycleButton icon={<UploadCloud size={14} />} label="更新" disabled={!available("update") || !distributionId} reason={!distributionId ? "没有兼容的可用 distribution" : reason("update")} onClick={() => confirmCommand({ title: "批准 Client Manager 更新", description: `将 ${serverName} 的 ${item.profileKey} 从 ${item.activeVersion || "未安装"} 更新到 ${item.desiredVersion || "目标版本"}。Run 将 staged activate、健康确认,并在失败时恢复 previous slot。`, execute: update })} />
|
||||
<LifecycleButton icon={<RotateCcw size={14} />} label="回滚" disabled={!available("rollback")} reason={reason("rollback")} onClick={() => confirmCommand({ title: "回滚 Client Manager", description: `确认将 ${item.profileKey} 回滚到 ${item.previousVersion || "previous slot"}?当前组件 session 将被撤销并需要重新注册。`, danger: true, execute: () => runCommand(item, "回滚 Client Manager", () => control("rollback")) })} />
|
||||
<LifecycleButton icon={<RefreshCw size={14} />} label="重试" disabled={!item.retryable} reason="当前失败不可重试" onClick={() => void runCommand(item, "重试 Client Manager", () => platformApiClient.retryClientManagerLifecycle(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("retry") }))} />
|
||||
<LifecycleButton icon={<Ban size={14} />} label="撤销会话" disabled={!item.activeArtifactId || item.status === "uninstalled"} reason="组件尚未安装" onClick={() => confirmCommand({ title: "撤销 Client Manager 会话", description: `撤销 ${item.profileKey} 的独立组件 session。Run session 与 job lease 不受影响,组件必须使用当前 key generation 重新注册。`, danger: true, execute: () => runCommand(item, "撤销 Client Manager 会话", () => platformApiClient.revokeClientManagerSession(item.serverInstanceId, { profileKey: item.profileKey, reason: "operator revoked component session" })) })} />
|
||||
<LifecycleButton icon={<KeyRound size={14} />} label="重置密钥" disabled={item.status === "uninstalled"} reason="已卸载" onClick={() => confirmCommand({ title: "重置 Client Manager 密钥", description: `重置 ${item.profileKey} 的 component key 会撤销旧 session/artifact generation。必须重新构建并重新部署,不会显示或导出原始密钥。`, danger: true, execute: async () => { await platformApiClient.resetClientManagerKey(item.serverInstanceId, { componentKind: "client-manager", componentKey: item.profileKey }); await runCommand(item, "刷新密钥重置状态", () => platformApiClient.getClientManagerLifecycle(item.serverInstanceId, item.profileKey)); } })} />
|
||||
<LifecycleButton icon={<Trash2 size={14} />} label="卸载" danger disabled={!available("uninstall")} reason={reason("uninstall")} onClick={() => confirmCommand({ title: "卸载 Client Manager", description: `确认停止并卸载 ${serverName} 的 ${item.profileKey}?Run 只会清理 Client Manager workspace,Platform 保留 build 与 artifact 记录。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionCell({ label, version, revision, artifact }: { label: string; version?: string; revision?: string; artifact?: string }) {
|
||||
return <div className="client-manager-version-cell"><span>{label}</span><strong>{version || "--"}</strong><small>{revision ? `revision ${shortRef(revision)}` : "revision --"}{artifact ? ` · artifact ${shortRef(artifact)}` : ""}</small></div>;
|
||||
}
|
||||
|
||||
function LifecycleButton({ icon, label, disabled, reason, danger, onClick }: { icon: ReactNode; label: string; disabled: boolean; reason: string; danger?: boolean; onClick: () => void }) {
|
||||
return <button type="button" className={cx("icon-command", danger && "danger-command")} disabled={disabled} title={disabled ? reason : label} onClick={onClick}>{icon}<span>{label}</span></button>;
|
||||
}
|
||||
|
||||
function lifecycleLabel(status: ClientManagerInstallationResponse["status"]): string {
|
||||
return ({ requested: "已请求", building: "构建中", available: "可部署", deploying: "部署中", installed: "已安装", registering: "等待注册", online: "在线", degraded: "降级", offline: "离线", updating: "更新中", rolling_back: "回滚中", stopping: "停止中", uninstalled: "已卸载", failed: "失败" })[status];
|
||||
}
|
||||
function lifecycleTone(status: ClientManagerInstallationResponse["status"]): string { return ["online", "installed"].includes(status) ? "status-active" : ["failed", "offline", "uninstalled"].includes(status) ? "status-disabled" : "status-pending"; }
|
||||
function healthLabel(health: ClientManagerInstallationResponse["health"]): string { return ({ unknown: "健康未知", healthy: "健康", degraded: "健康降级", unhealthy: "不健康", offline: "心跳离线" })[health]; }
|
||||
function healthTone(health: ClientManagerInstallationResponse["health"]): string { return health === "healthy" ? "status-active" : health === "unknown" || health === "degraded" ? "status-pending" : "status-disabled"; }
|
||||
function shortRef(value: string): string { return value.length > 18 ? `${value.slice(0, 18)}…` : value; }
|
||||
function formatTime(value: string): string { const time = new Date(value); return Number.isNaN(time.getTime()) ? "未知" : time.toLocaleString(); }
|
||||
function safeError(error: unknown, fallback: string): string { const message = error instanceof Error ? error.message : fallback; return message.replace(/Bearer\s+\S+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]").slice(0, 240); }
|
||||
@@ -21,7 +21,6 @@ Plugin page runs with safe platform context.
|
||||
- `run.distribution.request`: platform-mediated Run distribution request.
|
||||
- `dependencies.request`: declared dependency check or install request.
|
||||
- `logs.backfill.request`: bounded historical log backfill request.
|
||||
- `client-manager.request`: Client Manager lifecycle request through Platform.
|
||||
- `plugin-lifecycle.request`: declared plugin lifecycle request through Platform.
|
||||
- `ai.invoke`: platform-mediated AI invocation.
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export type PluginPermission =
|
||||
| "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"
|
||||
@@ -28,7 +27,6 @@ export type PluginBridgeAction =
|
||||
| "run.distribution.request"
|
||||
| "dependencies.request"
|
||||
| "logs.backfill.request"
|
||||
| "client-manager.request"
|
||||
| "plugin-lifecycle.request"
|
||||
| "ai.invoke";
|
||||
|
||||
@@ -44,7 +42,6 @@ const pluginPermissions: readonly PluginPermission[] = [
|
||||
"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",
|
||||
@@ -61,7 +58,6 @@ const pluginBridgeActions: readonly PluginBridgeAction[] = [
|
||||
"run.distribution.request",
|
||||
"dependencies.request",
|
||||
"logs.backfill.request",
|
||||
"client-manager.request",
|
||||
"plugin-lifecycle.request",
|
||||
"ai.invoke"
|
||||
];
|
||||
|
||||
@@ -24,6 +24,6 @@ Pages must use the shared black-mecha / magical-girl visual system from `../them
|
||||
- Do not introduce opaque white cards, heavy dark dashboards, stock marketing layouts, or single-page custom gradients that bypass the theme tokens.
|
||||
- Preserve text/icons for status and operation results; do not rely on color-only cues.
|
||||
- Read `../theme/README.md` before adding a new page surface pattern.
|
||||
# Server Detail lifecycle behavior
|
||||
# Server Detail operations behavior
|
||||
|
||||
The Server Detail Client Manager section is a real operations surface: it consumes safe Platform projections, polls only while a lifecycle job is active, shows current job attempts/progress, and provides recovery actions for retryable failure, stale key generation, failed update rollback, and offline health. Action buttons remain compact and disabled with the Platform-provided reason when declaration, permission, endpoint, artifact, target, or lifecycle state is not ready.
|
||||
The Server Detail workspace consumes safe Platform projections for lifecycle, plugin-owned pages, terminal/log streams, and run distribution actions. Action buttons remain compact and disabled with the Platform-provided reason when declaration, permission, endpoint, artifact, target, or lifecycle state is not ready.
|
||||
|
||||
@@ -23,6 +23,6 @@ Navigation entries are generated from the current user's capability set (`contra
|
||||
- Server creation opens inline from 服务器管理 (no separate `/servers/new` page).
|
||||
|
||||
Server and plugin detail flows must use routes, modals, or drawers. Do not build a fixed left-list/right-detail page.
|
||||
# Server Detail lifecycle route
|
||||
# Server Detail operations route
|
||||
|
||||
The `serverDetail` route owns the Client Manager workspace as an overview operations surface. It is a detail workflow, not a permanent split pane: list pages remain full-width and destructive lifecycle commands use shared confirmation dialogs. Route state does not contain component secrets or Run session material.
|
||||
The `serverDetail` route owns the server operations workspace: plugin-declared pages, lifecycle controls, AI assistance, and terminal/log drawers. It is a detail workflow, not a permanent split pane; list pages remain full-width and destructive lifecycle commands use shared confirmation dialogs. Route state does not contain component secrets or Run session material.
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseSafeClientManagerLifecycle, parseSafeClientManagerLifecycleList } from "./clientManagerLifecycle";
|
||||
|
||||
export const safeClientManagerLifecycleFixture = {
|
||||
id: "client-manager-installation-1",
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.scum",
|
||||
profileKey: "scum-client-manager",
|
||||
targetOs: "windows",
|
||||
targetArch: "amd64",
|
||||
status: "online",
|
||||
phase: "component heartbeat healthy",
|
||||
desiredVersion: "2.0.0",
|
||||
activeVersion: "2.0.0",
|
||||
previousVersion: "1.0.0",
|
||||
desiredRevision: "rev-2",
|
||||
activeRevision: "rev-2",
|
||||
previousRevision: "rev-1",
|
||||
desiredArtifactId: "artifact-2",
|
||||
activeArtifactId: "artifact-2",
|
||||
previousArtifactId: "artifact-1",
|
||||
keyGeneration: 3,
|
||||
deploymentGeneration: 4,
|
||||
currentJobId: "job-update-1",
|
||||
lastSuccessfulJobId: "job-deploy-1",
|
||||
lastOperation: "update",
|
||||
health: "healthy",
|
||||
healthReason: "component heartbeat healthy",
|
||||
lastSeenAt: "2026-07-18T08:00:00Z",
|
||||
retryable: false,
|
||||
requiresRedeploy: false,
|
||||
updatedAt: "2026-07-18T08:00:00Z",
|
||||
distribution: { id: "distribution-2", artifactId: "artifact-2", sourceRevision: "rev-2", targetOs: "windows", targetArch: "amd64", checksum: `sha256:${"a".repeat(64)}`, keyGeneration: 3, status: "available" },
|
||||
job: { id: "job-update-1", state: "running", progress: { percent: 65, message: "health confirmation" }, attempt: 1, createdAt: "2026-07-18T07:59:00Z", updatedAt: "2026-07-18T08:00:00Z" },
|
||||
actions: [
|
||||
{ operation: "start", available: false, reason: "already online" },
|
||||
{ operation: "stop", available: true },
|
||||
{ operation: "rollback", available: true }
|
||||
]
|
||||
} as const;
|
||||
|
||||
describe("Client Manager lifecycle schema", () => {
|
||||
it("preserves safe lifecycle, job progress, versions and action availability", () => {
|
||||
const parsed = parseSafeClientManagerLifecycle(safeClientManagerLifecycleFixture);
|
||||
expect(parsed).toMatchObject({ status: "online", health: "healthy", activeVersion: "2.0.0", previousVersion: "1.0.0", job: { state: "running", progress: { percent: 65 } } });
|
||||
expect(parseSafeClientManagerLifecycleList({ items: [safeClientManagerLifecycleFixture], count: 1 })).toMatchObject({ count: 1, items: [{ profileKey: "scum-client-manager" }] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ runEndpointId: "run-private" },
|
||||
{ pid: 4124 },
|
||||
{ secretRef: "redacted" },
|
||||
{ healthReason: "/Users/operator/client-manager" },
|
||||
{ healthReason: "unix://private.sock" }
|
||||
])("rejects machine and credential projection %#", (unsafe) => {
|
||||
expect(() => parseSafeClientManagerLifecycle({ ...safeClientManagerLifecycleFixture, ...unsafe })).toThrow(/forbidden|sensitive/i);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import type {
|
||||
ClientManagerInstallationListResponse,
|
||||
ClientManagerInstallationResponse,
|
||||
ClientManagerLifecycleActionResponse,
|
||||
ClientManagerLifecycleOperation,
|
||||
ClientManagerLifecycleStatus,
|
||||
JobState
|
||||
} from "../api/types";
|
||||
|
||||
const lifecycleStatuses = new Set<ClientManagerLifecycleStatus>([
|
||||
"requested", "building", "available", "deploying", "installed", "registering", "online", "degraded", "offline", "updating", "rolling_back", "stopping", "uninstalled", "failed"
|
||||
]);
|
||||
const lifecycleOperations = new Set<ClientManagerLifecycleOperation>(["deploy", "start", "stop", "restart", "status", "update", "rollback", "uninstall"]);
|
||||
const jobStates = new Set<JobState>(["queued", "accepted", "running", "retrying", "succeeded", "failed", "cancelled"]);
|
||||
const forbiddenKeys = new Set(["key", "token", "secretref", "secretvalue", "hostpath", "pid", "socket", "credential", "dsn", "password", "runendpointid"]);
|
||||
const forbiddenFragments = ["secret://", "/users/", "/var/run/", "bearer ", "password=", "unix://", "tcp://", "mysql://", "sqlite://", "rcon://"];
|
||||
|
||||
export function parseSafeClientManagerLifecycleList(value: unknown): ClientManagerInstallationListResponse {
|
||||
const record = object(value, "Client Manager lifecycle list");
|
||||
rejectSensitiveProjection(record);
|
||||
const items = array(record.items, "items").map(parseSafeClientManagerLifecycle);
|
||||
const count = number(record.count, "count");
|
||||
return { items, count };
|
||||
}
|
||||
|
||||
export function parseSafeClientManagerLifecycle(value: unknown): ClientManagerInstallationResponse {
|
||||
const record = object(value, "Client Manager lifecycle");
|
||||
rejectSensitiveProjection(record);
|
||||
const status = string(record.status, "status") as ClientManagerLifecycleStatus;
|
||||
if (!lifecycleStatuses.has(status)) throw new Error("Client Manager lifecycle status is invalid");
|
||||
const actions = array(record.actions, "actions").map(parseAction);
|
||||
const result: ClientManagerInstallationResponse = {
|
||||
id: string(record.id, "id"),
|
||||
serverInstanceId: string(record.serverInstanceId, "serverInstanceId"),
|
||||
pluginId: string(record.pluginId, "pluginId"),
|
||||
profileKey: string(record.profileKey, "profileKey"),
|
||||
targetOs: string(record.targetOs, "targetOs"),
|
||||
targetArch: string(record.targetArch, "targetArch"),
|
||||
status,
|
||||
phase: string(record.phase, "phase"),
|
||||
keyGeneration: number(record.keyGeneration, "keyGeneration"),
|
||||
deploymentGeneration: number(record.deploymentGeneration, "deploymentGeneration"),
|
||||
health: health(record.health),
|
||||
retryable: boolean(record.retryable, "retryable"),
|
||||
requiresRedeploy: boolean(record.requiresRedeploy, "requiresRedeploy"),
|
||||
updatedAt: string(record.updatedAt, "updatedAt"),
|
||||
actions
|
||||
};
|
||||
copyOptionalStrings(record, result, ["desiredVersion", "activeVersion", "previousVersion", "desiredRevision", "activeRevision", "previousRevision", "desiredArtifactId", "activeArtifactId", "previousArtifactId", "currentJobId", "lastSuccessfulJobId", "healthReason", "lastSeenAt", "installedAt", "uninstalledAt"]);
|
||||
if (record.lastOperation !== undefined) {
|
||||
const operation = string(record.lastOperation, "lastOperation") as ClientManagerLifecycleOperation;
|
||||
if (!lifecycleOperations.has(operation)) throw new Error("Client Manager lifecycle operation is invalid");
|
||||
result.lastOperation = operation;
|
||||
}
|
||||
if (record.distribution !== undefined) {
|
||||
const distribution = object(record.distribution, "distribution");
|
||||
result.distribution = {
|
||||
id: string(distribution.id, "distribution.id"), artifactId: string(distribution.artifactId, "distribution.artifactId"), sourceRevision: string(distribution.sourceRevision, "distribution.sourceRevision"),
|
||||
targetOs: string(distribution.targetOs, "distribution.targetOs"), targetArch: string(distribution.targetArch, "distribution.targetArch"), checksum: string(distribution.checksum, "distribution.checksum"),
|
||||
keyGeneration: number(distribution.keyGeneration, "distribution.keyGeneration"), status: string(distribution.status, "distribution.status")
|
||||
};
|
||||
}
|
||||
if (record.job !== undefined) {
|
||||
const job = object(record.job, "job");
|
||||
const state = string(job.state, "job.state") as JobState;
|
||||
if (!jobStates.has(state)) throw new Error("Client Manager job state is invalid");
|
||||
const progress = object(job.progress, "job.progress");
|
||||
result.job = { id: string(job.id, "job.id"), state, progress: { percent: number(progress.percent, "job.progress.percent"), message: optionalString(progress.message) }, attempt: number(job.attempt, "job.attempt"), createdAt: string(job.createdAt, "job.createdAt"), updatedAt: string(job.updatedAt, "job.updatedAt") };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseAction(value: unknown): ClientManagerLifecycleActionResponse {
|
||||
const action = object(value, "action");
|
||||
const operation = string(action.operation, "action.operation") as ClientManagerLifecycleOperation;
|
||||
if (!lifecycleOperations.has(operation)) throw new Error("Client Manager action is invalid");
|
||||
return { operation, available: boolean(action.available, "action.available"), reason: optionalString(action.reason) };
|
||||
}
|
||||
|
||||
function rejectSensitiveProjection(value: unknown, key = ""): void {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.toLowerCase();
|
||||
if (forbiddenFragments.some((fragment) => normalized.includes(fragment))) throw new Error("Client Manager response contains sensitive machine data");
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => rejectSensitiveProjection(item, key));
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const [childKey, child] of Object.entries(value)) {
|
||||
if (forbiddenKeys.has(childKey.toLowerCase())) throw new Error("Client Manager response contains a forbidden field");
|
||||
rejectSensitiveProjection(child, childKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyOptionalStrings(source: Record<string, unknown>, target: ClientManagerInstallationResponse, keys: Array<keyof ClientManagerInstallationResponse>) {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === "string" && value !== "") (target as unknown as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function array(value: unknown, label: string): unknown[] { if (!Array.isArray(value)) throw new Error(`${label} must be an array`); return value; }
|
||||
function string(value: unknown, label: string): string { if (typeof value !== "string" || value === "") throw new Error(`${label} must be a string`); return value; }
|
||||
function optionalString(value: unknown): string | undefined { return typeof value === "string" && value !== "" ? value : undefined; }
|
||||
function number(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a number`); return value; }
|
||||
function boolean(value: unknown, label: string): boolean { if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); return value; }
|
||||
function health(value: unknown): ClientManagerInstallationResponse["health"] {
|
||||
if (value === "unknown" || value === "healthy" || value === "degraded" || value === "unhealthy" || value === "offline") return value;
|
||||
throw new Error("Client Manager health is invalid");
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
ClientManagerBuildRequest,
|
||||
DependencyJobRequest,
|
||||
LogBackfillRequest,
|
||||
RunDistributionGenerateRequest,
|
||||
@@ -92,25 +91,6 @@ export function runUpdateRequest(serverInstanceId: string, artifactId: string, c
|
||||
};
|
||||
}
|
||||
|
||||
export function clientManagerBuildRequest(input: {
|
||||
serverInstanceId: string;
|
||||
profileKey: string;
|
||||
targetOs: string;
|
||||
targetArch: string;
|
||||
repositoryUrl: string;
|
||||
sourceRevision?: string;
|
||||
sequence?: number;
|
||||
}): ClientManagerBuildRequest {
|
||||
return {
|
||||
profileKey: input.profileKey.trim(),
|
||||
targetOs: input.targetOs,
|
||||
targetArch: input.targetArch,
|
||||
repositoryUrl: input.repositoryUrl.trim(),
|
||||
sourceRevision: input.sourceRevision?.trim() || undefined,
|
||||
idempotencyKey: runtimeIdempotencyKey("client-manager.generate", input.serverInstanceId, input.sequence ?? Date.now())
|
||||
};
|
||||
}
|
||||
|
||||
export function dependencyJobRequest(serverInstanceId: string, probeKey: string, installPlanKey = "", planDigest = "", sequence = Date.now()): DependencyJobRequest {
|
||||
return {
|
||||
probeKey: probeKey.trim(),
|
||||
|
||||
@@ -576,26 +576,6 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.plugin-control-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--jelly-highlight),var(--glass-wash),rgba(255,255,255,.24);box-shadow:inset 0 1px 0 var(--crystal-rim);min-width:0}
|
||||
.plugin-control-row>div{min-width:0}
|
||||
.plugin-control-row p{margin:2px 0 0;color:var(--ink-faint);font-size:12.5px}
|
||||
.client-manager-lifecycle-panel h2{display:inline-flex;align-items:center;gap:7px}
|
||||
.client-manager-lifecycle-list{display:grid;gap:12px;margin-top:12px}
|
||||
.client-manager-lifecycle-row{display:grid;gap:12px;padding:14px;border-block:1px solid color-mix(in srgb,var(--line-strong) 78%,transparent);background:color-mix(in srgb,var(--surface) 42%,transparent);box-shadow:inset 3px 0 0 color-mix(in srgb,var(--accent) 72%,transparent);min-width:0}
|
||||
.client-manager-job-progress>div,.client-manager-lifecycle-head,.client-manager-phase-line{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;min-width:0}
|
||||
.client-manager-lifecycle-head>div:first-child{display:grid;gap:4px;min-width:0}
|
||||
.client-manager-lifecycle-head strong{color:var(--ink);font-size:15px;overflow-wrap:anywhere}
|
||||
.client-manager-version-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
||||
.client-manager-version-cell{display:grid;align-content:center;gap:3px;min-height:72px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 80%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 70%,transparent)}
|
||||
.client-manager-version-cell small,.client-manager-version-cell span{color:var(--ink-soft);font-size:11px;overflow-wrap:anywhere}
|
||||
.client-manager-version-cell strong{color:var(--ink);font-size:14px;overflow-wrap:anywhere}
|
||||
.client-manager-phase-line{justify-content:flex-start;color:var(--ink-soft);font-size:12px}
|
||||
.client-manager-phase-line span:first-child{display:inline-flex;align-items:center;gap:5px;color:var(--ink);font-weight:700}
|
||||
.client-manager-job-progress{display:grid;gap:6px;padding:10px;border:1px solid color-mix(in srgb,var(--accent) 42%,var(--line));border-radius:6px;background:color-mix(in srgb,var(--accent-soft) 36%,transparent);color:var(--ink-soft);font-size:12px}
|
||||
.client-manager-job-progress progress{width:100%;height:8px;accent-color:var(--accent)}
|
||||
.client-manager-recovery{display:flex;align-items:flex-start;gap:8px;padding:10px;border-left:3px solid var(--gold);background:color-mix(in srgb,var(--gold-soft) 38%,transparent);color:var(--ink);font-size:12px;line-height:1.5}
|
||||
.client-manager-recovery svg{flex:0 0 auto;margin-top:1px}
|
||||
.client-manager-command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(112px,1fr));gap:7px}
|
||||
.client-manager-command-grid .icon-command{justify-content:center;min-width:0}
|
||||
@media (max-width:1080px){.client-manager-version-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
}
|
||||
.maintenance-triage-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
|
||||
.triage-card{min-height:120px;display:grid;align-content:start;gap:6px;padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--corner-sparkle),var(--surface);background-size:52px 52px,auto,auto,auto;background-position:right 8px top 6px,center,center,center;background-repeat:no-repeat;color:var(--ink-soft);cursor:pointer;text-align:left;box-shadow:inset 0 1px 0 var(--crystal-rim),var(--panel-shadow)}
|
||||
.triage-card svg{color:var(--accent-deep)}
|
||||
@@ -728,7 +708,6 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.catalog-grid,.console-grid,.metric-grid,.overview-two-col,.resource-list-item,.server-card-grid{grid-template-columns:1fr}
|
||||
.form-grid,.server-metrics,.server-workspace{grid-template-columns:1fr}
|
||||
.terminal-command-form{grid-template-columns:1fr}
|
||||
.client-manager-command-grid,.client-manager-version-grid{grid-template-columns:1fr}
|
||||
.server-card-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}
|
||||
.server-toolbar{align-items:stretch}
|
||||
|
||||
@@ -189,8 +189,6 @@ function requiredPermissions(action: PluginBridgeAction): PluginPermission[] {
|
||||
return ["server.dependencies.manage"];
|
||||
case "logs.backfill.request":
|
||||
return ["server.logs.read"];
|
||||
case "client-manager.request":
|
||||
return ["server.client-manager.manage"];
|
||||
case "plugin-lifecycle.request":
|
||||
return ["server.lifecycle"];
|
||||
case "ai.invoke":
|
||||
|
||||
+2
-7
@@ -48,7 +48,7 @@ Plugin pages may request these operations only through bridge helpers:
|
||||
- `createLogBackfillRequest`: request historical log cursors for declared sources.
|
||||
- `createProductionPluginLifecycleRequest`: request server-bound install/enable/disable/upgrade/rollback/retire/dependency-check through Platform operations.
|
||||
|
||||
Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, immutable reviewed dependency plan digests, and idempotency keys only. Dependency install bridge helpers require a `sha256:<64 hex>` reviewed plan digest; Platform re-resolves the declaration and rejects stale or missing approvals. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets.
|
||||
Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, immutable reviewed dependency plan digests, and idempotency keys only. Dependency install bridge helpers require a `sha256:<64 hex>` reviewed plan digest; Platform re-resolves the declaration and rejects stale or missing approvals. The plugin SDK and manifest validation reject raw run keys, component keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets.
|
||||
|
||||
Validated manifests are registered through the platform registry API rather than by plugin code importing platform internals. Platform persists the validated runtime-profile declaration with the installed plugin contract and repeats safety validation before a plugin becomes installable. Per-server values are stored separately as platform-owned runtime bindings; plugin pages receive only logical readiness and never the stored values.
|
||||
|
||||
@@ -71,9 +71,4 @@ npm run validate:manifest
|
||||
|
||||
Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, target-matched typed dependency plan requests, run distribution envelopes, typed dependency/log backfill requests, and plugin-owned typed SCUM RCON data flows. Marketplace package acquisition, private source credentials, public build-worker sandboxing, remote plugin hosting policies, production KMS/code signing/fleet rollout, and external package distribution remain future work.
|
||||
|
||||
Runtime-profile declarations do not provide a general secret vault, arbitrary machine execution, production code signing/KMS, or fleet orchestration. The durable Client Manager installation/session state, bounded scheduler, process supervisor, and isolated log/artifact/control channels are Platform/Run capabilities; plugins receive only declarations and safe status projections.
|
||||
# Client Manager profile contract
|
||||
|
||||
Plugins may declare a Client Manager profile with version/revision, supported targets, fixed relative executable, deployment mode, lifecycle capabilities, bounded startup/stop/health settings, compatibility constraints, and update policy. Platform enables lifecycle actions only after a real available distribution, complete server binding, an owned online Run endpoint, matching target/revision, and the current component-key generation.
|
||||
|
||||
Plugin pages and SDK bridge responses expose profile declarations, action availability, logical status/health, version/revision, job progress, and safe failure reasons only. They never receive component keys, sessions, secret refs/values, host paths, PIDs, sockets, credentials, DSNs, or direct Run endpoints. Arbitrary shell, raw credentials, and endpoint-bearing declarations are rejected during manifest validation.
|
||||
Runtime-profile declarations do not provide a general secret vault, arbitrary machine execution, production code signing/KMS, or fleet orchestration. The bounded scheduler, process supervisor, and isolated log/artifact/control channels are Platform/Run capabilities; plugins receive only declarations and safe status projections.
|
||||
|
||||
@@ -131,11 +131,6 @@
|
||||
"uniqueItems": true,
|
||||
"maxItems": 16
|
||||
},
|
||||
"clientManagers": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/runtimeClientManagerProfile" },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"dllExtensions": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/runtimeDLLExtensionProfile" },
|
||||
@@ -271,28 +266,7 @@
|
||||
"items": { "$ref": "#/$defs/gameClientBridgePageContract" },
|
||||
"maxItems": 64
|
||||
},
|
||||
"features": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeFeature" }, "maxItems": 128 },
|
||||
"companion": { "$ref": "#/$defs/gameClientBridgeCompanion" }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeCompanion": {
|
||||
"type": "object",
|
||||
"required": ["profileKey", "configTemplateKey", "configSchemaRef", "configFormat", "platformBaseUrlSource", "registrationProof", "proofMaterialSource", "proofMaterialEnv", "sessionMode", "tlsPolicy", "heartbeatIntervalSeconds", "commandPollIntervalSeconds", "requestTimeoutSeconds"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"profileKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"configTemplateKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"configSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"configFormat": { "const": "yaml" },
|
||||
"platformBaseUrlSource": { "const": "run-control" },
|
||||
"registrationProof": { "const": "hmac-sha256" },
|
||||
"proofMaterialSource": { "const": "component-package" },
|
||||
"proofMaterialEnv": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{2,63}$" },
|
||||
"sessionMode": { "const": "component-session" },
|
||||
"tlsPolicy": { "const": "verify-system-roots" },
|
||||
"heartbeatIntervalSeconds": { "type": "integer", "minimum": 5, "maximum": 300 },
|
||||
"commandPollIntervalSeconds": { "type": "integer", "minimum": 1, "maximum": 60 },
|
||||
"requestTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
|
||||
"features": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeFeature" }, "maxItems": 128 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeCommand": {
|
||||
@@ -463,11 +437,6 @@
|
||||
"remote.run.logs.transfer",
|
||||
"remote.run.rcon.command",
|
||||
"remote.run.program.command",
|
||||
"client-manager.deploy",
|
||||
"client-manager.control",
|
||||
"client-manager.update",
|
||||
"client-manager.rollback",
|
||||
"client-manager.uninstall",
|
||||
"artifacts.read",
|
||||
"artifacts.write",
|
||||
"ai.invoke"
|
||||
@@ -486,7 +455,6 @@
|
||||
"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",
|
||||
@@ -504,7 +472,6 @@
|
||||
"run.distribution.request",
|
||||
"dependencies.request",
|
||||
"logs.backfill.request",
|
||||
"client-manager.request",
|
||||
"plugin-lifecycle.request",
|
||||
"ai.invoke"
|
||||
]
|
||||
@@ -568,7 +535,6 @@
|
||||
}
|
||||
},
|
||||
"transportKeys": { "type": "array", "items": { "$ref": "#/$defs/logicalKey" }, "uniqueItems": true },
|
||||
"clientManagerRef": { "$ref": "#/$defs/logicalKey" },
|
||||
"dllExtensionRefs": { "type": "array", "items": { "$ref": "#/$defs/logicalKey" }, "uniqueItems": true, "maxItems": 16 },
|
||||
"platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true }
|
||||
}
|
||||
@@ -702,7 +668,7 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/logicalKey" },
|
||||
"kind": { "enum": ["process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager"] },
|
||||
"kind": { "enum": ["process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query"] },
|
||||
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"streamKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"cursorKind": { "enum": ["sequence", "offset", "fingerprint", "ftp-listing", "sql-cursor"] },
|
||||
@@ -741,138 +707,6 @@
|
||||
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+$",
|
||||
"maxLength": 160
|
||||
},
|
||||
"clientManagerComponentCapability": {
|
||||
"enum": [
|
||||
"component.register",
|
||||
"component.heartbeat",
|
||||
"component.health",
|
||||
"component.control",
|
||||
"game-client.bridge",
|
||||
"logs.stream"
|
||||
]
|
||||
},
|
||||
"clientManagerLifecycleAction": {
|
||||
"enum": ["start", "stop", "restart", "status", "update", "rollback", "uninstall"]
|
||||
},
|
||||
"clientManagerArgument": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_./:=@+-]+$",
|
||||
"maxLength": 120
|
||||
},
|
||||
"runtimeClientManagerProfile": {
|
||||
"type": "object",
|
||||
"required": ["key", "repository", "supportedTargets", "build", "outputArtifacts"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/logicalKey" },
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", "maxLength": 40 },
|
||||
"repository": {
|
||||
"type": "object",
|
||||
"required": ["url", "revisionPolicy"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"url": { "type": "string", "pattern": "^https://[a-zA-Z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+\\.git$", "maxLength": 240 },
|
||||
"branch": { "type": "string", "pattern": "^[a-zA-Z0-9._/-]+$", "maxLength": 120 },
|
||||
"tag": { "type": "string", "pattern": "^[a-zA-Z0-9._/-]+$", "maxLength": 120 },
|
||||
"revision": { "type": "string", "pattern": "^[a-fA-F0-9]{7,64}$" },
|
||||
"revisionPolicy": { "enum": ["pinned", "branch", "tag"] }
|
||||
}
|
||||
},
|
||||
"supportedTargets": { "type": "array", "items": { "$ref": "#/$defs/runtimeTarget" }, "minItems": 1, "uniqueItems": true },
|
||||
"build": {
|
||||
"type": "object",
|
||||
"required": ["system"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"system": { "enum": ["go", "npm", "cargo", "make"] },
|
||||
"workspaceRef": { "$ref": "#/$defs/relativePathRef" },
|
||||
"entryRef": { "$ref": "#/$defs/relativePathRef" }
|
||||
}
|
||||
},
|
||||
"configTemplates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["key", "templateRef", "outputRef"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/logicalKey" },
|
||||
"templateRef": { "$ref": "#/$defs/relativePathRef" },
|
||||
"outputRef": { "$ref": "#/$defs/relativePathRef" }
|
||||
}
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"outputArtifacts": { "type": "array", "items": { "$ref": "#/$defs/relativePathRef" }, "minItems": 1, "uniqueItems": true },
|
||||
"deployment": {
|
||||
"type": "object",
|
||||
"required": ["mode", "executableRef", "requiredRunCapabilities"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": { "const": "run-supervised" },
|
||||
"executableRef": { "$ref": "#/$defs/relativePathRef" },
|
||||
"arguments": { "type": "array", "items": { "$ref": "#/$defs/clientManagerArgument" }, "maxItems": 32 },
|
||||
"autoStart": { "type": "boolean" },
|
||||
"requiredRunCapabilities": {
|
||||
"type": "array",
|
||||
"items": { "enum": ["client-manager.deploy", "client-manager.control", "client-manager.update", "client-manager.rollback", "client-manager.uninstall"] },
|
||||
"uniqueItems": true,
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"lifecycle": {
|
||||
"type": "object",
|
||||
"required": ["actions", "startupTimeoutSeconds", "stopTimeoutSeconds"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"actions": { "type": "array", "items": { "$ref": "#/$defs/clientManagerLifecycleAction" }, "uniqueItems": true, "minItems": 1 },
|
||||
"startupTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 300 },
|
||||
"stopTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 120 }
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"type": "object",
|
||||
"required": ["mode", "intervalSeconds", "degradedAfterSeconds", "offlineAfterSeconds", "requiredCapabilities"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": { "enum": ["component-heartbeat", "process"] },
|
||||
"intervalSeconds": { "type": "integer", "minimum": 5, "maximum": 300 },
|
||||
"degradedAfterSeconds": { "type": "integer", "minimum": 10, "maximum": 1800 },
|
||||
"offlineAfterSeconds": { "type": "integer", "minimum": 15, "maximum": 3600 },
|
||||
"requiredCapabilities": { "type": "array", "items": { "$ref": "#/$defs/clientManagerComponentCapability" }, "uniqueItems": true, "minItems": 1 }
|
||||
}
|
||||
},
|
||||
"compatibility": {
|
||||
"type": "object",
|
||||
"required": ["allowDowngrade"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"minimumVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", "maxLength": 40 },
|
||||
"maximumVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", "maxLength": 40 },
|
||||
"allowDowngrade": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"updatePolicy": {
|
||||
"type": "object",
|
||||
"required": ["strategy", "requireApproval", "healthConfirmationSeconds", "retainPrevious"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"strategy": { "const": "manual-staged" },
|
||||
"requireApproval": { "const": true },
|
||||
"healthConfirmationSeconds": { "type": "integer", "minimum": 5, "maximum": 600 },
|
||||
"retainPrevious": { "const": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "required": ["deployment"] },
|
||||
"then": { "required": ["version", "lifecycle", "health", "updatePolicy"] }
|
||||
}
|
||||
]
|
||||
},
|
||||
"runtimeDLLExtensionProfile": {
|
||||
"type": "object",
|
||||
"required": ["key", "displayName", "kind", "activation", "version", "releaseState", "targetKey", "modKey", "dllRef", "supportedTargets", "updateOnStart", "rconPort"],
|
||||
|
||||
@@ -412,102 +412,24 @@ function validateServerDeploymentProfiles(manifest: unknown): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateClientManagerProfiles(manifest: unknown): string[] {
|
||||
function validateUnsupportedLegacyClientManagerDeclarations(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
type ClientManagerProfile = {
|
||||
key?: string;
|
||||
version?: string;
|
||||
repository?: { revisionPolicy?: string; branch?: string; tag?: string; revision?: string };
|
||||
outputArtifacts?: string[];
|
||||
deployment?: { executableRef?: string; requiredRunCapabilities?: string[] };
|
||||
lifecycle?: { actions?: string[]; startupTimeoutSeconds?: number; stopTimeoutSeconds?: number };
|
||||
health?: { mode?: string; intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number; requiredCapabilities?: string[] };
|
||||
compatibility?: { minimumVersion?: string; maximumVersion?: string };
|
||||
updatePolicy?: { healthConfirmationSeconds?: number };
|
||||
const declaration = manifest as {
|
||||
runtimeProfiles?: { clientManagers?: unknown[]; lifecycleProfiles?: Array<{ clientManagerRef?: unknown }> };
|
||||
gameClientBridge?: { companion?: unknown };
|
||||
};
|
||||
const profiles = (manifest as { runtimeProfiles?: { clientManagers?: ClientManagerProfile[] } }).runtimeProfiles?.clientManagers ?? [];
|
||||
const errors: string[] = [];
|
||||
const parseVersion = (value: string | undefined): number[] | undefined => {
|
||||
const match = value?.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/);
|
||||
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
|
||||
};
|
||||
const compareVersions = (left: number[], right: number[]): number => {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return left[index] - right[index];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
for (const [profileIndex, profile] of profiles.entries()) {
|
||||
const location = `manifest.runtimeProfiles.clientManagers[${profileIndex}]`;
|
||||
const policy = profile.repository?.revisionPolicy;
|
||||
if (policy === "pinned" && !profile.repository?.revision) {
|
||||
errors.push(`${location}.repository.revision: required for pinned revision policy`);
|
||||
}
|
||||
if (policy === "branch" && !profile.repository?.branch) {
|
||||
errors.push(`${location}.repository.branch: required for branch revision policy`);
|
||||
}
|
||||
if (policy === "tag" && !profile.repository?.tag) {
|
||||
errors.push(`${location}.repository.tag: required for tag revision policy`);
|
||||
}
|
||||
if (!profile.deployment) {
|
||||
continue;
|
||||
}
|
||||
if (!profile.version || !parseVersion(profile.version)) {
|
||||
errors.push(`${location}.version: lifecycle deployment requires a semantic version`);
|
||||
}
|
||||
if (!profile.outputArtifacts?.includes(profile.deployment.executableRef ?? "")) {
|
||||
errors.push(`${location}.deployment.executableRef: must name one declared output artifact`);
|
||||
}
|
||||
const actions = new Set(profile.lifecycle?.actions ?? []);
|
||||
const runCapabilities = new Set(profile.deployment.requiredRunCapabilities ?? []);
|
||||
if (!runCapabilities.has("client-manager.deploy")) {
|
||||
errors.push(`${location}.deployment.requiredRunCapabilities: client-manager.deploy is required`);
|
||||
}
|
||||
if (["start", "stop", "restart", "status"].some((action) => actions.has(action)) && !runCapabilities.has("client-manager.control")) {
|
||||
errors.push(`${location}.deployment.requiredRunCapabilities: lifecycle control actions require client-manager.control`);
|
||||
}
|
||||
if (actions.has("update") && !runCapabilities.has("client-manager.update")) {
|
||||
errors.push(`${location}.deployment.requiredRunCapabilities: update requires client-manager.update`);
|
||||
}
|
||||
if (actions.has("rollback") && !runCapabilities.has("client-manager.rollback")) {
|
||||
errors.push(`${location}.deployment.requiredRunCapabilities: rollback requires client-manager.rollback`);
|
||||
}
|
||||
if (actions.has("uninstall") && !runCapabilities.has("client-manager.uninstall")) {
|
||||
errors.push(`${location}.deployment.requiredRunCapabilities: uninstall requires client-manager.uninstall`);
|
||||
}
|
||||
const interval = profile.health?.intervalSeconds ?? 0;
|
||||
const degraded = profile.health?.degradedAfterSeconds ?? 0;
|
||||
const offline = profile.health?.offlineAfterSeconds ?? 0;
|
||||
if (degraded < interval * 2 || offline <= degraded) {
|
||||
errors.push(`${location}.health: degraded threshold must allow two heartbeats and offline threshold must be later`);
|
||||
}
|
||||
if (profile.health?.mode === "component-heartbeat") {
|
||||
const required = new Set(profile.health.requiredCapabilities ?? []);
|
||||
for (const capability of ["component.register", "component.heartbeat", "component.health"]) {
|
||||
if (!required.has(capability)) {
|
||||
errors.push(`${location}.health.requiredCapabilities: ${capability} is required for component-heartbeat mode`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const version = parseVersion(profile.version);
|
||||
const minimum = parseVersion(profile.compatibility?.minimumVersion);
|
||||
const maximum = parseVersion(profile.compatibility?.maximumVersion);
|
||||
if (minimum && maximum && compareVersions(minimum, maximum) > 0) {
|
||||
errors.push(`${location}.compatibility: minimumVersion must not exceed maximumVersion`);
|
||||
}
|
||||
if (version && minimum && compareVersions(version, minimum) < 0) {
|
||||
errors.push(`${location}.compatibility: profile version is below minimumVersion`);
|
||||
}
|
||||
if (version && maximum && compareVersions(version, maximum) > 0) {
|
||||
errors.push(`${location}.compatibility: profile version exceeds maximumVersion`);
|
||||
}
|
||||
if ((profile.updatePolicy?.healthConfirmationSeconds ?? 0) < interval) {
|
||||
errors.push(`${location}.updatePolicy.healthConfirmationSeconds: must cover at least one health interval`);
|
||||
if (declaration.runtimeProfiles?.clientManagers !== undefined) {
|
||||
errors.push("manifest.runtimeProfiles.clientManagers: client-manager profiles are no longer supported");
|
||||
}
|
||||
if (declaration.gameClientBridge?.companion !== undefined) {
|
||||
errors.push("manifest.gameClientBridge.companion: client-manager companion declarations are no longer supported");
|
||||
}
|
||||
for (const [index, profile] of (declaration.runtimeProfiles?.lifecycleProfiles ?? []).entries()) {
|
||||
if (profile.clientManagerRef !== undefined) {
|
||||
errors.push(`manifest.runtimeProfiles.lifecycleProfiles[${index}].clientManagerRef: client-manager references are no longer supported`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
@@ -626,29 +548,15 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
pollIntervalSeconds?: number;
|
||||
};
|
||||
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] };
|
||||
type BridgeCompanion = {
|
||||
profileKey?: string;
|
||||
configTemplateKey?: string;
|
||||
configSchemaRef?: string;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
commandPollIntervalSeconds?: number;
|
||||
requestTimeoutSeconds?: number;
|
||||
registrationProof?: string;
|
||||
proofMaterialSource?: string;
|
||||
proofMaterialEnv?: string;
|
||||
sessionMode?: string;
|
||||
tlsPolicy?: string;
|
||||
};
|
||||
type PluginPage = { key?: string; permissions?: string[]; bridgeActions?: string[] };
|
||||
type RuntimeTransportProfile = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] };
|
||||
type RuntimeClientManager = { key?: string; configTemplates?: Array<{ key?: string; outputRef?: string }>; health?: { intervalSeconds?: number; requiredCapabilities?: string[] } };
|
||||
const declaration = manifest as {
|
||||
capabilities?: string[];
|
||||
permissions?: string[];
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
pages?: PluginPage[];
|
||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[] };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[] };
|
||||
};
|
||||
const bridge = declaration.gameClientBridge;
|
||||
if (!bridge) {
|
||||
@@ -663,45 +571,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
|
||||
const remoteDatabaseEngines = new Set(declaration.remoteAccess?.databaseEngines ?? []);
|
||||
const transportProfiles = declaration.runtimeProfiles?.transportProfiles ?? [];
|
||||
const companion = bridge.companion;
|
||||
if (companion) {
|
||||
const location = "manifest.gameClientBridge.companion";
|
||||
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === companion.profileKey);
|
||||
if (!manager) {
|
||||
errors.push(`${location}.profileKey: must reference a declared Client Manager profile`);
|
||||
} else {
|
||||
const template = manager.configTemplates?.find((candidate) => candidate.key === companion.configTemplateKey);
|
||||
if (!template) {
|
||||
errors.push(`${location}.configTemplateKey: must reference the Client Manager profile`);
|
||||
} else if (template.outputRef !== "config.yaml") {
|
||||
errors.push(`${location}.configTemplateKey: config template must materialize config.yaml`);
|
||||
}
|
||||
if (manager.health?.intervalSeconds !== companion.heartbeatIntervalSeconds) {
|
||||
errors.push(`${location}.heartbeatIntervalSeconds: must match the Client Manager health interval`);
|
||||
}
|
||||
for (const capability of ["component.register", "component.heartbeat", "component.health", "game-client.bridge"]) {
|
||||
if (!manager.health?.requiredCapabilities?.includes(capability)) {
|
||||
errors.push(`${location}: Client Manager health must require ${capability}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!companion.configSchemaRef || !isSafeRelativeJsonRef(companion.configSchemaRef)) {
|
||||
errors.push(`${location}.configSchemaRef: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
if (companion.registrationProof !== "hmac-sha256" || companion.proofMaterialSource !== "component-package" || companion.sessionMode !== "component-session" || companion.tlsPolicy !== "verify-system-roots") {
|
||||
errors.push(`${location}: secure component registration/session/TLS policy is required`);
|
||||
}
|
||||
const reservedProofEnvironments = new Set(["COMSPEC", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "HOME", "LD_LIBRARY_PATH", "LD_PRELOAD", "PATH", "PATHEXT", "SHELL", "SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "WINDIR"]);
|
||||
if (!/^[A-Z][A-Z0-9_]{2,63}$/.test(companion.proofMaterialEnv ?? "") || reservedProofEnvironments.has(companion.proofMaterialEnv ?? "")) {
|
||||
errors.push(`${location}.proofMaterialEnv: must be a bounded environment variable name`);
|
||||
}
|
||||
if (!Number.isInteger(companion.commandPollIntervalSeconds) || (companion.commandPollIntervalSeconds ?? 0) < 1 || (companion.commandPollIntervalSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.commandPollIntervalSeconds: must be between 1 and 60`);
|
||||
}
|
||||
if (!Number.isInteger(companion.requestTimeoutSeconds) || (companion.requestTimeoutSeconds ?? 0) < 1 || (companion.requestTimeoutSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.requestTimeoutSeconds: must be between 1 and 60`);
|
||||
}
|
||||
}
|
||||
for (const [index, command] of (bridge.commands ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.commands[${index}]`;
|
||||
const type = command.type ?? "";
|
||||
@@ -927,160 +796,6 @@ function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: str
|
||||
return errors;
|
||||
}
|
||||
|
||||
type CompanionConfigDeclaration = {
|
||||
profileKey?: string;
|
||||
configSchemaRef?: string;
|
||||
registrationProof?: string;
|
||||
proofMaterialEnv?: string;
|
||||
sessionMode?: string;
|
||||
tlsPolicy?: string;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
commandPollIntervalSeconds?: number;
|
||||
requestTimeoutSeconds?: number;
|
||||
};
|
||||
|
||||
type CompanionRuntimeProfile = {
|
||||
key?: string;
|
||||
version?: string;
|
||||
supportedTargets?: Array<{ os?: string; arch?: string }>;
|
||||
health?: { requiredCapabilities?: string[] };
|
||||
};
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function sameStringSet(left: unknown, right: string[]): boolean {
|
||||
if (!Array.isArray(left) || !left.every((item) => typeof item === "string") || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
const leftSet = new Set(left);
|
||||
const rightSet = new Set(right);
|
||||
return leftSet.size === left.length && rightSet.size === right.length && [...leftSet].every((item) => rightSet.has(item));
|
||||
}
|
||||
|
||||
function isSafeHTTPSBaseURL(value: unknown): boolean {
|
||||
if (typeof value !== "string" || value.length === 0 || /\s/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" && parsed.hostname !== "" && parsed.username === "" && parsed.password === "" && parsed.search === "" && parsed.hash === "" && (parsed.pathname === "" || parsed.pathname === "/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateGeneratedCompanionConfig(manifest: unknown, companion: CompanionConfigDeclaration, example: unknown, location: string): string[] {
|
||||
const declaration = manifest as {
|
||||
id?: string;
|
||||
runtimeProfiles?: { clientManagers?: CompanionRuntimeProfile[] };
|
||||
};
|
||||
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === companion.profileKey);
|
||||
const root = recordValue(example);
|
||||
const platform = recordValue(root?.platform);
|
||||
const component = recordValue(root?.component);
|
||||
const proof = recordValue(root?.proof);
|
||||
const session = recordValue(root?.session);
|
||||
const timing = recordValue(root?.timing);
|
||||
const tls = recordValue(root?.tls);
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isSafeHTTPSBaseURL(platform?.baseUrl)) {
|
||||
errors.push(`${location}.example.platform.baseUrl: must be an HTTPS origin without userinfo, path, query, or fragment`);
|
||||
}
|
||||
if (component?.pluginId !== declaration.id) {
|
||||
errors.push(`${location}.example.component.pluginId: must match the plugin manifest id`);
|
||||
}
|
||||
if (component?.profileKey !== companion.profileKey) {
|
||||
errors.push(`${location}.example.component.profileKey: must match the companion profileKey`);
|
||||
}
|
||||
if (manager?.version && component?.version !== manager.version) {
|
||||
errors.push(`${location}.example.component.version: must match the Client Manager profile version`);
|
||||
}
|
||||
if (manager && !manager.supportedTargets?.some((target) => target.os === component?.targetOs && target.arch === component?.targetArch)) {
|
||||
errors.push(`${location}.example.component: targetOs and targetArch must match a supported Client Manager target`);
|
||||
}
|
||||
if (proof?.mode !== companion.registrationProof) {
|
||||
errors.push(`${location}.example.proof.mode: must match the companion registrationProof`);
|
||||
}
|
||||
if (proof?.materialEnv !== companion.proofMaterialEnv) {
|
||||
errors.push(`${location}.example.proof.materialEnv: must match the companion proofMaterialEnv`);
|
||||
}
|
||||
if (session?.mode !== companion.sessionMode) {
|
||||
errors.push(`${location}.example.session.mode: must match the companion sessionMode`);
|
||||
}
|
||||
if (tls?.policy !== companion.tlsPolicy) {
|
||||
errors.push(`${location}.example.tls.policy: must match the companion tlsPolicy`);
|
||||
}
|
||||
for (const [field, expected] of [
|
||||
["heartbeatIntervalSeconds", companion.heartbeatIntervalSeconds],
|
||||
["commandPollIntervalSeconds", companion.commandPollIntervalSeconds],
|
||||
["requestTimeoutSeconds", companion.requestTimeoutSeconds]
|
||||
] as const) {
|
||||
if (timing?.[field] !== expected) {
|
||||
errors.push(`${location}.example.timing.${field}: must match the companion declaration`);
|
||||
}
|
||||
}
|
||||
if (!manager || !sameStringSet(root?.capabilities, manager.health?.requiredCapabilities ?? [])) {
|
||||
errors.push(`${location}.example.capabilities: must exactly match the Client Manager requiredCapabilities`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateGameClientBridgeCompanionConfig(manifest: unknown, manifestDir: string): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
const companion = (manifest as { gameClientBridge?: { companion?: CompanionConfigDeclaration } }).gameClientBridge?.companion;
|
||||
if (!companion) {
|
||||
return [];
|
||||
}
|
||||
const location = "manifest.gameClientBridge.companion.configSchemaRef";
|
||||
const ref = companion.configSchemaRef ?? "";
|
||||
if (!isSafeRelativeJsonRef(ref)) {
|
||||
return [`${location}: raw host paths and unsafe schema references are not allowed`];
|
||||
}
|
||||
const schemaPath = path.resolve(manifestDir, ref);
|
||||
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
||||
return [`${location}: missing companion config schema file ${ref}`];
|
||||
}
|
||||
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
||||
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
||||
return [`${location}: companion config schema must remain inside the plugin manifest directory`];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
try {
|
||||
const schema = readJson(schemaPath) as AnySchema;
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
||||
if (!ajv.validateSchema(schema)) {
|
||||
errors.push(...formatErrors(`${location}.schema`, ajv.errors));
|
||||
return errors;
|
||||
}
|
||||
errors.push(...scanUnsafeBridgeSchema(schema, `${location}.schema`));
|
||||
errors.push(...validateBoundedBridgeSchema(schema, `${location}.schema`));
|
||||
const validate = ajv.compile(schema);
|
||||
const examplePath = schemaPath.replace(/\.schema\.json$/, ".generated.example.json");
|
||||
if (!fs.existsSync(examplePath)) {
|
||||
errors.push(`${location}: missing generated companion config example`);
|
||||
return errors;
|
||||
}
|
||||
const example = readJson(examplePath);
|
||||
if (!validate(example)) {
|
||||
errors.push(...formatErrors(`${location}.example`, validate.errors));
|
||||
}
|
||||
errors.push(...scanUnsafeValues(example, `${location}.example`));
|
||||
errors.push(...validateGeneratedCompanionConfig(manifest, companion, example, location));
|
||||
const serialized = `${JSON.stringify(schema)}\n${JSON.stringify(example)}`;
|
||||
if (/\/api\/v1\/scum-clients\//i.test(serialized) || /InsecureSkipVerify/i.test(serialized) || /"(?:authKey|componentKey|credential|password|sessionToken|secret)"\s*:/i.test(serialized)) {
|
||||
errors.push(`${location}: companion config must not contain legacy endpoints, insecure TLS, or inline proof/session material`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${location}: companion config schema or example is invalid: ${error instanceof Error ? error.message : "invalid JSON"}`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateManifestFile(manifestPath: string): string[] {
|
||||
const absoluteManifestPath = path.resolve(rootDir, manifestPath);
|
||||
const manifest = readJson(absoluteManifestPath);
|
||||
@@ -1099,11 +814,10 @@ export function validateManifestFile(manifestPath: string): string[] {
|
||||
errors.push(...validateCreateFieldDeclarations(manifest));
|
||||
errors.push(...validateDependencyPlans(manifest));
|
||||
errors.push(...validateServerDeploymentProfiles(manifest));
|
||||
errors.push(...validateClientManagerProfiles(manifest));
|
||||
errors.push(...validateUnsupportedLegacyClientManagerDeclarations(manifest));
|
||||
errors.push(...validateDLLExtensionProfiles(manifest));
|
||||
errors.push(...validateGameClientBridgeCatalog(manifest));
|
||||
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
|
||||
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
|
||||
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
|
||||
errors.push(...assetValidation.errors);
|
||||
errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared));
|
||||
|
||||
@@ -13,11 +13,10 @@ Plugins use the platform bridge for every privileged action.
|
||||
- `run.distribution.request`: request platform-mediated run package generation, download, key reset, or self-update orchestration.
|
||||
- `dependencies.request`: request typed dependency checks or approved install plans declared by the plugin runtime profile.
|
||||
- `logs.backfill.request`: request historical log backfill for a declared log source.
|
||||
- `client-manager.request`: request generation/download/key reset or a typed status, deploy, start, stop, restart, update, rollback, session-revoke, retry, or uninstall operation for a plugin-declared companion client manager.
|
||||
- `plugin-lifecycle.request`: request a server-bound install, enable, disable, upgrade, rollback, retire, or dependency-check operation through Platform capacity and compatibility gates.
|
||||
- `ai.invoke`: request platform-mediated AI assistance.
|
||||
- `theme.tokens`: read safe platform theme tokens.
|
||||
- `game-client-bridge`: query declared companion health and snapshots, and queue or cancel only manifest-declared typed commands through Platform.
|
||||
- `game-client-bridge`: query declared bridge snapshots, and queue or cancel only manifest-declared typed commands through Platform.
|
||||
|
||||
## Execution Envelopes
|
||||
|
||||
@@ -33,9 +32,7 @@ Remote access requests use `createRemoteAccessRequest` with a plugin-declared `r
|
||||
|
||||
SQLite reads use manifest-declared `gameClientBridge.queryTemplates`. Plugin pages send only a declared template key plus typed inputs; Platform verifies the page contract, permission, SQLite transport/target, timeout, and row limit before dispatch. Query declarations and browser envelopes never contain SQL text, DSNs, credentials, sockets, or host paths.
|
||||
|
||||
Run distribution, dependency, log backfill, and client-manager requests use `createRunDistributionRequest`, `createDependencyActionRequest`, `createLogBackfillRequest`, and `createClientManagerRequest`. Client-manager lifecycle envelopes carry only operation names, logical profile/installation IDs, target OS/architecture, artifact IDs, expected deployment generations, and idempotency keys. `parseClientManagerLifecycleStatus` whitelists safe state, version, health, job, artifact, and action fields. Raw run/client-manager keys, component sessions, secret refs, host paths, PIDs, sockets, credentials, and direct Run endpoint details are never plugin bridge fields.
|
||||
|
||||
Client-manager lifecycle requests remain Platform-mediated. A plugin declaration does not grant access by itself: Platform rechecks the installed plugin, server owner/administrator scope, runtime binding, assigned Run endpoint capabilities, current distribution target/revision/key generation, and durable installation state before dispatching a typed job.
|
||||
Run distribution, dependency, and log backfill requests use `createRunDistributionRequest`, `createDependencyActionRequest`, and `createLogBackfillRequest`. These envelopes carry only operation names, logical profile keys, target platforms, artifact IDs, approved dependency plan digests, cursors, and idempotency keys. Raw run keys, component sessions, secret refs, host paths, PIDs, sockets, credentials, and direct Run endpoint details are never plugin bridge fields.
|
||||
|
||||
Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. A command may declare a protected `sql`, `rcon`, or management-program request: the plugin supplies only its one bounded text field and logical transport/target keys; Platform authorizes, approves, redacts, queues, and forwards it to Run. A management program is not host OS shell access. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields.
|
||||
|
||||
@@ -53,6 +50,3 @@ The bridge must not expose:
|
||||
- unrestricted artifact storage credentials.
|
||||
- direct storage URLs or presigned backend URLs.
|
||||
- FTP, rsync, database, or RCON credentials.
|
||||
# Client Manager lifecycle bridge
|
||||
|
||||
The bridge may request typed `deploy`, `start`, `stop`, `restart`, `status`, `update`, `rollback`, `revoke`, `retry`, or `uninstall` intents when Platform action gating says they are available. Results are safe logical projections with real job phase/progress and redacted recovery guidance. The bridge is not a transport for Run sessions, component keys, artifact bytes, machine paths, process IDs, sockets, or credentials; component registration and heartbeat remain component-to-Platform contracts outside the plugin page.
|
||||
|
||||
+1
-201
@@ -10,7 +10,6 @@ export type PluginPermission =
|
||||
| "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"
|
||||
@@ -49,11 +48,6 @@ export type RunCapability =
|
||||
| "remote.run.logs.transfer"
|
||||
| "remote.run.rcon.command"
|
||||
| "remote.run.program.command"
|
||||
| "client-manager.deploy"
|
||||
| "client-manager.control"
|
||||
| "client-manager.update"
|
||||
| "client-manager.rollback"
|
||||
| "client-manager.uninstall"
|
||||
| "artifacts.read"
|
||||
| "artifacts.write"
|
||||
| "ai.invoke";
|
||||
@@ -70,7 +64,6 @@ export type PluginBridgeAction =
|
||||
| "run.distribution.request"
|
||||
| "dependencies.request"
|
||||
| "logs.backfill.request"
|
||||
| "client-manager.request"
|
||||
| "plugin-lifecycle.request"
|
||||
| "ai.invoke";
|
||||
|
||||
@@ -183,30 +176,6 @@ export type PluginLogBackfillPayload = Record<string, string> & {
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
export type PluginClientManagerPayload = Record<string, string> & {
|
||||
operation:
|
||||
| "generate"
|
||||
| "download"
|
||||
| "reset-key"
|
||||
| "status"
|
||||
| "deploy"
|
||||
| "start"
|
||||
| "stop"
|
||||
| "restart"
|
||||
| "update"
|
||||
| "rollback"
|
||||
| "revoke-session"
|
||||
| "retry"
|
||||
| "uninstall";
|
||||
profileKey: string;
|
||||
targetOS?: RuntimePlatform;
|
||||
targetArch?: RuntimeArch;
|
||||
artifactId?: string;
|
||||
installationId?: string;
|
||||
expectedDeploymentGeneration?: string;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
export type RemoteAccessMethod = "ftp" | "rsync" | "run";
|
||||
export type RemoteDatabaseEngine = "mysql" | "sqlite";
|
||||
|
||||
@@ -282,22 +251,6 @@ export interface GameClientBridgePageContract {
|
||||
|
||||
export interface GameClientBridgeFeatureDeclaration { key: string; title: string; permission: PluginPermission; requiredHandlers?: string[]; requiredEventProducers?: string[]; }
|
||||
|
||||
export interface GameClientBridgeCompanionDeclaration {
|
||||
profileKey: string;
|
||||
configTemplateKey: string;
|
||||
configSchemaRef: string;
|
||||
configFormat: "yaml";
|
||||
platformBaseUrlSource: "run-control";
|
||||
registrationProof: "hmac-sha256";
|
||||
proofMaterialSource: "component-package";
|
||||
proofMaterialEnv: string;
|
||||
sessionMode: "component-session";
|
||||
tlsPolicy: "verify-system-roots";
|
||||
heartbeatIntervalSeconds: number;
|
||||
commandPollIntervalSeconds: number;
|
||||
requestTimeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeManifest {
|
||||
commands: GameClientBridgeCommandDeclaration[];
|
||||
snapshots: GameClientBridgeSnapshotDeclaration[];
|
||||
@@ -307,7 +260,6 @@ export interface GameClientBridgeManifest {
|
||||
maxCommands: number;
|
||||
pages?: GameClientBridgePageContract[];
|
||||
features?: GameClientBridgeFeatureDeclaration[];
|
||||
companion?: GameClientBridgeCompanionDeclaration;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeProfileStatus {
|
||||
@@ -421,7 +373,6 @@ export interface RuntimeLifecycleProfile {
|
||||
capabilities: RunCapability[];
|
||||
actionRefs?: Partial<Record<PluginLifecycleAction, string>>;
|
||||
transportKeys?: string[];
|
||||
clientManagerRef?: string;
|
||||
dllExtensionRefs?: string[];
|
||||
platforms?: RuntimePlatform[];
|
||||
}
|
||||
@@ -454,7 +405,7 @@ export interface RuntimeInstallPlan {
|
||||
|
||||
export interface RuntimeLogSource {
|
||||
key: string;
|
||||
kind: "process.stdout" | "process.stderr" | "file.tail" | "ftp.poll" | "sql.query" | "client-manager";
|
||||
kind: "process.stdout" | "process.stderr" | "file.tail" | "ftp.poll" | "sql.query";
|
||||
targetKey?: string;
|
||||
streamKey: string;
|
||||
cursorKind?: "sequence" | "offset" | "fingerprint" | "ftp-listing" | "sql-cursor";
|
||||
@@ -480,57 +431,6 @@ export interface RuntimeDataTarget {
|
||||
platforms?: RuntimePlatform[];
|
||||
}
|
||||
|
||||
export interface RuntimeClientManagerProfile {
|
||||
key: string;
|
||||
displayName?: string;
|
||||
version?: string;
|
||||
repository: {
|
||||
url: string;
|
||||
revisionPolicy: "pinned" | "branch" | "tag";
|
||||
branch?: string;
|
||||
tag?: string;
|
||||
revision?: string;
|
||||
};
|
||||
supportedTargets: RuntimeTarget[];
|
||||
build: {
|
||||
system: "go" | "npm" | "cargo" | "make";
|
||||
workspaceRef?: string;
|
||||
entryRef?: string;
|
||||
};
|
||||
configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>;
|
||||
outputArtifacts: string[];
|
||||
deployment?: {
|
||||
mode: "run-supervised";
|
||||
executableRef: string;
|
||||
arguments?: string[];
|
||||
autoStart?: boolean;
|
||||
requiredRunCapabilities: Array<"client-manager.deploy" | "client-manager.control" | "client-manager.update" | "client-manager.rollback" | "client-manager.uninstall">;
|
||||
};
|
||||
lifecycle?: {
|
||||
actions: Array<"start" | "stop" | "restart" | "status" | "update" | "rollback" | "uninstall">;
|
||||
startupTimeoutSeconds: number;
|
||||
stopTimeoutSeconds: number;
|
||||
};
|
||||
health?: {
|
||||
mode: "component-heartbeat" | "process";
|
||||
intervalSeconds: number;
|
||||
degradedAfterSeconds: number;
|
||||
offlineAfterSeconds: number;
|
||||
requiredCapabilities: Array<"component.register" | "component.heartbeat" | "component.health" | "component.control" | "game-client.bridge" | "logs.stream">;
|
||||
};
|
||||
compatibility?: {
|
||||
minimumVersion?: string;
|
||||
maximumVersion?: string;
|
||||
allowDowngrade: boolean;
|
||||
};
|
||||
updatePolicy?: {
|
||||
strategy: "manual-staged";
|
||||
requireApproval: true;
|
||||
healthConfirmationSeconds: number;
|
||||
retainPrevious: true;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuntimeDLLExtensionProfile {
|
||||
key: string;
|
||||
displayName: string;
|
||||
@@ -559,7 +459,6 @@ export interface GamePluginRuntimeProfiles {
|
||||
logSources?: RuntimeLogSource[];
|
||||
transportProfiles?: RuntimeTransportProfile[];
|
||||
dataTargets?: RuntimeDataTarget[];
|
||||
clientManagers?: RuntimeClientManagerProfile[];
|
||||
dllExtensions?: RuntimeDLLExtensionProfile[];
|
||||
}
|
||||
|
||||
@@ -576,24 +475,6 @@ export interface PluginArtifactReference {
|
||||
storageBehavior?: string;
|
||||
}
|
||||
|
||||
export interface PluginClientManagerLifecycleStatus {
|
||||
installationId: string;
|
||||
profileKey: string;
|
||||
status: string;
|
||||
phase?: string;
|
||||
targetOS?: RuntimePlatform;
|
||||
targetArch?: RuntimeArch;
|
||||
version?: string;
|
||||
previousVersion?: string;
|
||||
artifactId?: string;
|
||||
currentJobId?: string;
|
||||
deploymentGeneration?: number;
|
||||
health?: string;
|
||||
healthReason?: string;
|
||||
lastSeenAt?: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
export type PluginBridgeExecutionResponse<TResult extends Record<string, string> = Record<string, string>> = {
|
||||
requestId: string;
|
||||
pluginId: string;
|
||||
@@ -620,7 +501,6 @@ export const pluginBridgeActionPolicies: Record<PluginBridgeAction, PluginBridge
|
||||
"run.distribution.request": { permissions: ["server.run.distribution"] },
|
||||
"dependencies.request": { permissions: ["server.dependencies.manage"] },
|
||||
"logs.backfill.request": { permissions: ["server.logs.read"] },
|
||||
"client-manager.request": { permissions: ["server.client-manager.manage"] },
|
||||
"plugin-lifecycle.request": { permissions: ["server.lifecycle"] },
|
||||
"ai.invoke": { permissions: ["ai.invoke"], aiPurposeRequired: true }
|
||||
};
|
||||
@@ -918,40 +798,6 @@ export function createLogBackfillRequest(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function createClientManagerRequest(input: {
|
||||
requestId: string;
|
||||
context: PluginBridgeContext;
|
||||
operation: PluginClientManagerPayload["operation"];
|
||||
profileKey: string;
|
||||
targetOS?: RuntimePlatform;
|
||||
targetArch?: RuntimeArch;
|
||||
artifactId?: string;
|
||||
installationId?: string;
|
||||
expectedDeploymentGeneration?: number;
|
||||
idempotencyKey: string;
|
||||
}): PluginBridgeExecutionRequest<PluginClientManagerPayload> {
|
||||
const payload: PluginClientManagerPayload = {
|
||||
operation: input.operation,
|
||||
profileKey: input.profileKey,
|
||||
artifactId: input.artifactId ?? "",
|
||||
installationId: input.installationId ?? "",
|
||||
expectedDeploymentGeneration: typeof input.expectedDeploymentGeneration === "number" ? String(input.expectedDeploymentGeneration) : "",
|
||||
idempotencyKey: input.idempotencyKey
|
||||
};
|
||||
if (input.targetOS) {
|
||||
payload.targetOS = input.targetOS;
|
||||
}
|
||||
if (input.targetArch) {
|
||||
payload.targetArch = input.targetArch;
|
||||
}
|
||||
return createBridgeExecutionRequest({
|
||||
requestId: input.requestId,
|
||||
context: input.context,
|
||||
action: "client-manager.request",
|
||||
payload
|
||||
});
|
||||
}
|
||||
|
||||
export function parseArtifactReference(result: Record<string, string> | undefined): PluginArtifactReference | undefined {
|
||||
if (!result) {
|
||||
return undefined;
|
||||
@@ -984,52 +830,6 @@ export function parseArtifactReference(result: Record<string, string> | undefine
|
||||
return reference;
|
||||
}
|
||||
|
||||
export function parseClientManagerLifecycleStatus(result: Record<string, string> | undefined): PluginClientManagerLifecycleStatus | undefined {
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
const deploymentGeneration = Number(result.deploymentGeneration ?? "0");
|
||||
const values = [
|
||||
result.installationId,
|
||||
result.profileKey,
|
||||
result.status,
|
||||
result.phase,
|
||||
result.targetOS,
|
||||
result.targetArch,
|
||||
result.version,
|
||||
result.previousVersion,
|
||||
result.artifactId,
|
||||
result.currentJobId,
|
||||
result.health,
|
||||
result.healthReason,
|
||||
result.lastSeenAt,
|
||||
result.actions
|
||||
];
|
||||
if (!result.installationId || !result.profileKey || !result.status || !Number.isSafeInteger(deploymentGeneration) || deploymentGeneration < 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (values.some((value) => typeof value === "string" && containsUnsafeReferenceContent(value))) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
installationId: result.installationId,
|
||||
profileKey: result.profileKey,
|
||||
status: result.status,
|
||||
phase: result.phase,
|
||||
targetOS: result.targetOS as RuntimePlatform | undefined,
|
||||
targetArch: result.targetArch as RuntimeArch | undefined,
|
||||
version: result.version,
|
||||
previousVersion: result.previousVersion,
|
||||
artifactId: result.artifactId,
|
||||
currentJobId: result.currentJobId,
|
||||
deploymentGeneration,
|
||||
health: result.health,
|
||||
healthReason: result.healthReason,
|
||||
lastSeenAt: result.lastSeenAt,
|
||||
actions: (result.actions ?? "").split(",").filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBridgeExecutionResponse<TResult extends Record<string, string>>(response: PluginBridgeExecutionResponse<TResult>): PluginBridgeExecutionResponse<TResult> {
|
||||
const safeError = response.error ? bridgeError(response.error.code, response.error.message, response.error.details ?? []) : undefined;
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
canRequestBridgeAction,
|
||||
createAIInvocationRequest,
|
||||
createArtifactOpenRequest,
|
||||
createClientManagerRequest,
|
||||
createBridgeExecutionRequest,
|
||||
createLifecycleDispatchRequest,
|
||||
createProductionPluginLifecycleRequest,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
createRunDistributionRequest,
|
||||
hasPluginPermission,
|
||||
parseArtifactReference,
|
||||
parseClientManagerLifecycleStatus,
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
@@ -256,17 +254,7 @@ describe("plugin manifest validation", () => {
|
||||
files: Array<{ key: string; directoryKey: string; label: string; kind: string; streamKey?: string; editable?: boolean }>;
|
||||
configFields: Array<{ key: string; fileKey: string; configKey: string; label: string }>;
|
||||
};
|
||||
runtimeProfiles?: {
|
||||
lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>;
|
||||
logSources?: Array<{ key: string }>;
|
||||
clientManagers?: Array<{
|
||||
key: string;
|
||||
build?: { workspaceRef?: string; entryRef?: string };
|
||||
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
|
||||
deployment?: { arguments?: string[] };
|
||||
health?: { intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number };
|
||||
}>;
|
||||
};
|
||||
runtimeProfiles?: { lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>; logSources?: Array<{ key: string }>; clientManagers?: unknown[] };
|
||||
version: string;
|
||||
};
|
||||
const installAction = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/actions/install.json"), "utf8")) as { environment?: Record<string, string> };
|
||||
@@ -724,9 +712,7 @@ describe("plugin manifest validation", () => {
|
||||
expect(errors.some((error) => error.includes("raw host path"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("arbitrary shell"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("not approved for dependency download"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("client-manager.deploy is required"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("offline threshold"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("profile version is below minimumVersion"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("runtimeProfiles.clientManagers") && error.includes("no longer supported"))).toBe(true);
|
||||
});
|
||||
|
||||
it("validates typed lifecycle declarations and rejects shell/path escapes", () => {
|
||||
@@ -1050,24 +1036,6 @@ describe("plugin SDK", () => {
|
||||
});
|
||||
|
||||
it("types runtime profile declarations without raw credentials", () => {
|
||||
const clientManager: RuntimeClientManagerProfile = {
|
||||
key: "safe-client-manager",
|
||||
version: "1.2.3",
|
||||
repository: { url: "https://github.com/example/safe-client.git", revisionPolicy: "pinned", revision: "0123456789abcdef" },
|
||||
supportedTargets: [{ os: "linux", arch: "amd64" }],
|
||||
build: { system: "go", entryRef: "cmd/client/main.go" },
|
||||
outputArtifacts: ["safe-client"],
|
||||
deployment: {
|
||||
mode: "run-supervised",
|
||||
executableRef: "safe-client",
|
||||
arguments: ["--config", "config.json"],
|
||||
requiredRunCapabilities: ["client-manager.deploy", "client-manager.control", "client-manager.update", "client-manager.rollback", "client-manager.uninstall"]
|
||||
},
|
||||
lifecycle: { actions: ["start", "stop", "restart", "status", "update", "rollback", "uninstall"], startupTimeoutSeconds: 30, stopTimeoutSeconds: 15 },
|
||||
health: { mode: "component-heartbeat", intervalSeconds: 15, degradedAfterSeconds: 45, offlineAfterSeconds: 120, requiredCapabilities: ["component.register", "component.heartbeat", "component.health"] },
|
||||
compatibility: { minimumVersion: "1.0.0", allowDowngrade: false },
|
||||
updatePolicy: { strategy: "manual-staged", requireApproval: true, healthConfirmationSeconds: 60, retainPrevious: true }
|
||||
};
|
||||
const manifest: GamePluginManifest = {
|
||||
id: "game.runtime",
|
||||
name: "Runtime Fixture",
|
||||
@@ -1081,22 +1049,20 @@ describe("plugin SDK", () => {
|
||||
discovery: [{ key: "java", kind: "command.version", targetKey: "java", required: true }],
|
||||
dependencyProbes: [{ key: "java-21", kind: "java.version", targetKey: "java", minimumVersion: "21" }],
|
||||
logSources: [{ key: "console", kind: "process.stdout", streamKey: "console", cursorKind: "sequence" }],
|
||||
transportProfiles: [{ key: "files", kind: "file", capabilities: ["files.read"] }],
|
||||
clientManagers: [clientManager]
|
||||
transportProfiles: [{ key: "files", kind: "file", capabilities: ["files.read"] }]
|
||||
}
|
||||
};
|
||||
|
||||
expect(manifest.runtimeProfiles?.discovery?.[0].targetKey).toBe("java");
|
||||
expect(manifest.runtimeProfiles?.clientManagers?.[0].deployment?.requiredRunCapabilities).toContain("client-manager.deploy");
|
||||
expect(JSON.stringify(manifest)).not.toContain("password=");
|
||||
});
|
||||
|
||||
it("builds run distribution, dependency, log backfill, and client-manager envelopes", () => {
|
||||
it("builds run distribution, dependency, and log backfill envelopes", () => {
|
||||
const context: PluginBridgeContext = {
|
||||
pluginId: "game.scum",
|
||||
routeKey: "remote",
|
||||
serverInstanceId: "server-1",
|
||||
permissions: ["server.run.distribution", "server.dependencies.manage", "server.logs.read", "server.client-manager.manage"]
|
||||
permissions: ["server.run.distribution", "server.dependencies.manage", "server.logs.read"]
|
||||
};
|
||||
|
||||
expect(canRequestBridgeAction(context, "run.distribution.request")).toBe(true);
|
||||
@@ -1117,36 +1083,6 @@ describe("plugin SDK", () => {
|
||||
action: "logs.backfill.request",
|
||||
payload: { sourceKey: "chat-log", limit: "500" }
|
||||
});
|
||||
expect(createClientManagerRequest({ requestId: "client-1", context, operation: "generate", profileKey: "example-client-manager", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-client" })).toMatchObject({
|
||||
action: "client-manager.request",
|
||||
payload: { operation: "generate", profileKey: "example-client-manager" }
|
||||
});
|
||||
expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "example-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret");
|
||||
expect(createClientManagerRequest({ requestId: "client-3", context, operation: "deploy", profileKey: "example-client-manager", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: 2, idempotencyKey: "idem-deploy" })).toMatchObject({
|
||||
action: "client-manager.request",
|
||||
payload: { operation: "deploy", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: "2" }
|
||||
});
|
||||
expect(parseClientManagerLifecycleStatus({
|
||||
installationId: "cm-install-1",
|
||||
profileKey: "example-client-manager",
|
||||
status: "online",
|
||||
phase: "healthy",
|
||||
targetOS: "windows",
|
||||
targetArch: "amd64",
|
||||
version: "1.0.0",
|
||||
artifactId: "artifact-1",
|
||||
deploymentGeneration: "2",
|
||||
health: "healthy",
|
||||
actions: "stop,restart,update,uninstall"
|
||||
})).toMatchObject({ installationId: "cm-install-1", deploymentGeneration: 2, actions: ["stop", "restart", "update", "uninstall"] });
|
||||
expect(parseClientManagerLifecycleStatus({
|
||||
installationId: "cm-install-1",
|
||||
profileKey: "example-client-manager",
|
||||
status: "online",
|
||||
deploymentGeneration: "2",
|
||||
actions: "stop",
|
||||
healthReason: "Bearer stolen-session"
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds full plugin lifecycle envelopes through Platform only", () => {
|
||||
|
||||
Reference in New Issue
Block a user