feat: support custom server deployment drafts
This commit is contained in:
@@ -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