diff --git a/openspec/changes/support-custom-server-deployment-workflows/.openspec.yaml b/openspec/changes/support-custom-server-deployment-workflows/.openspec.yaml new file mode 100644 index 0000000..5e6d53a --- /dev/null +++ b/openspec/changes/support-custom-server-deployment-workflows/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-24 diff --git a/openspec/changes/support-custom-server-deployment-workflows/design.md b/openspec/changes/support-custom-server-deployment-workflows/design.md new file mode 100644 index 0000000..dbfa703 --- /dev/null +++ b/openspec/changes/support-custom-server-deployment-workflows/design.md @@ -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. diff --git a/openspec/changes/support-custom-server-deployment-workflows/proposal.md b/openspec/changes/support-custom-server-deployment-workflows/proposal.md new file mode 100644 index 0000000..1e78c2a --- /dev/null +++ b/openspec/changes/support-custom-server-deployment-workflows/proposal.md @@ -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 仓库:需要实现新任务执行输入、路径预检、自定义进程策略及阶段进度;本仓库不包含其源码。 diff --git a/openspec/changes/support-custom-server-deployment-workflows/specs/plugin-create-configuration/spec.md b/openspec/changes/support-custom-server-deployment-workflows/specs/plugin-create-configuration/spec.md new file mode 100644 index 0000000..203ea5a --- /dev/null +++ b/openspec/changes/support-custom-server-deployment-workflows/specs/plugin-create-configuration/spec.md @@ -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 diff --git a/openspec/changes/support-custom-server-deployment-workflows/specs/run-custom-process-execution/spec.md b/openspec/changes/support-custom-server-deployment-workflows/specs/run-custom-process-execution/spec.md new file mode 100644 index 0000000..952c38d --- /dev/null +++ b/openspec/changes/support-custom-server-deployment-workflows/specs/run-custom-process-execution/spec.md @@ -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 diff --git a/openspec/changes/support-custom-server-deployment-workflows/specs/server-deployment-workflows/spec.md b/openspec/changes/support-custom-server-deployment-workflows/specs/server-deployment-workflows/spec.md new file mode 100644 index 0000000..672ffbb --- /dev/null +++ b/openspec/changes/support-custom-server-deployment-workflows/specs/server-deployment-workflows/spec.md @@ -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 diff --git a/openspec/changes/support-custom-server-deployment-workflows/tasks.md b/openspec/changes/support-custom-server-deployment-workflows/tasks.md new file mode 100644 index 0000000..b786bb2 --- /dev/null +++ b/openspec/changes/support-custom-server-deployment-workflows/tasks.md @@ -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. diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 2678a25..809b74b 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -68,6 +68,8 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/server-instances/{id}/process/status", h.serverInstanceProcessStatus) mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions) mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding) + mux.HandleFunc("/api/v1/server-instances/{id}/deployment", h.serverDeployment) + mux.HandleFunc("/api/v1/server-instances/{id}/deploy", h.serverInstanceDeploy) mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters) mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands) mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate) diff --git a/platform/api/server_lifecycle_handlers.go b/platform/api/server_lifecycle_handlers.go index 119f14a..5baee3b 100644 --- a/platform/api/server_lifecycle_handlers.go +++ b/platform/api/server_lifecycle_handlers.go @@ -37,6 +37,52 @@ func (h *coreHandlers) serverInstanceCreateWorkflow(w http.ResponseWriter, r *ht writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result)) } +// serverDeployment reads safe deployment metadata or updates protected deployment input. +func (h *coreHandlers) serverDeployment(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + view, err := h.core.GetServerDeploymentForSession(bearerToken(r), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerDeploymentFromDomain(view)) + case http.MethodPut: + request, err := decodeJSON[dto.ServerDeploymentRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + view, err := h.core.UpdateServerDeploymentForSession(bearerToken(r), r.PathValue("id"), request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerDeploymentFromDomain(view)) + default: + writeMethodNotAllowed(w, http.MethodGet+", "+http.MethodPut) + } +} + +// serverInstanceDeploy binds an existing draft definition to its configured Run and queues install. +func (h *coreHandlers) serverInstanceDeploy(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.DeployServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result)) +} + // serverInstanceStart godoc // @Summary Start server instance // @Description Validates lifecycle state and config version, then queues a start job through the platform job channel. diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index d955afa..1f74561 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -4,6 +4,7 @@ import "time" type RunJobProgressReport struct { Percent int + Phase string Message string } diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 0bdd92c..c74c6dc 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -320,11 +320,25 @@ type GamePluginBridge struct { Actions []string } +// PluginCreateField is a safe, declarative game setting exposed in the +// management console during server definition creation. It deliberately +// excludes command text, host paths, secrets, and arbitrary JSON schemas. +type PluginCreateField struct { + Key string + Label string + Type string + Required bool + DefaultValue string + Options []string + ConfigKey string +} + type GamePluginManifestServer struct { Type string DisplayName string SupportedOS []string CreateFormSchema string + CreateFields []PluginCreateField } type GamePluginManifestAI struct { @@ -554,6 +568,7 @@ type GamePlugin struct { SupportedOS []string ManifestRef string CreateFormSchemaRef string + CreateFields []PluginCreateField RequiredRunCapabilities []string DeclaredPermissions []string Permissions PluginPermissions @@ -580,6 +595,7 @@ type PluginMarketplacePlugin struct { SupportedOS []string ManifestRef string CreateFormSchemaRef string + CreateFields []PluginCreateField Capabilities []string DeclaredPermissions []string Permissions PluginPermissions @@ -676,6 +692,9 @@ type ServerInstance struct { ConfigUpdatedAt time.Time CreatedAt time.Time UpdatedAt time.Time + // Deployment stores operator-supplied deployment inputs. Its protected path + // and command values are never included in normal platform read projections. + Deployment ServerDeploymentDefinition } type ServerInstanceUpdate struct { @@ -715,6 +734,77 @@ type ServerConfig struct { UpdatedAt time.Time } +// ServerDeploymentMode selects how Run prepares and launches a server. +type ServerDeploymentMode string + +const ( + ServerDeploymentModeGuided ServerDeploymentMode = "guided-install" + ServerDeploymentModeExisting ServerDeploymentMode = "existing-server" + ServerDeploymentModeCustom ServerDeploymentMode = "custom-command" +) + +// ServerCommandShell controls deliberate shell interpretation of a command. +// Empty means Run receives the command as its default argv-compatible form. +type ServerCommandShell string + +const ( + ServerCommandShellNone ServerCommandShell = "" + ServerCommandShellPosix ServerCommandShell = "posix-sh" + ServerCommandShellPowerShell ServerCommandShell = "powershell" + ServerCommandShellCmd ServerCommandShell = "cmd" +) + +// ServerDeploymentDefinition persists game inputs plus write-only execution +// material. It is copied into a leased Run assignment but never into DTO read +// views; callers receive ServerDeploymentView instead. +type ServerDeploymentDefinition struct { + Mode ServerDeploymentMode + ProfileKey string + RuntimeBindings map[string]string + CreateInputs map[string]string + ServerRoot string + WorkingDirectory string + InstallCommand string + StartCommand string + StopCommand string + StatusCommand string + Shell ServerCommandShell + Revision int + UpdatedAt time.Time +} + +type ServerDeploymentUpdate struct { + RunEndpointID string + Mode ServerDeploymentMode + ProfileKey string + RuntimeBindings map[string]string + CreateInputs map[string]string + ServerRoot string + WorkingDirectory string + InstallCommand string + StartCommand string + StopCommand string + StatusCommand string + Shell ServerCommandShell +} + +// ServerDeploymentView is the intentionally redacted read model. +type ServerDeploymentView struct { + ServerInstanceID string + Mode ServerDeploymentMode + ProfileKey string + CreateInputs map[string]string + ServerRootConfigured bool + WorkingDirectoryConfigured bool + InstallCommandConfigured bool + StartCommandConfigured bool + StopCommandConfigured bool + StatusCommandConfigured bool + Shell ServerCommandShell + Revision int + UpdatedAt time.Time +} + type ConfigDiffLine struct { Kind string OldNumber int @@ -821,6 +911,12 @@ const ( JobCapabilityDependenciesCheck = "dependencies.check" JobCapabilityDependenciesInstall = "dependencies.install" JobCapabilityLogsBackfill = "logs.backfill" + // JobCapabilityDeploymentPlan gates Run implementations that understand + // protected deployment definitions, absolute paths, and custom commands. + JobCapabilityDeploymentPlan = "deployment.plan.v1" + JobCapabilityDeploymentShellPosix = "deployment.shell.posix-sh" + JobCapabilityDeploymentShellPowerShell = "deployment.shell.powershell" + JobCapabilityDeploymentShellCmd = "deployment.shell.cmd" ) type RunEndpoint struct { @@ -837,6 +933,7 @@ type RunEndpoint struct { type JobProgress struct { Percent int + Phase string Message string } @@ -861,6 +958,7 @@ type JobExecutionInput struct { Inputs map[string]string DLLExtensions []RuntimeDLLExtensionPlan SourceRCON *RuntimeSourceRCONPlan + Deployment *ServerDeploymentDefinition } type JobExecutionResult struct { @@ -1372,6 +1470,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin { plugin.RequiredRunCapabilities = CopyStringSlice(plugin.RequiredRunCapabilities) plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions) plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS) + plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields) plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions) plugin.Pages = CopyGamePluginPageSlice(plugin.Pages) plugin.Tags = CopyStringSlice(plugin.Tags) @@ -1387,6 +1486,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin { func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketplacePlugin { plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS) plugin.Capabilities = CopyStringSlice(plugin.Capabilities) + plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields) plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions) plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions) plugin.Pages = CopyGamePluginPageSlice(plugin.Pages) @@ -1419,6 +1519,7 @@ func CopyGamePluginManifestRegistration(registration GamePluginManifestRegistrat func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest { manifest.Tags = CopyStringSlice(manifest.Tags) manifest.Server.SupportedOS = CopyStringSlice(manifest.Server.SupportedOS) + manifest.Server.CreateFields = CopyPluginCreateFields(manifest.Server.CreateFields) manifest.Bridge.Actions = CopyStringSlice(manifest.Bridge.Actions) manifest.Capabilities = CopyStringSlice(manifest.Capabilities) manifest.Permissions = CopyStringSlice(manifest.Permissions) @@ -1431,6 +1532,17 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest { return manifest } +func CopyPluginCreateFields(fields []PluginCreateField) []PluginCreateField { + if fields == nil { + return nil + } + copy := append([]PluginCreateField(nil), fields...) + for i := range copy { + copy[i].Options = CopyStringSlice(copy[i].Options) + } + return copy +} + func CopyGamePluginProductionLifecycle(lifecycle GamePluginProductionLifecycle) GamePluginProductionLifecycle { lifecycle.Operations = CopyStringSlice(lifecycle.Operations) lifecycle.ApprovalRequired = CopyStringSlice(lifecycle.ApprovalRequired) @@ -1524,9 +1636,27 @@ func CopyPluginBridgeExecuteResponse(response PluginBridgeExecuteResponse) Plugi func CopyServerInstance(instance ServerInstance) ServerInstance { instance.AdminUserIDs = CopyStringSlice(instance.AdminUserIDs) + instance.Deployment = CopyServerDeploymentDefinition(instance.Deployment) return instance } +func CopyServerDeploymentDefinition(definition ServerDeploymentDefinition) ServerDeploymentDefinition { + definition.RuntimeBindings = CopyStringMap(definition.RuntimeBindings) + definition.CreateInputs = CopyStringMap(definition.CreateInputs) + return definition +} + +func CopyServerDeploymentUpdate(update ServerDeploymentUpdate) ServerDeploymentUpdate { + update.RuntimeBindings = CopyStringMap(update.RuntimeBindings) + update.CreateInputs = CopyStringMap(update.CreateInputs) + return update +} + +func CopyServerDeploymentView(view ServerDeploymentView) ServerDeploymentView { + view.CreateInputs = CopyStringMap(view.CreateInputs) + return view +} + func CopyPlatformResourceUsage(usage PlatformResourceUsage) PlatformResourceUsage { return usage } @@ -1583,6 +1713,10 @@ func CopyJob(job Job) Job { job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs) job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...) job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON) + if job.ExecutionInput.Deployment != nil { + copy := CopyServerDeploymentDefinition(*job.ExecutionInput.Deployment) + job.ExecutionInput.Deployment = © + } return job } diff --git a/platform/domain/server_lifecycle.go b/platform/domain/server_lifecycle.go index d70bdfb..1f696e0 100644 --- a/platform/domain/server_lifecycle.go +++ b/platform/domain/server_lifecycle.go @@ -25,6 +25,7 @@ type ServerLifecycleCreate struct { IdempotencyKey string ProfileKey string Bindings map[string]string + Deployment ServerDeploymentDefinition } type ServerLifecycleCommand struct { @@ -57,6 +58,7 @@ func LifecycleCapabilityForAction(action ServerLifecycleAction) string { func CopyServerLifecycleCreate(create ServerLifecycleCreate) ServerLifecycleCreate { create.Bindings = CopyStringMap(create.Bindings) + create.Deployment = CopyServerDeploymentDefinition(create.Deployment) return create } diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 729b2f6..e9acf03 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -92,20 +92,38 @@ type RunJobResultRequest struct { } type RunJobExecutionInputBody struct { - WorkspaceScope string `json:"workspaceScope,omitempty"` - Content string `json:"content,omitempty"` - ExpectedVersion int `json:"expectedVersion,omitempty"` - ExpectedChecksum string `json:"expectedChecksum,omitempty"` - MaxReadBytes int `json:"maxReadBytes,omitempty"` - RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"` - RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"` - TimeoutSeconds int `json:"timeoutSeconds,omitempty"` - PluginID string `json:"pluginId,omitempty"` - LifecycleOperation string `json:"lifecycleOperation,omitempty"` - TargetVersion string `json:"targetVersion,omitempty"` - Inputs map[string]string `json:"inputs,omitempty"` - DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"` - SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"` + WorkspaceScope string `json:"workspaceScope,omitempty"` + Content string `json:"content,omitempty"` + ExpectedVersion int `json:"expectedVersion,omitempty"` + ExpectedChecksum string `json:"expectedChecksum,omitempty"` + MaxReadBytes int `json:"maxReadBytes,omitempty"` + RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"` + RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` + PluginID string `json:"pluginId,omitempty"` + LifecycleOperation string `json:"lifecycleOperation,omitempty"` + TargetVersion string `json:"targetVersion,omitempty"` + Inputs map[string]string `json:"inputs,omitempty"` + DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"` + SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"` + Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"` +} + +// ServerDeploymentExecutionBody is included only in a leased Run assignment. +// It is intentionally absent from all public server and job response DTOs. +type ServerDeploymentExecutionBody struct { + Mode domain.ServerDeploymentMode `json:"mode"` + ProfileKey string `json:"profileKey,omitempty"` + RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"` + CreateInputs map[string]string `json:"createInputs,omitempty"` + ServerRoot string `json:"serverRoot,omitempty"` + WorkingDirectory string `json:"workingDirectory,omitempty"` + InstallCommand string `json:"installCommand,omitempty"` + StartCommand string `json:"startCommand,omitempty"` + StopCommand string `json:"stopCommand,omitempty"` + StatusCommand string `json:"statusCommand,omitempty"` + Shell domain.ServerCommandShell `json:"shell,omitempty"` + Revision int `json:"revision"` } type RuntimeSourceRCONPlanBody struct { @@ -565,7 +583,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign State: assignment.State, Progress: progressReportFromDomain(assignment.Progress), ResultRef: assignment.ResultRef, - ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON)}, + ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment)}, LeaseToken: assignment.LeaseToken, Attempt: assignment.Attempt, MaxAttempts: assignment.MaxAttempts, @@ -578,6 +596,14 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign } } +func deploymentExecutionFromDomain(definition *domain.ServerDeploymentDefinition) *ServerDeploymentExecutionBody { + if definition == nil { + return nil + } + copy := domain.CopyServerDeploymentDefinition(*definition) + return &ServerDeploymentExecutionBody{Mode: copy.Mode, ProfileKey: copy.ProfileKey, RuntimeBindings: copy.RuntimeBindings, CreateInputs: copy.CreateInputs, ServerRoot: copy.ServerRoot, WorkingDirectory: copy.WorkingDirectory, InstallCommand: copy.InstallCommand, StartCommand: copy.StartCommand, StopCommand: copy.StopCommand, StatusCommand: copy.StatusCommand, Shell: copy.Shell, Revision: copy.Revision} +} + func runtimeSourceRCONPlanFromDomain(plan *domain.RuntimeSourceRCONPlan) *RuntimeSourceRCONPlanBody { if plan == nil { return nil @@ -588,6 +614,7 @@ func runtimeSourceRCONPlanFromDomain(plan *domain.RuntimeSourceRCONPlan) *Runtim func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressReport { return domain.RunJobProgressReport{ Percent: progress.Percent, + Phase: progress.Phase, Message: progress.Message, } } @@ -595,6 +622,7 @@ func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressRepor func progressReportFromDomain(progress domain.RunJobProgressReport) JobProgressBody { return JobProgressBody{ Percent: progress.Percent, + Phase: progress.Phase, Message: progress.Message, } } diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 1c69de7..709f634 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -187,11 +187,22 @@ type GamePluginBridgeBody struct { Actions []string `json:"actions"` } +type PluginCreateFieldBody struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Required bool `json:"required,omitempty"` + DefaultValue string `json:"defaultValue,omitempty"` + Options []string `json:"options,omitempty"` + ConfigKey string `json:"configKey,omitempty"` +} + type GamePluginManifestServerBody struct { - Type string `json:"type"` - DisplayName string `json:"displayName"` - SupportedOS []string `json:"supportedOs,omitempty"` - CreateFormSchema string `json:"createFormSchema"` + Type string `json:"type"` + DisplayName string `json:"displayName"` + SupportedOS []string `json:"supportedOs,omitempty"` + CreateFormSchema string `json:"createFormSchema"` + CreateFields []PluginCreateFieldBody `json:"createFields,omitempty"` } type GamePluginManifestAIBody struct { @@ -314,6 +325,7 @@ type GamePluginCreateRequest struct { SupportedOS []string `json:"supportedOs,omitempty"` ManifestRef string `json:"manifestRef"` CreateFormSchemaRef string `json:"createFormSchemaRef"` + CreateFields []PluginCreateFieldBody `json:"createFields,omitempty"` RequiredRunCapabilities []string `json:"requiredRunCapabilities"` DeclaredPermissions []string `json:"declaredPermissions,omitempty"` Permissions PluginPermissionsResponse `json:"permissions"` @@ -339,6 +351,7 @@ type GamePluginResponse struct { SupportedOS []string `json:"supportedOs,omitempty"` ManifestRef string `json:"manifestRef"` CreateFormSchemaRef string `json:"createFormSchemaRef"` + CreateFields []PluginCreateFieldBody `json:"createFields,omitempty"` RequiredRunCapabilities []string `json:"requiredRunCapabilities"` DeclaredPermissions []string `json:"declaredPermissions"` Permissions PluginPermissionsResponse `json:"permissions"` @@ -656,6 +669,7 @@ type JobCreateRequest struct { type JobProgressBody struct { Percent int `json:"percent"` + Phase string `json:"phase,omitempty"` Message string `json:"message,omitempty"` } @@ -929,12 +943,35 @@ func (bridge GamePluginBridgeBody) ToDomain() domain.GamePluginBridge { return domain.GamePluginBridge{Actions: domain.CopyStringSlice(bridge.Actions)} } +func pluginCreateFieldsToDomain(fields []PluginCreateFieldBody) []domain.PluginCreateField { + if fields == nil { + return nil + } + out := make([]domain.PluginCreateField, len(fields)) + for i, field := range fields { + out[i] = domain.PluginCreateField{Key: field.Key, Label: field.Label, Type: field.Type, Required: field.Required, DefaultValue: field.DefaultValue, Options: domain.CopyStringSlice(field.Options), ConfigKey: field.ConfigKey} + } + return out +} + +func pluginCreateFieldsFromDomain(fields []domain.PluginCreateField) []PluginCreateFieldBody { + if fields == nil { + return nil + } + out := make([]PluginCreateFieldBody, len(fields)) + for i, field := range fields { + out[i] = PluginCreateFieldBody{Key: field.Key, Label: field.Label, Type: field.Type, Required: field.Required, DefaultValue: field.DefaultValue, Options: domain.CopyStringSlice(field.Options), ConfigKey: field.ConfigKey} + } + return out +} + func (server GamePluginManifestServerBody) ToDomain() domain.GamePluginManifestServer { return domain.GamePluginManifestServer{ Type: server.Type, DisplayName: server.DisplayName, SupportedOS: domain.CopyStringSlice(server.SupportedOS), CreateFormSchema: server.CreateFormSchema, + CreateFields: pluginCreateFieldsToDomain(server.CreateFields), } } @@ -1001,6 +1038,7 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin { SupportedOS: domain.CopyStringSlice(request.SupportedOS), ManifestRef: request.ManifestRef, CreateFormSchemaRef: request.CreateFormSchemaRef, + CreateFields: pluginCreateFieldsToDomain(request.CreateFields), RequiredRunCapabilities: domain.CopyStringSlice(request.RequiredRunCapabilities), DeclaredPermissions: domain.CopyStringSlice(request.DeclaredPermissions), Permissions: permissionsToDomain(request.Permissions), @@ -1247,6 +1285,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse { SupportedOS: plugin.SupportedOS, ManifestRef: plugin.ManifestRef, CreateFormSchemaRef: plugin.CreateFormSchemaRef, + CreateFields: pluginCreateFieldsFromDomain(plugin.CreateFields), RequiredRunCapabilities: plugin.RequiredRunCapabilities, DeclaredPermissions: plugin.DeclaredPermissions, Permissions: permissionsFromDomain(plugin.Permissions), @@ -1785,6 +1824,7 @@ func capacityToDomain(capacity RunCapacityResponse) domain.RunCapacity { func progressFromDomain(progress domain.JobProgress) JobProgressBody { return JobProgressBody{ Percent: progress.Percent, + Phase: progress.Phase, Message: progress.Message, } } @@ -1792,6 +1832,7 @@ func progressFromDomain(progress domain.JobProgress) JobProgressBody { func progressToDomain(progress JobProgressBody) domain.JobProgress { return domain.JobProgress{ Percent: progress.Percent, + Phase: progress.Phase, Message: progress.Message, } } diff --git a/platform/dto/server_lifecycle.go b/platform/dto/server_lifecycle.go index ed8620d..68c8c8e 100644 --- a/platform/dto/server_lifecycle.go +++ b/platform/dto/server_lifecycle.go @@ -1,16 +1,52 @@ package dto -import "browser.local/platform/domain" +import ( + "time" + + "browser.local/platform/domain" +) + +type ServerDeploymentRequest struct { + RunEndpointID string `json:"runEndpointId,omitempty"` + Mode domain.ServerDeploymentMode `json:"mode"` + ProfileKey string `json:"profileKey,omitempty"` + RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"` + CreateInputs map[string]string `json:"createInputs,omitempty"` + ServerRoot string `json:"serverRoot,omitempty"` + WorkingDirectory string `json:"workingDirectory,omitempty"` + InstallCommand string `json:"installCommand,omitempty"` + StartCommand string `json:"startCommand,omitempty"` + StopCommand string `json:"stopCommand,omitempty"` + StatusCommand string `json:"statusCommand,omitempty"` + Shell domain.ServerCommandShell `json:"shell,omitempty"` +} + +type ServerDeploymentResponse struct { + ServerInstanceID string `json:"serverInstanceId"` + Mode domain.ServerDeploymentMode `json:"mode,omitempty"` + ProfileKey string `json:"profileKey,omitempty"` + CreateInputs map[string]string `json:"createInputs,omitempty"` + ServerRootConfigured bool `json:"serverRootConfigured"` + WorkingDirectoryConfigured bool `json:"workingDirectoryConfigured"` + InstallCommandConfigured bool `json:"installCommandConfigured"` + StartCommandConfigured bool `json:"startCommandConfigured"` + StopCommandConfigured bool `json:"stopCommandConfigured"` + StatusCommandConfigured bool `json:"statusCommandConfigured"` + Shell domain.ServerCommandShell `json:"shell,omitempty"` + Revision int `json:"revision"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} type ServerLifecycleCreateRequest struct { - ID string `json:"id"` - PluginID string `json:"pluginId"` - RunEndpointID string `json:"runEndpointId"` - Name string `json:"name"` - OwnerUserID string `json:"ownerUserId,omitempty"` - IdempotencyKey string `json:"idempotencyKey"` - ProfileKey string `json:"profileKey"` - Bindings map[string]string `json:"bindings,omitempty"` + ID string `json:"id"` + PluginID string `json:"pluginId"` + RunEndpointID string `json:"runEndpointId"` + Name string `json:"name"` + OwnerUserID string `json:"ownerUserId,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` + ProfileKey string `json:"profileKey"` + Bindings map[string]string `json:"bindings,omitempty"` + Deployment ServerDeploymentRequest `json:"deployment,omitempty"` } type ServerLifecycleCommandRequest struct { @@ -35,9 +71,23 @@ func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCre IdempotencyKey: request.IdempotencyKey, ProfileKey: request.ProfileKey, Bindings: domain.CopyStringMap(request.Bindings), + Deployment: request.Deployment.deploymentDefinition(), } } +func (request ServerDeploymentRequest) ToDomain() domain.ServerDeploymentUpdate { + return domain.ServerDeploymentUpdate{RunEndpointID: request.RunEndpointID, Mode: request.Mode, ProfileKey: request.ProfileKey, RuntimeBindings: domain.CopyStringMap(request.RuntimeBindings), CreateInputs: domain.CopyStringMap(request.CreateInputs), ServerRoot: request.ServerRoot, WorkingDirectory: request.WorkingDirectory, InstallCommand: request.InstallCommand, StartCommand: request.StartCommand, StopCommand: request.StopCommand, StatusCommand: request.StatusCommand, Shell: request.Shell} +} + +func (request ServerDeploymentRequest) deploymentDefinition() domain.ServerDeploymentDefinition { + update := request.ToDomain() + return domain.ServerDeploymentDefinition{Mode: update.Mode, ProfileKey: update.ProfileKey, RuntimeBindings: update.RuntimeBindings, CreateInputs: update.CreateInputs, ServerRoot: update.ServerRoot, WorkingDirectory: update.WorkingDirectory, InstallCommand: update.InstallCommand, StartCommand: update.StartCommand, StopCommand: update.StopCommand, StatusCommand: update.StatusCommand, Shell: update.Shell} +} + +func ServerDeploymentFromDomain(view domain.ServerDeploymentView) ServerDeploymentResponse { + return ServerDeploymentResponse{ServerInstanceID: view.ServerInstanceID, Mode: view.Mode, ProfileKey: view.ProfileKey, CreateInputs: domain.CopyStringMap(view.CreateInputs), ServerRootConfigured: view.ServerRootConfigured, WorkingDirectoryConfigured: view.WorkingDirectoryConfigured, InstallCommandConfigured: view.InstallCommandConfigured, StartCommandConfigured: view.StartCommandConfigured, StopCommandConfigured: view.StopCommandConfigured, StatusCommandConfigured: view.StatusCommandConfigured, Shell: view.Shell, Revision: view.Revision, UpdatedAt: optionalTime(view.UpdatedAt)} +} + func (request ServerLifecycleCommandRequest) ToDomain(serverInstanceID string) domain.ServerLifecycleCommand { return domain.ServerLifecycleCommand{ ServerInstanceID: serverInstanceID, diff --git a/platform/protocol/server-deployment.md b/platform/protocol/server-deployment.md new file mode 100644 index 0000000..1c1a46d --- /dev/null +++ b/platform/protocol/server-deployment.md @@ -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. diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 8287613..fbee626 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -56,7 +56,11 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai job = normalizeJobScheduling(job, stamp) job.Attempt++ job.State = domain.JobStateAccepted - job.Progress = domain.JobProgress{Percent: 0, Message: "claimed; awaiting Run acknowledgement"} + phase := job.Progress.Phase + if job.ExecutionInput.Deployment != nil { + phase = "claimed" + } + job.Progress = domain.JobProgress{Percent: 0, Phase: phase, Message: "claimed; awaiting Run acknowledgement"} job.NextAttemptAt = time.Time{} job.LeaseTokenHash = tokenHash(leaseToken) job.LeaseSessionGen = session.Generation @@ -147,7 +151,7 @@ func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (do if progress.Sequence > 0 && progress.Sequence <= job.LastProgressSeq { return domain.RunJobProgressResult{}, validationError("progress sequence is stale") } - job.Progress = domain.JobProgress{Percent: progress.Progress.Percent, Message: progress.Progress.Message} + job.Progress = domain.JobProgress{Percent: progress.Progress.Percent, Phase: progress.Progress.Phase, Message: progress.Progress.Message} if progress.Sequence > 0 { job.LastProgressSeq = progress.Sequence } @@ -208,7 +212,7 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo } if result.State == domain.JobStateFailed && result.Retryable && job.Attempt < job.RetryPolicy.MaxAttempts && job.CancelRequestedAt.IsZero() { - job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Message: terminalMessage(result)} + job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Phase: result.Progress.Phase, Message: terminalMessage(result)} if err := svc.scheduleJobRetry(&job, stamp, "retryable Run failure"); err != nil { return domain.RunJobResultResult{}, err } @@ -216,7 +220,7 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo } job.State = result.State - job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Message: terminalMessage(result)} + job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Phase: result.Progress.Phase, Message: terminalMessage(result)} job.ResultRef = result.ResultRef job.ExecutionResult = result.ExecutionResult job.TerminalAt = stamp @@ -598,9 +602,9 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen InputRef: job.InputRef, IdempotencyKey: job.IdempotencyKey, State: job.State, - Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message}, + Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Phase: job.Progress.Phase, Message: job.Progress.Message}, ResultRef: job.ResultRef, - ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON)}, + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment)}, LeaseToken: leaseToken, Attempt: job.Attempt, MaxAttempts: job.RetryPolicy.MaxAttempts, @@ -613,6 +617,14 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen } } +func deploymentPlanForDispatchValue(definition *domain.ServerDeploymentDefinition) *domain.ServerDeploymentDefinition { + if definition == nil { + return nil + } + copy := domain.CopyServerDeploymentDefinition(*definition) + return © +} + func emptyJobClaim(runEndpointID string, stamp time.Time) domain.RunJobClaimResult { return domain.RunJobClaimResult{Accepted: true, RunEndpointID: runEndpointID, NextPollSeconds: defaultJobPollSeconds, ServerTime: stamp} } diff --git a/platform/service/resources.go b/platform/service/resources.go index 851bc31..3c594c0 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -85,6 +85,9 @@ type Core interface { CreateServerInstanceForSession(string, domain.ServerInstance) (domain.ServerInstance, error) CreateServerInstanceWorkflow(domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) CreateServerInstanceWorkflowForSession(string, domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) + GetServerDeploymentForSession(string, string) (domain.ServerDeploymentView, error) + UpdateServerDeploymentForSession(string, string, domain.ServerDeploymentUpdate) (domain.ServerDeploymentView, error) + DeployServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StartServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) @@ -693,6 +696,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe SupportedOS: manifest.Server.SupportedOS, ManifestRef: registration.ManifestRef, CreateFormSchemaRef: manifest.Server.CreateFormSchema, + CreateFields: manifest.Server.CreateFields, RequiredRunCapabilities: manifest.Capabilities, DeclaredPermissions: manifest.Permissions, Permissions: pluginPermissionsFromManifest(manifest.Permissions), @@ -1458,6 +1462,7 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark SupportedOS: plugin.SupportedOS, ManifestRef: plugin.ManifestRef, CreateFormSchemaRef: plugin.CreateFormSchemaRef, + CreateFields: plugin.CreateFields, Capabilities: plugin.RequiredRunCapabilities, DeclaredPermissions: plugin.DeclaredPermissions, Permissions: plugin.Permissions, @@ -1534,9 +1539,12 @@ func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (do if err != nil { return domain.ServerInstance{}, fmt.Errorf("get plugin dependency: %w", err) } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) - if err != nil { - return domain.ServerInstance{}, fmt.Errorf("get run endpoint dependency: %w", err) + var endpoint domain.RunEndpoint + if strings.TrimSpace(instance.RunEndpointID) != "" { + endpoint, err = svc.store.RunEndpoints().Get(instance.RunEndpointID) + if err != nil { + return domain.ServerInstance{}, fmt.Errorf("get run endpoint dependency: %w", err) + } } if instance.PluginVersion == "" { @@ -1559,8 +1567,15 @@ func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (do if err := validator.ValidateServerInstance(instance); err != nil { return domain.ServerInstance{}, err } - if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { - return domain.ServerInstance{}, err + if instance.State != domain.ServerInstanceStateDraft && strings.TrimSpace(instance.RunEndpointID) == "" { + return domain.ServerInstance{}, validationError("runEndpointId is required when server is not a draft") + } + if strings.TrimSpace(instance.RunEndpointID) != "" { + if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { + return domain.ServerInstance{}, err + } + } else if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion { + return domain.ServerInstance{}, validationError("plugin must be installed and match the server plugin version") } if err := svc.store.ServerInstances().Create(instance); err != nil { return domain.ServerInstance{}, err diff --git a/platform/service/server_deployment.go b/platform/service/server_deployment.go new file mode 100644 index 0000000..cb8ce9a --- /dev/null +++ b/platform/service/server_deployment.go @@ -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, + }) +} diff --git a/platform/service/server_deployment_test.go b/platform/service/server_deployment_test.go new file mode 100644 index 0000000..b21800a --- /dev/null +++ b/platform/service/server_deployment_test.go @@ -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) + } +} diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index 0648431..caa2a62 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -18,13 +18,21 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc return domain.ServerLifecycleResult{}, err } - plugin, endpoint, err := svc.lifecycleDependencies(create.PluginID, create.RunEndpointID) + plugin, err := svc.store.GamePlugins().Get(create.PluginID) if err != nil { - return domain.ServerLifecycleResult{}, err + return domain.ServerLifecycleResult{}, fmt.Errorf("get plugin dependency: %w", err) + } + if plugin.Status != domain.GamePluginStatusInstalled { + return domain.ServerLifecycleResult{}, validationError("plugin must be installed") } if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil { return domain.ServerLifecycleResult{}, err } + if create.Deployment.Mode != "" { + if err := validator.ValidatePluginCreateInputs(plugin.CreateFields, create.Deployment.CreateInputs); err != nil { + return domain.ServerLifecycleResult{}, err + } + } stamp := svc.now() instance := domain.ServerInstance{ @@ -39,31 +47,63 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc ConfigKey: "server.properties", CreatedAt: stamp, UpdatedAt: stamp, + Deployment: create.Deployment, + } + if instance.Deployment.Mode != "" { + instance.Deployment.ProfileKey = create.ProfileKey + instance.Deployment.RuntimeBindings = domain.CopyStringMap(create.Bindings) + instance.Deployment.Revision = maxInt(1, instance.Deployment.Revision) + instance.Deployment.UpdatedAt = stamp } instance.ConfigContent = buildLogicalServerConfig(instance) instance.ConfigChecksum = validator.BytesChecksum([]byte(instance.ConfigContent)) instance.ConfigUpdatedAt = stamp + if strings.TrimSpace(create.RunEndpointID) == "" { + instance.State = domain.ServerInstanceStateDraft + } if err := validator.ValidateServerInstance(instance); err != nil { return domain.ServerLifecycleResult{}, err } + if strings.TrimSpace(create.RunEndpointID) == "" { + if err := svc.store.ServerInstances().Create(instance); err != nil { + return domain.ServerLifecycleResult{}, err + } + return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{Accepted: true, Action: domain.ServerLifecycleActionCreate, Instance: instance}), nil + } + endpoint, err := svc.store.RunEndpoints().Get(create.RunEndpointID) + if err != nil { + return domain.ServerLifecycleResult{}, fmt.Errorf("get run endpoint dependency: %w", err) + } if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { return domain.ServerLifecycleResult{}, err } if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { return domain.ServerLifecycleResult{}, err } + if instance.Deployment.Mode != "" && !containsString(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) { + return domain.ServerLifecycleResult{}, validationError("run endpoint missing required capability: deployment.plan.v1") + } + if requiredShellCapability := deploymentShellCapability(instance.Deployment.Shell); requiredShellCapability != "" && !containsString(endpoint.Capabilities, requiredShellCapability) { + return domain.ServerLifecycleResult{}, validationError("run endpoint policy does not allow selected command shell") + } if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { return domain.ServerLifecycleResult{}, err } - binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: create.ProfileKey, Bindings: create.Bindings}, true) - if err != nil { - return domain.ServerLifecycleResult{}, err + var binding domain.RuntimeBinding + hasBinding := strings.TrimSpace(create.ProfileKey) != "" + if hasBinding { + binding, err = svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: create.ProfileKey, Bindings: create.Bindings}, true) + if err != nil { + return domain.ServerLifecycleResult{}, err + } } if err := svc.store.ServerInstances().Create(instance); err != nil { return domain.ServerLifecycleResult{}, err } - if err := svc.store.RuntimeBindings().Create(binding); err != nil { - return domain.ServerLifecycleResult{}, err + if hasBinding { + if err := svc.store.RuntimeBindings().Create(binding); err != nil { + return domain.ServerLifecycleResult{}, err + } } job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey) @@ -208,15 +248,21 @@ func (svc *CoreService) lifecycleDependencies(pluginID string, runEndpointID str func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, action domain.ServerLifecycleAction, idempotencyKey string) (domain.Job, error) { capability := domain.LifecycleCapabilityForAction(action) binding, err := svc.runtimeBindingForServer(instance.ID) - if err != nil { + if err != nil && !errors.Is(err, repo.ErrNotFound) { return domain.Job{}, err } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.Job{}, err } - actionRef := binding.ProfileKey - profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey) + profileKey := "" + if err == nil { + profileKey = binding.ProfileKey + } else { + profileKey = instance.Deployment.ProfileKey + } + actionRef := profileKey + profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey) if hasProfile { if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" { actionRef = ref @@ -245,11 +291,13 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act Capability: capability, TargetKey: actionRef, IdempotencyKey: idempotencyKey, + Progress: lifecycleJobProgress(instance.Deployment), ExecutionInput: domain.JobExecutionInput{ - WorkspaceScope: binding.ProfileKey, + WorkspaceScope: profileKey, PluginID: plugin.ID, LifecycleOperation: lifecycleExecutionOperation(action), DLLExtensions: dllExtensions, + Deployment: deploymentPlanForDispatch(instance.Deployment), }, }) if err != nil { @@ -261,6 +309,21 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act return job, nil } +func lifecycleJobProgress(deployment domain.ServerDeploymentDefinition) domain.JobProgress { + if deployment.Mode != "" { + return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"} + } + return domain.JobProgress{} +} + +func deploymentPlanForDispatch(definition domain.ServerDeploymentDefinition) *domain.ServerDeploymentDefinition { + if definition.Mode == "" { + return nil + } + copy := domain.CopyServerDeploymentDefinition(definition) + return © +} + func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string { switch action { case domain.ServerLifecycleActionCreate: diff --git a/platform/validator/job_channel.go b/platform/validator/job_channel.go index 068055a..05899d1 100644 --- a/platform/validator/job_channel.go +++ b/platform/validator/job_channel.go @@ -142,10 +142,22 @@ func appendProgressViolations(violations []string, progress domain.RunJobProgres if progress.Percent < 0 || progress.Percent > 100 { violations = append(violations, "progress.percent must be between 0 and 100") } + if progress.Phase != "" && !validDeploymentProgressPhase(progress.Phase) { + violations = append(violations, "progress.phase is invalid") + } violations = appendMessageLength(violations, "progress.message", progress.Message) return violations } +func validDeploymentProgressPhase(phase string) bool { + switch phase { + case "queued", "claimed", "preflight", "install", "configure", "start", "health": + return true + default: + return false + } +} + func appendMessageLength(violations []string, field string, message string) []string { if len(message) > maxJobChannelMessageLength { violations = append(violations, field+" is too long") diff --git a/platform/validator/resources.go b/platform/validator/resources.go index deb15cc..3f1334e 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -2,6 +2,7 @@ package validator import ( "fmt" + "strconv" "strings" "browser.local/platform/domain" @@ -155,6 +156,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error { violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...) violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...) violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...) + violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...) violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...) return finish(violations) } @@ -182,6 +184,7 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife if !safeRelativeJSONRef(manifest.Server.CreateFormSchema) { violations = append(violations, "manifest.server.createFormSchema must be a safe relative JSON reference") } + violations = append(violations, validatePluginCreateFields("manifest.server.createFields", manifest.Server.CreateFields)...) for i, osName := range manifest.Server.SupportedOS { if !validPluginSupportedOS(osName) { violations = append(violations, fmt.Sprintf("manifest.server.supportedOs[%d] is not allowed", i)) @@ -226,6 +229,122 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife return finish(violations) } +func validatePluginCreateFields(prefix string, fields []domain.PluginCreateField) []string { + if len(fields) > 32 { + return []string{prefix + " must contain at most 32 fields"} + } + var violations []string + keys := map[string]struct{}{} + for i, field := range fields { + item := fmt.Sprintf("%s[%d]", prefix, i) + if !validDistributionLogicalKey(field.Key) { + violations = append(violations, item+".key is invalid") + } + if _, found := keys[field.Key]; found { + violations = append(violations, item+".key duplicates another field") + } + keys[field.Key] = struct{}{} + if strings.TrimSpace(field.Label) == "" || len(field.Label) > 60 || strings.TrimSpace(field.Label) != field.Label { + violations = append(violations, item+".label is invalid") + } + if !validPluginCreateFieldType(field.Type) { + violations = append(violations, item+".type is invalid") + } + if len(field.DefaultValue) > 256 || strings.TrimSpace(field.DefaultValue) != field.DefaultValue || containsUnsafeRuntimeSecret(field.DefaultValue) || looksLikeRawHostPath(field.DefaultValue) { + violations = append(violations, item+".defaultValue is unsafe") + } + if field.ConfigKey != "" && !validDistributionLogicalKey(field.ConfigKey) { + violations = append(violations, item+".configKey is invalid") + } + if len(field.Options) > 32 { + violations = append(violations, item+".options has too many values") + } + options := map[string]struct{}{} + for _, option := range field.Options { + if strings.TrimSpace(option) == "" || len(option) > 120 || strings.TrimSpace(option) != option || containsUnsafeRuntimeSecret(option) || looksLikeRawHostPath(option) { + violations = append(violations, item+".options contains an unsafe value") + break + } + if _, found := options[option]; found { + violations = append(violations, item+".options contains duplicates") + break + } + options[option] = struct{}{} + } + if field.Type == "select" && len(field.Options) == 0 { + violations = append(violations, item+".options is required for select") + } + if field.Type != "select" && len(field.Options) > 0 { + violations = append(violations, item+".options is only valid for select") + } + } + return violations +} + +func validPluginCreateFieldType(value string) bool { + switch value { + case "text", "number", "boolean", "select", "port": + return true + default: + return false + } +} + +// ValidatePluginCreateInputs keeps game settings distinct from runtime +// bindings while enforcing the plugin's published, non-executable field set. +func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[string]string) error { + if len(fields) == 0 { + return nil + } + declared := make(map[string]domain.PluginCreateField, len(fields)) + for _, field := range fields { + declared[field.Key] = field + } + var violations []string + for key, value := range inputs { + field, found := declared[key] + if !found { + violations = append(violations, "createInputs."+key+" is not declared by plugin") + continue + } + if len(value) > 1024 || strings.TrimSpace(value) != value || containsUnsafeRuntimeSecret(value) { + violations = append(violations, "createInputs."+key+" is invalid") + continue + } + if value == "" { + if field.Required && field.DefaultValue == "" { + violations = append(violations, "createInputs."+key+" is required") + } + continue + } + switch field.Type { + case "port": + port, err := strconv.Atoi(value) + if err != nil || port < 1 || port > 65535 { + violations = append(violations, "createInputs."+key+" must be a port between 1 and 65535") + } + case "number": + if _, err := strconv.ParseFloat(value, 64); err != nil { + violations = append(violations, "createInputs."+key+" must be numeric") + } + case "boolean": + if value != "true" && value != "false" { + violations = append(violations, "createInputs."+key+" must be true or false") + } + case "select": + if !containsString(field.Options, value) { + violations = append(violations, "createInputs."+key+" is not an allowed option") + } + } + } + for _, field := range fields { + if field.Required && field.DefaultValue == "" && strings.TrimSpace(inputs[field.Key]) == "" { + violations = append(violations, "createInputs."+field.Key+" is required") + } + } + return finish(violations) +} + func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string { companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { @@ -676,7 +795,9 @@ func validateServerInstance(instance domain.ServerInstance, allowDeleted bool) e violations = appendRequired(violations, "id", instance.ID) violations = appendRequired(violations, "pluginId", instance.PluginID) violations = appendRequired(violations, "pluginVersion", instance.PluginVersion) - violations = appendRequired(violations, "runEndpointId", instance.RunEndpointID) + if instance.State != domain.ServerInstanceStateDraft { + violations = appendRequired(violations, "runEndpointId", instance.RunEndpointID) + } violations = appendRequired(violations, "name", instance.Name) if strings.TrimSpace(instance.OwnerUserID) != instance.OwnerUserID { violations = append(violations, "ownerUserId must not have surrounding whitespace") @@ -702,9 +823,61 @@ func validateServerInstance(instance domain.ServerInstance, allowDeleted bool) e if instance.ConfigVersion < 0 { violations = append(violations, "configVersion must not be negative") } + if err := ValidateServerDeploymentDefinition(instance.Deployment); err != nil { + violations = append(violations, err.Error()) + } return finish(violations) } +func ValidateServerDeploymentDefinition(definition domain.ServerDeploymentDefinition) error { + if definition.Mode == "" { + return nil + } + var violations []string + if definition.Mode != domain.ServerDeploymentModeGuided && definition.Mode != domain.ServerDeploymentModeExisting && definition.Mode != domain.ServerDeploymentModeCustom { + violations = append(violations, "deployment mode is invalid") + } + if definition.Shell != domain.ServerCommandShellNone && definition.Shell != domain.ServerCommandShellPosix && definition.Shell != domain.ServerCommandShellPowerShell && definition.Shell != domain.ServerCommandShellCmd { + violations = append(violations, "deployment shell is invalid") + } + if definition.Revision < 0 { + violations = append(violations, "deployment revision must not be negative") + } + for key, value := range definition.CreateInputs { + if !validDistributionLogicalKey(key) || len(value) > 1024 || strings.TrimSpace(value) != value || containsUnsafeRuntimeSecret(value) { + violations = append(violations, "deployment createInputs are invalid") + break + } + } + for key, value := range definition.RuntimeBindings { + if !validDistributionLogicalKey(key) || strings.TrimSpace(value) != value || looksLikeRawHostPath(value) || containsUnsafeRuntimeSecret(value) { + violations = append(violations, "deployment runtimeBindings are invalid") + break + } + } + for _, value := range []string{definition.ServerRoot, definition.WorkingDirectory} { + if value != "" && (len(value) > 1024 || strings.TrimSpace(value) != value || !looksLikeAbsoluteHostPath(value)) { + violations = append(violations, "deployment path must be an absolute host path") + break + } + } + for _, value := range []string{definition.InstallCommand, definition.StartCommand, definition.StopCommand, definition.StatusCommand} { + if value != "" && (len(value) > 4096 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\x00\r\n") || containsUnsafeRuntimeSecret(value)) { + violations = append(violations, "deployment command is invalid or contains a secret") + break + } + } + if definition.Mode == domain.ServerDeploymentModeCustom && strings.TrimSpace(definition.StartCommand) == "" { + violations = append(violations, "custom deployment requires a start command") + } + return finish(violations) +} + +func looksLikeAbsoluteHostPath(value string) bool { + trimmed := strings.TrimSpace(value) + return strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, `\\`) || (len(trimmed) >= 3 && trimmed[1] == ':' && (trimmed[2] == '\\' || trimmed[2] == '/')) +} + func ValidateServerInstanceUpdate(update domain.ServerInstanceUpdate) error { var violations []string if update.Name != nil { @@ -973,6 +1146,9 @@ func ValidateJob(job domain.Job) error { if job.Progress.Percent < 0 || job.Progress.Percent > 100 { violations = append(violations, "progress.percent must be between 0 and 100") } + if job.Progress.Phase != "" && !validDeploymentProgressPhase(job.Progress.Phase) { + violations = append(violations, "progress.phase is invalid") + } if len(job.Progress.Message) > maxProgressMessageLength { violations = append(violations, "progress.message is too long") } @@ -1581,6 +1757,7 @@ func validPluginRunCapability(capability string) bool { domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, + domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, "artifacts.read", "artifacts.write", "artifact.read", "artifact.write", diff --git a/platform/validator/server_lifecycle.go b/platform/validator/server_lifecycle.go index 06e4e0c..83b1a9f 100644 --- a/platform/validator/server_lifecycle.go +++ b/platform/validator/server_lifecycle.go @@ -13,10 +13,14 @@ func ValidateServerLifecycleCreate(create domain.ServerLifecycleCreate) error { var violations []string violations = appendRequired(violations, "id", create.ID) violations = appendRequired(violations, "pluginId", create.PluginID) - violations = appendRequired(violations, "runEndpointId", create.RunEndpointID) violations = appendRequired(violations, "name", create.Name) - violations = appendRequired(violations, "profileKey", create.ProfileKey) + if create.RunEndpointID != "" { + violations = appendRequired(violations, "profileKey", create.ProfileKey) + } violations = appendLifecycleIdempotencyViolations(violations, create.IdempotencyKey) + if err := ValidateServerDeploymentDefinition(create.Deployment); err != nil { + violations = append(violations, err.Error()) + } return finish(violations) } diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 3d61152..cd17f0d 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -88,6 +88,8 @@ import type { ServerLifecycleCommandRequest, ServerLifecycleCreateRequest, ServerLifecycleResponse, + ServerDeploymentRequest, + ServerDeploymentResponse, ServerConfigWriteApprovalRequest, ServerConfigWriteDispatchResponse, SourceRCONCommandRequest, @@ -185,6 +187,18 @@ export class PlatformApiClient { }); } + async getServerDeployment(id: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/deployment`); + } + + async updateServerDeployment(id: string, request: ServerDeploymentRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/deployment`, { method: "PUT", body: request }); + } + + async deployServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/deploy`, { method: "POST", body: request }); + } + async getServerRuntimeBinding(id: string): Promise { return this.request(`/server-instances/${encodeURIComponent(id)}/runtime-binding`); } diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 8faca3a..1044dfa 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -342,6 +342,7 @@ export interface GamePluginResponse { supportedOs?: string[]; manifestRef: string; createFormSchemaRef: string; + createFields?: PluginCreateFieldResponse[]; requiredRunCapabilities: string[]; declaredPermissions: string[]; permissions: PluginPermissionsResponse; @@ -357,6 +358,18 @@ export interface GamePluginResponse { 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 { items: GamePluginResponse[]; count: number; @@ -439,11 +452,48 @@ export interface ServerInstanceListResponse { export interface ServerLifecycleCreateRequest { id: string; pluginId: string; - runEndpointId: string; + runEndpointId?: string; name: string; idempotencyKey: string; - profileKey: string; - bindings: Record; + profileKey?: string; + bindings?: Record; + 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; + createInputs?: Record; + 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; + serverRootConfigured: boolean; + workingDirectoryConfigured: boolean; + installCommandConfigured: boolean; + startCommandConfigured: boolean; + stopCommandConfigured: boolean; + statusCommandConfigured: boolean; + shell?: ServerCommandShell; + revision: number; + updatedAt?: string; } export interface RuntimeBindingUpdateRequest { @@ -531,6 +581,7 @@ export interface RunEndpointListResponse { export interface JobProgressBody { percent: number; + phase?: "queued" | "claimed" | "preflight" | "install" | "configure" | "start" | "health"; message?: string; } diff --git a/platform_web/contracts/serverManagement.ts b/platform_web/contracts/serverManagement.ts index 3872793..c28cc96 100644 --- a/platform_web/contracts/serverManagement.ts +++ b/platform_web/contracts/serverManagement.ts @@ -1,8 +1,9 @@ import type { - GamePluginResponse, - JobResponse, - RunEndpointResponse, - ServerInstanceResponse, + GamePluginResponse, + JobResponse, + RunEndpointResponse, + ServerDeploymentMode, + ServerInstanceResponse, ServerInstanceState } from "../api/types"; @@ -15,8 +16,17 @@ export interface ServerCreateFormState { name: string; pluginId: string; runEndpointId: string; - profileKey: string; - bindings: Record; + profileKey: string; + bindings: Record; + createInputs: Record; + deploymentMode: ServerDeploymentMode; + serverRoot: string; + workingDirectory: string; + installCommand: string; + startCommand: string; + stopCommand: string; + statusCommand: string; + shell: "" | "posix-sh" | "powershell" | "cmd"; } export interface RuntimeBindingField { @@ -55,8 +65,17 @@ export const emptyServerCreateForm: ServerCreateFormState = { name: "", pluginId: "", runEndpointId: "", - profileKey: "", - bindings: {} + profileKey: "", + bindings: {}, + createInputs: {}, + deploymentMode: "guided-install", + serverRoot: "", + workingDirectory: "", + installCommand: "", + startCommand: "", + stopCommand: "", + statusCommand: "", + shell: "" }; export function summarizeServerManagement(instances: ServerInstanceResponse[], jobs: JobResponse[]): ServerManagementSummary { @@ -104,8 +123,13 @@ export function defaultServerCreateForm(plugins: GamePluginResponse[], endpoints ...emptyServerCreateForm, pluginId: plugin?.id ?? "", profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", - runEndpointId: endpoints[0]?.id ?? "" - }; + runEndpointId: "", + createInputs: pluginCreateInputDefaults(plugin) + }; +} + +export function pluginCreateInputDefaults(plugin: GamePluginResponse | undefined): Record { + return Object.fromEntries((plugin?.createFields ?? []).map((field) => [field.key, field.defaultValue ?? ""])); } export function runtimeBindingFields(plugin: GamePluginResponse | undefined, profileKey: string): RuntimeBindingField[] { diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index e391870..4ab0927 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -21,6 +21,8 @@ import type { ServerMemberResponse, ServerMetricsResponse, RuntimeBindingResponse, + RunEndpointResponse, + ServerDeploymentResponse, ServerRuntimeActionsResponse, MetricSampleResponse, RemoteAdapterDeclarationResponse @@ -46,7 +48,7 @@ import { import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; 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 { serverDetailSections, serverIsOnline, @@ -89,6 +91,8 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa const [remoteAdapters, setRemoteAdapters] = useState([]); const [runtimeActions, setRuntimeActions] = useState>({ status: "loading" }); const [runtimeBinding, setRuntimeBinding] = useState>({ status: "loading" }); + const [deployment, setDeployment] = useState>({ status: "loading" }); + const [endpoints, setEndpoints] = useState([]); const [confirm, setConfirm] = useState Promise }>(null); const [confirmBusy, setConfirmBusy] = useState(false); @@ -99,9 +103,10 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa } setInstance({ status: "loading" }); 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.listGamePlugins(), + platformApiClient.listRunEndpoints(), platformApiClient.listJobs(serverId), platformApiClient .getServerRuntimeActions(serverId) @@ -111,15 +116,21 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa .getServerRuntimeBinding(serverId) .then((data): LoadState => ({ status: "ready", data })) .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })), + platformApiClient + .getServerDeployment(serverId) + .then((data): LoadState => ({ status: "ready", data })) + .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })), platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })), platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })), platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })) ]); setInstance({ status: "ready", data: detail }); setPlugins(pluginResponse.items); + setEndpoints(endpointResponse.items); setJobs(jobResponse.items); setRuntimeActions(runtimeResponse); setRuntimeBinding(bindingResponse); + setDeployment(deploymentResponse); setMetricHistory(metricHistoryResponse.items); setBackups(backupResponse.items); setRemoteAdapters(adapterResponse.items); @@ -137,6 +148,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa setArtifacts([]); setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" }); setRuntimeBinding({ status: "error", reason: "运行配置加载失败" }); + setDeployment({ status: "error", reason: "部署定义加载失败" }); setMetricHistory([]); setBackups([]); setRemoteAdapters([]); @@ -295,6 +307,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa onChanged={() => void refresh()} /> )} + {section === "overview" && void refresh()} />} {section === "overview" && plugin.id === instance.data.pluginId)?.runtimeProfiles} />} {section === "overview" && } {section === "overview" && ( @@ -412,6 +425,104 @@ function ServerMetadataSection({ instance, session, operations, onChanged }: Ser ); } +interface ServerDeploymentSectionProps { + instance: ServerInstanceResponse; + deployment: LoadState; + 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) { + 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 ; + if (deployment.status === "error") return ; + const view = deployment.data; + return
+

部署定义

{view.mode || "未配置"} · 修订 {view.revision}
+

根目录、工作目录和命令为受保护输入:保存后仅显示已配置状态,不会显示原文。

+
根目录:{view.serverRootConfigured ? "已配置" : "未配置"}工作目录:{view.workingDirectoryConfigured ? "已配置" : "未配置"}安装 / 启动:{view.installCommandConfigured ? "已配置" : "未配置"} / {view.startCommandConfigured ? "已配置" : "未配置"}
+
void save(event)}> +
+ + + + + {mode === "custom-command" && <>} +
+
{instance.state === "draft" || instance.state === "failed" ? : null}
+
+ {result &&
} +
; +} + +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 { instance: ServerInstanceResponse; session: PageComponentProps["session"]; @@ -587,7 +698,7 @@ function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectio {failed.length > 0 && ⚠ 最近有 {failed.length} 个任务失败。} {pending.length > 0 ? ( - 进行中任务:{pending[0].capability}({pending[0].state},{pending[0].progress.percent}%) + 进行中任务:{pending[0].capability}({deploymentProgressLabel(pending[0].progress)},{pending[0].progress.percent}%) ) : ( 当前没有进行中的任务。 @@ -2267,6 +2378,7 @@ function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHis 任务 {job.id} 进度 {job.progress.percent}% + {job.progress.phase && 阶段:{deploymentProgressLabel(job.progress)}} 尝试 {job.attempt}/{job.retryPolicy.maxAttempts} diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index 1dd9d2b..730657d 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -23,6 +23,7 @@ import { canDeleteServer, defaultServerCreateForm, endpointLabel, + pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, type ServerCreateFormState @@ -101,6 +102,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr pluginId: plugin?.id ?? "", profileKey, 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) ? current.runEndpointId : endpointResponse.items[0]?.id || "" @@ -156,13 +158,14 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr const createProfileOptions = selectedCreatePlugin?.runtimeProfiles?.lifecycleProfiles ?? []; const createProfileUnavailable = Boolean(selectedCreatePlugin && createProfileOptions.length === 0); const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey); + const createPluginFields = selectedCreatePlugin?.createFields ?? []; function updateForm(event: ChangeEvent) { const { name, value } = event.target; setForm((current) => { if (name === "pluginId") { 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") { return { ...current, profileKey: value, bindings: {} }; @@ -175,12 +178,16 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr 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) { event.preventDefault(); const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName }); try { 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)); setShowCreate(false); await refresh(); @@ -489,7 +496,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr { if (!createPending) setShowCreate(false); }} > @@ -511,7 +518,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr