feat: support custom server deployment drafts

This commit is contained in:
npc0-hue
2026-07-24 16:56:28 +08:00
parent 292b380f3c
commit 220ef91a8e
36 changed files with 1520 additions and 85 deletions
+2
View File
@@ -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)
+46
View File
@@ -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.
+1
View File
@@ -4,6 +4,7 @@ import "time"
type RunJobProgressReport struct {
Percent int
Phase string
Message string
}
+134
View File
@@ -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 = &copy
}
return job
}
+2
View File
@@ -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
View File
@@ -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,
}
}
+45 -4
View File
@@ -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,
}
}
+59 -9
View File
@@ -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,
+42
View File
@@ -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.
+18 -6
View File
@@ -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 &copy
}
func emptyJobClaim(runEndpointID string, stamp time.Time) domain.RunJobClaimResult {
return domain.RunJobClaimResult{Accepted: true, RunEndpointID: runEndpointID, NextPollSeconds: defaultJobPollSeconds, ServerTime: stamp}
}
+20 -5
View File
@@ -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
+189
View File
@@ -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)
}
}
+74 -11
View File
@@ -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 &copy
}
func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string {
switch action {
case domain.ServerLifecycleActionCreate:
+12
View File
@@ -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")
+178 -1
View File
@@ -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",
+6 -2
View File
@@ -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)
}