diff --git a/platform/api/authorization_test.go b/platform/api/authorization_test.go index 039bbb3..6975574 100644 --- a/platform/api/authorization_test.go +++ b/platform/api/authorization_test.go @@ -18,6 +18,7 @@ import ( "browser.local/platform/dto" "browser.local/platform/repo" "browser.local/platform/service" + "browser.local/platform/validator" ) func TestAuthorizedRouterEnforcesAdminAndCrossOwnerBoundaries(t *testing.T) { @@ -165,6 +166,71 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) { } } +func TestSignedRunArtifactChunkUploadUsesHeaderEnvelope(t *testing.T) { + store := repo.NewMemoryStore() + core := service.NewCoreService(store) + if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil { + t.Fatalf("create plugin: %v", err) + } + if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil { + t.Fatalf("create endpoint: %v", err) + } + if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-signed-artifact", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Signed Artifact", State: domain.ServerInstanceStateReady, ConfigVersion: 1}); err != nil { + t.Fatalf("create server: %v", err) + } + if _, err := core.CreateJob(domain.Job{ID: "job-signed-artifact", ServerInstanceID: "server-signed-artifact", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "job-signed-artifact"}); err != nil { + t.Fatalf("create job: %v", err) + } + token := "run-artifact-session-secret" + stamp := time.Now().UTC() + hash := sha256.Sum256([]byte(token)) + if err := store.RunControlSessions().Create(domain.RunControlSession{RunEndpointID: "run-local", SessionTokenHash: hex.EncodeToString(hash[:]), Status: domain.AuthSessionStatusActive, Generation: 1, CapabilityFingerprint: "cap-v1", HeartbeatIntervalSeconds: 15, CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true}); err != nil { + t.Fatalf("create Run session: %v", err) + } + payload := []byte("signed raw artifact chunk") + opened, err := core.OpenArtifactTransfer(domain.ArtifactTransferOpen{RunEndpointID: "run-local", SessionToken: token, ArtifactID: "artifact-signed-raw", Direction: domain.ArtifactTransferDirectionUpload, OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: "job-signed-artifact", SizeBytes: int64(len(payload)), ChunkSizeBytes: len(payload), Checksum: validator.BytesChecksum(payload), IdempotencyKey: "signed-raw-artifact"}) + if err != nil { + t.Fatalf("open transfer: %v", err) + } + router := NewTestRouterWithCore(core) + unsigned := rawArtifactChunkRequest(t, router, token, opened.TransferID, payload, "nonce-unsigned-raw", stamp, false) + assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized) + signed := rawArtifactChunkRequest(t, router, token, opened.TransferID, payload, "nonce-signed-raw", stamp, true) + assertStatus(t, signed, http.StatusOK) + response := decodeBody[dto.ArtifactChunkUploadResponse](t, signed) + if !response.Accepted || response.TransferID != opened.TransferID || response.NextMissingChunkIndex != 1 { + t.Fatalf("unexpected signed chunk response: %+v", response) + } +} + +func rawArtifactChunkRequest(t *testing.T, router http.Handler, token string, transferID string, payload []byte, nonce string, stamp time.Time, signed bool) *httptest.ResponseRecorder { + t.Helper() + path := "/api/v1/run/artifacts/chunks" + timestamp := strconv.FormatInt(stamp.Unix(), 10) + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("X-Run-Endpoint", "run-local") + req.Header.Set("X-Run-Session-Token", token) + req.Header.Set("X-Run-Timestamp", timestamp) + req.Header.Set("X-Run-Nonce", nonce) + req.Header.Set("X-Artifact-Transfer-Id", transferID) + req.Header.Set("X-Artifact-Id", "artifact-signed-raw") + req.Header.Set("X-Artifact-Chunk-Index", "0") + req.Header.Set("X-Artifact-Offset", "0") + req.Header.Set("X-Artifact-Size", strconv.Itoa(len(payload))) + req.Header.Set("X-Artifact-Checksum", validator.BytesChecksum(payload)) + if signed { + bodyHash := sha256.Sum256(payload) + canonical := strings.Join([]string{http.MethodPost, path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n") + mac := hmac.New(sha256.New, []byte(token)) + _, _ = mac.Write([]byte(canonical)) + req.Header.Set("X-Run-Signature", hex.EncodeToString(mac.Sum(nil))) + } + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + return recorder +} + func TestAuthorizedRouterAllowsRunLifecycleReportWithoutBearer(t *testing.T) { store := repo.NewMemoryStore() core := service.NewCoreService(store) diff --git a/platform/api/log_events_handlers.go b/platform/api/log_events_handlers.go index 11094c0..5af082b 100644 --- a/platform/api/log_events_handlers.go +++ b/platform/api/log_events_handlers.go @@ -17,7 +17,6 @@ type componentLogServerContextKey struct{} const ( logEventHeartbeatInterval = 15 * time.Second - liveLogSourceClockSkew = 90 * time.Second managedLogSessionIDPrefix = "log-session:" ) @@ -27,7 +26,6 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { writeMethodNotAllowed(w, http.MethodGet) return } - liveBoundary := time.Now().UTC().Add(-liveLogSourceClockSkew) instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r) if err != nil { writeServiceError(w, err) @@ -150,12 +148,6 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { return } } - if !sourceLogEntryIsLive(event.Entry, liveBoundary) { - if event.Entry.Seq > emittedThrough[event.Stream.ID] { - emittedThrough[event.Stream.ID] = event.Entry.Seq - } - continue - } if !active.hasStream(event.Stream.ID) { active.streams = append(active.streams, event.Stream) if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(event.Stream)); err != nil { @@ -179,10 +171,6 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { } } -func sourceLogEntryIsLive(entry domain.LogEntry, liveBoundary time.Time) bool { - return !entry.Timestamp.IsZero() && !entry.Timestamp.Before(liveBoundary) -} - type supervisedLogSession struct { sessionID string startedAt time.Time diff --git a/platform/api/log_ingest_handlers_test.go b/platform/api/log_ingest_handlers_test.go index a218b0e..7de197c 100644 --- a/platform/api/log_ingest_handlers_test.go +++ b/platform/api/log_ingest_handlers_test.go @@ -153,7 +153,7 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) { assertSSEEvent(t, reader, "log", `"seq":2`) } -func TestLogEventsSSESkipsBufferedBackfillAfterOpen(t *testing.T) { +func TestLogEventsSSEStreamsBufferedAppendAfterOpen(t *testing.T) { router := newTestRouter() hello := createLogIngestAPIFixtures(t, router) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK) @@ -180,11 +180,12 @@ func TestLogEventsSSESkipsBufferedBackfillAfterOpen(t *testing.T) { assertSSEEvent(t, reader, "stream", `"id":"log-1"`) assertSSEEvent(t, reader, "ready", `"streamCount":1`) - stale := validLogBatchRequest(t, hello.SessionToken, 2, 2) - assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", stale), http.StatusOK) + buffered := validLogBatchRequest(t, hello.SessionToken, 2, 2) + assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", buffered), http.StatusOK) fresh := validLogBatchRequest(t, hello.SessionToken, 3, 3) retimestampLogBatchRequest(t, &fresh, time.Now().UTC().Add(time.Second)) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", fresh), http.StatusOK) + assertSSEEvent(t, reader, "log", `"seq":2`) assertSSEEvent(t, reader, "log", `"seq":3`) } diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 64ab9a9..e9dfd37 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -944,6 +944,10 @@ func (h *coreHandlers) gamePluginManifestRegistration(w http.ResponseWriter, r * writeDecodeError(w, err) return } + if violations := request.Manifest.RuntimeProfiles.UnsupportedLegacyClientManagerViolations("manifest.runtimeProfiles"); len(violations) > 0 { + writeServiceError(w, validator.ValidationError{Violations: violations}) + return + } plugin, err := h.core.RegisterGamePluginManifest(request.ToDomain()) if err != nil { writeServiceError(w, err) diff --git a/platform/domain/client_manager_lifecycle.go b/platform/domain/client_manager_lifecycle.go deleted file mode 100644 index 3661df1..0000000 --- a/platform/domain/client_manager_lifecycle.go +++ /dev/null @@ -1,392 +0,0 @@ -package domain - -import "time" - -type ClientManagerLifecycleStatus string - -const ( - ClientManagerLifecycleRequested ClientManagerLifecycleStatus = "requested" - ClientManagerLifecycleBuilding ClientManagerLifecycleStatus = "building" - ClientManagerLifecycleAvailable ClientManagerLifecycleStatus = "available" - ClientManagerLifecycleDeploying ClientManagerLifecycleStatus = "deploying" - ClientManagerLifecycleInstalled ClientManagerLifecycleStatus = "installed" - ClientManagerLifecycleRegistering ClientManagerLifecycleStatus = "registering" - ClientManagerLifecycleOnline ClientManagerLifecycleStatus = "online" - ClientManagerLifecycleDegraded ClientManagerLifecycleStatus = "degraded" - ClientManagerLifecycleOffline ClientManagerLifecycleStatus = "offline" - ClientManagerLifecycleUpdating ClientManagerLifecycleStatus = "updating" - ClientManagerLifecycleRollingBack ClientManagerLifecycleStatus = "rolling_back" - ClientManagerLifecycleStopping ClientManagerLifecycleStatus = "stopping" - ClientManagerLifecycleUninstalled ClientManagerLifecycleStatus = "uninstalled" - ClientManagerLifecycleFailed ClientManagerLifecycleStatus = "failed" -) - -type ClientManagerHealthStatus string - -const ( - ClientManagerHealthUnknown ClientManagerHealthStatus = "unknown" - ClientManagerHealthHealthy ClientManagerHealthStatus = "healthy" - ClientManagerHealthDegraded ClientManagerHealthStatus = "degraded" - ClientManagerHealthUnhealthy ClientManagerHealthStatus = "unhealthy" - ClientManagerHealthOffline ClientManagerHealthStatus = "offline" -) - -type ClientManagerLifecycleOperation string - -const ( - ClientManagerOperationDeploy ClientManagerLifecycleOperation = "deploy" - ClientManagerOperationStart ClientManagerLifecycleOperation = "start" - ClientManagerOperationStop ClientManagerLifecycleOperation = "stop" - ClientManagerOperationRestart ClientManagerLifecycleOperation = "restart" - ClientManagerOperationStatus ClientManagerLifecycleOperation = "status" - ClientManagerOperationUpdate ClientManagerLifecycleOperation = "update" - ClientManagerOperationRollback ClientManagerLifecycleOperation = "rollback" - ClientManagerOperationUninstall ClientManagerLifecycleOperation = "uninstall" -) - -const ( - JobCapabilityClientManagerDeploy = "client-manager.deploy" - JobCapabilityClientManagerControl = "client-manager.control" - JobCapabilityClientManagerUpdate = "client-manager.update" - JobCapabilityClientManagerRollback = "client-manager.rollback" - JobCapabilityClientManagerUninstall = "client-manager.uninstall" -) - -const ClientManagerCompanionConfigSchemaVersion = 1 - -type ClientManagerSessionStatus string - -const ( - ClientManagerSessionActive ClientManagerSessionStatus = "active" - ClientManagerSessionRevoked ClientManagerSessionStatus = "revoked" - ClientManagerSessionExpired ClientManagerSessionStatus = "expired" -) - -type RuntimeClientManagerDeployment struct { - Mode string - ExecutableRef string - Arguments []string - AutoStart bool - RequiredRunCapabilities []string -} - -type RuntimeClientManagerLifecycle struct { - Actions []string - StartupTimeoutSeconds int - StopTimeoutSeconds int -} - -type RuntimeClientManagerHealth struct { - Mode string - IntervalSeconds int - DegradedAfterSeconds int - OfflineAfterSeconds int - RequiredCapabilities []string -} - -type RuntimeClientManagerCompatibility struct { - MinimumVersion string - MaximumVersion string - AllowDowngrade bool -} - -type RuntimeClientManagerUpdatePolicy struct { - Strategy string - RequireApproval bool - HealthConfirmationSeconds int - RetainPrevious bool -} - -type ClientManagerInstallation struct { - ID string - ServerInstanceID string - PluginID string - ProfileKey string - RunEndpointID 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 - Checksum string - KeyGeneration int - DeploymentGeneration int - CurrentJobID string - LastSuccessfulJobID string - LastOperation ClientManagerLifecycleOperation - Health ClientManagerHealthStatus - HealthReason string - LastSeenAt time.Time - LastHeartbeatSequence uint64 - Retryable bool - RequiresRedeploy bool - CreatedAt time.Time - UpdatedAt time.Time - InstalledAt time.Time - UninstalledAt time.Time -} - -type ClientManagerSession struct { - ID string - InstallationID string - ServerInstanceID string - ProfileKey string - RunEndpointID string - ArtifactID string - KeyGeneration int - DeploymentGeneration int - TokenHash string - Capabilities []string - Status ClientManagerSessionStatus - LastHeartbeatSequence uint64 - LastSeenAt time.Time - ExpiresAt time.Time - CreatedAt time.Time - UpdatedAt time.Time - RevokedAt time.Time -} - -type ClientManagerRegistrationNonce struct { - ID string - InstallationID string - ExpiresAt time.Time - CreatedAt time.Time -} - -type ClientManagerLifecycleActionAvailability struct { - Operation ClientManagerLifecycleOperation - Available bool - Reason string -} - -type ClientManagerLifecycleView struct { - Installation ClientManagerInstallation - Distribution ClientManagerDistribution - Job Job - Actions []ClientManagerLifecycleActionAvailability -} - -type ClientManagerDeployRequest struct { - ServerInstanceID string - ProfileKey string - DistributionID string - ExpectedDeploymentGeneration int - IdempotencyKey string -} - -type ClientManagerControlRequest struct { - ServerInstanceID string - ProfileKey string - Operation ClientManagerLifecycleOperation - ExpectedDeploymentGeneration int - IdempotencyKey string -} - -type ClientManagerUpdateRequest struct { - ServerInstanceID string - ProfileKey string - DistributionID string - ExpectedDeploymentGeneration int - Approved bool - IdempotencyKey string -} - -type ClientManagerUninstallRequest struct { - ServerInstanceID string - ProfileKey string - ExpectedDeploymentGeneration int - Confirmed bool - IdempotencyKey string -} - -type ClientManagerRetryRequest struct { - ServerInstanceID string - ProfileKey string - ExpectedDeploymentGeneration int - IdempotencyKey string -} - -type ClientManagerRevokeSessionRequest struct { - ServerInstanceID string - ProfileKey string - Reason string -} - -type ClientManagerLifecycleInputRequest struct { - RunEndpointID string - SessionToken string - JobID string - LeaseToken string - Attempt int -} - -type ClientManagerLifecycleInput struct { - InstallationID string - ServerInstanceID string - ProfileKey string - Operation ClientManagerLifecycleOperation - ArtifactID string - Checksum string - TargetOS string - TargetArch string - Version string - SourceRevision string - KeyGeneration int - DeploymentGeneration int - ExecutableRef string - Arguments []string - AutoStart bool - StartupTimeoutSeconds int - StopTimeoutSeconds int - HealthConfirmationSeconds int - IdempotencyKey string - CompanionConfig *ClientManagerCompanionConfigInput -} - -type ClientManagerCompanionConfigInput struct { - SchemaVersion int - ConfigTemplateKey string - ConfigTemplateRef string - ConfigOutputRef string - ConfigSchemaRef string - ConfigFormat string - PlatformBaseURLSource string - InstallationID string - ServerInstanceID string - PluginID string - ProfileKey string - ArtifactID string - Version string - SourceRevision string - TargetOS string - TargetArch string - KeyGeneration int - DeploymentGeneration int - Capabilities []string - RegistrationProof string - ProofMaterialSource string - ProofMaterialEnv string - SessionMode string - TLSPolicy string - HeartbeatIntervalSeconds int - CommandPollIntervalSeconds int - RequestTimeoutSeconds int -} - -type ClientManagerRegisterRequest struct { - InstallationID string - ServerInstanceID string - ProfileKey string - ArtifactID string - Version string - SourceRevision string - TargetOS string - TargetArch string - KeyGeneration int - DeploymentGeneration int - Capabilities []string - Timestamp time.Time - Nonce string - Signature string -} - -type ClientManagerRegisterResult struct { - Accepted bool - InstallationID string - SessionToken string - ExpiresAt time.Time - HeartbeatEvery int - ServerTime time.Time -} - -type ClientManagerHeartbeat struct { - InstallationID string - SessionToken string - Sequence uint64 - Health ClientManagerHealthStatus - HealthReason string - Capabilities []string - SentAt time.Time -} - -type ClientManagerHeartbeatResult struct { - Accepted bool - InstallationID string - Status ClientManagerLifecycleStatus - Health ClientManagerHealthStatus - NextHeartbeat int - SessionExpiresAt time.Time - ServerTime time.Time -} - -type ClientManagerInstallationFilter struct { - ServerInstanceID string - ProfileKey string - RunEndpointID string - Status ClientManagerLifecycleStatus -} - -type ClientManagerSessionFilter struct { - InstallationID string - ServerInstanceID string - ProfileKey string - Status ClientManagerSessionStatus -} - -type ClientManagerNonceFilter struct { - InstallationID string - ExpiresBefore time.Time -} - -func CopyClientManagerInstallation(value ClientManagerInstallation) ClientManagerInstallation { - return value -} - -func CopyClientManagerSession(value ClientManagerSession) ClientManagerSession { - value.Capabilities = CopyStringSlice(value.Capabilities) - return value -} - -func CopyClientManagerRegistrationNonce(value ClientManagerRegistrationNonce) ClientManagerRegistrationNonce { - return value -} - -func CopyClientManagerLifecycleView(value ClientManagerLifecycleView) ClientManagerLifecycleView { - value.Installation = CopyClientManagerInstallation(value.Installation) - value.Distribution = CopyClientManagerDistribution(value.Distribution) - value.Job = CopyJob(value.Job) - value.Actions = append([]ClientManagerLifecycleActionAvailability(nil), value.Actions...) - return value -} - -func CopyClientManagerLifecycleInput(value ClientManagerLifecycleInput) ClientManagerLifecycleInput { - value.Arguments = CopyStringSlice(value.Arguments) - if value.CompanionConfig != nil { - companion := CopyClientManagerCompanionConfigInput(*value.CompanionConfig) - value.CompanionConfig = &companion - } - return value -} - -func CopyClientManagerCompanionConfigInput(value ClientManagerCompanionConfigInput) ClientManagerCompanionConfigInput { - value.Capabilities = CopyStringSlice(value.Capabilities) - return value -} - -func CopyClientManagerRegisterRequest(value ClientManagerRegisterRequest) ClientManagerRegisterRequest { - value.Capabilities = CopyStringSlice(value.Capabilities) - return value -} - -func CopyClientManagerHeartbeat(value ClientManagerHeartbeat) ClientManagerHeartbeat { - value.Capabilities = CopyStringSlice(value.Capabilities) - return value -} diff --git a/platform/domain/client_manager_lifecycle_companion_test.go b/platform/domain/client_manager_lifecycle_companion_test.go deleted file mode 100644 index a468dc2..0000000 --- a/platform/domain/client_manager_lifecycle_companion_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package domain - -import "testing" - -func TestCopyClientManagerLifecycleInputDeepCopiesCompanionCapabilities(t *testing.T) { - original := ClientManagerLifecycleInput{ - Arguments: []string{"--foreground"}, - CompanionConfig: &ClientManagerCompanionConfigInput{ - Capabilities: []string{"component.register", "game-client.bridge"}, - }, - } - cloned := CopyClientManagerLifecycleInput(original) - cloned.Arguments[0] = "--changed" - cloned.CompanionConfig.Capabilities[0] = "changed" - - if original.Arguments[0] != "--foreground" { - t.Fatalf("arguments were not deeply copied: %+v", original.Arguments) - } - if original.CompanionConfig.Capabilities[0] != "component.register" { - t.Fatalf("companion capabilities were not deeply copied: %+v", original.CompanionConfig.Capabilities) - } -} diff --git a/platform/domain/resources.go b/platform/domain/resources.go index bee4925..7896137 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -108,8 +108,7 @@ const ( type DistributionComponentKind string const ( - DistributionComponentRun DistributionComponentKind = "run" - DistributionComponentClientManager DistributionComponentKind = "client-manager" + DistributionComponentRun DistributionComponentKind = "run" ) type ComponentKeyStatus string @@ -402,7 +401,6 @@ type RuntimeLifecycleProfile struct { Capabilities []string ActionRefs PluginLifecycleActions TransportKeys []string - ClientManagerRef string DLLExtensionRefs []string Platforms []string } @@ -463,28 +461,6 @@ type RuntimeDataTarget struct { Platforms []string } -type RuntimeClientManagerProfile struct { - Key string - DisplayName string - Version string - RepositoryURL string - RevisionPolicy string - Branch string - Tag string - Revision string - SupportedTargets []RuntimeTarget - BuildSystem string - WorkspaceRef string - EntryRef string - ConfigTemplates []RuntimeConfigTemplate - OutputArtifacts []string - Deployment RuntimeClientManagerDeployment - Lifecycle RuntimeClientManagerLifecycle - Health RuntimeClientManagerHealth - Compatibility RuntimeClientManagerCompatibility - UpdatePolicy RuntimeClientManagerUpdatePolicy -} - type RuntimeDLLExtensionProfile struct { Key string DisplayName string @@ -593,7 +569,6 @@ type GamePluginRuntimeProfiles struct { LogSources []RuntimeLogSource TransportProfiles []RuntimeTransportProfile DataTargets []RuntimeDataTarget - ClientManagers []RuntimeClientManagerProfile DLLExtensions []RuntimeDLLExtensionProfile } @@ -1432,26 +1407,6 @@ type RunDistribution struct { UpdatedAt time.Time } -type ClientManagerDistribution struct { - ID string - ServerInstanceID string - PluginID string - ProfileKey string - Version string - TargetOS string - TargetArch string - RepositoryURL string - SourceRevision string - BuildJobID string - ArtifactID string - Checksum string - KeyGeneration int - SecretRef string - Status DistributionStatus - CreatedAt time.Time - UpdatedAt time.Time -} - type DependencyStatus struct { ID string ServerInstanceID string @@ -1471,25 +1426,6 @@ type DependencyStatus struct { UpdatedAt time.Time } -type ClientManagerBuildJob struct { - ID string - ServerInstanceID string - PluginID string - ProfileKey string - Version string - TargetOS string - TargetArch string - RepositoryURL string - SourceRevision string - ArtifactID string - Checksum string - KeyGeneration int - LogsRef string - Status DistributionJobStatus - CreatedAt time.Time - UpdatedAt time.Time -} - type RunUpdateJob struct { ID string ServerInstanceID string @@ -1517,16 +1453,6 @@ type RunDistributionGenerateRequest struct { IdempotencyKey string } -type ClientManagerBuildRequest struct { - ServerInstanceID string - ProfileKey string - TargetOS string - TargetArch string - RepositoryURL string - SourceRevision string - IdempotencyKey string -} - type ComponentKeyResetRequest struct { ServerInstanceID string ComponentKind DistributionComponentKind @@ -1716,26 +1642,12 @@ type RunDistributionFilter struct { Status DistributionStatus } -type ClientManagerDistributionFilter struct { - ServerInstanceID string - ProfileKey string - TargetOS string - TargetArch string - Status DistributionStatus -} - type DependencyStatusFilter struct { ServerInstanceID string ProbeKey string State DependencyState } -type ClientManagerBuildJobFilter struct { - ServerInstanceID string - ProfileKey string - Status DistributionJobStatus -} - type RunUpdateJobFilter struct { ServerInstanceID string Status DistributionJobStatus @@ -1927,16 +1839,6 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi for i := range profiles.DataTargets { profiles.DataTargets[i].Platforms = CopyStringSlice(profiles.DataTargets[i].Platforms) } - profiles.ClientManagers = append([]RuntimeClientManagerProfile(nil), profiles.ClientManagers...) - for i := range profiles.ClientManagers { - profiles.ClientManagers[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.ClientManagers[i].SupportedTargets...) - profiles.ClientManagers[i].ConfigTemplates = append([]RuntimeConfigTemplate(nil), profiles.ClientManagers[i].ConfigTemplates...) - profiles.ClientManagers[i].OutputArtifacts = CopyStringSlice(profiles.ClientManagers[i].OutputArtifacts) - profiles.ClientManagers[i].Deployment.Arguments = CopyStringSlice(profiles.ClientManagers[i].Deployment.Arguments) - profiles.ClientManagers[i].Deployment.RequiredRunCapabilities = CopyStringSlice(profiles.ClientManagers[i].Deployment.RequiredRunCapabilities) - profiles.ClientManagers[i].Lifecycle.Actions = CopyStringSlice(profiles.ClientManagers[i].Lifecycle.Actions) - profiles.ClientManagers[i].Health.RequiredCapabilities = CopyStringSlice(profiles.ClientManagers[i].Health.RequiredCapabilities) - } profiles.DLLExtensions = append([]RuntimeDLLExtensionProfile(nil), profiles.DLLExtensions...) for i := range profiles.DLLExtensions { profiles.DLLExtensions[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.DLLExtensions[i].SupportedTargets...) @@ -2242,10 +2144,6 @@ func CopyRunDistribution(distribution RunDistribution) RunDistribution { return distribution } -func CopyClientManagerDistribution(distribution ClientManagerDistribution) ClientManagerDistribution { - return distribution -} - func CopyDependencyStatus(status DependencyStatus) DependencyStatus { return status } @@ -2259,10 +2157,6 @@ func CopyDependencyCatalog(catalog DependencyCatalog) DependencyCatalog { return catalog } -func CopyClientManagerBuildJob(job ClientManagerBuildJob) ClientManagerBuildJob { - return job -} - func CopyRunUpdateJob(job RunUpdateJob) RunUpdateJob { return job } @@ -2271,10 +2165,6 @@ func CopyRunDistributionGenerateRequest(request RunDistributionGenerateRequest) return request } -func CopyClientManagerBuildRequest(request ClientManagerBuildRequest) ClientManagerBuildRequest { - return request -} - func CopyComponentKeyResetRequest(request ComponentKeyResetRequest) ComponentKeyResetRequest { return request } diff --git a/platform/domain/resources.md b/platform/domain/resources.md index edb46cc..4cc1b74 100644 --- a/platform/domain/resources.md +++ b/platform/domain/resources.md @@ -104,7 +104,7 @@ Run sessions persist only a token hash, generation, status, expiry, capability f - `serverInstanceId`: server instance using the binding. - `pluginId` and `pluginVersion`: installed plugin contract that declared the logical runtime profile. - `profileKey`: declared lifecycle/runtime profile key. -- `mode`: runtime mode such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`. +- `mode`: runtime mode such as `local-process`, `hosted-ftp-rcon`, or `ftp-only`. - `bindings`: logical binding keys to operator-provided settings. - `missingKeys`: logical keys that must be completed before dependent actions are available. - `status`: `complete` or `incomplete`. diff --git a/platform/dto/client_manager_lifecycle.go b/platform/dto/client_manager_lifecycle.go deleted file mode 100644 index c256eb5..0000000 --- a/platform/dto/client_manager_lifecycle.go +++ /dev/null @@ -1,308 +0,0 @@ -package dto - -import ( - "time" - - "browser.local/platform/domain" -) - -type ClientManagerDeployRequest struct { - ProfileKey string `json:"profileKey"` - DistributionID string `json:"distributionId"` - ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration,omitempty"` - IdempotencyKey string `json:"idempotencyKey"` -} - -type ClientManagerControlRequest struct { - ProfileKey string `json:"profileKey"` - Operation string `json:"operation"` - ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"` - IdempotencyKey string `json:"idempotencyKey"` -} - -type ClientManagerUpdateRequest struct { - ProfileKey string `json:"profileKey"` - DistributionID string `json:"distributionId"` - ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"` - Approved bool `json:"approved"` - IdempotencyKey string `json:"idempotencyKey"` -} - -type ClientManagerUninstallRequest struct { - ProfileKey string `json:"profileKey"` - ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"` - Confirmed bool `json:"confirmed"` - IdempotencyKey string `json:"idempotencyKey"` -} - -type ClientManagerRetryRequest struct { - ProfileKey string `json:"profileKey"` - ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"` - IdempotencyKey string `json:"idempotencyKey"` -} - -type ClientManagerRevokeSessionRequest struct { - ProfileKey string `json:"profileKey"` - Reason string `json:"reason,omitempty"` -} - -type ClientManagerLifecycleActionResponse struct { - Operation string `json:"operation"` - Available bool `json:"available"` - Reason string `json:"reason,omitempty"` -} - -type ClientManagerLifecycleJobResponse struct { - ID string `json:"id,omitempty"` - State domain.JobState `json:"state,omitempty"` - Progress JobProgressBody `json:"progress,omitempty"` - Attempt int `json:"attempt,omitempty"` - CreatedAt time.Time `json:"createdAt,omitempty"` - UpdatedAt time.Time `json:"updatedAt,omitempty"` -} - -type ClientManagerInstallationResponse struct { - ID string `json:"id"` - ServerInstanceID string `json:"serverInstanceId"` - PluginID string `json:"pluginId"` - ProfileKey string `json:"profileKey"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - Status string `json:"status"` - Phase string `json:"phase,omitempty"` - DesiredVersion string `json:"desiredVersion,omitempty"` - ActiveVersion string `json:"activeVersion,omitempty"` - PreviousVersion string `json:"previousVersion,omitempty"` - DesiredRevision string `json:"desiredRevision,omitempty"` - ActiveRevision string `json:"activeRevision,omitempty"` - PreviousRevision string `json:"previousRevision,omitempty"` - DesiredArtifactID string `json:"desiredArtifactId,omitempty"` - ActiveArtifactID string `json:"activeArtifactId,omitempty"` - PreviousArtifactID string `json:"previousArtifactId,omitempty"` - KeyGeneration int `json:"keyGeneration"` - DeploymentGeneration int `json:"deploymentGeneration"` - CurrentJobID string `json:"currentJobId,omitempty"` - LastSuccessfulJobID string `json:"lastSuccessfulJobId,omitempty"` - LastOperation string `json:"lastOperation,omitempty"` - Health string `json:"health"` - HealthReason string `json:"healthReason,omitempty"` - LastSeenAt time.Time `json:"lastSeenAt,omitempty"` - Retryable bool `json:"retryable"` - RequiresRedeploy bool `json:"requiresRedeploy"` - InstalledAt time.Time `json:"installedAt,omitempty"` - UninstalledAt time.Time `json:"uninstalledAt,omitempty"` - UpdatedAt time.Time `json:"updatedAt"` - Distribution *ClientManagerDistributionSummaryResponse `json:"distribution,omitempty"` - Job *ClientManagerLifecycleJobResponse `json:"job,omitempty"` - Actions []ClientManagerLifecycleActionResponse `json:"actions"` -} - -type ClientManagerDistributionSummaryResponse struct { - ID string `json:"id"` - ArtifactID string `json:"artifactId"` - SourceRevision string `json:"sourceRevision"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - Checksum string `json:"checksum"` - KeyGeneration int `json:"keyGeneration"` - Status string `json:"status"` -} - -type ClientManagerInstallationListResponse struct { - Items []ClientManagerInstallationResponse `json:"items"` - Count int `json:"count"` -} - -type ClientManagerLifecycleInputRequest struct { - RunEndpointID string `json:"runEndpointId"` - SessionToken string `json:"sessionToken"` - JobID string `json:"jobId"` - LeaseToken string `json:"leaseToken"` - Attempt int `json:"attempt"` -} - -type ClientManagerLifecycleInputResponse struct { - InstallationID string `json:"installationId"` - ServerInstanceID string `json:"serverInstanceId"` - ProfileKey string `json:"profileKey"` - Operation string `json:"operation"` - ArtifactID string `json:"artifactId,omitempty"` - Checksum string `json:"checksum,omitempty"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - Version string `json:"version,omitempty"` - SourceRevision string `json:"sourceRevision,omitempty"` - KeyGeneration int `json:"keyGeneration"` - DeploymentGeneration int `json:"deploymentGeneration"` - ExecutableRef string `json:"executableRef"` - Arguments []string `json:"arguments"` - AutoStart bool `json:"autoStart"` - StartupTimeoutSeconds int `json:"startupTimeoutSeconds"` - StopTimeoutSeconds int `json:"stopTimeoutSeconds"` - HealthConfirmationSeconds int `json:"healthConfirmationSeconds"` - IdempotencyKey string `json:"idempotencyKey"` - CompanionConfig *ClientManagerCompanionConfigInputResponse `json:"companionConfig,omitempty"` -} - -type ClientManagerCompanionConfigInputResponse struct { - SchemaVersion int `json:"schemaVersion"` - ConfigTemplateKey string `json:"configTemplateKey"` - ConfigTemplateRef string `json:"configTemplateRef"` - ConfigOutputRef string `json:"configOutputRef"` - ConfigSchemaRef string `json:"configSchemaRef"` - ConfigFormat string `json:"configFormat"` - PlatformBaseURLSource string `json:"platformBaseUrlSource"` - InstallationID string `json:"installationId"` - ServerInstanceID string `json:"serverInstanceId"` - PluginID string `json:"pluginId"` - ProfileKey string `json:"profileKey"` - ArtifactID string `json:"artifactId"` - Version string `json:"version"` - SourceRevision string `json:"sourceRevision"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - KeyGeneration int `json:"keyGeneration"` - DeploymentGeneration int `json:"deploymentGeneration"` - Capabilities []string `json:"capabilities"` - RegistrationProof string `json:"registrationProof"` - ProofMaterialSource string `json:"proofMaterialSource"` - ProofMaterialEnv string `json:"proofMaterialEnv"` - SessionMode string `json:"sessionMode"` - TLSPolicy string `json:"tlsPolicy"` - HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"` - CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds"` - RequestTimeoutSeconds int `json:"requestTimeoutSeconds"` -} - -type ClientManagerRegisterRequest struct { - InstallationID string `json:"installationId"` - ServerInstanceID string `json:"serverInstanceId"` - ProfileKey string `json:"profileKey"` - ArtifactID string `json:"artifactId"` - Version string `json:"version"` - SourceRevision string `json:"sourceRevision"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - KeyGeneration int `json:"keyGeneration"` - DeploymentGeneration int `json:"deploymentGeneration"` - Capabilities []string `json:"capabilities"` - Timestamp time.Time `json:"timestamp"` - Nonce string `json:"nonce"` - Signature string `json:"signature"` -} - -type ClientManagerRegisterResponse struct { - Accepted bool `json:"accepted"` - InstallationID string `json:"installationId"` - SessionToken string `json:"sessionToken"` - ExpiresAt time.Time `json:"expiresAt"` - HeartbeatEvery int `json:"heartbeatEverySeconds"` - ServerTime time.Time `json:"serverTime"` -} - -type ClientManagerHeartbeatRequest struct { - InstallationID string `json:"installationId"` - SessionToken string `json:"sessionToken"` - Sequence uint64 `json:"sequence"` - Health string `json:"health"` - HealthReason string `json:"healthReason,omitempty"` - Capabilities []string `json:"capabilities"` - SentAt time.Time `json:"sentAt"` -} - -type ClientManagerHeartbeatResponse struct { - Accepted bool `json:"accepted"` - InstallationID string `json:"installationId"` - Status string `json:"status"` - Health string `json:"health"` - NextHeartbeat int `json:"nextHeartbeatSeconds"` - SessionExpiresAt time.Time `json:"sessionExpiresAt"` - ServerTime time.Time `json:"serverTime"` -} - -func (request ClientManagerDeployRequest) ToDomain(serverID string) domain.ClientManagerDeployRequest { - return domain.ClientManagerDeployRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, DistributionID: request.DistributionID, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey} -} - -func (request ClientManagerControlRequest) ToDomain(serverID string) domain.ClientManagerControlRequest { - return domain.ClientManagerControlRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, Operation: domain.ClientManagerLifecycleOperation(request.Operation), ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey} -} - -func (request ClientManagerUpdateRequest) ToDomain(serverID string) domain.ClientManagerUpdateRequest { - return domain.ClientManagerUpdateRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, DistributionID: request.DistributionID, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, Approved: request.Approved, IdempotencyKey: request.IdempotencyKey} -} - -func (request ClientManagerUninstallRequest) ToDomain(serverID string) domain.ClientManagerUninstallRequest { - return domain.ClientManagerUninstallRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, Confirmed: request.Confirmed, IdempotencyKey: request.IdempotencyKey} -} - -func (request ClientManagerRetryRequest) ToDomain(serverID string) domain.ClientManagerRetryRequest { - return domain.ClientManagerRetryRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey} -} - -func (request ClientManagerRevokeSessionRequest) ToDomain(serverID string) domain.ClientManagerRevokeSessionRequest { - return domain.ClientManagerRevokeSessionRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, Reason: request.Reason} -} - -func (request ClientManagerLifecycleInputRequest) ToDomain() domain.ClientManagerLifecycleInputRequest { - return domain.ClientManagerLifecycleInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt} -} - -func ClientManagerLifecycleInputFromDomain(value domain.ClientManagerLifecycleInput) ClientManagerLifecycleInputResponse { - value = domain.CopyClientManagerLifecycleInput(value) - response := ClientManagerLifecycleInputResponse{InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, ProfileKey: value.ProfileKey, Operation: string(value.Operation), ArtifactID: value.ArtifactID, Checksum: value.Checksum, TargetOS: value.TargetOS, TargetArch: value.TargetArch, Version: value.Version, SourceRevision: value.SourceRevision, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, ExecutableRef: value.ExecutableRef, Arguments: value.Arguments, AutoStart: value.AutoStart, StartupTimeoutSeconds: value.StartupTimeoutSeconds, StopTimeoutSeconds: value.StopTimeoutSeconds, HealthConfirmationSeconds: value.HealthConfirmationSeconds, IdempotencyKey: value.IdempotencyKey} - if value.CompanionConfig != nil { - companion := ClientManagerCompanionConfigInputFromDomain(*value.CompanionConfig) - response.CompanionConfig = &companion - } - return response -} - -func ClientManagerCompanionConfigInputFromDomain(value domain.ClientManagerCompanionConfigInput) ClientManagerCompanionConfigInputResponse { - value = domain.CopyClientManagerCompanionConfigInput(value) - return ClientManagerCompanionConfigInputResponse{SchemaVersion: value.SchemaVersion, ConfigTemplateKey: value.ConfigTemplateKey, ConfigTemplateRef: value.ConfigTemplateRef, ConfigOutputRef: value.ConfigOutputRef, ConfigSchemaRef: value.ConfigSchemaRef, ConfigFormat: value.ConfigFormat, PlatformBaseURLSource: value.PlatformBaseURLSource, InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, ProfileKey: value.ProfileKey, ArtifactID: value.ArtifactID, Version: value.Version, SourceRevision: value.SourceRevision, TargetOS: value.TargetOS, TargetArch: value.TargetArch, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, Capabilities: value.Capabilities, RegistrationProof: value.RegistrationProof, ProofMaterialSource: value.ProofMaterialSource, ProofMaterialEnv: value.ProofMaterialEnv, SessionMode: value.SessionMode, TLSPolicy: value.TLSPolicy, HeartbeatIntervalSeconds: value.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.RequestTimeoutSeconds} -} - -func (request ClientManagerRegisterRequest) ToDomain() domain.ClientManagerRegisterRequest { - return domain.ClientManagerRegisterRequest{InstallationID: request.InstallationID, ServerInstanceID: request.ServerInstanceID, ProfileKey: request.ProfileKey, ArtifactID: request.ArtifactID, Version: request.Version, SourceRevision: request.SourceRevision, TargetOS: request.TargetOS, TargetArch: request.TargetArch, KeyGeneration: request.KeyGeneration, DeploymentGeneration: request.DeploymentGeneration, Capabilities: domain.CopyStringSlice(request.Capabilities), Timestamp: request.Timestamp, Nonce: request.Nonce, Signature: request.Signature} -} - -func ClientManagerRegisterFromDomain(value domain.ClientManagerRegisterResult) ClientManagerRegisterResponse { - return ClientManagerRegisterResponse{Accepted: value.Accepted, InstallationID: value.InstallationID, SessionToken: value.SessionToken, ExpiresAt: value.ExpiresAt, HeartbeatEvery: value.HeartbeatEvery, ServerTime: value.ServerTime} -} - -func (request ClientManagerHeartbeatRequest) ToDomain() domain.ClientManagerHeartbeat { - return domain.ClientManagerHeartbeat{InstallationID: request.InstallationID, SessionToken: request.SessionToken, Sequence: request.Sequence, Health: domain.ClientManagerHealthStatus(request.Health), HealthReason: request.HealthReason, Capabilities: domain.CopyStringSlice(request.Capabilities), SentAt: request.SentAt} -} - -func ClientManagerHeartbeatFromDomain(value domain.ClientManagerHeartbeatResult) ClientManagerHeartbeatResponse { - return ClientManagerHeartbeatResponse{Accepted: value.Accepted, InstallationID: value.InstallationID, Status: string(value.Status), Health: string(value.Health), NextHeartbeat: value.NextHeartbeat, SessionExpiresAt: value.SessionExpiresAt, ServerTime: value.ServerTime} -} - -func ClientManagerLifecycleViewFromDomain(value domain.ClientManagerLifecycleView) ClientManagerInstallationResponse { - value = domain.CopyClientManagerLifecycleView(value) - installation := value.Installation - response := ClientManagerInstallationResponse{ID: installation.ID, ServerInstanceID: installation.ServerInstanceID, PluginID: installation.PluginID, ProfileKey: installation.ProfileKey, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Status: string(installation.Status), Phase: installation.Phase, DesiredVersion: installation.DesiredVersion, ActiveVersion: installation.ActiveVersion, PreviousVersion: installation.PreviousVersion, DesiredRevision: installation.DesiredRevision, ActiveRevision: installation.ActiveRevision, PreviousRevision: installation.PreviousRevision, DesiredArtifactID: installation.DesiredArtifactID, ActiveArtifactID: installation.ActiveArtifactID, PreviousArtifactID: installation.PreviousArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, CurrentJobID: installation.CurrentJobID, LastSuccessfulJobID: installation.LastSuccessfulJobID, LastOperation: string(installation.LastOperation), Health: string(installation.Health), HealthReason: installation.HealthReason, LastSeenAt: installation.LastSeenAt, Retryable: installation.Retryable, RequiresRedeploy: installation.RequiresRedeploy, InstalledAt: installation.InstalledAt, UninstalledAt: installation.UninstalledAt, UpdatedAt: installation.UpdatedAt} - if value.Distribution.ID != "" { - response.Distribution = &ClientManagerDistributionSummaryResponse{ID: value.Distribution.ID, ArtifactID: value.Distribution.ArtifactID, SourceRevision: value.Distribution.SourceRevision, TargetOS: value.Distribution.TargetOS, TargetArch: value.Distribution.TargetArch, Checksum: value.Distribution.Checksum, KeyGeneration: value.Distribution.KeyGeneration, Status: string(value.Distribution.Status)} - } - if value.Job.ID != "" { - response.Job = &ClientManagerLifecycleJobResponse{ID: value.Job.ID, State: value.Job.State, Progress: JobProgressBody{Percent: value.Job.Progress.Percent, Message: value.Job.Progress.Message}, Attempt: value.Job.Attempt, CreatedAt: value.Job.CreatedAt, UpdatedAt: value.Job.UpdatedAt} - } - response.Actions = make([]ClientManagerLifecycleActionResponse, len(value.Actions)) - for i, action := range value.Actions { - response.Actions[i] = ClientManagerLifecycleActionResponse{Operation: string(action.Operation), Available: action.Available, Reason: action.Reason} - } - if response.Actions == nil { - response.Actions = []ClientManagerLifecycleActionResponse{} - } - return response -} - -func ClientManagerLifecycleViewsFromDomain(values []domain.ClientManagerLifecycleView) ClientManagerInstallationListResponse { - items := make([]ClientManagerInstallationResponse, len(values)) - for i, value := range values { - items[i] = ClientManagerLifecycleViewFromDomain(value) - } - return ClientManagerInstallationListResponse{Items: items, Count: len(items)} -} diff --git a/platform/dto/client_manager_lifecycle_companion_test.go b/platform/dto/client_manager_lifecycle_companion_test.go deleted file mode 100644 index a301122..0000000 --- a/platform/dto/client_manager_lifecycle_companion_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package dto - -import ( - "encoding/json" - "strings" - "testing" - - "browser.local/platform/domain" -) - -func TestClientManagerLifecycleInputProjectsRunOnlyCompanionConfigWithoutRawMaterial(t *testing.T) { - response := ClientManagerLifecycleInputFromDomain(domain.ClientManagerLifecycleInput{ - InstallationID: "installation-1", - ServerInstanceID: "server-1", - ProfileKey: "scum-client-manager", - Operation: domain.ClientManagerOperationDeploy, - DeploymentGeneration: 2, - CompanionConfig: &domain.ClientManagerCompanionConfigInput{ - SchemaVersion: 1, - ConfigTemplateKey: "client-config", - ConfigTemplateRef: "config.yaml.example", - ConfigOutputRef: "config.yaml", - ConfigSchemaRef: "schemas/companion/config.schema.json", - ConfigFormat: "yaml", - PlatformBaseURLSource: "run-control", - InstallationID: "installation-1", - ServerInstanceID: "server-1", - PluginID: "game.scum", - ProfileKey: "scum-client-manager", - ArtifactID: "artifact-1", - Version: "1.0.0", - SourceRevision: "revision-1", - TargetOS: "windows", - TargetArch: "amd64", - KeyGeneration: 3, - DeploymentGeneration: 2, - Capabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"}, - RegistrationProof: "hmac-sha256", - ProofMaterialSource: "component-package", - ProofMaterialEnv: "SCUM_COMPONENT_PROOF", - SessionMode: "component-session", - TLSPolicy: "verify-system-roots", - HeartbeatIntervalSeconds: 30, - CommandPollIntervalSeconds: 5, - RequestTimeoutSeconds: 15, - }, - }) - if response.CompanionConfig == nil || response.CompanionConfig.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" { - t.Fatalf("expected run-only companion projection, got %+v", response.CompanionConfig) - } - encoded, err := json.Marshal(response) - if err != nil { - t.Fatalf("marshal lifecycle input: %v", err) - } - serialized := string(encoded) - for _, forbidden := range []string{`"proofMaterial":`, `"authKey":`, `"componentKey":`, `"sessionToken":`, `"secretRef":`, "/Users/", "run.socket"} { - if strings.Contains(serialized, forbidden) { - t.Fatalf("run lifecycle input leaked %q: %s", forbidden, serialized) - } - } - - browserProjection, err := json.Marshal(ClientManagerLifecycleViewFromDomain(domain.ClientManagerLifecycleView{Installation: domain.ClientManagerInstallation{ID: "installation-1"}})) - if err != nil { - t.Fatalf("marshal browser lifecycle projection: %v", err) - } - if strings.Contains(string(browserProjection), "companionConfig") || strings.Contains(string(browserProjection), "proofMaterialEnv") { - t.Fatalf("browser lifecycle projection exposed run-only config: %s", browserProjection) - } -} diff --git a/platform/dto/distributions.go b/platform/dto/distributions.go index baf4a34..8401fe6 100644 --- a/platform/dto/distributions.go +++ b/platform/dto/distributions.go @@ -80,19 +80,6 @@ type LogBackfillRequest struct { IdempotencyKey string `json:"idempotencyKey,omitempty"` } -type ClientManagerDownloadRequest struct { - ProfileKey string `json:"profileKey,omitempty"` -} - -type ClientManagerBuildRequest struct { - ProfileKey string `json:"profileKey"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - RepositoryURL string `json:"repositoryUrl"` - SourceRevision string `json:"sourceRevision,omitempty"` - IdempotencyKey string `json:"idempotencyKey,omitempty"` -} - type ComponentKeyResetRequest struct { ComponentKind string `json:"componentKind"` ComponentKey string `json:"componentKey,omitempty"` @@ -145,26 +132,6 @@ type RunDistributionResponse struct { UpdatedAt time.Time `json:"updatedAt"` } -type ClientManagerDistributionResponse struct { - ID string `json:"id"` - ServerInstanceID string `json:"serverInstanceId"` - PluginID string `json:"pluginId"` - ProfileKey string `json:"profileKey"` - Version string `json:"version,omitempty"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - RepositoryURL string `json:"repositoryUrl"` - SourceRevision string `json:"sourceRevision"` - BuildJobID string `json:"buildJobId"` - ArtifactID string `json:"artifactId"` - Checksum string `json:"checksum"` - KeyGeneration int `json:"keyGeneration"` - SecretRef string `json:"secretRef"` - Status string `json:"status"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - type DependencyStatusResponse struct { ID string `json:"id"` ServerInstanceID string `json:"serverInstanceId"` @@ -225,25 +192,6 @@ type DependencyCatalogResponse struct { UpdatedAt time.Time `json:"updatedAt"` } -type ClientManagerBuildJobResponse struct { - ID string `json:"id"` - ServerInstanceID string `json:"serverInstanceId"` - PluginID string `json:"pluginId"` - ProfileKey string `json:"profileKey"` - Version string `json:"version,omitempty"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - RepositoryURL string `json:"repositoryUrl"` - SourceRevision string `json:"sourceRevision"` - ArtifactID string `json:"artifactId,omitempty"` - Checksum string `json:"checksum,omitempty"` - KeyGeneration int `json:"keyGeneration,omitempty"` - LogsRef string `json:"logsRef,omitempty"` - Status string `json:"status"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - type RunUpdateJobResponse struct { ID string `json:"id"` ServerInstanceID string `json:"serverInstanceId"` @@ -310,18 +258,6 @@ func (request LogBackfillRequest) ToDomain(serverInstanceID string) domain.LogBa } } -func (request ClientManagerBuildRequest) ToDomain(serverInstanceID string) domain.ClientManagerBuildRequest { - return domain.ClientManagerBuildRequest{ - ServerInstanceID: serverInstanceID, - ProfileKey: request.ProfileKey, - TargetOS: request.TargetOS, - TargetArch: request.TargetArch, - RepositoryURL: request.RepositoryURL, - SourceRevision: request.SourceRevision, - IdempotencyKey: request.IdempotencyKey, - } -} - func (request ComponentKeyResetRequest) ToDomain(serverInstanceID string) domain.ComponentKeyResetRequest { return domain.ComponentKeyResetRequest{ ServerInstanceID: serverInstanceID, @@ -381,28 +317,6 @@ func RunDistributionFromDomain(distribution domain.RunDistribution) RunDistribut } } -func ClientManagerDistributionFromDomain(distribution domain.ClientManagerDistribution) ClientManagerDistributionResponse { - return ClientManagerDistributionResponse{ - ID: distribution.ID, - ServerInstanceID: distribution.ServerInstanceID, - PluginID: distribution.PluginID, - ProfileKey: distribution.ProfileKey, - Version: distribution.Version, - TargetOS: distribution.TargetOS, - TargetArch: distribution.TargetArch, - RepositoryURL: distribution.RepositoryURL, - SourceRevision: distribution.SourceRevision, - BuildJobID: distribution.BuildJobID, - ArtifactID: distribution.ArtifactID, - Checksum: distribution.Checksum, - KeyGeneration: distribution.KeyGeneration, - SecretRef: distribution.SecretRef, - Status: string(distribution.Status), - CreatedAt: distribution.CreatedAt, - UpdatedAt: distribution.UpdatedAt, - } -} - func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatusResponse { return DependencyStatusResponse{ ID: status.ID, @@ -441,27 +355,6 @@ func DependencyCatalogFromDomain(catalog domain.DependencyCatalog) DependencyCat return DependencyCatalogResponse{ServerInstanceID: catalog.ServerInstanceID, PluginID: catalog.PluginID, PluginVersion: catalog.PluginVersion, ProfileKey: catalog.ProfileKey, TargetOS: catalog.TargetOS, TargetArch: catalog.TargetArch, Probes: probes, Plans: plans, UpdatedAt: catalog.UpdatedAt} } -func ClientManagerBuildJobFromDomain(job domain.ClientManagerBuildJob) ClientManagerBuildJobResponse { - return ClientManagerBuildJobResponse{ - ID: job.ID, - ServerInstanceID: job.ServerInstanceID, - PluginID: job.PluginID, - ProfileKey: job.ProfileKey, - Version: job.Version, - TargetOS: job.TargetOS, - TargetArch: job.TargetArch, - RepositoryURL: job.RepositoryURL, - SourceRevision: job.SourceRevision, - ArtifactID: job.ArtifactID, - Checksum: job.Checksum, - KeyGeneration: job.KeyGeneration, - LogsRef: job.LogsRef, - Status: string(job.Status), - CreatedAt: job.CreatedAt, - UpdatedAt: job.UpdatedAt, - } -} - func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJobResponse { return RunUpdateJobResponse{ ID: job.ID, diff --git a/platform/dto/runtime_profiles.go b/platform/dto/runtime_profiles.go index 7844130..bf5a498 100644 --- a/platform/dto/runtime_profiles.go +++ b/platform/dto/runtime_profiles.go @@ -1,6 +1,8 @@ package dto import ( + "encoding/json" + "fmt" "net/url" "path" @@ -27,7 +29,7 @@ type RuntimeLifecycleProfileBody struct { Capabilities []string `json:"capabilities"` ActionRefs PluginLifecycleActionsBody `json:"actionRefs,omitempty"` TransportKeys []string `json:"transportKeys,omitempty"` - ClientManagerRef string `json:"clientManagerRef,omitempty"` + ClientManagerRef json.RawMessage `json:"clientManagerRef,omitempty"` DLLExtensionRefs []string `json:"dllExtensionRefs,omitempty"` Platforms []string `json:"platforms,omitempty"` } @@ -148,57 +150,6 @@ type RuntimeConfigTemplateBody struct { OutputRef string `json:"outputRef"` } -type RuntimeClientManagerDeploymentBody struct { - Mode string `json:"mode"` - ExecutableRef string `json:"executableRef"` - Arguments []string `json:"arguments,omitempty"` - AutoStart bool `json:"autoStart,omitempty"` - RequiredRunCapabilities []string `json:"requiredRunCapabilities"` -} - -type RuntimeClientManagerLifecycleBody struct { - Actions []string `json:"actions"` - StartupTimeoutSeconds int `json:"startupTimeoutSeconds"` - StopTimeoutSeconds int `json:"stopTimeoutSeconds"` -} - -type RuntimeClientManagerHealthBody struct { - Mode string `json:"mode"` - IntervalSeconds int `json:"intervalSeconds"` - DegradedAfterSeconds int `json:"degradedAfterSeconds"` - OfflineAfterSeconds int `json:"offlineAfterSeconds"` - RequiredCapabilities []string `json:"requiredCapabilities"` -} - -type RuntimeClientManagerCompatibilityBody struct { - MinimumVersion string `json:"minimumVersion,omitempty"` - MaximumVersion string `json:"maximumVersion,omitempty"` - AllowDowngrade bool `json:"allowDowngrade"` -} - -type RuntimeClientManagerUpdatePolicyBody struct { - Strategy string `json:"strategy"` - RequireApproval bool `json:"requireApproval"` - HealthConfirmationSeconds int `json:"healthConfirmationSeconds"` - RetainPrevious bool `json:"retainPrevious"` -} - -type RuntimeClientManagerProfileBody struct { - Key string `json:"key"` - DisplayName string `json:"displayName,omitempty"` - Version string `json:"version,omitempty"` - Repository RuntimeRepositoryBody `json:"repository"` - SupportedTargets []RuntimeTargetBody `json:"supportedTargets"` - Build RuntimeBuildBody `json:"build"` - ConfigTemplates []RuntimeConfigTemplateBody `json:"configTemplates,omitempty"` - OutputArtifacts []string `json:"outputArtifacts"` - Deployment RuntimeClientManagerDeploymentBody `json:"deployment,omitempty"` - Lifecycle RuntimeClientManagerLifecycleBody `json:"lifecycle,omitempty"` - Health RuntimeClientManagerHealthBody `json:"health,omitempty"` - Compatibility RuntimeClientManagerCompatibilityBody `json:"compatibility,omitempty"` - UpdatePolicy RuntimeClientManagerUpdatePolicyBody `json:"updatePolicy,omitempty"` -} - type RuntimeDLLExtensionProfileBody struct { Key string `json:"key"` DisplayName string `json:"displayName"` @@ -264,10 +215,23 @@ type GamePluginRuntimeProfilesBody struct { LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"` DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"` - ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"` + ClientManagers *[]json.RawMessage `json:"clientManagers,omitempty"` DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"` } +func (body GamePluginRuntimeProfilesBody) UnsupportedLegacyClientManagerViolations(prefix string) []string { + var violations []string + if body.ClientManagers != nil { + violations = append(violations, prefix+".clientManagers is no longer supported") + } + for i, profile := range body.LifecycleProfiles { + if len(profile.ClientManagerRef) > 0 { + violations = append(violations, fmt.Sprintf("%s.lifecycleProfiles[%d].clientManagerRef is no longer supported", prefix, i)) + } + } + return violations +} + // GamePluginRuntimeProfilesResponseBody is intentionally distinct from the // manifest input shape so browser responses cannot accidentally include // machine-local DLL placement or RCON activation details. @@ -289,7 +253,7 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro profiles.Discovery = append(profiles.Discovery, domain.RuntimeDiscoveryProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, Expected: item.Expected, Platforms: domain.CopyStringSlice(item.Platforms)}) } for _, item := range body.LifecycleProfiles { - profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), ClientManagerRef: item.ClientManagerRef, DLLExtensionRefs: domain.CopyStringSlice(item.DLLExtensionRefs), Platforms: domain.CopyStringSlice(item.Platforms)}) + profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), DLLExtensionRefs: domain.CopyStringSlice(item.DLLExtensionRefs), Platforms: domain.CopyStringSlice(item.Platforms)}) } for _, item := range body.DependencyProbes { profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: domain.CopyStringSlice(item.Platforms)}) @@ -329,25 +293,6 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro for _, item := range body.DataTargets { profiles.DataTargets = append(profiles.DataTargets, domain.RuntimeDataTarget{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: domain.CopyStringSlice(item.Platforms)}) } - for _, item := range body.ClientManagers { - manager := domain.RuntimeClientManagerProfile{ - Key: item.Key, DisplayName: item.DisplayName, Version: item.Version, - RepositoryURL: item.Repository.URL, RevisionPolicy: item.Repository.RevisionPolicy, Branch: item.Repository.Branch, Tag: item.Repository.Tag, Revision: item.Repository.Revision, - BuildSystem: item.Build.System, WorkspaceRef: item.Build.WorkspaceRef, EntryRef: item.Build.EntryRef, OutputArtifacts: domain.CopyStringSlice(item.OutputArtifacts), - Deployment: domain.RuntimeClientManagerDeployment{Mode: item.Deployment.Mode, ExecutableRef: item.Deployment.ExecutableRef, Arguments: domain.CopyStringSlice(item.Deployment.Arguments), AutoStart: item.Deployment.AutoStart, RequiredRunCapabilities: domain.CopyStringSlice(item.Deployment.RequiredRunCapabilities)}, - Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: domain.CopyStringSlice(item.Lifecycle.Actions), StartupTimeoutSeconds: item.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: item.Lifecycle.StopTimeoutSeconds}, - Health: domain.RuntimeClientManagerHealth{Mode: item.Health.Mode, IntervalSeconds: item.Health.IntervalSeconds, DegradedAfterSeconds: item.Health.DegradedAfterSeconds, OfflineAfterSeconds: item.Health.OfflineAfterSeconds, RequiredCapabilities: domain.CopyStringSlice(item.Health.RequiredCapabilities)}, - Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: item.Compatibility.MinimumVersion, MaximumVersion: item.Compatibility.MaximumVersion, AllowDowngrade: item.Compatibility.AllowDowngrade}, - UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{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, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch}) - } - for _, config := range item.ConfigTemplates { - manager.ConfigTemplates = append(manager.ConfigTemplates, domain.RuntimeConfigTemplate{Key: config.Key, TemplateRef: config.TemplateRef, OutputRef: config.OutputRef}) - } - profiles.ClientManagers = append(profiles.ClientManagers, manager) - } for _, item := range body.DLLExtensions { extension := domain.RuntimeDLLExtensionProfile{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, SCUMExecutableChecksum: item.SCUMExecutableChecksum, UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart, RCONPort: item.RCONPort} for _, target := range item.SupportedTargets { diff --git a/platform/model/client_manager_lifecycle.go b/platform/model/client_manager_lifecycle.go deleted file mode 100644 index 73f927c..0000000 --- a/platform/model/client_manager_lifecycle.go +++ /dev/null @@ -1,114 +0,0 @@ -package model - -import ( - "time" - - "browser.local/platform/domain" -) - -// ClientManagerInstallation is the durable desired/observed lifecycle aggregate for one server/profile. -type ClientManagerInstallation struct { - // ID is the stable installation identifier. - ID string `json:"id" db:"id"` - // ServerInstanceID scopes the installation to one authorized server. - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - // PluginID identifies the installed game plugin declaration. - PluginID string `json:"pluginId" db:"plugin_id"` - // ProfileKey identifies the plugin client-manager profile. - ProfileKey string `json:"profileKey" db:"profile_key"` - // RunEndpointID is the fenced machine executor assignment. - RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"` - // TargetOS is the package operating-system target. - TargetOS string `json:"targetOs" db:"target_os"` - // TargetArch is the package architecture target. - TargetArch string `json:"targetArch" db:"target_arch"` - // Status is the durable lifecycle state. - Status domain.ClientManagerLifecycleStatus `json:"status" db:"status"` - // Phase is a bounded safe execution phase. - Phase string `json:"phase" db:"phase"` - DesiredVersion string `json:"desiredVersion" db:"desired_version"` - ActiveVersion string `json:"activeVersion" db:"active_version"` - PreviousVersion string `json:"previousVersion" db:"previous_version"` - DesiredRevision string `json:"desiredRevision" db:"desired_revision"` - ActiveRevision string `json:"activeRevision" db:"active_revision"` - PreviousRevision string `json:"previousRevision" db:"previous_revision"` - DesiredArtifactID string `json:"desiredArtifactId" db:"desired_artifact_id"` - ActiveArtifactID string `json:"activeArtifactId" db:"active_artifact_id"` - PreviousArtifactID string `json:"previousArtifactId" db:"previous_artifact_id"` - Checksum string `json:"checksum" db:"checksum"` - KeyGeneration int `json:"keyGeneration" db:"key_generation"` - DeploymentGeneration int `json:"deploymentGeneration" db:"deployment_generation"` - CurrentJobID string `json:"currentJobId" db:"current_job_id"` - LastSuccessfulJobID string `json:"lastSuccessfulJobId" db:"last_successful_job_id"` - LastOperation domain.ClientManagerLifecycleOperation `json:"lastOperation" db:"last_operation"` - Health domain.ClientManagerHealthStatus `json:"health" db:"health"` - HealthReason string `json:"healthReason" db:"health_reason"` - LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"` - LastHeartbeatSequence uint64 `json:"lastHeartbeatSequence" db:"last_heartbeat_sequence"` - Retryable bool `json:"retryable" db:"retryable"` - RequiresRedeploy bool `json:"requiresRedeploy" db:"requires_redeploy"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - InstalledAt time.Time `json:"installedAt" db:"installed_at"` - UninstalledAt time.Time `json:"uninstalledAt" db:"uninstalled_at"` -} - -func (ClientManagerInstallation) TableName() string { return "client_manager_installations" } - -// ClientManagerSession stores only the hash and fences for a short-lived component session. -type ClientManagerSession struct { - ID string `json:"id" db:"id"` - InstallationID string `json:"installationId" db:"installation_id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - ProfileKey string `json:"profileKey" db:"profile_key"` - RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"` - ArtifactID string `json:"artifactId" db:"artifact_id"` - KeyGeneration int `json:"keyGeneration" db:"key_generation"` - DeploymentGeneration int `json:"deploymentGeneration" db:"deployment_generation"` - TokenHash string `json:"tokenHash" db:"token_hash"` - Capabilities []string `json:"capabilities" db:"capabilities"` - Status domain.ClientManagerSessionStatus `json:"status" db:"status"` - LastHeartbeatSequence uint64 `json:"lastHeartbeatSequence" db:"last_heartbeat_sequence"` - LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"` - ExpiresAt time.Time `json:"expiresAt" db:"expires_at"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - RevokedAt time.Time `json:"revokedAt" db:"revoked_at"` -} - -func (ClientManagerSession) TableName() string { return "client_manager_sessions" } - -// ClientManagerRegistrationNonce is a bounded replay fence for one signed registration request. -type ClientManagerRegistrationNonce struct { - ID string `json:"id" db:"id"` - InstallationID string `json:"installationId" db:"installation_id"` - ExpiresAt time.Time `json:"expiresAt" db:"expires_at"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` -} - -func (ClientManagerRegistrationNonce) TableName() string { return "client_manager_registration_nonces" } - -func ClientManagerInstallationFromDomain(value domain.ClientManagerInstallation) ClientManagerInstallation { - return ClientManagerInstallation(value) -} - -func (value ClientManagerInstallation) ToDomain() domain.ClientManagerInstallation { - return domain.ClientManagerInstallation(value) -} - -func ClientManagerSessionFromDomain(value domain.ClientManagerSession) ClientManagerSession { - value = domain.CopyClientManagerSession(value) - return ClientManagerSession(value) -} - -func (value ClientManagerSession) ToDomain() domain.ClientManagerSession { - return domain.CopyClientManagerSession(domain.ClientManagerSession(value)) -} - -func ClientManagerRegistrationNonceFromDomain(value domain.ClientManagerRegistrationNonce) ClientManagerRegistrationNonce { - return ClientManagerRegistrationNonce(value) -} - -func (value ClientManagerRegistrationNonce) ToDomain() domain.ClientManagerRegistrationNonce { - return domain.ClientManagerRegistrationNonce(value) -} diff --git a/platform/model/distributions.go b/platform/model/distributions.go index c021639..ab3e324 100644 --- a/platform/model/distributions.go +++ b/platform/model/distributions.go @@ -60,28 +60,6 @@ type RunDistribution struct { func (RunDistribution) TableName() string { return "run_distributions" } -type ClientManagerDistribution struct { - ID string `json:"id" db:"id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - PluginID string `json:"pluginId" db:"plugin_id"` - ProfileKey string `json:"profileKey" db:"profile_key"` - Version string `json:"version" db:"version"` - TargetOS string `json:"targetOs" db:"target_os"` - TargetArch string `json:"targetArch" db:"target_arch"` - RepositoryURL string `json:"repositoryUrl" db:"repository_url"` - SourceRevision string `json:"sourceRevision" db:"source_revision"` - BuildJobID string `json:"buildJobId" db:"build_job_id"` - ArtifactID string `json:"artifactId" db:"artifact_id"` - Checksum string `json:"checksum" db:"checksum"` - KeyGeneration int `json:"keyGeneration" db:"key_generation"` - SecretRef string `json:"secretRef" db:"secret_ref"` - Status domain.DistributionStatus `json:"status" db:"status"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` -} - -func (ClientManagerDistribution) TableName() string { return "client_manager_distributions" } - type DependencyStatus struct { ID string `json:"id" db:"id"` ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` @@ -103,27 +81,6 @@ type DependencyStatus struct { func (DependencyStatus) TableName() string { return "dependency_statuses" } -type ClientManagerBuildJob struct { - ID string `json:"id" db:"id"` - ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` - PluginID string `json:"pluginId" db:"plugin_id"` - ProfileKey string `json:"profileKey" db:"profile_key"` - Version string `json:"version" db:"version"` - TargetOS string `json:"targetOs" db:"target_os"` - TargetArch string `json:"targetArch" db:"target_arch"` - RepositoryURL string `json:"repositoryUrl" db:"repository_url"` - SourceRevision string `json:"sourceRevision" db:"source_revision"` - ArtifactID string `json:"artifactId" db:"artifact_id"` - Checksum string `json:"checksum" db:"checksum"` - KeyGeneration int `json:"keyGeneration" db:"key_generation"` - LogsRef string `json:"logsRef,omitempty" db:"logs_ref"` - Status domain.DistributionJobStatus `json:"status" db:"status"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` -} - -func (ClientManagerBuildJob) TableName() string { return "client_manager_build_jobs" } - type RunUpdateJob struct { ID string `json:"id" db:"id"` ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` @@ -195,14 +152,6 @@ func (distribution RunDistribution) ToDomain() domain.RunDistribution { return domain.RunDistribution(distribution) } -func ClientManagerDistributionFromDomain(distribution domain.ClientManagerDistribution) ClientManagerDistribution { - return ClientManagerDistribution(distribution) -} - -func (distribution ClientManagerDistribution) ToDomain() domain.ClientManagerDistribution { - return domain.ClientManagerDistribution(distribution) -} - func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatus { return DependencyStatus(status) } @@ -211,14 +160,6 @@ func (status DependencyStatus) ToDomain() domain.DependencyStatus { return domain.DependencyStatus(status) } -func ClientManagerBuildJobFromDomain(job domain.ClientManagerBuildJob) ClientManagerBuildJob { - return ClientManagerBuildJob(job) -} - -func (job ClientManagerBuildJob) ToDomain() domain.ClientManagerBuildJob { - return domain.ClientManagerBuildJob(job) -} - func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJob { return RunUpdateJob(job) } diff --git a/platform/model/resources_test.go b/platform/model/resources_test.go index 4ad8872..a003be1 100644 --- a/platform/model/resources_test.go +++ b/platform/model/resources_test.go @@ -9,19 +9,16 @@ import ( func TestTableNames(t *testing.T) { tests := map[string]string{ - User{}.TableName(): "users", - AIProvider{}.TableName(): "ai_providers", - GamePlugin{}.TableName(): "game_plugins", - ServerInstance{}.TableName(): "server_instances", - RunEndpoint{}.TableName(): "run_endpoints", - Job{}.TableName(): "jobs", - Artifact{}.TableName(): "artifacts", - LogStream{}.TableName(): "log_streams", - PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations", - AIConfigDiff{}.TableName(): "ai_config_diffs", - ClientManagerInstallation{}.TableName(): "client_manager_installations", - ClientManagerSession{}.TableName(): "client_manager_sessions", - ClientManagerRegistrationNonce{}.TableName(): "client_manager_registration_nonces", + User{}.TableName(): "users", + AIProvider{}.TableName(): "ai_providers", + GamePlugin{}.TableName(): "game_plugins", + ServerInstance{}.TableName(): "server_instances", + RunEndpoint{}.TableName(): "run_endpoints", + Job{}.TableName(): "jobs", + Artifact{}.TableName(): "artifacts", + LogStream{}.TableName(): "log_streams", + PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations", + AIConfigDiff{}.TableName(): "ai_config_diffs", } for got, want := range tests { diff --git a/platform/protocol/dependency-update-contracts.md b/platform/protocol/dependency-update-contracts.md index adbc4f2..62abd9d 100644 --- a/platform/protocol/dependency-update-contracts.md +++ b/platform/protocol/dependency-update-contracts.md @@ -16,4 +16,4 @@ Platform owns the reviewable dependency catalog, immutable plan digest, selected 3. The terminal staged result moves the safe phase to `restart-requested`. The local journal persists the activation manifest before helper launch. The helper backs up/replaces atomically, starts the new binary with helper environment removed, waits for health, and rolls back on timeout or identity failure. 4. The new Run reports success or rollback through signed `update-health` only after registration and job reconciliation. Platform then projects `succeeded` or `rolled-back`; a hello-only outcome is never treated as health confirmation. -Control heartbeat, job ack/result/cancel/reconcile, durable logs, and artifact upload use independent loops and deadlines. This contract does not include production code signing/KMS, rollout rings/fleet orchestration, client-manager lifecycle, plugin lifecycle, external mirrors/storage, or real AI-provider integration. +Control heartbeat, job ack/result/cancel/reconcile, durable logs, and artifact upload use independent loops and deadlines. This contract does not include production code signing/KMS, rollout rings/fleet orchestration, plugin lifecycle, external mirrors/storage, or real AI-provider integration. diff --git a/platform/repo/client_manager_lifecycle_test.go b/platform/repo/client_manager_lifecycle_test.go deleted file mode 100644 index 1e6bd04..0000000 --- a/platform/repo/client_manager_lifecycle_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package repo - -import ( - "path/filepath" - "testing" - "time" - - "browser.local/platform/domain" -) - -func TestClientManagerLifecycleRepositoriesPersistAndFilter(t *testing.T) { - path := filepath.Join(t.TempDir(), "metadata.json") - store, err := NewFileStore(path) - if err != nil { - t.Fatalf("create file store: %v", err) - } - stamp := time.Date(2026, 7, 18, 3, 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", ActiveVersion: "1.0.0", ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3, Health: domain.ClientManagerHealthHealthy, LastSeenAt: stamp, CreatedAt: stamp, UpdatedAt: stamp} - if err := store.ClientManagerInstallations().Create(installation); err != nil { - t.Fatalf("persist installation: %v", err) - } - session := domain.ClientManagerSession{ID: "cm-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: 2, DeploymentGeneration: 3, TokenHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Capabilities: []string{"component.register", "component.heartbeat"}, Status: domain.ClientManagerSessionActive, LastSeenAt: stamp, ExpiresAt: stamp.Add(15 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp} - if err := store.ClientManagerSessions().Create(session); err != nil { - t.Fatalf("persist session: %v", err) - } - nonce := domain.ClientManagerRegistrationNonce{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", InstallationID: installation.ID, CreatedAt: stamp, ExpiresAt: stamp.Add(5 * time.Minute)} - if err := store.ClientManagerNonces().Create(nonce); err != nil { - t.Fatalf("persist nonce: %v", err) - } - - restarted, err := NewFileStore(path) - if err != nil { - t.Fatalf("reload file store: %v", err) - } - installations, err := restarted.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: "server-1", ProfileKey: "scum-client-manager", Status: domain.ClientManagerLifecycleOnline}) - if err != nil || len(installations) != 1 || installations[0].DeploymentGeneration != 3 { - t.Fatalf("unexpected persisted installations: items=%+v err=%v", installations, err) - } - sessions, err := restarted.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive}) - if err != nil || len(sessions) != 1 { - t.Fatalf("unexpected persisted sessions: items=%+v err=%v", sessions, err) - } - sessions[0].Capabilities[0] = "mutated" - stored, _ := restarted.ClientManagerSessions().Get(session.ID) - if stored.Capabilities[0] != "component.register" { - t.Fatalf("repository returned shared capability storage: %+v", stored) - } - expired, err := restarted.ClientManagerNonces().List(domain.ClientManagerNonceFilter{ExpiresBefore: stamp.Add(6 * time.Minute)}) - if err != nil || len(expired) != 1 { - t.Fatalf("unexpected nonce expiry filter: items=%+v err=%v", expired, err) - } - if err := restarted.ClientManagerNonces().Delete(nonce.ID); err != nil { - t.Fatalf("delete expired nonce: %v", err) - } - if _, err := restarted.ClientManagerNonces().Get(nonce.ID); err != ErrNotFound { - t.Fatalf("expected deleted nonce, got %v", err) - } -} diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index fb89dd7..ca798b3 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -13,34 +13,29 @@ import ( ) type StoreSnapshot struct { - Users []domain.User `json:"users"` - AuthSessions []domain.AuthSessionRecord `json:"authSessions"` - RunControlSessions []domain.RunControlSession `json:"runControlSessions"` - AIProviders []domain.AIProvider `json:"aiProviders"` - GamePlugins []domain.GamePlugin `json:"gamePlugins"` - ServerInstances []domain.ServerInstance `json:"serverInstances"` - RunEndpoints []domain.RunEndpoint `json:"runEndpoints"` - Jobs []domain.Job `json:"jobs"` - Artifacts []domain.Artifact `json:"artifacts"` - RuntimeBindings []domain.RuntimeBinding `json:"runtimeBindings"` - EncryptedComponentKeys []domain.EncryptedComponentKey `json:"encryptedComponentKeys"` - RunDistributions []domain.RunDistribution `json:"runDistributions"` - ClientManagerDistributions []domain.ClientManagerDistribution `json:"clientManagerDistributions"` - ClientManagerInstallations []domain.ClientManagerInstallation `json:"clientManagerInstallations"` - ClientManagerSessions []domain.ClientManagerSession `json:"clientManagerSessions"` - ClientManagerNonces []domain.ClientManagerRegistrationNonce `json:"clientManagerNonces"` - DependencyStatuses []domain.DependencyStatus `json:"dependencyStatuses"` - ClientManagerBuildJobs []domain.ClientManagerBuildJob `json:"clientManagerBuildJobs"` - RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"` - LogStreams []domain.LogStream `json:"logStreams"` - MetricSamples []domain.MetricSample `json:"metricSamples"` - Backups []domain.BackupRecord `json:"backups"` - PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"` - AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"` - GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"` - GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"` - GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"` - PluginDataRecords []domain.PluginDataRecord `json:"pluginDataRecords"` + Users []domain.User `json:"users"` + AuthSessions []domain.AuthSessionRecord `json:"authSessions"` + RunControlSessions []domain.RunControlSession `json:"runControlSessions"` + AIProviders []domain.AIProvider `json:"aiProviders"` + GamePlugins []domain.GamePlugin `json:"gamePlugins"` + ServerInstances []domain.ServerInstance `json:"serverInstances"` + RunEndpoints []domain.RunEndpoint `json:"runEndpoints"` + Jobs []domain.Job `json:"jobs"` + Artifacts []domain.Artifact `json:"artifacts"` + RuntimeBindings []domain.RuntimeBinding `json:"runtimeBindings"` + EncryptedComponentKeys []domain.EncryptedComponentKey `json:"encryptedComponentKeys"` + RunDistributions []domain.RunDistribution `json:"runDistributions"` + DependencyStatuses []domain.DependencyStatus `json:"dependencyStatuses"` + RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"` + LogStreams []domain.LogStream `json:"logStreams"` + MetricSamples []domain.MetricSample `json:"metricSamples"` + Backups []domain.BackupRecord `json:"backups"` + PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"` + AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"` + GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"` + GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"` + GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"` + PluginDataRecords []domain.PluginDataRecord `json:"pluginDataRecords"` } type FileStore struct { @@ -119,30 +114,10 @@ func (store *FileStore) RunDistributions() RunDistributionRepository { return &persistentRepository[domain.RunDistribution, domain.RunDistributionFilter]{repository: store.MemoryStore.runDists, persist: store.persist} } -func (store *FileStore) ClientManagerDistributions() ClientManagerDistributionRepository { - return &persistentRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]{repository: store.MemoryStore.clientDists, persist: store.persist} -} - -func (store *FileStore) ClientManagerInstallations() ClientManagerInstallationRepository { - return &persistentRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]{repository: store.MemoryStore.clientInstalls, persist: store.persist} -} - -func (store *FileStore) ClientManagerSessions() ClientManagerSessionRepository { - return &persistentRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]{repository: store.MemoryStore.clientSessions, persist: store.persist} -} - -func (store *FileStore) ClientManagerNonces() ClientManagerNonceRepository { - return &persistentRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]{repository: store.MemoryStore.clientNonces, persist: store.persist} -} - func (store *FileStore) DependencyStatuses() DependencyStatusRepository { return &persistentRepository[domain.DependencyStatus, domain.DependencyStatusFilter]{repository: store.MemoryStore.dependencies, persist: store.persist} } -func (store *FileStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository { - return &persistentRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]{repository: store.MemoryStore.buildJobs, persist: store.persist} -} - func (store *FileStore) RunUpdateJobs() RunUpdateJobRepository { return &persistentRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]{repository: store.MemoryStore.updateJobs, persist: store.persist} } @@ -227,34 +202,29 @@ func (store *FileStore) persist() error { func (store *FileStore) snapshot() StoreSnapshot { return StoreSnapshot{ - Users: snapshotRepository(store.MemoryStore.users), - AuthSessions: snapshotRepository(store.MemoryStore.authSessions), - RunControlSessions: snapshotRepository(store.MemoryStore.runSessions), - AIProviders: snapshotRepository(store.MemoryStore.aiProviders), - GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), - ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), - RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), - Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), - Artifacts: snapshotRepository(store.MemoryStore.artifacts), - RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings), - EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys), - RunDistributions: snapshotRepository(store.MemoryStore.runDists), - ClientManagerDistributions: snapshotRepository(store.MemoryStore.clientDists), - ClientManagerInstallations: snapshotRepository(store.MemoryStore.clientInstalls), - ClientManagerSessions: snapshotRepository(store.MemoryStore.clientSessions), - ClientManagerNonces: snapshotRepository(store.MemoryStore.clientNonces), - DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies), - ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs), - RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs), - LogStreams: snapshotRepository(store.MemoryStore.logStreams), - MetricSamples: snapshotRepository(store.MemoryStore.metricSamples), - Backups: snapshotRepository(store.MemoryStore.backups), - PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle), - AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs), - GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), - GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), - GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), - PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords), + Users: snapshotRepository(store.MemoryStore.users), + AuthSessions: snapshotRepository(store.MemoryStore.authSessions), + RunControlSessions: snapshotRepository(store.MemoryStore.runSessions), + AIProviders: snapshotRepository(store.MemoryStore.aiProviders), + GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), + ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), + RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), + Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), + Artifacts: snapshotRepository(store.MemoryStore.artifacts), + RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings), + EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys), + RunDistributions: snapshotRepository(store.MemoryStore.runDists), + DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies), + RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs), + LogStreams: snapshotRepository(store.MemoryStore.logStreams), + MetricSamples: snapshotRepository(store.MemoryStore.metricSamples), + Backups: snapshotRepository(store.MemoryStore.backups), + PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle), + AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs), + GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), + GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), + GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), + PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords), } } @@ -271,12 +241,7 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.runtimeBindings, snapshot.RuntimeBindings) loadRepository(store.MemoryStore.componentKeys, snapshot.EncryptedComponentKeys) loadRepository(store.MemoryStore.runDists, snapshot.RunDistributions) - loadRepository(store.MemoryStore.clientDists, snapshot.ClientManagerDistributions) - loadRepository(store.MemoryStore.clientInstalls, snapshot.ClientManagerInstallations) - loadRepository(store.MemoryStore.clientSessions, snapshot.ClientManagerSessions) - loadRepository(store.MemoryStore.clientNonces, snapshot.ClientManagerNonces) loadRepository(store.MemoryStore.dependencies, snapshot.DependencyStatuses) - loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs) loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs) loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams) loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples) diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index 9bc8aaf..91251b0 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -101,30 +101,10 @@ func (store *MySQLStore) RunDistributions() RunDistributionRepository { return &persistentRepository[domain.RunDistribution, domain.RunDistributionFilter]{repository: store.MemoryStore.runDists, persist: store.persist} } -func (store *MySQLStore) ClientManagerDistributions() ClientManagerDistributionRepository { - return &persistentRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]{repository: store.MemoryStore.clientDists, persist: store.persist} -} - -func (store *MySQLStore) ClientManagerInstallations() ClientManagerInstallationRepository { - return &persistentRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]{repository: store.MemoryStore.clientInstalls, persist: store.persist} -} - -func (store *MySQLStore) ClientManagerSessions() ClientManagerSessionRepository { - return &persistentRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]{repository: store.MemoryStore.clientSessions, persist: store.persist} -} - -func (store *MySQLStore) ClientManagerNonces() ClientManagerNonceRepository { - return &persistentRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]{repository: store.MemoryStore.clientNonces, persist: store.persist} -} - func (store *MySQLStore) DependencyStatuses() DependencyStatusRepository { return &persistentRepository[domain.DependencyStatus, domain.DependencyStatusFilter]{repository: store.MemoryStore.dependencies, persist: store.persist} } -func (store *MySQLStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository { - return &persistentRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]{repository: store.MemoryStore.buildJobs, persist: store.persist} -} - func (store *MySQLStore) RunUpdateJobs() RunUpdateJobRepository { return &persistentRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]{repository: store.MemoryStore.updateJobs, persist: store.persist} } @@ -226,34 +206,29 @@ ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysq func (store *MySQLStore) snapshot() StoreSnapshot { return StoreSnapshot{ - Users: snapshotRepository(store.MemoryStore.users), - AuthSessions: snapshotRepository(store.MemoryStore.authSessions), - RunControlSessions: snapshotRepository(store.MemoryStore.runSessions), - AIProviders: snapshotRepository(store.MemoryStore.aiProviders), - GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), - ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), - RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), - Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), - Artifacts: snapshotRepository(store.MemoryStore.artifacts), - RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings), - EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys), - RunDistributions: snapshotRepository(store.MemoryStore.runDists), - ClientManagerDistributions: snapshotRepository(store.MemoryStore.clientDists), - ClientManagerInstallations: snapshotRepository(store.MemoryStore.clientInstalls), - ClientManagerSessions: snapshotRepository(store.MemoryStore.clientSessions), - ClientManagerNonces: snapshotRepository(store.MemoryStore.clientNonces), - DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies), - ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs), - RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs), - LogStreams: snapshotRepository(store.MemoryStore.logStreams), - MetricSamples: snapshotRepository(store.MemoryStore.metricSamples), - Backups: snapshotRepository(store.MemoryStore.backups), - PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle), - AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs), - GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), - GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), - GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), - PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords), + Users: snapshotRepository(store.MemoryStore.users), + AuthSessions: snapshotRepository(store.MemoryStore.authSessions), + RunControlSessions: snapshotRepository(store.MemoryStore.runSessions), + AIProviders: snapshotRepository(store.MemoryStore.aiProviders), + GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), + ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), + RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), + Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), + Artifacts: snapshotRepository(store.MemoryStore.artifacts), + RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings), + EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys), + RunDistributions: snapshotRepository(store.MemoryStore.runDists), + DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies), + RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs), + LogStreams: snapshotRepository(store.MemoryStore.logStreams), + MetricSamples: snapshotRepository(store.MemoryStore.metricSamples), + Backups: snapshotRepository(store.MemoryStore.backups), + PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle), + AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs), + GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), + GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), + GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), + PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords), } } @@ -270,12 +245,7 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.runtimeBindings, snapshot.RuntimeBindings) loadRepository(store.MemoryStore.componentKeys, snapshot.EncryptedComponentKeys) loadRepository(store.MemoryStore.runDists, snapshot.RunDistributions) - loadRepository(store.MemoryStore.clientDists, snapshot.ClientManagerDistributions) - loadRepository(store.MemoryStore.clientInstalls, snapshot.ClientManagerInstallations) - loadRepository(store.MemoryStore.clientSessions, snapshot.ClientManagerSessions) - loadRepository(store.MemoryStore.clientNonces, snapshot.ClientManagerNonces) loadRepository(store.MemoryStore.dependencies, snapshot.DependencyStatuses) - loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs) loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs) loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams) loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples) diff --git a/platform/repo/resources.go b/platform/repo/resources.go index eae19fb..4bda2fe 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -99,35 +99,6 @@ type RunDistributionRepository interface { Update(domain.RunDistribution) error } -type ClientManagerDistributionRepository interface { - Create(domain.ClientManagerDistribution) error - Get(id string) (domain.ClientManagerDistribution, error) - List(domain.ClientManagerDistributionFilter) ([]domain.ClientManagerDistribution, error) - Update(domain.ClientManagerDistribution) error -} - -type ClientManagerInstallationRepository interface { - Create(domain.ClientManagerInstallation) error - Get(id string) (domain.ClientManagerInstallation, error) - List(domain.ClientManagerInstallationFilter) ([]domain.ClientManagerInstallation, error) - Update(domain.ClientManagerInstallation) error -} - -type ClientManagerSessionRepository interface { - Create(domain.ClientManagerSession) error - Get(id string) (domain.ClientManagerSession, error) - List(domain.ClientManagerSessionFilter) ([]domain.ClientManagerSession, error) - Update(domain.ClientManagerSession) error -} - -type ClientManagerNonceRepository interface { - Create(domain.ClientManagerRegistrationNonce) error - Get(id string) (domain.ClientManagerRegistrationNonce, error) - List(domain.ClientManagerNonceFilter) ([]domain.ClientManagerRegistrationNonce, error) - Update(domain.ClientManagerRegistrationNonce) error - Delete(id string) error -} - type DependencyStatusRepository interface { Create(domain.DependencyStatus) error Get(id string) (domain.DependencyStatus, error) @@ -135,13 +106,6 @@ type DependencyStatusRepository interface { Update(domain.DependencyStatus) error } -type ClientManagerBuildJobRepository interface { - Create(domain.ClientManagerBuildJob) error - Get(id string) (domain.ClientManagerBuildJob, error) - List(domain.ClientManagerBuildJobFilter) ([]domain.ClientManagerBuildJob, error) - Update(domain.ClientManagerBuildJob) error -} - type RunUpdateJobRepository interface { Create(domain.RunUpdateJob) error Get(id string) (domain.RunUpdateJob, error) @@ -233,12 +197,7 @@ type Store interface { RuntimeBindings() RuntimeBindingRepository EncryptedComponentKeys() EncryptedComponentKeyRepository RunDistributions() RunDistributionRepository - ClientManagerDistributions() ClientManagerDistributionRepository - ClientManagerInstallations() ClientManagerInstallationRepository - ClientManagerSessions() ClientManagerSessionRepository - ClientManagerNonces() ClientManagerNonceRepository DependencyStatuses() DependencyStatusRepository - ClientManagerBuildJobs() ClientManagerBuildJobRepository RunUpdateJobs() RunUpdateJobRepository LogStreams() LogStreamRepository MetricSamples() MetricSampleRepository @@ -264,12 +223,7 @@ type MemoryStore struct { runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter] componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter] runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter] - clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter] - clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter] - clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter] - clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter] dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter] - buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter] updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter] logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter] @@ -340,36 +294,11 @@ func NewMemoryStore() *MemoryStore { domain.CopyRunDistribution, matchRunDistribution, ), - clientDists: newMemoryRepository( - func(distribution domain.ClientManagerDistribution) string { return distribution.ID }, - domain.CopyClientManagerDistribution, - matchClientManagerDistribution, - ), - clientInstalls: newMemoryRepository( - func(installation domain.ClientManagerInstallation) string { return installation.ID }, - domain.CopyClientManagerInstallation, - matchClientManagerInstallation, - ), - clientSessions: newMemoryRepository( - func(session domain.ClientManagerSession) string { return session.ID }, - domain.CopyClientManagerSession, - matchClientManagerSession, - ), - clientNonces: newMemoryRepository( - func(nonce domain.ClientManagerRegistrationNonce) string { return nonce.ID }, - domain.CopyClientManagerRegistrationNonce, - matchClientManagerNonce, - ), dependencies: newMemoryRepository( func(status domain.DependencyStatus) string { return status.ID }, domain.CopyDependencyStatus, matchDependencyStatus, ), - buildJobs: newMemoryRepository( - func(job domain.ClientManagerBuildJob) string { return job.ID }, - domain.CopyClientManagerBuildJob, - matchClientManagerBuildJob, - ), updateJobs: newMemoryRepository( func(job domain.RunUpdateJob) string { return job.ID }, domain.CopyRunUpdateJob, @@ -424,27 +353,12 @@ func (store *MemoryStore) RuntimeBindings() RuntimeBindingRepository { ret func (store *MemoryStore) EncryptedComponentKeys() EncryptedComponentKeyRepository { return store.componentKeys } -func (store *MemoryStore) RunDistributions() RunDistributionRepository { return store.runDists } -func (store *MemoryStore) ClientManagerDistributions() ClientManagerDistributionRepository { - return store.clientDists -} -func (store *MemoryStore) ClientManagerInstallations() ClientManagerInstallationRepository { - return store.clientInstalls -} -func (store *MemoryStore) ClientManagerSessions() ClientManagerSessionRepository { - return store.clientSessions -} -func (store *MemoryStore) ClientManagerNonces() ClientManagerNonceRepository { - return store.clientNonces -} +func (store *MemoryStore) RunDistributions() RunDistributionRepository { return store.runDists } func (store *MemoryStore) DependencyStatuses() DependencyStatusRepository { return store.dependencies } -func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository { - return store.buildJobs -} -func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs } -func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams } -func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples } -func (store *MemoryStore) Backups() BackupRepository { return store.backups } +func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs } +func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams } +func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples } +func (store *MemoryStore) Backups() BackupRepository { return store.backups } func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository { return store.pluginLifecycle } @@ -659,45 +573,12 @@ func matchRunDistribution(distribution domain.RunDistribution, filter domain.Run (filter.Status == "" || distribution.Status == filter.Status) } -func matchClientManagerDistribution(distribution domain.ClientManagerDistribution, filter domain.ClientManagerDistributionFilter) bool { - return (filter.ServerInstanceID == "" || distribution.ServerInstanceID == filter.ServerInstanceID) && - (filter.ProfileKey == "" || distribution.ProfileKey == filter.ProfileKey) && - (filter.TargetOS == "" || distribution.TargetOS == filter.TargetOS) && - (filter.TargetArch == "" || distribution.TargetArch == filter.TargetArch) && - (filter.Status == "" || distribution.Status == filter.Status) -} - -func matchClientManagerInstallation(installation domain.ClientManagerInstallation, filter domain.ClientManagerInstallationFilter) bool { - return (filter.ServerInstanceID == "" || installation.ServerInstanceID == filter.ServerInstanceID) && - (filter.ProfileKey == "" || installation.ProfileKey == filter.ProfileKey) && - (filter.RunEndpointID == "" || installation.RunEndpointID == filter.RunEndpointID) && - (filter.Status == "" || installation.Status == filter.Status) -} - -func matchClientManagerSession(session domain.ClientManagerSession, filter domain.ClientManagerSessionFilter) bool { - return (filter.InstallationID == "" || session.InstallationID == filter.InstallationID) && - (filter.ServerInstanceID == "" || session.ServerInstanceID == filter.ServerInstanceID) && - (filter.ProfileKey == "" || session.ProfileKey == filter.ProfileKey) && - (filter.Status == "" || session.Status == filter.Status) -} - -func matchClientManagerNonce(nonce domain.ClientManagerRegistrationNonce, filter domain.ClientManagerNonceFilter) bool { - return (filter.InstallationID == "" || nonce.InstallationID == filter.InstallationID) && - (filter.ExpiresBefore.IsZero() || nonce.ExpiresAt.Before(filter.ExpiresBefore) || nonce.ExpiresAt.Equal(filter.ExpiresBefore)) -} - func matchDependencyStatus(status domain.DependencyStatus, filter domain.DependencyStatusFilter) bool { return (filter.ServerInstanceID == "" || status.ServerInstanceID == filter.ServerInstanceID) && (filter.ProbeKey == "" || status.ProbeKey == filter.ProbeKey) && (filter.State == "" || status.State == filter.State) } -func matchClientManagerBuildJob(job domain.ClientManagerBuildJob, filter domain.ClientManagerBuildJobFilter) bool { - return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) && - (filter.ProfileKey == "" || job.ProfileKey == filter.ProfileKey) && - (filter.Status == "" || job.Status == filter.Status) -} - func matchRunUpdateJob(job domain.RunUpdateJob, filter domain.RunUpdateJobFilter) bool { return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) && (filter.Status == "" || job.Status == filter.Status) diff --git a/platform/service/artifact_download.go b/platform/service/artifact_download.go index 71b1fe8..b158478 100644 --- a/platform/service/artifact_download.go +++ b/platform/service/artifact_download.go @@ -282,15 +282,6 @@ func (svc *CoreService) distributionArtifactDownloadPresentation(artifactID stri } } } - clients, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{}) - if err == nil { - for _, distribution := range clients { - if distribution.ArtifactID == artifactID { - format := packageFormatForTarget(distribution.TargetOS) - return distributionPackageFilename(distribution.ProfileKey, distribution.TargetOS, distribution.TargetArch, format), packageContentType(format), true - } - } - } return "", "", false } diff --git a/platform/service/client_manager_lifecycle.go b/platform/service/client_manager_lifecycle.go deleted file mode 100644 index 05e741a..0000000 --- a/platform/service/client_manager_lifecycle.go +++ /dev/null @@ -1,1492 +0,0 @@ -package service - -import ( - "crypto/hmac" - "crypto/sha256" - "crypto/subtle" - "encoding/hex" - "errors" - "sort" - "strconv" - "strings" - "time" - - "browser.local/platform/domain" - "browser.local/platform/repo" - "browser.local/platform/validator" -) - -const ( - clientManagerRegistrationWindow = 5 * time.Minute - 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 - } - if err := validator.ValidateClientManagerDeployRequest(request); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - _, instance, plugin, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerDeploy, "client-manager.deploy.denied") - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if request.ExpectedDeploymentGeneration > 0 && request.ExpectedDeploymentGeneration != installation.DeploymentGeneration { - return domain.ClientManagerLifecycleView{}, validationError("client-manager deployment generation is stale") - } - if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { - if existing.ServerInstanceID != instance.ID || existing.Capability != domain.JobCapabilityClientManagerDeploy || existing.TargetKey != "client-manager/"+profile.Key || installation.CurrentJobID != existing.ID { - return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy idempotency key conflicts with another job") - } - return svc.clientManagerLifecycleView(installation) - } else if !errors.Is(err, repo.ErrNotFound) { - return domain.ClientManagerLifecycleView{}, err - } - - stamp := svc.now() - installation.RunEndpointID = endpoint.ID - installation.TargetOS = distribution.TargetOS - installation.TargetArch = distribution.TargetArch - installation.DesiredVersion = clientManagerDistributionVersion(distribution, profile) - installation.DesiredRevision = distribution.SourceRevision - installation.DesiredArtifactID = distribution.ArtifactID - installation.Checksum = distribution.Checksum - installation.KeyGeneration = distribution.KeyGeneration - installation.DeploymentGeneration++ - installation.LastOperation = domain.ClientManagerOperationDeploy - installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationDeploy, installation.ID, request.IdempotencyKey) - installation.Phase = "deployment queued" - installation.Health = domain.ClientManagerHealthUnknown - installation.HealthReason = "awaiting verified deployment" - installation.Retryable = false - installation.RequiresRedeploy = false - installation.UpdatedAt = stamp - if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleDeploying); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if err := validator.ValidateClientManagerInstallation(installation); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerDeploy, "deployment queued")) - if err != nil { - installation.Status = domain.ClientManagerLifecycleFailed - installation.Phase = "deployment dispatch failed" - installation.HealthReason = "Run endpoint rejected deployment" - installation.Retryable = true - installation.UpdatedAt = svc.now() - _ = svc.store.ClientManagerInstallations().Update(installation) - return domain.ClientManagerLifecycleView{}, err - } - if job.ID != installation.CurrentJobID { - return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy job fence is invalid") - } - return svc.clientManagerLifecycleView(installation) -} - -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 - } - capability := domain.JobCapabilityClientManagerControl - if request.Operation == domain.ClientManagerOperationRollback { - capability = domain.JobCapabilityClientManagerRollback - } - _, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager."+string(request.Operation)+".denied") - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if !containsString(profile.Lifecycle.Actions, string(request.Operation)) { - return domain.ClientManagerLifecycleView{}, ErrForbidden - } - installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || installation.RequiresRedeploy { - return domain.ClientManagerLifecycleView{}, validationError("client-manager deployment generation is stale or requires redeploy") - } - if installation.ActiveArtifactID == "" || installation.Status == domain.ClientManagerLifecycleUninstalled || installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping { - return domain.ClientManagerLifecycleView{}, validationError("client-manager state does not allow control") - } - if request.Operation == domain.ClientManagerOperationRollback && installation.PreviousArtifactID == "" { - return domain.ClientManagerLifecycleView{}, validationError("client-manager previous deployment is unavailable") - } - if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { - if installation.CurrentJobID != existing.ID || existing.Capability != capability { - return domain.ClientManagerLifecycleView{}, validationError("client-manager control idempotency key conflicts with another job") - } - return svc.clientManagerLifecycleView(installation) - } else if !errors.Is(err, repo.ErrNotFound) { - return domain.ClientManagerLifecycleView{}, err - } - - installation.LastOperation = request.Operation - installation.CurrentJobID = clientManagerJobID(request.Operation, installation.ID, request.IdempotencyKey) - installation.Phase = string(request.Operation) + " queued" - installation.Retryable = false - installation.UpdatedAt = svc.now() - switch request.Operation { - case domain.ClientManagerOperationStop: - if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleStopping); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - case domain.ClientManagerOperationRollback: - installation.DesiredArtifactID = installation.PreviousArtifactID - installation.DesiredVersion = installation.PreviousVersion - installation.DesiredRevision = installation.PreviousRevision - installation.DeploymentGeneration++ - if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleRollingBack); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - case domain.ClientManagerOperationStart, domain.ClientManagerOperationRestart: - if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleRegistering); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - } - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, capability, installation.Phase)) - if err != nil { - installation.Status = domain.ClientManagerLifecycleFailed - installation.Phase = string(request.Operation) + " dispatch failed" - installation.Retryable = true - installation.UpdatedAt = svc.now() - _ = svc.store.ClientManagerInstallations().Update(installation) - return domain.ClientManagerLifecycleView{}, err - } - if job.ID != installation.CurrentJobID { - return domain.ClientManagerLifecycleView{}, validationError("client-manager control job fence is invalid") - } - return svc.clientManagerLifecycleView(installation) -} - -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 - } - _, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUpdate, "client-manager.update.denied") - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if !containsString(profile.Lifecycle.Actions, "update") || profile.UpdatePolicy.Strategy != "manual-staged" || !profile.UpdatePolicy.RequireApproval { - return domain.ClientManagerLifecycleView{}, ErrForbidden - } - installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || installation.ActiveArtifactID == "" || installation.RequiresRedeploy { - return domain.ClientManagerLifecycleView{}, validationError("client-manager update fence is stale or no healthy baseline is installed") - } - distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if distribution.ArtifactID == installation.ActiveArtifactID || !clientManagerVersionAllowed(profile, installation.ActiveVersion, clientManagerDistributionVersion(distribution, profile)) { - return domain.ClientManagerLifecycleView{}, validationError("client-manager update artifact is incompatible") - } - if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { - if installation.CurrentJobID != existing.ID || existing.Capability != domain.JobCapabilityClientManagerUpdate { - return domain.ClientManagerLifecycleView{}, validationError("client-manager update idempotency key conflicts with another job") - } - return svc.clientManagerLifecycleView(installation) - } else if !errors.Is(err, repo.ErrNotFound) { - return domain.ClientManagerLifecycleView{}, err - } - installation.DesiredArtifactID = distribution.ArtifactID - installation.DesiredVersion = clientManagerDistributionVersion(distribution, profile) - installation.DesiredRevision = distribution.SourceRevision - installation.Checksum = distribution.Checksum - installation.KeyGeneration = distribution.KeyGeneration - installation.DeploymentGeneration++ - installation.LastOperation = domain.ClientManagerOperationUpdate - installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationUpdate, installation.ID, request.IdempotencyKey) - installation.Phase = "staged update queued" - installation.Retryable = false - installation.UpdatedAt = svc.now() - if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleUpdating); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if err := svc.revokeClientManagerSessions(installation.ID, "update activation requires a new component session"); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerUpdate, installation.Phase)) - if err != nil { - installation.Status = domain.ClientManagerLifecycleFailed - installation.Phase = "update dispatch failed" - installation.Retryable = true - installation.UpdatedAt = svc.now() - _ = svc.store.ClientManagerInstallations().Update(installation) - return domain.ClientManagerLifecycleView{}, err - } - if job.ID != installation.CurrentJobID { - return domain.ClientManagerLifecycleView{}, validationError("client-manager update job fence is invalid") - } - return svc.clientManagerLifecycleView(installation) -} - -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 - } - _, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUninstall, "client-manager.uninstall.denied") - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if !containsString(profile.Lifecycle.Actions, "uninstall") { - return domain.ClientManagerLifecycleView{}, ErrForbidden - } - installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration { - return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall generation is stale") - } - if installation.Status == domain.ClientManagerLifecycleUninstalled { - return svc.clientManagerLifecycleView(installation) - } - if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { - if installation.CurrentJobID != existing.ID || existing.Capability != domain.JobCapabilityClientManagerUninstall { - return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall idempotency key conflicts with another job") - } - return svc.clientManagerLifecycleView(installation) - } else if !errors.Is(err, repo.ErrNotFound) { - return domain.ClientManagerLifecycleView{}, err - } - installation.LastOperation = domain.ClientManagerOperationUninstall - installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationUninstall, installation.ID, request.IdempotencyKey) - installation.Phase = "safe uninstall queued" - installation.UpdatedAt = svc.now() - if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleStopping); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if err := svc.revokeClientManagerSessions(installation.ID, "uninstall requested"); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerUninstall, installation.Phase)) - if err != nil { - installation.Status = domain.ClientManagerLifecycleFailed - installation.Phase = "uninstall dispatch failed" - installation.Retryable = true - installation.UpdatedAt = svc.now() - _ = svc.store.ClientManagerInstallations().Update(installation) - return domain.ClientManagerLifecycleView{}, err - } - if job.ID != installation.CurrentJobID { - return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall job fence is invalid") - } - return svc.clientManagerLifecycleView(installation) -} - -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 - } - instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - installation, err := svc.getClientManagerInstallation(instance.ID, request.ProfileKey) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if err := svc.revokeClientManagerSessions(installation.ID, "operator revoked component session"); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if installation.Status != domain.ClientManagerLifecycleUninstalled { - installation.Status = domain.ClientManagerLifecycleOffline - installation.Health = domain.ClientManagerHealthOffline - installation.HealthReason = "component session revoked" - installation.Phase = "registration required" - installation.UpdatedAt = svc.now() - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - } - return svc.clientManagerLifecycleView(installation) -} - -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 - } - installation, err := svc.getClientManagerInstallation(request.ServerInstanceID, request.ProfileKey) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || !installation.Retryable || installation.LastOperation == "" { - return domain.ClientManagerLifecycleView{}, validationError("client-manager lifecycle operation is not retryable or generation is stale") - } - capability := domain.JobCapabilityClientManagerControl - switch installation.LastOperation { - case domain.ClientManagerOperationDeploy: - capability = domain.JobCapabilityClientManagerDeploy - case domain.ClientManagerOperationUpdate: - capability = domain.JobCapabilityClientManagerUpdate - case domain.ClientManagerOperationRollback: - capability = domain.JobCapabilityClientManagerRollback - case domain.ClientManagerOperationUninstall: - capability = domain.JobCapabilityClientManagerUninstall - } - _, _, _, _, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager.retry.denied") - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { - if installation.CurrentJobID != existing.ID || existing.Capability != capability { - return domain.ClientManagerLifecycleView{}, validationError("client-manager retry idempotency key conflicts with another job") - } - return svc.clientManagerLifecycleView(installation) - } else if !errors.Is(err, repo.ErrNotFound) { - return domain.ClientManagerLifecycleView{}, err - } - installation.CurrentJobID = clientManagerJobID(installation.LastOperation, installation.ID, request.IdempotencyKey) - installation.Phase = string(installation.LastOperation) + " retry queued" - installation.Retryable = false - switch installation.LastOperation { - case domain.ClientManagerOperationDeploy: - installation.Status = domain.ClientManagerLifecycleDeploying - case domain.ClientManagerOperationUpdate: - installation.Status = domain.ClientManagerLifecycleUpdating - case domain.ClientManagerOperationRollback: - installation.Status = domain.ClientManagerLifecycleRollingBack - case domain.ClientManagerOperationStop, domain.ClientManagerOperationUninstall: - installation.Status = domain.ClientManagerLifecycleStopping - default: - installation.Status = domain.ClientManagerLifecycleRegistering - } - installation.UpdatedAt = svc.now() - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, capability, installation.Phase)) - if err != nil { - installation.Status = domain.ClientManagerLifecycleFailed - installation.Retryable = true - installation.UpdatedAt = svc.now() - _ = svc.store.ClientManagerInstallations().Update(installation) - return domain.ClientManagerLifecycleView{}, err - } - if job.ID != installation.CurrentJobID { - return domain.ClientManagerLifecycleView{}, validationError("client-manager retry job fence is invalid") - } - return svc.clientManagerLifecycleView(installation) -} - -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 - } - if err := svc.ReconcileClientManagerLifecycle(); err != nil { - return domain.ClientManagerLifecycleView{}, err - } - installation, err := svc.getClientManagerInstallation(serverInstanceID, profileKey) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - return svc.clientManagerLifecycleView(installation) -} - -func (svc *CoreService) ListClientManagerLifecyclesForSession(sessionID, serverInstanceID string) ([]domain.ClientManagerLifecycleView, error) { - return nil, legacyClientManagerUnsupported() - - if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil { - return nil, err - } - if err := svc.ReconcileClientManagerLifecycle(); err != nil { - return nil, err - } - installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID}) - if err != nil { - return nil, err - } - views := make([]domain.ClientManagerLifecycleView, 0, len(installations)) - for _, installation := range installations { - view, err := svc.clientManagerLifecycleView(installation) - if err != nil { - return nil, err - } - views = append(views, view) - } - sort.Slice(views, func(i, j int) bool { return views[i].Installation.ProfileKey < views[j].Installation.ProfileKey }) - return views, nil -} - -func clientManagerLifecycleJob(installation domain.ClientManagerInstallation, idempotencyKey, capability, message string) domain.Job { - return domain.Job{ID: installation.CurrentJobID, ServerInstanceID: installation.ServerInstanceID, RunEndpointID: installation.RunEndpointID, Capability: capability, TargetKey: "client-manager/" + installation.ProfileKey, InputRef: "input://client-manager-lifecycle/" + installation.ID + "/" + strconv.Itoa(installation.DeploymentGeneration), IdempotencyKey: idempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: message}} -} - -func clientManagerJobID(operation domain.ClientManagerLifecycleOperation, installationID, idempotencyKey string) string { - return jobIDFromParts("job-client-manager-"+string(operation), installationID, idempotencyKey) -} - -func (svc *CoreService) authorizeClientManagerLifecycle(sessionID, serverInstanceID, profileKey, capability, deniedAction string) (domain.User, domain.ServerInstance, domain.GamePlugin, domain.RuntimeClientManagerProfile, domain.RunEndpoint, error) { - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err - } - instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) - if err != nil { - return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err - } - if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.client-manager.manage", deniedAction); err != nil { - return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err - } - if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, deniedAction); err != nil { - return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err - } - profile, err := findRuntimeClientManagerProfile(plugin, profileKey) - if err != nil || profile.Deployment.Mode != "run-supervised" || !containsString(profile.Deployment.RequiredRunCapabilities, capability) { - return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, ErrForbidden - } - endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID) - if err != nil { - return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err - } - if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil { - return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err - } - return user, instance, plugin, profile, endpoint, nil -} - -func (svc *CoreService) authorizedClientManagerDistribution(instance domain.ServerInstance, profile domain.RuntimeClientManagerProfile, distributionID string) (domain.ClientManagerDistribution, error) { - distribution, err := svc.store.ClientManagerDistributions().Get(distributionID) - if err != nil { - return domain.ClientManagerDistribution{}, err - } - if distribution.ServerInstanceID != instance.ID || distribution.PluginID != instance.PluginID || distribution.ProfileKey != profile.Key || distribution.Status != domain.DistributionStatusAvailable || !clientManagerProfileSupportsTarget(profile, distribution.TargetOS, distribution.TargetArch) || !clientManagerProfileAllowsRevision(profile, distribution.SourceRevision) { - return domain.ClientManagerDistribution{}, validationError("client-manager distribution is outside the lifecycle scope") - } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) - if err != nil { - return domain.ClientManagerDistribution{}, err - } - if distribution.TargetOS != endpoint.Platform || distribution.TargetArch != endpoint.Architecture { - return domain.ClientManagerDistribution{}, validationError("client-manager distribution target does not match assigned Run endpoint") - } - key, err := svc.activeComponentKey(instance.ID, domain.DistributionComponentClientManager, profile.Key) - if err != nil || key.Generation != distribution.KeyGeneration { - return domain.ClientManagerDistribution{}, validationError("client-manager distribution key generation is no longer current") - } - artifact, err := svc.store.Artifacts().Get(distribution.ArtifactID) - if err != nil { - return domain.ClientManagerDistribution{}, err - } - if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != distribution.Checksum || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID { - return domain.ClientManagerDistribution{}, validationError("client-manager distribution artifact is unavailable or outside its build job") - } - return domain.CopyClientManagerDistribution(distribution), nil -} - -func findRuntimeClientManagerProfile(plugin domain.GamePlugin, profileKey string) (domain.RuntimeClientManagerProfile, error) { - plugin = domain.CopyGamePlugin(plugin) - for _, profile := range plugin.RuntimeProfiles.ClientManagers { - if profile.Key == strings.TrimSpace(profileKey) { - return profile, nil - } - } - return domain.RuntimeClientManagerProfile{}, repo.ErrNotFound -} - -func clientManagerCompanionConfigInput(plugin domain.GamePlugin, profile domain.RuntimeClientManagerProfile, installation domain.ClientManagerInstallation, artifactID, version, revision string) (*domain.ClientManagerCompanionConfigInput, error) { - companion := plugin.GameClientBridge.Companion - if companion == (domain.GameClientBridgeCompanionDeclaration{}) { - return nil, nil - } - if companion.ProfileKey == "" { - return nil, validationError("client-manager companion declaration is incomplete") - } - if companion.ProfileKey != installation.ProfileKey { - return nil, nil - } - var configTemplate domain.RuntimeConfigTemplate - found := false - for _, candidate := range profile.ConfigTemplates { - if candidate.Key == companion.ConfigTemplateKey { - configTemplate = candidate - found = true - break - } - } - if !found || strings.TrimSpace(configTemplate.TemplateRef) == "" || configTemplate.OutputRef != "config.yaml" { - return nil, validationError("client-manager companion config template is unavailable") - } - config := domain.ClientManagerCompanionConfigInput{ - SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion, - ConfigTemplateKey: companion.ConfigTemplateKey, - ConfigTemplateRef: configTemplate.TemplateRef, - ConfigOutputRef: configTemplate.OutputRef, - ConfigSchemaRef: companion.ConfigSchemaRef, - ConfigFormat: companion.ConfigFormat, - PlatformBaseURLSource: companion.PlatformBaseURLSource, - InstallationID: installation.ID, - ServerInstanceID: installation.ServerInstanceID, - PluginID: plugin.ID, - ProfileKey: installation.ProfileKey, - ArtifactID: artifactID, - Version: version, - SourceRevision: revision, - TargetOS: installation.TargetOS, - TargetArch: installation.TargetArch, - KeyGeneration: installation.KeyGeneration, - DeploymentGeneration: installation.DeploymentGeneration, - Capabilities: domain.CopyStringSlice(profile.Health.RequiredCapabilities), - RegistrationProof: companion.RegistrationProof, - ProofMaterialSource: companion.ProofMaterialSource, - ProofMaterialEnv: companion.ProofMaterialEnv, - SessionMode: companion.SessionMode, - TLSPolicy: companion.TLSPolicy, - HeartbeatIntervalSeconds: companion.HeartbeatIntervalSeconds, - CommandPollIntervalSeconds: companion.CommandPollIntervalSeconds, - RequestTimeoutSeconds: companion.RequestTimeoutSeconds, - } - if err := validator.ValidateClientManagerCompanionConfigInput(config); err != nil { - return nil, err - } - return &config, nil -} - -func clientManagerProfileSupportsTarget(profile domain.RuntimeClientManagerProfile, targetOS, targetArch string) bool { - for _, target := range profile.SupportedTargets { - if target.OS == targetOS && target.Arch == targetArch { - return true - } - } - return false -} - -func clientManagerProfileRevision(profile domain.RuntimeClientManagerProfile) string { - switch profile.RevisionPolicy { - case "pinned": - return profile.Revision - case "tag": - return profile.Tag - default: - return profile.Branch - } -} - -func clientManagerProfileAllowsRevision(profile domain.RuntimeClientManagerProfile, revision string) bool { - revision = strings.TrimSpace(revision) - if revision == "" { - return false - } - switch profile.RevisionPolicy { - case "pinned": - return revision == profile.Revision - case "tag": - return revision == profile.Tag || revision == profile.Revision - case "branch": - return revision == profile.Branch || profile.Revision != "" && revision == profile.Revision - default: - return false - } -} - -func clientManagerDistributionVersion(distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile) string { - if strings.TrimSpace(distribution.Version) != "" { - return distribution.Version - } - return profile.Version -} - -func (svc *CoreService) ensureClientManagerInstallationFromDistribution(instance domain.ServerInstance, plugin domain.GamePlugin, distribution domain.ClientManagerDistribution) (domain.ClientManagerInstallation, error) { - installationID := distributionID("client-manager-installation", instance.ID, distribution.ProfileKey) - installation, err := svc.store.ClientManagerInstallations().Get(installationID) - if err == nil { - return installation, nil - } - if !errors.Is(err, repo.ErrNotFound) { - return domain.ClientManagerInstallation{}, err - } - status := domain.ClientManagerLifecycleBuilding - phase := "build in progress" - if distribution.Status == domain.DistributionStatusAvailable { - status = domain.ClientManagerLifecycleAvailable - phase = "artifact available" - } else if distribution.Status == domain.DistributionStatusFailed || distribution.Status == domain.DistributionStatusRevoked { - status = domain.ClientManagerLifecycleFailed - phase = "build unavailable" - } - stamp := svc.now() - installation = domain.ClientManagerInstallation{ID: installationID, ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: distribution.ProfileKey, RunEndpointID: instance.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, Status: status, Phase: phase, DesiredVersion: distribution.Version, DesiredRevision: distribution.SourceRevision, DesiredArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, KeyGeneration: distribution.KeyGeneration, Health: domain.ClientManagerHealthUnknown, HealthReason: "component is not installed", CreatedAt: stamp, UpdatedAt: stamp} - if err := validator.ValidateClientManagerInstallation(installation); err != nil { - return domain.ClientManagerInstallation{}, err - } - if err := svc.store.ClientManagerInstallations().Create(installation); err != nil { - if errors.Is(err, repo.ErrDuplicate) { - return svc.store.ClientManagerInstallations().Get(installation.ID) - } - return domain.ClientManagerInstallation{}, err - } - return installation, nil -} - -func (svc *CoreService) ProjectClientManagerDistribution(distribution domain.ClientManagerDistribution) error { - return legacyClientManagerUnsupported() - - instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID) - if err != nil { - return err - } - plugin, err := svc.store.GamePlugins().Get(distribution.PluginID) - if err != nil { - return err - } - installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution) - if err != nil { - return err - } - if installation.ActiveArtifactID != "" || installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping { - return nil - } - installation.DesiredVersion = distribution.Version - installation.DesiredRevision = distribution.SourceRevision - installation.DesiredArtifactID = distribution.ArtifactID - installation.TargetOS = distribution.TargetOS - installation.TargetArch = distribution.TargetArch - installation.Checksum = distribution.Checksum - installation.KeyGeneration = distribution.KeyGeneration - switch distribution.Status { - case domain.DistributionStatusBuilding: - installation.Status = domain.ClientManagerLifecycleBuilding - installation.Phase = "build in progress" - case domain.DistributionStatusAvailable: - installation.Status = domain.ClientManagerLifecycleAvailable - installation.Phase = "artifact available" - case domain.DistributionStatusFailed, domain.DistributionStatusRevoked: - installation.Status = domain.ClientManagerLifecycleFailed - installation.Phase = "build unavailable" - installation.RequiresRedeploy = distribution.Status == domain.DistributionStatusRevoked - } - installation.UpdatedAt = svc.now() - return svc.store.ClientManagerInstallations().Update(installation) -} - -func (svc *CoreService) getClientManagerInstallation(serverInstanceID, profileKey string) (domain.ClientManagerInstallation, error) { - items, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey}) - if err != nil { - return domain.ClientManagerInstallation{}, err - } - if len(items) == 0 { - return domain.ClientManagerInstallation{}, repo.ErrNotFound - } - sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) }) - return items[0], nil -} - -func setClientManagerLifecycleStatus(installation *domain.ClientManagerInstallation, status domain.ClientManagerLifecycleStatus) error { - if err := validator.ValidateClientManagerLifecycleTransition(installation.Status, status); err != nil { - return err - } - installation.Status = status - return nil -} - -func clientManagerVersionAllowed(profile domain.RuntimeClientManagerProfile, current, desired string) bool { - currentParts, currentOK := parseClientManagerVersion(current) - desiredParts, desiredOK := parseClientManagerVersion(desired) - if !desiredOK { - return false - } - if currentOK && !profile.Compatibility.AllowDowngrade && compareClientManagerVersion(desiredParts, currentParts) < 0 { - return false - } - if minimum, ok := parseClientManagerVersion(profile.Compatibility.MinimumVersion); ok && compareClientManagerVersion(desiredParts, minimum) < 0 { - return false - } - if maximum, ok := parseClientManagerVersion(profile.Compatibility.MaximumVersion); ok && compareClientManagerVersion(desiredParts, maximum) > 0 { - return false - } - return true -} - -func parseClientManagerVersion(value string) ([3]int, bool) { - var result [3]int - parts := strings.SplitN(strings.SplitN(value, "-", 2)[0], ".", 4) - if len(parts) != 3 { - return result, false - } - for i, part := range parts { - parsed, err := strconv.Atoi(part) - if err != nil || parsed < 0 { - return [3]int{}, false - } - result[i] = parsed - } - return result, true -} - -func compareClientManagerVersion(left, right [3]int) int { - for i := 0; i < 3; i++ { - if left[i] < right[i] { - return -1 - } - if left[i] > right[i] { - return 1 - } - } - return 0 -} - -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 - } - session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken) - if err != nil { - return domain.ClientManagerLifecycleInput{}, err - } - job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt) - if err != nil { - return domain.ClientManagerLifecycleInput{}, err - } - if !isClientManagerJobCapability(job.Capability) || job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { - return domain.ClientManagerLifecycleInput{}, validationError("job is not an active client-manager lifecycle job") - } - installation, err := svc.getClientManagerInstallation(job.ServerInstanceID, strings.TrimPrefix(job.TargetKey, "client-manager/")) - if err != nil { - return domain.ClientManagerLifecycleInput{}, err - } - if installation.CurrentJobID != job.ID || installation.RunEndpointID != request.RunEndpointID || installation.DeploymentGeneration <= 0 || job.InputRef != "input://client-manager-lifecycle/"+installation.ID+"/"+strconv.Itoa(installation.DeploymentGeneration) { - return domain.ClientManagerLifecycleInput{}, validationError("client-manager lifecycle input fence is stale") - } - instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) - if err != nil || instance.RunEndpointID != request.RunEndpointID { - return domain.ClientManagerLifecycleInput{}, validationError("client-manager endpoint ownership fence is stale") - } - plugin, err := svc.store.GamePlugins().Get(installation.PluginID) - if err != nil { - return domain.ClientManagerLifecycleInput{}, err - } - profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) - if err != nil { - return domain.ClientManagerLifecycleInput{}, err - } - key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey) - if err != nil || key.Generation != installation.KeyGeneration || installation.RequiresRedeploy { - return domain.ClientManagerLifecycleInput{}, validationError("client-manager component key generation is stale") - } - operation := installation.LastOperation - artifactID := installation.DesiredArtifactID - checksum := installation.Checksum - version := installation.DesiredVersion - revision := installation.DesiredRevision - if operation == domain.ClientManagerOperationStart || operation == domain.ClientManagerOperationStop || operation == domain.ClientManagerOperationRestart || operation == domain.ClientManagerOperationStatus || operation == domain.ClientManagerOperationUninstall { - artifactID = installation.ActiveArtifactID - version = installation.ActiveVersion - revision = installation.ActiveRevision - checksum = "" - } - companionConfig, err := clientManagerCompanionConfigInput(plugin, profile, installation, artifactID, version, revision) - if err != nil { - return domain.ClientManagerLifecycleInput{}, err - } - return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey, CompanionConfig: companionConfig}), nil -} - -func (svc *CoreService) ReadClientManagerLifecycleChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) { - return domain.RunUpdateChunk{}, legacyClientManagerUnsupported() - - if err := validator.ValidateRunUpdateChunkRequest(request); err != nil { - return domain.RunUpdateChunk{}, err - } - job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt) - if err != nil { - return domain.RunUpdateChunk{}, err - } - if !isClientManagerJobCapability(job.Capability) || job.Capability == domain.JobCapabilityClientManagerControl || job.Capability == domain.JobCapabilityClientManagerUninstall { - return domain.RunUpdateChunk{}, validationError("job does not carry a client-manager artifact") - } - installation, err := svc.getClientManagerInstallation(job.ServerInstanceID, strings.TrimPrefix(job.TargetKey, "client-manager/")) - if err != nil || installation.CurrentJobID != job.ID || installation.DesiredArtifactID == "" { - return domain.RunUpdateChunk{}, validationError("client-manager artifact fence is stale") - } - artifact, err := svc.store.Artifacts().Get(installation.DesiredArtifactID) - if err != nil { - return domain.RunUpdateChunk{}, err - } - if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != installation.Checksum { - return domain.RunUpdateChunk{}, validationError("client-manager artifact is unavailable or checksum changed") - } - payload, err := svc.artifactPayload(artifact.ID) - if err != nil { - return domain.RunUpdateChunk{}, err - } - if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum || request.Offset >= artifact.SizeBytes { - return domain.RunUpdateChunk{}, validationError("client-manager artifact content or offset is invalid") - } - length := request.Length - if remaining := artifact.SizeBytes - request.Offset; int64(length) > remaining { - length = int(remaining) - } - end := request.Offset + int64(length) - return domain.CopyRunUpdateChunk(domain.RunUpdateChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):int(end)], Complete: end == artifact.SizeBytes}), nil -} - -func isClientManagerJobCapability(capability string) bool { - switch capability { - case domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall: - return true - default: - return false - } -} - -func (svc *CoreService) projectClientManagerLifecycleProgress(job domain.Job, stamp time.Time) error { - if !isClientManagerJobCapability(job.Capability) { - return nil - } - installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: job.ServerInstanceID}) - if err != nil { - return err - } - for _, installation := range installations { - if installation.CurrentJobID != job.ID { - continue - } - installation.Phase = safeClientManagerLifecycleMessage(job.Progress.Message, "lifecycle job running") - installation.UpdatedAt = stamp - return svc.store.ClientManagerInstallations().Update(installation) - } - return repo.ErrNotFound -} - -func (svc *CoreService) projectClientManagerLifecycleResult(job domain.Job, stamp time.Time) error { - if !isClientManagerJobCapability(job.Capability) { - return nil - } - installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: job.ServerInstanceID}) - if err != nil { - return err - } - for _, installation := range installations { - if installation.CurrentJobID != job.ID { - continue - } - installation.Phase = safeClientManagerLifecycleMessage(job.Progress.Message, "lifecycle job finished") - installation.UpdatedAt = stamp - installation.Retryable = false - success := job.State == domain.JobStateSucceeded - if !success { - installation.Retryable = job.State == domain.JobStateFailed - installation.HealthReason = "client-manager " + string(installation.LastOperation) + " did not complete" - if installation.LastOperation == domain.ClientManagerOperationUpdate && job.ExecutionResult.Kind == "client-manager.rollback.restored" { - installation.Status = domain.ClientManagerLifecycleDegraded - installation.Phase = "update failed; previous deployment restored" - installation.Health = domain.ClientManagerHealthDegraded - } else if job.State == domain.JobStateCancelled && installation.ActiveArtifactID != "" { - installation.Status = domain.ClientManagerLifecycleOffline - installation.Health = domain.ClientManagerHealthOffline - } else { - installation.Status = domain.ClientManagerLifecycleFailed - installation.Health = domain.ClientManagerHealthUnhealthy - } - } else { - switch installation.LastOperation { - case domain.ClientManagerOperationDeploy: - installation.ActiveArtifactID = installation.DesiredArtifactID - installation.ActiveVersion = installation.DesiredVersion - installation.ActiveRevision = installation.DesiredRevision - installation.Status = domain.ClientManagerLifecycleRegistering - installation.Phase = "deployed; component registration pending" - installation.InstalledAt = stamp - installation.Health = domain.ClientManagerHealthUnknown - installation.HealthReason = "awaiting component heartbeat" - installation.LastSeenAt = time.Time{} - installation.LastHeartbeatSequence = 0 - case domain.ClientManagerOperationStart, domain.ClientManagerOperationRestart: - installation.Status = domain.ClientManagerLifecycleRegistering - installation.Phase = "process started; component registration pending" - installation.Health = domain.ClientManagerHealthUnknown - installation.HealthReason = "awaiting component heartbeat" - installation.LastSeenAt = time.Time{} - installation.LastHeartbeatSequence = 0 - case domain.ClientManagerOperationStop: - installation.Status = domain.ClientManagerLifecycleOffline - installation.Phase = "process stopped" - installation.Health = domain.ClientManagerHealthOffline - installation.HealthReason = "stopped by operator" - case domain.ClientManagerOperationStatus: - if job.ExecutionResult.ProcessState == "running" { - installation.Status = domain.ClientManagerLifecycleRegistering - installation.Phase = "process running; heartbeat pending" - } else { - installation.Status = domain.ClientManagerLifecycleOffline - installation.Health = domain.ClientManagerHealthOffline - installation.Phase = "process is not running" - } - case domain.ClientManagerOperationUpdate: - installation.PreviousArtifactID = installation.ActiveArtifactID - installation.PreviousVersion = installation.ActiveVersion - installation.PreviousRevision = installation.ActiveRevision - installation.ActiveArtifactID = installation.DesiredArtifactID - installation.ActiveVersion = installation.DesiredVersion - installation.ActiveRevision = installation.DesiredRevision - installation.Status = domain.ClientManagerLifecycleRegistering - installation.Phase = "update activated; health registration pending" - installation.Health = domain.ClientManagerHealthUnknown - installation.HealthReason = "awaiting updated component heartbeat" - installation.LastSeenAt = time.Time{} - installation.LastHeartbeatSequence = 0 - case domain.ClientManagerOperationRollback: - oldArtifact, oldVersion, oldRevision := installation.ActiveArtifactID, installation.ActiveVersion, installation.ActiveRevision - installation.ActiveArtifactID, installation.ActiveVersion, installation.ActiveRevision = installation.DesiredArtifactID, installation.DesiredVersion, installation.DesiredRevision - installation.PreviousArtifactID, installation.PreviousVersion, installation.PreviousRevision = oldArtifact, oldVersion, oldRevision - installation.Status = domain.ClientManagerLifecycleRegistering - installation.Phase = "rollback activated; health registration pending" - installation.Health = domain.ClientManagerHealthUnknown - installation.HealthReason = "awaiting rolled-back component heartbeat" - installation.LastSeenAt = time.Time{} - installation.LastHeartbeatSequence = 0 - case domain.ClientManagerOperationUninstall: - installation.Status = domain.ClientManagerLifecycleUninstalled - installation.Phase = "managed workspace removed" - installation.Health = domain.ClientManagerHealthOffline - installation.HealthReason = "uninstalled" - installation.ActiveArtifactID = "" - installation.ActiveVersion = "" - installation.ActiveRevision = "" - installation.PreviousArtifactID = "" - installation.PreviousVersion = "" - installation.PreviousRevision = "" - installation.UninstalledAt = stamp - } - installation.LastSuccessfulJobID = job.ID - } - installation.CurrentJobID = job.ID - if err := validator.ValidateClientManagerInstallation(installation); err != nil { - return err - } - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return err - } - return nil - } - return repo.ErrNotFound -} - -func safeClientManagerLifecycleMessage(value, fallback string) string { - value = strings.TrimSpace(value) - if value == "" { - return fallback - } - if len(value) > 160 || strings.Contains(strings.ToLower(value), "secret://") || strings.Contains(strings.ToLower(value), "bearer ") || strings.Contains(strings.ToLower(value), "password=") || strings.Contains(strings.ToLower(value), "token=") || strings.Contains(value, "tcp://") || strings.Contains(value, "unix://") || strings.Contains(value, "/Users/") { - return fallback - } - return value -} - -func (svc *CoreService) clientManagerLifecycleView(installation domain.ClientManagerInstallation) (domain.ClientManagerLifecycleView, error) { - view := domain.ClientManagerLifecycleView{Installation: domain.CopyClientManagerInstallation(installation)} - if installation.DesiredArtifactID != "" { - distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey}) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - for _, distribution := range distributions { - if distribution.ArtifactID == installation.DesiredArtifactID { - view.Distribution = distribution - break - } - } - } - if installation.CurrentJobID != "" { - job, err := svc.store.Jobs().Get(installation.CurrentJobID) - if err == nil { - view.Job = job - } else if !errors.Is(err, repo.ErrNotFound) { - return domain.ClientManagerLifecycleView{}, err - } - } - instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - plugin, err := svc.store.GamePlugins().Get(installation.PluginID) - if err != nil { - return domain.ClientManagerLifecycleView{}, err - } - profile, _ := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) - endpoint, endpointErr := svc.store.RunEndpoints().Get(instance.RunEndpointID) - view.Actions = svc.clientManagerLifecycleActions(installation, view.Distribution, profile, endpoint, endpointErr) - return domain.CopyClientManagerLifecycleView(view), nil -} - -func (svc *CoreService) clientManagerLifecycleActions(installation domain.ClientManagerInstallation, distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile, endpoint domain.RunEndpoint, endpointErr error) []domain.ClientManagerLifecycleActionAvailability { - operations := []domain.ClientManagerLifecycleOperation{domain.ClientManagerOperationDeploy, domain.ClientManagerOperationStart, domain.ClientManagerOperationStop, domain.ClientManagerOperationRestart, domain.ClientManagerOperationStatus, domain.ClientManagerOperationUpdate, domain.ClientManagerOperationRollback, domain.ClientManagerOperationUninstall} - items := make([]domain.ClientManagerLifecycleActionAvailability, 0, len(operations)) - for _, operation := range operations { - capability := domain.JobCapabilityClientManagerControl - switch operation { - case domain.ClientManagerOperationDeploy: - capability = domain.JobCapabilityClientManagerDeploy - case domain.ClientManagerOperationUpdate: - capability = domain.JobCapabilityClientManagerUpdate - case domain.ClientManagerOperationRollback: - capability = domain.JobCapabilityClientManagerRollback - case domain.ClientManagerOperationUninstall: - capability = domain.JobCapabilityClientManagerUninstall - } - available := endpointErr == nil && endpoint.Status == domain.RunEndpointStatusOnline && svc.runEndpointHeartbeatCurrent(endpoint) && containsString(endpoint.Capabilities, capability) && containsString(profile.Deployment.RequiredRunCapabilities, capability) - reason := "" - if !available { - reason = "assigned Run endpoint is offline or lacks the declared capability" - } - if operation != domain.ClientManagerOperationDeploy && !containsString(profile.Lifecycle.Actions, string(operation)) { - available = false - reason = "client-manager profile does not declare this action" - } - if installation.RequiresRedeploy && operation != domain.ClientManagerOperationDeploy && operation != domain.ClientManagerOperationUninstall { - available = false - reason = "component key changed; rebuild and redeploy is required" - } - if operation == domain.ClientManagerOperationDeploy && (installation.DesiredArtifactID == "" || distribution.Status != domain.DistributionStatusAvailable) { - available = false - reason = "no available client-manager build artifact is ready" - } - if operation == domain.ClientManagerOperationRollback && installation.PreviousArtifactID == "" { - available = false - reason = "no previous deployment is retained" - } - if operation != domain.ClientManagerOperationDeploy && operation != domain.ClientManagerOperationUninstall && installation.ActiveArtifactID == "" { - available = false - reason = "client manager is not installed" - } - if installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping { - available = false - reason = "another lifecycle operation is in progress" - } - items = append(items, domain.ClientManagerLifecycleActionAvailability{Operation: operation, Available: available, Reason: reason}) - } - return items -} - -func (svc *CoreService) clientManagerRuntimeActionProjection(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint, bindingsComplete bool, bindingReason string) []domain.ServerRuntimeAction { - profiles := plugin.RuntimeProfiles.ClientManagers - hasLifecycle := false - for _, profile := range profiles { - if profile.Deployment.Mode == "run-supervised" { - hasLifecycle = true - break - } - } - installations, _ := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID}) - hasInstalled := false - hasPrevious := false - hasUpdateCandidate := false - for _, installation := range installations { - hasInstalled = hasInstalled || installation.ActiveArtifactID != "" && installation.Status != domain.ClientManagerLifecycleUninstalled - hasPrevious = hasPrevious || installation.PreviousArtifactID != "" - hasUpdateCandidate = hasUpdateCandidate || installation.DesiredArtifactID != "" && installation.DesiredArtifactID != installation.ActiveArtifactID - } - baseReason := fallbackReason(!hasLifecycle || !bindingsComplete, "plugin does not declare a complete Client Manager lifecycle", bindingReason) - return []domain.ServerRuntimeAction{ - runtimeAction("deploy-client-manager", "Deploy client manager", hasLifecycle && bindingsComplete && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerDeploy), fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerDeploy), "run endpoint cannot deploy client managers", baseReason)), - runtimeAction("control-client-manager", "Control client manager", hasLifecycle && hasInstalled && bindingsComplete && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerControl), fallbackReason(!hasInstalled || !svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerControl), "client manager is not installed or endpoint cannot control it", baseReason)), - runtimeAction("update-client-manager", "Update client manager", hasLifecycle && hasInstalled && hasUpdateCandidate && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerUpdate), "no compatible update candidate is available"), - runtimeAction("rollback-client-manager", "Rollback client manager", hasLifecycle && hasPrevious && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerRollback), "no retained previous deployment is available"), - runtimeAction("uninstall-client-manager", "Uninstall client manager", hasLifecycle && hasInstalled && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerUninstall), "client manager is not installed or endpoint cannot uninstall it"), - } -} - -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 - } - stamp := svc.now() - if request.Timestamp.Before(stamp.Add(-clientManagerRegistrationWindow)) || request.Timestamp.After(stamp.Add(clientManagerRegistrationWindow)) { - return domain.ClientManagerRegisterResult{}, ErrUnauthorized - } - installation, err := svc.store.ClientManagerInstallations().Get(request.InstallationID) - if err != nil { - return domain.ClientManagerRegisterResult{}, ErrUnauthorized - } - if installation.ServerInstanceID != request.ServerInstanceID || installation.ProfileKey != request.ProfileKey || installation.ActiveArtifactID != request.ArtifactID || installation.ActiveVersion != request.Version || installation.ActiveRevision != request.SourceRevision || installation.TargetOS != request.TargetOS || installation.TargetArch != request.TargetArch || installation.KeyGeneration != request.KeyGeneration || installation.DeploymentGeneration != request.DeploymentGeneration || installation.RequiresRedeploy || installation.Status == domain.ClientManagerLifecycleUninstalled { - return domain.ClientManagerRegisterResult{}, ErrUnauthorized - } - instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) - if err != nil || instance.RunEndpointID != installation.RunEndpointID { - return domain.ClientManagerRegisterResult{}, ErrUnauthorized - } - plugin, err := svc.store.GamePlugins().Get(installation.PluginID) - if err != nil { - return domain.ClientManagerRegisterResult{}, ErrUnauthorized - } - profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) - if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, request.Capabilities) { - return domain.ClientManagerRegisterResult{}, ErrUnauthorized - } - key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey) - if err != nil || key.Generation != request.KeyGeneration { - return domain.ClientManagerRegisterResult{}, ErrUnauthorized - } - plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey) - if err != nil { - return domain.ClientManagerRegisterResult{}, err - } - expected := clientManagerRegistrationSignature(plainKey, request) - if subtle.ConstantTimeCompare([]byte(expected), []byte(request.Signature)) != 1 { - return domain.ClientManagerRegisterResult{}, ErrUnauthorized - } - nonceID := clientManagerNonceID(request.InstallationID, request.Nonce) - if _, err := svc.store.ClientManagerNonces().Get(nonceID); err == nil { - return domain.ClientManagerRegisterResult{}, ErrUnauthorized - } else if !errors.Is(err, repo.ErrNotFound) { - return domain.ClientManagerRegisterResult{}, err - } - nonce := domain.ClientManagerRegistrationNonce{ID: nonceID, InstallationID: installation.ID, ExpiresAt: stamp.Add(clientManagerRegistrationWindow), CreatedAt: stamp} - if err := validator.ValidateClientManagerNonce(nonce); err != nil { - return domain.ClientManagerRegisterResult{}, err - } - if err := svc.store.ClientManagerNonces().Create(nonce); err != nil { - return domain.ClientManagerRegisterResult{}, ErrUnauthorized - } - if err := svc.revokeClientManagerSessions(installation.ID, "component registered a replacement session"); err != nil { - return domain.ClientManagerRegisterResult{}, err - } - token, err := randomToken() - if err != nil { - return domain.ClientManagerRegisterResult{}, err - } - session := domain.ClientManagerSession{ID: clientManagerSessionID(installation.ID, installation.DeploymentGeneration, token), 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: domain.CopyStringSlice(request.Capabilities), Status: domain.ClientManagerSessionActive, LastSeenAt: stamp, ExpiresAt: stamp.Add(clientManagerSessionTTL), CreatedAt: stamp, UpdatedAt: stamp} - if err := validator.ValidateClientManagerSession(session); err != nil { - return domain.ClientManagerRegisterResult{}, err - } - if err := svc.store.ClientManagerSessions().Create(session); err != nil { - return domain.ClientManagerRegisterResult{}, err - } - installation.Status = domain.ClientManagerLifecycleOnline - installation.Phase = "component registered" - installation.Health = domain.ClientManagerHealthHealthy - installation.HealthReason = "authenticated component session active" - installation.LastSeenAt = stamp - installation.LastHeartbeatSequence = 0 - installation.UpdatedAt = stamp - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return domain.ClientManagerRegisterResult{}, err - } - return domain.ClientManagerRegisterResult{Accepted: true, InstallationID: installation.ID, SessionToken: token, ExpiresAt: session.ExpiresAt, HeartbeatEvery: profile.Health.IntervalSeconds, ServerTime: stamp}, nil -} - -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 - } - stamp := svc.now() - installation, err := svc.store.ClientManagerInstallations().Get(heartbeat.InstallationID) - if err != nil { - return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized - } - sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive}) - if err != nil { - return domain.ClientManagerHeartbeatResult{}, err - } - var session domain.ClientManagerSession - for _, candidate := range sessions { - if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(tokenHash(heartbeat.SessionToken))) == 1 { - session = candidate - break - } - } - if session.ID == "" || !stamp.Before(session.ExpiresAt) || session.KeyGeneration != installation.KeyGeneration || session.DeploymentGeneration != installation.DeploymentGeneration || session.ArtifactID != installation.ActiveArtifactID || session.RunEndpointID != installation.RunEndpointID || heartbeat.Sequence <= session.LastHeartbeatSequence { - return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized - } - plugin, err := svc.store.GamePlugins().Get(installation.PluginID) - if err != nil { - return domain.ClientManagerHeartbeatResult{}, err - } - profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) - if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, heartbeat.Capabilities) || !clientManagerCapabilitiesMatch(session.Capabilities, heartbeat.Capabilities) { - return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized - } - session.LastHeartbeatSequence = heartbeat.Sequence - session.LastSeenAt = stamp - session.UpdatedAt = stamp - if err := svc.store.ClientManagerSessions().Update(session); err != nil { - return domain.ClientManagerHeartbeatResult{}, err - } - installation.LastHeartbeatSequence = heartbeat.Sequence - installation.LastSeenAt = stamp - installation.Health = heartbeat.Health - installation.HealthReason = safeClientManagerLifecycleMessage(heartbeat.HealthReason, "component health reported") - installation.Status = domain.ClientManagerLifecycleOnline - if heartbeat.Health == domain.ClientManagerHealthDegraded || heartbeat.Health == domain.ClientManagerHealthUnhealthy { - installation.Status = domain.ClientManagerLifecycleDegraded - } - installation.Phase = "component heartbeat accepted" - installation.UpdatedAt = stamp - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return domain.ClientManagerHeartbeatResult{}, err - } - return domain.ClientManagerHeartbeatResult{Accepted: true, InstallationID: installation.ID, Status: installation.Status, Health: installation.Health, NextHeartbeat: profile.Health.IntervalSeconds, SessionExpiresAt: session.ExpiresAt, ServerTime: stamp}, nil -} - -func clientManagerRegistrationSignature(key string, request domain.ClientManagerRegisterRequest) string { - capabilities := domain.CopyStringSlice(request.Capabilities) - sort.Strings(capabilities) - canonical := strings.Join([]string{request.InstallationID, request.ServerInstanceID, request.ProfileKey, request.ArtifactID, request.Version, request.SourceRevision, request.TargetOS, request.TargetArch, strconv.Itoa(request.KeyGeneration), strconv.Itoa(request.DeploymentGeneration), request.Timestamp.UTC().Format(time.RFC3339Nano), request.Nonce, strings.Join(capabilities, ",")}, "\n") - mac := hmac.New(sha256.New, []byte(key)) - _, _ = mac.Write([]byte(canonical)) - return "sha256:" + hex.EncodeToString(mac.Sum(nil)) -} - -func clientManagerNonceID(installationID, nonce string) string { - sum := sha256.Sum256([]byte(installationID + "\x00" + nonce)) - return hex.EncodeToString(sum[:]) -} - -func clientManagerSessionID(installationID string, generation int, token string) string { - sum := sha256.Sum256([]byte(installationID + "\x00" + strconv.Itoa(generation) + "\x00" + token)) - return "cm-session-" + hex.EncodeToString(sum[:12]) -} - -func clientManagerCapabilitiesMatch(required, actual []string) bool { - actualSet := map[string]struct{}{} - for _, capability := range actual { - switch capability { - case "component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream": - actualSet[capability] = struct{}{} - default: - return false - } - } - for _, capability := range required { - if _, ok := actualSet[capability]; !ok { - return false - } - } - return true -} - -func (svc *CoreService) revokeClientManagerSessions(installationID, reason string) error { - sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installationID, Status: domain.ClientManagerSessionActive}) - if err != nil { - return err - } - stamp := svc.now() - for _, session := range sessions { - session.Status = domain.ClientManagerSessionRevoked - session.RevokedAt = stamp - session.UpdatedAt = stamp - if err := svc.store.ClientManagerSessions().Update(session); err != nil { - return err - } - } - _ = reason - return nil -} - -func (svc *CoreService) ReconcileClientManagerLifecycle() error { - return nil - - stamp := svc.now() - nonces, err := svc.store.ClientManagerNonces().List(domain.ClientManagerNonceFilter{ExpiresBefore: stamp}) - if err != nil { - return err - } - for _, nonce := range nonces { - if err := svc.store.ClientManagerNonces().Delete(nonce.ID); err != nil && !errors.Is(err, repo.ErrNotFound) { - return err - } - } - sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{}) - if err != nil { - return err - } - for _, session := range sessions { - if session.Status == domain.ClientManagerSessionActive && !stamp.Before(session.ExpiresAt) { - session.Status = domain.ClientManagerSessionExpired - session.UpdatedAt = stamp - session.RevokedAt = stamp - if err := svc.store.ClientManagerSessions().Update(session); err != nil { - return err - } - } - } - installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{}) - if err != nil { - return err - } - for _, installation := range installations { - changed := false - if installation.CurrentJobID != "" { - job, jobErr := svc.store.Jobs().Get(installation.CurrentJobID) - if jobErr == nil && isTerminalJobState(job.State) && installation.UpdatedAt.Before(job.UpdatedAt) { - if err := svc.projectClientManagerLifecycleResult(job, job.UpdatedAt); err != nil { - return err - } - continue - } - } - instance, instanceErr := svc.store.ServerInstances().Get(installation.ServerInstanceID) - if instanceErr == nil && installation.RunEndpointID != instance.RunEndpointID && installation.Status != domain.ClientManagerLifecycleUninstalled { - if err := svc.revokeClientManagerSessions(installation.ID, "Run endpoint assignment changed"); err != nil { - return err - } - installation.Status = domain.ClientManagerLifecycleFailed - installation.Health = domain.ClientManagerHealthOffline - installation.HealthReason = "assigned Run endpoint changed; redeploy required" - installation.RequiresRedeploy = true - changed = true - } - if key, keyErr := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey); keyErr == nil && installation.KeyGeneration > 0 && key.Generation != installation.KeyGeneration && installation.Status != domain.ClientManagerLifecycleUninstalled { - if err := svc.revokeClientManagerSessions(installation.ID, "component key generation changed"); err != nil { - return err - } - installation.Status = domain.ClientManagerLifecycleFailed - installation.Health = domain.ClientManagerHealthOffline - installation.HealthReason = "component key changed; rebuild and redeploy required" - installation.RequiresRedeploy = true - changed = true - } - if installation.ActiveArtifactID != "" && !installation.LastSeenAt.IsZero() { - plugin, pluginErr := svc.store.GamePlugins().Get(installation.PluginID) - profile, profileErr := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) - if pluginErr == nil && profileErr == nil { - age := stamp.Sub(installation.LastSeenAt) - if age >= time.Duration(profile.Health.OfflineAfterSeconds)*time.Second && installation.Status != domain.ClientManagerLifecycleOffline && installation.Status != domain.ClientManagerLifecycleUninstalled { - installation.Status = domain.ClientManagerLifecycleOffline - installation.Health = domain.ClientManagerHealthOffline - installation.HealthReason = "component heartbeat timed out" - changed = true - } else if age >= time.Duration(profile.Health.DegradedAfterSeconds)*time.Second && installation.Status == domain.ClientManagerLifecycleOnline { - installation.Status = domain.ClientManagerLifecycleDegraded - installation.Health = domain.ClientManagerHealthDegraded - installation.HealthReason = "component heartbeat is late" - changed = true - } - } - } - if changed { - installation.UpdatedAt = stamp - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return err - } - } - } - return nil -} - -func (svc *CoreService) fenceClientManagerAfterKeyReset(serverInstanceID, profileKey string, generation int) error { - installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey}) - if err != nil { - return err - } - for _, installation := range installations { - if err := svc.revokeClientManagerSessions(installation.ID, "component key reset"); err != nil { - return err - } - installation.Status = domain.ClientManagerLifecycleFailed - installation.Phase = "key reset; rebuild and redeploy required" - installation.Health = domain.ClientManagerHealthOffline - installation.HealthReason = "component key generation changed" - installation.RequiresRedeploy = true - installation.KeyGeneration = generation - installation.UpdatedAt = svc.now() - if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { - return err - } - } - return nil -} - -func (svc *CoreService) resolveClientManagerDistributionID(serverInstanceID, profileKey, artifactOrDistributionID string) (string, error) { - if distribution, err := svc.store.ClientManagerDistributions().Get(artifactOrDistributionID); err == nil { - if distribution.ServerInstanceID == serverInstanceID && distribution.ProfileKey == profileKey { - return distribution.ID, nil - } - return "", ErrForbidden - } - distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey}) - if err != nil { - return "", err - } - for _, distribution := range distributions { - if distribution.ArtifactID == artifactOrDistributionID { - return distribution.ID, nil - } - } - return "", repo.ErrNotFound -} - -func safeClientManagerBridgeResult(view domain.ClientManagerLifecycleView) map[string]string { - installation := view.Installation - actions := make([]string, 0, len(view.Actions)) - for _, action := range view.Actions { - if action.Available { - actions = append(actions, string(action.Operation)) - } - } - sort.Strings(actions) - result := map[string]string{ - "installationId": installation.ID, - "profileKey": installation.ProfileKey, - "status": string(installation.Status), - "phase": installation.Phase, - "targetOS": installation.TargetOS, - "targetArch": installation.TargetArch, - "version": installation.ActiveVersion, - "previousVersion": installation.PreviousVersion, - "artifactId": installation.ActiveArtifactID, - "currentJobId": installation.CurrentJobID, - "deploymentGeneration": strconv.Itoa(installation.DeploymentGeneration), - "health": string(installation.Health), - "healthReason": installation.HealthReason, - "actions": strings.Join(actions, ","), - } - if !installation.LastSeenAt.IsZero() { - result["lastSeenAt"] = installation.LastSeenAt.UTC().Format(time.RFC3339) - } - return result -} diff --git a/platform/service/control_test.go b/platform/service/control_test.go index b147289..5d834a3 100644 --- a/platform/service/control_test.go +++ b/platform/service/control_test.go @@ -389,11 +389,6 @@ func TestCoreServiceGeneratedSCUMRunRegistrationDoesNotQueueGuidedStart(t *testi domain.JobCapabilityFilesRead, "files.patch", "logs.read", - domain.JobCapabilityClientManagerDeploy, - domain.JobCapabilityClientManagerControl, - domain.JobCapabilityClientManagerUpdate, - domain.JobCapabilityClientManagerRollback, - domain.JobCapabilityClientManagerUninstall, "artifacts.read", "artifacts.write", "ai.invoke", diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index 6e82569..7d0fbb0 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -169,45 +169,6 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D }, nil } - clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID}) - if err != nil { - return domain.DistributionBuildInput{}, err - } - for _, distribution := range clientDistributions { - if distribution.BuildJobID != job.ID { - continue - } - key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey) - if err != nil { - return domain.DistributionBuildInput{}, err - } - if key.Generation != distribution.KeyGeneration { - return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current") - } - plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey) - if err != nil { - return domain.DistributionBuildInput{}, err - } - return domain.DistributionBuildInput{ - JobID: job.ID, - ComponentKind: domain.DistributionComponentClientManager, - ServerInstanceID: distribution.ServerInstanceID, - PluginID: distribution.PluginID, - RunEndpointID: job.RunEndpointID, - ProfileKey: distribution.ProfileKey, - TargetOS: distribution.TargetOS, - TargetArch: distribution.TargetArch, - PlatformURL: runReleasePlatformURL(), - PackageFormat: packageFormatForTarget(distribution.TargetOS), - RepositoryURL: distribution.RepositoryURL, - SourceRevision: distribution.SourceRevision, - ArtifactID: distribution.ArtifactID, - OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS), - SecretRef: distribution.SecretRef, - KeyGeneration: distribution.KeyGeneration, - AuthKey: plainKey, - }, nil - } return domain.DistributionBuildInput{}, repo.ErrNotFound } diff --git a/platform/service/distribution_build_execution_test.go b/platform/service/distribution_build_execution_test.go index ad5bafd..cb75f1b 100644 --- a/platform/service/distribution_build_execution_test.go +++ b/platform/service/distribution_build_execution_test.go @@ -25,34 +25,6 @@ type progressDistributionBuilder struct { payload []byte } -type failingBuildProgressStore struct { - repo.Store - buildJobs repo.ClientManagerBuildJobRepository -} - -func (store failingBuildProgressStore) ClientManagerBuildJobs() repo.ClientManagerBuildJobRepository { - return store.buildJobs -} - -type failWhenDistributionJobRunningRepository struct { - repo.ClientManagerBuildJobRepository - jobs repo.JobRepository - err error -} - -func (repository failWhenDistributionJobRunningRepository) List(filter domain.ClientManagerBuildJobFilter) ([]domain.ClientManagerBuildJob, error) { - jobs, err := repository.jobs.List(domain.JobFilter{ServerInstanceID: filter.ServerInstanceID}) - if err != nil { - return nil, err - } - for _, job := range jobs { - if job.Capability == domain.JobCapabilityDistributionBuild && job.State == domain.JobStateRunning { - return nil, repository.err - } - } - return repository.ClientManagerBuildJobRepository.List(filter) -} - func (builder captureDistributionBuilder) Readiness() (bool, string) { return true, "" } @@ -421,49 +393,6 @@ func TestCoreServiceProjectsPlatformBuilderProgressBeforeCompletion(t *testing.T completeDistributionBuild(t, svc, distribution, nil) } -func TestCoreServiceTerminalizesPlatformBuildWhenRunningProjectionFails(t *testing.T) { - svc, session, instance := newDistributionTestFixture(t) - const leakedDetail = "sensitive-builder-token /private/platform/build/input/auth-key" - baseStore := svc.store - svc.store = failingBuildProgressStore{ - Store: baseStore, - buildJobs: failWhenDistributionJobRunningRepository{ - ClientManagerBuildJobRepository: baseStore.ClientManagerBuildJobs(), - jobs: baseStore.Jobs(), - err: errors.New(leakedDetail), - }, - } - - distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ - ServerInstanceID: instance.ID, - TargetOS: "linux", - TargetArch: "amd64", - IdempotencyKey: "running-projection-failure-terminalizes", - }) - if err != nil { - t.Fatalf("queue platform distribution: %v", err) - } - deadline := time.Now().Add(time.Second) - for time.Now().Before(deadline) { - job, jobErr := svc.GetJob(distribution.BuildJobID) - updated, distributionErr := svc.store.RunDistributions().Get(distribution.ID) - if jobErr != nil || distributionErr != nil { - t.Fatalf("read failed build state: jobErr=%v distributionErr=%v", jobErr, distributionErr) - } - if isTerminalJobState(job.State) { - if job.State != domain.JobStateFailed || updated.Status != domain.DistributionStatusFailed { - t.Fatalf("projection failure did not end in failed state: job=%+v distribution=%+v", job, updated) - } - if strings.Contains(job.Progress.Message, "sensitive-builder-token") || strings.Contains(job.Progress.Message, "/private/") || strings.Contains(job.TerminalFingerprint, "sensitive-builder-token") { - t.Fatalf("terminal build state leaked internal failure details: %+v", job) - } - return - } - time.Sleep(time.Millisecond) - } - t.Fatal("platform build remained non-terminal after running projection failure") -} - func TestCoreServiceDiscardsBuildCompletedAfterKeyReset(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) inputs := make(chan domain.DistributionBuildInput, 1) diff --git a/platform/service/distribution_build_jobs.go b/platform/service/distribution_build_jobs.go index 6ac75e5..70c4e8c 100644 --- a/platform/service/distribution_build_jobs.go +++ b/platform/service/distribution_build_jobs.go @@ -1,7 +1,6 @@ package service import ( - "errors" "strings" "time" @@ -77,44 +76,6 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui }, nil } - clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID}) - if err != nil { - return domain.DistributionBuildInput{}, err - } - for _, distribution := range clientDistributions { - if distribution.BuildJobID != job.ID { - continue - } - key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey) - if err != nil { - return domain.DistributionBuildInput{}, err - } - if key.Generation != distribution.KeyGeneration { - return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current") - } - plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey) - if err != nil { - return domain.DistributionBuildInput{}, err - } - return domain.DistributionBuildInput{ - JobID: job.ID, - ComponentKind: domain.DistributionComponentClientManager, - ServerInstanceID: distribution.ServerInstanceID, - PluginID: distribution.PluginID, - RunEndpointID: job.RunEndpointID, - ProfileKey: distribution.ProfileKey, - TargetOS: distribution.TargetOS, - TargetArch: distribution.TargetArch, - PackageFormat: packageFormatForTarget(distribution.TargetOS), - RepositoryURL: distribution.RepositoryURL, - SourceRevision: distribution.SourceRevision, - ArtifactID: distribution.ArtifactID, - OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS), - SecretRef: distribution.SecretRef, - KeyGeneration: distribution.KeyGeneration, - AuthKey: plainKey, - }, nil - } return domain.DistributionBuildInput{}, repo.ErrNotFound } @@ -122,21 +83,6 @@ func (svc *CoreService) projectDistributionBuildProgress(job domain.Job, stamp t if job.Capability != domain.JobCapabilityDistributionBuild { return nil } - builds, err := svc.store.ClientManagerBuildJobs().List(domain.ClientManagerBuildJobFilter{ServerInstanceID: job.ServerInstanceID}) - if err != nil { - return err - } - for _, build := range builds { - if build.ID != job.ID || build.Status != domain.DistributionJobStatusQueued { - continue - } - build.Status = domain.DistributionJobStatusRunning - build.UpdatedAt = stamp - if err := validator.ValidateClientManagerBuildJob(build); err != nil { - return err - } - return svc.store.ClientManagerBuildJobs().Update(build) - } return nil } @@ -148,7 +94,6 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim return err } status := domain.DistributionStatusFailed - buildStatus := domain.DistributionJobStatusFailed var artifact domain.Artifact if job.State == domain.JobStateSucceeded { artifactID := strings.TrimPrefix(job.ResultRef, "artifact://") @@ -164,7 +109,6 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim return validationError("distribution build artifact is unavailable or outside the job scope") } status = domain.DistributionStatusAvailable - buildStatus = domain.DistributionJobStatusSucceeded } runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID}) @@ -195,57 +139,6 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim return svc.store.RunDistributions().Update(distribution) } - clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID}) - if err != nil { - return err - } - for _, distribution := range clientDistributions { - if distribution.BuildJobID != job.ID { - continue - } - if distribution.Status == domain.DistributionStatusRevoked { - if artifact.ID != "" { - return svc.expireDistributionArtifact(artifact.ID) - } - return nil - } - if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID { - return validationError("client-manager build returned an unexpected artifact") - } - distribution.Status = status - if artifact.ID != "" { - distribution.Checksum = artifact.Checksum - } - distribution.UpdatedAt = stamp - if err := validator.ValidateClientManagerDistribution(distribution); err != nil { - return err - } - if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil { - return err - } - if err := svc.ProjectClientManagerDistribution(distribution); err != nil { - return err - } - build, err := svc.store.ClientManagerBuildJobs().Get(job.ID) - if err != nil && !errors.Is(err, repo.ErrNotFound) { - return err - } - if err == nil { - build.Status = buildStatus - if artifact.ID != "" { - build.ArtifactID = artifact.ID - build.Checksum = artifact.Checksum - } - build.UpdatedAt = stamp - if err := validator.ValidateClientManagerBuildJob(build); err != nil { - return err - } - if err := svc.store.ClientManagerBuildJobs().Update(build); err != nil { - return err - } - } - return nil - } return repo.ErrNotFound } @@ -265,18 +158,6 @@ func (svc *CoreService) validateDistributionBuildResult(job domain.Job) error { break } } - if expectedArtifactID == "" { - clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID}) - if err != nil { - return err - } - for _, distribution := range clientDistributions { - if distribution.BuildJobID == job.ID { - expectedArtifactID = distribution.ArtifactID - break - } - } - } if expectedArtifactID == "" { return repo.ErrNotFound } diff --git a/platform/service/distribution_builder.go b/platform/service/distribution_builder.go index 824b24c..848a672 100644 --- a/platform/service/distribution_builder.go +++ b/platform/service/distribution_builder.go @@ -2,10 +2,8 @@ package service import ( "archive/tar" - "archive/zip" "bufio" "bytes" - "compress/gzip" "context" "encoding/base64" "encoding/hex" @@ -307,14 +305,7 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib if len(binary) == 0 { return nil, validationError("platform builder produced an empty distribution executable") } - if input.ComponentKind == domain.DistributionComponentRun { - return binary, nil - } - configPayload, err := os.ReadFile(filepath.Join(outputDir, "config.yaml")) - if err != nil { - return nil, validationError("platform builder did not produce client-manager configuration") - } - return packageClientManagerDistribution(input.PackageFormat, outputName, binary, configPayload) + return binary, nil } // prepareRunSource implements the source phase of a Jenkins-style build. A @@ -519,36 +510,8 @@ EOF exit 0 fi -if [ "$COMPONENT_KIND" != "client-manager" ]; then - printf 'unsupported component kind\n' >&2 - exit 2 -fi -case "$REPOSITORY_URL" in - https://*) ;; - *) printf 'client-manager repository must use https\n' >&2; exit 2 ;; -esac -cd /workspace/build -progress 24 'git_sync: fetching client-manager source' -git init --quiet -git remote add origin "$REPOSITORY_URL" -git fetch --quiet --depth 1 origin "$SOURCE_REVISION" -git checkout --quiet --detach FETCH_HEAD -progress 36 'env_check: writing client-manager configuration' -{ - printf 'server_url: "%s"\n' "$PLATFORM_URL" - printf 'server_instance_id: "%s"\n' "$SERVER_INSTANCE_ID" - printf 'scum_client_credential: "%s"\n' "$auth_key" - printf 'scum_client_name: "%s"\n' "$PROFILE_KEY" - printf 'scum_client_version: "platform-build"\n' - printf 'scum_client_machine_label: "managed-client"\n' - printf 'ftp_provider: 3\n' -} > config.yaml -progress 52 'deps_download: downloading Go modules' -go mod download -progress 76 'build_compile: compiling client-manager executable' -go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" . -cp config.yaml /workspace/output/config.yaml -progress 88 'package_finalize: client-manager package inputs written' +printf 'unsupported component kind\n' >&2 +exit 2 ` func (builder *DockerDistributionBuilder) containerArgs(input domain.DistributionBuildInput, sourceDir string, inputDir string, buildDir string, outputDir string, goBuildCacheDir string, goModCacheDir string, outputName string) []string { @@ -588,71 +551,6 @@ func (builder *DockerDistributionBuilder) containerArgs(input domain.Distributio } } -func packageClientManagerDistribution(packageFormat string, outputName string, binary []byte, configPayload []byte) ([]byte, error) { - switch packageFormat { - case "zip": - return zipDistributionFiles(outputName, binary, configPayload) - case "tar.gz": - return tarGzipDistributionFiles(outputName, binary, configPayload) - default: - return nil, validationError("platform builder received an unsupported client-manager package format") - } -} - -func zipDistributionFiles(outputName string, binary []byte, configPayload []byte) ([]byte, error) { - var buffer bytes.Buffer - writer := zip.NewWriter(&buffer) - files := []struct { - name string - mode os.FileMode - payload []byte - }{{outputName, 0o755, binary}, {"config.yaml", 0o600, configPayload}} - for _, file := range files { - header := &zip.FileHeader{Name: file.name, Method: zip.Deflate} - header.SetMode(file.mode) - header.SetModTime(time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC)) - entry, err := writer.CreateHeader(header) - if err != nil { - return nil, err - } - if _, err := entry.Write(file.payload); err != nil { - return nil, err - } - } - if err := writer.Close(); err != nil { - return nil, err - } - return buffer.Bytes(), nil -} - -func tarGzipDistributionFiles(outputName string, binary []byte, configPayload []byte) ([]byte, error) { - var buffer bytes.Buffer - gzipWriter := gzip.NewWriter(&buffer) - gzipWriter.Header.ModTime = time.Unix(0, 0).UTC() - tarWriter := tar.NewWriter(gzipWriter) - files := []struct { - name string - mode int64 - payload []byte - }{{outputName, 0o755, binary}, {"config.yaml", 0o600, configPayload}} - for _, file := range files { - header := &tar.Header{Name: file.name, Mode: file.mode, Size: int64(len(file.payload)), ModTime: time.Unix(0, 0).UTC()} - if err := tarWriter.WriteHeader(header); err != nil { - return nil, err - } - if _, err := tarWriter.Write(file.payload); err != nil { - return nil, err - } - } - if err := tarWriter.Close(); err != nil { - return nil, err - } - if err := gzipWriter.Close(); err != nil { - return nil, err - } - return buffer.Bytes(), nil -} - // safeBuilderFailure keeps host paths and secret values out of reported build // failures. func safeBuilderFailure(output []byte, sensitiveValues ...string) string { diff --git a/platform/service/distribution_builder_test.go b/platform/service/distribution_builder_test.go index 445b7a3..414b822 100644 --- a/platform/service/distribution_builder_test.go +++ b/platform/service/distribution_builder_test.go @@ -2,12 +2,9 @@ package service import ( "archive/tar" - "archive/zip" "bytes" - "compress/gzip" "context" "errors" - "io" "os" "path/filepath" "strings" @@ -384,29 +381,6 @@ func TestBuilderJobFailureMessageRedactsSensitiveValuesAndHostPaths(t *testing.T } } -func TestPackageClientManagerDistributionProducesProtectedArchives(t *testing.T) { - for _, packageFormat := range []string{"zip", "tar.gz"} { - t.Run(packageFormat, func(t *testing.T) { - payload, err := packageClientManagerDistribution(packageFormat, "client-manager.exe", []byte("binary"), []byte("credential: protected")) - if err != nil { - t.Fatalf("package client manager: %v", err) - } - files := readDistributionArchive(t, packageFormat, payload) - if string(files["client-manager.exe"].payload) != "binary" || files["client-manager.exe"].mode.Perm() != 0o755 { - t.Fatalf("unexpected executable archive entry: %+v", files["client-manager.exe"]) - } - if string(files["config.yaml"].payload) != "credential: protected" || files["config.yaml"].mode.Perm() != 0o600 { - t.Fatalf("unexpected config archive entry: %+v", files["config.yaml"]) - } - }) - } -} - -type distributionArchiveFile struct { - mode os.FileMode - payload []byte -} - func createBuilderSource(t *testing.T) string { t.Helper() sourceDir := t.TempDir() @@ -443,55 +417,3 @@ func builderMountHostPath(t *testing.T, args []string, containerSuffix string) s t.Fatalf("Docker arguments do not mount %s: %+v", containerSuffix, args) return "" } - -func readDistributionArchive(t *testing.T, packageFormat string, payload []byte) map[string]distributionArchiveFile { - t.Helper() - files := map[string]distributionArchiveFile{} - switch packageFormat { - case "zip": - reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload))) - if err != nil { - t.Fatalf("open zip: %v", err) - } - for _, entry := range reader.File { - body, err := entry.Open() - if err != nil { - t.Fatalf("open zip entry %s: %v", entry.Name, err) - } - content, err := io.ReadAll(body) - if closeErr := body.Close(); err == nil { - err = closeErr - } - if err != nil { - t.Fatalf("read zip entry %s: %v", entry.Name, err) - } - files[entry.Name] = distributionArchiveFile{mode: entry.Mode(), payload: content} - } - case "tar.gz": - gzipReader, err := gzip.NewReader(bytes.NewReader(payload)) - if err != nil { - t.Fatalf("open gzip: %v", err) - } - tarReader := tar.NewReader(gzipReader) - for { - header, err := tarReader.Next() - if errors.Is(err, io.EOF) { - break - } - if err != nil { - t.Fatalf("read tar header: %v", err) - } - content, err := io.ReadAll(tarReader) - if err != nil { - t.Fatalf("read tar entry %s: %v", header.Name, err) - } - files[header.Name] = distributionArchiveFile{mode: os.FileMode(header.Mode), payload: content} - } - if err := gzipReader.Close(); err != nil { - t.Fatalf("close gzip: %v", err) - } - default: - t.Fatalf("unsupported archive format %q", packageFormat) - } - return files -} diff --git a/platform/service/distributions.go b/platform/service/distributions.go index 053d20c..2c9d7dc 100644 --- a/platform/service/distributions.go +++ b/platform/service/distributions.go @@ -115,158 +115,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ return domain.CopyRunDistribution(distribution), nil } -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 - } - if err := validator.ValidateClientManagerBuildRequest(request); err != nil { - return domain.ClientManagerDistribution{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.ClientManagerDistribution{}, err - } - instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) - if err != nil { - return domain.ClientManagerDistribution{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return domain.ClientManagerDistribution{}, err - } - if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.client-manager.manage", "client-manager.build.denied"); err != nil { - return domain.ClientManagerDistribution{}, err - } - if err := validatePluginTarget(plugin, request.TargetOS); err != nil { - return domain.ClientManagerDistribution{}, err - } - profile, err := findRuntimeClientManagerProfile(plugin, request.ProfileKey) - if err != nil { - return domain.ClientManagerDistribution{}, err - } - if strings.TrimSpace(request.SourceRevision) == "" { - request.SourceRevision = clientManagerProfileRevision(profile) - } - if !clientManagerProfileSupportsTarget(profile, request.TargetOS, request.TargetArch) || request.RepositoryURL != profile.RepositoryURL || !clientManagerProfileAllowsRevision(profile, request.SourceRevision) { - return domain.ClientManagerDistribution{}, validationError("client-manager build must match the declared profile repository, revision, and target") - } - if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil { - return domain.ClientManagerDistribution{}, err - } - if ready, reason := svc.distributionBuilderReadiness(); !ready { - return domain.ClientManagerDistribution{}, validationError(reason) - } - - key, _, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey) - if err != nil { - return domain.ClientManagerDistribution{}, err - } - clientDistributionID := distributionID("client-manager-dist", instance.ID, request.ProfileKey, request.TargetOS, request.TargetArch, key.Generation, request.IdempotencyKey) - if existing, err := svc.store.ClientManagerDistributions().Get(clientDistributionID); err == nil { - return domain.CopyClientManagerDistribution(existing), nil - } else if !errors.Is(err, repo.ErrNotFound) { - return domain.ClientManagerDistribution{}, err - } - - artifactID := artifactIDForDistribution(clientDistributionID + "-binary") - stamp := svc.now() - buildJobID := jobIDFromParts("job-distribution-build", instance.ID, clientDistributionID) - buildJob := domain.ClientManagerBuildJob{ - ID: buildJobID, - ServerInstanceID: instance.ID, - PluginID: plugin.ID, - ProfileKey: request.ProfileKey, - Version: profile.Version, - TargetOS: request.TargetOS, - TargetArch: request.TargetArch, - RepositoryURL: request.RepositoryURL, - SourceRevision: request.SourceRevision, - ArtifactID: artifactID, - KeyGeneration: key.Generation, - Status: domain.DistributionJobStatusQueued, - CreatedAt: stamp, - UpdatedAt: stamp, - } - if err := validator.ValidateClientManagerBuildJob(buildJob); err != nil { - return domain.ClientManagerDistribution{}, err - } - if err := svc.store.ClientManagerBuildJobs().Create(buildJob); err != nil { - if !errors.Is(err, repo.ErrDuplicate) { - return domain.ClientManagerDistribution{}, err - } - existing, getErr := svc.store.ClientManagerBuildJobs().Get(buildJob.ID) - if getErr != nil { - return domain.ClientManagerDistribution{}, getErr - } - if !sameClientManagerBuildJobArtifacts(existing, buildJob) { - return domain.ClientManagerDistribution{}, validationError("client-manager build job already exists with different artifacts") - } - buildJob = existing - } - distribution := domain.ClientManagerDistribution{ - ID: clientDistributionID, - ServerInstanceID: instance.ID, - PluginID: plugin.ID, - ProfileKey: request.ProfileKey, - Version: profile.Version, - TargetOS: request.TargetOS, - TargetArch: request.TargetArch, - RepositoryURL: request.RepositoryURL, - SourceRevision: request.SourceRevision, - BuildJobID: buildJob.ID, - ArtifactID: artifactID, - KeyGeneration: key.Generation, - SecretRef: key.SecretRef, - Status: domain.DistributionStatusBuilding, - CreatedAt: stamp, - UpdatedAt: stamp, - } - if err := validator.ValidateClientManagerDistribution(distribution); err != nil { - return domain.ClientManagerDistribution{}, err - } - if err := svc.store.ClientManagerDistributions().Create(distribution); err != nil { - if errors.Is(err, repo.ErrDuplicate) { - existing, getErr := svc.store.ClientManagerDistributions().Get(distribution.ID) - if getErr != nil { - return domain.ClientManagerDistribution{}, getErr - } - return domain.CopyClientManagerDistribution(existing), nil - } - return domain.ClientManagerDistribution{}, err - } - if err := svc.ProjectClientManagerDistribution(distribution); err != nil { - return domain.ClientManagerDistribution{}, err - } - job, err := svc.CreateJob(domain.Job{ - ID: buildJobID, - ServerInstanceID: instance.ID, - RunEndpointID: platformDistributionBuilderEndpointID, - Capability: domain.JobCapabilityDistributionBuild, - TargetKey: "distribution/client-manager/" + request.ProfileKey, - InputRef: "input://distribution-build/" + distribution.ID, - IdempotencyKey: "distribution-build:" + distribution.ID, - Progress: domain.JobProgress{Percent: 0, Message: "build queued"}, - }) - if err != nil { - buildJob.Status = domain.DistributionJobStatusFailed - buildJob.UpdatedAt = svc.now() - distribution.Status = domain.DistributionStatusFailed - distribution.UpdatedAt = buildJob.UpdatedAt - _ = svc.store.ClientManagerBuildJobs().Update(buildJob) - _ = svc.store.ClientManagerDistributions().Update(distribution) - _ = svc.ProjectClientManagerDistribution(distribution) - return domain.ClientManagerDistribution{}, err - } - if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild { - return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job") - } - svc.enqueueDistributionBuild(job) - return domain.CopyClientManagerDistribution(distribution), nil -} - func (svc *CoreService) OpenLatestRunDistributionDownloadForSession(sessionID string, serverInstanceID string) (domain.ArtifactDownloadReference, error) { instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) if err != nil { @@ -288,33 +136,6 @@ func (svc *CoreService) OpenLatestRunDistributionDownloadForSession(sessionID st return svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: latest.ArtifactID}) } -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 - } - filter := domain.ClientManagerDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable} - if strings.TrimSpace(profileKey) != "" { - filter.ProfileKey = strings.TrimSpace(profileKey) - } - distributions, err := svc.store.ClientManagerDistributions().List(filter) - if err != nil { - return domain.ArtifactDownloadReference{}, err - } - var latest domain.ClientManagerDistribution - for _, distribution := range distributions { - if latest.ID == "" || distribution.KeyGeneration > latest.KeyGeneration || (distribution.KeyGeneration == latest.KeyGeneration && distribution.UpdatedAt.After(latest.UpdatedAt)) { - latest = distribution - } - } - if latest.ID == "" { - return domain.ArtifactDownloadReference{}, repo.ErrNotFound - } - return svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: latest.ArtifactID}) -} - func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error) { request = domain.CopyComponentKeyResetRequest(request) if err := validator.ValidateComponentKeyResetRequest(request); err != nil { @@ -810,27 +631,6 @@ func (svc *CoreService) revokeComponentDistributions(serverInstanceID string, ki return err } } - case domain.DistributionComponentClientManager: - distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: serverInstanceID, ProfileKey: componentKey}) - if err != nil { - return err - } - for _, distribution := range distributions { - if distribution.KeyGeneration >= latestGeneration || distribution.Status == domain.DistributionStatusRevoked { - continue - } - distribution.Status = domain.DistributionStatusRevoked - distribution.UpdatedAt = svc.now() - if err := validator.ValidateClientManagerDistribution(distribution); err != nil { - return err - } - if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil { - return err - } - if err := svc.expireDistributionArtifact(distribution.ArtifactID); err != nil { - return err - } - } } return nil } @@ -929,22 +729,6 @@ func (svc *CoreService) ensureArtifactPayload(artifactID string, payload []byte, return nil } -func sameClientManagerBuildJobArtifacts(existing domain.ClientManagerBuildJob, expected domain.ClientManagerBuildJob) bool { - return existing.ServerInstanceID == expected.ServerInstanceID && - existing.PluginID == expected.PluginID && - existing.ProfileKey == expected.ProfileKey && - existing.Version == expected.Version && - existing.TargetOS == expected.TargetOS && - existing.TargetArch == expected.TargetArch && - existing.RepositoryURL == expected.RepositoryURL && - existing.SourceRevision == expected.SourceRevision && - existing.ArtifactID == expected.ArtifactID && - existing.Checksum == expected.Checksum && - existing.KeyGeneration == expected.KeyGeneration && - existing.LogsRef == expected.LogsRef && - existing.Status == expected.Status -} - func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, jobID string, required bool, state domain.DependencyState, message string) error { statusID := distributionID("dependency-status", instance.ID, request.ProbeKey) stamp := svc.now() @@ -1070,14 +854,6 @@ func fingerprintForString(value string) string { return hex.EncodeToString(sum[:])[:12] } -func clientManagerOutputName(profileKey string, targetOS string) string { - name := sanitizeIDPart(profileKey) - if targetOS == "windows" { - return name + ".exe" - } - return name -} - func minInt(a int, b int) int { if a < b { return a diff --git a/platform/service/distributions_test.go b/platform/service/distributions_test.go index 9fd57a0..2c2625d 100644 --- a/platform/service/distributions_test.go +++ b/platform/service/distributions_test.go @@ -511,30 +511,18 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test } } -func TestCoreServiceRejectsClientManagerDistributionRequests(t *testing.T) { +func TestCoreServiceRejectsClientManagerComponentRequests(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) - _, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ - ServerInstanceID: instance.ID, - ProfileKey: "scum-client-manager", - TargetOS: "windows", - TargetArch: "amd64", - RepositoryURL: "https://github.com/F88888/scum_client.git", - SourceRevision: "main", - IdempotencyKey: "idem-client-manager", - }) - if err == nil { - t.Fatal("expected client-manager distribution request to be rejected") - } if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{ ServerInstanceID: instance.ID, - ComponentKind: domain.DistributionComponentClientManager, + ComponentKind: domain.DistributionComponentKind("client-manager"), ComponentKey: "scum-client-manager", }); 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, + ComponentKind: domain.DistributionComponentKind("client-manager"), ComponentKey: "scum-client-manager", Generation: 1, Key: "legacy-client-manager-key", @@ -555,9 +543,9 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv ) plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRunSelfUpdate, - domain.JobCapabilityDependenciesCheck, - domain.JobCapabilityDependenciesInstall, - domain.JobCapabilityLogsBackfill, + domain.JobCapabilityDependenciesCheck, + domain.JobCapabilityDependenciesInstall, + domain.JobCapabilityLogsBackfill, ) plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRunDistribution), @@ -572,9 +560,9 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv } endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRunSelfUpdate, - domain.JobCapabilityDependenciesCheck, - domain.JobCapabilityDependenciesInstall, - domain.JobCapabilityLogsBackfill, + domain.JobCapabilityDependenciesCheck, + domain.JobCapabilityDependenciesInstall, + domain.JobCapabilityLogsBackfill, ) endpoint.Platform = "linux" endpoint.Architecture = "amd64" diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index f9421e4..1cb31a1 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -256,9 +256,6 @@ func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (do if err := svc.projectDependencyAndRunUpdateProgress(job, stamp); err != nil { return domain.RunJobProgressResult{}, err } - if err := svc.projectClientManagerLifecycleProgress(job, stamp); err != nil { - return domain.RunJobProgressResult{}, err - } return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, progress.LeaseToken), ServerTime: stamp}, nil } @@ -347,9 +344,6 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo if err := svc.projectDependencyAndRunUpdateResult(job, stamp); err != nil { return domain.RunJobResultResult{}, err } - if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil { - return domain.RunJobResultResult{}, err - } if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil { return domain.RunJobResultResult{}, err } @@ -425,26 +419,6 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "run.update.staged" { return validationError("Run self-update result type is invalid") } - case domain.JobCapabilityClientManagerDeploy: - if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" { - return validationError("client-manager deploy result type is invalid") - } - case domain.JobCapabilityClientManagerControl: - if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.control" { - return validationError("client-manager control result type is invalid") - } - case domain.JobCapabilityClientManagerUpdate: - if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.updated" || result.State == domain.JobStateFailed && result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != "client-manager.rollback.restored" { - return validationError("client-manager update result type is invalid") - } - case domain.JobCapabilityClientManagerRollback: - if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.rolled-back" { - return validationError("client-manager rollback result type is invalid") - } - case domain.JobCapabilityClientManagerUninstall: - if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.uninstalled" { - return validationError("client-manager uninstall result type is invalid") - } } return nil } diff --git a/platform/service/runtime_bindings.go b/platform/service/runtime_bindings.go index 9cb6d7e..ecfa810 100644 --- a/platform/service/runtime_bindings.go +++ b/platform/service/runtime_bindings.go @@ -157,9 +157,6 @@ func runtimeBindingKeys(profiles domain.GamePluginRuntimeProfiles, profile domai } } } - if profile.ClientManagerRef != "" { - add(profile.ClientManagerRef, true) - } required := make([]string, 0, len(requiredSet)) for key := range requiredSet { required = append(required, key) diff --git a/platform/service/server_deployment_test.go b/platform/service/server_deployment_test.go index 18854c0..3433633 100644 --- a/platform/service/server_deployment_test.go +++ b/platform/service/server_deployment_test.go @@ -93,7 +93,7 @@ func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedSupervisedStartAction(t plugin.Version = "1.0.0" plugin.ServerType = "scum" plugin.Status = domain.GamePluginStatusInstalled - plugin.RequiredRunCapabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "process.restart", "files.list", "files.patch", domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, "artifacts.read", "artifacts.write", "ai.invoke"} + plugin.RequiredRunCapabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "process.restart", "files.list", "files.patch", "artifacts.read", "artifacts.write", "ai.invoke"} plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"} plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}} requireManualSCUMRuntimeBindings(&plugin, "local") diff --git a/platform/validator/client_manager_lifecycle.go b/platform/validator/client_manager_lifecycle.go deleted file mode 100644 index a6ffe79..0000000 --- a/platform/validator/client_manager_lifecycle.go +++ /dev/null @@ -1,293 +0,0 @@ -package validator - -import ( - "regexp" - "strings" - - "browser.local/platform/domain" -) - -var clientManagerIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$`) - -func ValidateClientManagerInstallation(value domain.ClientManagerInstallation) error { - var violations []string - violations = appendRequired(violations, "id", value.ID) - violations = appendRequired(violations, "serverInstanceId", value.ServerInstanceID) - violations = appendRequired(violations, "pluginId", value.PluginID) - violations = appendRequired(violations, "profileKey", value.ProfileKey) - violations = appendRequired(violations, "runEndpointId", value.RunEndpointID) - if !validDistributionLogicalKey(value.ProfileKey) { - violations = append(violations, "profileKey is invalid") - } - if value.TargetOS != "" || value.TargetArch != "" { - violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch) - } - if !validClientManagerLifecycleStatus(value.Status) { - violations = append(violations, "status is invalid") - } - if !validClientManagerHealth(value.Health) { - violations = append(violations, "health is invalid") - } - if value.KeyGeneration < 0 || value.DeploymentGeneration < 0 { - violations = append(violations, "keyGeneration and deploymentGeneration must not be negative") - } - if value.Checksum != "" && !validSHA256Checksum(value.Checksum) { - violations = append(violations, "checksum must be sha256:") - } - for field, content := range map[string]string{"phase": value.Phase, "healthReason": value.HealthReason} { - if len(content) > 240 || unsafeLifecycleText(content) { - violations = append(violations, field+" must be bounded and redacted") - } - } - if value.CreatedAt.IsZero() || value.UpdatedAt.IsZero() { - violations = append(violations, "createdAt and updatedAt are required") - } - return finish(violations) -} - -func ValidateClientManagerLifecycleTransition(from, to domain.ClientManagerLifecycleStatus) error { - if from == to { - return nil - } - allowed := map[domain.ClientManagerLifecycleStatus][]domain.ClientManagerLifecycleStatus{ - domain.ClientManagerLifecycleRequested: {domain.ClientManagerLifecycleBuilding, domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleBuilding: {domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleAvailable: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleDeploying: {domain.ClientManagerLifecycleInstalled, domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleFailed, domain.ClientManagerLifecycleUninstalled}, - domain.ClientManagerLifecycleInstalled: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleRegistering: {domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleOnline: {domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleDegraded: {domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleOffline: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleUpdating: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleRollingBack: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleStopping: {domain.ClientManagerLifecycleInstalled, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleUninstalled: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed}, - domain.ClientManagerLifecycleFailed: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled}, - } - for _, candidate := range allowed[from] { - if candidate == to { - return nil - } - } - return finish([]string{"client-manager lifecycle transition is invalid"}) -} - -func ValidateClientManagerSession(value domain.ClientManagerSession) error { - var violations []string - for field, content := range map[string]string{"id": value.ID, "installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "runEndpointId": value.RunEndpointID, "artifactId": value.ArtifactID, "tokenHash": value.TokenHash} { - violations = appendRequired(violations, field, content) - } - if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 { - violations = append(violations, "keyGeneration and deploymentGeneration must be positive") - } - if len(value.TokenHash) != 64 || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(value.TokenHash) { - violations = append(violations, "tokenHash must be a SHA-256 digest") - } - if !oneOf(string(value.Status), string(domain.ClientManagerSessionActive), string(domain.ClientManagerSessionRevoked), string(domain.ClientManagerSessionExpired)) { - violations = append(violations, "status is invalid") - } - if value.ExpiresAt.IsZero() || value.CreatedAt.IsZero() || value.UpdatedAt.IsZero() { - violations = append(violations, "session timestamps are required") - } - return finish(violations) -} - -func ValidateClientManagerNonce(value domain.ClientManagerRegistrationNonce) error { - var violations []string - violations = appendRequired(violations, "id", value.ID) - violations = appendRequired(violations, "installationId", value.InstallationID) - if len(value.ID) != 64 || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(value.ID) { - violations = append(violations, "id must be a nonce SHA-256 digest") - } - if value.ExpiresAt.IsZero() || value.CreatedAt.IsZero() || !value.ExpiresAt.After(value.CreatedAt) { - violations = append(violations, "nonce expiry must follow creation") - } - return finish(violations) -} - -func ValidateClientManagerDeployRequest(value domain.ClientManagerDeployRequest) error { - return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, value.DistributionID, value.IdempotencyKey, value.ExpectedDeploymentGeneration, true) -} - -func ValidateClientManagerControlRequest(value domain.ClientManagerControlRequest) error { - if !oneOf(string(value.Operation), "start", "stop", "restart", "status", "rollback") { - return finish([]string{"operation is invalid"}) - } - return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false) -} - -func ValidateClientManagerUpdateRequest(value domain.ClientManagerUpdateRequest) error { - var violations []string - if !value.Approved { - violations = append(violations, "approved must be true") - } - if err := validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, value.DistributionID, value.IdempotencyKey, value.ExpectedDeploymentGeneration, true); err != nil { - violations = append(violations, err.Error()) - } - return finish(violations) -} - -func ValidateClientManagerUninstallRequest(value domain.ClientManagerUninstallRequest) error { - var violations []string - if !value.Confirmed { - violations = append(violations, "confirmed must be true") - } - if err := validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false); err != nil { - violations = append(violations, err.Error()) - } - return finish(violations) -} - -func ValidateClientManagerRetryRequest(value domain.ClientManagerRetryRequest) error { - return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false) -} - -func ValidateClientManagerLifecycleInputRequest(value domain.ClientManagerLifecycleInputRequest) error { - var violations []string - for field, content := range map[string]string{"runEndpointId": value.RunEndpointID, "sessionToken": value.SessionToken, "jobId": value.JobID, "leaseToken": value.LeaseToken} { - violations = appendRequired(violations, field, content) - } - if value.Attempt <= 0 { - violations = append(violations, "attempt must be positive") - } - return finish(violations) -} - -func ValidateClientManagerCompanionConfigInput(value domain.ClientManagerCompanionConfigInput) error { - var violations []string - if value.SchemaVersion != domain.ClientManagerCompanionConfigSchemaVersion { - violations = append(violations, "schemaVersion is invalid") - } - for field, content := range map[string]string{ - "configTemplateKey": value.ConfigTemplateKey, "configTemplateRef": value.ConfigTemplateRef, "configOutputRef": value.ConfigOutputRef, - "configSchemaRef": value.ConfigSchemaRef, "installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, - "pluginId": value.PluginID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version, - "sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "proofMaterialEnv": value.ProofMaterialEnv, - } { - violations = appendRequired(violations, field, content) - } - if !clientManagerIdentifierPattern.MatchString(value.ConfigTemplateKey) { - violations = append(violations, "configTemplateKey is invalid") - } - violations = append(violations, validateSafeRelativeRuntimePath("configTemplateRef", value.ConfigTemplateRef)...) - if value.ConfigOutputRef != "config.yaml" { - violations = append(violations, "configOutputRef must be config.yaml") - } - if !safeRelativeJSONRef(value.ConfigSchemaRef) { - violations = append(violations, "configSchemaRef must be a safe relative JSON reference") - } - if value.ConfigFormat != "yaml" || value.PlatformBaseURLSource != "run-control" || value.RegistrationProof != "hmac-sha256" || value.ProofMaterialSource != "component-package" || value.SessionMode != "component-session" || value.TLSPolicy != "verify-system-roots" { - violations = append(violations, "companion bootstrap security policy is invalid") - } - if !validCompanionProofEnvironment(value.ProofMaterialEnv) { - violations = append(violations, "proofMaterialEnv is invalid") - } - for field, identifier := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "pluginId": value.PluginID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID} { - if !clientManagerIdentifierPattern.MatchString(identifier) { - violations = append(violations, field+" is invalid") - } - } - violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch) - if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 { - violations = append(violations, "keyGeneration and deploymentGeneration must be positive") - } - if len(value.Capabilities) == 0 || len(value.Capabilities) > 32 { - violations = append(violations, "capabilities must be bounded") - } - capabilities := make(map[string]struct{}, len(value.Capabilities)) - for _, capability := range value.Capabilities { - if !clientManagerIdentifierPattern.MatchString(capability) { - violations = append(violations, "capability is invalid") - } - if _, exists := capabilities[capability]; exists { - violations = append(violations, "capabilities must be unique") - } - capabilities[capability] = struct{}{} - } - for _, required := range []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"} { - if _, exists := capabilities[required]; !exists { - violations = append(violations, "capabilities must include "+required) - } - } - if value.HeartbeatIntervalSeconds < 5 || value.HeartbeatIntervalSeconds > 300 || value.CommandPollIntervalSeconds < 1 || value.CommandPollIntervalSeconds > 60 || value.RequestTimeoutSeconds < 1 || value.RequestTimeoutSeconds > 60 { - violations = append(violations, "companion timing policy is invalid") - } - return finish(violations) -} - -func ValidateClientManagerRegisterRequest(value domain.ClientManagerRegisterRequest) error { - var violations []string - for field, content := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version, "sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "nonce": value.Nonce, "signature": value.Signature} { - violations = appendRequired(violations, field, content) - } - violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch) - if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 { - violations = append(violations, "keyGeneration and deploymentGeneration must be positive") - } - if value.Timestamp.IsZero() { - violations = append(violations, "timestamp is required") - } - if !regexp.MustCompile(`^[A-Za-z0-9_-]{16,128}$`).MatchString(value.Nonce) { - violations = append(violations, "nonce is invalid") - } - if !regexp.MustCompile(`^sha256:[a-f0-9]{64}$`).MatchString(value.Signature) { - violations = append(violations, "signature is invalid") - } - if len(value.Capabilities) == 0 { - violations = append(violations, "capabilities must not be empty") - } - return finish(violations) -} - -func ValidateClientManagerHeartbeat(value domain.ClientManagerHeartbeat) error { - var violations []string - violations = appendRequired(violations, "installationId", value.InstallationID) - violations = appendRequired(violations, "sessionToken", value.SessionToken) - if value.Sequence == 0 { - violations = append(violations, "sequence must be positive") - } - if !validClientManagerHealth(value.Health) || value.Health == domain.ClientManagerHealthUnknown { - violations = append(violations, "health is invalid") - } - if len(value.HealthReason) > 240 || unsafeLifecycleText(value.HealthReason) { - violations = append(violations, "healthReason must be bounded and redacted") - } - if value.SentAt.IsZero() { - violations = append(violations, "sentAt is required") - } - return finish(violations) -} - -func validateClientManagerOperationRequest(serverID, profileKey, distributionID, idempotencyKey string, generation int, distributionRequired bool) error { - var violations []string - violations = appendRequired(violations, "serverInstanceId", serverID) - violations = appendRequired(violations, "profileKey", profileKey) - violations = appendRequired(violations, "idempotencyKey", idempotencyKey) - if distributionRequired { - violations = appendRequired(violations, "distributionId", distributionID) - } - if !validDistributionLogicalKey(profileKey) { - violations = append(violations, "profileKey is invalid") - } - if !clientManagerIdentifierPattern.MatchString(idempotencyKey) { - violations = append(violations, "idempotencyKey is invalid") - } - if generation < 0 { - violations = append(violations, "expectedDeploymentGeneration must not be negative") - } - return finish(violations) -} - -func validClientManagerLifecycleStatus(value domain.ClientManagerLifecycleStatus) bool { - return oneOf(string(value), "requested", "building", "available", "deploying", "installed", "registering", "online", "degraded", "offline", "updating", "rolling_back", "stopping", "uninstalled", "failed") -} - -func validClientManagerHealth(value domain.ClientManagerHealthStatus) bool { - return oneOf(string(value), "unknown", "healthy", "degraded", "unhealthy", "offline") -} - -func unsafeLifecycleText(value string) bool { - lowered := strings.ToLower(strings.TrimSpace(value)) - return strings.Contains(lowered, "secret://") || strings.Contains(lowered, "bearer ") || strings.Contains(lowered, "password=") || strings.Contains(lowered, "token=") || strings.Contains(lowered, "unix://") || strings.Contains(lowered, "tcp://") || looksLikeRawHostPath(value) -} diff --git a/platform/validator/client_manager_lifecycle_test.go b/platform/validator/client_manager_lifecycle_test.go deleted file mode 100644 index 904b117..0000000 --- a/platform/validator/client_manager_lifecycle_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package validator - -import ( - "strings" - "testing" - "time" - - "browser.local/platform/domain" -) - -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 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 historical installation projection: %v", err) - } - installation.HealthReason = "Bearer stolen-session" - if err := ValidateClientManagerInstallation(installation); err == nil { - t.Fatal("expected session-bearing health reason rejection") - } - 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 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") - } -} diff --git a/platform/validator/distributions.go b/platform/validator/distributions.go index ba98d43..8ead4d0 100644 --- a/platform/validator/distributions.go +++ b/platform/validator/distributions.go @@ -2,7 +2,6 @@ package validator import ( "fmt" - "net/url" "strings" "browser.local/platform/domain" @@ -57,9 +56,6 @@ func ValidateEncryptedComponentKey(key domain.EncryptedComponentKey) error { if !validDistributionComponentKind(key.ComponentKind) { violations = append(violations, "componentKind is invalid") } - if key.ComponentKind == domain.DistributionComponentClientManager && strings.TrimSpace(key.ComponentKey) == "" { - violations = append(violations, "componentKey is required for client-manager keys") - } if key.ComponentKey != "" && !validDistributionLogicalKey(key.ComponentKey) { violations = append(violations, "componentKey is invalid") } @@ -125,44 +121,6 @@ func ValidateRunDistribution(distribution domain.RunDistribution) error { return finish(violations) } -func ValidateClientManagerDistribution(distribution domain.ClientManagerDistribution) error { - var violations []string - violations = appendRequired(violations, "id", distribution.ID) - violations = appendRequired(violations, "serverInstanceId", distribution.ServerInstanceID) - violations = appendRequired(violations, "pluginId", distribution.PluginID) - violations = appendRequired(violations, "profileKey", distribution.ProfileKey) - violations = appendRequired(violations, "repositoryUrl", distribution.RepositoryURL) - violations = appendRequired(violations, "sourceRevision", distribution.SourceRevision) - violations = appendRequired(violations, "buildJobId", distribution.BuildJobID) - violations = appendRequired(violations, "artifactId", distribution.ArtifactID) - violations = appendRequired(violations, "secretRef", distribution.SecretRef) - violations = appendDistributionTargetViolations(violations, distribution.TargetOS, distribution.TargetArch) - violations = appendDistributionStatusViolations(violations, distribution.Status) - if !validDistributionLogicalKey(distribution.ProfileKey) { - violations = append(violations, "profileKey is invalid") - } - if distribution.Checksum != "" && !validSHA256Checksum(distribution.Checksum) { - violations = append(violations, "checksum must be sha256:") - } - if distribution.Status == domain.DistributionStatusAvailable && distribution.Checksum == "" { - violations = append(violations, "checksum is required when distribution is available") - } - if distribution.KeyGeneration <= 0 { - violations = append(violations, "keyGeneration must be positive") - } - violations = append(violations, validateRepositoryURL("repositoryUrl", distribution.RepositoryURL)...) - if !strings.HasPrefix(distribution.SecretRef, "secret://runtime-keys/") { - violations = append(violations, "secretRef must be redacted runtime key ref") - } - if distribution.CreatedAt.IsZero() { - violations = append(violations, "createdAt is required") - } - if distribution.UpdatedAt.IsZero() { - violations = append(violations, "updatedAt is required") - } - return finish(violations) -} - func ValidateDependencyStatus(status domain.DependencyStatus) error { var violations []string violations = appendRequired(violations, "id", status.ID) @@ -205,40 +163,6 @@ func ValidateDependencyStatus(status domain.DependencyStatus) error { return finish(violations) } -func ValidateClientManagerBuildJob(job domain.ClientManagerBuildJob) error { - var violations []string - violations = appendRequired(violations, "id", job.ID) - violations = appendRequired(violations, "serverInstanceId", job.ServerInstanceID) - violations = appendRequired(violations, "pluginId", job.PluginID) - violations = appendRequired(violations, "profileKey", job.ProfileKey) - violations = appendRequired(violations, "repositoryUrl", job.RepositoryURL) - violations = appendRequired(violations, "sourceRevision", job.SourceRevision) - violations = appendDistributionTargetViolations(violations, job.TargetOS, job.TargetArch) - if !validDistributionLogicalKey(job.ProfileKey) { - violations = append(violations, "profileKey is invalid") - } - if job.Checksum != "" && !validSHA256Checksum(job.Checksum) { - violations = append(violations, "checksum must be sha256:") - } - if job.LogsRef != "" && !validScopedInputRef(job.LogsRef) { - violations = append(violations, "logsRef is not allowed") - } - if job.KeyGeneration < 0 { - violations = append(violations, "keyGeneration must not be negative") - } - if !validDistributionJobStatus(job.Status) { - violations = append(violations, "status is invalid") - } - violations = append(violations, validateRepositoryURL("repositoryUrl", job.RepositoryURL)...) - if job.CreatedAt.IsZero() { - violations = append(violations, "createdAt is required") - } - if job.UpdatedAt.IsZero() { - violations = append(violations, "updatedAt is required") - } - return finish(violations) -} - func ValidateRunUpdateJob(job domain.RunUpdateJob) error { var violations []string violations = appendRequired(violations, "id", job.ID) @@ -297,28 +221,6 @@ func ValidateRunDistributionGenerateRequest(request domain.RunDistributionGenera return finish(violations) } -func ValidateClientManagerBuildRequest(request domain.ClientManagerBuildRequest) error { - var violations []string - violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) - violations = appendRequired(violations, "profileKey", request.ProfileKey) - violations = appendRequired(violations, "targetOs", request.TargetOS) - violations = appendRequired(violations, "targetArch", request.TargetArch) - violations = appendRequired(violations, "repositoryUrl", request.RepositoryURL) - violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey) - violations = appendDistributionTargetViolations(violations, request.TargetOS, request.TargetArch) - if !validDistributionLogicalKey(request.ProfileKey) { - violations = append(violations, "profileKey is invalid") - } - if request.SourceRevision != "" && !validDistributionLogicalKey(request.SourceRevision) { - violations = append(violations, "sourceRevision is invalid") - } - if containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey) { - violations = append(violations, "idempotencyKey is unsafe") - } - violations = append(violations, validateRepositoryURL("repositoryUrl", request.RepositoryURL)...) - return finish(violations) -} - func ValidateComponentKeyResetRequest(request domain.ComponentKeyResetRequest) error { var violations []string violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) @@ -341,8 +243,8 @@ func ValidateComponentAuthenticationRequest(request domain.ComponentAuthenticati 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") @@ -373,20 +275,6 @@ func appendDistributionStatusViolations(violations []string, status domain.Distr return violations } -func validateRepositoryURL(field string, value string) []string { - if strings.TrimSpace(value) == "" { - return nil - } - parsed, err := url.ParseRequestURI(strings.TrimSpace(value)) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || !strings.HasSuffix(parsed.Path, ".git") { - return []string{field + " must be an HTTPS git repository URL"} - } - for _, reason := range unsafePluginStringReasons(value) { - return []string{field + ": " + reason} - } - return nil -} - func validDistributionComponentKind(kind domain.DistributionComponentKind) bool { switch kind { case domain.DistributionComponentRun: diff --git a/platform/validator/observability.go b/platform/validator/observability.go index 947cf2c..e501441 100644 --- a/platform/validator/observability.go +++ b/platform/validator/observability.go @@ -109,7 +109,7 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin } var violations []string for key, value := range inputs { - if !clientManagerIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) { + if !runtimeIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) { violations = append(violations, field+" key is invalid or unsafe") } limit := 2048 diff --git a/platform/validator/resources.go b/platform/validator/resources.go index ce4aeb5..fd2b7cf 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -33,6 +33,7 @@ var ( gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`) gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`) gameClientBridgeCaptureNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,79}$`) + runtimeIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$`) ) type ValidationError struct { @@ -487,7 +488,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid commandTypes := map[string]struct{}{} for index, command := range bridge.Commands { prefix := fmt.Sprintf("%s.commands[%d]", field, index) - if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) { + if !runtimeIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) { violations = append(violations, prefix+".type is invalid or unsafe") } if _, exists := commandTypes[command.Type]; exists { @@ -514,7 +515,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid for index, snapshot := range bridge.Snapshots { prefix := fmt.Sprintf("%s.snapshots[%d]", field, index) key := snapshot.Type + "\x00" + snapshot.SchemaVersion - if !clientManagerIdentifierPattern.MatchString(snapshot.Type) || !clientManagerIdentifierPattern.MatchString(snapshot.SchemaVersion) { + if !runtimeIdentifierPattern.MatchString(snapshot.Type) || !runtimeIdentifierPattern.MatchString(snapshot.SchemaVersion) { violations = append(violations, prefix+" type or schemaVersion is invalid") } if _, exists := snapshotTypes[key]; exists { @@ -531,7 +532,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{} for index, template := range bridge.QueryTemplates { prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index) - if !clientManagerIdentifierPattern.MatchString(template.Key) { + if !runtimeIdentifierPattern.MatchString(template.Key) { violations = append(violations, prefix+".key is invalid") } if _, exists := queryTemplates[template.Key]; exists { @@ -583,7 +584,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid lifecycleProjectionKeys := map[string]struct{}{} for index, projection := range bridge.LifecycleProjections { prefix := fmt.Sprintf("%s.lifecycleProjections[%d]", field, index) - if !clientManagerIdentifierPattern.MatchString(projection.Key) { + if !runtimeIdentifierPattern.MatchString(projection.Key) { violations = append(violations, prefix+".key is invalid") } if _, exists := lifecycleProjectionKeys[projection.Key]; exists { @@ -620,7 +621,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid features := map[string]domain.GameClientBridgeFeatureDeclaration{} for index, feature := range bridge.Features { prefix := fmt.Sprintf("%s.features[%d]", field, index) - if !clientManagerIdentifierPattern.MatchString(feature.Key) || unsafeGameClientBridgeCommandType(feature.Key) { + if !runtimeIdentifierPattern.MatchString(feature.Key) || unsafeGameClientBridgeCommandType(feature.Key) { violations = append(violations, prefix+".key is invalid or unsafe") } if _, exists := features[feature.Key]; exists { @@ -637,12 +638,12 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid violations = append(violations, prefix+" must require a handler or event producer") } for _, handler := range feature.RequiredHandlers { - if !clientManagerIdentifierPattern.MatchString(handler) { + if !runtimeIdentifierPattern.MatchString(handler) { violations = append(violations, prefix+".requiredHandlers contains an invalid handler") } } for _, producer := range feature.RequiredEventProducers { - if !clientManagerIdentifierPattern.MatchString(producer) { + if !runtimeIdentifierPattern.MatchString(producer) { violations = append(violations, prefix+".requiredEventProducers contains an invalid producer") } } @@ -1916,7 +1917,7 @@ func validatePluginPages(pages []domain.GamePluginPage) []string { violations = append(violations, duplicateViolations(prefix+".permissions", page.Permissions)...) violations = append(violations, validateBridgeActions(prefix+".bridgeActions", page.BridgeActions)...) for _, key := range page.FeatureKeys { - if !clientManagerIdentifierPattern.MatchString(key) { + if !runtimeIdentifierPattern.MatchString(key) { violations = append(violations, prefix+".featureKeys contains an invalid feature key") } } @@ -2349,7 +2350,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", @@ -2362,7 +2363,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.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, "artifacts.read", "artifacts.write", "artifact.read", "artifact.write", "ai.invoke": return true diff --git a/platform/validator/runtime_profiles.go b/platform/validator/runtime_profiles.go index 6dd92b9..17e720e 100644 --- a/platform/validator/runtime_profiles.go +++ b/platform/validator/runtime_profiles.go @@ -47,7 +47,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles prefix := fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d]", i) violations = append(violations, validateProfileKey(prefix+".key", profile.Key)...) violations = append(violations, recordRuntimeProfileKey(lifecycleKeys, prefix+".key", profile.Key)...) - if !oneOf(profile.Mode, "local-process", "hosted-ftp-rcon", "ftp-only", "custom-client") { + if !oneOf(profile.Mode, "local-process", "hosted-ftp-rcon", "ftp-only") { violations = append(violations, prefix+".mode is invalid") } if len(profile.Capabilities) == 0 { @@ -64,9 +64,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles violations = append(violations, validateProfileKey(fmt.Sprintf("%s.transportKeys[%d]", prefix, j), key)...) } violations = append(violations, duplicateViolations(prefix+".transportKeys", profile.TransportKeys)...) - if 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)...) } @@ -298,9 +295,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles violations = append(violations, prefix+".transportKey must reference a declared SQLite query transport") } } - 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) violations = append(violations, validateProfileKey(prefix+".key", extension.Key)...) @@ -495,8 +489,8 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt for i, transport := range profiles.TransportProfiles { check(fmt.Sprintf("runtimeProfiles.transportProfiles[%d].capabilities", i), transport.Capabilities) } - return violations - } + return violations +} func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string { var violations []string diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index da9fac2..cd50906 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -241,7 +241,7 @@ export interface RuntimeDiscoveryProbeResponse { export interface RuntimeLifecycleProfileResponse { key: string; - mode: "local-process" | "hosted-ftp-rcon" | "ftp-only" | "custom-client"; + mode: "local-process" | "hosted-ftp-rcon" | "ftp-only"; capabilities: string[]; actionRefs?: Record; transportKeys?: string[]; diff --git a/platform_web/contracts/pluginPageHost.ts b/platform_web/contracts/pluginPageHost.ts index 0b602be..70a2367 100644 --- a/platform_web/contracts/pluginPageHost.ts +++ b/platform_web/contracts/pluginPageHost.ts @@ -1,5 +1,5 @@ import type { PluginBridgeExecuteEnvelope, PluginBridgeExecutionResult } from "./pluginBridge"; -import type { GameClientBridgeCommandFilterRequest, GameClientBridgeQueueRequest, GameClientBridgeSnapshotQuery } from "../api/types"; +import type { GameClientBridgeCommandFilterRequest, GameClientBridgeQueueRequest, GameClientBridgeSnapshotQuery, LogStreamCursorRequest, LogStreamCursorResponse, LogStreamListResponse } from "../api/types"; export interface PluginDataMutation { operation: "put" | "delete"; @@ -20,5 +20,9 @@ export interface PluginPageWorkspaceActions { list: (filter?: GameClientBridgeCommandFilterRequest) => Promise; snapshots: (query?: GameClientBridgeSnapshotQuery) => Promise; }; + logs?: { + listStreams: () => Promise; + query: (request: LogStreamCursorRequest) => Promise; + }; dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise; } diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index 6cad529..a191674 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -21,9 +21,11 @@ const apiMocks = vi.hoisted(() => ({ listGameClientBridgeCommands: vi.fn(), listGameClientBridgeSnapshots: vi.fn(), listGamePlugins: vi.fn(), + listLogStreams: vi.fn(), listPluginData: vi.fn(), putPluginData: vi.fn(), queueGameClientBridgeCommand: vi.fn(), + queryLogStream: vi.fn(), transactPluginData: vi.fn() })); @@ -53,7 +55,7 @@ const plugin: GamePluginResponse = { manifestRef: "artifact://manifests/game.scum/1.0.0", createFormSchemaRef: "schemas/create-form.schema.json", requiredRunCapabilities: [], - declaredPermissions: ["server.read", "server.game-client.read", "server.game-client.command"], + declaredPermissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"], permissions: { ai: true, logs: false, files: false, jobs: true, artifacts: false, remoteAccess: false }, lifecycleActions: {}, bridgeActions: ["server.instances.read"], @@ -64,7 +66,7 @@ const plugin: GamePluginResponse = { bundleKey: "scum-server-plugin", bundleVersion: "1.0.3", bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", - permissions: ["server.read", "server.game-client.read", "server.game-client.command"], + permissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"], bridgeActions: ["server.instances.read"] }], tags: ["scum"], @@ -97,6 +99,8 @@ beforeEach(() => { apiMocks.getGameClientBridgeCommand.mockResolvedValue({ id: "command-1" }); apiMocks.listGameClientBridgeCommands.mockResolvedValue({ items: [], count: 0 }); apiMocks.listGameClientBridgeSnapshots.mockResolvedValue({ items: [], count: 0 }); + apiMocks.listLogStreams.mockResolvedValue({ items: [{ id: "stream-login", serverInstanceId: "server-1", source: "file", streamKey: "scum.login", latestSeq: 2, storageBackend: "local-segments", retentionPolicy: "default", createdAt: "2026-08-15T12:00:00Z", updatedAt: "2026-08-15T12:00:00Z" }], count: 1 }); + apiMocks.queryLogStream.mockResolvedValue({ logStreamId: "stream-login", entries: [], nextSeq: 2, latestSeq: 2 }); apiMocks.executePluginBridge.mockResolvedValue({ requestId: "request-1", action: "server.instances.read", status: "ok", result: { state: "ready" } }); bundleMocks.loadPluginPageBundle.mockResolvedValue(({ workspaceActions }: { workspaceActions?: PluginPageWorkspaceActions }) => { capturedWorkspaceActions = workspaceActions; @@ -186,6 +190,7 @@ describe("PluginPageHostPage", () => { const actions = capturedWorkspaceActions; expect(actions?.pluginData).toBeDefined(); expect(actions?.gameClient).toBeDefined(); + expect(actions?.logs).toBeDefined(); expect(actions?.dispatch).toBeDefined(); const mutations = [ @@ -211,6 +216,8 @@ describe("PluginPageHostPage", () => { await actions?.gameClient?.get("command-1"); await actions?.gameClient?.list(commandFilter); await actions?.gameClient?.snapshots(snapshotQuery); + await actions?.logs?.listStreams(); + await actions?.logs?.query({ logStreamId: "stream-login", afterSeq: 1, limit: 50 }); await expect(actions?.dispatch?.(dispatchEnvelope)).resolves.toMatchObject({ status: "ok", result: { state: "ready" } }); expect(apiMocks.listPluginData).toHaveBeenCalledWith("server-1", "scum_users", "user-1"); @@ -221,6 +228,8 @@ describe("PluginPageHostPage", () => { expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", "command-1"); expect(apiMocks.listGameClientBridgeCommands).toHaveBeenCalledWith("server-1", commandFilter); expect(apiMocks.listGameClientBridgeSnapshots).toHaveBeenCalledWith("server-1", snapshotQuery); + expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1"); + expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: "stream-login", afterSeq: 1, limit: 50 }); expect(apiMocks.executePluginBridge).toHaveBeenCalledWith({ requestId: "request-1", pluginId: "game.scum", diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index cecc743..a293cab 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -81,6 +81,18 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded list: (filter) => platformApiClient.listGameClientBridgeCommands(serverId, filter), snapshots: (query) => platformApiClient.listGameClientBridgeSnapshots(serverId, query) }, + logs: { + listStreams: () => { + const context = hostContextRef.current; + if (!serverId || !context?.permissions.includes("server.logs.read")) throw new Error("plugin page is missing server.logs.read"); + return platformApiClient.listLogStreams(serverId); + }, + query: (request) => { + const context = hostContextRef.current; + if (!serverId || !context?.permissions.includes("server.logs.read")) throw new Error("plugin page is missing server.logs.read"); + return platformApiClient.queryLogStream(request); + } + }, dispatch: (envelope, signal) => { const context = hostContextRef.current; if (!context) return Promise.resolve({ requestId: envelope.requestId, action: envelope.action, status: "error", error: { code: "host_unavailable", message: "plugin bridge host is unavailable" } }); diff --git a/plugins/README.md b/plugins/README.md index 6b63b67..defa461 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -35,7 +35,7 @@ Remote access declarations describe whether a plugin can use `ftp`, `rsync`, or Runtime profiles are declarative contracts, not executable scripts. A profile can declare: - discovery probes for logical targets such as Java, Steam app, service, file, or toolchain checks. -- lifecycle modes such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`. +- lifecycle modes such as `local-process`, `hosted-ftp-rcon`, or `ftp-only`. - dependency probes and typed install plans for supported OS targets. - log sources for stdout/stderr, file tailing, FTP polling, or plugin-declared typed channels. Log bodies remain opaque and verbatim to Run and Platform. - transport profiles for declared file, FTP/rsync, SQL, RCON, and run-mediated operations. diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index 1c0e8d5..23a2183 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -8,6 +8,11 @@ export type PluginDataActions = { transact: (collection: string, mutations: PluginDataMutation[]) => Promise; }; +export type LogActions = { + listStreams: () => Promise; + query: (request: { logStreamId: string; afterSeq: number; limit: number }) => Promise; +}; + export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record }; export type PluginBridgeExecutionResult = { status?: string; result?: Record; error?: { message?: string } }; @@ -73,6 +78,7 @@ export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, p export type SCUMWorkspaceActions = { pluginData?: PluginDataActions; + logs?: LogActions; dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise; }; @@ -128,7 +134,8 @@ export const scumCollections = { mapSettings: "scum_map_settings", vehicles: "scum_vehicles", flags: "scum_flags", - trajectories: "scum_trajectories" + trajectories: "scum_trajectories", + logCursors: "scum_log_cursors" } as const; type SurfaceKey = keyof SCUMSurfaceData; @@ -144,13 +151,56 @@ const pageCollections: Record = { export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise { if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。"); + const canonical = canonicalPageKey(pageKey); + if (pageCollections[canonical].includes("players")) await projectSCUMLoginLogs(actions).catch(() => undefined); const data: SCUMSurfaceData = { ...emptySCUMSurfaceData }; - const keys = pageCollections[canonicalPageKey(pageKey)]; + const keys = pageCollections[canonical]; const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const)); for (const [key, response] of records) data[key] = collectionRecords(response); return data; } +export async function projectSCUMLoginLogs(actions: SCUMWorkspaceActions, limitPerStream = 200): Promise { + if (!actions.pluginData || !actions.logs) return 0; + const streamResponse = await actions.logs.listStreams(); + const streams = logStreams(streamResponse).filter((stream) => textValue(stream.streamKey) === "scum.login" && textValue(stream.id)); + if (!streams.length) return 0; + const existingPlayers = collectionRecords(await actions.pluginData.list(scumCollections.players)); + const playerByIdentity = playerIndex(existingPlayers); + const playerMutations = new Map(); + const activityMutations: PluginDataMutation[] = []; + let projected = 0; + for (const stream of streams) { + const streamId = textValue(stream.id); + const cursorKey = `scum.login:${streamId}`; + const cursor = firstCollectionRecord(await actions.pluginData.list(scumCollections.logCursors, cursorKey)); + const afterSeq = Math.max(0, numberValue(cursor?.nextSeq ?? cursor?.lastSeq)); + const response = await actions.logs.query({ logStreamId: streamId, afterSeq, limit: limitPerStream }); + const entries = logEntries(response); + let lastSeq = afterSeq; + for (const entry of entries) { + const seq = numberValue(entry.seq); + if (seq > lastSeq) lastSeq = seq; + const event = parseSCUMLoginLogEntry(entry, streamId); + if (!event) continue; + const match = findPlayer(playerByIdentity, event); + const playerKeyValue = match ? textValue(existingPlayers[match.index]._recordKey) || scumLogPlayerKey(event) : scumLogPlayerKey(event); + const existing = match ? existingPlayers[match.index] : {}; + const player = scumLoginPlayerRecord(existing, event); + playerMutations.set(playerKeyValue, { operation: "put", key: playerKeyValue, value: player }); + if (match) existingPlayers[match.index] = { ...player, _recordKey: playerKeyValue }; + else { existingPlayers.push({ ...player, _recordKey: playerKeyValue }); addPlayerToIndex(playerByIdentity, player, existingPlayers.length - 1); } + activityMutations.push({ operation: "put", key: `${streamId}:${seq || stableTextHash(textValue(event.rawLine))}`, value: scumLoginActivityRecord(event) }); + projected++; + } + const nextSeq = Math.max(lastSeq, numberValue((response as RecordMap).nextSeq), numberValue((response as RecordMap).latestSeq)); + if (nextSeq > afterSeq) await actions.pluginData.put(scumCollections.logCursors, cursorKey, { streamId, streamKey: "scum.login", nextSeq, updatedAt: new Date().toISOString() }); + } + if (playerMutations.size) await actions.pluginData.transact(scumCollections.players, [...playerMutations.values()]); + if (activityMutations.length) await actions.pluginData.transact(scumCollections.activityEvents, activityMutations); + return projected; +} + export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unknown): RecordMap[] { const playerSnapshot = latestSnapshotPayload(playersResponse); const merged = players.map((player) => ({ ...player })); @@ -322,6 +372,69 @@ function collectionRecords(response: unknown): RecordMap[] { }); } +function firstCollectionRecord(response: unknown): RecordMap | undefined { return collectionRecords(response)[0]; } + +function logStreams(response: unknown): RecordMap[] { return isRecord(response) && Array.isArray(response.items) ? response.items.filter(isRecord) : []; } +function logEntries(response: unknown): RecordMap[] { return isRecord(response) && Array.isArray(response.entries) ? response.entries.filter(isRecord) : []; } + +function parseSCUMLoginLogEntry(entry: RecordMap, streamId: string): RecordMap | null { + const line = textValue(entry.line); + if (!line.trim()) return null; + const fields = isRecord(entry.fields) ? entry.fields : {}; + const explicitType = firstText(fields, "eventType", "type", "action").toLowerCase(); + const lowered = line.toLowerCase(); + const logout = /\b(logged\s*out|logout|disconnected|left)\b/.test(explicitType) || /(?:\blogged\s*out\b|\blogout\s*:|\bdisconnected\b|\bleft\s+the\s+server\b)/i.test(line); + const login = /\b(logged\s*in|login|connected|joined)\b/.test(explicitType) || /(?:\blogged\s*in\b|\blogin\s*:|\bconnected\b|\bjoined\s+the\s+server\b)/i.test(line); + if (!login && !logout) return null; + const steamId = firstText(fields, "steamId", "steamID", "userId", "userID", "playerId", "gamePlayerId") || firstRegex(line, /\b\d{17}\b/); + const displayName = firstText(fields, "displayName", "playerName", "name", "characterName") || extractSCUMPlayerName(line, steamId); + const ip = firstText(fields, "lastLoginIp", "loginIp", "ipAddress", "ip") || firstRegex(line, /\b(?:\d{1,3}\.){3}\d{1,3}\b/); + if (!steamId && !displayName && !ip) return null; + const occurredAt = textValue(entry.timestamp) || new Date().toISOString(); + return { eventType: logout ? "logout" : "login", steamId, gamePlayerId: steamId, displayName, lastLoginIp: ip, loginIp: ip, occurredAt, observedAt: occurredAt, rawLine: line, streamId, seq: numberValue(entry.seq), source: "plugin.log.scum.login" }; +} + +function scumLoginPlayerRecord(existing: RecordMap, event: RecordMap): RecordMap { + const occurredAt = textValue(event.occurredAt); + const login = textValue(event.eventType) === "login"; + const identity = { steamId: textValue(event.steamId), gamePlayerId: textValue(event.gamePlayerId), displayName: textValue(event.displayName) }; + const network = textValue(event.lastLoginIp) ? { lastLoginIp: textValue(event.lastLoginIp), loginIp: textValue(event.loginIp) } : {}; + const status = login + ? { online: true, status: "online", lastSeenAt: occurredAt, lastLoginAt: occurredAt, lastLoginObservedAt: occurredAt, lastLoginRawLine: textValue(event.rawLine) } + : { online: false, status: "offline", lastSeenAt: occurredAt, lastLogoutAt: occurredAt, lastLogoutObservedAt: occurredAt, lastLogoutRawLine: textValue(event.rawLine) }; + return { ...existing, ...withoutEmpty(identity), ...network, ...status, source: "plugin.log.scum.login", updatedAt: occurredAt }; +} + +function scumLoginActivityRecord(event: RecordMap): RecordMap { + return withoutEmpty({ id: `${textValue(event.streamId)}:${numberValue(event.seq) || stableTextHash(textValue(event.rawLine))}`, eventType: textValue(event.eventType), type: textValue(event.eventType), steamId: textValue(event.steamId), gamePlayerId: textValue(event.gamePlayerId), displayName: textValue(event.displayName), lastLoginIp: textValue(event.lastLoginIp), loginIp: textValue(event.loginIp), occurredAt: textValue(event.occurredAt), observedAt: textValue(event.observedAt), rawLine: textValue(event.rawLine), streamId: textValue(event.streamId), seq: numberValue(event.seq), source: "plugin.log.scum.login" }); +} + +function scumLogPlayerKey(event: RecordMap): string { + const identity = firstText(event, "steamId", "gamePlayerId"); + if (identity) return identity; + const name = textValue(event.displayName).trim().toLowerCase(); + if (name) return `name:${safeCommandId(name)}`; + return `log:${stableTextHash(textValue(event.rawLine))}`; +} + +function extractSCUMPlayerName(line: string, steamId: string): string { + for (const pattern of [/\b(?:player|user|name|character)\s*[:=]\s*["']?([^"'(),;\[\]]{2,80})/i, /\b(?:player|user|character)\s+["']([^"']{2,80})["']/i, /["']([^"']{2,80})["']\s*(?:\(|\[|SteamID\s*)?\d{17}/i, /\d{17}\s*(?:\)|\])?\s*["']([^"']{2,80})["']/]) { + const match = pattern.exec(line); + if (match?.[1]) return match[1].trim(); + } + if (steamId) { + const index = line.indexOf(steamId); + const before = index > 0 ? line.slice(0, index).replace(/.*\b(?:player|user|character)\b\s*/i, "").replace(/[\[(,:;-]+$/g, "").trim() : ""; + if (before && before.length <= 80) return before; + } + return ""; +} + +function firstRegex(value: string, pattern: RegExp): string { return pattern.exec(value)?.[0] ?? ""; } +function numberValue(value: unknown): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; } +function withoutEmpty(value: RecordMap): RecordMap { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== "" && item !== undefined && item !== null)); } +function stableTextHash(value: string): string { let hash = 2166136261; for (let index = 0; index < value.length; index++) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(16); } + function latestSnapshotPayload(response: unknown): RecordMap | undefined { if (!isRecord(response) || !Array.isArray(response.items)) return undefined; const snapshots = response.items.filter(isRecord).sort((left, right) => snapshotOrder(right) - snapshotOrder(left)); diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index d97f40f..39d97e2 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -1064,6 +1064,7 @@ "bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e", "permissions": [ "server.read", + "server.logs.read", "server.remote.access", "server.game-client.read", "server.game-client.command", diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 47d1857..77e508e 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -521,7 +521,7 @@ "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, - "mode": { "enum": ["local-process", "hosted-ftp-rcon", "ftp-only", "custom-client"] }, + "mode": { "enum": ["local-process", "hosted-ftp-rcon", "ftp-only"] }, "capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 }, "actionRefs": { "type": "object", diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index 572f7bc..8e58391 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -369,7 +369,7 @@ export interface RuntimeDiscoveryProbe { export interface RuntimeLifecycleProfile { key: string; - mode: "local-process" | "hosted-ftp-rcon" | "ftp-only" | "custom-client"; + mode: "local-process" | "hosted-ftp-rcon" | "ftp-only"; capabilities: RunCapability[]; actionRefs?: Partial>; transportKeys?: string[]; diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 7d99fe4..470443a 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -426,8 +426,8 @@ describe("plugin manifest validation", () => { pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>; }; pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>; - runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> }; - }; + runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }>; dataTargets?: unknown[] }; + }; const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions", "scum.tasks", "scum.events", "scum.native-timed-gifts"]; const expectedColumnsByKey: Record = { "scum.player.profile": ["userProfileId", "steamId", "gamePlayerId", "displayName", "squadId", "squadName", "famePoints", "normalBalance", "goldBalance", "x", "y", "z", "lastLoginTime", "lastSaveTime"], diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 9190089..085e0b4 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; -import { buildPlayerAttributeMutation, createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, playerAttributeDrafts, playerAttributeSqlPreview, queueGiftDelivery, queuePlayerAttributePatch, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; +import { buildPlayerAttributeMutation, createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, playerAttributeDrafts, playerAttributeSqlPreview, projectSCUMLoginLogs, queueGiftDelivery, queuePlayerAttributePatch, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; @@ -103,6 +103,27 @@ describe("SCUM plugin feature module", () => { expect(dataClientSource).not.toContain("scum-client-manager"); }); + it("projects SCUM login log lines inside the plugin-owned page module", async () => { + const pluginData = pluginDataActions({ list: async (collection, key) => { + if (collection === scumCollections.players) return { items: [{ key: "76561198000000001", value: { steamId: "76561198000000001", displayName: "Old", normalBalance: 100 } }] }; + if (collection === scumCollections.logCursors && key) return { items: [{ key, value: { nextSeq: 1 } }] }; + return { items: [], count: 0 }; + } }); + const logs = { + listStreams: vi.fn(async () => ({ items: [{ id: "stream-login", streamKey: "scum.login", latestSeq: 3 }], count: 1 })), + query: vi.fn(async () => ({ logStreamId: "stream-login", entries: [ + { seq: 2, timestamp: "2026-08-10T00:01:00Z", line: "Login: Player 'Mira' SteamID 76561198000000001 IP 203.0.113.7" }, + { seq: 3, timestamp: "2026-08-10T00:04:00Z", line: "Logout: Player 'Mira' SteamID 76561198000000001 IP 203.0.113.7" } + ], nextSeq: 3, latestSeq: 3 })) + }; + await expect(projectSCUMLoginLogs({ pluginData, logs })).resolves.toBe(2); + expect(logs.query).toHaveBeenCalledWith({ logStreamId: "stream-login", afterSeq: 1, limit: 200 }); + expect(pluginData.transact).toHaveBeenCalledWith(scumCollections.players, [expect.objectContaining({ key: "76561198000000001", value: expect.objectContaining({ displayName: "Mira", online: false, status: "offline", normalBalance: 100, lastLoginIp: "203.0.113.7", lastLogoutObservedAt: "2026-08-10T00:04:00Z", source: "plugin.log.scum.login" }) })]); + expect(pluginData.transact).toHaveBeenCalledWith(scumCollections.activityEvents, expect.arrayContaining([expect.objectContaining({ key: "stream-login:2", value: expect.objectContaining({ eventType: "login", rawLine: expect.stringContaining("Login:") }) }), expect.objectContaining({ key: "stream-login:3", value: expect.objectContaining({ eventType: "logout", rawLine: expect.stringContaining("Logout:") }) })])); + expect(pluginData.put).toHaveBeenCalledWith(scumCollections.logCursors, "scum.login:stream-login", expect.objectContaining({ nextSeq: 3 })); + expect(dataClientSource).toContain("projectSCUMLoginLogs"); + }); + it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => { const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 })); await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows"); diff --git a/scripts/local-debug/smoke.sh b/scripts/local-debug/smoke.sh index 7fa349b..c82c4fe 100755 --- a/scripts/local-debug/smoke.sh +++ b/scripts/local-debug/smoke.sh @@ -1106,8 +1106,7 @@ const localRuntimeProfiles = { }], logSources: source.runtimeProfiles?.logSources ?? [], transportProfiles: source.runtimeProfiles?.transportProfiles ?? [], - dataTargets: source.runtimeProfiles?.dataTargets ?? [], - clientManagers: [] + dataTargets: source.runtimeProfiles?.dataTargets ?? [] }; const localGameClientBridge = JSON.parse(JSON.stringify(source.gameClientBridge ?? {})); delete localGameClientBridge.companion;