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
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-24
@@ -0,0 +1,76 @@
## Context
The platform currently creates an `installing` server instance only when a Run endpoint is already online. It records runtime bindings as safe logical references and dispatches only plugin/profile/action metadata. This prevents self-hosted operators from defining a server before Run is installed, from pointing at an existing absolute directory, and from using a nonstandard command such as a Python virtual environment launcher.
The independent Run repository remains the only host-side executor. Repository rules forbid returning host paths, raw credentials, or direct sockets from Run to Platform or plugins.
## Goals / Non-Goals
**Goals:**
- Persist an editable server deployment definition without a Run binding.
- Support guided install, existing-server adoption, and custom lifecycle command modes.
- Accept operator-entered absolute paths and command lines as protected write-only execution inputs.
- Render and validate plugin create fields, including port and player-count fields.
- Bind a saved definition to an online Run only at deployment/start time and send Run a versioned, redaction-safe execution plan.
- Surface queued, claimed, preflight, install, configure, start, and health stages to the operator.
**Non-Goals:**
- Platform-side SSH, shell execution, direct sockets, or a Run source tree in this repository.
- Cloud hosting, billing, provider marketplaces, or automatic network/firewall provisioning.
- Returning stored full paths, command text, or raw secrets through normal Platform APIs.
- Guaranteeing a generic command works on a node whose Run policy disallows it.
## Decisions
### 1. Separate deployment definitions from runtime bindings
Add a server deployment definition associated with a server instance. It holds deployment mode, plugin create inputs, protected path/command fields, configuration revision, and a binding state. Runtime bindings remain for plugin-declared logical adapters such as RCON and file transports.
This avoids weakening the existing logical-reference contract simply to accommodate physical deployment input. Reusing runtime bindings would make host paths appear in APIs that intentionally redact those values.
### 2. A server can exist as an unbound draft
`draft` is added as an editable server state. A draft has no `runEndpointId`, no queued lifecycle job, and can be created or edited before any Run registers. Binding and deployment are explicit later operations. `installing`, `ready`, `running`, `stopped`, and `failed` retain their existing lifecycle meaning.
The alternative—requiring a placeholder Run endpoint—would preserve the current coupling and create misleading jobs.
### 3. Paths and commands are protected write-only fields
The browser can submit full paths and command text as an operator action. Platform stores them in a protected deployment record and only sends them to the assigned Run via a leased job input. Read APIs return configured flags, a non-sensitive display mode, and a content fingerprint, never the value. Editing a protected value requires resubmission; an empty update preserves the stored value.
Raw credentials are rejected from commands and must be represented by secret references. This satisfies the host-path redaction rule while supporting real input such as `/srv/server/.venv/bin/python`.
### 4. Plugin templates are recommendations; custom commands override per lifecycle action
Plugins publish create-field schemas and optionally map inputs to recommended install/start/stop templates. Guided mode resolves these templates. Existing-server and custom-command modes permit an operator to provide a working directory plus install/start/stop commands; a missing install command is valid for adoption.
Run receives an argv-oriented command plan by default. A full shell command is allowed only when the operator explicitly selects a shell kind and the Run endpoint advertises the corresponding custom-process policy. This avoids accidental shell interpretation while allowing deliberate venv, batch, PowerShell, and shell-wrapper deployments.
### 5. Run preflight and lifecycle phases are first-class job progress
The lifecycle job execution input includes deployment revision, mode, protected plan, create inputs, and a bounded phase vocabulary: `queued`, `claimed`, `preflight`, `install`, `configure`, `start`, and `health`. Run validates paths/executables, policy, port availability, and plugin compatibility before any write. It reports only phase, percent, safe summary, and structured safe error code.
Platform shows these phases after submit and distinguishes an unclaimed job from a running job. A Run implementation is required in its independent repository; until it supports this input version, Platform must fail safely with an actionable compatibility reason.
## Risks / Trade-offs
- [A custom command can be destructive] → Require server-owner/node-operator authorization, Run policy opt-in, explicit shell selection, bounded timeout, command fingerprint audit, and confirmation before dispatch.
- [A path is sensitive operational data] → Treat it as write-only in read models and strip it from logs, job summaries, plugin bridge results, and diagnostics.
- [Existing persisted instances assume a Run endpoint] → Migrate existing records unchanged; only newly created drafts omit it.
- [Run protocol rollout lags Platform] → Version the execution input and make deployment unavailable with a clear compatibility result rather than silently ignoring user input.
- [Port collision cannot be known from Platform] → Validate form shape in Platform, then make the Run preflight authoritative and return its safe diagnostic.
## Migration Plan
1. Add deployment definition persistence and draft state while accepting all existing bound instances unchanged.
2. Release Platform/Web support for draft creation and protected deployment updates.
3. Release the versioned contract to Run; enable guided and custom dispatch only after Run reports the deployment-plan capability.
4. Update first-party SCUM and Minecraft templates and add Palworld only as a separate plugin change.
5. Roll back by retaining deployment definitions as drafts and refusing dispatch to incompatible Run versions; no host-side rollback is initiated automatically.
## Open Questions
- The independent Run repository must define its exact supported custom shell identifiers and endpoint policy advertisement.
- A separate Palworld plugin remains required; this change provides the shared deployment capability but does not invent a Palworld launcher.
@@ -0,0 +1,31 @@
## Why
服务器创建目前要求已注册的 Run 节点,并且只投递固定生命周期动作;插件声明的端口、人数和路径字段没有进入创建请求。真实自托管场景需要先创建和编辑服务器定义,再在 Run 可用时将其部署到任意用户指定的本机目录,并支持引导式或自定义命令部署。
## What Changes
- 新增可在未绑定 Run 时保存的服务器部署草稿,并允许随后绑定 Run 节点和执行部署。
- 新增部署方式:插件引导安装、接管已有服务器、用户自定义生命周期命令。
- 将插件声明的创建表单字段变为实际可渲染、校验和持久化的游戏配置输入;SCUM 和 Minecraft 首先使用该能力。
- 接受用户主动输入的完整服务器根目录、工作目录和命令,但将它们作为受保护执行输入:不在普通详情、任务摘要、日志或插件桥接中回显。
- 新增 Run 预检、部署阶段进度和可诊断的排队/领取/执行状态,替代“任务已派发”即结束的体验。
- **BREAKING** 扩展服务器创建与生命周期任务契约,使部署定义和受保护执行输入成为显式字段,而不是复用 runtime bindings。
## Capabilities
### New Capabilities
- `server-deployment-workflows`: 草稿、Run 绑定、部署方式、受保护执行输入、预检和阶段化部署状态。
- `plugin-create-configuration`: 插件创建字段的安全发布、渲染、校验与 SCUM/Minecraft 配置映射。
- `run-custom-process-execution`: Run 对用户定义安装/启动/停止命令的受策略控制执行与脱敏进度回报。
### Modified Capabilities
- None; the repository has no baseline OpenSpec capability specifications.
## Impact
- `platform/`:领域模型、DTO、验证、持久化、服务器生命周期服务、任务执行输入和 API。
- `platform_web/`:服务器创建向导、草稿编辑、部署进度与 API types/client。
- `plugins/`:创建 schema、SCUM/Minecraft 声明和生命周期动作模板。
- 独立 Run 仓库:需要实现新任务执行输入、路径预检、自定义进程策略及阶段进度;本仓库不包含其源码。
@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Plugin create schemas drive server creation input
The system SHALL publish validated plugin create-field schemas to the management console and SHALL render supported fields during server draft creation. Required fields, defaults, select options, numeric values, port values, and boolean values MUST be validated before saving.
#### Scenario: Create a SCUM definition
- **WHEN** an operator chooses the SCUM plugin
- **THEN** the console renders the declared server name, game port, query port, and maximum player fields with their declared defaults
### Requirement: Game configuration is distinct from runtime transport binding
The system SHALL persist plugin create inputs as deployment configuration and MUST NOT store game ports, player limits, paths, or startup commands in runtime binding records.
#### Scenario: Save Minecraft port settings
- **WHEN** an operator saves Minecraft game and RCON ports in a draft
- **THEN** the values are retained as plugin create configuration and runtime binding remains reserved for declared transports
@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Run executes protected custom lifecycle plans under declared policy
The system SHALL dispatch custom lifecycle commands only to a Run endpoint that advertises deployment-plan support and the selected execution policy. Run MUST perform local path, executable, timeout, and port preflight before executing a write or process action.
#### Scenario: Run accepts an argv custom start plan
- **WHEN** a compatible Run claims a custom start job using argv execution mode
- **THEN** it receives the protected working directory and arguments only through the leased execution input and reports a safe preflight result
### Requirement: Shell interpretation is explicit
The system SHALL require an explicit shell kind for a shell command string and MUST NOT infer shell interpretation from command text. The system MUST reject shell execution when the selected Run policy does not allow that shell kind.
#### Scenario: Disallowed shell command
- **WHEN** an operator selects a shell command mode unsupported by the assigned Run
- **THEN** dispatch fails with a safe policy error and does not execute the command
### Requirement: Run reports redacted phase progress
The Run contract SHALL report only a defined deployment phase, percent, and safe message or error code. It MUST NOT return raw host paths, raw command text, raw credentials, or direct socket values.
#### Scenario: Preflight path failure
- **WHEN** a configured working directory is unavailable on Run
- **THEN** Run reports a `preflight` failure with a safe reason without echoing the supplied absolute path
@@ -0,0 +1,40 @@
## ADDED Requirements
### Requirement: Server definitions can be saved before Run is available
The system SHALL allow an authorized server manager to create and edit a draft server definition without a Run endpoint binding. The system MUST NOT dispatch a lifecycle job for an unbound draft.
#### Scenario: Create an unbound draft
- **WHEN** an authorized user saves a server definition without selecting a Run endpoint
- **THEN** the system stores it in `draft` state and returns no install job
#### Scenario: Deploy a draft after Run registration
- **WHEN** an authorized user binds a draft to a compatible online Run endpoint and requests deployment
- **THEN** the system validates the deployment definition and queues the requested lifecycle job
### Requirement: Deployment modes support real self-hosted layouts
The system SHALL support `guided-install`, `existing-server`, and `custom-command` deployment modes. An operator MAY provide an absolute server root and working directory for all modes and lifecycle command definitions for custom-command mode.
#### Scenario: Adopt an existing Python virtual-environment server
- **WHEN** an operator saves existing-server or custom-command mode with an absolute working directory and a Python virtual-environment startup command
- **THEN** the system stores the protected execution input and does not require the server directory to be adjacent to Run
### Requirement: Protected execution inputs are not exposed by read APIs
The system SHALL treat supplied host paths and command text as protected execution inputs. List, detail, job, audit, log, and plugin bridge read responses MUST expose only configured state, deployment mode, and safe fingerprints or summaries.
#### Scenario: Read a configured custom deployment
- **WHEN** an authorized user reads a server deployment definition after saving a path and command
- **THEN** the response indicates the protected fields are configured without returning their values
### Requirement: Deployment requires an execution-capable Run only when dispatching
The system SHALL require an online compatible Run endpoint only for preflight, install, start, stop, or status dispatch. The system MUST reject dispatch when the assigned Run does not declare the versioned deployment-plan capability.
#### Scenario: Attempt deployment with incompatible Run
- **WHEN** an operator requests deployment against a Run that lacks deployment-plan support
- **THEN** the system returns a safe compatibility reason and does not queue an executable lifecycle job
### Requirement: Lifecycle status explains waiting and execution phases
The system SHALL surface whether a deployment job is queued, claimed, in preflight, installing, configuring, starting, or performing a health check. Safe Run failures MUST remain attached to the job and server state.
#### Scenario: Run has not claimed deployment
- **WHEN** a deployment job remains queued
- **THEN** the server UI identifies it as waiting for Run claim rather than reporting installation progress
@@ -0,0 +1,29 @@
## 1. Platform deployment contracts and persistence
- [x] 1.1 Add draft server state and protected deployment-definition domain, DTO, repository, and persistence contracts.
- [x] 1.2 Add validated create-input schemas, protected path/command updates, and safe deployment read projections.
- [x] 1.3 Add draft creation, later Run binding, and deployment dispatch services with versioned execution-plan input.
## 2. Run-facing lifecycle and diagnostics
- [x] 2.1 Extend lifecycle jobs with deployment-plan capability checks and redacted phase progress validation/projection.
- [x] 2.2 Document the independent Run contract for preflight, custom argv/shell policy, and safe phase reports.
- [x] 2.3 Add Platform service/API tests for unbound drafts, protected input redaction, compatibility rejection, and deployment dispatch.
## 3. Plugin declarations
- [x] 3.1 Extend plugin create-form declarations with supported field metadata and recommended deployment-template mappings.
- [x] 3.2 Update SCUM and Minecraft declarations with usable guided-install fields and defaults; preserve their runtime-binding semantics.
- [x] 3.3 Add manifest validation tests for the new create/deployment declaration rules.
## 4. Management console workflows
- [x] 4.1 Add API types/client/contracts/schemas for draft creation, deployment-definition updates, Run binding, and deploy dispatch.
- [x] 4.2 Replace the create dialog with a staged modal workflow that renders plugin fields and deployment modes, including protected full-path and custom-command inputs.
- [x] 4.3 Add server detail deployment editing and stage-aware job status without rendering protected inputs.
- [x] 4.4 Add focused frontend tests for field rendering, redaction, drafts, and queued/claimed/preflight status copy.
## 5. Verification and delivery
- [x] 5.1 Run focused backend, plugin, and frontend verification plus `scripts/check-structure.sh`.
- [x] 5.2 Run `openspec validate support-custom-server-deployment-workflows --strict`, mark verified tasks complete, stage only this task's files, commit, and push the current branch.
+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}/process/status", h.serverInstanceProcessStatus)
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions) 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}/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}/remote-adapters", h.remoteAdapters)
mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands) mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands)
mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate) 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)) 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 // serverInstanceStart godoc
// @Summary Start server instance // @Summary Start server instance
// @Description Validates lifecycle state and config version, then queues a start job through the platform job channel. // @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 { type RunJobProgressReport struct {
Percent int Percent int
Phase string
Message string Message string
} }
+134
View File
@@ -320,11 +320,25 @@ type GamePluginBridge struct {
Actions []string 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 GamePluginManifestServer struct {
Type string Type string
DisplayName string DisplayName string
SupportedOS []string SupportedOS []string
CreateFormSchema string CreateFormSchema string
CreateFields []PluginCreateField
} }
type GamePluginManifestAI struct { type GamePluginManifestAI struct {
@@ -554,6 +568,7 @@ type GamePlugin struct {
SupportedOS []string SupportedOS []string
ManifestRef string ManifestRef string
CreateFormSchemaRef string CreateFormSchemaRef string
CreateFields []PluginCreateField
RequiredRunCapabilities []string RequiredRunCapabilities []string
DeclaredPermissions []string DeclaredPermissions []string
Permissions PluginPermissions Permissions PluginPermissions
@@ -580,6 +595,7 @@ type PluginMarketplacePlugin struct {
SupportedOS []string SupportedOS []string
ManifestRef string ManifestRef string
CreateFormSchemaRef string CreateFormSchemaRef string
CreateFields []PluginCreateField
Capabilities []string Capabilities []string
DeclaredPermissions []string DeclaredPermissions []string
Permissions PluginPermissions Permissions PluginPermissions
@@ -676,6 +692,9 @@ type ServerInstance struct {
ConfigUpdatedAt time.Time ConfigUpdatedAt time.Time
CreatedAt time.Time CreatedAt time.Time
UpdatedAt 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 { type ServerInstanceUpdate struct {
@@ -715,6 +734,77 @@ type ServerConfig struct {
UpdatedAt time.Time 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 { type ConfigDiffLine struct {
Kind string Kind string
OldNumber int OldNumber int
@@ -821,6 +911,12 @@ const (
JobCapabilityDependenciesCheck = "dependencies.check" JobCapabilityDependenciesCheck = "dependencies.check"
JobCapabilityDependenciesInstall = "dependencies.install" JobCapabilityDependenciesInstall = "dependencies.install"
JobCapabilityLogsBackfill = "logs.backfill" 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 { type RunEndpoint struct {
@@ -837,6 +933,7 @@ type RunEndpoint struct {
type JobProgress struct { type JobProgress struct {
Percent int Percent int
Phase string
Message string Message string
} }
@@ -861,6 +958,7 @@ type JobExecutionInput struct {
Inputs map[string]string Inputs map[string]string
DLLExtensions []RuntimeDLLExtensionPlan DLLExtensions []RuntimeDLLExtensionPlan
SourceRCON *RuntimeSourceRCONPlan SourceRCON *RuntimeSourceRCONPlan
Deployment *ServerDeploymentDefinition
} }
type JobExecutionResult struct { type JobExecutionResult struct {
@@ -1372,6 +1470,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
plugin.RequiredRunCapabilities = CopyStringSlice(plugin.RequiredRunCapabilities) plugin.RequiredRunCapabilities = CopyStringSlice(plugin.RequiredRunCapabilities)
plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions) plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions)
plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS) plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS)
plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields)
plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions) plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions)
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages) plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
plugin.Tags = CopyStringSlice(plugin.Tags) plugin.Tags = CopyStringSlice(plugin.Tags)
@@ -1387,6 +1486,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketplacePlugin { func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketplacePlugin {
plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS) plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS)
plugin.Capabilities = CopyStringSlice(plugin.Capabilities) plugin.Capabilities = CopyStringSlice(plugin.Capabilities)
plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields)
plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions) plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions)
plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions) plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions)
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages) plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
@@ -1419,6 +1519,7 @@ func CopyGamePluginManifestRegistration(registration GamePluginManifestRegistrat
func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest { func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
manifest.Tags = CopyStringSlice(manifest.Tags) manifest.Tags = CopyStringSlice(manifest.Tags)
manifest.Server.SupportedOS = CopyStringSlice(manifest.Server.SupportedOS) manifest.Server.SupportedOS = CopyStringSlice(manifest.Server.SupportedOS)
manifest.Server.CreateFields = CopyPluginCreateFields(manifest.Server.CreateFields)
manifest.Bridge.Actions = CopyStringSlice(manifest.Bridge.Actions) manifest.Bridge.Actions = CopyStringSlice(manifest.Bridge.Actions)
manifest.Capabilities = CopyStringSlice(manifest.Capabilities) manifest.Capabilities = CopyStringSlice(manifest.Capabilities)
manifest.Permissions = CopyStringSlice(manifest.Permissions) manifest.Permissions = CopyStringSlice(manifest.Permissions)
@@ -1431,6 +1532,17 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
return manifest 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 { func CopyGamePluginProductionLifecycle(lifecycle GamePluginProductionLifecycle) GamePluginProductionLifecycle {
lifecycle.Operations = CopyStringSlice(lifecycle.Operations) lifecycle.Operations = CopyStringSlice(lifecycle.Operations)
lifecycle.ApprovalRequired = CopyStringSlice(lifecycle.ApprovalRequired) lifecycle.ApprovalRequired = CopyStringSlice(lifecycle.ApprovalRequired)
@@ -1524,9 +1636,27 @@ func CopyPluginBridgeExecuteResponse(response PluginBridgeExecuteResponse) Plugi
func CopyServerInstance(instance ServerInstance) ServerInstance { func CopyServerInstance(instance ServerInstance) ServerInstance {
instance.AdminUserIDs = CopyStringSlice(instance.AdminUserIDs) instance.AdminUserIDs = CopyStringSlice(instance.AdminUserIDs)
instance.Deployment = CopyServerDeploymentDefinition(instance.Deployment)
return instance 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 { func CopyPlatformResourceUsage(usage PlatformResourceUsage) PlatformResourceUsage {
return usage return usage
} }
@@ -1583,6 +1713,10 @@ func CopyJob(job Job) Job {
job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs) job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs)
job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...) job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)
job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON) job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON)
if job.ExecutionInput.Deployment != nil {
copy := CopyServerDeploymentDefinition(*job.ExecutionInput.Deployment)
job.ExecutionInput.Deployment = &copy
}
return job return job
} }
+2
View File
@@ -25,6 +25,7 @@ type ServerLifecycleCreate struct {
IdempotencyKey string IdempotencyKey string
ProfileKey string ProfileKey string
Bindings map[string]string Bindings map[string]string
Deployment ServerDeploymentDefinition
} }
type ServerLifecycleCommand struct { type ServerLifecycleCommand struct {
@@ -57,6 +58,7 @@ func LifecycleCapabilityForAction(action ServerLifecycleAction) string {
func CopyServerLifecycleCreate(create ServerLifecycleCreate) ServerLifecycleCreate { func CopyServerLifecycleCreate(create ServerLifecycleCreate) ServerLifecycleCreate {
create.Bindings = CopyStringMap(create.Bindings) create.Bindings = CopyStringMap(create.Bindings)
create.Deployment = CopyServerDeploymentDefinition(create.Deployment)
return create return create
} }
+43 -15
View File
@@ -92,20 +92,38 @@ type RunJobResultRequest struct {
} }
type RunJobExecutionInputBody struct { type RunJobExecutionInputBody struct {
WorkspaceScope string `json:"workspaceScope,omitempty"` WorkspaceScope string `json:"workspaceScope,omitempty"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
ExpectedVersion int `json:"expectedVersion,omitempty"` ExpectedVersion int `json:"expectedVersion,omitempty"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"` ExpectedChecksum string `json:"expectedChecksum,omitempty"`
MaxReadBytes int `json:"maxReadBytes,omitempty"` MaxReadBytes int `json:"maxReadBytes,omitempty"`
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"` RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"` RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"` TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
PluginID string `json:"pluginId,omitempty"` PluginID string `json:"pluginId,omitempty"`
LifecycleOperation string `json:"lifecycleOperation,omitempty"` LifecycleOperation string `json:"lifecycleOperation,omitempty"`
TargetVersion string `json:"targetVersion,omitempty"` TargetVersion string `json:"targetVersion,omitempty"`
Inputs map[string]string `json:"inputs,omitempty"` Inputs map[string]string `json:"inputs,omitempty"`
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"` DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,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 { type RuntimeSourceRCONPlanBody struct {
@@ -565,7 +583,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
State: assignment.State, State: assignment.State,
Progress: progressReportFromDomain(assignment.Progress), Progress: progressReportFromDomain(assignment.Progress),
ResultRef: assignment.ResultRef, 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, LeaseToken: assignment.LeaseToken,
Attempt: assignment.Attempt, Attempt: assignment.Attempt,
MaxAttempts: assignment.MaxAttempts, 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 { func runtimeSourceRCONPlanFromDomain(plan *domain.RuntimeSourceRCONPlan) *RuntimeSourceRCONPlanBody {
if plan == nil { if plan == nil {
return nil return nil
@@ -588,6 +614,7 @@ func runtimeSourceRCONPlanFromDomain(plan *domain.RuntimeSourceRCONPlan) *Runtim
func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressReport { func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressReport {
return domain.RunJobProgressReport{ return domain.RunJobProgressReport{
Percent: progress.Percent, Percent: progress.Percent,
Phase: progress.Phase,
Message: progress.Message, Message: progress.Message,
} }
} }
@@ -595,6 +622,7 @@ func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressRepor
func progressReportFromDomain(progress domain.RunJobProgressReport) JobProgressBody { func progressReportFromDomain(progress domain.RunJobProgressReport) JobProgressBody {
return JobProgressBody{ return JobProgressBody{
Percent: progress.Percent, Percent: progress.Percent,
Phase: progress.Phase,
Message: progress.Message, Message: progress.Message,
} }
} }
+45 -4
View File
@@ -187,11 +187,22 @@ type GamePluginBridgeBody struct {
Actions []string `json:"actions"` 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 GamePluginManifestServerBody struct {
Type string `json:"type"` Type string `json:"type"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
SupportedOS []string `json:"supportedOs,omitempty"` SupportedOS []string `json:"supportedOs,omitempty"`
CreateFormSchema string `json:"createFormSchema"` CreateFormSchema string `json:"createFormSchema"`
CreateFields []PluginCreateFieldBody `json:"createFields,omitempty"`
} }
type GamePluginManifestAIBody struct { type GamePluginManifestAIBody struct {
@@ -314,6 +325,7 @@ type GamePluginCreateRequest struct {
SupportedOS []string `json:"supportedOs,omitempty"` SupportedOS []string `json:"supportedOs,omitempty"`
ManifestRef string `json:"manifestRef"` ManifestRef string `json:"manifestRef"`
CreateFormSchemaRef string `json:"createFormSchemaRef"` CreateFormSchemaRef string `json:"createFormSchemaRef"`
CreateFields []PluginCreateFieldBody `json:"createFields,omitempty"`
RequiredRunCapabilities []string `json:"requiredRunCapabilities"` RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
DeclaredPermissions []string `json:"declaredPermissions,omitempty"` DeclaredPermissions []string `json:"declaredPermissions,omitempty"`
Permissions PluginPermissionsResponse `json:"permissions"` Permissions PluginPermissionsResponse `json:"permissions"`
@@ -339,6 +351,7 @@ type GamePluginResponse struct {
SupportedOS []string `json:"supportedOs,omitempty"` SupportedOS []string `json:"supportedOs,omitempty"`
ManifestRef string `json:"manifestRef"` ManifestRef string `json:"manifestRef"`
CreateFormSchemaRef string `json:"createFormSchemaRef"` CreateFormSchemaRef string `json:"createFormSchemaRef"`
CreateFields []PluginCreateFieldBody `json:"createFields,omitempty"`
RequiredRunCapabilities []string `json:"requiredRunCapabilities"` RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
DeclaredPermissions []string `json:"declaredPermissions"` DeclaredPermissions []string `json:"declaredPermissions"`
Permissions PluginPermissionsResponse `json:"permissions"` Permissions PluginPermissionsResponse `json:"permissions"`
@@ -656,6 +669,7 @@ type JobCreateRequest struct {
type JobProgressBody struct { type JobProgressBody struct {
Percent int `json:"percent"` Percent int `json:"percent"`
Phase string `json:"phase,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
} }
@@ -929,12 +943,35 @@ func (bridge GamePluginBridgeBody) ToDomain() domain.GamePluginBridge {
return domain.GamePluginBridge{Actions: domain.CopyStringSlice(bridge.Actions)} 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 { func (server GamePluginManifestServerBody) ToDomain() domain.GamePluginManifestServer {
return domain.GamePluginManifestServer{ return domain.GamePluginManifestServer{
Type: server.Type, Type: server.Type,
DisplayName: server.DisplayName, DisplayName: server.DisplayName,
SupportedOS: domain.CopyStringSlice(server.SupportedOS), SupportedOS: domain.CopyStringSlice(server.SupportedOS),
CreateFormSchema: server.CreateFormSchema, CreateFormSchema: server.CreateFormSchema,
CreateFields: pluginCreateFieldsToDomain(server.CreateFields),
} }
} }
@@ -1001,6 +1038,7 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
SupportedOS: domain.CopyStringSlice(request.SupportedOS), SupportedOS: domain.CopyStringSlice(request.SupportedOS),
ManifestRef: request.ManifestRef, ManifestRef: request.ManifestRef,
CreateFormSchemaRef: request.CreateFormSchemaRef, CreateFormSchemaRef: request.CreateFormSchemaRef,
CreateFields: pluginCreateFieldsToDomain(request.CreateFields),
RequiredRunCapabilities: domain.CopyStringSlice(request.RequiredRunCapabilities), RequiredRunCapabilities: domain.CopyStringSlice(request.RequiredRunCapabilities),
DeclaredPermissions: domain.CopyStringSlice(request.DeclaredPermissions), DeclaredPermissions: domain.CopyStringSlice(request.DeclaredPermissions),
Permissions: permissionsToDomain(request.Permissions), Permissions: permissionsToDomain(request.Permissions),
@@ -1247,6 +1285,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
SupportedOS: plugin.SupportedOS, SupportedOS: plugin.SupportedOS,
ManifestRef: plugin.ManifestRef, ManifestRef: plugin.ManifestRef,
CreateFormSchemaRef: plugin.CreateFormSchemaRef, CreateFormSchemaRef: plugin.CreateFormSchemaRef,
CreateFields: pluginCreateFieldsFromDomain(plugin.CreateFields),
RequiredRunCapabilities: plugin.RequiredRunCapabilities, RequiredRunCapabilities: plugin.RequiredRunCapabilities,
DeclaredPermissions: plugin.DeclaredPermissions, DeclaredPermissions: plugin.DeclaredPermissions,
Permissions: permissionsFromDomain(plugin.Permissions), Permissions: permissionsFromDomain(plugin.Permissions),
@@ -1785,6 +1824,7 @@ func capacityToDomain(capacity RunCapacityResponse) domain.RunCapacity {
func progressFromDomain(progress domain.JobProgress) JobProgressBody { func progressFromDomain(progress domain.JobProgress) JobProgressBody {
return JobProgressBody{ return JobProgressBody{
Percent: progress.Percent, Percent: progress.Percent,
Phase: progress.Phase,
Message: progress.Message, Message: progress.Message,
} }
} }
@@ -1792,6 +1832,7 @@ func progressFromDomain(progress domain.JobProgress) JobProgressBody {
func progressToDomain(progress JobProgressBody) domain.JobProgress { func progressToDomain(progress JobProgressBody) domain.JobProgress {
return domain.JobProgress{ return domain.JobProgress{
Percent: progress.Percent, Percent: progress.Percent,
Phase: progress.Phase,
Message: progress.Message, Message: progress.Message,
} }
} }
+59 -9
View File
@@ -1,16 +1,52 @@
package dto 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 { type ServerLifecycleCreateRequest struct {
ID string `json:"id"` ID string `json:"id"`
PluginID string `json:"pluginId"` PluginID string `json:"pluginId"`
RunEndpointID string `json:"runEndpointId"` RunEndpointID string `json:"runEndpointId"`
Name string `json:"name"` Name string `json:"name"`
OwnerUserID string `json:"ownerUserId,omitempty"` OwnerUserID string `json:"ownerUserId,omitempty"`
IdempotencyKey string `json:"idempotencyKey"` IdempotencyKey string `json:"idempotencyKey"`
ProfileKey string `json:"profileKey"` ProfileKey string `json:"profileKey"`
Bindings map[string]string `json:"bindings,omitempty"` Bindings map[string]string `json:"bindings,omitempty"`
Deployment ServerDeploymentRequest `json:"deployment,omitempty"`
} }
type ServerLifecycleCommandRequest struct { type ServerLifecycleCommandRequest struct {
@@ -35,9 +71,23 @@ func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCre
IdempotencyKey: request.IdempotencyKey, IdempotencyKey: request.IdempotencyKey,
ProfileKey: request.ProfileKey, ProfileKey: request.ProfileKey,
Bindings: domain.CopyStringMap(request.Bindings), 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 { func (request ServerLifecycleCommandRequest) ToDomain(serverInstanceID string) domain.ServerLifecycleCommand {
return domain.ServerLifecycleCommand{ return domain.ServerLifecycleCommand{
ServerInstanceID: serverInstanceID, 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 = normalizeJobScheduling(job, stamp)
job.Attempt++ job.Attempt++
job.State = domain.JobStateAccepted 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.NextAttemptAt = time.Time{}
job.LeaseTokenHash = tokenHash(leaseToken) job.LeaseTokenHash = tokenHash(leaseToken)
job.LeaseSessionGen = session.Generation job.LeaseSessionGen = session.Generation
@@ -147,7 +151,7 @@ func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (do
if progress.Sequence > 0 && progress.Sequence <= job.LastProgressSeq { if progress.Sequence > 0 && progress.Sequence <= job.LastProgressSeq {
return domain.RunJobProgressResult{}, validationError("progress sequence is stale") 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 { if progress.Sequence > 0 {
job.LastProgressSeq = progress.Sequence 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() { 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 { if err := svc.scheduleJobRetry(&job, stamp, "retryable Run failure"); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
@@ -216,7 +220,7 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
} }
job.State = result.State 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.ResultRef = result.ResultRef
job.ExecutionResult = result.ExecutionResult job.ExecutionResult = result.ExecutionResult
job.TerminalAt = stamp job.TerminalAt = stamp
@@ -598,9 +602,9 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
InputRef: job.InputRef, InputRef: job.InputRef,
IdempotencyKey: job.IdempotencyKey, IdempotencyKey: job.IdempotencyKey,
State: job.State, 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, 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, LeaseToken: leaseToken,
Attempt: job.Attempt, Attempt: job.Attempt,
MaxAttempts: job.RetryPolicy.MaxAttempts, 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 { func emptyJobClaim(runEndpointID string, stamp time.Time) domain.RunJobClaimResult {
return domain.RunJobClaimResult{Accepted: true, RunEndpointID: runEndpointID, NextPollSeconds: defaultJobPollSeconds, ServerTime: stamp} 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) CreateServerInstanceForSession(string, domain.ServerInstance) (domain.ServerInstance, error)
CreateServerInstanceWorkflow(domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) CreateServerInstanceWorkflow(domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error)
CreateServerInstanceWorkflowForSession(string, 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) StartServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
@@ -693,6 +696,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
SupportedOS: manifest.Server.SupportedOS, SupportedOS: manifest.Server.SupportedOS,
ManifestRef: registration.ManifestRef, ManifestRef: registration.ManifestRef,
CreateFormSchemaRef: manifest.Server.CreateFormSchema, CreateFormSchemaRef: manifest.Server.CreateFormSchema,
CreateFields: manifest.Server.CreateFields,
RequiredRunCapabilities: manifest.Capabilities, RequiredRunCapabilities: manifest.Capabilities,
DeclaredPermissions: manifest.Permissions, DeclaredPermissions: manifest.Permissions,
Permissions: pluginPermissionsFromManifest(manifest.Permissions), Permissions: pluginPermissionsFromManifest(manifest.Permissions),
@@ -1458,6 +1462,7 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark
SupportedOS: plugin.SupportedOS, SupportedOS: plugin.SupportedOS,
ManifestRef: plugin.ManifestRef, ManifestRef: plugin.ManifestRef,
CreateFormSchemaRef: plugin.CreateFormSchemaRef, CreateFormSchemaRef: plugin.CreateFormSchemaRef,
CreateFields: plugin.CreateFields,
Capabilities: plugin.RequiredRunCapabilities, Capabilities: plugin.RequiredRunCapabilities,
DeclaredPermissions: plugin.DeclaredPermissions, DeclaredPermissions: plugin.DeclaredPermissions,
Permissions: plugin.Permissions, Permissions: plugin.Permissions,
@@ -1534,9 +1539,12 @@ func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (do
if err != nil { if err != nil {
return domain.ServerInstance{}, fmt.Errorf("get plugin dependency: %w", err) return domain.ServerInstance{}, fmt.Errorf("get plugin dependency: %w", err)
} }
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) var endpoint domain.RunEndpoint
if err != nil { if strings.TrimSpace(instance.RunEndpointID) != "" {
return domain.ServerInstance{}, fmt.Errorf("get run endpoint 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)
}
} }
if instance.PluginVersion == "" { if instance.PluginVersion == "" {
@@ -1559,8 +1567,15 @@ func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (do
if err := validator.ValidateServerInstance(instance); err != nil { if err := validator.ValidateServerInstance(instance); err != nil {
return domain.ServerInstance{}, err return domain.ServerInstance{}, err
} }
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { if instance.State != domain.ServerInstanceStateDraft && strings.TrimSpace(instance.RunEndpointID) == "" {
return domain.ServerInstance{}, err 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 { if err := svc.store.ServerInstances().Create(instance); err != nil {
return domain.ServerInstance{}, err 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 return domain.ServerLifecycleResult{}, err
} }
plugin, endpoint, err := svc.lifecycleDependencies(create.PluginID, create.RunEndpointID) plugin, err := svc.store.GamePlugins().Get(create.PluginID)
if err != nil { 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 { if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
return domain.ServerLifecycleResult{}, err 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() stamp := svc.now()
instance := domain.ServerInstance{ instance := domain.ServerInstance{
@@ -39,31 +47,63 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
ConfigKey: "server.properties", ConfigKey: "server.properties",
CreatedAt: stamp, CreatedAt: stamp,
UpdatedAt: 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.ConfigContent = buildLogicalServerConfig(instance)
instance.ConfigChecksum = validator.BytesChecksum([]byte(instance.ConfigContent)) instance.ConfigChecksum = validator.BytesChecksum([]byte(instance.ConfigContent))
instance.ConfigUpdatedAt = stamp instance.ConfigUpdatedAt = stamp
if strings.TrimSpace(create.RunEndpointID) == "" {
instance.State = domain.ServerInstanceStateDraft
}
if err := validator.ValidateServerInstance(instance); err != nil { if err := validator.ValidateServerInstance(instance); err != nil {
return domain.ServerLifecycleResult{}, err 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 { if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
return domain.ServerLifecycleResult{}, err 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 { if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: create.ProfileKey, Bindings: create.Bindings}, true) var binding domain.RuntimeBinding
if err != nil { hasBinding := strings.TrimSpace(create.ProfileKey) != ""
return domain.ServerLifecycleResult{}, err 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 { if err := svc.store.ServerInstances().Create(instance); err != nil {
return domain.ServerLifecycleResult{}, err return domain.ServerLifecycleResult{}, err
} }
if err := svc.store.RuntimeBindings().Create(binding); err != nil { if hasBinding {
return domain.ServerLifecycleResult{}, err if err := svc.store.RuntimeBindings().Create(binding); err != nil {
return domain.ServerLifecycleResult{}, err
}
} }
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey) 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) { func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, action domain.ServerLifecycleAction, idempotencyKey string) (domain.Job, error) {
capability := domain.LifecycleCapabilityForAction(action) capability := domain.LifecycleCapabilityForAction(action)
binding, err := svc.runtimeBindingForServer(instance.ID) binding, err := svc.runtimeBindingForServer(instance.ID)
if err != nil { if err != nil && !errors.Is(err, repo.ErrNotFound) {
return domain.Job{}, err return domain.Job{}, err
} }
plugin, err := svc.store.GamePlugins().Get(instance.PluginID) plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil { if err != nil {
return domain.Job{}, err return domain.Job{}, err
} }
actionRef := binding.ProfileKey profileKey := ""
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey) if err == nil {
profileKey = binding.ProfileKey
} else {
profileKey = instance.Deployment.ProfileKey
}
actionRef := profileKey
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey)
if hasProfile { if hasProfile {
if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" { if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" {
actionRef = ref actionRef = ref
@@ -245,11 +291,13 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
Capability: capability, Capability: capability,
TargetKey: actionRef, TargetKey: actionRef,
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
Progress: lifecycleJobProgress(instance.Deployment),
ExecutionInput: domain.JobExecutionInput{ ExecutionInput: domain.JobExecutionInput{
WorkspaceScope: binding.ProfileKey, WorkspaceScope: profileKey,
PluginID: plugin.ID, PluginID: plugin.ID,
LifecycleOperation: lifecycleExecutionOperation(action), LifecycleOperation: lifecycleExecutionOperation(action),
DLLExtensions: dllExtensions, DLLExtensions: dllExtensions,
Deployment: deploymentPlanForDispatch(instance.Deployment),
}, },
}) })
if err != nil { if err != nil {
@@ -261,6 +309,21 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
return job, nil 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 { func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string {
switch action { switch action {
case domain.ServerLifecycleActionCreate: case domain.ServerLifecycleActionCreate:
+12
View File
@@ -142,10 +142,22 @@ func appendProgressViolations(violations []string, progress domain.RunJobProgres
if progress.Percent < 0 || progress.Percent > 100 { if progress.Percent < 0 || progress.Percent > 100 {
violations = append(violations, "progress.percent must be between 0 and 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) violations = appendMessageLength(violations, "progress.message", progress.Message)
return violations 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 { func appendMessageLength(violations []string, field string, message string) []string {
if len(message) > maxJobChannelMessageLength { if len(message) > maxJobChannelMessageLength {
violations = append(violations, field+" is too long") violations = append(violations, field+" is too long")
+178 -1
View File
@@ -2,6 +2,7 @@ package validator
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"browser.local/platform/domain" "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, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...) 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, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...) violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
return finish(violations) return finish(violations)
} }
@@ -182,6 +184,7 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
if !safeRelativeJSONRef(manifest.Server.CreateFormSchema) { if !safeRelativeJSONRef(manifest.Server.CreateFormSchema) {
violations = append(violations, "manifest.server.createFormSchema must be a safe relative JSON reference") 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 { for i, osName := range manifest.Server.SupportedOS {
if !validPluginSupportedOS(osName) { if !validPluginSupportedOS(osName) {
violations = append(violations, fmt.Sprintf("manifest.server.supportedOs[%d] is not allowed", i)) 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) 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 { func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) 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 { 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, "id", instance.ID)
violations = appendRequired(violations, "pluginId", instance.PluginID) violations = appendRequired(violations, "pluginId", instance.PluginID)
violations = appendRequired(violations, "pluginVersion", instance.PluginVersion) 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) violations = appendRequired(violations, "name", instance.Name)
if strings.TrimSpace(instance.OwnerUserID) != instance.OwnerUserID { if strings.TrimSpace(instance.OwnerUserID) != instance.OwnerUserID {
violations = append(violations, "ownerUserId must not have surrounding whitespace") 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 { if instance.ConfigVersion < 0 {
violations = append(violations, "configVersion must not be negative") violations = append(violations, "configVersion must not be negative")
} }
if err := ValidateServerDeploymentDefinition(instance.Deployment); err != nil {
violations = append(violations, err.Error())
}
return finish(violations) 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 { func ValidateServerInstanceUpdate(update domain.ServerInstanceUpdate) error {
var violations []string var violations []string
if update.Name != nil { if update.Name != nil {
@@ -973,6 +1146,9 @@ func ValidateJob(job domain.Job) error {
if job.Progress.Percent < 0 || job.Progress.Percent > 100 { if job.Progress.Percent < 0 || job.Progress.Percent > 100 {
violations = append(violations, "progress.percent must be between 0 and 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 { if len(job.Progress.Message) > maxProgressMessageLength {
violations = append(violations, "progress.message is too long") violations = append(violations, "progress.message is too long")
} }
@@ -1581,6 +1757,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall,
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write", "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 var violations []string
violations = appendRequired(violations, "id", create.ID) violations = appendRequired(violations, "id", create.ID)
violations = appendRequired(violations, "pluginId", create.PluginID) violations = appendRequired(violations, "pluginId", create.PluginID)
violations = appendRequired(violations, "runEndpointId", create.RunEndpointID)
violations = appendRequired(violations, "name", create.Name) 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) violations = appendLifecycleIdempotencyViolations(violations, create.IdempotencyKey)
if err := ValidateServerDeploymentDefinition(create.Deployment); err != nil {
violations = append(violations, err.Error())
}
return finish(violations) return finish(violations)
} }
+14
View File
@@ -88,6 +88,8 @@ import type {
ServerLifecycleCommandRequest, ServerLifecycleCommandRequest,
ServerLifecycleCreateRequest, ServerLifecycleCreateRequest,
ServerLifecycleResponse, ServerLifecycleResponse,
ServerDeploymentRequest,
ServerDeploymentResponse,
ServerConfigWriteApprovalRequest, ServerConfigWriteApprovalRequest,
ServerConfigWriteDispatchResponse, ServerConfigWriteDispatchResponse,
SourceRCONCommandRequest, SourceRCONCommandRequest,
@@ -185,6 +187,18 @@ export class PlatformApiClient {
}); });
} }
async getServerDeployment(id: string): Promise<ServerDeploymentResponse> {
return this.request<ServerDeploymentResponse>(`/server-instances/${encodeURIComponent(id)}/deployment`);
}
async updateServerDeployment(id: string, request: ServerDeploymentRequest): Promise<ServerDeploymentResponse> {
return this.request<ServerDeploymentResponse>(`/server-instances/${encodeURIComponent(id)}/deployment`, { method: "PUT", body: request });
}
async deployServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/deploy`, { method: "POST", body: request });
}
async getServerRuntimeBinding(id: string): Promise<RuntimeBindingResponse> { async getServerRuntimeBinding(id: string): Promise<RuntimeBindingResponse> {
return this.request<RuntimeBindingResponse>(`/server-instances/${encodeURIComponent(id)}/runtime-binding`); return this.request<RuntimeBindingResponse>(`/server-instances/${encodeURIComponent(id)}/runtime-binding`);
} }
+54 -3
View File
@@ -342,6 +342,7 @@ export interface GamePluginResponse {
supportedOs?: string[]; supportedOs?: string[];
manifestRef: string; manifestRef: string;
createFormSchemaRef: string; createFormSchemaRef: string;
createFields?: PluginCreateFieldResponse[];
requiredRunCapabilities: string[]; requiredRunCapabilities: string[];
declaredPermissions: string[]; declaredPermissions: string[];
permissions: PluginPermissionsResponse; permissions: PluginPermissionsResponse;
@@ -357,6 +358,18 @@ export interface GamePluginResponse {
status: GamePluginStatus; status: GamePluginStatus;
} }
export type PluginCreateFieldType = "text" | "number" | "boolean" | "select" | "port";
export interface PluginCreateFieldResponse {
key: string;
label: string;
type: PluginCreateFieldType;
required?: boolean;
defaultValue?: string;
options?: string[];
configKey?: string;
}
export interface GamePluginListResponse { export interface GamePluginListResponse {
items: GamePluginResponse[]; items: GamePluginResponse[];
count: number; count: number;
@@ -439,11 +452,48 @@ export interface ServerInstanceListResponse {
export interface ServerLifecycleCreateRequest { export interface ServerLifecycleCreateRequest {
id: string; id: string;
pluginId: string; pluginId: string;
runEndpointId: string; runEndpointId?: string;
name: string; name: string;
idempotencyKey: string; idempotencyKey: string;
profileKey: string; profileKey?: string;
bindings: Record<string, string>; bindings?: Record<string, string>;
deployment?: ServerDeploymentRequest;
}
export type ServerDeploymentMode = "guided-install" | "existing-server" | "custom-command";
export type ServerCommandShell = "" | "posix-sh" | "powershell" | "cmd";
// This request is write-only for paths and commands. The matching response
// intentionally returns configured flags rather than those values.
export interface ServerDeploymentRequest {
runEndpointId?: string;
mode: ServerDeploymentMode;
profileKey?: string;
runtimeBindings?: Record<string, string>;
createInputs?: Record<string, string>;
serverRoot?: string;
workingDirectory?: string;
installCommand?: string;
startCommand?: string;
stopCommand?: string;
statusCommand?: string;
shell?: ServerCommandShell;
}
export interface ServerDeploymentResponse {
serverInstanceId: string;
mode?: ServerDeploymentMode;
profileKey?: string;
createInputs?: Record<string, string>;
serverRootConfigured: boolean;
workingDirectoryConfigured: boolean;
installCommandConfigured: boolean;
startCommandConfigured: boolean;
stopCommandConfigured: boolean;
statusCommandConfigured: boolean;
shell?: ServerCommandShell;
revision: number;
updatedAt?: string;
} }
export interface RuntimeBindingUpdateRequest { export interface RuntimeBindingUpdateRequest {
@@ -531,6 +581,7 @@ export interface RunEndpointListResponse {
export interface JobProgressBody { export interface JobProgressBody {
percent: number; percent: number;
phase?: "queued" | "claimed" | "preflight" | "install" | "configure" | "start" | "health";
message?: string; message?: string;
} }
+34 -10
View File
@@ -1,8 +1,9 @@
import type { import type {
GamePluginResponse, GamePluginResponse,
JobResponse, JobResponse,
RunEndpointResponse, RunEndpointResponse,
ServerInstanceResponse, ServerDeploymentMode,
ServerInstanceResponse,
ServerInstanceState ServerInstanceState
} from "../api/types"; } from "../api/types";
@@ -15,8 +16,17 @@ export interface ServerCreateFormState {
name: string; name: string;
pluginId: string; pluginId: string;
runEndpointId: string; runEndpointId: string;
profileKey: string; profileKey: string;
bindings: Record<string, string>; bindings: Record<string, string>;
createInputs: Record<string, string>;
deploymentMode: ServerDeploymentMode;
serverRoot: string;
workingDirectory: string;
installCommand: string;
startCommand: string;
stopCommand: string;
statusCommand: string;
shell: "" | "posix-sh" | "powershell" | "cmd";
} }
export interface RuntimeBindingField { export interface RuntimeBindingField {
@@ -55,8 +65,17 @@ export const emptyServerCreateForm: ServerCreateFormState = {
name: "", name: "",
pluginId: "", pluginId: "",
runEndpointId: "", runEndpointId: "",
profileKey: "", profileKey: "",
bindings: {} bindings: {},
createInputs: {},
deploymentMode: "guided-install",
serverRoot: "",
workingDirectory: "",
installCommand: "",
startCommand: "",
stopCommand: "",
statusCommand: "",
shell: ""
}; };
export function summarizeServerManagement(instances: ServerInstanceResponse[], jobs: JobResponse[]): ServerManagementSummary { export function summarizeServerManagement(instances: ServerInstanceResponse[], jobs: JobResponse[]): ServerManagementSummary {
@@ -104,8 +123,13 @@ export function defaultServerCreateForm(plugins: GamePluginResponse[], endpoints
...emptyServerCreateForm, ...emptyServerCreateForm,
pluginId: plugin?.id ?? "", pluginId: plugin?.id ?? "",
profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "",
runEndpointId: endpoints[0]?.id ?? "" runEndpointId: "",
}; createInputs: pluginCreateInputDefaults(plugin)
};
}
export function pluginCreateInputDefaults(plugin: GamePluginResponse | undefined): Record<string, string> {
return Object.fromEntries((plugin?.createFields ?? []).map((field) => [field.key, field.defaultValue ?? ""]));
} }
export function runtimeBindingFields(plugin: GamePluginResponse | undefined, profileKey: string): RuntimeBindingField[] { export function runtimeBindingFields(plugin: GamePluginResponse | undefined, profileKey: string): RuntimeBindingField[] {
+115 -3
View File
@@ -21,6 +21,8 @@ import type {
ServerMemberResponse, ServerMemberResponse,
ServerMetricsResponse, ServerMetricsResponse,
RuntimeBindingResponse, RuntimeBindingResponse,
RunEndpointResponse,
ServerDeploymentResponse,
ServerRuntimeActionsResponse, ServerRuntimeActionsResponse,
MetricSampleResponse, MetricSampleResponse,
RemoteAdapterDeclarationResponse RemoteAdapterDeclarationResponse
@@ -46,7 +48,7 @@ import {
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge"; import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
import { canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; import { canStartServer, canStopServer, endpointLabel, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { import {
serverDetailSections, serverDetailSections,
serverIsOnline, serverIsOnline,
@@ -89,6 +91,8 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]); const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]);
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" }); const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" }); const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [endpoints, setEndpoints] = useState<RunEndpointResponse[]>([]);
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null); const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false); const [confirmBusy, setConfirmBusy] = useState(false);
@@ -99,9 +103,10 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
} }
setInstance({ status: "loading" }); setInstance({ status: "loading" });
try { try {
const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([ const [detail, pluginResponse, endpointResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId), platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(), platformApiClient.listGamePlugins(),
platformApiClient.listRunEndpoints(),
platformApiClient.listJobs(serverId), platformApiClient.listJobs(serverId),
platformApiClient platformApiClient
.getServerRuntimeActions(serverId) .getServerRuntimeActions(serverId)
@@ -111,15 +116,21 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
.getServerRuntimeBinding(serverId) .getServerRuntimeBinding(serverId)
.then((data): LoadState<RuntimeBindingResponse> => ({ status: "ready", data })) .then((data): LoadState<RuntimeBindingResponse> => ({ status: "ready", data }))
.catch((error): LoadState<RuntimeBindingResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })), .catch((error): LoadState<RuntimeBindingResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })),
platformApiClient
.getServerDeployment(serverId)
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })), platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })), platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })) platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 }))
]); ]);
setInstance({ status: "ready", data: detail }); setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items); setPlugins(pluginResponse.items);
setEndpoints(endpointResponse.items);
setJobs(jobResponse.items); setJobs(jobResponse.items);
setRuntimeActions(runtimeResponse); setRuntimeActions(runtimeResponse);
setRuntimeBinding(bindingResponse); setRuntimeBinding(bindingResponse);
setDeployment(deploymentResponse);
setMetricHistory(metricHistoryResponse.items); setMetricHistory(metricHistoryResponse.items);
setBackups(backupResponse.items); setBackups(backupResponse.items);
setRemoteAdapters(adapterResponse.items); setRemoteAdapters(adapterResponse.items);
@@ -137,6 +148,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
setArtifacts([]); setArtifacts([]);
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" }); setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
setRuntimeBinding({ status: "error", reason: "运行配置加载失败" }); setRuntimeBinding({ status: "error", reason: "运行配置加载失败" });
setDeployment({ status: "error", reason: "部署定义加载失败" });
setMetricHistory([]); setMetricHistory([]);
setBackups([]); setBackups([]);
setRemoteAdapters([]); setRemoteAdapters([]);
@@ -295,6 +307,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={() => void refresh()} onChanged={() => void refresh()}
/> />
)} )}
{section === "overview" && <ServerDeploymentSection instance={instance.data} deployment={deployment} endpoints={endpoints} session={session} operations={operations} onChanged={() => void refresh()} />}
{section === "overview" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />} {section === "overview" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />} {section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{section === "overview" && ( {section === "overview" && (
@@ -412,6 +425,104 @@ function ServerMetadataSection({ instance, session, operations, onChanged }: Ser
); );
} }
interface ServerDeploymentSectionProps {
instance: ServerInstanceResponse;
deployment: LoadState<ServerDeploymentResponse>;
endpoints: RunEndpointResponse[];
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onChanged: () => void;
}
function ServerDeploymentSection({ instance, deployment, endpoints, session, operations, onChanged }: ServerDeploymentSectionProps) {
const [runEndpointId, setRunEndpointId] = useState(instance.runEndpointId);
const [mode, setMode] = useState<"guided-install" | "existing-server" | "custom-command">("guided-install");
const [serverRoot, setServerRoot] = useState("");
const [workingDirectory, setWorkingDirectory] = useState("");
const [installCommand, setInstallCommand] = useState("");
const [startCommand, setStartCommand] = useState("");
const [stopCommand, setStopCommand] = useState("");
const [statusCommand, setStatusCommand] = useState("");
const [shell, setShell] = useState<"" | "posix-sh" | "powershell" | "cmd">("");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
useEffect(() => {
setRunEndpointId(instance.runEndpointId);
if (deployment.status === "ready") {
setMode(deployment.data.mode ?? "guided-install");
setShell(deployment.data.shell ?? "");
}
}, [deployment, instance.id, instance.runEndpointId]);
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setResult(null);
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
await platformApiClient.updateServerDeployment(instance.id, { runEndpointId: runEndpointId || undefined, mode, serverRoot: serverRoot.trim() || undefined, workingDirectory: workingDirectory.trim() || undefined, installCommand: installCommand.trim() || undefined, startCommand: startCommand.trim() || undefined, stopCommand: stopCommand.trim() || undefined, statusCommand: statusCommand.trim() || undefined, shell: shell || undefined });
operations.succeed(operationId, "部署定义已保存;受保护路径和命令不会回显。");
setResult({ status: "succeeded", label: "部署定义已保存" });
setServerRoot(""); setWorkingDirectory(""); setInstallCommand(""); setStartCommand(""); setStopCommand(""); setStatusCommand("");
onChanged();
} catch (error) {
const label = error instanceof Error ? error.message : "部署定义保存失败";
operations.fail(operationId, label);
setResult({ status: "failed", label });
} finally { setBusy(false); }
}
async function deploy() {
setBusy(true);
setResult(null);
const operationId = operations.begin({ intent: "部署服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
const response = await platformApiClient.deployServerInstance(instance.id, { expectedConfigVersion: instance.configVersion, idempotencyKey: `web:deploy:${instance.id}:${Date.now()}` });
operations.succeed(operationId, response.job.id ? "部署任务已进入队列,等待 Run 领取。" : "部署请求已接受。");
setResult({ status: "succeeded", label: deploymentProgressLabel(response.job.progress) });
onChanged();
} catch (error) {
const label = error instanceof Error ? error.message : "部署失败";
operations.fail(operationId, label);
setResult({ status: "failed", label });
} finally { setBusy(false); }
}
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
const view = deployment.data;
return <article className="console-panel" aria-label="server deployment">
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> </h2><span className="page-status">{view.mode || "未配置"} · {view.revision}</span></div>
<p className="section-copy"></p>
<div className="action-list"><span>{view.serverRootConfigured ? "已配置" : "未配置"}</span><span>{view.workingDirectoryConfigured ? "已配置" : "未配置"}</span><span> / {view.installCommandConfigured ? "已配置" : "未配置"} / {view.startCommandConfigured ? "已配置" : "未配置"}</span></div>
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void save(event)}>
<div className="form-grid">
<label><select value={runEndpointId} onChange={(event) => setRunEndpointId(event.target.value)}><option value=""></option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label>
<label><select value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="guided-install"></option><option value="existing-server"></option><option value="custom-command"></option></select></label>
<label><input value={serverRoot} onChange={(event) => setServerRoot(event.target.value)} placeholder="完整绝对路径" autoComplete="off" /></label>
<label><input value={workingDirectory} onChange={(event) => setWorkingDirectory(event.target.value)} placeholder="完整绝对路径" autoComplete="off" /></label>
{mode === "custom-command" && <><label><select value={shell} onChange={(event) => setShell(event.target.value as typeof shell)}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input value={installCommand} onChange={(event) => setInstallCommand(event.target.value)} autoComplete="off" /></label><label><input value={startCommand} onChange={(event) => setStartCommand(event.target.value)} autoComplete="off" /></label><label><input value={stopCommand} onChange={(event) => setStopCommand(event.target.value)} autoComplete="off" /></label><label><input value={statusCommand} onChange={(event) => setStatusCommand(event.target.value)} autoComplete="off" /></label></>}
</div>
<div className="action-strip"><button type="submit" className="primary-command" disabled={busy}><Pencil size={14} /><span>{busy ? "保存中…" : "保存部署定义"}</span></button>{instance.state === "draft" || instance.state === "failed" ? <button type="button" className="icon-command" disabled={busy || !runEndpointId} onClick={() => void deploy()}><Sparkles size={14} /><span> Run</span></button> : null}</div>
</form>
{result && <div style={{ marginTop: 10 }}><ResultBadge status={result.status} label={result.label} /></div>}
</article>;
}
function deploymentProgressLabel(progress: JobResponse["progress"]): string {
switch (progress.phase) {
case "queued": return "任务已排队,等待 Run 领取";
case "claimed": return "Run 已领取任务";
case "preflight": return "正在执行本机预检";
case "install": return "正在安装服务器";
case "configure": return "正在写入游戏配置";
case "start": return "正在启动服务器";
case "health": return "正在进行健康检查";
default: return "部署任务已提交";
}
}
interface ServerAdministratorsSectionProps { interface ServerAdministratorsSectionProps {
instance: ServerInstanceResponse; instance: ServerInstanceResponse;
session: PageComponentProps["session"]; session: PageComponentProps["session"];
@@ -587,7 +698,7 @@ function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectio
{failed.length > 0 && <span> {failed.length} </span>} {failed.length > 0 && <span> {failed.length} </span>}
{pending.length > 0 ? ( {pending.length > 0 ? (
<span> <span>
{pending[0].capability}{pending[0].state}{pending[0].progress.percent}% {pending[0].capability}{deploymentProgressLabel(pending[0].progress)}{pending[0].progress.percent}%
</span> </span>
) : ( ) : (
<span></span> <span></span>
@@ -2267,6 +2378,7 @@ function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHis
<code>{job.id}</code> <code>{job.id}</code>
</span> </span>
<span> {job.progress.percent}%</span> <span> {job.progress.percent}%</span>
{job.progress.phase && <span>{deploymentProgressLabel(job.progress)}</span>}
<span> <span>
{job.attempt}/{job.retryPolicy.maxAttempts} {job.attempt}/{job.retryPolicy.maxAttempts}
</span> </span>
+60 -8
View File
@@ -23,6 +23,7 @@ import {
canDeleteServer, canDeleteServer,
defaultServerCreateForm, defaultServerCreateForm,
endpointLabel, endpointLabel,
pluginCreateInputDefaults,
pluginLabel, pluginLabel,
runtimeBindingFields, runtimeBindingFields,
type ServerCreateFormState type ServerCreateFormState
@@ -101,6 +102,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
pluginId: plugin?.id ?? "", pluginId: plugin?.id ?? "",
profileKey, profileKey,
bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {}, bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {},
createInputs: plugin?.id === current.pluginId ? current.createInputs : pluginCreateInputDefaults(plugin),
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId) runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
? current.runEndpointId ? current.runEndpointId
: endpointResponse.items[0]?.id || "" : endpointResponse.items[0]?.id || ""
@@ -156,13 +158,14 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const createProfileOptions = selectedCreatePlugin?.runtimeProfiles?.lifecycleProfiles ?? []; const createProfileOptions = selectedCreatePlugin?.runtimeProfiles?.lifecycleProfiles ?? [];
const createProfileUnavailable = Boolean(selectedCreatePlugin && createProfileOptions.length === 0); const createProfileUnavailable = Boolean(selectedCreatePlugin && createProfileOptions.length === 0);
const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey); const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey);
const createPluginFields = selectedCreatePlugin?.createFields ?? [];
function updateForm(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) { function updateForm(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
const { name, value } = event.target; const { name, value } = event.target;
setForm((current) => { setForm((current) => {
if (name === "pluginId") { if (name === "pluginId") {
const plugin = plugins.find((item) => item.id === value); const plugin = plugins.find((item) => item.id === value);
return { ...current, pluginId: value, profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", bindings: {} }; return { ...current, pluginId: value, profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", bindings: {}, createInputs: pluginCreateInputDefaults(plugin) };
} }
if (name === "profileKey") { if (name === "profileKey") {
return { ...current, profileKey: value, bindings: {} }; return { ...current, profileKey: value, bindings: {} };
@@ -175,12 +178,16 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } })); setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } }));
} }
function updateCreateInput(key: string, value: string) {
setForm((current) => ({ ...current, createInputs: { ...current.createInputs, [key]: value } }));
}
async function handleCreate(event: FormEvent<HTMLFormElement>) { async function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName }); const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
try { try {
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(form)); const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(form));
operations.succeed(operationId, `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发`, result.job); operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `已保存草稿 ${result.instance.id};可在 Run 注册后绑定并部署。`, result.job.id ? result.job : undefined);
setForm(defaultServerCreateForm(plugins, endpoints)); setForm(defaultServerCreateForm(plugins, endpoints));
setShowCreate(false); setShowCreate(false);
await refresh(); await refresh();
@@ -489,7 +496,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
<ManagementDialog <ManagementDialog
open={showCreate && canManageServers} open={showCreate && canManageServers}
title="创建服务器" title="创建服务器"
description="选择插件声明的运行配置和安全逻辑绑定。提交后以 Platform 返回的实例与安装任务为准。" description="先保存服务器定义也可以;Run 注册后再绑定部署。根目录和命令只会写入受保护执行计划,之后不会在页面、任务或日志中显示。"
wide wide
onClose={() => { if (!createPending) setShowCreate(false); }} onClose={() => { if (!createPending) setShowCreate(false); }}
> >
@@ -511,7 +518,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</label> </label>
<label> <label>
<select name="runEndpointId" value={form.runEndpointId} onChange={updateForm} required> <select name="runEndpointId" value={form.runEndpointId} onChange={updateForm}>
<option value="">稿</option>
{endpoints.map((endpoint) => ( {endpoints.map((endpoint) => (
<option key={endpoint.id} value={endpoint.id}> <option key={endpoint.id} value={endpoint.id}>
{endpointLabel(endpoint, endpoint.id)} {endpointLabel(endpoint, endpoint.id)}
@@ -520,8 +528,9 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</select> </select>
</label> </label>
<label> <label>
<select name="profileKey" value={form.profileKey} onChange={updateForm} required> <select name="profileKey" value={form.profileKey} onChange={updateForm}>
<option value="">使</option>
{createProfileOptions.map((profile) => ( {createProfileOptions.map((profile) => (
<option key={profile.key} value={profile.key}> <option key={profile.key} value={profile.key}>
{profile.key} · {profile.mode} {profile.key} · {profile.mode}
@@ -535,6 +544,49 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
<span> manifest </span> <span> manifest </span>
</div> </div>
)} )}
<label>
<select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}>
<option value="guided-install"></option>
<option value="existing-server"></option>
<option value="custom-command"></option>
</select>
</label>
<label>
<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder="/srv/scum-alpha 或 C:\\Games\\SCUM" autoComplete="off" />
</label>
<label>
<input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder="/srv/scum-alpha" autoComplete="off" />
</label>
{createPluginFields.map((field) => (
<label key={field.key}>
{field.label}{field.required ? "(必填)" : ""}
{field.type === "select" ? (
<select value={form.createInputs[field.key] ?? ""} onChange={(event) => updateCreateInput(field.key, event.target.value)} required={field.required}>
{!field.required && <option value=""></option>}
{field.options?.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : (
<input type={field.type === "boolean" ? "checkbox" : field.type === "number" || field.type === "port" ? "number" : "text"} min={field.type === "port" ? 1 : undefined} max={field.type === "port" ? 65535 : undefined} checked={field.type === "boolean" ? form.createInputs[field.key] === "true" : undefined} value={field.type === "boolean" ? undefined : form.createInputs[field.key] ?? ""} onChange={(event) => updateCreateInput(field.key, field.type === "boolean" ? String(event.target.checked) : event.target.value)} required={field.required} />
)}
</label>
))}
{form.deploymentMode === "custom-command" && (
<>
<label>
<select name="shell" value={form.shell} onChange={updateForm}>
<option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option>
</select>
</label>
<label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="例如 SteamCMD 安装命令" /></label>
<label><input name="startCommand" value={form.startCommand} onChange={updateForm} autoComplete="off" required placeholder="例如 /srv/app/.venv/bin/python server.py" /></label>
<label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label>
<label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label>
</>
)}
{createBindingFields.map((field) => ( {createBindingFields.map((field) => (
<label key={field.key}> <label key={field.key}>
{field.key}{field.required ? "(必填)" : ""} {field.key}{field.required ? "(必填)" : ""}
@@ -551,9 +603,9 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</div> </div>
<div className="confirm-actions"> <div className="confirm-actions">
<button type="button" disabled={createPending} onClick={() => setShowCreate(false)}></button> <button type="button" disabled={createPending} onClick={() => setShowCreate(false)}></button>
<button type="submit" className="confirm-primary" disabled={createPending || !form.profileKey || createProfileUnavailable} title="创建服务器"> <button type="submit" className="confirm-primary" disabled={createPending || createProfileUnavailable} title="创建服务器">
<Sparkles size={16} /> <Sparkles size={16} />
<span>{createPending ? "创建中…" : "创建并安装"}</span> <span>{createPending ? "保存中…" : form.runEndpointId ? "保存并部署" : "保存草稿"}</span>
</button> </button>
</div> </div>
</form> </form>
+19 -2
View File
@@ -11,6 +11,10 @@ const plugin: GamePluginResponse = {
serverType: "runtime", serverType: "runtime",
manifestRef: "artifact://runtime-manifest", manifestRef: "artifact://runtime-manifest",
createFormSchemaRef: "schemas/create.json", createFormSchemaRef: "schemas/create.json",
createFields: [
{ key: "gamePort", label: "游戏端口", type: "port", required: true, defaultValue: "7777" },
{ key: "maxPlayers", label: "最大玩家数", type: "number", required: true, defaultValue: "64" }
],
requiredRunCapabilities: ["process.install"], requiredRunCapabilities: ["process.install"],
declaredPermissions: ["server.create"], declaredPermissions: ["server.create"],
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false }, permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false },
@@ -65,11 +69,17 @@ describe("runtime profile server creation contracts", () => {
).toEqual({ ).toEqual({
id: "server-1", id: "server-1",
pluginId: "game.runtime", pluginId: "game.runtime",
runEndpointId: "", runEndpointId: undefined,
name: "Runtime Server", name: "Runtime Server",
idempotencyKey: "web:create:server-1:17", idempotencyKey: "web:create:server-1:17",
profileKey: "local", profileKey: "local",
bindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" } bindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" },
deployment: {
mode: "guided-install",
profileKey: "local",
runtimeBindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" },
createInputs: { gamePort: "7777", maxPlayers: "64" }
}
}); });
}); });
@@ -84,4 +94,11 @@ describe("runtime profile server creation contracts", () => {
}); });
expect(serverInstanceIdFromName("测试服", 18)).toBe("server-18"); expect(serverInstanceIdFromName("测试服", 18)).toBe("server-18");
}); });
it("keeps complete paths and commands in a write-only deployment payload", () => {
const form = defaultServerCreateForm([plugin], []);
const request = serverCreateRequestFromForm({ ...form, name: "Venv Server", deploymentMode: "custom-command", serverRoot: "/srv/venv-server", workingDirectory: "/srv/venv-server", startCommand: "/srv/venv-server/.venv/bin/python server.py", shell: "" }, 19);
expect(request.runEndpointId).toBeUndefined();
expect(request.deployment).toMatchObject({ mode: "custom-command", serverRoot: "/srv/venv-server", workingDirectory: "/srv/venv-server", startCommand: "/srv/venv-server/.venv/bin/python server.py" });
});
}); });
+16 -3
View File
@@ -16,11 +16,24 @@ export function serverCreateRequestFromForm(form: ServerCreateFormState, sequenc
return { return {
id, id,
pluginId: form.pluginId.trim(), pluginId: form.pluginId.trim(),
runEndpointId: form.runEndpointId.trim(), runEndpointId: form.runEndpointId.trim() || undefined,
name: form.name.trim(), name: form.name.trim(),
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence), idempotencyKey: lifecycleIdempotencyKey("create", id, sequence),
profileKey: form.profileKey.trim(), profileKey: form.profileKey.trim() || undefined,
bindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")) bindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")),
deployment: {
mode: form.deploymentMode,
profileKey: form.profileKey.trim() || undefined,
runtimeBindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")),
createInputs: Object.fromEntries(Object.entries(form.createInputs).map(([key, value]) => [key, value.trim()])),
serverRoot: form.serverRoot.trim() || undefined,
workingDirectory: form.workingDirectory.trim() || undefined,
installCommand: form.installCommand.trim() || undefined,
startCommand: form.startCommand.trim() || undefined,
stopCommand: form.stopCommand.trim() || undefined,
statusCommand: form.statusCommand.trim() || undefined,
shell: form.shell || undefined
}
}; };
} }
@@ -19,7 +19,12 @@
"linux", "linux",
"darwin" "darwin"
], ],
"createFormSchema": "schemas/create-form.schema.json" "createFormSchema": "schemas/create-form.schema.json",
"createFields": [
{ "key": "serverName", "label": "服务器名称", "type": "text", "required": true, "configKey": "motd" },
{ "key": "gamePort", "label": "游戏端口", "type": "port", "required": true, "defaultValue": "25565", "configKey": "serverPort" },
{ "key": "rconPort", "label": "RCON 端口", "type": "port", "required": true, "defaultValue": "25575", "configKey": "rconPort" }
]
}, },
"capabilities": [ "capabilities": [
"process.install", "process.install",
@@ -19,7 +19,13 @@
"windows", "windows",
"linux" "linux"
], ],
"createFormSchema": "schemas/create-form.schema.json" "createFormSchema": "schemas/create-form.schema.json",
"createFields": [
{ "key": "serverName", "label": "SCUM 服务器名称", "type": "text", "required": true, "configKey": "serverName" },
{ "key": "gamePort", "label": "游戏端口", "type": "port", "required": true, "defaultValue": "7777", "configKey": "gamePort" },
{ "key": "queryPort", "label": "查询端口", "type": "port", "required": true, "defaultValue": "27015", "configKey": "queryPort" },
{ "key": "maxPlayers", "label": "最大玩家数", "type": "number", "required": true, "defaultValue": "64", "configKey": "maxPlayers" }
]
}, },
"capabilities": [ "capabilities": [
"process.install", "process.install",
@@ -26,7 +26,12 @@
"type": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$", "maxLength": 80 }, "type": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$", "maxLength": 80 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"supportedOS": { "type": "array", "items": { "enum": ["windows", "linux", "darwin"] } }, "supportedOS": { "type": "array", "items": { "enum": ["windows", "linux", "darwin"] } },
"createFormSchema": { "$ref": "#/$defs/relativeJsonRef" } "createFormSchema": { "$ref": "#/$defs/relativeJsonRef" },
"createFields": {
"type": "array",
"maxItems": 32,
"items": { "$ref": "#/$defs/pluginCreateField" }
}
} }
}, },
"bridge": { "bridge": {
@@ -191,6 +196,20 @@
} }
}, },
"$defs": { "$defs": {
"pluginCreateField": {
"type": "object",
"required": ["key", "label", "type"],
"additionalProperties": false,
"properties": {
"key": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$", "maxLength": 80 },
"label": { "type": "string", "minLength": 1, "maxLength": 60 },
"type": { "enum": ["text", "number", "boolean", "select", "port"] },
"required": { "type": "boolean" },
"defaultValue": { "type": "string", "maxLength": 256 },
"options": { "type": "array", "maxItems": 32, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 120 } },
"configKey": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$", "maxLength": 80 }
}
},
"gameClientBridgeManifest": { "gameClientBridgeManifest": {
"type": "object", "type": "object",
"required": ["commands", "snapshots", "commandRetentionSeconds", "maxCommands"], "required": ["commands", "snapshots", "commandRetentionSeconds", "maxCommands"],
+20
View File
@@ -111,6 +111,25 @@ function scanUnsafeValues(value: unknown, location: string): string[] {
return []; return [];
} }
function validateCreateFieldDeclarations(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null || !("server" in manifest)) return [];
const server = (manifest as { server?: { createFields?: Array<{ key?: unknown; type?: unknown; defaultValue?: unknown; options?: unknown }> } }).server;
if (!Array.isArray(server?.createFields)) return [];
const errors: string[] = [];
for (const [index, field] of server.createFields.entries()) {
const location = `manifest.server.createFields[${index}]`;
for (const [name, value] of [["defaultValue", field.defaultValue], ["options", field.options]] as const) {
const values = Array.isArray(value) ? value : [value];
for (const item of values) {
if (typeof item !== "string") continue;
if (item.startsWith("/") || item.startsWith("\\\\") || /^[a-z]:[\\/]/i.test(item)) errors.push(`${location}.${name}: raw host path access is not allowed`);
errors.push(...unsafeStringReasons(item).map((reason) => `${location}.${name}: ${reason}`));
}
}
}
return errors;
}
function isSafeRelativeJsonRef(value: string): boolean { function isSafeRelativeJsonRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value); return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
} }
@@ -1103,6 +1122,7 @@ export function validateManifestFile(manifestPath: string): string[] {
} }
errors.push(...scanUnsafeValues(manifest, "manifest")); errors.push(...scanUnsafeValues(manifest, "manifest"));
errors.push(...validateCreateFieldDeclarations(manifest));
errors.push(...validateDependencyPlans(manifest)); errors.push(...validateDependencyPlans(manifest));
errors.push(...validateClientManagerProfiles(manifest)); errors.push(...validateClientManagerProfiles(manifest));
errors.push(...validateDLLExtensionProfiles(manifest)); errors.push(...validateDLLExtensionProfiles(manifest));
+11
View File
@@ -173,6 +173,17 @@ describe("plugin manifest validation", () => {
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]); expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
}); });
it("rejects unsupported or unsafe inline create-field declarations", () => {
const malformed = validateTemporaryScumCompanionManifest((manifest) => {
manifest.server.createFields[0].type = "path";
});
expect(malformed.some((error) => error.includes("createFields") && error.includes("type"))).toBe(true);
const unsafe = validateTemporaryScumCompanionManifest((manifest) => {
manifest.server.createFields[0].defaultValue = "/srv/hidden-server";
});
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
});
it("accepts a pinned ready SCUM UE4SS DLL release and lifecycle reference", () => { it("accepts a pinned ready SCUM UE4SS DLL release and lifecycle reference", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => { const errors = validateTemporaryScumCompanionManifest((manifest) => {
const extension = manifest.runtimeProfiles.dllExtensions[0]; const extension = manifest.runtimeProfiles.dllExtensions[0];