feat: support custom server deployment drafts
This commit is contained in:
@@ -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 仓库:需要实现新任务执行输入、路径预检、自定义进程策略及阶段进度;本仓库不包含其源码。
|
||||||
+15
@@ -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
|
||||||
+22
@@ -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
|
||||||
+40
@@ -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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "time"
|
|||||||
|
|
||||||
type RunJobProgressReport struct {
|
type RunJobProgressReport struct {
|
||||||
Percent int
|
Percent int
|
||||||
|
Phase string
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ©
|
||||||
|
}
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Server deployment plan v1
|
||||||
|
|
||||||
|
`deployment.plan.v1` is the capability gate for Run implementations that can
|
||||||
|
execute a protected server deployment plan. Platform only sends the plan in a
|
||||||
|
leased `RunJobAssignmentResponse.executionInput.deployment`; it never appears
|
||||||
|
in public server, job, audit, log, or plugin-bridge responses.
|
||||||
|
|
||||||
|
## Capability and policy
|
||||||
|
|
||||||
|
Run advertises `deployment.plan.v1` along with its normal lifecycle
|
||||||
|
capabilities. A Run that supports shell commands additionally advertises its
|
||||||
|
local policy for `posix-sh`, `powershell`, or `cmd` out of band with its
|
||||||
|
operator configuration. Platform must not infer shell support from command
|
||||||
|
text. Empty `shell` means argv-oriented execution.
|
||||||
|
|
||||||
|
## Required local preflight
|
||||||
|
|
||||||
|
Before a write, install, or process action, Run validates the selected plan:
|
||||||
|
|
||||||
|
- absolute server root and working directory are allowed anywhere permitted by
|
||||||
|
the local Run policy; they are not required to be adjacent to the Run binary;
|
||||||
|
- the effective directory, executable, permissions, timeout, plugin version,
|
||||||
|
and requested ports are locally valid;
|
||||||
|
- selected shell kind and custom-command policy are enabled;
|
||||||
|
- no raw command, path, secret, socket address, or credential is emitted in a
|
||||||
|
result, diagnostic, log batch, or artifact name.
|
||||||
|
|
||||||
|
An `existing-server` plan may omit installation. A `custom-command` plan
|
||||||
|
requires a start command. Guided templates remain plugin recommendations;
|
||||||
|
Run owns their local resolution and execution.
|
||||||
|
|
||||||
|
## Safe progress reports
|
||||||
|
|
||||||
|
Run reports bounded progress with `percent`, `phase`, and a safe message. The
|
||||||
|
allowed phase vocabulary is `queued`, `claimed`, `preflight`, `install`,
|
||||||
|
`configure`, `start`, and `health`. On failure it reports a stable safe error
|
||||||
|
code and summary such as `working-directory-unavailable`, never the supplied
|
||||||
|
path or command text.
|
||||||
|
|
||||||
|
Platform treats preflight as authoritative. It does not open a direct shell,
|
||||||
|
SSH connection, raw socket, or host filesystem to compensate for a failed
|
||||||
|
preflight.
|
||||||
@@ -56,7 +56,11 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
|||||||
job = normalizeJobScheduling(job, stamp)
|
job = 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 ©
|
||||||
|
}
|
||||||
|
|
||||||
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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/repo"
|
||||||
|
"browser.local/platform/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (svc *CoreService) GetServerDeploymentForSession(sessionID, serverInstanceID string) (domain.ServerDeploymentView, error) {
|
||||||
|
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
|
||||||
|
return domain.ServerDeploymentView{}, err
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerDeploymentView{}, err
|
||||||
|
}
|
||||||
|
return deploymentView(instance), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstanceID string, update domain.ServerDeploymentUpdate) (domain.ServerDeploymentView, error) {
|
||||||
|
_, instance, err := svc.requireServerOwner(sessionID, serverInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerDeploymentView{}, err
|
||||||
|
}
|
||||||
|
if instance.State == domain.ServerInstanceStateInstalling || instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateDeleted {
|
||||||
|
return domain.ServerDeploymentView{}, validationError("deployment definition cannot be changed while the server is active")
|
||||||
|
}
|
||||||
|
definition := mergeDeploymentDefinition(instance.Deployment, update)
|
||||||
|
definition.Revision = instance.Deployment.Revision + 1
|
||||||
|
definition.UpdatedAt = svc.now()
|
||||||
|
if err := validator.ValidateServerDeploymentDefinition(definition); err != nil {
|
||||||
|
return domain.ServerDeploymentView{}, err
|
||||||
|
}
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerDeploymentView{}, err
|
||||||
|
}
|
||||||
|
if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, definition.CreateInputs); err != nil {
|
||||||
|
return domain.ServerDeploymentView{}, err
|
||||||
|
}
|
||||||
|
if update.RunEndpointID != "" {
|
||||||
|
if _, err := svc.store.RunEndpoints().Get(update.RunEndpointID); err != nil {
|
||||||
|
return domain.ServerDeploymentView{}, err
|
||||||
|
}
|
||||||
|
instance.RunEndpointID = update.RunEndpointID
|
||||||
|
}
|
||||||
|
instance.Deployment = definition
|
||||||
|
instance.UpdatedAt = definition.UpdatedAt
|
||||||
|
if err := validator.ValidateServerInstance(instance); err != nil {
|
||||||
|
return domain.ServerDeploymentView{}, err
|
||||||
|
}
|
||||||
|
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||||
|
return domain.ServerDeploymentView{}, err
|
||||||
|
}
|
||||||
|
return deploymentView(instance), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
|
||||||
|
if err := validator.ValidateServerLifecycleCommand(command); err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if instance.State != domain.ServerInstanceStateDraft && instance.State != domain.ServerInstanceStateFailed {
|
||||||
|
return domain.ServerLifecycleResult{}, validationError("server instance is not deployable")
|
||||||
|
}
|
||||||
|
if instance.ConfigVersion != command.ExpectedConfigVersion {
|
||||||
|
return domain.ServerLifecycleResult{}, validationError("expectedConfigVersion must match server instance")
|
||||||
|
}
|
||||||
|
if instance.Deployment.Mode == "" {
|
||||||
|
return domain.ServerLifecycleResult{}, validationError("deployment definition is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(instance.RunEndpointID) == "" {
|
||||||
|
return domain.ServerLifecycleResult{}, validationError("run endpoint must be selected before deployment")
|
||||||
|
}
|
||||||
|
plugin, endpoint, err := svc.lifecycleDependencies(instance.PluginID, instance.RunEndpointID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if !containsString(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) {
|
||||||
|
return domain.ServerLifecycleResult{}, validationError("run endpoint missing required capability: deployment.plan.v1")
|
||||||
|
}
|
||||||
|
if requiredShellCapability := deploymentShellCapability(instance.Deployment.Shell); requiredShellCapability != "" && !containsString(endpoint.Capabilities, requiredShellCapability) {
|
||||||
|
return domain.ServerLifecycleResult{}, validationError("run endpoint policy does not allow selected command shell")
|
||||||
|
}
|
||||||
|
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityInstall); err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, command.IdempotencyKey, instance.ID, domain.LifecycleCapabilityInstall); err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(instance.Deployment.ProfileKey) != "" {
|
||||||
|
binding, bindingErr := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: instance.Deployment.ProfileKey, Bindings: instance.Deployment.RuntimeBindings}, true)
|
||||||
|
if bindingErr != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, bindingErr
|
||||||
|
}
|
||||||
|
if existing, existingErr := svc.runtimeBindingForServer(instance.ID); existingErr == nil {
|
||||||
|
binding.CreatedAt = existing.CreatedAt
|
||||||
|
if err := svc.store.RuntimeBindings().Update(binding); err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
} else if errors.Is(existingErr, repo.ErrNotFound) {
|
||||||
|
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return domain.ServerLifecycleResult{}, existingErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instance.State = domain.ServerInstanceStateInstalling
|
||||||
|
instance.UpdatedAt = svc.now()
|
||||||
|
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, command.IdempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerLifecycleResult{}, err
|
||||||
|
}
|
||||||
|
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{Accepted: true, Action: domain.ServerLifecycleActionCreate, Instance: instance, Job: job}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deploymentShellCapability(shell domain.ServerCommandShell) string {
|
||||||
|
switch shell {
|
||||||
|
case domain.ServerCommandShellPosix:
|
||||||
|
return domain.JobCapabilityDeploymentShellPosix
|
||||||
|
case domain.ServerCommandShellPowerShell:
|
||||||
|
return domain.JobCapabilityDeploymentShellPowerShell
|
||||||
|
case domain.ServerCommandShellCmd:
|
||||||
|
return domain.JobCapabilityDeploymentShellCmd
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeDeploymentDefinition(current domain.ServerDeploymentDefinition, update domain.ServerDeploymentUpdate) domain.ServerDeploymentDefinition {
|
||||||
|
definition := domain.CopyServerDeploymentDefinition(current)
|
||||||
|
definition.Mode = update.Mode
|
||||||
|
if update.ProfileKey != "" {
|
||||||
|
definition.ProfileKey = update.ProfileKey
|
||||||
|
}
|
||||||
|
if update.RuntimeBindings != nil {
|
||||||
|
definition.RuntimeBindings = domain.CopyStringMap(update.RuntimeBindings)
|
||||||
|
}
|
||||||
|
if update.CreateInputs != nil {
|
||||||
|
definition.CreateInputs = domain.CopyStringMap(update.CreateInputs)
|
||||||
|
}
|
||||||
|
if update.ServerRoot != "" {
|
||||||
|
definition.ServerRoot = update.ServerRoot
|
||||||
|
}
|
||||||
|
if update.WorkingDirectory != "" {
|
||||||
|
definition.WorkingDirectory = update.WorkingDirectory
|
||||||
|
}
|
||||||
|
if update.InstallCommand != "" {
|
||||||
|
definition.InstallCommand = update.InstallCommand
|
||||||
|
}
|
||||||
|
if update.StartCommand != "" {
|
||||||
|
definition.StartCommand = update.StartCommand
|
||||||
|
}
|
||||||
|
if update.StopCommand != "" {
|
||||||
|
definition.StopCommand = update.StopCommand
|
||||||
|
}
|
||||||
|
if update.StatusCommand != "" {
|
||||||
|
definition.StatusCommand = update.StatusCommand
|
||||||
|
}
|
||||||
|
definition.Shell = update.Shell
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
|
||||||
|
func deploymentView(instance domain.ServerInstance) domain.ServerDeploymentView {
|
||||||
|
definition := instance.Deployment
|
||||||
|
return domain.CopyServerDeploymentView(domain.ServerDeploymentView{
|
||||||
|
ServerInstanceID: instance.ID, Mode: definition.Mode, ProfileKey: definition.ProfileKey, CreateInputs: domain.CopyStringMap(definition.CreateInputs),
|
||||||
|
ServerRootConfigured: definition.ServerRoot != "", WorkingDirectoryConfigured: definition.WorkingDirectory != "", InstallCommandConfigured: definition.InstallCommand != "", StartCommandConfigured: definition.StartCommand != "", StopCommandConfigured: definition.StopCommand != "", StatusCommandConfigured: definition.StatusCommand != "", Shell: definition.Shell, Revision: definition.Revision, UpdatedAt: definition.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibleRun(t *testing.T) {
|
||||||
|
svc, _ := newLifecycleRunService(t)
|
||||||
|
createLifecyclePlugin(t, svc)
|
||||||
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "deployment-owner", DisplayName: "Deployment Owner", Email: "deployment-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||||
|
|
||||||
|
draft, err := svc.CreateServerInstanceWorkflowForSession(ownerSession, domain.ServerLifecycleCreate{
|
||||||
|
ID: "venv-draft", PluginID: "server.scum", Name: "Venv server", IdempotencyKey: "draft-venv",
|
||||||
|
Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/venv-server", WorkingDirectory: "/srv/venv-server", StartCommand: "/srv/venv-server/.venv/bin/python server.py"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create unbound deployment draft: %v", err)
|
||||||
|
}
|
||||||
|
if draft.Instance.State != domain.ServerInstanceStateDraft || draft.Job.ID != "" {
|
||||||
|
t.Fatalf("draft must have no lifecycle job, got %+v", draft)
|
||||||
|
}
|
||||||
|
view, err := svc.GetServerDeploymentForSession(ownerSession, draft.Instance.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read deployment view: %v", err)
|
||||||
|
}
|
||||||
|
if !view.ServerRootConfigured || !view.WorkingDirectoryConfigured || !view.StartCommandConfigured {
|
||||||
|
t.Fatalf("expected protected values to be marked configured: %+v", view)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join([]string{view.ServerInstanceID, string(view.Mode), view.ProfileKey}, " "), "/srv/") {
|
||||||
|
t.Fatalf("redacted deployment view leaked host path: %+v", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.UpdateServerDeploymentForSession(ownerSession, draft.Instance.ID, domain.ServerDeploymentUpdate{RunEndpointID: "run-local", Mode: domain.ServerDeploymentModeCustom}); err != nil {
|
||||||
|
t.Fatalf("bind draft to run: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.DeployServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: draft.Instance.ID, ExpectedConfigVersion: draft.Instance.ConfigVersion, IdempotencyKey: "deploy-incompatible"}); err == nil || !strings.Contains(err.Error(), "deployment.plan.v1") {
|
||||||
|
t.Fatalf("expected incompatible Run rejection, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := svc.store.RunEndpoints().Get("run-local")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get endpoint: %v", err)
|
||||||
|
}
|
||||||
|
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan)
|
||||||
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||||
|
t.Fatalf("enable deployment capability: %v", err)
|
||||||
|
}
|
||||||
|
deployed, err := svc.DeployServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: draft.Instance.ID, ExpectedConfigVersion: draft.Instance.ConfigVersion, IdempotencyKey: "deploy-compatible"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy compatible draft: %v", err)
|
||||||
|
}
|
||||||
|
if deployed.Job.ExecutionInput.Deployment == nil || deployed.Job.ExecutionInput.Deployment.StartCommand != "/srv/venv-server/.venv/bin/python server.py" || deployed.Job.Progress.Phase != "queued" {
|
||||||
|
t.Fatalf("Run job must carry protected plan and queued phase: %+v", deployed.Job)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,13 +18,21 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
|
|||||||
return domain.ServerLifecycleResult{}, err
|
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 ©
|
||||||
|
}
|
||||||
|
|
||||||
func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string {
|
func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string {
|
||||||
switch action {
|
switch action {
|
||||||
case domain.ServerLifecycleActionCreate:
|
case domain.ServerLifecycleActionCreate:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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"],
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user