feat: support custom server deployment drafts
This commit is contained in:
@@ -68,6 +68,8 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/process/status", h.serverInstanceProcessStatus)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/deployment", h.serverDeployment)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/deploy", h.serverInstanceDeploy)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate)
|
||||
|
||||
@@ -37,6 +37,52 @@ func (h *coreHandlers) serverInstanceCreateWorkflow(w http.ResponseWriter, r *ht
|
||||
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
|
||||
}
|
||||
|
||||
// serverDeployment reads safe deployment metadata or updates protected deployment input.
|
||||
func (h *coreHandlers) serverDeployment(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
view, err := h.core.GetServerDeploymentForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerDeploymentFromDomain(view))
|
||||
case http.MethodPut:
|
||||
request, err := decodeJSON[dto.ServerDeploymentRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.UpdateServerDeploymentForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerDeploymentFromDomain(view))
|
||||
default:
|
||||
writeMethodNotAllowed(w, http.MethodGet+", "+http.MethodPut)
|
||||
}
|
||||
}
|
||||
|
||||
// serverInstanceDeploy binds an existing draft definition to its configured Run and queues install.
|
||||
func (h *coreHandlers) serverInstanceDeploy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.DeployServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result))
|
||||
}
|
||||
|
||||
// serverInstanceStart godoc
|
||||
// @Summary Start server instance
|
||||
// @Description Validates lifecycle state and config version, then queues a start job through the platform job channel.
|
||||
|
||||
@@ -4,6 +4,7 @@ import "time"
|
||||
|
||||
type RunJobProgressReport struct {
|
||||
Percent int
|
||||
Phase string
|
||||
Message string
|
||||
}
|
||||
|
||||
|
||||
@@ -320,11 +320,25 @@ type GamePluginBridge struct {
|
||||
Actions []string
|
||||
}
|
||||
|
||||
// PluginCreateField is a safe, declarative game setting exposed in the
|
||||
// management console during server definition creation. It deliberately
|
||||
// excludes command text, host paths, secrets, and arbitrary JSON schemas.
|
||||
type PluginCreateField struct {
|
||||
Key string
|
||||
Label string
|
||||
Type string
|
||||
Required bool
|
||||
DefaultValue string
|
||||
Options []string
|
||||
ConfigKey string
|
||||
}
|
||||
|
||||
type GamePluginManifestServer struct {
|
||||
Type string
|
||||
DisplayName string
|
||||
SupportedOS []string
|
||||
CreateFormSchema string
|
||||
CreateFields []PluginCreateField
|
||||
}
|
||||
|
||||
type GamePluginManifestAI struct {
|
||||
@@ -554,6 +568,7 @@ type GamePlugin struct {
|
||||
SupportedOS []string
|
||||
ManifestRef string
|
||||
CreateFormSchemaRef string
|
||||
CreateFields []PluginCreateField
|
||||
RequiredRunCapabilities []string
|
||||
DeclaredPermissions []string
|
||||
Permissions PluginPermissions
|
||||
@@ -580,6 +595,7 @@ type PluginMarketplacePlugin struct {
|
||||
SupportedOS []string
|
||||
ManifestRef string
|
||||
CreateFormSchemaRef string
|
||||
CreateFields []PluginCreateField
|
||||
Capabilities []string
|
||||
DeclaredPermissions []string
|
||||
Permissions PluginPermissions
|
||||
@@ -676,6 +692,9 @@ type ServerInstance struct {
|
||||
ConfigUpdatedAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// Deployment stores operator-supplied deployment inputs. Its protected path
|
||||
// and command values are never included in normal platform read projections.
|
||||
Deployment ServerDeploymentDefinition
|
||||
}
|
||||
|
||||
type ServerInstanceUpdate struct {
|
||||
@@ -715,6 +734,77 @@ type ServerConfig struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ServerDeploymentMode selects how Run prepares and launches a server.
|
||||
type ServerDeploymentMode string
|
||||
|
||||
const (
|
||||
ServerDeploymentModeGuided ServerDeploymentMode = "guided-install"
|
||||
ServerDeploymentModeExisting ServerDeploymentMode = "existing-server"
|
||||
ServerDeploymentModeCustom ServerDeploymentMode = "custom-command"
|
||||
)
|
||||
|
||||
// ServerCommandShell controls deliberate shell interpretation of a command.
|
||||
// Empty means Run receives the command as its default argv-compatible form.
|
||||
type ServerCommandShell string
|
||||
|
||||
const (
|
||||
ServerCommandShellNone ServerCommandShell = ""
|
||||
ServerCommandShellPosix ServerCommandShell = "posix-sh"
|
||||
ServerCommandShellPowerShell ServerCommandShell = "powershell"
|
||||
ServerCommandShellCmd ServerCommandShell = "cmd"
|
||||
)
|
||||
|
||||
// ServerDeploymentDefinition persists game inputs plus write-only execution
|
||||
// material. It is copied into a leased Run assignment but never into DTO read
|
||||
// views; callers receive ServerDeploymentView instead.
|
||||
type ServerDeploymentDefinition struct {
|
||||
Mode ServerDeploymentMode
|
||||
ProfileKey string
|
||||
RuntimeBindings map[string]string
|
||||
CreateInputs map[string]string
|
||||
ServerRoot string
|
||||
WorkingDirectory string
|
||||
InstallCommand string
|
||||
StartCommand string
|
||||
StopCommand string
|
||||
StatusCommand string
|
||||
Shell ServerCommandShell
|
||||
Revision int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ServerDeploymentUpdate struct {
|
||||
RunEndpointID string
|
||||
Mode ServerDeploymentMode
|
||||
ProfileKey string
|
||||
RuntimeBindings map[string]string
|
||||
CreateInputs map[string]string
|
||||
ServerRoot string
|
||||
WorkingDirectory string
|
||||
InstallCommand string
|
||||
StartCommand string
|
||||
StopCommand string
|
||||
StatusCommand string
|
||||
Shell ServerCommandShell
|
||||
}
|
||||
|
||||
// ServerDeploymentView is the intentionally redacted read model.
|
||||
type ServerDeploymentView struct {
|
||||
ServerInstanceID string
|
||||
Mode ServerDeploymentMode
|
||||
ProfileKey string
|
||||
CreateInputs map[string]string
|
||||
ServerRootConfigured bool
|
||||
WorkingDirectoryConfigured bool
|
||||
InstallCommandConfigured bool
|
||||
StartCommandConfigured bool
|
||||
StopCommandConfigured bool
|
||||
StatusCommandConfigured bool
|
||||
Shell ServerCommandShell
|
||||
Revision int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ConfigDiffLine struct {
|
||||
Kind string
|
||||
OldNumber int
|
||||
@@ -821,6 +911,12 @@ const (
|
||||
JobCapabilityDependenciesCheck = "dependencies.check"
|
||||
JobCapabilityDependenciesInstall = "dependencies.install"
|
||||
JobCapabilityLogsBackfill = "logs.backfill"
|
||||
// JobCapabilityDeploymentPlan gates Run implementations that understand
|
||||
// protected deployment definitions, absolute paths, and custom commands.
|
||||
JobCapabilityDeploymentPlan = "deployment.plan.v1"
|
||||
JobCapabilityDeploymentShellPosix = "deployment.shell.posix-sh"
|
||||
JobCapabilityDeploymentShellPowerShell = "deployment.shell.powershell"
|
||||
JobCapabilityDeploymentShellCmd = "deployment.shell.cmd"
|
||||
)
|
||||
|
||||
type RunEndpoint struct {
|
||||
@@ -837,6 +933,7 @@ type RunEndpoint struct {
|
||||
|
||||
type JobProgress struct {
|
||||
Percent int
|
||||
Phase string
|
||||
Message string
|
||||
}
|
||||
|
||||
@@ -861,6 +958,7 @@ type JobExecutionInput struct {
|
||||
Inputs map[string]string
|
||||
DLLExtensions []RuntimeDLLExtensionPlan
|
||||
SourceRCON *RuntimeSourceRCONPlan
|
||||
Deployment *ServerDeploymentDefinition
|
||||
}
|
||||
|
||||
type JobExecutionResult struct {
|
||||
@@ -1372,6 +1470,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
|
||||
plugin.RequiredRunCapabilities = CopyStringSlice(plugin.RequiredRunCapabilities)
|
||||
plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions)
|
||||
plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS)
|
||||
plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields)
|
||||
plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions)
|
||||
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
|
||||
plugin.Tags = CopyStringSlice(plugin.Tags)
|
||||
@@ -1387,6 +1486,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
|
||||
func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketplacePlugin {
|
||||
plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS)
|
||||
plugin.Capabilities = CopyStringSlice(plugin.Capabilities)
|
||||
plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields)
|
||||
plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions)
|
||||
plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions)
|
||||
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
|
||||
@@ -1419,6 +1519,7 @@ func CopyGamePluginManifestRegistration(registration GamePluginManifestRegistrat
|
||||
func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
|
||||
manifest.Tags = CopyStringSlice(manifest.Tags)
|
||||
manifest.Server.SupportedOS = CopyStringSlice(manifest.Server.SupportedOS)
|
||||
manifest.Server.CreateFields = CopyPluginCreateFields(manifest.Server.CreateFields)
|
||||
manifest.Bridge.Actions = CopyStringSlice(manifest.Bridge.Actions)
|
||||
manifest.Capabilities = CopyStringSlice(manifest.Capabilities)
|
||||
manifest.Permissions = CopyStringSlice(manifest.Permissions)
|
||||
@@ -1431,6 +1532,17 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
|
||||
return manifest
|
||||
}
|
||||
|
||||
func CopyPluginCreateFields(fields []PluginCreateField) []PluginCreateField {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
copy := append([]PluginCreateField(nil), fields...)
|
||||
for i := range copy {
|
||||
copy[i].Options = CopyStringSlice(copy[i].Options)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
func CopyGamePluginProductionLifecycle(lifecycle GamePluginProductionLifecycle) GamePluginProductionLifecycle {
|
||||
lifecycle.Operations = CopyStringSlice(lifecycle.Operations)
|
||||
lifecycle.ApprovalRequired = CopyStringSlice(lifecycle.ApprovalRequired)
|
||||
@@ -1524,9 +1636,27 @@ func CopyPluginBridgeExecuteResponse(response PluginBridgeExecuteResponse) Plugi
|
||||
|
||||
func CopyServerInstance(instance ServerInstance) ServerInstance {
|
||||
instance.AdminUserIDs = CopyStringSlice(instance.AdminUserIDs)
|
||||
instance.Deployment = CopyServerDeploymentDefinition(instance.Deployment)
|
||||
return instance
|
||||
}
|
||||
|
||||
func CopyServerDeploymentDefinition(definition ServerDeploymentDefinition) ServerDeploymentDefinition {
|
||||
definition.RuntimeBindings = CopyStringMap(definition.RuntimeBindings)
|
||||
definition.CreateInputs = CopyStringMap(definition.CreateInputs)
|
||||
return definition
|
||||
}
|
||||
|
||||
func CopyServerDeploymentUpdate(update ServerDeploymentUpdate) ServerDeploymentUpdate {
|
||||
update.RuntimeBindings = CopyStringMap(update.RuntimeBindings)
|
||||
update.CreateInputs = CopyStringMap(update.CreateInputs)
|
||||
return update
|
||||
}
|
||||
|
||||
func CopyServerDeploymentView(view ServerDeploymentView) ServerDeploymentView {
|
||||
view.CreateInputs = CopyStringMap(view.CreateInputs)
|
||||
return view
|
||||
}
|
||||
|
||||
func CopyPlatformResourceUsage(usage PlatformResourceUsage) PlatformResourceUsage {
|
||||
return usage
|
||||
}
|
||||
@@ -1583,6 +1713,10 @@ func CopyJob(job Job) Job {
|
||||
job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs)
|
||||
job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)
|
||||
job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON)
|
||||
if job.ExecutionInput.Deployment != nil {
|
||||
copy := CopyServerDeploymentDefinition(*job.ExecutionInput.Deployment)
|
||||
job.ExecutionInput.Deployment = ©
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ type ServerLifecycleCreate struct {
|
||||
IdempotencyKey string
|
||||
ProfileKey string
|
||||
Bindings map[string]string
|
||||
Deployment ServerDeploymentDefinition
|
||||
}
|
||||
|
||||
type ServerLifecycleCommand struct {
|
||||
@@ -57,6 +58,7 @@ func LifecycleCapabilityForAction(action ServerLifecycleAction) string {
|
||||
|
||||
func CopyServerLifecycleCreate(create ServerLifecycleCreate) ServerLifecycleCreate {
|
||||
create.Bindings = CopyStringMap(create.Bindings)
|
||||
create.Deployment = CopyServerDeploymentDefinition(create.Deployment)
|
||||
return create
|
||||
}
|
||||
|
||||
|
||||
+43
-15
@@ -92,20 +92,38 @@ type RunJobResultRequest struct {
|
||||
}
|
||||
|
||||
type RunJobExecutionInputBody struct {
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty"`
|
||||
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
|
||||
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
|
||||
TargetVersion string `json:"targetVersion,omitempty"`
|
||||
Inputs map[string]string `json:"inputs,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
|
||||
SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"`
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty"`
|
||||
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
|
||||
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
|
||||
TargetVersion string `json:"targetVersion,omitempty"`
|
||||
Inputs map[string]string `json:"inputs,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
|
||||
SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"`
|
||||
Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"`
|
||||
}
|
||||
|
||||
// ServerDeploymentExecutionBody is included only in a leased Run assignment.
|
||||
// It is intentionally absent from all public server and job response DTOs.
|
||||
type ServerDeploymentExecutionBody struct {
|
||||
Mode domain.ServerDeploymentMode `json:"mode"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"`
|
||||
CreateInputs map[string]string `json:"createInputs,omitempty"`
|
||||
ServerRoot string `json:"serverRoot,omitempty"`
|
||||
WorkingDirectory string `json:"workingDirectory,omitempty"`
|
||||
InstallCommand string `json:"installCommand,omitempty"`
|
||||
StartCommand string `json:"startCommand,omitempty"`
|
||||
StopCommand string `json:"stopCommand,omitempty"`
|
||||
StatusCommand string `json:"statusCommand,omitempty"`
|
||||
Shell domain.ServerCommandShell `json:"shell,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
}
|
||||
|
||||
type RuntimeSourceRCONPlanBody struct {
|
||||
@@ -565,7 +583,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
|
||||
State: assignment.State,
|
||||
Progress: progressReportFromDomain(assignment.Progress),
|
||||
ResultRef: assignment.ResultRef,
|
||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON)},
|
||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment)},
|
||||
LeaseToken: assignment.LeaseToken,
|
||||
Attempt: assignment.Attempt,
|
||||
MaxAttempts: assignment.MaxAttempts,
|
||||
@@ -578,6 +596,14 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
|
||||
}
|
||||
}
|
||||
|
||||
func deploymentExecutionFromDomain(definition *domain.ServerDeploymentDefinition) *ServerDeploymentExecutionBody {
|
||||
if definition == nil {
|
||||
return nil
|
||||
}
|
||||
copy := domain.CopyServerDeploymentDefinition(*definition)
|
||||
return &ServerDeploymentExecutionBody{Mode: copy.Mode, ProfileKey: copy.ProfileKey, RuntimeBindings: copy.RuntimeBindings, CreateInputs: copy.CreateInputs, ServerRoot: copy.ServerRoot, WorkingDirectory: copy.WorkingDirectory, InstallCommand: copy.InstallCommand, StartCommand: copy.StartCommand, StopCommand: copy.StopCommand, StatusCommand: copy.StatusCommand, Shell: copy.Shell, Revision: copy.Revision}
|
||||
}
|
||||
|
||||
func runtimeSourceRCONPlanFromDomain(plan *domain.RuntimeSourceRCONPlan) *RuntimeSourceRCONPlanBody {
|
||||
if plan == nil {
|
||||
return nil
|
||||
@@ -588,6 +614,7 @@ func runtimeSourceRCONPlanFromDomain(plan *domain.RuntimeSourceRCONPlan) *Runtim
|
||||
func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressReport {
|
||||
return domain.RunJobProgressReport{
|
||||
Percent: progress.Percent,
|
||||
Phase: progress.Phase,
|
||||
Message: progress.Message,
|
||||
}
|
||||
}
|
||||
@@ -595,6 +622,7 @@ func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressRepor
|
||||
func progressReportFromDomain(progress domain.RunJobProgressReport) JobProgressBody {
|
||||
return JobProgressBody{
|
||||
Percent: progress.Percent,
|
||||
Phase: progress.Phase,
|
||||
Message: progress.Message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,11 +187,22 @@ type GamePluginBridgeBody struct {
|
||||
Actions []string `json:"actions"`
|
||||
}
|
||||
|
||||
type PluginCreateFieldBody struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
DefaultValue string `json:"defaultValue,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
ConfigKey string `json:"configKey,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginManifestServerBody struct {
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"displayName"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
CreateFormSchema string `json:"createFormSchema"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"displayName"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
CreateFormSchema string `json:"createFormSchema"`
|
||||
CreateFields []PluginCreateFieldBody `json:"createFields,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginManifestAIBody struct {
|
||||
@@ -314,6 +325,7 @@ type GamePluginCreateRequest struct {
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
CreateFields []PluginCreateFieldBody `json:"createFields,omitempty"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions,omitempty"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
@@ -339,6 +351,7 @@ type GamePluginResponse struct {
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
CreateFields []PluginCreateFieldBody `json:"createFields,omitempty"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
@@ -656,6 +669,7 @@ type JobCreateRequest struct {
|
||||
|
||||
type JobProgressBody struct {
|
||||
Percent int `json:"percent"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
@@ -929,12 +943,35 @@ func (bridge GamePluginBridgeBody) ToDomain() domain.GamePluginBridge {
|
||||
return domain.GamePluginBridge{Actions: domain.CopyStringSlice(bridge.Actions)}
|
||||
}
|
||||
|
||||
func pluginCreateFieldsToDomain(fields []PluginCreateFieldBody) []domain.PluginCreateField {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]domain.PluginCreateField, len(fields))
|
||||
for i, field := range fields {
|
||||
out[i] = domain.PluginCreateField{Key: field.Key, Label: field.Label, Type: field.Type, Required: field.Required, DefaultValue: field.DefaultValue, Options: domain.CopyStringSlice(field.Options), ConfigKey: field.ConfigKey}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pluginCreateFieldsFromDomain(fields []domain.PluginCreateField) []PluginCreateFieldBody {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]PluginCreateFieldBody, len(fields))
|
||||
for i, field := range fields {
|
||||
out[i] = PluginCreateFieldBody{Key: field.Key, Label: field.Label, Type: field.Type, Required: field.Required, DefaultValue: field.DefaultValue, Options: domain.CopyStringSlice(field.Options), ConfigKey: field.ConfigKey}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (server GamePluginManifestServerBody) ToDomain() domain.GamePluginManifestServer {
|
||||
return domain.GamePluginManifestServer{
|
||||
Type: server.Type,
|
||||
DisplayName: server.DisplayName,
|
||||
SupportedOS: domain.CopyStringSlice(server.SupportedOS),
|
||||
CreateFormSchema: server.CreateFormSchema,
|
||||
CreateFields: pluginCreateFieldsToDomain(server.CreateFields),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1001,6 +1038,7 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
|
||||
SupportedOS: domain.CopyStringSlice(request.SupportedOS),
|
||||
ManifestRef: request.ManifestRef,
|
||||
CreateFormSchemaRef: request.CreateFormSchemaRef,
|
||||
CreateFields: pluginCreateFieldsToDomain(request.CreateFields),
|
||||
RequiredRunCapabilities: domain.CopyStringSlice(request.RequiredRunCapabilities),
|
||||
DeclaredPermissions: domain.CopyStringSlice(request.DeclaredPermissions),
|
||||
Permissions: permissionsToDomain(request.Permissions),
|
||||
@@ -1247,6 +1285,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
|
||||
SupportedOS: plugin.SupportedOS,
|
||||
ManifestRef: plugin.ManifestRef,
|
||||
CreateFormSchemaRef: plugin.CreateFormSchemaRef,
|
||||
CreateFields: pluginCreateFieldsFromDomain(plugin.CreateFields),
|
||||
RequiredRunCapabilities: plugin.RequiredRunCapabilities,
|
||||
DeclaredPermissions: plugin.DeclaredPermissions,
|
||||
Permissions: permissionsFromDomain(plugin.Permissions),
|
||||
@@ -1785,6 +1824,7 @@ func capacityToDomain(capacity RunCapacityResponse) domain.RunCapacity {
|
||||
func progressFromDomain(progress domain.JobProgress) JobProgressBody {
|
||||
return JobProgressBody{
|
||||
Percent: progress.Percent,
|
||||
Phase: progress.Phase,
|
||||
Message: progress.Message,
|
||||
}
|
||||
}
|
||||
@@ -1792,6 +1832,7 @@ func progressFromDomain(progress domain.JobProgress) JobProgressBody {
|
||||
func progressToDomain(progress JobProgressBody) domain.JobProgress {
|
||||
return domain.JobProgress{
|
||||
Percent: progress.Percent,
|
||||
Phase: progress.Phase,
|
||||
Message: progress.Message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
package dto
|
||||
|
||||
import "browser.local/platform/domain"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
type ServerDeploymentRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId,omitempty"`
|
||||
Mode domain.ServerDeploymentMode `json:"mode"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"`
|
||||
CreateInputs map[string]string `json:"createInputs,omitempty"`
|
||||
ServerRoot string `json:"serverRoot,omitempty"`
|
||||
WorkingDirectory string `json:"workingDirectory,omitempty"`
|
||||
InstallCommand string `json:"installCommand,omitempty"`
|
||||
StartCommand string `json:"startCommand,omitempty"`
|
||||
StopCommand string `json:"stopCommand,omitempty"`
|
||||
StatusCommand string `json:"statusCommand,omitempty"`
|
||||
Shell domain.ServerCommandShell `json:"shell,omitempty"`
|
||||
}
|
||||
|
||||
type ServerDeploymentResponse struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Mode domain.ServerDeploymentMode `json:"mode,omitempty"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
CreateInputs map[string]string `json:"createInputs,omitempty"`
|
||||
ServerRootConfigured bool `json:"serverRootConfigured"`
|
||||
WorkingDirectoryConfigured bool `json:"workingDirectoryConfigured"`
|
||||
InstallCommandConfigured bool `json:"installCommandConfigured"`
|
||||
StartCommandConfigured bool `json:"startCommandConfigured"`
|
||||
StopCommandConfigured bool `json:"stopCommandConfigured"`
|
||||
StatusCommandConfigured bool `json:"statusCommandConfigured"`
|
||||
Shell domain.ServerCommandShell `json:"shell,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ServerLifecycleCreateRequest struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Name string `json:"name"`
|
||||
OwnerUserID string `json:"ownerUserId,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Bindings map[string]string `json:"bindings,omitempty"`
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Name string `json:"name"`
|
||||
OwnerUserID string `json:"ownerUserId,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Bindings map[string]string `json:"bindings,omitempty"`
|
||||
Deployment ServerDeploymentRequest `json:"deployment,omitempty"`
|
||||
}
|
||||
|
||||
type ServerLifecycleCommandRequest struct {
|
||||
@@ -35,9 +71,23 @@ func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCre
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
ProfileKey: request.ProfileKey,
|
||||
Bindings: domain.CopyStringMap(request.Bindings),
|
||||
Deployment: request.Deployment.deploymentDefinition(),
|
||||
}
|
||||
}
|
||||
|
||||
func (request ServerDeploymentRequest) ToDomain() domain.ServerDeploymentUpdate {
|
||||
return domain.ServerDeploymentUpdate{RunEndpointID: request.RunEndpointID, Mode: request.Mode, ProfileKey: request.ProfileKey, RuntimeBindings: domain.CopyStringMap(request.RuntimeBindings), CreateInputs: domain.CopyStringMap(request.CreateInputs), ServerRoot: request.ServerRoot, WorkingDirectory: request.WorkingDirectory, InstallCommand: request.InstallCommand, StartCommand: request.StartCommand, StopCommand: request.StopCommand, StatusCommand: request.StatusCommand, Shell: request.Shell}
|
||||
}
|
||||
|
||||
func (request ServerDeploymentRequest) deploymentDefinition() domain.ServerDeploymentDefinition {
|
||||
update := request.ToDomain()
|
||||
return domain.ServerDeploymentDefinition{Mode: update.Mode, ProfileKey: update.ProfileKey, RuntimeBindings: update.RuntimeBindings, CreateInputs: update.CreateInputs, ServerRoot: update.ServerRoot, WorkingDirectory: update.WorkingDirectory, InstallCommand: update.InstallCommand, StartCommand: update.StartCommand, StopCommand: update.StopCommand, StatusCommand: update.StatusCommand, Shell: update.Shell}
|
||||
}
|
||||
|
||||
func ServerDeploymentFromDomain(view domain.ServerDeploymentView) ServerDeploymentResponse {
|
||||
return ServerDeploymentResponse{ServerInstanceID: view.ServerInstanceID, Mode: view.Mode, ProfileKey: view.ProfileKey, CreateInputs: domain.CopyStringMap(view.CreateInputs), ServerRootConfigured: view.ServerRootConfigured, WorkingDirectoryConfigured: view.WorkingDirectoryConfigured, InstallCommandConfigured: view.InstallCommandConfigured, StartCommandConfigured: view.StartCommandConfigured, StopCommandConfigured: view.StopCommandConfigured, StatusCommandConfigured: view.StatusCommandConfigured, Shell: view.Shell, Revision: view.Revision, UpdatedAt: optionalTime(view.UpdatedAt)}
|
||||
}
|
||||
|
||||
func (request ServerLifecycleCommandRequest) ToDomain(serverInstanceID string) domain.ServerLifecycleCommand {
|
||||
return domain.ServerLifecycleCommand{
|
||||
ServerInstanceID: serverInstanceID,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Server deployment plan v1
|
||||
|
||||
`deployment.plan.v1` is the capability gate for Run implementations that can
|
||||
execute a protected server deployment plan. Platform only sends the plan in a
|
||||
leased `RunJobAssignmentResponse.executionInput.deployment`; it never appears
|
||||
in public server, job, audit, log, or plugin-bridge responses.
|
||||
|
||||
## Capability and policy
|
||||
|
||||
Run advertises `deployment.plan.v1` along with its normal lifecycle
|
||||
capabilities. A Run that supports shell commands additionally advertises its
|
||||
local policy for `posix-sh`, `powershell`, or `cmd` out of band with its
|
||||
operator configuration. Platform must not infer shell support from command
|
||||
text. Empty `shell` means argv-oriented execution.
|
||||
|
||||
## Required local preflight
|
||||
|
||||
Before a write, install, or process action, Run validates the selected plan:
|
||||
|
||||
- absolute server root and working directory are allowed anywhere permitted by
|
||||
the local Run policy; they are not required to be adjacent to the Run binary;
|
||||
- the effective directory, executable, permissions, timeout, plugin version,
|
||||
and requested ports are locally valid;
|
||||
- selected shell kind and custom-command policy are enabled;
|
||||
- no raw command, path, secret, socket address, or credential is emitted in a
|
||||
result, diagnostic, log batch, or artifact name.
|
||||
|
||||
An `existing-server` plan may omit installation. A `custom-command` plan
|
||||
requires a start command. Guided templates remain plugin recommendations;
|
||||
Run owns their local resolution and execution.
|
||||
|
||||
## Safe progress reports
|
||||
|
||||
Run reports bounded progress with `percent`, `phase`, and a safe message. The
|
||||
allowed phase vocabulary is `queued`, `claimed`, `preflight`, `install`,
|
||||
`configure`, `start`, and `health`. On failure it reports a stable safe error
|
||||
code and summary such as `working-directory-unavailable`, never the supplied
|
||||
path or command text.
|
||||
|
||||
Platform treats preflight as authoritative. It does not open a direct shell,
|
||||
SSH connection, raw socket, or host filesystem to compensate for a failed
|
||||
preflight.
|
||||
@@ -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:
|
||||
|
||||
@@ -142,10 +142,22 @@ func appendProgressViolations(violations []string, progress domain.RunJobProgres
|
||||
if progress.Percent < 0 || progress.Percent > 100 {
|
||||
violations = append(violations, "progress.percent must be between 0 and 100")
|
||||
}
|
||||
if progress.Phase != "" && !validDeploymentProgressPhase(progress.Phase) {
|
||||
violations = append(violations, "progress.phase is invalid")
|
||||
}
|
||||
violations = appendMessageLength(violations, "progress.message", progress.Message)
|
||||
return violations
|
||||
}
|
||||
|
||||
func validDeploymentProgressPhase(phase string) bool {
|
||||
switch phase {
|
||||
case "queued", "claimed", "preflight", "install", "configure", "start", "health":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func appendMessageLength(violations []string, field string, message string) []string {
|
||||
if len(message) > maxJobChannelMessageLength {
|
||||
violations = append(violations, field+" is too long")
|
||||
|
||||
@@ -2,6 +2,7 @@ package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
@@ -155,6 +156,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
||||
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
|
||||
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
|
||||
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
|
||||
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
||||
return finish(violations)
|
||||
}
|
||||
@@ -182,6 +184,7 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
||||
if !safeRelativeJSONRef(manifest.Server.CreateFormSchema) {
|
||||
violations = append(violations, "manifest.server.createFormSchema must be a safe relative JSON reference")
|
||||
}
|
||||
violations = append(violations, validatePluginCreateFields("manifest.server.createFields", manifest.Server.CreateFields)...)
|
||||
for i, osName := range manifest.Server.SupportedOS {
|
||||
if !validPluginSupportedOS(osName) {
|
||||
violations = append(violations, fmt.Sprintf("manifest.server.supportedOs[%d] is not allowed", i))
|
||||
@@ -226,6 +229,122 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validatePluginCreateFields(prefix string, fields []domain.PluginCreateField) []string {
|
||||
if len(fields) > 32 {
|
||||
return []string{prefix + " must contain at most 32 fields"}
|
||||
}
|
||||
var violations []string
|
||||
keys := map[string]struct{}{}
|
||||
for i, field := range fields {
|
||||
item := fmt.Sprintf("%s[%d]", prefix, i)
|
||||
if !validDistributionLogicalKey(field.Key) {
|
||||
violations = append(violations, item+".key is invalid")
|
||||
}
|
||||
if _, found := keys[field.Key]; found {
|
||||
violations = append(violations, item+".key duplicates another field")
|
||||
}
|
||||
keys[field.Key] = struct{}{}
|
||||
if strings.TrimSpace(field.Label) == "" || len(field.Label) > 60 || strings.TrimSpace(field.Label) != field.Label {
|
||||
violations = append(violations, item+".label is invalid")
|
||||
}
|
||||
if !validPluginCreateFieldType(field.Type) {
|
||||
violations = append(violations, item+".type is invalid")
|
||||
}
|
||||
if len(field.DefaultValue) > 256 || strings.TrimSpace(field.DefaultValue) != field.DefaultValue || containsUnsafeRuntimeSecret(field.DefaultValue) || looksLikeRawHostPath(field.DefaultValue) {
|
||||
violations = append(violations, item+".defaultValue is unsafe")
|
||||
}
|
||||
if field.ConfigKey != "" && !validDistributionLogicalKey(field.ConfigKey) {
|
||||
violations = append(violations, item+".configKey is invalid")
|
||||
}
|
||||
if len(field.Options) > 32 {
|
||||
violations = append(violations, item+".options has too many values")
|
||||
}
|
||||
options := map[string]struct{}{}
|
||||
for _, option := range field.Options {
|
||||
if strings.TrimSpace(option) == "" || len(option) > 120 || strings.TrimSpace(option) != option || containsUnsafeRuntimeSecret(option) || looksLikeRawHostPath(option) {
|
||||
violations = append(violations, item+".options contains an unsafe value")
|
||||
break
|
||||
}
|
||||
if _, found := options[option]; found {
|
||||
violations = append(violations, item+".options contains duplicates")
|
||||
break
|
||||
}
|
||||
options[option] = struct{}{}
|
||||
}
|
||||
if field.Type == "select" && len(field.Options) == 0 {
|
||||
violations = append(violations, item+".options is required for select")
|
||||
}
|
||||
if field.Type != "select" && len(field.Options) > 0 {
|
||||
violations = append(violations, item+".options is only valid for select")
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validPluginCreateFieldType(value string) bool {
|
||||
switch value {
|
||||
case "text", "number", "boolean", "select", "port":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePluginCreateInputs keeps game settings distinct from runtime
|
||||
// bindings while enforcing the plugin's published, non-executable field set.
|
||||
func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[string]string) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
declared := make(map[string]domain.PluginCreateField, len(fields))
|
||||
for _, field := range fields {
|
||||
declared[field.Key] = field
|
||||
}
|
||||
var violations []string
|
||||
for key, value := range inputs {
|
||||
field, found := declared[key]
|
||||
if !found {
|
||||
violations = append(violations, "createInputs."+key+" is not declared by plugin")
|
||||
continue
|
||||
}
|
||||
if len(value) > 1024 || strings.TrimSpace(value) != value || containsUnsafeRuntimeSecret(value) {
|
||||
violations = append(violations, "createInputs."+key+" is invalid")
|
||||
continue
|
||||
}
|
||||
if value == "" {
|
||||
if field.Required && field.DefaultValue == "" {
|
||||
violations = append(violations, "createInputs."+key+" is required")
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch field.Type {
|
||||
case "port":
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
violations = append(violations, "createInputs."+key+" must be a port between 1 and 65535")
|
||||
}
|
||||
case "number":
|
||||
if _, err := strconv.ParseFloat(value, 64); err != nil {
|
||||
violations = append(violations, "createInputs."+key+" must be numeric")
|
||||
}
|
||||
case "boolean":
|
||||
if value != "true" && value != "false" {
|
||||
violations = append(violations, "createInputs."+key+" must be true or false")
|
||||
}
|
||||
case "select":
|
||||
if !containsString(field.Options, value) {
|
||||
violations = append(violations, "createInputs."+key+" is not an allowed option")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field.Required && field.DefaultValue == "" && strings.TrimSpace(inputs[field.Key]) == "" {
|
||||
violations = append(violations, "createInputs."+field.Key+" is required")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
|
||||
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
|
||||
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
||||
@@ -676,7 +795,9 @@ func validateServerInstance(instance domain.ServerInstance, allowDeleted bool) e
|
||||
violations = appendRequired(violations, "id", instance.ID)
|
||||
violations = appendRequired(violations, "pluginId", instance.PluginID)
|
||||
violations = appendRequired(violations, "pluginVersion", instance.PluginVersion)
|
||||
violations = appendRequired(violations, "runEndpointId", instance.RunEndpointID)
|
||||
if instance.State != domain.ServerInstanceStateDraft {
|
||||
violations = appendRequired(violations, "runEndpointId", instance.RunEndpointID)
|
||||
}
|
||||
violations = appendRequired(violations, "name", instance.Name)
|
||||
if strings.TrimSpace(instance.OwnerUserID) != instance.OwnerUserID {
|
||||
violations = append(violations, "ownerUserId must not have surrounding whitespace")
|
||||
@@ -702,9 +823,61 @@ func validateServerInstance(instance domain.ServerInstance, allowDeleted bool) e
|
||||
if instance.ConfigVersion < 0 {
|
||||
violations = append(violations, "configVersion must not be negative")
|
||||
}
|
||||
if err := ValidateServerDeploymentDefinition(instance.Deployment); err != nil {
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateServerDeploymentDefinition(definition domain.ServerDeploymentDefinition) error {
|
||||
if definition.Mode == "" {
|
||||
return nil
|
||||
}
|
||||
var violations []string
|
||||
if definition.Mode != domain.ServerDeploymentModeGuided && definition.Mode != domain.ServerDeploymentModeExisting && definition.Mode != domain.ServerDeploymentModeCustom {
|
||||
violations = append(violations, "deployment mode is invalid")
|
||||
}
|
||||
if definition.Shell != domain.ServerCommandShellNone && definition.Shell != domain.ServerCommandShellPosix && definition.Shell != domain.ServerCommandShellPowerShell && definition.Shell != domain.ServerCommandShellCmd {
|
||||
violations = append(violations, "deployment shell is invalid")
|
||||
}
|
||||
if definition.Revision < 0 {
|
||||
violations = append(violations, "deployment revision must not be negative")
|
||||
}
|
||||
for key, value := range definition.CreateInputs {
|
||||
if !validDistributionLogicalKey(key) || len(value) > 1024 || strings.TrimSpace(value) != value || containsUnsafeRuntimeSecret(value) {
|
||||
violations = append(violations, "deployment createInputs are invalid")
|
||||
break
|
||||
}
|
||||
}
|
||||
for key, value := range definition.RuntimeBindings {
|
||||
if !validDistributionLogicalKey(key) || strings.TrimSpace(value) != value || looksLikeRawHostPath(value) || containsUnsafeRuntimeSecret(value) {
|
||||
violations = append(violations, "deployment runtimeBindings are invalid")
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, value := range []string{definition.ServerRoot, definition.WorkingDirectory} {
|
||||
if value != "" && (len(value) > 1024 || strings.TrimSpace(value) != value || !looksLikeAbsoluteHostPath(value)) {
|
||||
violations = append(violations, "deployment path must be an absolute host path")
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, value := range []string{definition.InstallCommand, definition.StartCommand, definition.StopCommand, definition.StatusCommand} {
|
||||
if value != "" && (len(value) > 4096 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\x00\r\n") || containsUnsafeRuntimeSecret(value)) {
|
||||
violations = append(violations, "deployment command is invalid or contains a secret")
|
||||
break
|
||||
}
|
||||
}
|
||||
if definition.Mode == domain.ServerDeploymentModeCustom && strings.TrimSpace(definition.StartCommand) == "" {
|
||||
violations = append(violations, "custom deployment requires a start command")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func looksLikeAbsoluteHostPath(value string) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
return strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, `\\`) || (len(trimmed) >= 3 && trimmed[1] == ':' && (trimmed[2] == '\\' || trimmed[2] == '/'))
|
||||
}
|
||||
|
||||
func ValidateServerInstanceUpdate(update domain.ServerInstanceUpdate) error {
|
||||
var violations []string
|
||||
if update.Name != nil {
|
||||
@@ -973,6 +1146,9 @@ func ValidateJob(job domain.Job) error {
|
||||
if job.Progress.Percent < 0 || job.Progress.Percent > 100 {
|
||||
violations = append(violations, "progress.percent must be between 0 and 100")
|
||||
}
|
||||
if job.Progress.Phase != "" && !validDeploymentProgressPhase(job.Progress.Phase) {
|
||||
violations = append(violations, "progress.phase is invalid")
|
||||
}
|
||||
if len(job.Progress.Message) > maxProgressMessageLength {
|
||||
violations = append(violations, "progress.message is too long")
|
||||
}
|
||||
@@ -1581,6 +1757,7 @@ func validPluginRunCapability(capability string) bool {
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
||||
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
|
||||
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
|
||||
domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall,
|
||||
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
|
||||
|
||||
@@ -13,10 +13,14 @@ func ValidateServerLifecycleCreate(create domain.ServerLifecycleCreate) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", create.ID)
|
||||
violations = appendRequired(violations, "pluginId", create.PluginID)
|
||||
violations = appendRequired(violations, "runEndpointId", create.RunEndpointID)
|
||||
violations = appendRequired(violations, "name", create.Name)
|
||||
violations = appendRequired(violations, "profileKey", create.ProfileKey)
|
||||
if create.RunEndpointID != "" {
|
||||
violations = appendRequired(violations, "profileKey", create.ProfileKey)
|
||||
}
|
||||
violations = appendLifecycleIdempotencyViolations(violations, create.IdempotencyKey)
|
||||
if err := ValidateServerDeploymentDefinition(create.Deployment); err != nil {
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user