Files
browser/platform/service/server_lifecycle.go
T

522 lines
21 KiB
Go

package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) {
create = domain.CopyServerLifecycleCreate(create)
if err := validator.ValidateServerLifecycleCreate(create); err != nil {
return domain.ServerLifecycleResult{}, err
}
plugin, err := svc.store.GamePlugins().Get(create.PluginID)
if err != nil {
return domain.ServerLifecycleResult{}, fmt.Errorf("get plugin dependency: %w", err)
}
if plugin.Status != domain.GamePluginStatusInstalled {
return domain.ServerLifecycleResult{}, validationError("plugin must be installed")
}
create.Deployment = applyPluginCreateDefaults(plugin, create.Deployment)
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
return domain.ServerLifecycleResult{}, err
}
if create.Deployment.Mode != "" {
if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, create.Deployment.CreateInputs); err != nil {
return domain.ServerLifecycleResult{}, err
}
}
stamp := svc.now()
instance := domain.ServerInstance{
ID: create.ID,
PluginID: create.PluginID,
PluginVersion: plugin.Version,
RunEndpointID: create.RunEndpointID,
Name: create.Name,
OwnerUserID: create.OwnerUserID,
State: domain.ServerInstanceStateInstalling,
ConfigVersion: 1,
ConfigKey: "server.properties",
CreatedAt: stamp,
UpdatedAt: stamp,
Deployment: create.Deployment,
}
if strings.TrimSpace(create.DeploymentTargetID) != "" {
instance.DeploymentTargetID = create.DeploymentTargetID
instance.RunEndpointID = dedicatedRunEndpointID(create.ID)
instance.State = domain.ServerInstanceStateDraft
}
if instance.Deployment.Mode != "" {
instance.Deployment.ProfileKey = create.ProfileKey
instance.Deployment.RuntimeBindings = domain.CopyStringMap(create.Bindings)
instance.Deployment.Revision = maxInt(1, instance.Deployment.Revision)
instance.Deployment.UpdatedAt = stamp
}
instance.ConfigContent = buildLogicalServerConfig(instance)
instance.ConfigChecksum = validator.BytesChecksum([]byte(instance.ConfigContent))
instance.ConfigUpdatedAt = stamp
if strings.TrimSpace(create.DeploymentTargetID) != "" {
target, targetErr := svc.store.RunEndpoints().Get(create.DeploymentTargetID)
if targetErr != nil {
return domain.ServerLifecycleResult{}, fmt.Errorf("get deployment target dependency: %w", targetErr)
}
if err := svc.validateRunnableEndpoint(target, domain.JobCapabilityDistributionBuild); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validator.ValidateServerInstance(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
var binding domain.RuntimeBinding
if strings.TrimSpace(create.ProfileKey) != "" && deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
var bindingErr error
binding, bindingErr = svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: create.ProfileKey, Bindings: create.Bindings}, true)
if bindingErr != nil {
return domain.ServerLifecycleResult{}, bindingErr
}
}
if err := svc.store.ServerInstances().Create(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
if binding.ID != "" {
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
return domain.ServerLifecycleResult{}, err
}
}
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{Accepted: true, Action: domain.ServerLifecycleActionCreate, Instance: instance}), nil
}
if strings.TrimSpace(create.RunEndpointID) == "" {
instance.State = domain.ServerInstanceStateDraft
}
if err := validator.ValidateServerInstance(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
if strings.TrimSpace(create.RunEndpointID) == "" {
if err := svc.store.ServerInstances().Create(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{Accepted: true, Action: domain.ServerLifecycleActionCreate, Instance: instance}), nil
}
endpoint, err := svc.store.RunEndpoints().Get(create.RunEndpointID)
if err != nil {
return domain.ServerLifecycleResult{}, fmt.Errorf("get run endpoint dependency: %w", err)
}
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
return domain.ServerLifecycleResult{}, err
}
var binding domain.RuntimeBinding
hasBinding := strings.TrimSpace(create.ProfileKey) != "" && deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment)
if hasBinding {
binding, err = svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: create.ProfileKey, Bindings: create.Bindings}, true)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
}
if err := svc.store.ServerInstances().Create(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
if hasBinding {
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
return domain.ServerLifecycleResult{}, err
}
}
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{
Accepted: true,
Action: domain.ServerLifecycleActionCreate,
Instance: instance,
Job: job,
}), nil
}
func dedicatedRunEndpointID(serverInstanceID string) string {
return "server-run-" + serverInstanceID
}
func (svc *CoreService) CreateServerInstanceWorkflowForSession(sessionID string, create domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
if strings.TrimSpace(create.OwnerUserID) == "" {
create.OwnerUserID = user.ID
}
if !isPlatformAdmin(user) && create.OwnerUserID != user.ID {
return domain.ServerLifecycleResult{}, ErrForbidden
}
return svc.CreateServerInstanceWorkflow(create)
}
func (svc *CoreService) StartServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStart, []domain.ServerInstanceState{
domain.ServerInstanceStateReady,
domain.ServerInstanceStateStopped,
})
}
func (svc *CoreService) StartServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
return domain.ServerLifecycleResult{}, err
}
return svc.StartServerInstance(command)
}
func (svc *CoreService) StopServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStop, []domain.ServerInstanceState{
domain.ServerInstanceStateRunning,
})
}
func (svc *CoreService) StopServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
return domain.ServerLifecycleResult{}, err
}
return svc.StopServerInstance(command)
}
func (svc *CoreService) QueryServerInstanceProcessForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
return domain.ServerLifecycleResult{}, err
}
return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStatus, []domain.ServerInstanceState{
domain.ServerInstanceStateReady,
domain.ServerInstanceStateStopped,
domain.ServerInstanceStateRunning,
domain.ServerInstanceStateFailed,
})
}
func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLifecycleCommand, action domain.ServerLifecycleAction, allowedStates []domain.ServerInstanceState) (domain.ServerLifecycleResult, error) {
command = domain.CopyServerLifecycleCommand(command)
if err := validator.ValidateServerLifecycleCommand(command); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validator.ValidateServerLifecycleAction(action); err != nil {
return domain.ServerLifecycleResult{}, err
}
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
if instance.ConfigVersion != command.ExpectedConfigVersion {
return domain.ServerLifecycleResult{}, validationError("expectedConfigVersion must match server instance")
}
if !serverStateAllowed(instance.State, allowedStates) {
return domain.ServerLifecycleResult{}, validationError(fmt.Sprintf("server instance state %q cannot %s", instance.State, action))
}
plugin, endpoint, err := svc.lifecycleDependencies(instance.PluginID, instance.RunEndpointID)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validateLifecycleActionRef(plugin, action); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, action); err != nil {
return domain.ServerLifecycleResult{}, err
}
if action == domain.ServerLifecycleActionStart {
binding, bindingErr := svc.runtimeBindingForServer(instance.ID)
if bindingErr != nil && !errors.Is(bindingErr, repo.ErrNotFound) {
return domain.ServerLifecycleResult{}, bindingErr
}
if bindingErr == nil {
if profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey); exists && len(profile.DLLExtensionRefs) > 0 {
if _, extensionErr := lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint); extensionErr != nil {
return domain.ServerLifecycleResult{}, extensionErr
}
}
}
}
if deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
if err := svc.requireCompleteRuntimeBindings(instance.OwnerUserID, instance.ID, "server.lifecycle."+string(action)+".denied"); err != nil {
return domain.ServerLifecycleResult{}, err
}
}
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(action)); err != nil {
return domain.ServerLifecycleResult{}, err
}
job, err := svc.dispatchLifecycleJob(instance, action, command.IdempotencyKey)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{
Accepted: true,
Action: action,
Instance: instance,
Job: job,
}), nil
}
func (svc *CoreService) lifecycleDependencies(pluginID string, runEndpointID string) (domain.GamePlugin, domain.RunEndpoint, error) {
plugin, err := svc.store.GamePlugins().Get(pluginID)
if err != nil {
return domain.GamePlugin{}, domain.RunEndpoint{}, fmt.Errorf("get plugin dependency: %w", err)
}
endpoint, err := svc.store.RunEndpoints().Get(runEndpointID)
if err != nil {
return domain.GamePlugin{}, domain.RunEndpoint{}, fmt.Errorf("get run endpoint dependency: %w", err)
}
return plugin, endpoint, nil
}
func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, action domain.ServerLifecycleAction, idempotencyKey string) (domain.Job, error) {
capability := domain.LifecycleCapabilityForAction(action)
binding, err := svc.runtimeBindingForServer(instance.ID)
if err != nil && !errors.Is(err, repo.ErrNotFound) {
return domain.Job{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.Job{}, err
}
profileKey := ""
if err == nil && strings.TrimSpace(binding.ProfileKey) != "" {
profileKey = binding.ProfileKey
} else {
profileKey = instance.Deployment.ProfileKey
}
actionRef := ""
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey)
if hasProfile {
if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" {
actionRef = ref
}
}
if strings.TrimSpace(actionRef) == "" {
actionRef = lifecycleActionRef(plugin, action)
}
if strings.TrimSpace(actionRef) == "" {
return domain.Job{}, validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))
}
var dllExtensions []domain.RuntimeDLLExtensionPlan
var logSources []domain.RuntimeLogSource
if action == domain.ServerLifecycleActionStart && hasProfile {
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.Job{}, err
}
dllExtensions, err = lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint)
if err != nil {
return domain.Job{}, err
}
logSources = lifecycleProcessLogSources(plugin.RuntimeProfiles)
}
job, err := svc.CreateJob(domain.Job{
ID: lifecycleJobID(instance.ID, action, idempotencyKey),
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: capability,
TargetKey: actionRef,
IdempotencyKey: idempotencyKey,
Progress: lifecycleJobProgress(instance.Deployment),
ExecutionInput: domain.JobExecutionInput{
WorkspaceScope: profileKey,
PluginID: plugin.ID,
LifecycleOperation: lifecycleExecutionOperation(action),
LogSources: logSources,
DLLExtensions: dllExtensions,
Deployment: deploymentPlanForDispatch(instance.Deployment),
},
})
if err != nil {
return domain.Job{}, err
}
if job.ServerInstanceID != instance.ID || job.RunEndpointID != instance.RunEndpointID || job.Capability != capability {
return domain.Job{}, validationError("idempotencyKey is already used for a different lifecycle target")
}
return job, nil
}
func lifecycleProcessLogSources(profiles domain.GamePluginRuntimeProfiles) []domain.RuntimeLogSource {
sources := []domain.RuntimeLogSource{}
for _, source := range profiles.LogSources {
if source.Kind == "process.stdout" || source.Kind == "process.stderr" {
sources = append(sources, source)
}
}
return sources
}
func lifecycleJobProgress(deployment domain.ServerDeploymentDefinition) domain.JobProgress {
if deployment.Mode != "" {
return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"}
}
return domain.JobProgress{}
}
func deploymentPlanForDispatch(definition domain.ServerDeploymentDefinition) *domain.ServerDeploymentDefinition {
if definition.Mode == "" {
return nil
}
copy := domain.CopyServerDeploymentDefinition(definition)
return &copy
}
func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string {
switch action {
case domain.ServerLifecycleActionCreate:
return "install"
case domain.ServerLifecycleActionStart:
return "start"
case domain.ServerLifecycleActionStop:
return "stop"
case domain.ServerLifecycleActionStatus:
return "status"
default:
return string(action)
}
}
func runtimeProfileActionRef(actions domain.PluginLifecycleActions, action domain.ServerLifecycleAction) string {
switch action {
case domain.ServerLifecycleActionCreate:
return actions.Install
case domain.ServerLifecycleActionStart:
return actions.Start
case domain.ServerLifecycleActionStop:
return actions.Stop
case domain.ServerLifecycleActionStatus:
return actions.Status
default:
return ""
}
}
func runtimeLifecycleProfileForKey(profiles domain.GamePluginRuntimeProfiles, key string) (domain.RuntimeLifecycleProfile, bool) {
for _, profile := range profiles.LifecycleProfiles {
if profile.Key == key {
return profile, true
}
}
return domain.RuntimeLifecycleProfile{}, false
}
func lifecycleDLLExtensionPlans(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) ([]domain.RuntimeDLLExtensionPlan, error) {
if len(profile.DLLExtensionRefs) == 0 {
return nil, nil
}
byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(profiles.DLLExtensions))
for _, extension := range profiles.DLLExtensions {
byKey[extension.Key] = extension
}
plans := make([]domain.RuntimeDLLExtensionPlan, 0, len(profile.DLLExtensionRefs))
for _, key := range profile.DLLExtensionRefs {
extension, exists := byKey[key]
if !exists || extension.ReleaseState != "ready" {
return nil, validationError("extension_release_unavailable: selected DLL release is not ready")
}
if !runtimeDLLExtensionSupportsTarget(extension, endpoint.Platform, endpoint.Architecture) {
return nil, validationError("unsupported_extension_platform: UE4SS DLL requires windows/amd64")
}
plans = append(plans, domain.RuntimeDLLExtensionPlan{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort})
}
return plans, nil
}
func runtimeDLLExtensionSupportsTarget(extension domain.RuntimeDLLExtensionProfile, platform string, architecture string) bool {
for _, target := range extension.SupportedTargets {
if strings.EqualFold(target.OS, platform) && strings.EqualFold(target.Arch, architecture) {
return true
}
}
return false
}
func (svc *CoreService) validateLifecycleIdempotency(runEndpointID string, idempotencyKey string, serverInstanceID string, capability string) error {
existing, err := svc.store.Jobs().GetByIdempotency(runEndpointID, idempotencyKey)
if errors.Is(err, repo.ErrNotFound) {
return nil
}
if err != nil {
return err
}
if existing.ServerInstanceID == serverInstanceID && existing.Capability == capability {
return nil
}
return validationError("idempotencyKey is already used for a different lifecycle target")
}
func validateLifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleAction) error {
if strings.TrimSpace(lifecycleActionRef(plugin, action)) == "" {
return validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))
}
return nil
}
func lifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleAction) string {
switch action {
case domain.ServerLifecycleActionCreate:
return plugin.LifecycleActions.Install
case domain.ServerLifecycleActionStart:
return plugin.LifecycleActions.Start
case domain.ServerLifecycleActionStop:
return plugin.LifecycleActions.Stop
case domain.ServerLifecycleActionStatus:
return plugin.LifecycleActions.Status
default:
return ""
}
}
func serverStateAllowed(state domain.ServerInstanceState, allowed []domain.ServerInstanceState) bool {
for _, candidate := range allowed {
if state == candidate {
return true
}
}
return false
}
func lifecycleJobID(serverInstanceID string, action domain.ServerLifecycleAction, idempotencyKey string) string {
sum := sha256.Sum256([]byte(idempotencyKey))
return fmt.Sprintf("server-lifecycle:%s:%s:%s", serverInstanceID, action, hex.EncodeToString(sum[:8]))
}
func validateServerInstanceLifecycleDependencies(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint, action domain.ServerLifecycleAction) error {
if requiredShellCapability := deploymentShellCapability(instance.Deployment.Shell); requiredShellCapability != "" && !containsString(endpoint.Capabilities, requiredShellCapability) {
return validationError("run endpoint policy does not allow selected command shell")
}
return validator.ValidateServerInstanceDependenciesForCapabilities(instance, plugin, endpoint, lifecycleRequiredRunCapabilities(instance, plugin, action))
}
func lifecycleRequiredRunCapabilities(instance domain.ServerInstance, plugin domain.GamePlugin, action domain.ServerLifecycleAction) []string {
required := append([]string(nil), domain.LifecycleCapabilityForAction(action))
if instance.Deployment.Mode == domain.ServerDeploymentModeCustom {
required = append(required, domain.JobCapabilityDeploymentPlan)
}
return compactUniqueStrings(required)
}
func compactUniqueStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || containsString(out, value) {
continue
}
out = append(out, value)
}
return out
}