Files
browser/platform/service/client_manager_lifecycle.go
T

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.GetRunEndpoint(instance.RunEndpointID)
if err != nil {
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
}
if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil {
return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err
}
return user, instance, plugin, profile, endpoint, nil
}
func (svc *CoreService) authorizedClientManagerDistribution(instance domain.ServerInstance, profile domain.RuntimeClientManagerProfile, distributionID string) (domain.ClientManagerDistribution, error) {
distribution, err := svc.store.ClientManagerDistributions().Get(distributionID)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
if distribution.ServerInstanceID != instance.ID || distribution.PluginID != instance.PluginID || distribution.ProfileKey != profile.Key || distribution.Status != domain.DistributionStatusAvailable || !clientManagerProfileSupportsTarget(profile, distribution.TargetOS, distribution.TargetArch) || !clientManagerProfileAllowsRevision(profile, distribution.SourceRevision) {
return domain.ClientManagerDistribution{}, validationError("client-manager distribution is outside the lifecycle scope")
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
if distribution.TargetOS != endpoint.Platform || distribution.TargetArch != endpoint.Architecture {
return domain.ClientManagerDistribution{}, validationError("client-manager distribution target does not match assigned Run endpoint")
}
key, err := svc.activeComponentKey(instance.ID, domain.DistributionComponentClientManager, profile.Key)
if err != nil || key.Generation != distribution.KeyGeneration {
return domain.ClientManagerDistribution{}, validationError("client-manager distribution key generation is no longer current")
}
artifact, err := svc.store.Artifacts().Get(distribution.ArtifactID)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != distribution.Checksum || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID {
return domain.ClientManagerDistribution{}, validationError("client-manager distribution artifact is unavailable or outside its build job")
}
return domain.CopyClientManagerDistribution(distribution), nil
}
func findRuntimeClientManagerProfile(plugin domain.GamePlugin, profileKey string) (domain.RuntimeClientManagerProfile, error) {
plugin = domain.CopyGamePlugin(plugin)
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
if profile.Key == strings.TrimSpace(profileKey) {
return profile, nil
}
}
return domain.RuntimeClientManagerProfile{}, repo.ErrNotFound
}
func clientManagerCompanionConfigInput(plugin domain.GamePlugin, profile domain.RuntimeClientManagerProfile, installation domain.ClientManagerInstallation, artifactID, version, revision string) (*domain.ClientManagerCompanionConfigInput, error) {
companion := plugin.GameClientBridge.Companion
if companion == (domain.GameClientBridgeCompanionDeclaration{}) {
return nil, nil
}
if companion.ProfileKey == "" {
return nil, validationError("client-manager companion declaration is incomplete")
}
if companion.ProfileKey != installation.ProfileKey {
return nil, nil
}
var configTemplate domain.RuntimeConfigTemplate
found := false
for _, candidate := range profile.ConfigTemplates {
if candidate.Key == companion.ConfigTemplateKey {
configTemplate = candidate
found = true
break
}
}
if !found || strings.TrimSpace(configTemplate.TemplateRef) == "" || configTemplate.OutputRef != "config.yaml" {
return nil, validationError("client-manager companion config template is unavailable")
}
config := domain.ClientManagerCompanionConfigInput{
SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion,
ConfigTemplateKey: companion.ConfigTemplateKey,
ConfigTemplateRef: configTemplate.TemplateRef,
ConfigOutputRef: configTemplate.OutputRef,
ConfigSchemaRef: companion.ConfigSchemaRef,
ConfigFormat: companion.ConfigFormat,
PlatformBaseURLSource: companion.PlatformBaseURLSource,
InstallationID: installation.ID,
ServerInstanceID: installation.ServerInstanceID,
PluginID: plugin.ID,
ProfileKey: installation.ProfileKey,
ArtifactID: artifactID,
Version: version,
SourceRevision: revision,
TargetOS: installation.TargetOS,
TargetArch: installation.TargetArch,
KeyGeneration: installation.KeyGeneration,
DeploymentGeneration: installation.DeploymentGeneration,
Capabilities: domain.CopyStringSlice(profile.Health.RequiredCapabilities),
RegistrationProof: companion.RegistrationProof,
ProofMaterialSource: companion.ProofMaterialSource,
ProofMaterialEnv: companion.ProofMaterialEnv,
SessionMode: companion.SessionMode,
TLSPolicy: companion.TLSPolicy,
HeartbeatIntervalSeconds: companion.HeartbeatIntervalSeconds,
CommandPollIntervalSeconds: companion.CommandPollIntervalSeconds,
RequestTimeoutSeconds: companion.RequestTimeoutSeconds,
}
if err := validator.ValidateClientManagerCompanionConfigInput(config); err != nil {
return nil, err
}
return &config, nil
}
func clientManagerProfileSupportsTarget(profile domain.RuntimeClientManagerProfile, targetOS, targetArch string) bool {
for _, target := range profile.SupportedTargets {
if target.OS == targetOS && target.Arch == targetArch {
return true
}
}
return false
}
func clientManagerProfileRevision(profile domain.RuntimeClientManagerProfile) string {
switch profile.RevisionPolicy {
case "pinned":
return profile.Revision
case "tag":
return profile.Tag
default:
return profile.Branch
}
}
func clientManagerProfileAllowsRevision(profile domain.RuntimeClientManagerProfile, revision string) bool {
revision = strings.TrimSpace(revision)
if revision == "" {
return false
}
switch profile.RevisionPolicy {
case "pinned":
return revision == profile.Revision
case "tag":
return revision == profile.Tag || revision == profile.Revision
case "branch":
return revision == profile.Branch || profile.Revision != "" && revision == profile.Revision
default:
return false
}
}
func clientManagerDistributionVersion(distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile) string {
if strings.TrimSpace(distribution.Version) != "" {
return distribution.Version
}
return profile.Version
}
func (svc *CoreService) ensureClientManagerInstallationFromDistribution(instance domain.ServerInstance, plugin domain.GamePlugin, distribution domain.ClientManagerDistribution) (domain.ClientManagerInstallation, error) {
installationID := distributionID("client-manager-installation", instance.ID, distribution.ProfileKey)
installation, err := svc.store.ClientManagerInstallations().Get(installationID)
if err == nil {
return installation, nil
}
if !errors.Is(err, repo.ErrNotFound) {
return domain.ClientManagerInstallation{}, err
}
status := domain.ClientManagerLifecycleBuilding
phase := "build in progress"
if distribution.Status == domain.DistributionStatusAvailable {
status = domain.ClientManagerLifecycleAvailable
phase = "artifact available"
} else if distribution.Status == domain.DistributionStatusFailed || distribution.Status == domain.DistributionStatusRevoked {
status = domain.ClientManagerLifecycleFailed
phase = "build unavailable"
}
stamp := svc.now()
installation = domain.ClientManagerInstallation{ID: installationID, ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: distribution.ProfileKey, RunEndpointID: instance.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, Status: status, Phase: phase, DesiredVersion: distribution.Version, DesiredRevision: distribution.SourceRevision, DesiredArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, KeyGeneration: distribution.KeyGeneration, Health: domain.ClientManagerHealthUnknown, HealthReason: "component is not installed", CreatedAt: stamp, UpdatedAt: stamp}
if err := validator.ValidateClientManagerInstallation(installation); err != nil {
return domain.ClientManagerInstallation{}, err
}
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
if errors.Is(err, repo.ErrDuplicate) {
return svc.store.ClientManagerInstallations().Get(installation.ID)
}
return domain.ClientManagerInstallation{}, err
}
return installation, nil
}
func (svc *CoreService) ProjectClientManagerDistribution(distribution domain.ClientManagerDistribution) error {
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 = svc.clientManagerLifecycleActions(installation, view.Distribution, profile, endpoint, endpointErr)
return domain.CopyClientManagerLifecycleView(view), nil
}
func (svc *CoreService) clientManagerLifecycleActions(installation domain.ClientManagerInstallation, distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile, endpoint domain.RunEndpoint, endpointErr error) []domain.ClientManagerLifecycleActionAvailability {
operations := []domain.ClientManagerLifecycleOperation{domain.ClientManagerOperationDeploy, domain.ClientManagerOperationStart, domain.ClientManagerOperationStop, domain.ClientManagerOperationRestart, domain.ClientManagerOperationStatus, domain.ClientManagerOperationUpdate, domain.ClientManagerOperationRollback, domain.ClientManagerOperationUninstall}
items := make([]domain.ClientManagerLifecycleActionAvailability, 0, len(operations))
for _, operation := range operations {
capability := domain.JobCapabilityClientManagerControl
switch operation {
case domain.ClientManagerOperationDeploy:
capability = domain.JobCapabilityClientManagerDeploy
case domain.ClientManagerOperationUpdate:
capability = domain.JobCapabilityClientManagerUpdate
case domain.ClientManagerOperationRollback:
capability = domain.JobCapabilityClientManagerRollback
case domain.ClientManagerOperationUninstall:
capability = domain.JobCapabilityClientManagerUninstall
}
available := endpointErr == nil && endpoint.Status == domain.RunEndpointStatusOnline && svc.runEndpointHeartbeatCurrent(endpoint) && containsString(endpoint.Capabilities, capability) && containsString(profile.Deployment.RequiredRunCapabilities, capability)
reason := ""
if !available {
reason = "assigned Run endpoint is offline or lacks the declared capability"
}
if operation != domain.ClientManagerOperationDeploy && !containsString(profile.Lifecycle.Actions, string(operation)) {
available = false
reason = "client-manager profile does not declare this action"
}
if installation.RequiresRedeploy && operation != domain.ClientManagerOperationDeploy && operation != domain.ClientManagerOperationUninstall {
available = false
reason = "component key changed; rebuild and redeploy is required"
}
if operation == domain.ClientManagerOperationDeploy && (installation.DesiredArtifactID == "" || distribution.Status != domain.DistributionStatusAvailable) {
available = false
reason = "no available client-manager build artifact is ready"
}
if operation == domain.ClientManagerOperationRollback && installation.PreviousArtifactID == "" {
available = false
reason = "no previous deployment is retained"
}
if operation != domain.ClientManagerOperationDeploy && operation != domain.ClientManagerOperationUninstall && installation.ActiveArtifactID == "" {
available = false
reason = "client manager is not installed"
}
if installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping {
available = false
reason = "another lifecycle operation is in progress"
}
items = append(items, domain.ClientManagerLifecycleActionAvailability{Operation: operation, Available: available, Reason: reason})
}
return items
}
func (svc *CoreService) clientManagerRuntimeActionProjection(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint, bindingsComplete bool, bindingReason string) []domain.ServerRuntimeAction {
profiles := plugin.RuntimeProfiles.ClientManagers
hasLifecycle := false
for _, profile := range profiles {
if profile.Deployment.Mode == "run-supervised" {
hasLifecycle = true
break
}
}
installations, _ := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
hasInstalled := false
hasPrevious := false
hasUpdateCandidate := false
for _, installation := range installations {
hasInstalled = hasInstalled || installation.ActiveArtifactID != "" && installation.Status != domain.ClientManagerLifecycleUninstalled
hasPrevious = hasPrevious || installation.PreviousArtifactID != ""
hasUpdateCandidate = hasUpdateCandidate || installation.DesiredArtifactID != "" && installation.DesiredArtifactID != installation.ActiveArtifactID
}
baseReason := fallbackReason(!hasLifecycle || !bindingsComplete, "plugin does not declare a complete Client Manager lifecycle", bindingReason)
return []domain.ServerRuntimeAction{
runtimeAction("deploy-client-manager", "Deploy client manager", hasLifecycle && bindingsComplete && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerDeploy), fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerDeploy), "run endpoint cannot deploy client managers", baseReason)),
runtimeAction("control-client-manager", "Control client manager", hasLifecycle && hasInstalled && bindingsComplete && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerControl), fallbackReason(!hasInstalled || !svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerControl), "client manager is not installed or endpoint cannot control it", baseReason)),
runtimeAction("update-client-manager", "Update client manager", hasLifecycle && hasInstalled && hasUpdateCandidate && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerUpdate), "no compatible update candidate is available"),
runtimeAction("rollback-client-manager", "Rollback client manager", hasLifecycle && hasPrevious && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerRollback), "no retained previous deployment is available"),
runtimeAction("uninstall-client-manager", "Uninstall client manager", hasLifecycle && hasInstalled && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerUninstall), "client manager is not installed or endpoint cannot uninstall it"),
}
}
func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error) {
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
}