1461 lines
75 KiB
Go
1461 lines
75 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"errors"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
const (
|
|
clientManagerRegistrationWindow = 5 * time.Minute
|
|
clientManagerSessionTTL = 15 * time.Minute
|
|
)
|
|
|
|
func (svc *CoreService) DeployClientManagerForSession(sessionID string, request domain.ClientManagerDeployRequest) (domain.ClientManagerLifecycleView, error) {
|
|
if strings.TrimSpace(request.IdempotencyKey) == "" {
|
|
request.IdempotencyKey = "client-manager-deploy-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.DistributionID
|
|
}
|
|
if err := validator.ValidateClientManagerDeployRequest(request); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
_, instance, plugin, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerDeploy, "client-manager.deploy.denied")
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if request.ExpectedDeploymentGeneration > 0 && request.ExpectedDeploymentGeneration != installation.DeploymentGeneration {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager deployment generation is stale")
|
|
}
|
|
if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil {
|
|
if existing.ServerInstanceID != instance.ID || existing.Capability != domain.JobCapabilityClientManagerDeploy || existing.TargetKey != "client-manager/"+profile.Key || installation.CurrentJobID != existing.ID {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy idempotency key conflicts with another job")
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
|
|
stamp := svc.now()
|
|
installation.RunEndpointID = endpoint.ID
|
|
installation.TargetOS = distribution.TargetOS
|
|
installation.TargetArch = distribution.TargetArch
|
|
installation.DesiredVersion = clientManagerDistributionVersion(distribution, profile)
|
|
installation.DesiredRevision = distribution.SourceRevision
|
|
installation.DesiredArtifactID = distribution.ArtifactID
|
|
installation.Checksum = distribution.Checksum
|
|
installation.KeyGeneration = distribution.KeyGeneration
|
|
installation.DeploymentGeneration++
|
|
installation.LastOperation = domain.ClientManagerOperationDeploy
|
|
installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationDeploy, installation.ID, request.IdempotencyKey)
|
|
installation.Phase = "deployment queued"
|
|
installation.Health = domain.ClientManagerHealthUnknown
|
|
installation.HealthReason = "awaiting verified deployment"
|
|
installation.Retryable = false
|
|
installation.RequiresRedeploy = false
|
|
installation.UpdatedAt = stamp
|
|
if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleDeploying); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if err := validator.ValidateClientManagerInstallation(installation); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerDeploy, "deployment queued"))
|
|
if err != nil {
|
|
installation.Status = domain.ClientManagerLifecycleFailed
|
|
installation.Phase = "deployment dispatch failed"
|
|
installation.HealthReason = "Run endpoint rejected deployment"
|
|
installation.Retryable = true
|
|
installation.UpdatedAt = svc.now()
|
|
_ = svc.store.ClientManagerInstallations().Update(installation)
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if job.ID != installation.CurrentJobID {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy job fence is invalid")
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
}
|
|
|
|
func (svc *CoreService) ControlClientManagerForSession(sessionID string, request domain.ClientManagerControlRequest) (domain.ClientManagerLifecycleView, error) {
|
|
if err := validator.ValidateClientManagerControlRequest(request); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
capability := domain.JobCapabilityClientManagerControl
|
|
if request.Operation == domain.ClientManagerOperationRollback {
|
|
capability = domain.JobCapabilityClientManagerRollback
|
|
}
|
|
_, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager."+string(request.Operation)+".denied")
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if !containsString(profile.Lifecycle.Actions, string(request.Operation)) {
|
|
return domain.ClientManagerLifecycleView{}, ErrForbidden
|
|
}
|
|
installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || installation.RequiresRedeploy {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager deployment generation is stale or requires redeploy")
|
|
}
|
|
if installation.ActiveArtifactID == "" || installation.Status == domain.ClientManagerLifecycleUninstalled || installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager state does not allow control")
|
|
}
|
|
if request.Operation == domain.ClientManagerOperationRollback && installation.PreviousArtifactID == "" {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager previous deployment is unavailable")
|
|
}
|
|
if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil {
|
|
if installation.CurrentJobID != existing.ID || existing.Capability != capability {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager control idempotency key conflicts with another job")
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
|
|
installation.LastOperation = request.Operation
|
|
installation.CurrentJobID = clientManagerJobID(request.Operation, installation.ID, request.IdempotencyKey)
|
|
installation.Phase = string(request.Operation) + " queued"
|
|
installation.Retryable = false
|
|
installation.UpdatedAt = svc.now()
|
|
switch request.Operation {
|
|
case domain.ClientManagerOperationStop:
|
|
if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleStopping); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
case domain.ClientManagerOperationRollback:
|
|
installation.DesiredArtifactID = installation.PreviousArtifactID
|
|
installation.DesiredVersion = installation.PreviousVersion
|
|
installation.DesiredRevision = installation.PreviousRevision
|
|
installation.DeploymentGeneration++
|
|
if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleRollingBack); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
case domain.ClientManagerOperationStart, domain.ClientManagerOperationRestart:
|
|
if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleRegistering); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
}
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, capability, installation.Phase))
|
|
if err != nil {
|
|
installation.Status = domain.ClientManagerLifecycleFailed
|
|
installation.Phase = string(request.Operation) + " dispatch failed"
|
|
installation.Retryable = true
|
|
installation.UpdatedAt = svc.now()
|
|
_ = svc.store.ClientManagerInstallations().Update(installation)
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if job.ID != installation.CurrentJobID {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager control job fence is invalid")
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
}
|
|
|
|
func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request domain.ClientManagerUpdateRequest) (domain.ClientManagerLifecycleView, error) {
|
|
if err := validator.ValidateClientManagerUpdateRequest(request); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
_, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUpdate, "client-manager.update.denied")
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if !containsString(profile.Lifecycle.Actions, "update") || profile.UpdatePolicy.Strategy != "manual-staged" || !profile.UpdatePolicy.RequireApproval {
|
|
return domain.ClientManagerLifecycleView{}, ErrForbidden
|
|
}
|
|
installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || installation.ActiveArtifactID == "" || installation.RequiresRedeploy {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager update fence is stale or no healthy baseline is installed")
|
|
}
|
|
distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if distribution.ArtifactID == installation.ActiveArtifactID || !clientManagerVersionAllowed(profile, installation.ActiveVersion, clientManagerDistributionVersion(distribution, profile)) {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager update artifact is incompatible")
|
|
}
|
|
if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil {
|
|
if installation.CurrentJobID != existing.ID || existing.Capability != domain.JobCapabilityClientManagerUpdate {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager update idempotency key conflicts with another job")
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
installation.DesiredArtifactID = distribution.ArtifactID
|
|
installation.DesiredVersion = clientManagerDistributionVersion(distribution, profile)
|
|
installation.DesiredRevision = distribution.SourceRevision
|
|
installation.Checksum = distribution.Checksum
|
|
installation.KeyGeneration = distribution.KeyGeneration
|
|
installation.DeploymentGeneration++
|
|
installation.LastOperation = domain.ClientManagerOperationUpdate
|
|
installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationUpdate, installation.ID, request.IdempotencyKey)
|
|
installation.Phase = "staged update queued"
|
|
installation.Retryable = false
|
|
installation.UpdatedAt = svc.now()
|
|
if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleUpdating); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if err := svc.revokeClientManagerSessions(installation.ID, "update activation requires a new component session"); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerUpdate, installation.Phase))
|
|
if err != nil {
|
|
installation.Status = domain.ClientManagerLifecycleFailed
|
|
installation.Phase = "update dispatch failed"
|
|
installation.Retryable = true
|
|
installation.UpdatedAt = svc.now()
|
|
_ = svc.store.ClientManagerInstallations().Update(installation)
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if job.ID != installation.CurrentJobID {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager update job fence is invalid")
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
}
|
|
|
|
func (svc *CoreService) UninstallClientManagerForSession(sessionID string, request domain.ClientManagerUninstallRequest) (domain.ClientManagerLifecycleView, error) {
|
|
if err := validator.ValidateClientManagerUninstallRequest(request); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
_, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUninstall, "client-manager.uninstall.denied")
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if !containsString(profile.Lifecycle.Actions, "uninstall") {
|
|
return domain.ClientManagerLifecycleView{}, ErrForbidden
|
|
}
|
|
installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall generation is stale")
|
|
}
|
|
if installation.Status == domain.ClientManagerLifecycleUninstalled {
|
|
return svc.clientManagerLifecycleView(installation)
|
|
}
|
|
if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil {
|
|
if installation.CurrentJobID != existing.ID || existing.Capability != domain.JobCapabilityClientManagerUninstall {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall idempotency key conflicts with another job")
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
installation.LastOperation = domain.ClientManagerOperationUninstall
|
|
installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationUninstall, installation.ID, request.IdempotencyKey)
|
|
installation.Phase = "safe uninstall queued"
|
|
installation.UpdatedAt = svc.now()
|
|
if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleStopping); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if err := svc.revokeClientManagerSessions(installation.ID, "uninstall requested"); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerUninstall, installation.Phase))
|
|
if err != nil {
|
|
installation.Status = domain.ClientManagerLifecycleFailed
|
|
installation.Phase = "uninstall dispatch failed"
|
|
installation.Retryable = true
|
|
installation.UpdatedAt = svc.now()
|
|
_ = svc.store.ClientManagerInstallations().Update(installation)
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if job.ID != installation.CurrentJobID {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall job fence is invalid")
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
}
|
|
|
|
func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, request domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error) {
|
|
if _, err := svc.GetCurrentUser(sessionID); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
installation, err := svc.getClientManagerInstallation(instance.ID, request.ProfileKey)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if err := svc.revokeClientManagerSessions(installation.ID, "operator revoked component session"); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if installation.Status != domain.ClientManagerLifecycleUninstalled {
|
|
installation.Status = domain.ClientManagerLifecycleOffline
|
|
installation.Health = domain.ClientManagerHealthOffline
|
|
installation.HealthReason = "component session revoked"
|
|
installation.Phase = "registration required"
|
|
installation.UpdatedAt = svc.now()
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
}
|
|
|
|
func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string, request domain.ClientManagerRetryRequest) (domain.ClientManagerLifecycleView, error) {
|
|
if err := validator.ValidateClientManagerRetryRequest(request); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
installation, err := svc.getClientManagerInstallation(request.ServerInstanceID, request.ProfileKey)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || !installation.Retryable || installation.LastOperation == "" {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager lifecycle operation is not retryable or generation is stale")
|
|
}
|
|
capability := domain.JobCapabilityClientManagerControl
|
|
switch installation.LastOperation {
|
|
case domain.ClientManagerOperationDeploy:
|
|
capability = domain.JobCapabilityClientManagerDeploy
|
|
case domain.ClientManagerOperationUpdate:
|
|
capability = domain.JobCapabilityClientManagerUpdate
|
|
case domain.ClientManagerOperationRollback:
|
|
capability = domain.JobCapabilityClientManagerRollback
|
|
case domain.ClientManagerOperationUninstall:
|
|
capability = domain.JobCapabilityClientManagerUninstall
|
|
}
|
|
_, _, _, _, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager.retry.denied")
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil {
|
|
if installation.CurrentJobID != existing.ID || existing.Capability != capability {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager retry idempotency key conflicts with another job")
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
installation.CurrentJobID = clientManagerJobID(installation.LastOperation, installation.ID, request.IdempotencyKey)
|
|
installation.Phase = string(installation.LastOperation) + " retry queued"
|
|
installation.Retryable = false
|
|
switch installation.LastOperation {
|
|
case domain.ClientManagerOperationDeploy:
|
|
installation.Status = domain.ClientManagerLifecycleDeploying
|
|
case domain.ClientManagerOperationUpdate:
|
|
installation.Status = domain.ClientManagerLifecycleUpdating
|
|
case domain.ClientManagerOperationRollback:
|
|
installation.Status = domain.ClientManagerLifecycleRollingBack
|
|
case domain.ClientManagerOperationStop, domain.ClientManagerOperationUninstall:
|
|
installation.Status = domain.ClientManagerLifecycleStopping
|
|
default:
|
|
installation.Status = domain.ClientManagerLifecycleRegistering
|
|
}
|
|
installation.UpdatedAt = svc.now()
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, capability, installation.Phase))
|
|
if err != nil {
|
|
installation.Status = domain.ClientManagerLifecycleFailed
|
|
installation.Retryable = true
|
|
installation.UpdatedAt = svc.now()
|
|
_ = svc.store.ClientManagerInstallations().Update(installation)
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if job.ID != installation.CurrentJobID {
|
|
return domain.ClientManagerLifecycleView{}, validationError("client-manager retry job fence is invalid")
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
}
|
|
|
|
func (svc *CoreService) GetClientManagerLifecycleForSession(sessionID, serverInstanceID, profileKey string) (domain.ClientManagerLifecycleView, error) {
|
|
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
installation, err := svc.getClientManagerInstallation(serverInstanceID, profileKey)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
return svc.clientManagerLifecycleView(installation)
|
|
}
|
|
|
|
func (svc *CoreService) ListClientManagerLifecyclesForSession(sessionID, serverInstanceID string) ([]domain.ClientManagerLifecycleView, error) {
|
|
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
|
|
return nil, err
|
|
}
|
|
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
views := make([]domain.ClientManagerLifecycleView, 0, len(installations))
|
|
for _, installation := range installations {
|
|
view, err := svc.clientManagerLifecycleView(installation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
views = append(views, view)
|
|
}
|
|
sort.Slice(views, func(i, j int) bool { return views[i].Installation.ProfileKey < views[j].Installation.ProfileKey })
|
|
return views, nil
|
|
}
|
|
|
|
func clientManagerLifecycleJob(installation domain.ClientManagerInstallation, idempotencyKey, capability, message string) domain.Job {
|
|
return domain.Job{ID: installation.CurrentJobID, ServerInstanceID: installation.ServerInstanceID, RunEndpointID: installation.RunEndpointID, Capability: capability, TargetKey: "client-manager/" + installation.ProfileKey, InputRef: "input://client-manager-lifecycle/" + installation.ID + "/" + strconv.Itoa(installation.DeploymentGeneration), IdempotencyKey: idempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: message}}
|
|
}
|
|
|
|
func clientManagerJobID(operation domain.ClientManagerLifecycleOperation, installationID, idempotencyKey string) string {
|
|
return jobIDFromParts("job-client-manager-"+string(operation), installationID, idempotencyKey)
|
|
}
|
|
|
|
func (svc *CoreService) authorizeClientManagerLifecycle(sessionID, serverInstanceID, profileKey, capability, deniedAction string) (domain.User, domain.ServerInstance, domain.GamePlugin, domain.RuntimeClientManagerProfile, domain.RunEndpoint, error) {
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
|
}
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
|
if err != nil {
|
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
|
}
|
|
if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.client-manager.manage", deniedAction); err != nil {
|
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
|
}
|
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, deniedAction); err != nil {
|
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
|
}
|
|
profile, err := findRuntimeClientManagerProfile(plugin, profileKey)
|
|
if err != nil || profile.Deployment.Mode != "run-supervised" || !containsString(profile.Deployment.RequiredRunCapabilities, capability) {
|
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, ErrForbidden
|
|
}
|
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
|
if err != nil {
|
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
|
}
|
|
if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil {
|
|
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
|
|
}
|
|
return user, instance, plugin, profile, endpoint, nil
|
|
}
|
|
|
|
func (svc *CoreService) authorizedClientManagerDistribution(instance domain.ServerInstance, profile domain.RuntimeClientManagerProfile, distributionID string) (domain.ClientManagerDistribution, error) {
|
|
distribution, err := svc.store.ClientManagerDistributions().Get(distributionID)
|
|
if err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
if distribution.ServerInstanceID != instance.ID || distribution.PluginID != instance.PluginID || distribution.ProfileKey != profile.Key || distribution.Status != domain.DistributionStatusAvailable || !clientManagerProfileSupportsTarget(profile, distribution.TargetOS, distribution.TargetArch) || !clientManagerProfileAllowsRevision(profile, distribution.SourceRevision) {
|
|
return domain.ClientManagerDistribution{}, validationError("client-manager distribution is outside the lifecycle scope")
|
|
}
|
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
|
if err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
if distribution.TargetOS != endpoint.Platform || distribution.TargetArch != endpoint.Architecture {
|
|
return domain.ClientManagerDistribution{}, validationError("client-manager distribution target does not match assigned Run endpoint")
|
|
}
|
|
key, err := svc.activeComponentKey(instance.ID, domain.DistributionComponentClientManager, profile.Key)
|
|
if err != nil || key.Generation != distribution.KeyGeneration {
|
|
return domain.ClientManagerDistribution{}, validationError("client-manager distribution key generation is no longer current")
|
|
}
|
|
artifact, err := svc.store.Artifacts().Get(distribution.ArtifactID)
|
|
if err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != distribution.Checksum || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID {
|
|
return domain.ClientManagerDistribution{}, validationError("client-manager distribution artifact is unavailable or outside its build job")
|
|
}
|
|
return domain.CopyClientManagerDistribution(distribution), nil
|
|
}
|
|
|
|
func findRuntimeClientManagerProfile(plugin domain.GamePlugin, profileKey string) (domain.RuntimeClientManagerProfile, error) {
|
|
plugin = domain.CopyGamePlugin(plugin)
|
|
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
|
if profile.Key == strings.TrimSpace(profileKey) {
|
|
return profile, nil
|
|
}
|
|
}
|
|
return domain.RuntimeClientManagerProfile{}, repo.ErrNotFound
|
|
}
|
|
|
|
func clientManagerCompanionConfigInput(plugin domain.GamePlugin, profile domain.RuntimeClientManagerProfile, installation domain.ClientManagerInstallation, artifactID, version, revision string) (*domain.ClientManagerCompanionConfigInput, error) {
|
|
companion := plugin.GameClientBridge.Companion
|
|
if companion == (domain.GameClientBridgeCompanionDeclaration{}) {
|
|
return nil, nil
|
|
}
|
|
if companion.ProfileKey == "" {
|
|
return nil, validationError("client-manager companion declaration is incomplete")
|
|
}
|
|
if companion.ProfileKey != installation.ProfileKey {
|
|
return nil, nil
|
|
}
|
|
var configTemplate domain.RuntimeConfigTemplate
|
|
found := false
|
|
for _, candidate := range profile.ConfigTemplates {
|
|
if candidate.Key == companion.ConfigTemplateKey {
|
|
configTemplate = candidate
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found || strings.TrimSpace(configTemplate.TemplateRef) == "" || configTemplate.OutputRef != "config.yaml" {
|
|
return nil, validationError("client-manager companion config template is unavailable")
|
|
}
|
|
config := domain.ClientManagerCompanionConfigInput{
|
|
SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion,
|
|
ConfigTemplateKey: companion.ConfigTemplateKey,
|
|
ConfigTemplateRef: configTemplate.TemplateRef,
|
|
ConfigOutputRef: configTemplate.OutputRef,
|
|
ConfigSchemaRef: companion.ConfigSchemaRef,
|
|
ConfigFormat: companion.ConfigFormat,
|
|
PlatformBaseURLSource: companion.PlatformBaseURLSource,
|
|
InstallationID: installation.ID,
|
|
ServerInstanceID: installation.ServerInstanceID,
|
|
PluginID: plugin.ID,
|
|
ProfileKey: installation.ProfileKey,
|
|
ArtifactID: artifactID,
|
|
Version: version,
|
|
SourceRevision: revision,
|
|
TargetOS: installation.TargetOS,
|
|
TargetArch: installation.TargetArch,
|
|
KeyGeneration: installation.KeyGeneration,
|
|
DeploymentGeneration: installation.DeploymentGeneration,
|
|
Capabilities: domain.CopyStringSlice(profile.Health.RequiredCapabilities),
|
|
RegistrationProof: companion.RegistrationProof,
|
|
ProofMaterialSource: companion.ProofMaterialSource,
|
|
ProofMaterialEnv: companion.ProofMaterialEnv,
|
|
SessionMode: companion.SessionMode,
|
|
TLSPolicy: companion.TLSPolicy,
|
|
HeartbeatIntervalSeconds: companion.HeartbeatIntervalSeconds,
|
|
CommandPollIntervalSeconds: companion.CommandPollIntervalSeconds,
|
|
RequestTimeoutSeconds: companion.RequestTimeoutSeconds,
|
|
}
|
|
if err := validator.ValidateClientManagerCompanionConfigInput(config); err != nil {
|
|
return nil, err
|
|
}
|
|
return &config, nil
|
|
}
|
|
|
|
func clientManagerProfileSupportsTarget(profile domain.RuntimeClientManagerProfile, targetOS, targetArch string) bool {
|
|
for _, target := range profile.SupportedTargets {
|
|
if target.OS == targetOS && target.Arch == targetArch {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func clientManagerProfileRevision(profile domain.RuntimeClientManagerProfile) string {
|
|
switch profile.RevisionPolicy {
|
|
case "pinned":
|
|
return profile.Revision
|
|
case "tag":
|
|
return profile.Tag
|
|
default:
|
|
return profile.Branch
|
|
}
|
|
}
|
|
|
|
func clientManagerProfileAllowsRevision(profile domain.RuntimeClientManagerProfile, revision string) bool {
|
|
revision = strings.TrimSpace(revision)
|
|
if revision == "" {
|
|
return false
|
|
}
|
|
switch profile.RevisionPolicy {
|
|
case "pinned":
|
|
return revision == profile.Revision
|
|
case "tag":
|
|
return revision == profile.Tag || revision == profile.Revision
|
|
case "branch":
|
|
return revision == profile.Branch || profile.Revision != "" && revision == profile.Revision
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func clientManagerDistributionVersion(distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile) string {
|
|
if strings.TrimSpace(distribution.Version) != "" {
|
|
return distribution.Version
|
|
}
|
|
return profile.Version
|
|
}
|
|
|
|
func (svc *CoreService) ensureClientManagerInstallationFromDistribution(instance domain.ServerInstance, plugin domain.GamePlugin, distribution domain.ClientManagerDistribution) (domain.ClientManagerInstallation, error) {
|
|
installationID := distributionID("client-manager-installation", instance.ID, distribution.ProfileKey)
|
|
installation, err := svc.store.ClientManagerInstallations().Get(installationID)
|
|
if err == nil {
|
|
return installation, nil
|
|
}
|
|
if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.ClientManagerInstallation{}, err
|
|
}
|
|
status := domain.ClientManagerLifecycleBuilding
|
|
phase := "build in progress"
|
|
if distribution.Status == domain.DistributionStatusAvailable {
|
|
status = domain.ClientManagerLifecycleAvailable
|
|
phase = "artifact available"
|
|
} else if distribution.Status == domain.DistributionStatusFailed || distribution.Status == domain.DistributionStatusRevoked {
|
|
status = domain.ClientManagerLifecycleFailed
|
|
phase = "build unavailable"
|
|
}
|
|
stamp := svc.now()
|
|
installation = domain.ClientManagerInstallation{ID: installationID, ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: distribution.ProfileKey, RunEndpointID: instance.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, Status: status, Phase: phase, DesiredVersion: distribution.Version, DesiredRevision: distribution.SourceRevision, DesiredArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, KeyGeneration: distribution.KeyGeneration, Health: domain.ClientManagerHealthUnknown, HealthReason: "component is not installed", CreatedAt: stamp, UpdatedAt: stamp}
|
|
if err := validator.ValidateClientManagerInstallation(installation); err != nil {
|
|
return domain.ClientManagerInstallation{}, err
|
|
}
|
|
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
|
|
if errors.Is(err, repo.ErrDuplicate) {
|
|
return svc.store.ClientManagerInstallations().Get(installation.ID)
|
|
}
|
|
return domain.ClientManagerInstallation{}, err
|
|
}
|
|
return installation, nil
|
|
}
|
|
|
|
func (svc *CoreService) ProjectClientManagerDistribution(distribution domain.ClientManagerDistribution) error {
|
|
instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(distribution.PluginID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if installation.ActiveArtifactID != "" || installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping {
|
|
return nil
|
|
}
|
|
installation.DesiredVersion = distribution.Version
|
|
installation.DesiredRevision = distribution.SourceRevision
|
|
installation.DesiredArtifactID = distribution.ArtifactID
|
|
installation.TargetOS = distribution.TargetOS
|
|
installation.TargetArch = distribution.TargetArch
|
|
installation.Checksum = distribution.Checksum
|
|
installation.KeyGeneration = distribution.KeyGeneration
|
|
switch distribution.Status {
|
|
case domain.DistributionStatusBuilding:
|
|
installation.Status = domain.ClientManagerLifecycleBuilding
|
|
installation.Phase = "build in progress"
|
|
case domain.DistributionStatusAvailable:
|
|
installation.Status = domain.ClientManagerLifecycleAvailable
|
|
installation.Phase = "artifact available"
|
|
case domain.DistributionStatusFailed, domain.DistributionStatusRevoked:
|
|
installation.Status = domain.ClientManagerLifecycleFailed
|
|
installation.Phase = "build unavailable"
|
|
installation.RequiresRedeploy = distribution.Status == domain.DistributionStatusRevoked
|
|
}
|
|
installation.UpdatedAt = svc.now()
|
|
return svc.store.ClientManagerInstallations().Update(installation)
|
|
}
|
|
|
|
func (svc *CoreService) getClientManagerInstallation(serverInstanceID, profileKey string) (domain.ClientManagerInstallation, error) {
|
|
items, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey})
|
|
if err != nil {
|
|
return domain.ClientManagerInstallation{}, err
|
|
}
|
|
if len(items) == 0 {
|
|
return domain.ClientManagerInstallation{}, repo.ErrNotFound
|
|
}
|
|
sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) })
|
|
return items[0], nil
|
|
}
|
|
|
|
func setClientManagerLifecycleStatus(installation *domain.ClientManagerInstallation, status domain.ClientManagerLifecycleStatus) error {
|
|
if err := validator.ValidateClientManagerLifecycleTransition(installation.Status, status); err != nil {
|
|
return err
|
|
}
|
|
installation.Status = status
|
|
return nil
|
|
}
|
|
|
|
func clientManagerVersionAllowed(profile domain.RuntimeClientManagerProfile, current, desired string) bool {
|
|
currentParts, currentOK := parseClientManagerVersion(current)
|
|
desiredParts, desiredOK := parseClientManagerVersion(desired)
|
|
if !desiredOK {
|
|
return false
|
|
}
|
|
if currentOK && !profile.Compatibility.AllowDowngrade && compareClientManagerVersion(desiredParts, currentParts) < 0 {
|
|
return false
|
|
}
|
|
if minimum, ok := parseClientManagerVersion(profile.Compatibility.MinimumVersion); ok && compareClientManagerVersion(desiredParts, minimum) < 0 {
|
|
return false
|
|
}
|
|
if maximum, ok := parseClientManagerVersion(profile.Compatibility.MaximumVersion); ok && compareClientManagerVersion(desiredParts, maximum) > 0 {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseClientManagerVersion(value string) ([3]int, bool) {
|
|
var result [3]int
|
|
parts := strings.SplitN(strings.SplitN(value, "-", 2)[0], ".", 4)
|
|
if len(parts) != 3 {
|
|
return result, false
|
|
}
|
|
for i, part := range parts {
|
|
parsed, err := strconv.Atoi(part)
|
|
if err != nil || parsed < 0 {
|
|
return [3]int{}, false
|
|
}
|
|
result[i] = parsed
|
|
}
|
|
return result, true
|
|
}
|
|
|
|
func compareClientManagerVersion(left, right [3]int) int {
|
|
for i := 0; i < 3; i++ {
|
|
if left[i] < right[i] {
|
|
return -1
|
|
}
|
|
if left[i] > right[i] {
|
|
return 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientManagerLifecycleInputRequest) (domain.ClientManagerLifecycleInput, error) {
|
|
if err := validator.ValidateClientManagerLifecycleInputRequest(request); err != nil {
|
|
return domain.ClientManagerLifecycleInput{}, err
|
|
}
|
|
session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleInput{}, err
|
|
}
|
|
job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleInput{}, err
|
|
}
|
|
if !isClientManagerJobCapability(job.Capability) || job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
|
|
return domain.ClientManagerLifecycleInput{}, validationError("job is not an active client-manager lifecycle job")
|
|
}
|
|
installation, err := svc.getClientManagerInstallation(job.ServerInstanceID, strings.TrimPrefix(job.TargetKey, "client-manager/"))
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleInput{}, err
|
|
}
|
|
if installation.CurrentJobID != job.ID || installation.RunEndpointID != request.RunEndpointID || installation.DeploymentGeneration <= 0 || job.InputRef != "input://client-manager-lifecycle/"+installation.ID+"/"+strconv.Itoa(installation.DeploymentGeneration) {
|
|
return domain.ClientManagerLifecycleInput{}, validationError("client-manager lifecycle input fence is stale")
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
|
if err != nil || instance.RunEndpointID != request.RunEndpointID {
|
|
return domain.ClientManagerLifecycleInput{}, validationError("client-manager endpoint ownership fence is stale")
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(installation.PluginID)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleInput{}, err
|
|
}
|
|
profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleInput{}, err
|
|
}
|
|
key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey)
|
|
if err != nil || key.Generation != installation.KeyGeneration || installation.RequiresRedeploy {
|
|
return domain.ClientManagerLifecycleInput{}, validationError("client-manager component key generation is stale")
|
|
}
|
|
operation := installation.LastOperation
|
|
artifactID := installation.DesiredArtifactID
|
|
checksum := installation.Checksum
|
|
version := installation.DesiredVersion
|
|
revision := installation.DesiredRevision
|
|
if operation == domain.ClientManagerOperationStart || operation == domain.ClientManagerOperationStop || operation == domain.ClientManagerOperationRestart || operation == domain.ClientManagerOperationStatus || operation == domain.ClientManagerOperationUninstall {
|
|
artifactID = installation.ActiveArtifactID
|
|
version = installation.ActiveVersion
|
|
revision = installation.ActiveRevision
|
|
checksum = ""
|
|
}
|
|
companionConfig, err := clientManagerCompanionConfigInput(plugin, profile, installation, artifactID, version, revision)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleInput{}, err
|
|
}
|
|
return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey, CompanionConfig: companionConfig}), nil
|
|
}
|
|
|
|
func (svc *CoreService) ReadClientManagerLifecycleChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
|
|
if err := validator.ValidateRunUpdateChunkRequest(request); err != nil {
|
|
return domain.RunUpdateChunk{}, err
|
|
}
|
|
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
|
if err != nil {
|
|
return domain.RunUpdateChunk{}, err
|
|
}
|
|
if !isClientManagerJobCapability(job.Capability) || job.Capability == domain.JobCapabilityClientManagerControl || job.Capability == domain.JobCapabilityClientManagerUninstall {
|
|
return domain.RunUpdateChunk{}, validationError("job does not carry a client-manager artifact")
|
|
}
|
|
installation, err := svc.getClientManagerInstallation(job.ServerInstanceID, strings.TrimPrefix(job.TargetKey, "client-manager/"))
|
|
if err != nil || installation.CurrentJobID != job.ID || installation.DesiredArtifactID == "" {
|
|
return domain.RunUpdateChunk{}, validationError("client-manager artifact fence is stale")
|
|
}
|
|
artifact, err := svc.store.Artifacts().Get(installation.DesiredArtifactID)
|
|
if err != nil {
|
|
return domain.RunUpdateChunk{}, err
|
|
}
|
|
if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != installation.Checksum {
|
|
return domain.RunUpdateChunk{}, validationError("client-manager artifact is unavailable or checksum changed")
|
|
}
|
|
payload, err := svc.artifactPayload(artifact.ID)
|
|
if err != nil {
|
|
return domain.RunUpdateChunk{}, err
|
|
}
|
|
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum || request.Offset >= artifact.SizeBytes {
|
|
return domain.RunUpdateChunk{}, validationError("client-manager artifact content or offset is invalid")
|
|
}
|
|
length := request.Length
|
|
if remaining := artifact.SizeBytes - request.Offset; int64(length) > remaining {
|
|
length = int(remaining)
|
|
}
|
|
end := request.Offset + int64(length)
|
|
return domain.CopyRunUpdateChunk(domain.RunUpdateChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):int(end)], Complete: end == artifact.SizeBytes}), nil
|
|
}
|
|
|
|
func isClientManagerJobCapability(capability string) bool {
|
|
switch capability {
|
|
case domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (svc *CoreService) projectClientManagerLifecycleProgress(job domain.Job, stamp time.Time) error {
|
|
if !isClientManagerJobCapability(job.Capability) {
|
|
return nil
|
|
}
|
|
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: job.ServerInstanceID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, installation := range installations {
|
|
if installation.CurrentJobID != job.ID {
|
|
continue
|
|
}
|
|
installation.Phase = safeClientManagerLifecycleMessage(job.Progress.Message, "lifecycle job running")
|
|
installation.UpdatedAt = stamp
|
|
return svc.store.ClientManagerInstallations().Update(installation)
|
|
}
|
|
return repo.ErrNotFound
|
|
}
|
|
|
|
func (svc *CoreService) projectClientManagerLifecycleResult(job domain.Job, stamp time.Time) error {
|
|
if !isClientManagerJobCapability(job.Capability) {
|
|
return nil
|
|
}
|
|
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: job.ServerInstanceID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, installation := range installations {
|
|
if installation.CurrentJobID != job.ID {
|
|
continue
|
|
}
|
|
installation.Phase = safeClientManagerLifecycleMessage(job.Progress.Message, "lifecycle job finished")
|
|
installation.UpdatedAt = stamp
|
|
installation.Retryable = false
|
|
success := job.State == domain.JobStateSucceeded
|
|
if !success {
|
|
installation.Retryable = job.State == domain.JobStateFailed
|
|
installation.HealthReason = "client-manager " + string(installation.LastOperation) + " did not complete"
|
|
if installation.LastOperation == domain.ClientManagerOperationUpdate && job.ExecutionResult.Kind == "client-manager.rollback.restored" {
|
|
installation.Status = domain.ClientManagerLifecycleDegraded
|
|
installation.Phase = "update failed; previous deployment restored"
|
|
installation.Health = domain.ClientManagerHealthDegraded
|
|
} else if job.State == domain.JobStateCancelled && installation.ActiveArtifactID != "" {
|
|
installation.Status = domain.ClientManagerLifecycleOffline
|
|
installation.Health = domain.ClientManagerHealthOffline
|
|
} else {
|
|
installation.Status = domain.ClientManagerLifecycleFailed
|
|
installation.Health = domain.ClientManagerHealthUnhealthy
|
|
}
|
|
} else {
|
|
switch installation.LastOperation {
|
|
case domain.ClientManagerOperationDeploy:
|
|
installation.ActiveArtifactID = installation.DesiredArtifactID
|
|
installation.ActiveVersion = installation.DesiredVersion
|
|
installation.ActiveRevision = installation.DesiredRevision
|
|
installation.Status = domain.ClientManagerLifecycleRegistering
|
|
installation.Phase = "deployed; component registration pending"
|
|
installation.InstalledAt = stamp
|
|
installation.Health = domain.ClientManagerHealthUnknown
|
|
installation.HealthReason = "awaiting component heartbeat"
|
|
installation.LastSeenAt = time.Time{}
|
|
installation.LastHeartbeatSequence = 0
|
|
case domain.ClientManagerOperationStart, domain.ClientManagerOperationRestart:
|
|
installation.Status = domain.ClientManagerLifecycleRegistering
|
|
installation.Phase = "process started; component registration pending"
|
|
installation.Health = domain.ClientManagerHealthUnknown
|
|
installation.HealthReason = "awaiting component heartbeat"
|
|
installation.LastSeenAt = time.Time{}
|
|
installation.LastHeartbeatSequence = 0
|
|
case domain.ClientManagerOperationStop:
|
|
installation.Status = domain.ClientManagerLifecycleOffline
|
|
installation.Phase = "process stopped"
|
|
installation.Health = domain.ClientManagerHealthOffline
|
|
installation.HealthReason = "stopped by operator"
|
|
case domain.ClientManagerOperationStatus:
|
|
if job.ExecutionResult.ProcessState == "running" {
|
|
installation.Status = domain.ClientManagerLifecycleRegistering
|
|
installation.Phase = "process running; heartbeat pending"
|
|
} else {
|
|
installation.Status = domain.ClientManagerLifecycleOffline
|
|
installation.Health = domain.ClientManagerHealthOffline
|
|
installation.Phase = "process is not running"
|
|
}
|
|
case domain.ClientManagerOperationUpdate:
|
|
installation.PreviousArtifactID = installation.ActiveArtifactID
|
|
installation.PreviousVersion = installation.ActiveVersion
|
|
installation.PreviousRevision = installation.ActiveRevision
|
|
installation.ActiveArtifactID = installation.DesiredArtifactID
|
|
installation.ActiveVersion = installation.DesiredVersion
|
|
installation.ActiveRevision = installation.DesiredRevision
|
|
installation.Status = domain.ClientManagerLifecycleRegistering
|
|
installation.Phase = "update activated; health registration pending"
|
|
installation.Health = domain.ClientManagerHealthUnknown
|
|
installation.HealthReason = "awaiting updated component heartbeat"
|
|
installation.LastSeenAt = time.Time{}
|
|
installation.LastHeartbeatSequence = 0
|
|
case domain.ClientManagerOperationRollback:
|
|
oldArtifact, oldVersion, oldRevision := installation.ActiveArtifactID, installation.ActiveVersion, installation.ActiveRevision
|
|
installation.ActiveArtifactID, installation.ActiveVersion, installation.ActiveRevision = installation.DesiredArtifactID, installation.DesiredVersion, installation.DesiredRevision
|
|
installation.PreviousArtifactID, installation.PreviousVersion, installation.PreviousRevision = oldArtifact, oldVersion, oldRevision
|
|
installation.Status = domain.ClientManagerLifecycleRegistering
|
|
installation.Phase = "rollback activated; health registration pending"
|
|
installation.Health = domain.ClientManagerHealthUnknown
|
|
installation.HealthReason = "awaiting rolled-back component heartbeat"
|
|
installation.LastSeenAt = time.Time{}
|
|
installation.LastHeartbeatSequence = 0
|
|
case domain.ClientManagerOperationUninstall:
|
|
installation.Status = domain.ClientManagerLifecycleUninstalled
|
|
installation.Phase = "managed workspace removed"
|
|
installation.Health = domain.ClientManagerHealthOffline
|
|
installation.HealthReason = "uninstalled"
|
|
installation.ActiveArtifactID = ""
|
|
installation.ActiveVersion = ""
|
|
installation.ActiveRevision = ""
|
|
installation.PreviousArtifactID = ""
|
|
installation.PreviousVersion = ""
|
|
installation.PreviousRevision = ""
|
|
installation.UninstalledAt = stamp
|
|
}
|
|
installation.LastSuccessfulJobID = job.ID
|
|
}
|
|
installation.CurrentJobID = job.ID
|
|
if err := validator.ValidateClientManagerInstallation(installation); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
return repo.ErrNotFound
|
|
}
|
|
|
|
func safeClientManagerLifecycleMessage(value, fallback string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
if len(value) > 160 || strings.Contains(strings.ToLower(value), "secret://") || strings.Contains(strings.ToLower(value), "bearer ") || strings.Contains(strings.ToLower(value), "password=") || strings.Contains(strings.ToLower(value), "token=") || strings.Contains(value, "tcp://") || strings.Contains(value, "unix://") || strings.Contains(value, "/Users/") {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (svc *CoreService) clientManagerLifecycleView(installation domain.ClientManagerInstallation) (domain.ClientManagerLifecycleView, error) {
|
|
view := domain.ClientManagerLifecycleView{Installation: domain.CopyClientManagerInstallation(installation)}
|
|
if installation.DesiredArtifactID != "" {
|
|
distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey})
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
for _, distribution := range distributions {
|
|
if distribution.ArtifactID == installation.DesiredArtifactID {
|
|
view.Distribution = distribution
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if installation.CurrentJobID != "" {
|
|
job, err := svc.store.Jobs().Get(installation.CurrentJobID)
|
|
if err == nil {
|
|
view.Job = job
|
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(installation.PluginID)
|
|
if err != nil {
|
|
return domain.ClientManagerLifecycleView{}, err
|
|
}
|
|
profile, _ := findRuntimeClientManagerProfile(plugin, installation.ProfileKey)
|
|
endpoint, endpointErr := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
|
view.Actions = clientManagerLifecycleActions(installation, view.Distribution, profile, endpoint, endpointErr)
|
|
return domain.CopyClientManagerLifecycleView(view), nil
|
|
}
|
|
|
|
func clientManagerLifecycleActions(installation domain.ClientManagerInstallation, distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile, endpoint domain.RunEndpoint, endpointErr error) []domain.ClientManagerLifecycleActionAvailability {
|
|
operations := []domain.ClientManagerLifecycleOperation{domain.ClientManagerOperationDeploy, domain.ClientManagerOperationStart, domain.ClientManagerOperationStop, domain.ClientManagerOperationRestart, domain.ClientManagerOperationStatus, domain.ClientManagerOperationUpdate, domain.ClientManagerOperationRollback, domain.ClientManagerOperationUninstall}
|
|
items := make([]domain.ClientManagerLifecycleActionAvailability, 0, len(operations))
|
|
for _, operation := range operations {
|
|
capability := domain.JobCapabilityClientManagerControl
|
|
switch operation {
|
|
case domain.ClientManagerOperationDeploy:
|
|
capability = domain.JobCapabilityClientManagerDeploy
|
|
case domain.ClientManagerOperationUpdate:
|
|
capability = domain.JobCapabilityClientManagerUpdate
|
|
case domain.ClientManagerOperationRollback:
|
|
capability = domain.JobCapabilityClientManagerRollback
|
|
case domain.ClientManagerOperationUninstall:
|
|
capability = domain.JobCapabilityClientManagerUninstall
|
|
}
|
|
available := endpointErr == nil && endpoint.Status == domain.RunEndpointStatusOnline && containsString(endpoint.Capabilities, capability) && containsString(profile.Deployment.RequiredRunCapabilities, capability)
|
|
reason := ""
|
|
if !available {
|
|
reason = "assigned Run endpoint is offline or lacks the declared capability"
|
|
}
|
|
if operation != domain.ClientManagerOperationDeploy && !containsString(profile.Lifecycle.Actions, string(operation)) {
|
|
available = false
|
|
reason = "client-manager profile does not declare this action"
|
|
}
|
|
if installation.RequiresRedeploy && operation != domain.ClientManagerOperationDeploy && operation != domain.ClientManagerOperationUninstall {
|
|
available = false
|
|
reason = "component key changed; rebuild and redeploy is required"
|
|
}
|
|
if operation == domain.ClientManagerOperationDeploy && (installation.DesiredArtifactID == "" || distribution.Status != domain.DistributionStatusAvailable) {
|
|
available = false
|
|
reason = "no available client-manager build artifact is ready"
|
|
}
|
|
if operation == domain.ClientManagerOperationRollback && installation.PreviousArtifactID == "" {
|
|
available = false
|
|
reason = "no previous deployment is retained"
|
|
}
|
|
if operation != domain.ClientManagerOperationDeploy && operation != domain.ClientManagerOperationUninstall && installation.ActiveArtifactID == "" {
|
|
available = false
|
|
reason = "client manager is not installed"
|
|
}
|
|
if installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping {
|
|
available = false
|
|
reason = "another lifecycle operation is in progress"
|
|
}
|
|
items = append(items, domain.ClientManagerLifecycleActionAvailability{Operation: operation, Available: available, Reason: reason})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func (svc *CoreService) clientManagerRuntimeActionProjection(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint, bindingsComplete bool, bindingReason string) []domain.ServerRuntimeAction {
|
|
profiles := plugin.RuntimeProfiles.ClientManagers
|
|
hasLifecycle := false
|
|
for _, profile := range profiles {
|
|
if profile.Deployment.Mode == "run-supervised" {
|
|
hasLifecycle = true
|
|
break
|
|
}
|
|
}
|
|
installations, _ := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
|
|
hasInstalled := false
|
|
hasPrevious := false
|
|
hasUpdateCandidate := false
|
|
for _, installation := range installations {
|
|
hasInstalled = hasInstalled || installation.ActiveArtifactID != "" && installation.Status != domain.ClientManagerLifecycleUninstalled
|
|
hasPrevious = hasPrevious || installation.PreviousArtifactID != ""
|
|
hasUpdateCandidate = hasUpdateCandidate || installation.DesiredArtifactID != "" && installation.DesiredArtifactID != installation.ActiveArtifactID
|
|
}
|
|
baseReason := fallbackReason(!hasLifecycle || !bindingsComplete, "plugin does not declare a complete Client Manager lifecycle", bindingReason)
|
|
return []domain.ServerRuntimeAction{
|
|
runtimeAction("deploy-client-manager", "Deploy client manager", hasLifecycle && bindingsComplete && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerDeploy), fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerDeploy), "run endpoint cannot deploy client managers", baseReason)),
|
|
runtimeAction("control-client-manager", "Control client manager", hasLifecycle && hasInstalled && bindingsComplete && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerControl), fallbackReason(!hasInstalled || !svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerControl), "client manager is not installed or endpoint cannot control it", baseReason)),
|
|
runtimeAction("update-client-manager", "Update client manager", hasLifecycle && hasInstalled && hasUpdateCandidate && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerUpdate), "no compatible update candidate is available"),
|
|
runtimeAction("rollback-client-manager", "Rollback client manager", hasLifecycle && hasPrevious && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerRollback), "no retained previous deployment is available"),
|
|
runtimeAction("uninstall-client-manager", "Uninstall client manager", hasLifecycle && hasInstalled && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerUninstall), "client manager is not installed or endpoint cannot uninstall it"),
|
|
}
|
|
}
|
|
|
|
func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error) {
|
|
request = domain.CopyClientManagerRegisterRequest(request)
|
|
if err := validator.ValidateClientManagerRegisterRequest(request); err != nil {
|
|
return domain.ClientManagerRegisterResult{}, err
|
|
}
|
|
stamp := svc.now()
|
|
if request.Timestamp.Before(stamp.Add(-clientManagerRegistrationWindow)) || request.Timestamp.After(stamp.Add(clientManagerRegistrationWindow)) {
|
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
|
}
|
|
installation, err := svc.store.ClientManagerInstallations().Get(request.InstallationID)
|
|
if err != nil {
|
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
|
}
|
|
if installation.ServerInstanceID != request.ServerInstanceID || installation.ProfileKey != request.ProfileKey || installation.ActiveArtifactID != request.ArtifactID || installation.ActiveVersion != request.Version || installation.ActiveRevision != request.SourceRevision || installation.TargetOS != request.TargetOS || installation.TargetArch != request.TargetArch || installation.KeyGeneration != request.KeyGeneration || installation.DeploymentGeneration != request.DeploymentGeneration || installation.RequiresRedeploy || installation.Status == domain.ClientManagerLifecycleUninstalled {
|
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
|
if err != nil || instance.RunEndpointID != installation.RunEndpointID {
|
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(installation.PluginID)
|
|
if err != nil {
|
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
|
}
|
|
profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey)
|
|
if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, request.Capabilities) {
|
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
|
}
|
|
key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey)
|
|
if err != nil || key.Generation != request.KeyGeneration {
|
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
|
}
|
|
plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
|
|
if err != nil {
|
|
return domain.ClientManagerRegisterResult{}, err
|
|
}
|
|
expected := clientManagerRegistrationSignature(plainKey, request)
|
|
if subtle.ConstantTimeCompare([]byte(expected), []byte(request.Signature)) != 1 {
|
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
|
}
|
|
nonceID := clientManagerNonceID(request.InstallationID, request.Nonce)
|
|
if _, err := svc.store.ClientManagerNonces().Get(nonceID); err == nil {
|
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.ClientManagerRegisterResult{}, err
|
|
}
|
|
nonce := domain.ClientManagerRegistrationNonce{ID: nonceID, InstallationID: installation.ID, ExpiresAt: stamp.Add(clientManagerRegistrationWindow), CreatedAt: stamp}
|
|
if err := validator.ValidateClientManagerNonce(nonce); err != nil {
|
|
return domain.ClientManagerRegisterResult{}, err
|
|
}
|
|
if err := svc.store.ClientManagerNonces().Create(nonce); err != nil {
|
|
return domain.ClientManagerRegisterResult{}, ErrUnauthorized
|
|
}
|
|
if err := svc.revokeClientManagerSessions(installation.ID, "component registered a replacement session"); err != nil {
|
|
return domain.ClientManagerRegisterResult{}, err
|
|
}
|
|
token, err := randomToken()
|
|
if err != nil {
|
|
return domain.ClientManagerRegisterResult{}, err
|
|
}
|
|
session := domain.ClientManagerSession{ID: clientManagerSessionID(installation.ID, installation.DeploymentGeneration, token), InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: domain.CopyStringSlice(request.Capabilities), Status: domain.ClientManagerSessionActive, LastSeenAt: stamp, ExpiresAt: stamp.Add(clientManagerSessionTTL), CreatedAt: stamp, UpdatedAt: stamp}
|
|
if err := validator.ValidateClientManagerSession(session); err != nil {
|
|
return domain.ClientManagerRegisterResult{}, err
|
|
}
|
|
if err := svc.store.ClientManagerSessions().Create(session); err != nil {
|
|
return domain.ClientManagerRegisterResult{}, err
|
|
}
|
|
installation.Status = domain.ClientManagerLifecycleOnline
|
|
installation.Phase = "component registered"
|
|
installation.Health = domain.ClientManagerHealthHealthy
|
|
installation.HealthReason = "authenticated component session active"
|
|
installation.LastSeenAt = stamp
|
|
installation.LastHeartbeatSequence = 0
|
|
installation.UpdatedAt = stamp
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return domain.ClientManagerRegisterResult{}, err
|
|
}
|
|
return domain.ClientManagerRegisterResult{Accepted: true, InstallationID: installation.ID, SessionToken: token, ExpiresAt: session.ExpiresAt, HeartbeatEvery: profile.Health.IntervalSeconds, ServerTime: stamp}, nil
|
|
}
|
|
|
|
func (svc *CoreService) AcceptClientManagerHeartbeat(heartbeat domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error) {
|
|
heartbeat = domain.CopyClientManagerHeartbeat(heartbeat)
|
|
if err := validator.ValidateClientManagerHeartbeat(heartbeat); err != nil {
|
|
return domain.ClientManagerHeartbeatResult{}, err
|
|
}
|
|
stamp := svc.now()
|
|
installation, err := svc.store.ClientManagerInstallations().Get(heartbeat.InstallationID)
|
|
if err != nil {
|
|
return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized
|
|
}
|
|
sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
|
|
if err != nil {
|
|
return domain.ClientManagerHeartbeatResult{}, err
|
|
}
|
|
var session domain.ClientManagerSession
|
|
for _, candidate := range sessions {
|
|
if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(tokenHash(heartbeat.SessionToken))) == 1 {
|
|
session = candidate
|
|
break
|
|
}
|
|
}
|
|
if session.ID == "" || !stamp.Before(session.ExpiresAt) || session.KeyGeneration != installation.KeyGeneration || session.DeploymentGeneration != installation.DeploymentGeneration || session.ArtifactID != installation.ActiveArtifactID || session.RunEndpointID != installation.RunEndpointID || heartbeat.Sequence <= session.LastHeartbeatSequence {
|
|
return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(installation.PluginID)
|
|
if err != nil {
|
|
return domain.ClientManagerHeartbeatResult{}, err
|
|
}
|
|
profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey)
|
|
if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, heartbeat.Capabilities) || !clientManagerCapabilitiesMatch(session.Capabilities, heartbeat.Capabilities) {
|
|
return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized
|
|
}
|
|
session.LastHeartbeatSequence = heartbeat.Sequence
|
|
session.LastSeenAt = stamp
|
|
session.UpdatedAt = stamp
|
|
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
|
return domain.ClientManagerHeartbeatResult{}, err
|
|
}
|
|
installation.LastHeartbeatSequence = heartbeat.Sequence
|
|
installation.LastSeenAt = stamp
|
|
installation.Health = heartbeat.Health
|
|
installation.HealthReason = safeClientManagerLifecycleMessage(heartbeat.HealthReason, "component health reported")
|
|
installation.Status = domain.ClientManagerLifecycleOnline
|
|
if heartbeat.Health == domain.ClientManagerHealthDegraded || heartbeat.Health == domain.ClientManagerHealthUnhealthy {
|
|
installation.Status = domain.ClientManagerLifecycleDegraded
|
|
}
|
|
installation.Phase = "component heartbeat accepted"
|
|
installation.UpdatedAt = stamp
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return domain.ClientManagerHeartbeatResult{}, err
|
|
}
|
|
return domain.ClientManagerHeartbeatResult{Accepted: true, InstallationID: installation.ID, Status: installation.Status, Health: installation.Health, NextHeartbeat: profile.Health.IntervalSeconds, SessionExpiresAt: session.ExpiresAt, ServerTime: stamp}, nil
|
|
}
|
|
|
|
func clientManagerRegistrationSignature(key string, request domain.ClientManagerRegisterRequest) string {
|
|
capabilities := domain.CopyStringSlice(request.Capabilities)
|
|
sort.Strings(capabilities)
|
|
canonical := strings.Join([]string{request.InstallationID, request.ServerInstanceID, request.ProfileKey, request.ArtifactID, request.Version, request.SourceRevision, request.TargetOS, request.TargetArch, strconv.Itoa(request.KeyGeneration), strconv.Itoa(request.DeploymentGeneration), request.Timestamp.UTC().Format(time.RFC3339Nano), request.Nonce, strings.Join(capabilities, ",")}, "\n")
|
|
mac := hmac.New(sha256.New, []byte(key))
|
|
_, _ = mac.Write([]byte(canonical))
|
|
return "sha256:" + hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func clientManagerNonceID(installationID, nonce string) string {
|
|
sum := sha256.Sum256([]byte(installationID + "\x00" + nonce))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func clientManagerSessionID(installationID string, generation int, token string) string {
|
|
sum := sha256.Sum256([]byte(installationID + "\x00" + strconv.Itoa(generation) + "\x00" + token))
|
|
return "cm-session-" + hex.EncodeToString(sum[:12])
|
|
}
|
|
|
|
func clientManagerCapabilitiesMatch(required, actual []string) bool {
|
|
actualSet := map[string]struct{}{}
|
|
for _, capability := range actual {
|
|
switch capability {
|
|
case "component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream":
|
|
actualSet[capability] = struct{}{}
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
for _, capability := range required {
|
|
if _, ok := actualSet[capability]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (svc *CoreService) revokeClientManagerSessions(installationID, reason string) error {
|
|
sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installationID, Status: domain.ClientManagerSessionActive})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stamp := svc.now()
|
|
for _, session := range sessions {
|
|
session.Status = domain.ClientManagerSessionRevoked
|
|
session.RevokedAt = stamp
|
|
session.UpdatedAt = stamp
|
|
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_ = reason
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) ReconcileClientManagerLifecycle() error {
|
|
stamp := svc.now()
|
|
nonces, err := svc.store.ClientManagerNonces().List(domain.ClientManagerNonceFilter{ExpiresBefore: stamp})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, nonce := range nonces {
|
|
if err := svc.store.ClientManagerNonces().Delete(nonce.ID); err != nil && !errors.Is(err, repo.ErrNotFound) {
|
|
return err
|
|
}
|
|
}
|
|
sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, session := range sessions {
|
|
if session.Status == domain.ClientManagerSessionActive && !stamp.Before(session.ExpiresAt) {
|
|
session.Status = domain.ClientManagerSessionExpired
|
|
session.UpdatedAt = stamp
|
|
session.RevokedAt = stamp
|
|
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, installation := range installations {
|
|
changed := false
|
|
if installation.CurrentJobID != "" {
|
|
job, jobErr := svc.store.Jobs().Get(installation.CurrentJobID)
|
|
if jobErr == nil && isTerminalJobState(job.State) && installation.UpdatedAt.Before(job.UpdatedAt) {
|
|
if err := svc.projectClientManagerLifecycleResult(job, job.UpdatedAt); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
instance, instanceErr := svc.store.ServerInstances().Get(installation.ServerInstanceID)
|
|
if instanceErr == nil && installation.RunEndpointID != instance.RunEndpointID && installation.Status != domain.ClientManagerLifecycleUninstalled {
|
|
if err := svc.revokeClientManagerSessions(installation.ID, "Run endpoint assignment changed"); err != nil {
|
|
return err
|
|
}
|
|
installation.Status = domain.ClientManagerLifecycleFailed
|
|
installation.Health = domain.ClientManagerHealthOffline
|
|
installation.HealthReason = "assigned Run endpoint changed; redeploy required"
|
|
installation.RequiresRedeploy = true
|
|
changed = true
|
|
}
|
|
if key, keyErr := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey); keyErr == nil && installation.KeyGeneration > 0 && key.Generation != installation.KeyGeneration && installation.Status != domain.ClientManagerLifecycleUninstalled {
|
|
if err := svc.revokeClientManagerSessions(installation.ID, "component key generation changed"); err != nil {
|
|
return err
|
|
}
|
|
installation.Status = domain.ClientManagerLifecycleFailed
|
|
installation.Health = domain.ClientManagerHealthOffline
|
|
installation.HealthReason = "component key changed; rebuild and redeploy required"
|
|
installation.RequiresRedeploy = true
|
|
changed = true
|
|
}
|
|
if installation.ActiveArtifactID != "" && !installation.LastSeenAt.IsZero() {
|
|
plugin, pluginErr := svc.store.GamePlugins().Get(installation.PluginID)
|
|
profile, profileErr := findRuntimeClientManagerProfile(plugin, installation.ProfileKey)
|
|
if pluginErr == nil && profileErr == nil {
|
|
age := stamp.Sub(installation.LastSeenAt)
|
|
if age >= time.Duration(profile.Health.OfflineAfterSeconds)*time.Second && installation.Status != domain.ClientManagerLifecycleOffline && installation.Status != domain.ClientManagerLifecycleUninstalled {
|
|
installation.Status = domain.ClientManagerLifecycleOffline
|
|
installation.Health = domain.ClientManagerHealthOffline
|
|
installation.HealthReason = "component heartbeat timed out"
|
|
changed = true
|
|
} else if age >= time.Duration(profile.Health.DegradedAfterSeconds)*time.Second && installation.Status == domain.ClientManagerLifecycleOnline {
|
|
installation.Status = domain.ClientManagerLifecycleDegraded
|
|
installation.Health = domain.ClientManagerHealthDegraded
|
|
installation.HealthReason = "component heartbeat is late"
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
if changed {
|
|
installation.UpdatedAt = stamp
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) fenceClientManagerAfterKeyReset(serverInstanceID, profileKey string, generation int) error {
|
|
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, installation := range installations {
|
|
if err := svc.revokeClientManagerSessions(installation.ID, "component key reset"); err != nil {
|
|
return err
|
|
}
|
|
installation.Status = domain.ClientManagerLifecycleFailed
|
|
installation.Phase = "key reset; rebuild and redeploy required"
|
|
installation.Health = domain.ClientManagerHealthOffline
|
|
installation.HealthReason = "component key generation changed"
|
|
installation.RequiresRedeploy = true
|
|
installation.KeyGeneration = generation
|
|
installation.UpdatedAt = svc.now()
|
|
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) resolveClientManagerDistributionID(serverInstanceID, profileKey, artifactOrDistributionID string) (string, error) {
|
|
if distribution, err := svc.store.ClientManagerDistributions().Get(artifactOrDistributionID); err == nil {
|
|
if distribution.ServerInstanceID == serverInstanceID && distribution.ProfileKey == profileKey {
|
|
return distribution.ID, nil
|
|
}
|
|
return "", ErrForbidden
|
|
}
|
|
distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, distribution := range distributions {
|
|
if distribution.ArtifactID == artifactOrDistributionID {
|
|
return distribution.ID, nil
|
|
}
|
|
}
|
|
return "", repo.ErrNotFound
|
|
}
|
|
|
|
func safeClientManagerBridgeResult(view domain.ClientManagerLifecycleView) map[string]string {
|
|
installation := view.Installation
|
|
actions := make([]string, 0, len(view.Actions))
|
|
for _, action := range view.Actions {
|
|
if action.Available {
|
|
actions = append(actions, string(action.Operation))
|
|
}
|
|
}
|
|
sort.Strings(actions)
|
|
result := map[string]string{
|
|
"installationId": installation.ID,
|
|
"profileKey": installation.ProfileKey,
|
|
"status": string(installation.Status),
|
|
"phase": installation.Phase,
|
|
"targetOS": installation.TargetOS,
|
|
"targetArch": installation.TargetArch,
|
|
"version": installation.ActiveVersion,
|
|
"previousVersion": installation.PreviousVersion,
|
|
"artifactId": installation.ActiveArtifactID,
|
|
"currentJobId": installation.CurrentJobID,
|
|
"deploymentGeneration": strconv.Itoa(installation.DeploymentGeneration),
|
|
"health": string(installation.Health),
|
|
"healthReason": installation.HealthReason,
|
|
"actions": strings.Join(actions, ","),
|
|
}
|
|
if !installation.LastSeenAt.IsZero() {
|
|
result["lastSeenAt"] = installation.LastSeenAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return result
|
|
}
|