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
+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)
}