feat: support custom server deployment drafts
This commit is contained in:
@@ -56,7 +56,11 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
||||
job = normalizeJobScheduling(job, stamp)
|
||||
job.Attempt++
|
||||
job.State = domain.JobStateAccepted
|
||||
job.Progress = domain.JobProgress{Percent: 0, Message: "claimed; awaiting Run acknowledgement"}
|
||||
phase := job.Progress.Phase
|
||||
if job.ExecutionInput.Deployment != nil {
|
||||
phase = "claimed"
|
||||
}
|
||||
job.Progress = domain.JobProgress{Percent: 0, Phase: phase, Message: "claimed; awaiting Run acknowledgement"}
|
||||
job.NextAttemptAt = time.Time{}
|
||||
job.LeaseTokenHash = tokenHash(leaseToken)
|
||||
job.LeaseSessionGen = session.Generation
|
||||
@@ -147,7 +151,7 @@ func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (do
|
||||
if progress.Sequence > 0 && progress.Sequence <= job.LastProgressSeq {
|
||||
return domain.RunJobProgressResult{}, validationError("progress sequence is stale")
|
||||
}
|
||||
job.Progress = domain.JobProgress{Percent: progress.Progress.Percent, Message: progress.Progress.Message}
|
||||
job.Progress = domain.JobProgress{Percent: progress.Progress.Percent, Phase: progress.Progress.Phase, Message: progress.Progress.Message}
|
||||
if progress.Sequence > 0 {
|
||||
job.LastProgressSeq = progress.Sequence
|
||||
}
|
||||
@@ -208,7 +212,7 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
}
|
||||
|
||||
if result.State == domain.JobStateFailed && result.Retryable && job.Attempt < job.RetryPolicy.MaxAttempts && job.CancelRequestedAt.IsZero() {
|
||||
job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Message: terminalMessage(result)}
|
||||
job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Phase: result.Progress.Phase, Message: terminalMessage(result)}
|
||||
if err := svc.scheduleJobRetry(&job, stamp, "retryable Run failure"); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
@@ -216,7 +220,7 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
}
|
||||
|
||||
job.State = result.State
|
||||
job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Message: terminalMessage(result)}
|
||||
job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Phase: result.Progress.Phase, Message: terminalMessage(result)}
|
||||
job.ResultRef = result.ResultRef
|
||||
job.ExecutionResult = result.ExecutionResult
|
||||
job.TerminalAt = stamp
|
||||
@@ -598,9 +602,9 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
||||
InputRef: job.InputRef,
|
||||
IdempotencyKey: job.IdempotencyKey,
|
||||
State: job.State,
|
||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
|
||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Phase: job.Progress.Phase, Message: job.Progress.Message},
|
||||
ResultRef: job.ResultRef,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON)},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment)},
|
||||
LeaseToken: leaseToken,
|
||||
Attempt: job.Attempt,
|
||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||
@@ -613,6 +617,14 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
||||
}
|
||||
}
|
||||
|
||||
func deploymentPlanForDispatchValue(definition *domain.ServerDeploymentDefinition) *domain.ServerDeploymentDefinition {
|
||||
if definition == nil {
|
||||
return nil
|
||||
}
|
||||
copy := domain.CopyServerDeploymentDefinition(*definition)
|
||||
return ©
|
||||
}
|
||||
|
||||
func emptyJobClaim(runEndpointID string, stamp time.Time) domain.RunJobClaimResult {
|
||||
return domain.RunJobClaimResult{Accepted: true, RunEndpointID: runEndpointID, NextPollSeconds: defaultJobPollSeconds, ServerTime: stamp}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ type Core interface {
|
||||
CreateServerInstanceForSession(string, domain.ServerInstance) (domain.ServerInstance, error)
|
||||
CreateServerInstanceWorkflow(domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error)
|
||||
CreateServerInstanceWorkflowForSession(string, domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error)
|
||||
GetServerDeploymentForSession(string, string) (domain.ServerDeploymentView, error)
|
||||
UpdateServerDeploymentForSession(string, string, domain.ServerDeploymentUpdate) (domain.ServerDeploymentView, error)
|
||||
DeployServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
||||
StartServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
||||
StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
||||
StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
||||
@@ -693,6 +696,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
|
||||
SupportedOS: manifest.Server.SupportedOS,
|
||||
ManifestRef: registration.ManifestRef,
|
||||
CreateFormSchemaRef: manifest.Server.CreateFormSchema,
|
||||
CreateFields: manifest.Server.CreateFields,
|
||||
RequiredRunCapabilities: manifest.Capabilities,
|
||||
DeclaredPermissions: manifest.Permissions,
|
||||
Permissions: pluginPermissionsFromManifest(manifest.Permissions),
|
||||
@@ -1458,6 +1462,7 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark
|
||||
SupportedOS: plugin.SupportedOS,
|
||||
ManifestRef: plugin.ManifestRef,
|
||||
CreateFormSchemaRef: plugin.CreateFormSchemaRef,
|
||||
CreateFields: plugin.CreateFields,
|
||||
Capabilities: plugin.RequiredRunCapabilities,
|
||||
DeclaredPermissions: plugin.DeclaredPermissions,
|
||||
Permissions: plugin.Permissions,
|
||||
@@ -1534,9 +1539,12 @@ func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (do
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, fmt.Errorf("get plugin dependency: %w", err)
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, fmt.Errorf("get run endpoint dependency: %w", err)
|
||||
var endpoint domain.RunEndpoint
|
||||
if strings.TrimSpace(instance.RunEndpointID) != "" {
|
||||
endpoint, err = svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, fmt.Errorf("get run endpoint dependency: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if instance.PluginVersion == "" {
|
||||
@@ -1559,8 +1567,15 @@ func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (do
|
||||
if err := validator.ValidateServerInstance(instance); err != nil {
|
||||
return domain.ServerInstance{}, err
|
||||
}
|
||||
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
|
||||
return domain.ServerInstance{}, err
|
||||
if instance.State != domain.ServerInstanceStateDraft && strings.TrimSpace(instance.RunEndpointID) == "" {
|
||||
return domain.ServerInstance{}, validationError("runEndpointId is required when server is not a draft")
|
||||
}
|
||||
if strings.TrimSpace(instance.RunEndpointID) != "" {
|
||||
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
|
||||
return domain.ServerInstance{}, err
|
||||
}
|
||||
} else if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion {
|
||||
return domain.ServerInstance{}, validationError("plugin must be installed and match the server plugin version")
|
||||
}
|
||||
if err := svc.store.ServerInstances().Create(instance); err != nil {
|
||||
return domain.ServerInstance{}, err
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
func (svc *CoreService) GetServerDeploymentForSession(sessionID, serverInstanceID string) (domain.ServerDeploymentView, error) {
|
||||
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
return deploymentView(instance), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstanceID string, update domain.ServerDeploymentUpdate) (domain.ServerDeploymentView, error) {
|
||||
_, instance, err := svc.requireServerOwner(sessionID, serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
if instance.State == domain.ServerInstanceStateInstalling || instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateDeleted {
|
||||
return domain.ServerDeploymentView{}, validationError("deployment definition cannot be changed while the server is active")
|
||||
}
|
||||
definition := mergeDeploymentDefinition(instance.Deployment, update)
|
||||
definition.Revision = instance.Deployment.Revision + 1
|
||||
definition.UpdatedAt = svc.now()
|
||||
if err := validator.ValidateServerDeploymentDefinition(definition); err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, definition.CreateInputs); err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
if update.RunEndpointID != "" {
|
||||
if _, err := svc.store.RunEndpoints().Get(update.RunEndpointID); err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
instance.RunEndpointID = update.RunEndpointID
|
||||
}
|
||||
instance.Deployment = definition
|
||||
instance.UpdatedAt = definition.UpdatedAt
|
||||
if err := validator.ValidateServerInstance(instance); err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
return deploymentView(instance), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
|
||||
if err := validator.ValidateServerLifecycleCommand(command); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if instance.State != domain.ServerInstanceStateDraft && instance.State != domain.ServerInstanceStateFailed {
|
||||
return domain.ServerLifecycleResult{}, validationError("server instance is not deployable")
|
||||
}
|
||||
if instance.ConfigVersion != command.ExpectedConfigVersion {
|
||||
return domain.ServerLifecycleResult{}, validationError("expectedConfigVersion must match server instance")
|
||||
}
|
||||
if instance.Deployment.Mode == "" {
|
||||
return domain.ServerLifecycleResult{}, validationError("deployment definition is required")
|
||||
}
|
||||
if strings.TrimSpace(instance.RunEndpointID) == "" {
|
||||
return domain.ServerLifecycleResult{}, validationError("run endpoint must be selected before deployment")
|
||||
}
|
||||
plugin, endpoint, err := svc.lifecycleDependencies(instance.PluginID, instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if !containsString(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) {
|
||||
return domain.ServerLifecycleResult{}, validationError("run endpoint missing required capability: deployment.plan.v1")
|
||||
}
|
||||
if requiredShellCapability := deploymentShellCapability(instance.Deployment.Shell); requiredShellCapability != "" && !containsString(endpoint.Capabilities, requiredShellCapability) {
|
||||
return domain.ServerLifecycleResult{}, validationError("run endpoint policy does not allow selected command shell")
|
||||
}
|
||||
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityInstall); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, command.IdempotencyKey, instance.ID, domain.LifecycleCapabilityInstall); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(instance.Deployment.ProfileKey) != "" {
|
||||
binding, bindingErr := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: instance.Deployment.ProfileKey, Bindings: instance.Deployment.RuntimeBindings}, true)
|
||||
if bindingErr != nil {
|
||||
return domain.ServerLifecycleResult{}, bindingErr
|
||||
}
|
||||
if existing, existingErr := svc.runtimeBindingForServer(instance.ID); existingErr == nil {
|
||||
binding.CreatedAt = existing.CreatedAt
|
||||
if err := svc.store.RuntimeBindings().Update(binding); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
} else if errors.Is(existingErr, repo.ErrNotFound) {
|
||||
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
} else {
|
||||
return domain.ServerLifecycleResult{}, existingErr
|
||||
}
|
||||
}
|
||||
instance.State = domain.ServerInstanceStateInstalling
|
||||
instance.UpdatedAt = svc.now()
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, command.IdempotencyKey)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{Accepted: true, Action: domain.ServerLifecycleActionCreate, Instance: instance, Job: job}), nil
|
||||
}
|
||||
|
||||
func deploymentShellCapability(shell domain.ServerCommandShell) string {
|
||||
switch shell {
|
||||
case domain.ServerCommandShellPosix:
|
||||
return domain.JobCapabilityDeploymentShellPosix
|
||||
case domain.ServerCommandShellPowerShell:
|
||||
return domain.JobCapabilityDeploymentShellPowerShell
|
||||
case domain.ServerCommandShellCmd:
|
||||
return domain.JobCapabilityDeploymentShellCmd
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mergeDeploymentDefinition(current domain.ServerDeploymentDefinition, update domain.ServerDeploymentUpdate) domain.ServerDeploymentDefinition {
|
||||
definition := domain.CopyServerDeploymentDefinition(current)
|
||||
definition.Mode = update.Mode
|
||||
if update.ProfileKey != "" {
|
||||
definition.ProfileKey = update.ProfileKey
|
||||
}
|
||||
if update.RuntimeBindings != nil {
|
||||
definition.RuntimeBindings = domain.CopyStringMap(update.RuntimeBindings)
|
||||
}
|
||||
if update.CreateInputs != nil {
|
||||
definition.CreateInputs = domain.CopyStringMap(update.CreateInputs)
|
||||
}
|
||||
if update.ServerRoot != "" {
|
||||
definition.ServerRoot = update.ServerRoot
|
||||
}
|
||||
if update.WorkingDirectory != "" {
|
||||
definition.WorkingDirectory = update.WorkingDirectory
|
||||
}
|
||||
if update.InstallCommand != "" {
|
||||
definition.InstallCommand = update.InstallCommand
|
||||
}
|
||||
if update.StartCommand != "" {
|
||||
definition.StartCommand = update.StartCommand
|
||||
}
|
||||
if update.StopCommand != "" {
|
||||
definition.StopCommand = update.StopCommand
|
||||
}
|
||||
if update.StatusCommand != "" {
|
||||
definition.StatusCommand = update.StatusCommand
|
||||
}
|
||||
definition.Shell = update.Shell
|
||||
return definition
|
||||
}
|
||||
|
||||
func deploymentView(instance domain.ServerInstance) domain.ServerDeploymentView {
|
||||
definition := instance.Deployment
|
||||
return domain.CopyServerDeploymentView(domain.ServerDeploymentView{
|
||||
ServerInstanceID: instance.ID, Mode: definition.Mode, ProfileKey: definition.ProfileKey, CreateInputs: domain.CopyStringMap(definition.CreateInputs),
|
||||
ServerRootConfigured: definition.ServerRoot != "", WorkingDirectoryConfigured: definition.WorkingDirectory != "", InstallCommandConfigured: definition.InstallCommand != "", StartCommandConfigured: definition.StartCommand != "", StopCommandConfigured: definition.StopCommand != "", StatusCommandConfigured: definition.StatusCommand != "", Shell: definition.Shell, Revision: definition.Revision, UpdatedAt: definition.UpdatedAt,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibleRun(t *testing.T) {
|
||||
svc, _ := newLifecycleRunService(t)
|
||||
createLifecyclePlugin(t, svc)
|
||||
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "deployment-owner", DisplayName: "Deployment Owner", Email: "deployment-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
|
||||
draft, err := svc.CreateServerInstanceWorkflowForSession(ownerSession, domain.ServerLifecycleCreate{
|
||||
ID: "venv-draft", PluginID: "server.scum", Name: "Venv server", IdempotencyKey: "draft-venv",
|
||||
Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/venv-server", WorkingDirectory: "/srv/venv-server", StartCommand: "/srv/venv-server/.venv/bin/python server.py"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create unbound deployment draft: %v", err)
|
||||
}
|
||||
if draft.Instance.State != domain.ServerInstanceStateDraft || draft.Job.ID != "" {
|
||||
t.Fatalf("draft must have no lifecycle job, got %+v", draft)
|
||||
}
|
||||
view, err := svc.GetServerDeploymentForSession(ownerSession, draft.Instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("read deployment view: %v", err)
|
||||
}
|
||||
if !view.ServerRootConfigured || !view.WorkingDirectoryConfigured || !view.StartCommandConfigured {
|
||||
t.Fatalf("expected protected values to be marked configured: %+v", view)
|
||||
}
|
||||
if strings.Contains(strings.Join([]string{view.ServerInstanceID, string(view.Mode), view.ProfileKey}, " "), "/srv/") {
|
||||
t.Fatalf("redacted deployment view leaked host path: %+v", view)
|
||||
}
|
||||
|
||||
if _, err := svc.UpdateServerDeploymentForSession(ownerSession, draft.Instance.ID, domain.ServerDeploymentUpdate{RunEndpointID: "run-local", Mode: domain.ServerDeploymentModeCustom}); err != nil {
|
||||
t.Fatalf("bind draft to run: %v", err)
|
||||
}
|
||||
if _, err := svc.DeployServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: draft.Instance.ID, ExpectedConfigVersion: draft.Instance.ConfigVersion, IdempotencyKey: "deploy-incompatible"}); err == nil || !strings.Contains(err.Error(), "deployment.plan.v1") {
|
||||
t.Fatalf("expected incompatible Run rejection, got %v", err)
|
||||
}
|
||||
|
||||
endpoint, err := svc.store.RunEndpoints().Get("run-local")
|
||||
if err != nil {
|
||||
t.Fatalf("get endpoint: %v", err)
|
||||
}
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan)
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("enable deployment capability: %v", err)
|
||||
}
|
||||
deployed, err := svc.DeployServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: draft.Instance.ID, ExpectedConfigVersion: draft.Instance.ConfigVersion, IdempotencyKey: "deploy-compatible"})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy compatible draft: %v", err)
|
||||
}
|
||||
if deployed.Job.ExecutionInput.Deployment == nil || deployed.Job.ExecutionInput.Deployment.StartCommand != "/srv/venv-server/.venv/bin/python server.py" || deployed.Job.Progress.Phase != "queued" {
|
||||
t.Fatalf("Run job must carry protected plan and queued phase: %+v", deployed.Job)
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,21 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
|
||||
plugin, endpoint, err := svc.lifecycleDependencies(create.PluginID, create.RunEndpointID)
|
||||
plugin, err := svc.store.GamePlugins().Get(create.PluginID)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
return domain.ServerLifecycleResult{}, fmt.Errorf("get plugin dependency: %w", err)
|
||||
}
|
||||
if plugin.Status != domain.GamePluginStatusInstalled {
|
||||
return domain.ServerLifecycleResult{}, validationError("plugin must be installed")
|
||||
}
|
||||
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{
|
||||
@@ -39,31 +47,63 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
|
||||
ConfigKey: "server.properties",
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
Deployment: create.Deployment,
|
||||
}
|
||||
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.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 := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if instance.Deployment.Mode != "" && !containsString(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) {
|
||||
return domain.ServerLifecycleResult{}, validationError("run endpoint missing required capability: deployment.plan.v1")
|
||||
}
|
||||
if requiredShellCapability := deploymentShellCapability(instance.Deployment.Shell); requiredShellCapability != "" && !containsString(endpoint.Capabilities, requiredShellCapability) {
|
||||
return domain.ServerLifecycleResult{}, validationError("run endpoint policy does not allow selected command shell")
|
||||
}
|
||||
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: create.ProfileKey, Bindings: create.Bindings}, true)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
var binding domain.RuntimeBinding
|
||||
hasBinding := strings.TrimSpace(create.ProfileKey) != ""
|
||||
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 err := svc.store.RuntimeBindings().Create(binding); 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)
|
||||
@@ -208,15 +248,21 @@ func (svc *CoreService) lifecycleDependencies(pluginID string, runEndpointID str
|
||||
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 {
|
||||
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
|
||||
}
|
||||
actionRef := binding.ProfileKey
|
||||
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey)
|
||||
profileKey := ""
|
||||
if err == nil {
|
||||
profileKey = binding.ProfileKey
|
||||
} else {
|
||||
profileKey = instance.Deployment.ProfileKey
|
||||
}
|
||||
actionRef := profileKey
|
||||
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey)
|
||||
if hasProfile {
|
||||
if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" {
|
||||
actionRef = ref
|
||||
@@ -245,11 +291,13 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
||||
Capability: capability,
|
||||
TargetKey: actionRef,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Progress: lifecycleJobProgress(instance.Deployment),
|
||||
ExecutionInput: domain.JobExecutionInput{
|
||||
WorkspaceScope: binding.ProfileKey,
|
||||
WorkspaceScope: profileKey,
|
||||
PluginID: plugin.ID,
|
||||
LifecycleOperation: lifecycleExecutionOperation(action),
|
||||
DLLExtensions: dllExtensions,
|
||||
Deployment: deploymentPlanForDispatch(instance.Deployment),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -261,6 +309,21 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
||||
return job, nil
|
||||
}
|
||||
|
||||
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 ©
|
||||
}
|
||||
|
||||
func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string {
|
||||
switch action {
|
||||
case domain.ServerLifecycleActionCreate:
|
||||
|
||||
Reference in New Issue
Block a user