Remove legacy client-manager workflows

This commit is contained in:
npc0-hue
2026-09-03 13:08:08 +08:00
parent bf3c382d15
commit fe09d21a56
56 changed files with 304 additions and 4121 deletions
@@ -1,131 +0,0 @@
package service
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func TestClientManagerLifecycleInputIncludesGeneratedCompanionConfig(t *testing.T) {
svc, ownerSession, instance := newDistributionTestFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
capabilities := []string{"component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"}
for index := range plugin.RuntimeProfiles.ClientManagers {
manager := &plugin.RuntimeProfiles.ClientManagers[index]
if manager.Key != "scum-client-manager" {
continue
}
manager.ConfigTemplates = []domain.RuntimeConfigTemplate{{Key: "client-config", TemplateRef: "config.yaml.example", OutputRef: "config.yaml"}}
manager.Health.IntervalSeconds = 30
manager.Health.RequiredCapabilities = domain.CopyStringSlice(capabilities)
}
plugin.GameClientBridge.Companion = domain.GameClientBridgeCompanionDeclaration{
ProfileKey: "scum-client-manager",
ConfigTemplateKey: "client-config",
ConfigSchemaRef: "schemas/companion/config.schema.json",
ConfigFormat: "yaml",
PlatformBaseURLSource: "run-control",
RegistrationProof: "hmac-sha256",
ProofMaterialSource: "component-package",
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
SessionMode: "component-session",
TLSPolicy: "verify-system-roots",
HeartbeatIntervalSeconds: 30,
CommandPollIntervalSeconds: 5,
RequestTimeoutSeconds: 15,
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update companion declaration: %v", err)
}
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "companion-config-build-v1")
view, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "companion-config-deploy-v1"})
if err != nil {
t.Fatalf("queue companion deployment: %v", err)
}
runSession := registerClientManagerRun(t, svc)
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
request := domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
input, err := svc.GetClientManagerLifecycleInput(request)
if err != nil {
t.Fatalf("get companion lifecycle input: %v", err)
}
config := input.CompanionConfig
if config == nil {
t.Fatal("expected generated companion config input")
}
if config.SchemaVersion != 1 || config.ConfigTemplateRef != "config.yaml.example" || config.ConfigOutputRef != "config.yaml" || config.ConfigSchemaRef != "schemas/companion/config.schema.json" {
t.Fatalf("unexpected companion template contract: %+v", config)
}
if config.InstallationID != view.Installation.ID || config.ServerInstanceID != instance.ID || config.PluginID != plugin.ID || config.ProfileKey != "scum-client-manager" || config.ArtifactID != distribution.ArtifactID || config.KeyGeneration != distribution.KeyGeneration || config.DeploymentGeneration != view.Installation.DeploymentGeneration {
t.Fatalf("unexpected companion identity fence: %+v", config)
}
if strings.Join(config.Capabilities, ",") != strings.Join(capabilities, ",") || config.PlatformBaseURLSource != "run-control" || config.RegistrationProof != "hmac-sha256" || config.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" || config.SessionMode != "component-session" || config.TLSPolicy != "verify-system-roots" {
t.Fatalf("unexpected companion registration policy: %+v", config)
}
if config.HeartbeatIntervalSeconds != 30 || config.CommandPollIntervalSeconds != 5 || config.RequestTimeoutSeconds != 15 {
t.Fatalf("unexpected companion timing policy: %+v", config)
}
plugin, err = svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("reload plugin: %v", err)
}
plugin.GameClientBridge.Companion.TLSPolicy = "skip-verification"
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("persist unsafe companion policy: %v", err)
}
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "security policy") {
t.Fatalf("expected unsafe persisted policy to fail closed, got %v", err)
}
plugin.GameClientBridge.Companion.TLSPolicy = "verify-system-roots"
plugin.GameClientBridge.Companion.ProofMaterialEnv = "LD_PRELOAD"
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("persist reserved proof environment: %v", err)
}
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "proofMaterialEnv") {
t.Fatalf("expected reserved proof environment to fail closed, got %v", err)
}
plugin.GameClientBridge.Companion.ProofMaterialEnv = "SCUM_COMPONENT_PROOF"
for index := range plugin.RuntimeProfiles.ClientManagers {
plugin.RuntimeProfiles.ClientManagers[index].ConfigTemplates = nil
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("remove config template: %v", err)
}
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "config template") {
t.Fatalf("expected missing template to fail safely, got %v", err)
}
}
func TestClientManagerCompanionConfigInputIsOptionalForOtherProfiles(t *testing.T) {
config, err := clientManagerCompanionConfigInput(
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ProfileKey: "bridge-client"}}},
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
"artifact-1",
"1.0.0",
"revision-1",
)
if err != nil || config != nil {
t.Fatalf("expected no companion config for another profile, config=%+v err=%v", config, err)
}
config, err = clientManagerCompanionConfigInput(
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ConfigFormat: "yaml"}}},
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
"artifact-1",
"1.0.0",
"revision-1",
)
if err == nil || config != nil || !strings.Contains(err.Error(), "incomplete") {
t.Fatalf("expected partial companion declaration to fail closed, config=%+v err=%v", config, err)
}
}
@@ -21,7 +21,13 @@ const (
clientManagerSessionTTL = 15 * time.Minute
)
func legacyClientManagerUnsupported() error {
return validationError("client-manager lifecycle is no longer supported")
}
func (svc *CoreService) DeployClientManagerForSession(sessionID string, request domain.ClientManagerDeployRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if strings.TrimSpace(request.IdempotencyKey) == "" {
request.IdempotencyKey = "client-manager-deploy-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.DistributionID
}
@@ -96,6 +102,8 @@ func (svc *CoreService) DeployClientManagerForSession(sessionID string, request
}
func (svc *CoreService) ControlClientManagerForSession(sessionID string, request domain.ClientManagerControlRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if err := validator.ValidateClientManagerControlRequest(request); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -174,6 +182,8 @@ func (svc *CoreService) ControlClientManagerForSession(sessionID string, request
}
func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request domain.ClientManagerUpdateRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if err := validator.ValidateClientManagerUpdateRequest(request); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -242,6 +252,8 @@ func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request
}
func (svc *CoreService) UninstallClientManagerForSession(sessionID string, request domain.ClientManagerUninstallRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if err := validator.ValidateClientManagerUninstallRequest(request); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -299,6 +311,8 @@ func (svc *CoreService) UninstallClientManagerForSession(sessionID string, reque
}
func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, request domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if _, err := svc.GetCurrentUser(sessionID); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -327,6 +341,8 @@ func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, r
}
func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string, request domain.ClientManagerRetryRequest) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if err := validator.ValidateClientManagerRetryRequest(request); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -394,6 +410,8 @@ func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string,
}
func (svc *CoreService) GetClientManagerLifecycleForSession(sessionID, serverInstanceID, profileKey string) (domain.ClientManagerLifecycleView, error) {
return domain.ClientManagerLifecycleView{}, legacyClientManagerUnsupported()
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
return domain.ClientManagerLifecycleView{}, err
}
@@ -408,6 +426,8 @@ func (svc *CoreService) GetClientManagerLifecycleForSession(sessionID, serverIns
}
func (svc *CoreService) ListClientManagerLifecyclesForSession(sessionID, serverInstanceID string) ([]domain.ClientManagerLifecycleView, error) {
return nil, legacyClientManagerUnsupported()
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
return nil, err
}
@@ -645,6 +665,8 @@ func (svc *CoreService) ensureClientManagerInstallationFromDistribution(instance
}
func (svc *CoreService) ProjectClientManagerDistribution(distribution domain.ClientManagerDistribution) error {
return legacyClientManagerUnsupported()
instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID)
if err != nil {
return err
@@ -750,6 +772,8 @@ func compareClientManagerVersion(left, right [3]int) int {
}
func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientManagerLifecycleInputRequest) (domain.ClientManagerLifecycleInput, error) {
return domain.ClientManagerLifecycleInput{}, legacyClientManagerUnsupported()
if err := validator.ValidateClientManagerLifecycleInputRequest(request); err != nil {
return domain.ClientManagerLifecycleInput{}, err
}
@@ -806,6 +830,8 @@ func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientMana
}
func (svc *CoreService) ReadClientManagerLifecycleChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
return domain.RunUpdateChunk{}, legacyClientManagerUnsupported()
if err := validator.ValidateRunUpdateChunkRequest(request); err != nil {
return domain.RunUpdateChunk{}, err
}
@@ -1109,6 +1135,8 @@ func (svc *CoreService) clientManagerRuntimeActionProjection(instance domain.Ser
}
func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error) {
return domain.ClientManagerRegisterResult{}, legacyClientManagerUnsupported()
request = domain.CopyClientManagerRegisterRequest(request)
if err := validator.ValidateClientManagerRegisterRequest(request); err != nil {
return domain.ClientManagerRegisterResult{}, err
@@ -1189,6 +1217,8 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist
}
func (svc *CoreService) AcceptClientManagerHeartbeat(heartbeat domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error) {
return domain.ClientManagerHeartbeatResult{}, legacyClientManagerUnsupported()
heartbeat = domain.CopyClientManagerHeartbeat(heartbeat)
if err := validator.ValidateClientManagerHeartbeat(heartbeat); err != nil {
return domain.ClientManagerHeartbeatResult{}, err
@@ -1298,6 +1328,8 @@ func (svc *CoreService) revokeClientManagerSessions(installationID, reason strin
}
func (svc *CoreService) ReconcileClientManagerLifecycle() error {
return nil
stamp := svc.now()
nonces, err := svc.store.ClientManagerNonces().List(domain.ClientManagerNonceFilter{ExpiresBefore: stamp})
if err != nil {
@@ -1,289 +0,0 @@
package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
func TestClientManagerLifecycleBuildDeployRegisterHealthUpdateRollbackAndUninstall(t *testing.T) {
svc, ownerSession, instance := newDistributionTestFixture(t)
baseTime := svc.now()
svc.now = func() time.Time { return baseTime }
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "lifecycle-build-v1")
view, err := svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleAvailable {
t.Fatalf("expected available build projection, view=%+v err=%v", view, err)
}
view, err = svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-deploy-v1"})
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleDeploying || view.Job.State != domain.JobStateQueued {
t.Fatalf("queue deployment: view=%+v err=%v", view, err)
}
if _, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-deploy-v1"}); err != nil {
t.Fatalf("idempotent deployment: %v", err)
}
runSession := registerClientManagerRun(t, svc)
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
input, err := svc.GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil || input.ArtifactID != distribution.ArtifactID || input.KeyGeneration != distribution.KeyGeneration || input.DeploymentGeneration != view.Installation.DeploymentGeneration || strings.Contains(strings.Join(input.Arguments, " "), "/Users/") {
t.Fatalf("get fenced deployment input: input=%+v err=%v", input, err)
}
chunk, err := svc.ReadClientManagerLifecycleChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 0, Length: 7})
if err != nil || len(chunk.Payload) == 0 || chunk.ArtifactID != distribution.ArtifactID {
t.Fatalf("read deployment chunk: chunk=%+v err=%v", chunk, err)
}
if _, err := svc.GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt + 1}); err == nil {
t.Fatal("expected stale attempt to be rejected")
}
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.deployed", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleRegistering || view.Installation.ActiveArtifactID != distribution.ArtifactID {
t.Fatalf("expected deployed registration state, got %+v", view.Installation)
}
componentKey := currentClientManagerPlainKey(t, svc, instance.ID, "scum-client-manager")
registerRequest := lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, baseTime)
registerRequest.Signature = clientManagerRegistrationSignature(componentKey, registerRequest)
registration, err := svc.RegisterClientManager(registerRequest)
if err != nil || !registration.Accepted || registration.SessionToken == "" {
t.Fatalf("register client manager: result=%+v err=%v", registration, err)
}
if _, err := svc.RegisterClientManager(registerRequest); err == nil {
t.Fatal("expected registration nonce replay rejection")
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: runSession, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: registerRequest.Capabilities, SentAt: baseTime}); err == nil {
t.Fatal("Run control session must not authenticate as a Client Manager session")
}
heartbeat, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "ready", Capabilities: registerRequest.Capabilities, SentAt: baseTime})
if err != nil || heartbeat.Status != domain.ClientManagerLifecycleOnline {
t.Fatalf("accept heartbeat: result=%+v err=%v", heartbeat, err)
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: registerRequest.Capabilities, SentAt: baseTime}); err == nil {
t.Fatal("expected replayed heartbeat sequence rejection")
}
baseTime = baseTime.Add(50 * time.Second)
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
t.Fatalf("reconcile degraded health: %v", err)
}
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleDegraded {
t.Fatalf("expected degraded heartbeat timeout, got %+v", view.Installation)
}
baseTime = baseTime.Add(80 * time.Second)
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
t.Fatalf("reconcile offline health: %v", err)
}
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleOffline {
t.Fatalf("expected offline heartbeat timeout, got %+v", view.Installation)
}
updatedDistribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.1.0", "lifecycle-build-v2")
view, err = svc.UpdateClientManagerForSession(ownerSession, domain.ClientManagerUpdateRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: updatedDistribution.ID, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, Approved: true, IdempotencyKey: "lifecycle-update-v2"})
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleUpdating {
t.Fatalf("queue staged update: view=%+v err=%v", view, err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUpdate)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateFailed, "client-manager.rollback.restored", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.ActiveArtifactID != distribution.ArtifactID || !strings.Contains(view.Installation.Phase, "previous deployment restored") || !view.Installation.Retryable {
t.Fatalf("expected failed update to retain previous active slot, got %+v", view.Installation)
}
view, err = svc.RetryClientManagerLifecycleForSession(ownerSession, domain.ClientManagerRetryRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, IdempotencyKey: "lifecycle-update-v2-retry"})
if err != nil {
t.Fatalf("retry staged update: %v", err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUpdate)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.updated", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.ActiveArtifactID != updatedDistribution.ArtifactID || view.Installation.PreviousArtifactID != distribution.ArtifactID || view.Installation.Status != domain.ClientManagerLifecycleRegistering {
t.Fatalf("expected successful update slot commit, got %+v", view.Installation)
}
view, err = svc.ControlClientManagerForSession(ownerSession, domain.ClientManagerControlRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", Operation: domain.ClientManagerOperationRollback, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, IdempotencyKey: "lifecycle-rollback-v1"})
if err != nil {
t.Fatalf("queue explicit rollback: %v", err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerRollback)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.rolled-back", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.ActiveArtifactID != distribution.ArtifactID || view.Installation.PreviousArtifactID != updatedDistribution.ArtifactID {
t.Fatalf("expected rollback slot swap, got %+v", view.Installation)
}
view, err = svc.UninstallClientManagerForSession(ownerSession, domain.ClientManagerUninstallRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, Confirmed: true, IdempotencyKey: "lifecycle-uninstall"})
if err != nil {
t.Fatalf("queue uninstall: %v", err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUninstall)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.uninstalled", "stopped")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleUninstalled || view.Installation.ActiveArtifactID != "" {
t.Fatalf("expected durable uninstalled history, got %+v", view.Installation)
}
if _, err := svc.store.ClientManagerDistributions().Get(distribution.ID); err != nil {
t.Fatalf("uninstall must retain distribution history: %v", err)
}
}
func TestClientManagerLifecycleRejectsCrossScopeStaleAndRevokedIdentity(t *testing.T) {
svc, ownerSession, instance := newDistributionTestFixture(t)
now := svc.now()
svc.now = func() time.Time { return now }
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "lifecycle-scope-build")
view, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-scope-deploy"})
if err != nil {
t.Fatalf("queue deploy: %v", err)
}
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "other-owner", DisplayName: "Other", Email: "other-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
if _, err := svc.GetClientManagerLifecycleForSession(otherSession, instance.ID, "scum-client-manager"); err == nil {
t.Fatal("expected cross-owner lifecycle read denial")
}
if _, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration + 1, IdempotencyKey: "lifecycle-stale-deploy"}); err == nil {
t.Fatal("expected stale deployment generation denial")
}
runSession := registerClientManagerRun(t, svc)
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.deployed", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
plain := currentClientManagerPlainKey(t, svc, instance.ID, "scum-client-manager")
request := lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
request.ArtifactID = "cross-server-artifact"
request.Signature = clientManagerRegistrationSignature(plain, request)
if _, err := svc.RegisterClientManager(request); err == nil {
t.Fatal("expected cross-artifact registration rejection")
}
request = lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
request.KeyGeneration++
request.Nonce = "nonce-stale-key-generation"
request.Signature = clientManagerRegistrationSignature(plain, request)
if _, err := svc.RegisterClientManager(request); err == nil {
t.Fatal("expected stale key generation registration rejection")
}
request = lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
request.Nonce = "nonce-valid-component-identity"
request.Signature = clientManagerRegistrationSignature(plain, request)
registration, err := svc.RegisterClientManager(request)
if err != nil {
t.Fatalf("register valid component: %v", err)
}
now = now.Add(16 * time.Minute)
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
t.Fatalf("expire component session: %v", err)
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: request.Capabilities, SentAt: now}); err == nil {
t.Fatal("expected expired component session rejection")
}
request.Timestamp = now
request.Nonce = "nonce-replacement-after-expiry"
request.Signature = clientManagerRegistrationSignature(plain, request)
registration, err = svc.RegisterClientManager(request)
if err != nil {
t.Fatalf("register replacement component session: %v", err)
}
if _, err := svc.ResetComponentKeyForSession(ownerSession, domain.ComponentKeyResetRequest{ServerInstanceID: instance.ID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: "scum-client-manager"}); err != nil {
t.Fatalf("reset component key: %v", err)
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: request.Capabilities, SentAt: now}); err == nil {
t.Fatal("expected reset to revoke component session")
}
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if !view.Installation.RequiresRedeploy || view.Installation.Status != domain.ClientManagerLifecycleFailed {
t.Fatalf("expected key reset recovery projection, got %+v", view.Installation)
}
for _, forbidden := range []string{plain, registration.SessionToken, "secret://", "/Users/", "tcp://"} {
payload := strings.Join([]string{view.Installation.Phase, view.Installation.HealthReason}, " ")
if strings.Contains(payload, forbidden) {
t.Fatalf("safe lifecycle view leaked %q: %s", forbidden, payload)
}
}
}
func buildLifecycleDistribution(t *testing.T, svc *CoreService, session string, instance domain.ServerInstance, version, idempotency string) domain.ClientManagerDistribution {
t.Helper()
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
t.Fatalf("get lifecycle Run endpoint: %v", err)
}
endpoint.LastHeartbeatAt = svc.now()
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("refresh lifecycle Run heartbeat: %v", err)
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get lifecycle plugin: %v", err)
}
for i := range plugin.RuntimeProfiles.ClientManagers {
if plugin.RuntimeProfiles.ClientManagers[i].Key == "scum-client-manager" {
plugin.RuntimeProfiles.ClientManagers[i].Version = version
}
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update lifecycle version: %v", err)
}
payload := []byte("client-manager-package-" + version)
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: idempotency})
if err != nil {
t.Fatalf("generate lifecycle distribution: %v", err)
}
return completeClientDistributionBuild(t, svc, distribution, payload)
}
func registerClientManagerRun(t *testing.T, svc *CoreService) string {
t.Helper()
hello := validRunControlHello()
hello.Platform = "linux"
hello.Architecture = "amd64"
hello.CapabilityReport.Capabilities = append(hello.CapabilityReport.Capabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall)
result, err := svc.RegisterRunHello(hello)
if err != nil {
t.Fatalf("register lifecycle Run: %v", err)
}
return result.SessionToken
}
func claimClientManagerJob(t *testing.T, svc *CoreService, sessionToken, capability string) domain.RunJobClaimResult {
t.Helper()
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.Capability != capability {
t.Fatalf("claim %s job: claim=%+v err=%v", capability, claim, err)
}
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "typed lifecycle work started"}); err != nil {
t.Fatalf("ack lifecycle job: %v", err)
}
return claim
}
func completeClientManagerJob(t *testing.T, svc *CoreService, sessionToken string, claim domain.RunJobClaimResult, state domain.JobState, kind, processState string) {
t.Helper()
_, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: state, Progress: domain.RunJobProgressReport{Percent: 100, Message: "client-manager lifecycle terminal"}, Message: "client-manager lifecycle terminal", ExecutionResult: domain.JobExecutionResult{Kind: kind, ProcessState: processState, Summary: "bounded lifecycle result"}})
if err != nil {
t.Fatalf("complete lifecycle job: %v", err)
}
}
func currentClientManagerPlainKey(t *testing.T, svc *CoreService, serverID, profileKey string) string {
t.Helper()
key, err := svc.activeComponentKey(serverID, domain.DistributionComponentClientManager, profileKey)
if err != nil {
t.Fatalf("get active component key: %v", err)
}
plain, err := svc.decryptRuntimeKey(key.EncryptedKey)
if err != nil {
t.Fatalf("decrypt component key: %v", err)
}
return plain
}
func lifecycleRegisterRequest(installation domain.ClientManagerInstallation, capabilities []string, stamp time.Time) domain.ClientManagerRegisterRequest {
return domain.ClientManagerRegisterRequest{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, ArtifactID: installation.ActiveArtifactID, Version: installation.ActiveVersion, SourceRevision: installation.ActiveRevision, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, Capabilities: capabilities, Timestamp: stamp, Nonce: "nonce-client-manager-registration"}
}
@@ -632,7 +632,7 @@ func TestCoreServiceReportsPlatformBuilderUnavailable(t *testing.T) {
}
seen := 0
for _, action := range actions.Actions {
if action.Key != "generate-run" && action.Key != "generate-client-manager" {
if action.Key != "generate-run" {
continue
}
seen++
@@ -640,8 +640,8 @@ func TestCoreServiceReportsPlatformBuilderUnavailable(t *testing.T) {
t.Fatalf("expected explicit platform builder unavailable reason, got %+v", action)
}
}
if seen != 2 {
t.Fatalf("expected both build actions, got %+v", actions.Actions)
if seen != 1 {
t.Fatalf("expected run build action, got %+v", actions.Actions)
}
_, err = svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
+4 -22
View File
@@ -116,6 +116,8 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
}
func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID string, request domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error) {
return domain.ClientManagerDistribution{}, legacyClientManagerUnsupported()
request = domain.CopyClientManagerBuildRequest(request)
if strings.TrimSpace(request.IdempotencyKey) == "" {
request.IdempotencyKey = "client-manager-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.TargetOS + "-" + request.TargetArch
@@ -287,6 +289,8 @@ func (svc *CoreService) OpenLatestRunDistributionDownloadForSession(sessionID st
}
func (svc *CoreService) OpenLatestClientManagerDistributionDownloadForSession(sessionID string, serverInstanceID string, profileKey string) (domain.ArtifactDownloadReference, error) {
return domain.ArtifactDownloadReference{}, legacyClientManagerUnsupported()
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.ArtifactDownloadReference{}, err
@@ -354,11 +358,6 @@ func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request do
if err := svc.revokeComponentDistributions(instance.ID, request.ComponentKind, normalizedComponentKey(request.ComponentKind, request.ComponentKey), nextGeneration); err != nil {
return domain.EncryptedComponentKey{}, err
}
if request.ComponentKind == domain.DistributionComponentClientManager {
if err := svc.fenceClientManagerAfterKeyReset(instance.ID, normalizedComponentKey(request.ComponentKind, request.ComponentKey), nextGeneration); err != nil {
return domain.EncryptedComponentKey{}, err
}
}
if request.ComponentKind == domain.DistributionComponentRun {
if err := svc.revokeRunControlSessionForInstance(instance); err != nil {
return domain.EncryptedComponentKey{}, err
@@ -429,17 +428,6 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
break
}
}
hasAvailableClientPackage := false
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable})
if err != nil {
return domain.ServerRuntimeActions{}, err
}
for _, distribution := range clientDistributions {
if distribution.ArtifactID != "" {
hasAvailableClientPackage = true
break
}
}
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
runPackageInputsComplete, runPackageReason := bindingsComplete, bindingReason
if !deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
@@ -462,18 +450,12 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"),
runtimeAction("push-run-update", "Push run update", runRegistered && pluginDeclares(plugin, "server.run.distribution") && svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && runPackageInputsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", runPackageReason))),
runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"),
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && builderReady && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared", fallbackReason(!builderReady, builderReason, bindingReason))),
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
runtimeAction("dependencies-check", "Check dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)))),
runtimeAction("dependencies-install", "Install dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason)))),
runtimeAction("live-logs", "Live logs", runRegistered && pluginSupports(plugin, "logs.read"), fallbackReason(!runRegistered, "Run heartbeat has not been observed", "plugin does not declare live logs")),
runtimeAction("historical-logs", "Historical logs", runRegistered && svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason))),
},
}
if runRegistered {
actions.Actions = append(actions.Actions, svc.clientManagerRuntimeActionProjection(instance, plugin, endpoint, bindingsComplete, bindingReason)...)
}
return domain.CopyServerRuntimeActions(actions), nil
}
+16 -118
View File
@@ -511,24 +511,9 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test
}
}
func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperations(t *testing.T) {
func TestCoreServiceRejectsClientManagerDistributionRequests(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
runDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "idem-run-for-client",
})
if err != nil {
t.Fatalf("generate run distribution: %v", err)
}
runDistribution = completeDistributionBuild(t, svc, runDistribution, []byte("compiled run archive"))
if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: runDistribution.ArtifactID}); err != nil {
t.Fatalf("open run download: %v", err)
}
runConfig := readGeneratedPackageConfig(t, svc, session, runDistribution.ArtifactID)
clientDistribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
_, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
ServerInstanceID: instance.ID,
ProfileKey: "scum-client-manager",
TargetOS: "windows",
@@ -537,62 +522,26 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
SourceRevision: "main",
IdempotencyKey: "idem-client-manager",
})
if err != nil {
t.Fatalf("generate client-manager distribution: %v", err)
if err == nil {
t.Fatal("expected client-manager distribution request to be rejected")
}
clientConfig := readGeneratedPackageConfig(t, svc, session, clientDistribution.ArtifactID)
if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusBuilding {
t.Fatalf("unexpected client-manager distribution: %+v", clientDistribution)
}
if clientConfig.AuthKey == runConfig.AuthKey || clientDistribution.SecretRef == runDistribution.SecretRef {
t.Fatalf("client-manager must use a distinct key/ref, run=%+v client=%+v", runConfig, clientConfig)
}
build, err := svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
if err != nil {
t.Fatalf("get build job: %v", err)
}
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
t.Fatalf("unexpected build job: %+v", build)
}
clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
build, err = svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
if err != nil || build.Status != domain.DistributionJobStatusSucceeded || build.Checksum == "" {
t.Fatalf("expected uploaded client build to project as succeeded, build=%+v err=%v", build, err)
}
_, err = svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
ServerInstanceID: instance.ID,
ProfileKey: "scum-client-manager",
TargetOS: "darwin",
TargetArch: "amd64",
RepositoryURL: "https://github.com/F88888/scum_client.git",
IdempotencyKey: "idem-client-manager-denied",
})
if err == nil || !strings.Contains(err.Error(), "targetOs") {
t.Fatalf("expected unsupported target denial, got %v", err)
}
if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentClientManager,
ComponentKey: "scum-client-manager",
}); err != nil {
t.Fatalf("reset client-manager key: %v", err)
}); err == nil || !strings.Contains(err.Error(), "componentKind") {
t.Fatalf("expected client-manager key reset to be rejected, got %v", err)
}
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentClientManager,
ComponentKey: "scum-client-manager",
Generation: clientConfig.KeyGeneration,
Key: clientConfig.AuthKey,
Generation: 1,
Key: "legacy-client-manager-key",
})
if err != nil {
t.Fatalf("authenticate old client key: %v", err)
if err == nil || auth.Allowed {
t.Fatalf("expected client-manager component authentication to be rejected, auth=%+v err=%v", auth, err)
}
if auth.Allowed {
t.Fatalf("expected old client-manager key to be denied after reset, got %+v", auth)
}
}
func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
@@ -602,43 +551,30 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
plugin.SupportedOS = []string{"linux", "windows"}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions,
"server.run.distribution",
"server.client-manager.manage",
"server.dependencies.manage",
)
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities,
domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
)
plugin.BridgeActions = append(plugin.BridgeActions,
string(domain.PluginBridgeActionRunDistribution),
string(domain.PluginBridgeActionClientManager),
string(domain.PluginBridgeActionDependenciesRequest),
string(domain.PluginBridgeActionLogsBackfillRequest),
)
plugin.RuntimeProfiles.DependencyProbes = []domain.RuntimeDependencyProbe{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
plugin.RuntimeProfiles.InstallPlans = []domain.RuntimeInstallPlan{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []domain.RuntimeInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin fixture: %v", err)
}
endpoint.Capabilities = append(endpoint.Capabilities,
domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
)
endpoint.Platform = "linux"
endpoint.Architecture = "amd64"
@@ -712,24 +648,6 @@ func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string,
}
return generatedPackageConfig{Kind: "run", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, RunEndpointID: distribution.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
}
clients, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
if err != nil {
t.Fatalf("list client distributions: %v", err)
}
for _, distribution := range clients {
if distribution.ArtifactID != artifactID {
continue
}
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
if err != nil {
t.Fatalf("get client key: %v", err)
}
plain, err := decryptRuntimeKey(key.EncryptedKey)
if err != nil {
t.Fatalf("decrypt client key: %v", err)
}
return generatedPackageConfig{Kind: "client-manager", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, ProfileKey: distribution.ProfileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
}
t.Fatalf("distribution for artifact %s was not found", artifactID)
return generatedPackageConfig{}
}
@@ -754,26 +672,6 @@ func completeDistributionBuild(t *testing.T, svc *CoreService, distribution doma
return domain.RunDistribution{}
}
func completeClientDistributionBuild(t *testing.T, svc *CoreService, distribution domain.ClientManagerDistribution, _ []byte) domain.ClientManagerDistribution {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
updated, err := svc.store.ClientManagerDistributions().Get(distribution.ID)
if err != nil {
t.Fatalf("get client distribution: %v", err)
}
if updated.Status == domain.DistributionStatusAvailable {
return updated
}
if updated.Status == domain.DistributionStatusFailed {
t.Fatalf("platform client build failed: %+v", updated)
}
time.Sleep(time.Millisecond)
}
t.Fatalf("platform client build did not complete")
return domain.ClientManagerDistribution{}
}
func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
+51 -194
View File
@@ -3,7 +3,6 @@ package service
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"time"
@@ -13,15 +12,8 @@ import (
"browser.local/platform/validator"
)
const defaultGameClientBridgeLeaseDuration = 60 * time.Second
const defaultGameClientBridgeCommandRetention = 30 * 24 * time.Hour
type gameClientBridgeComponentSession struct {
Session domain.ClientManagerSession
Installation domain.ClientManagerInstallation
}
func (svc *CoreService) QueueGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
@@ -56,40 +48,20 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
if err != nil {
return domain.GameClientBridgeStatus{}, err
}
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}, Features: []domain.GameClientBridgeFeatureAvailability{}}
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.GameClientBridgeStatus{}, err
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge", Profiles: []domain.GameClientBridgeProfileDeclaration{}, Features: []domain.GameClientBridgeFeatureAvailability{}}
if !gameClientBridgeDeclared(plugin.GameClientBridge) {
return domain.CopyGameClientBridgeStatus(status), nil
}
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
if !containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
continue
}
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: profile.Key, Reason: "compatible companion session is offline", CommandTypes: gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands), SnapshotTypes: gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots), QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates)}
for _, installation := range installations {
if installation.ProfileKey != profile.Key || (installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded) || installation.RequiresRedeploy {
continue
}
sessions, listErr := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
if listErr != nil {
return domain.GameClientBridgeStatus{}, listErr
}
for _, session := range sessions {
if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID {
declaration.Available = true
declaration.Reason = ""
declaration.HandlerTypes = append(declaration.HandlerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "handler.")...)
declaration.EventProducerTypes = append(declaration.EventProducerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "event-producer.")...)
break
}
}
}
status.Profiles = append(status.Profiles, declaration)
status.Available = status.Available || declaration.Available
}
if len(status.Profiles) > 0 {
status.Reason = "no compatible companion session is online"
commandTypes := gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands)
snapshotTypes := gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots)
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: "plugin-owned", Available: true, CommandTypes: commandTypes, SnapshotTypes: snapshotTypes, QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates), HandlerTypes: commandTypes, EventProducerTypes: snapshotTypes}
if ready, reason := svc.gameClientBridgeRuntimeReadiness(instance, plugin); !ready {
declaration.Available = false
declaration.Reason = reason
status.Reason = reason
}
status.Profiles = append(status.Profiles, declaration)
status.Available = declaration.Available
if status.Available {
status.Reason = ""
}
@@ -97,15 +69,37 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
return domain.CopyGameClientBridgeStatus(status), nil
}
func gameClientBridgeSessionCapabilityValues(capabilities []string, prefix string) []string {
values := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
if value, found := strings.CutPrefix(capability, prefix); found && value != "" {
values = append(values, value)
func gameClientBridgeDeclared(bridge domain.GameClientBridgeManifest) bool {
return len(bridge.Commands) > 0 || len(bridge.Snapshots) > 0 || len(bridge.QueryTemplates) > 0 || len(bridge.LifecycleProjections) > 0 || len(bridge.DataPacks) > 0 || len(bridge.Pages) > 0 || len(bridge.Features) > 0
}
func (svc *CoreService) gameClientBridgeRuntimeReadiness(instance domain.ServerInstance, plugin domain.GamePlugin) (bool, string) {
if len(plugin.GameClientBridge.QueryTemplates) == 0 {
return true, ""
}
endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID)
if err != nil {
return false, "Run endpoint is not registered"
}
if endpoint.Status == domain.RunEndpointStatusOffline || endpoint.Status == domain.RunEndpointStatusDisabled {
return false, "Run heartbeat has not been observed"
}
transports := map[string]domain.RuntimeTransportProfile{}
for _, transport := range plugin.RuntimeProfiles.TransportProfiles {
transports[transport.Key] = transport
}
for _, template := range plugin.GameClientBridge.QueryTemplates {
transport, ok := transports[template.TransportKey]
if !ok {
return false, "bridge query transport is not declared"
}
for _, capability := range transport.Capabilities {
if !svc.endpointSupports(endpoint, capability) {
return false, "Run endpoint is missing bridge transport capability"
}
}
}
sort.Strings(values)
return values
return true, ""
}
func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatureDeclaration, profiles []domain.GameClientBridgeProfileDeclaration) []domain.GameClientBridgeFeatureAvailability {
@@ -204,14 +198,7 @@ func (svc *CoreService) QueryGameClientBridgeSnapshotsForSession(sessionID strin
}
func gameClientBridgeCommandDeclaration(plugin domain.GamePlugin, profileKey, commandType string) (domain.GameClientBridgeCommandDeclaration, bool) {
profileDeclared := false
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
if profile.Key == profileKey && containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
profileDeclared = true
break
}
}
if !profileDeclared {
if strings.TrimSpace(profileKey) == "" {
return domain.GameClientBridgeCommandDeclaration{}, false
}
for _, declaration := range plugin.GameClientBridge.Commands {
@@ -243,6 +230,15 @@ func gameClientBridgeSnapshotTypes(declarations []domain.GameClientBridgeSnapsho
return values
}
func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) {
for _, declaration := range plugin.GameClientBridge.Snapshots {
if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion {
return declaration, true
}
}
return domain.GameClientBridgeSnapshotDeclaration{}, false
}
func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQueryTemplateDeclaration) []string {
values := make([]string, len(declarations))
for index, declaration := range declarations {
@@ -306,113 +302,6 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridgeComponentSession, limit int) ([]domain.GameClientBridgeCommand, error) {
if limit == 0 {
limit = 10
}
if limit < 1 || limit > 50 {
return nil, validationError("bridge claim limit must be between 1 and 50")
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
return nil, err
}
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, State: domain.GameClientBridgeCommandPending})
if err != nil {
return nil, err
}
sort.SliceStable(commands, func(left, right int) bool {
if commands[left].Priority != commands[right].Priority {
return commands[left].Priority > commands[right].Priority
}
if !commands[left].CreatedAt.Equal(commands[right].CreatedAt) {
return commands[left].CreatedAt.Before(commands[right].CreatedAt)
}
return commands[left].ID < commands[right].ID
})
claimed := make([]domain.GameClientBridgeCommand, 0, limit)
for _, command := range commands {
if len(claimed) == limit {
break
}
if command.RunJobID != "" {
continue
}
fencingToken := command.Claim.FencingToken + 1
command.State = domain.GameClientBridgeCommandClaimed
command.Claim = domain.GameClientBridgeClaim{SessionID: component.Session.ID, InstallationID: component.Installation.ID, DeploymentGeneration: component.Session.DeploymentGeneration, FencingToken: fencingToken, LeaseExpiresAt: gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt), ClaimedAt: stamp}
command.UpdatedAt = stamp
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return nil, err
}
claimed = append(claimed, domain.CopyGameClientBridgeCommand(command))
}
return claimed, nil
}
func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
command, err := svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
command.Claim.AcknowledgedAt = stamp
command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt)
command.UpdatedAt = stamp
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
stamp := svc.now()
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if isTerminalGameClientBridgeCommandState(command.State) {
if command.Result.CompletedBy == component.Session.ID && gameClientBridgeClaimMatches(command, component, request.FencingToken) && command.Result.Status == request.Status && command.Result.Summary == request.Summary && reflect.DeepEqual(command.Result.Payload, request.Payload) {
return domain.CopyGameClientBridgeCommand(command), nil
}
return domain.GameClientBridgeCommand{}, validationError("bridge command already has a terminal result")
}
command, err = svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
switch request.Status {
case domain.GameClientBridgeResultSucceeded:
command.State = domain.GameClientBridgeCommandSucceeded
case domain.GameClientBridgeResultFailed:
command.State = domain.GameClientBridgeCommandFailed
case domain.GameClientBridgeResultUnknown:
command.State = domain.GameClientBridgeCommandUnknown
case domain.GameClientBridgeResultCancelled:
command.State = domain.GameClientBridgeCommandCancelled
}
command.Result = domain.GameClientBridgeResult{Status: request.Status, Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload), CompletedBy: component.Session.ID, CompletedAt: stamp}
command.CompletedAt = stamp
command.UpdatedAt = stamp
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.CopyGameClientBridgeCommand(command), nil
}
func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeCancelRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
@@ -595,26 +484,6 @@ func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) err
return nil
}
func (svc *CoreService) fencedGameClientBridgeCommand(component gameClientBridgeComponentSession, commandID string, fencingToken uint64, stamp time.Time) (domain.GameClientBridgeCommand, error) {
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
if command.State != domain.GameClientBridgeCommandClaimed || !gameClientBridgeClaimMatches(command, component, fencingToken) {
return domain.GameClientBridgeCommand{}, validationError("bridge command claim is stale")
}
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
return domain.GameClientBridgeCommand{}, err
}
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
}
if !command.Claim.LeaseExpiresAt.After(stamp) {
return domain.GameClientBridgeCommand{}, validationError("bridge command claim lease expired")
}
return command, nil
}
func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameClientBridgeCommand, stamp time.Time) error {
if isTerminalGameClientBridgeCommandState(command.State) {
return nil
@@ -625,18 +494,6 @@ func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameC
return svc.store.GameClientBridgeCommands().Update(command)
}
func gameClientBridgeClaimLeaseExpiry(stamp, commandExpiry time.Time) time.Time {
leaseExpiry := stamp.Add(defaultGameClientBridgeLeaseDuration)
if !commandExpiry.IsZero() && commandExpiry.Before(leaseExpiry) {
return commandExpiry
}
return leaseExpiry
}
func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, component gameClientBridgeComponentSession, fencingToken uint64) bool {
return command.Claim.SessionID == component.Session.ID && command.Claim.InstallationID == component.Installation.ID && command.Claim.DeploymentGeneration == component.Session.DeploymentGeneration && command.Claim.FencingToken == fencingToken
}
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
switch state {
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
@@ -1,205 +0,0 @@
package service
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const gameClientBridgeLogStreamCapability = "logs.stream"
const gameClientBridgeCapability = "game-client.bridge"
func (svc *CoreService) ClaimGameClientBridgeCommands(request domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeClaimRequest(request); err != nil {
return nil, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return nil, err
}
return svc.claimGameClientBridgeCommands(component, request.Limit)
}
func (svc *CoreService) AckGameClientBridgeCommand(request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
return svc.ackGameClientBridgeCommand(component, request)
}
func (svc *CoreService) CompleteGameClientBridgeCommand(request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
return svc.completeGameClientBridgeCommand(component, request)
}
func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeSnapshotIngestRequest(request); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
stamp := svc.now()
if request.ObservedAt.After(stamp.Add(5 * time.Minute)) {
return domain.GameClientBridgeSnapshot{}, validationError("snapshot observedAt is too far in the future")
}
plugin, err := svc.store.GamePlugins().Get(component.Installation.PluginID)
if err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
declaration, declared := gameClientBridgeSnapshotDeclaration(plugin, request.Type, request.SchemaVersion)
if !declared {
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot type and schema version are not declared")
}
if request.Retention != declaration.Retention {
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot retention does not match declaration")
}
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
streamID := gameClientBridgeStreamID(component.Session.ServerInstanceID, component.Installation.PluginID, component.Session.ProfileKey, request.Type, request.StreamKey)
latestSequence := uint64(0)
stream, streamErr := svc.store.GameClientBridgeSnapshotStreams().Get(streamID)
if streamErr == nil {
latestSequence = stream.LatestSequence
} else if streamErr != repo.ErrNotFound {
return domain.GameClientBridgeSnapshot{}, streamErr
}
existing, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, Type: request.Type, StreamKey: request.StreamKey})
if err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
for _, snapshot := range existing {
if snapshot.Sequence > latestSequence {
latestSequence = snapshot.Sequence
}
}
if request.Sequence <= latestSequence {
return domain.GameClientBridgeSnapshot{}, validationError("snapshot sequence is stale")
}
svc.bridgeSeq++
snapshot := domain.GameClientBridgeSnapshot{
ID: fmt.Sprintf("bridge-snapshot-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
ServerInstanceID: component.Session.ServerInstanceID,
PluginID: component.Installation.PluginID,
ProfileKey: component.Session.ProfileKey,
Type: request.Type,
SchemaVersion: request.SchemaVersion,
StreamKey: request.StreamKey,
Sequence: request.Sequence,
SourceSessionID: component.Session.ID,
ObservedAt: request.ObservedAt,
Payload: domain.CopyGameClientBridgePayload(request.Payload),
Retention: request.Retention,
CreatedAt: stamp,
ExpiresAt: stamp.Add(time.Duration(request.Retention.KeepForSeconds) * time.Second),
}
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
stream = domain.GameClientBridgeSnapshotStream{ID: streamID, ServerInstanceID: snapshot.ServerInstanceID, PluginID: snapshot.PluginID, ProfileKey: snapshot.ProfileKey, Type: snapshot.Type, StreamKey: snapshot.StreamKey, LatestSequence: snapshot.Sequence, UpdatedAt: stamp}
if streamErr == repo.ErrNotFound {
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
} else {
if err := svc.store.GameClientBridgeSnapshotStreams().Update(stream); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
}
return domain.CopyGameClientBridgeSnapshot(snapshot), nil
}
// AuthorizeGameClientBridgeLogStream authenticates a component session and
// returns only its bound server identity. Log entries are forwarded verbatim;
// this method intentionally performs no content inspection or transformation.
func (svc *CoreService) AuthorizeGameClientBridgeLogStream(request domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error) {
if err := validator.ValidateGameClientBridgeLogStreamRequest(request); err != nil {
return domain.ServerInstance{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.ServerInstance{}, err
}
if !containsString(component.Session.Capabilities, gameClientBridgeLogStreamCapability) {
return domain.ServerInstance{}, ErrForbidden
}
instance, err := svc.store.ServerInstances().Get(component.Session.ServerInstanceID)
if err != nil {
return domain.ServerInstance{}, err
}
if instance.PluginID != component.Installation.PluginID {
return domain.ServerInstance{}, ErrUnauthorized
}
return domain.CopyServerInstance(instance), nil
}
func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) {
for _, declaration := range plugin.GameClientBridge.Snapshots {
if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion {
return declaration, true
}
}
return domain.GameClientBridgeSnapshotDeclaration{}, false
}
func (svc *CoreService) authorizeGameClientBridgeSession(sessionToken string) (gameClientBridgeComponentSession, error) {
presentedHash := tokenHash(sessionToken)
sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{Status: domain.ClientManagerSessionActive})
if err != nil {
return gameClientBridgeComponentSession{}, err
}
var session domain.ClientManagerSession
for _, candidate := range sessions {
if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(presentedHash)) == 1 {
session = candidate
break
}
}
stamp := svc.now()
if session.ID == "" || !stamp.Before(session.ExpiresAt) {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
if !containsString(session.Capabilities, gameClientBridgeCapability) {
return gameClientBridgeComponentSession{}, ErrForbidden
}
installation, err := svc.store.ClientManagerInstallations().Get(session.InstallationID)
if err != nil {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
if installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
if installation.ServerInstanceID != session.ServerInstanceID || installation.ProfileKey != session.ProfileKey || installation.RunEndpointID != session.RunEndpointID || installation.ActiveArtifactID != session.ArtifactID || installation.KeyGeneration != session.KeyGeneration || installation.DeploymentGeneration != session.DeploymentGeneration || installation.RequiresRedeploy {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
key, err := svc.activeComponentKey(session.ServerInstanceID, domain.DistributionComponentClientManager, session.ProfileKey)
if err != nil || key.Generation != session.KeyGeneration {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
return gameClientBridgeComponentSession{Session: domain.CopyClientManagerSession(session), Installation: domain.CopyClientManagerInstallation(installation)}, nil
}
func gameClientBridgeStreamID(serverInstanceID, pluginID, profileKey, snapshotType, streamKey string) string {
digest := sha256.Sum256([]byte(serverInstanceID + "\x00" + pluginID + "\x00" + profileKey + "\x00" + snapshotType + "\x00" + streamKey))
return "bridge-stream-" + hex.EncodeToString(digest[:16])
}
@@ -1,194 +0,0 @@
package service
import (
"errors"
"testing"
"time"
"browser.local/platform/domain"
)
func seedGameClientBridgeComponentSession(t *testing.T, svc *CoreService, now time.Time, token string) (domain.ClientManagerInstallation, domain.ClientManagerSession) {
t.Helper()
installation := domain.ClientManagerInstallation{ID: "installation-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", RunEndpointID: "run-1", Status: domain.ClientManagerLifecycleOnline, ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3}
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
key := domain.EncryptedComponentKey{ID: "key-1", ServerInstanceID: installation.ServerInstanceID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: installation.ProfileKey, Generation: installation.KeyGeneration, Status: domain.ComponentKeyStatusActive}
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
t.Fatal(err)
}
if err := svc.store.ClientManagerSessions().Create(session); err != nil {
t.Fatal(err)
}
if err := svc.store.EncryptedComponentKeys().Create(key); err != nil {
t.Fatal(err)
}
return installation, session
}
func TestGameClientBridgeComponentSessionAuthorizesCommandsAndSnapshots(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
const token = "component-session-token"
_, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authorized-1"))
if err != nil {
t.Fatal(err)
}
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token, Limit: 5})
if err != nil || len(claimed) != 1 || claimed[0].ID != command.ID || claimed[0].Claim.SessionID != session.ID {
t.Fatalf("authorized claim: %#v err=%v", claimed, err)
}
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
t.Fatalf("authorized ack: %v", err)
}
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered"}); err != nil {
t.Fatalf("authorized result: %v", err)
}
snapshotRequest := domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"players": []any{}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}
snapshot, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest)
if err != nil || snapshot.SourceSessionID != session.ID || snapshot.Sequence != 1 {
t.Fatalf("authorized snapshot: %#v err=%v", snapshot, err)
}
if snapshot.SourceSessionID == token {
t.Fatal("raw component token persisted in snapshot")
}
higherSnapshotRequest := snapshotRequest
higherSnapshotRequest.Sequence = 2
higherSnapshot, err := svc.UploadGameClientBridgeSnapshot(higherSnapshotRequest)
if err != nil || higherSnapshot.Sequence != 2 {
t.Fatalf("higher snapshot sequence was not accepted: %#v err=%v", higherSnapshot, err)
}
equalSnapshotRequest := higherSnapshotRequest
if _, err := svc.UploadGameClientBridgeSnapshot(equalSnapshotRequest); err == nil {
t.Fatal("expected latest snapshot sequence to be rejected")
}
if _, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest); err == nil {
t.Fatal("expected lower snapshot sequence rejection")
}
currentSnapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current"})
if err != nil || len(currentSnapshots) != 2 {
t.Fatalf("stale snapshot attempts changed current stream records: %#v err=%v", currentSnapshots, err)
}
isolatedStreamRequest := snapshotRequest
isolatedStreamRequest.StreamKey = "secondary"
if snapshot, err := svc.UploadGameClientBridgeSnapshot(isolatedStreamRequest); err != nil || snapshot.Sequence != 1 {
t.Fatalf("independent snapshot stream did not start at sequence one: %#v err=%v", snapshot, err)
}
stream, err := svc.store.GameClientBridgeSnapshotStreams().Get(gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"))
if err != nil || stream.LatestSequence != 2 {
t.Fatalf("snapshot stream projection: %#v err=%v", stream, err)
}
}
func TestGameClientBridgeClaimCannotBeCompletedByAnotherCurrentSession(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
const firstToken = "component-session-token-one"
_, firstSession := seedGameClientBridgeComponentSession(t, svc, *clock, firstToken)
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "session-owner-1"))
if err != nil {
t.Fatal(err)
}
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: firstToken, Limit: 1})
if err != nil || len(claimed) != 1 {
t.Fatalf("first session claim: %#v err=%v", claimed, err)
}
const secondToken = "component-session-token-two"
secondSession := firstSession
secondSession.ID = "component-session-2"
secondSession.TokenHash = tokenHash(secondToken)
if err := svc.store.ClientManagerSessions().Create(secondSession); err != nil {
t.Fatalf("create second current session: %v", err)
}
request := domain.GameClientBridgeAckRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}
if _, err := svc.AckGameClientBridgeCommand(request); err == nil {
t.Fatal("expected second session ack to be rejected")
}
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
t.Fatal("expected second session result to be rejected")
}
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: firstToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
t.Fatalf("claim owner could not ack after rejected second session: %v", err)
}
}
func TestGameClientBridgeComponentSessionRejectsMissingCapabilityAndStaleFences(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
const token = "component-session-token"
installation, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authz-1")); err != nil {
t.Fatal(err)
}
session.Capabilities = []string{"component.heartbeat"}
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
t.Fatal(err)
}
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrForbidden) {
t.Fatalf("expected missing capability rejection, got %v", err)
}
session.Capabilities = []string{"component.heartbeat", gameClientBridgeCapability}
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
t.Fatal(err)
}
installation.DeploymentGeneration++
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected deployment fence rejection, got %v", err)
}
installation.DeploymentGeneration = session.DeploymentGeneration
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
installation.ActiveArtifactID = "artifact-2"
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected active artifact fence rejection, got %v", err)
}
installation.ActiveArtifactID = session.ArtifactID
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
installation.KeyGeneration++
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected installation key-generation fence rejection, got %v", err)
}
installation.KeyGeneration = session.KeyGeneration
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
t.Fatal(err)
}
key, err := svc.store.EncryptedComponentKeys().Get("key-1")
if err != nil {
t.Fatal(err)
}
key.Generation++
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
t.Fatal(err)
}
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected active component key-generation rejection, got %v", err)
}
key.Generation = session.KeyGeneration
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
t.Fatal(err)
}
session.ExpiresAt = *clock
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
t.Fatal(err)
}
if _, err := svc.UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"healthy": true}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expected expired session rejection, got %v", err)
}
}
+14 -198
View File
@@ -12,7 +12,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
t.Helper()
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
store := repo.NewMemoryStore()
plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
plugin := domain.GamePlugin{ID: "game.scum", GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
if err := store.GamePlugins().Create(plugin); err != nil {
t.Fatalf("seed bridge plugin: %v", err)
}
@@ -21,23 +21,19 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
}
func bridgeQueueRequest(now time.Time, key string) domain.GameClientBridgeQueueRequest {
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)}
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "plugin-owned", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)}
}
func bridgeComponent() gameClientBridgeComponentSession {
return gameClientBridgeComponentSession{
Session: domain.ClientManagerSession{ID: "component-session-1", ServerInstanceID: "server-1", ProfileKey: "scum-client", DeploymentGeneration: 3},
Installation: domain.ClientManagerInstallation{ID: "installation-1", PluginID: "game.scum"},
}
}
func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
func TestGameClientBridgeCommandQueueAndIdempotency(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
request := bridgeQueueRequest(*clock, "announce-1")
command, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil {
t.Fatalf("queue bridge command: %v", err)
}
if command.State != domain.GameClientBridgeCommandPending || command.Claim.SessionID != "" {
t.Fatalf("queued command should remain unclaimed: %#v", command)
}
duplicate, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil || duplicate.ID != command.ID {
t.Fatalf("idempotency reuse: command=%#v err=%v", duplicate, err)
@@ -46,36 +42,6 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
if len(commands) != 1 {
t.Fatalf("expected one durable command: %#v", commands)
}
component := bridgeComponent()
claimed, err := svc.claimGameClientBridgeCommands(component, 10)
if err != nil || len(claimed) != 1 || claimed[0].State != domain.GameClientBridgeCommandClaimed || claimed[0].Claim.FencingToken != 1 {
t.Fatalf("claim bridge command: %#v err=%v", claimed, err)
}
initialLeaseExpiry := claimed[0].Claim.LeaseExpiresAt
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 2}); err == nil {
t.Fatal("expected stale fencing token rejection")
}
*clock = clock.Add(10 * time.Second)
acked, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1})
if err != nil || acked.Claim.AcknowledgedAt.IsZero() || !acked.Claim.LeaseExpiresAt.Equal(clock.Add(defaultGameClientBridgeLeaseDuration)) || !acked.Claim.LeaseExpiresAt.After(initialLeaseExpiry) {
t.Fatalf("ack bridge command: %#v err=%v", acked, err)
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Payload: map[string]any{"sessionToken": "must-not-persist"}}); err == nil {
t.Fatal("expected unsafe result material to be rejected")
}
resultRequest := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered", Payload: map[string]any{"delivered": true}}
completed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
if err != nil || completed.State != domain.GameClientBridgeCommandSucceeded || completed.Result.Status != domain.GameClientBridgeResultSucceeded || completed.CompletedAt.IsZero() {
t.Fatalf("complete bridge command: %#v err=%v", completed, err)
}
replayed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
if err != nil || replayed.ID != completed.ID || replayed.State != completed.State || !replayed.CompletedAt.Equal(completed.CompletedAt) {
t.Fatalf("exact terminal result retry was not idempotent: replayed=%#v err=%v", replayed, err)
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "conflict"}); err == nil {
t.Fatal("expected conflicting terminal result rejection")
}
}
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
@@ -107,154 +73,19 @@ func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
}
}
func TestGameClientBridgeLeaseReclaimAndExpiry(t *testing.T) {
func TestGameClientBridgeReconciliationExpiresPendingCommand(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "lease-1"))
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "expires-1"))
if err != nil {
t.Fatal(err)
}
component := bridgeComponent()
first, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(first) != 1 {
t.Fatalf("first claim: %#v err=%v", first, err)
}
*clock = clock.Add(defaultGameClientBridgeLeaseDuration + time.Second)
second, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(second) != 1 || second[0].Claim.FencingToken != 2 {
t.Fatalf("reclaim expired lease: %#v err=%v", second, err)
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
t.Fatal("expected old claim fencing rejection")
}
*clock = command.ExpiresAt.Add(time.Second)
if err := svc.ReconcileGameClientBridgeCommands(); err != nil {
t.Fatalf("reconcile expired command: %v", err)
}
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
if err != nil || expired.State != domain.GameClientBridgeCommandExpired {
t.Fatalf("expected expired command: %#v err=%v", expired, err)
}
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(claimed) != 0 {
t.Fatalf("expired command was claimable: %#v err=%v", claimed, err)
}
}
func TestGameClientBridgePendingCommandExpiresBeforeFirstClaim(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
request := bridgeQueueRequest(*clock, "pending-expiry")
request.ExpiresAt = clock.Add(30 * time.Second)
command, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil {
t.Fatal(err)
}
*clock = request.ExpiresAt
claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
if err != nil || len(claimed) != 0 {
t.Fatalf("expired pending command was claimable: %#v err=%v", claimed, err)
}
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Claim.FencingToken != 0 {
t.Fatalf("first claim did not persist pending command expiry: %#v err=%v", expired, err)
}
}
func TestGameClientBridgeExpiredLeaseRejectsMutationsBeforeReclaim(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
for _, key := range []string{"expired-lease-ack", "expired-lease-result"} {
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, key)); err != nil {
t.Fatalf("queue %s: %v", key, err)
}
}
component := bridgeComponent()
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
if err != nil || len(claimed) != 2 {
t.Fatalf("claim lease-expiry commands: %#v err=%v", claimed, err)
}
*clock = claimed[0].Claim.LeaseExpiresAt
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
t.Fatal("expected ack at claim lease expiry to be rejected")
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
t.Fatal("expected result at claim lease expiry to be rejected")
}
for _, command := range claimed {
protected, getErr := svc.store.GameClientBridgeCommands().Get(command.ID)
if getErr != nil || protected.State != domain.GameClientBridgeCommandClaimed || !protected.Claim.AcknowledgedAt.IsZero() || protected.Result.Status != "" || !protected.CompletedAt.IsZero() || protected.Claim.FencingToken != command.Claim.FencingToken {
t.Fatalf("expired lease mutation changed protected command: %#v err=%v", protected, getErr)
}
}
reclaimed, err := svc.claimGameClientBridgeCommands(component, 2)
if err != nil || len(reclaimed) != 2 {
t.Fatalf("reclaim protected commands after lease sweep: %#v err=%v", reclaimed, err)
}
for _, command := range reclaimed {
if command.Claim.FencingToken != 2 {
t.Fatalf("reclaimed command did not advance fencing token: %#v", command)
}
}
}
func TestGameClientBridgeClaimMutationsExpireAtCommandDeadline(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
deadline := clock.Add(30 * time.Second)
commands := make([]domain.GameClientBridgeCommand, 0, 2)
for _, key := range []string{"deadline-ack", "deadline-result"} {
request := bridgeQueueRequest(*clock, key)
request.ExpiresAt = deadline
command, err := svc.queueGameClientBridgeCommand("user-1", request)
if err != nil {
t.Fatalf("queue deadline command: %v", err)
}
commands = append(commands, command)
}
component := bridgeComponent()
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
if err != nil || len(claimed) != 2 {
t.Fatalf("claim deadline commands: %#v err=%v", claimed, err)
}
for _, command := range claimed {
if !command.Claim.LeaseExpiresAt.Equal(deadline) {
t.Fatalf("claim lease exceeded command deadline: %#v", command.Claim)
}
}
*clock = deadline
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
t.Fatal("expected ack at command deadline to be rejected")
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
t.Fatal("expected result at command deadline to be rejected")
}
for _, command := range commands {
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() {
t.Fatalf("deadline mutation did not persist expiry: %#v err=%v", expired, err)
}
}
}
func TestGameClientBridgeFailedResultIsPersisted(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "failed-result"))
if err != nil {
t.Fatal(err)
}
component := bridgeComponent()
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(claimed) != 1 {
t.Fatalf("claim failed-result command: %#v err=%v", claimed, err)
}
request := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "game window unavailable", Payload: map[string]any{"retryable": true}}
failed, err := svc.completeGameClientBridgeCommand(component, request)
if err != nil || failed.State != domain.GameClientBridgeCommandFailed || failed.Result.Status != domain.GameClientBridgeResultFailed || failed.Result.Summary != request.Summary || failed.Result.CompletedBy != component.Session.ID || failed.CompletedAt.IsZero() {
t.Fatalf("record failed result: %#v err=%v", failed, err)
}
persisted, err := svc.store.GameClientBridgeCommands().Get(command.ID)
if err != nil || persisted.State != domain.GameClientBridgeCommandFailed || persisted.Result.Status != domain.GameClientBridgeResultFailed || persisted.Result.Payload["retryable"] != true || !persisted.CompletedAt.Equal(failed.CompletedAt) {
t.Fatalf("failed result was not persisted: %#v err=%v", persisted, err)
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.Claim.SessionID != "" {
t.Fatalf("expected expired unclaimed command: %#v err=%v", expired, err)
}
}
@@ -287,7 +118,7 @@ func TestGameClientBridgeOperatorCancellationExpiresAtCommandDeadline(t *testing
}
}
func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
func TestGameClientBridgeOperatorCancellationIsIdempotentAndTerminal(t *testing.T) {
svc, clock := newGameClientBridgeService(t)
user := domain.User{ID: "user-1", DisplayName: "Owner", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, CreatedAt: *clock, UpdatedAt: *clock}
if err := svc.store.Users().Create(user); err != nil {
@@ -304,29 +135,14 @@ func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
if err != nil {
t.Fatal(err)
}
component := bridgeComponent()
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(claimed) != 1 {
t.Fatalf("claim: %#v err=%v", claimed, err)
}
cancelled, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID {
if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID || cancelled.Result.Status != domain.GameClientBridgeResultCancelled {
t.Fatalf("cancel bridge command: %#v err=%v", cancelled, err)
}
repeated, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
if err != nil || repeated.State != domain.GameClientBridgeCommandCancelled || !repeated.Cancellation.CancelledAt.Equal(cancelled.Cancellation.CancelledAt) {
t.Fatalf("repeated cancellation was not idempotent: %#v err=%v", repeated, err)
}
remaining, err := svc.claimGameClientBridgeCommands(component, 1)
if err != nil || len(remaining) != 0 {
t.Fatalf("cancelled command remained claimable: %#v err=%v", remaining, err)
}
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "late success"}); err == nil {
t.Fatal("expected late success after cancellation rejection")
}
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
t.Fatal("expected late ack after cancellation rejection")
}
}
func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequence(t *testing.T) {
@@ -345,7 +161,7 @@ func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequ
t.Fatal(err)
}
for sequence := uint64(1); sequence <= 4; sequence++ {
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-" + string(rune('0'+sequence)), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}, ExpiresAt: clock.Add(time.Hour)}
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-" + string(rune('0'+sequence)), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "plugin-owned", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}, ExpiresAt: clock.Add(time.Hour)}
if sequence == 1 {
snapshot.ExpiresAt = clock.Add(-time.Second)
}
@@ -353,7 +169,7 @@ func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequ
t.Fatal(err)
}
}
stream := domain.GameClientBridgeSnapshotStream{ID: gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", LatestSequence: 4}
stream := domain.GameClientBridgeSnapshotStream{ID: "stream-players-current", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "plugin-owned", Type: "players", StreamKey: "current", LatestSequence: 4}
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
t.Fatal(err)
}
+2 -107
View File
@@ -166,35 +166,16 @@ type Core interface {
GetArtifactForSession(string, string) (domain.Artifact, error)
OpenArtifactDownloadForSession(string, domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error)
ReadArtifactContentForSession(string, domain.ArtifactContentRequest) (domain.ArtifactContent, error)
OpenArtifactContentStreamForSession(string, domain.ArtifactContentRequest) (ArtifactContentStream, error)
GetServerRuntimeActionsForSession(string, string) (domain.ServerRuntimeActions, error)
GetServerRuntimeBindingForSession(string, string) (domain.RuntimeBindingView, error)
UpdateServerRuntimeBindingForSession(string, string, domain.RuntimeBindingUpdate) (domain.RuntimeBindingView, error)
GenerateRunDistributionForSession(string, domain.RunDistributionGenerateRequest) (domain.RunDistribution, error)
GenerateClientManagerDistributionForSession(string, domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error)
OpenLatestRunDistributionDownloadForSession(string, string) (domain.ArtifactDownloadReference, error)
OpenLatestClientManagerDistributionDownloadForSession(string, string, string) (domain.ArtifactDownloadReference, error)
ResetComponentKeyForSession(string, domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error)
AuthenticateComponent(domain.ComponentAuthenticationRequest) (domain.ComponentAuthenticationResult, error)
DeployClientManagerForSession(string, domain.ClientManagerDeployRequest) (domain.ClientManagerLifecycleView, error)
ControlClientManagerForSession(string, domain.ClientManagerControlRequest) (domain.ClientManagerLifecycleView, error)
UpdateClientManagerForSession(string, domain.ClientManagerUpdateRequest) (domain.ClientManagerLifecycleView, error)
UninstallClientManagerForSession(string, domain.ClientManagerUninstallRequest) (domain.ClientManagerLifecycleView, error)
RetryClientManagerLifecycleForSession(string, domain.ClientManagerRetryRequest) (domain.ClientManagerLifecycleView, error)
RevokeClientManagerSessionForSession(string, domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error)
GetClientManagerLifecycleForSession(string, string, string) (domain.ClientManagerLifecycleView, error)
ListClientManagerLifecyclesForSession(string, string) ([]domain.ClientManagerLifecycleView, error)
GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest) (domain.ClientManagerLifecycleInput, error)
ReadClientManagerLifecycleChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
RegisterClientManager(domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error)
AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error)
ReconcileClientManagerLifecycle() error
QueueGameClientBridgeCommandForSession(string, domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error)
ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error)
AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error)
CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error)
CancelGameClientBridgeCommandForSession(string, domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error)
UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error)
AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error)
ReconcileGameClientBridgeCommands() error
GetGameClientBridgeStatusForSession(string, string) (domain.GameClientBridgeStatus, error)
ListGameClientBridgeCommandsForSession(string, domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error)
@@ -351,9 +332,6 @@ func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, ar
if err := service.RecoverIncompleteBackups(); err != nil {
return nil, err
}
if err := service.ReconcileClientManagerLifecycle(); err != nil {
return nil, err
}
if err := service.ReconcileGameClientBridgeCommands(); err != nil {
return nil, err
}
@@ -956,8 +934,6 @@ func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request doma
base = svc.executeBridgeDependenciesRequest(sessionID, base, plugin, instance, request.Payload)
case domain.PluginBridgeActionLogsBackfillRequest:
base = svc.executeBridgeLogsBackfillRequest(base, plugin, instance, request.Payload)
case domain.PluginBridgeActionClientManager:
base = svc.executeBridgeClientManager(sessionID, base, request)
case domain.PluginBridgeActionPluginLifecycle:
base = svc.executeBridgePluginLifecycle(sessionID, base, request)
case domain.PluginBridgeActionArtifactsOpen:
@@ -1364,87 +1340,6 @@ func (svc *CoreService) executeBridgeRunDistribution(sessionID string, base doma
return base
}
func (svc *CoreService) executeBridgeClientManager(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse {
profileKey := request.Payload["profileKey"]
operation := defaultBridgeValue(request.Payload["operation"], "status")
idempotencyKey := defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID)
generation, _ := strconv.Atoi(request.Payload["expectedDeploymentGeneration"])
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
if err != nil {
return bridgeExecutionError(base, err)
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return bridgeExecutionError(base, err)
}
profile, err := findRuntimeClientManagerProfile(plugin, profileKey)
if err != nil {
return bridgeExecutionError(base, ErrForbidden)
}
var view domain.ClientManagerLifecycleView
switch operation {
case "generate":
distribution, err := svc.GenerateClientManagerDistributionForSession(sessionID, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, TargetOS: defaultBridgeValue(request.Payload["targetOs"], "windows"), TargetArch: defaultBridgeValue(request.Payload["targetArch"], "amd64"), RepositoryURL: profile.RepositoryURL, SourceRevision: clientManagerProfileRevision(profile), IdempotencyKey: idempotencyKey})
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "queued"
base.Result = map[string]string{"distributionId": distribution.ID, "buildJobId": distribution.BuildJobID, "artifactId": distribution.ArtifactID, "checksum": distribution.Checksum, "keyGeneration": strconv.Itoa(distribution.KeyGeneration), "version": distribution.Version, "status": string(distribution.Status)}
return base
case "download":
reference, err := svc.OpenLatestClientManagerDistributionDownloadForSession(sessionID, instance.ID, profileKey)
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "ok"
base.Result = map[string]string{"artifactId": reference.ArtifactID, "downloadUrl": reference.DownloadURL, "checksum": reference.Checksum, "sizeBytes": strconv.FormatInt(reference.SizeBytes, 10), "expiresAt": reference.ExpiresAt.Format(time.RFC3339), "rangeSupported": strconv.FormatBool(reference.RangeSupported), "chunkSizeBytes": strconv.Itoa(reference.ChunkSizeBytes)}
return base
case "reset-key":
key, err := svc.ResetComponentKeyForSession(sessionID, domain.ComponentKeyResetRequest{ServerInstanceID: instance.ID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: profileKey})
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "ok"
base.Result = map[string]string{"profileKey": profileKey, "keyGeneration": strconv.Itoa(key.Generation), "status": string(key.Status), "requiresRedeploy": "true"}
return base
case "status":
view, err = svc.GetClientManagerLifecycleForSession(sessionID, instance.ID, profileKey)
case "deploy":
distributionID, resolveErr := svc.resolveClientManagerDistributionID(instance.ID, profileKey, request.Payload["artifactId"])
if resolveErr != nil {
return bridgeExecutionError(base, resolveErr)
}
view, err = svc.DeployClientManagerForSession(sessionID, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, DistributionID: distributionID, ExpectedDeploymentGeneration: generation, IdempotencyKey: idempotencyKey})
case "start", "stop", "restart", "rollback":
view, err = svc.ControlClientManagerForSession(sessionID, domain.ClientManagerControlRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, Operation: domain.ClientManagerLifecycleOperation(operation), ExpectedDeploymentGeneration: generation, IdempotencyKey: idempotencyKey})
case "update":
distributionID, resolveErr := svc.resolveClientManagerDistributionID(instance.ID, profileKey, request.Payload["artifactId"])
if resolveErr != nil {
return bridgeExecutionError(base, resolveErr)
}
view, err = svc.UpdateClientManagerForSession(sessionID, domain.ClientManagerUpdateRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, DistributionID: distributionID, ExpectedDeploymentGeneration: generation, Approved: true, IdempotencyKey: idempotencyKey})
case "retry":
view, err = svc.RetryClientManagerLifecycleForSession(sessionID, domain.ClientManagerRetryRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, ExpectedDeploymentGeneration: generation, IdempotencyKey: idempotencyKey})
case "revoke-session":
view, err = svc.RevokeClientManagerSessionForSession(sessionID, domain.ClientManagerRevokeSessionRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, Reason: "plugin bridge operator request"})
case "uninstall":
view, err = svc.UninstallClientManagerForSession(sessionID, domain.ClientManagerUninstallRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, ExpectedDeploymentGeneration: generation, Confirmed: true, IdempotencyKey: idempotencyKey})
default:
base.Status = "denied"
base.Error = &domain.PluginBridgeSafeError{Code: "invalid_client_manager_operation", Message: "client-manager operation is not supported"}
return base
}
if err != nil {
return bridgeExecutionError(base, err)
}
base.Status = "ok"
if view.Job.ID != "" && !isTerminalJobState(view.Job.State) {
base.Status = "queued"
}
base.Result = safeClientManagerBridgeResult(view)
return base
}
func (svc *CoreService) executeBridgeDependenciesRequest(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse {
action := defaultBridgeValue(payload["operation"], "check")
capability := domain.JobCapabilityDependenciesCheck
@@ -1544,7 +1439,7 @@ func pluginPermissionsFromManifest(permissions []string) domain.PluginPermission
aggregate.Artifacts = true
case "server.remote.access":
aggregate.RemoteAccess = true
case "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage":
case "server.run.distribution", "server.dependencies.manage":
aggregate.Jobs = true
aggregate.Artifacts = true
}