feat: move distribution builds to platform Docker builder

This commit is contained in:
npc0-hue
2026-07-30 19:25:50 +08:00
parent 1e004dc9ec
commit e614a17fe3
45 changed files with 4492 additions and 294 deletions
+20
View File
@@ -10,6 +10,12 @@ The platform's required first-party areas are 首页、服务器管理、插件
The platform_web visual direction is a unified magical-girl crystal-moonlight game operations console. Preserve the style rules in `platform_web/AGENTS.md` and `platform_web/theme/README.md`; do not replace it with generic opaque SaaS cards or unrelated visual systems without a future OpenSpec change. Global magical ultimate effects belong in `platform_web/components/MagicalParticleLayer.tsx`, not in page-local fixed decoration spans or one-off backdrop CSS. The platform_web visual direction is a unified magical-girl crystal-moonlight game operations console. Preserve the style rules in `platform_web/AGENTS.md` and `platform_web/theme/README.md`; do not replace it with generic opaque SaaS cards or unrelated visual systems without a future OpenSpec change. Global magical ultimate effects belong in `platform_web/components/MagicalParticleLayer.tsx`, not in page-local fixed decoration spans or one-off backdrop CSS.
## Server Creation Rules
Creating a server instance must require only the game plugin type and the server name. Do not require the owner to pick a deployment target, run endpoint, or runtime profile at creation time: the run executor does not exist yet at that point, so any such field can only be filled incorrectly.
The binding between a server instance and its run endpoint is established when the generated run registers itself, not by pre-selecting an existing endpoint in the creation form. Deployment target and runtime profile selection may exist only as optional actions on an already-created instance, never as creation prerequisites.
## Project Roots ## Project Roots
- `platform/` contains backend platform code. - `platform/` contains backend platform code.
@@ -87,6 +93,20 @@ Run-platform communication must remain channelized:
Large file transfer must not block control heartbeat, job ack/result, or log upload. Large file transfer must not block control heartbeat, job ack/result, or log upload.
## Distribution Build Ownership Rules
Distribution building is a platform-side responsibility. The platform must be able to build a run package for any server instance without depending on a privileged worker run endpoint being registered and online. Do not route `distribution.build` execution through machine-side run endpoints, and do not derive build availability from a run endpoint advertising that capability.
Platform-side builds must execute in a platform-owned Docker builder using a pinned image, with build workspaces isolated per plugin and per job. Builder readiness is a platform-level probe; when it is unavailable, the reported reason must name the platform builder rather than a run endpoint capability.
A generated run carries credentials scoped to one server instance and must never hold distribution-build authority. This restriction is a security boundary, not a fallback path: it must not be relaxed to unblock building. Fix the build path instead.
Component auth keys must stay inside the platform for builder-executed builds. Do not return plaintext auth keys to machine-side run endpoints for distribution builds.
## Local Run Distribution Testing
For local testing, when a generated `run.exe` must be made available to a remote server, start a temporary file server from the build output directory with `python3 -m http.server 8000`. Download the artifact through `https://bt.npc0.com/` followed by its relative file path, then download that file again on the machine where `run` will execute. This is a test-only distribution path; do not treat the temporary HTTP server or tunnel as a production artifact-delivery service.
## AI Provider Rules ## AI Provider Rules
AI provider keys and base URLs belong to `platform/`. Plugins may request AI assistance only through platform-mediated capabilities. Plugin page must never receive raw AI keys. AI provider keys and base URLs belong to `platform/`. Plugins may request AI assistance only through platform-mediated capabilities. Plugin page must never receive raw AI keys.
+12 -4
View File
@@ -7,14 +7,14 @@ The local debug workspace runs the real platform API, run worker, platform_web c
- Platform listens on `http://127.0.0.1:18080` by default. - Platform listens on `http://127.0.0.1:18080` by default.
- platform_web listens on `http://127.0.0.1:5173` by default and proxies `/api/v1` plus `/healthz` to platform. - platform_web listens on `http://127.0.0.1:5173` by default and proxies `/api/v1` plus `/healthz` to platform.
- Run editable source is loaded from `RUN_SOURCE_DIR` / legacy `RUN_REPO_DIR`, defaulting to the ignored nested `./run` checkout. - Run editable source is loaded from `RUN_SOURCE_DIR` / legacy `RUN_REPO_DIR`, defaulting to the ignored nested `./run` checkout.
- Run source is snapshotted into `.local-debug/run/build-buckets/source/current`, then the local bootstrap worker is built into `.local-debug/run/build-buckets/bootstrap/bin/run` and registers as `run-local-debug`. - Run source is snapshotted into `.local-debug/run/build-buckets/source/current`; the platform mounts that snapshot read-only into the dedicated `browser-platform-distribution-builder:1.0.0` image, and the local bootstrap worker is built into `.local-debug/run/build-buckets/bootstrap/bin/run` before registering as `run-local-debug`.
- Disposable state lives under `.local-debug/`. - Disposable state lives under `.local-debug/`.
- Logs live under `.local-debug/logs/`. - Logs live under `.local-debug/logs/`.
- PIDs live under `.local-debug/pids/`. - PIDs live under `.local-debug/pids/`.
- Go build cache for local services lives under `.local-debug/go-build-cache/`. - Go build cache for local services lives under `.local-debug/go-build-cache/`.
- The dev plugin fixture is `plugins/examples/dev-game-plugin/manifest.json`. - The dev plugin fixture is `plugins/examples/dev-game-plugin/manifest.json`.
The workflow does not require Docker-only infrastructure, external cloud services, real game binaries, raw credentials, raw AI keys, direct run sockets, or browser/plugin direct access to run. The workflow requires a local Docker daemon for the platform-owned distribution builder. It does not require external cloud services, real game binaries, raw AI keys, direct run sockets, or browser/plugin direct access to run. Component auth keys remain inside the platform and the ephemeral builder input.
## Port Discipline ## Port Discipline
@@ -57,6 +57,11 @@ Key platform variables:
- `PLATFORM_METADATA_PATH=.local-debug/platform/metadata.json` - `PLATFORM_METADATA_PATH=.local-debug/platform/metadata.json`
- `PLATFORM_LOG_BODY_BACKEND=file` - `PLATFORM_LOG_BODY_BACKEND=file`
- `PLATFORM_LOG_DIR=.local-debug/platform/logs` - `PLATFORM_LOG_DIR=.local-debug/platform/logs`
- `PLATFORM_BUILDER_DOCKER_BINARY=docker`
- `PLATFORM_BUILDER_IMAGE=browser-platform-distribution-builder:1.0.0`
- `PLATFORM_BUILDER_SOURCE_DIR=.local-debug/run/build-buckets/source/current`
- `PLATFORM_BUILDER_WORKSPACE_DIR=.local-debug/platform/distribution-builds`
- `PLATFORM_BUILDER_TIMEOUT_SECONDS=1800`
- `GOCACHE=.local-debug/go-build-cache` - `GOCACHE=.local-debug/go-build-cache`
Key run variables: Key run variables:
@@ -101,7 +106,9 @@ The smoke command verifies:
- dev plugin manifest validation and registration through `POST /api/v1/game-plugins/register-manifest`. - dev plugin manifest validation and registration through `POST /api/v1/game-plugins/register-manifest`.
- run endpoint heartbeat through `GET /api/v1/run/endpoints?status=online`. - run endpoint heartbeat through `GET /api/v1/run/endpoints?status=online`.
- server lifecycle fixture setup through `POST /api/v1/server-instances/workflows/create`. - server lifecycle fixture setup through `POST /api/v1/server-instances/workflows/create`.
- SCUM run package generation through Platform-dispatched `distribution.build` when the endpoint advertises build capability. - minimal server creation with plugin type and name only, followed by an explicit post-creation runtime binding.
- host-native Run generation through the platform-owned Docker builder, artifact download and execution, then generated Run registration plus a subsequent heartbeat without `distribution.build` authority.
- SCUM run package generation through the platform-owned Docker builder, without any Run endpoint advertising `distribution.build`.
- latest run download opening through `POST /api/v1/server-instances/{id}/run/download`. - latest run download opening through `POST /api/v1/server-instances/{id}/run/download`.
- generated artifact content reads through `/api/v1/artifacts/{id}/content` with chunk, total size, and checksum verification. - generated artifact content reads through `/api/v1/artifacts/{id}/content` with chunk, total size, and checksum verification.
- job, log stream, artifact, marketplace, and server list references. - job, log stream, artifact, marketplace, and server list references.
@@ -146,7 +153,7 @@ Required walkthrough:
- Open 插件市场. - Open 插件市场.
- Open 用户管理. - Open 用户管理.
- Open AI 提供商管理. - Open AI 提供商管理.
- Inspect `server-local-debug` server detail, plugin controls or marketplace detail, operation history, log references, and artifact references. - Inspect the invocation-scoped example server ID recorded in `.local-debug/smoke/run-build-config.env`, its generated Run endpoint, plugin controls or marketplace detail, operation history, log references, and artifact references.
- Scan visible browser text for forbidden fragments: `/Users/`, `/private/`, `unix://`, `tcp://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, `rawApiKey`, run session tokens, direct run URLs, and plugin-owned transport details. - Scan visible browser text for forbidden fragments: `/Users/`, `/private/`, `unix://`, `tcp://`, `Bearer `, `sk-`, `password=`, `apiKeyRef`, `rawApiKey`, run session tokens, direct run URLs, and plugin-owned transport details.
Acceptance requires logical IDs, platform routes, job refs, log refs, artifact refs, and safe metadata only. Acceptance requires logical IDs, platform routes, job refs, log refs, artifact refs, and safe metadata only.
@@ -182,6 +189,7 @@ The start script wraps these commands with the local debug environment:
```bash ```bash
(cd platform && go run ./cmd/platform) (cd platform && go run ./cmd/platform)
source scripts/local-debug/env.sh source scripts/local-debug/env.sh
local_debug_prepare_distribution_builder
local_debug_build_bootstrap_run local_debug_build_bootstrap_run
"$RUN_BOOTSTRAP_BIN" "$RUN_BOOTSTRAP_BIN"
npm --prefix platform_web run dev -- --port 5173 npm --prefix platform_web run dev -- --port 5173
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-30
@@ -0,0 +1,61 @@
## Context
Two independently reasonable decisions currently deadlock the build path.
`platform/service/job_channel.go` strips `distribution.build` from any component-authenticated session, so a generated run cannot claim build work. `platform/service/distributions.go` derives `generate-run` availability from `svc.endpointSupports(endpoint, domain.JobCapabilityDistributionBuild)`, falling back to `run endpoint cannot build distributions`. An instance bound to its own generated run therefore fails the availability check permanently.
`platform/service/distributions.go` picks the builder endpoint as `instance.DeploymentTargetID` when set, otherwise `instance.RunEndpointID`. Both resolve to machine-side endpoints, so building depends on a hand-maintained privileged worker being registered and online.
`platform/service/distribution_build_jobs.go` decrypts the component key and returns `AuthKey` in `DistributionBuildInput`. Any endpoint claiming a build job receives that plaintext credential.
`platform/validator/server_lifecycle.go` does not require a deployment target; it only requires `profileKey` when `runEndpointId` is supplied. The creation-time requirement is imposed by `platform_web/components/ServerDeploymentWorkflow.tsx`.
## Goals / Non-Goals
Goals: make build execution a platform responsibility with no dependency on machine-side endpoint state; reduce server creation to plugin type and server name; keep the generated-run build restriction as a security boundary; stop shipping plaintext auth keys to machine-side endpoints for builds.
Non-goals: changing the channel model for control/jobs/logs/artifacts; changing how a registered run executes game lifecycle work; adding billing, cloud host sales, or provider workflows; removing deployment target selection from post-creation instance management.
## Decisions
### Platform-owned Docker builder
The platform owns a builder that runs each distribution build in a container from a pinned image, with the run source snapshot mounted read-only and a per-job output directory mounted writable. Container-per-build keeps the existing plugin/job workspace isolation guarantee from `run-build-download-flow` and keeps the Go toolchain out of the platform runtime image.
Alternative considered: building in-process with the platform's own Go toolchain. Rejected because it makes the toolchain a hard platform deployment dependency and gives build code the platform process's filesystem and credential reach. The container boundary is what makes it safe to hold the auth key on the platform side.
Builder readiness is a platform-level probe, not a run endpoint capability. When the builder is unavailable, the unavailable reason names the builder so the operator is not sent looking at run endpoints.
### Availability derivation
`generate-run` and `generate-client-manager` availability becomes: plugin declares the capability, runtime bindings are complete, platform builder is ready. The `endpointSupports(..., JobCapabilityDistributionBuild)` term is removed from both actions. `bindingsComplete` and the plugin declaration checks stay as they are.
### Build job identity
Build jobs remain jobs with `distribution.build` capability so idempotency, artifact ownership (`ArtifactOwnerKindJob`), progress projection, and the `projectDistributionBuildResult` verification path are preserved unchanged. The change is who executes them: the platform builder claims and completes them internally instead of a machine-side endpoint claiming over the job channel. The existing artifact-scope assertions in `validateDistributionBuildResult` continue to guard the result.
The capability-stripping guard in `job_channel.go` stays. With platform-side execution it becomes redundant for correctness but remains as defense in depth: a machine-side endpoint must never be assignable build work even if a future dispatch path regresses.
### Secret handling
`GetDistributionBuildInput` remains for legacy machine-side flows already in the field, but platform-executed builds resolve the component key internally and never place it in a job-channel response. The key reaches the builder container through the per-job input file rather than an API response, so it is never transmitted to a machine-side endpoint.
### Creation form
The deployment target selector is removed from the create branch of `ServerDeploymentWorkflow.tsx` rather than made optional. Leaving an optional selector preserves the original defect: the listed endpoints are still wrong choices at creation time. The run endpoint selector on the non-create branch is unaffected. The `saveAsDraft` special case for the create branch loses its reason to exist for target selection and is simplified accordingly.
Backend validation already permits this, so no relaxation is needed there. `deploymentTargetId` remains accepted by the create DTO for post-creation and programmatic flows.
## Risks / Trade-offs
Docker becomes a platform deployment dependency for building. Mitigation: builder readiness is probed and surfaced as an explicit unavailable reason, so a platform without Docker degrades to "cannot build" with a clear cause rather than a misleading endpoint capability message.
Existing instances carry `DeploymentTargetID` values pointing at privileged workers. Those bindings stay valid for non-build work; only build routing stops consulting them.
## Migration Plan
Availability derivation and platform-side execution land together, since changing availability alone would surface an action that cannot execute. The creation-form change is independent and can land in the same change without ordering constraints.
## Open Questions
Whether the builder image is built from this repository or pinned from a registry is left to implementation, provided the image reference is pinned rather than floating.
@@ -0,0 +1,31 @@
## Why
Creating a server instance currently forces the owner to pick a deployment target before any run executor exists. The target dropdown lists already-registered run endpoints, but the intended flow is create server → platform builds run → operator executes run on the machine → run registers back. At creation time there is nothing correct to select, so the field can only be filled with an unrelated endpoint or bypassed with the draft checkbox.
Distribution building is also routed through machine-side run endpoints, while a generated run is intentionally stripped of `distribution.build` authority. Both restrictions are individually sound, but together they mean an instance bound to its own generated run can never build again: `generate-run` reports `run endpoint cannot build distributions`. Building only works when a separately maintained privileged worker endpoint happens to be registered and online, which makes the platform's core build path depend on hand-maintained machine state.
The current build dispatch additionally hands the plaintext component `authKey` to whichever endpoint claims the build job, so a privileged worker accumulates credentials for every server it has ever built. Moving builds into a platform-owned Docker builder removes that credential egress path instead of widening it.
## What Changes
- Remove deployment target selection from the server creation form. Creation requires only game plugin type and server name; the run endpoint binding is established when the generated run registers itself.
- Keep deployment target and runtime profile selection available as optional actions on an already-created instance, never as creation prerequisites.
- Move `distribution.build` execution into a platform-owned Docker builder. The platform builds run and client-manager packages itself and no longer dispatches build jobs to machine-side run endpoints.
- Keep the generated-run build restriction intact as a security boundary; `generate-run` availability must no longer depend on any run endpoint advertising `distribution.build`.
- Stop exposing plaintext component auth keys over the job channel for builds executed by the platform builder.
- Add tests proving an instance bound only to its own generated run can still generate a new run distribution.
## Capabilities
### New Capabilities
- `platform-side-distribution-builds`: Covers platform-owned Docker build execution, creation-time field requirements, and run-endpoint-independent build availability.
### Modified Capabilities
- `run-distribution-and-client-managers`: Build execution moves from machine-side run endpoints to the platform Docker builder; generated-run build restriction is preserved.
- `run-build-download-flow`: Build source snapshotting and artifact download must work without a privileged worker endpoint.
## Impact
- Affected roots: `platform/`, `platform_web/`, `scripts/`.
- Affected behavior: server creation validation, `generate-run` and `generate-client-manager` availability, job channel build dispatch, build input secret exposure.
- Verification requires `scripts/check-structure.sh`, platform tests, frontend tests, OpenSpec strict validation, and a local proof that run generation succeeds on an instance whose only endpoint is its own generated run.
@@ -0,0 +1,68 @@
## ADDED Requirements
### Requirement: Server creation requires only plugin type and server name
The system SHALL require only the game plugin type and the server name to create a server instance, and SHALL NOT require a deployment target, run endpoint, or runtime profile at creation time.
#### Scenario: Creation form field set
- **WHEN** an owner opens the server creation workflow
- **THEN** the form requires plugin type and server name only, and presents no deployment target or run endpoint selector as a creation prerequisite
#### Scenario: Creation without any registered endpoint
- **WHEN** an owner creates a server instance while no run endpoint is registered for that instance
- **THEN** creation succeeds and the instance is created without a deployment target binding
#### Scenario: Binding established by run registration
- **WHEN** a generated run for that instance registers itself with the platform
- **THEN** the platform binds the instance to that run endpoint without the owner having pre-selected it
#### Scenario: Target selection remains available after creation
- **WHEN** an owner opens an already-created instance
- **THEN** deployment target and runtime profile selection remain available as optional actions on that instance
### Requirement: Distribution builds execute in a platform-owned Docker builder
The platform SHALL execute `distribution.build` work in a platform-owned Docker builder and SHALL NOT dispatch distribution build jobs to machine-side run endpoints.
#### Scenario: Run distribution build execution
- **WHEN** an owner requests run generation for a server instance
- **THEN** the platform builds the package in its own Docker builder and records the resulting artifact against the build job
#### Scenario: Client-manager distribution build execution
- **WHEN** an owner requests client-manager generation for a server instance
- **THEN** the platform builds the package in its own Docker builder and records the resulting artifact against the build job
#### Scenario: Build failure reporting
- **WHEN** a platform Docker build fails
- **THEN** the distribution status becomes failed, the build job reports a failure, and the failure reason excludes host paths and secret values
### Requirement: Build availability is independent of run endpoint capabilities
The system SHALL determine `generate-run` and `generate-client-manager` availability from plugin declarations, runtime bindings, and platform builder readiness, and SHALL NOT require any run endpoint to advertise `distribution.build`.
#### Scenario: Instance bound only to its own generated run
- **WHEN** a server instance's only run endpoint is its own generated run, which holds no distribution-build authority
- **THEN** `generate-run` remains available and a new run distribution can be generated
#### Scenario: No privileged worker endpoint registered
- **WHEN** no run endpoint advertising `distribution.build` is registered or online
- **THEN** run generation still succeeds through the platform Docker builder
#### Scenario: Builder unavailable
- **WHEN** the platform Docker builder is unavailable
- **THEN** the unavailable reason names the platform builder rather than a run endpoint capability
### Requirement: Generated runs hold no distribution-build authority
The system SHALL continue to deny distribution-build work to component-authenticated generated runs. This restriction is a security boundary and SHALL NOT be relaxed to unblock building.
#### Scenario: Generated run claims a build
- **WHEN** a component-authenticated generated run claims work advertising `distribution.build`
- **THEN** the platform does not assign distribution build work to that run
### Requirement: Platform builds do not expose plaintext component auth keys over the job channel
The system SHALL keep component auth keys inside the platform when builds are executed by the platform Docker builder, and SHALL NOT return plaintext auth keys to machine-side run endpoints for distribution builds.
#### Scenario: Build input secret handling
- **WHEN** the platform builder assembles a package requiring a component auth key
- **THEN** the key is resolved inside the platform and is not transmitted to any machine-side run endpoint
#### Scenario: Generated package still authenticates
- **WHEN** a package built by the platform builder registers with the platform
- **THEN** its embedded credential and key generation are accepted as before
@@ -0,0 +1,40 @@
## 1. Server creation form
- [x] 1.1 Remove the deployment target selector from the create branch of `platform_web/components/ServerDeploymentWorkflow.tsx`, keeping the run endpoint selector on the non-create branch unchanged.
- [x] 1.2 Simplify the create-branch step gating so it no longer depends on `deploymentTargetId` or on `saveAsDraft` for target selection.
- [x] 1.3 Keep `deploymentTargetId` accepted in `platform_web/schemas/serverManagement.ts` and the create DTO for post-creation and programmatic flows.
- [x] 1.4 Update or add frontend tests proving creation submits with plugin type and server name only.
## 2. Platform Docker builder
- [x] 2.1 Add a platform-owned builder that executes a distribution build in a container from a pinned image, with run source mounted read-only and a per-job output directory mounted writable.
- [x] 2.2 Add a builder readiness probe and expose its unavailable reason as a platform-builder reason, not a run endpoint capability reason.
- [x] 2.3 Route `distribution.build` job execution to the platform builder so the job is claimed and completed internally instead of over the job channel.
- [x] 2.4 Preserve job idempotency, `ArtifactOwnerKindJob` artifact ownership, progress projection, and the existing `validateDistributionBuildResult` artifact-scope assertions.
- [x] 2.5 Keep build workspaces isolated per plugin and per job as required by `run-build-download-flow`.
## 3. Build availability derivation
- [x] 3.1 Remove the `endpointSupports(..., JobCapabilityDistributionBuild)` term from `generate-run` and `generate-client-manager` availability in `platform/service/distributions.go`.
- [x] 3.2 Derive availability from plugin declaration, complete runtime bindings, and builder readiness, keeping existing binding reasons intact.
- [x] 3.3 Stop resolving a machine-side builder endpoint for build dispatch in `GenerateRunDistribution` and the client-manager build path.
## 4. Secret handling
- [x] 4.1 Resolve component auth keys inside the platform for builder-executed builds and pass them to the container through the per-job input rather than a job-channel response.
- [x] 4.2 Keep the `distribution.build` capability-stripping guard in `platform/service/job_channel.go` as defense in depth.
- [x] 4.3 Add a test proving builder-executed builds do not return a plaintext auth key to a machine-side endpoint.
## 5. Tests and verification
- [x] 5.1 Add a platform test proving an instance whose only endpoint is its own generated run can generate a new run distribution.
- [x] 5.2 Add a platform test proving run generation succeeds with no endpoint advertising `distribution.build` registered or online.
- [x] 5.3 Keep `TestCoreServiceComponentRunCannotClaimDistributionBuild` passing.
- [x] 5.4 Add a builder-unavailable test proving the reason names the platform builder.
- [x] 5.5 Run `scripts/check-structure.sh`, platform tests, frontend tests, and `openspec validate platform-side-docker-distribution-builds --strict`.
- [x] 5.6 Prove the flow end to end in local debug: create a server with plugin type and name only, generate a run, download and execute it, confirm registration and heartbeat.
## 6. Documentation
- [x] 6.1 Add server creation field rules and platform-side build ownership rules to `AGENTS.md`.
- [x] 6.2 Document builder configuration values operators must provide or may tune.
@@ -0,0 +1,71 @@
# platform-side-distribution-builds Specification
## Purpose
TBD - created by archiving change platform-side-docker-distribution-builds. Update Purpose after archive.
## Requirements
### Requirement: Server creation requires only plugin type and server name
The system SHALL require only the game plugin type and the server name to create a server instance, and SHALL NOT require a deployment target, run endpoint, or runtime profile at creation time.
#### Scenario: Creation form field set
- **WHEN** an owner opens the server creation workflow
- **THEN** the form requires plugin type and server name only, and presents no deployment target or run endpoint selector as a creation prerequisite
#### Scenario: Creation without any registered endpoint
- **WHEN** an owner creates a server instance while no run endpoint is registered for that instance
- **THEN** creation succeeds and the instance is created without a deployment target binding
#### Scenario: Binding established by run registration
- **WHEN** a generated run for that instance registers itself with the platform
- **THEN** the platform binds the instance to that run endpoint without the owner having pre-selected it
#### Scenario: Target selection remains available after creation
- **WHEN** an owner opens an already-created instance
- **THEN** deployment target and runtime profile selection remain available as optional actions on that instance
### Requirement: Distribution builds execute in a platform-owned Docker builder
The platform SHALL execute `distribution.build` work in a platform-owned Docker builder and SHALL NOT dispatch distribution build jobs to machine-side run endpoints.
#### Scenario: Run distribution build execution
- **WHEN** an owner requests run generation for a server instance
- **THEN** the platform builds the package in its own Docker builder and records the resulting artifact against the build job
#### Scenario: Client-manager distribution build execution
- **WHEN** an owner requests client-manager generation for a server instance
- **THEN** the platform builds the package in its own Docker builder and records the resulting artifact against the build job
#### Scenario: Build failure reporting
- **WHEN** a platform Docker build fails
- **THEN** the distribution status becomes failed, the build job reports a failure, and the failure reason excludes host paths and secret values
### Requirement: Build availability is independent of run endpoint capabilities
The system SHALL determine `generate-run` and `generate-client-manager` availability from plugin declarations, runtime bindings, and platform builder readiness, and SHALL NOT require any run endpoint to advertise `distribution.build`.
#### Scenario: Instance bound only to its own generated run
- **WHEN** a server instance's only run endpoint is its own generated run, which holds no distribution-build authority
- **THEN** `generate-run` remains available and a new run distribution can be generated
#### Scenario: No privileged worker endpoint registered
- **WHEN** no run endpoint advertising `distribution.build` is registered or online
- **THEN** run generation still succeeds through the platform Docker builder
#### Scenario: Builder unavailable
- **WHEN** the platform Docker builder is unavailable
- **THEN** the unavailable reason names the platform builder rather than a run endpoint capability
### Requirement: Generated runs hold no distribution-build authority
The system SHALL continue to deny distribution-build work to component-authenticated generated runs. This restriction is a security boundary and SHALL NOT be relaxed to unblock building.
#### Scenario: Generated run claims a build
- **WHEN** a component-authenticated generated run claims work advertising `distribution.build`
- **THEN** the platform does not assign distribution build work to that run
### Requirement: Platform builds do not expose plaintext component auth keys over the job channel
The system SHALL keep component auth keys inside the platform when builds are executed by the platform Docker builder, and SHALL NOT return plaintext auth keys to machine-side run endpoints for distribution builds.
#### Scenario: Build input secret handling
- **WHEN** the platform builder assembles a package requiring a component auth key
- **THEN** the key is resolved inside the platform and is not transmitted to any machine-side run endpoint
#### Scenario: Generated package still authenticates
- **WHEN** a package built by the platform builder registers with the platform
- **THEN** its embedded credential and key generation are accepted as before
+10
View File
@@ -27,3 +27,13 @@ PLATFORM_ARTIFACT_DIR=.platform-data/artifacts
# Required outside disposable local development. This protects persisted component-key ciphertext. # Required outside disposable local development. This protects persisted component-key ciphertext.
# PLATFORM_SECRET_ENVELOPE_KEY=replace-with-at-least-32-random-characters # PLATFORM_SECRET_ENVELOPE_KEY=replace-with-at-least-32-random-characters
# Platform-owned distribution builder. Build the first-party image from
# platform/distribution-builder before starting the platform.
PLATFORM_BUILDER_DOCKER_BINARY=docker
PLATFORM_BUILDER_IMAGE=browser-platform-distribution-builder:1.0.0
PLATFORM_BUILDER_SOURCE_DIR=../run
PLATFORM_BUILDER_WORKSPACE_DIR=.platform-data/distribution-builds
PLATFORM_BUILDER_TIMEOUT_SECONDS=1800
# URL embedded into generated Run and client-manager packages.
PLATFORM_RUN_RELEASE_URL=https://scum.npc0.com
+18 -1
View File
@@ -54,6 +54,23 @@ Runtime configuration:
- `PLATFORM_BOOTSTRAP_ADMIN_EMAIL`: optional initial platform administrator email. - `PLATFORM_BOOTSTRAP_ADMIN_EMAIL`: optional initial platform administrator email.
- `PLATFORM_BOOTSTRAP_ADMIN_PASSWORD`: optional one-time bootstrap password; the platform applies no default and persists only a password verifier. - `PLATFORM_BOOTSTRAP_ADMIN_PASSWORD`: optional one-time bootstrap password; the platform applies no default and persists only a password verifier.
- `PLATFORM_SECRET_ENVELOPE_KEY`: external secret used to derive the AES-GCM component-key envelope key; use at least 32 random characters and keep it stable across restarts. - `PLATFORM_SECRET_ENVELOPE_KEY`: external secret used to derive the AES-GCM component-key envelope key; use at least 32 random characters and keep it stable across restarts.
- `PLATFORM_BUILDER_DOCKER_BINARY`: Docker-compatible CLI used by the platform builder, default `docker`.
- `PLATFORM_BUILDER_IMAGE`: prebuilt, explicitly versioned or digest-pinned builder image, default `browser-platform-distribution-builder:1.0.0`; floating tags such as `latest` are rejected.
- `PLATFORM_BUILDER_SOURCE_DIR`: read-only Run source snapshot containing `go.mod`; this is required for builder readiness.
- `PLATFORM_BUILDER_WORKSPACE_DIR`: private per-plugin/per-job build workspace, default `<PLATFORM_DATA_DIR>/distribution-builds`.
- `PLATFORM_BUILDER_TIMEOUT_SECONDS`: positive build deadline, default `1800`.
- `PLATFORM_RUN_RELEASE_URL`: public platform URL embedded into generated components, default `https://scum.npc0.com`.
Build the dedicated toolchain image before enabling distribution generation:
```bash
docker build --pull -t browser-platform-distribution-builder:1.0.0 distribution-builder
export PLATFORM_BUILDER_SOURCE_DIR=/absolute/path/to/read-only/run-source-snapshot
export PLATFORM_BUILDER_IMAGE=browser-platform-distribution-builder:1.0.0
go run ./cmd/platform
```
Each build runs in a separate read-only container. The platform mounts source and per-job input read-only, mounts only the job build/output directories writable, and passes the component auth key through a mode-`0600` input file. The key is not sent through the machine-side job channel or Docker arguments. Production deployments may use an internal-registry `image@sha256:...` reference; the selected image must already exist in the Docker daemon because builds run with `--pull never`.
MySQL configuration example: MySQL configuration example:
@@ -85,4 +102,4 @@ Client Manager installations are durable aggregates separate from Run distributi
Component registration uses the current client-manager key generation, a timestamped nonce, and a short-lived hashed component session. It never reuses a Run session or job lease. Key reset revokes old sessions/artifacts and marks the installation for current-generation rebuild/redeploy. Run reports only logical health, phase, and bounded execution evidence; host paths, PIDs, sockets, raw keys, and credential material are not operator or plugin projections. Production KMS/code-signing, private source credentials, and fleet rollout remain explicit non-goals. Component registration uses the current client-manager key generation, a timestamped nonce, and a short-lived hashed component session. It never reuses a Run session or job lease. Key reset revokes old sessions/artifacts and marks the installation for current-generation rebuild/redeploy. Run reports only logical health, phase, and bounded execution evidence; host paths, PIDs, sockets, raw keys, and credential material are not operator or plugin projections. Production KMS/code-signing, private source credentials, and fleet rollout remain explicit non-goals.
Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata. Server creation selects a declared profile, saves complete logical bindings before install dispatch, and existing lifecycle/runtime actions are gated when the binding is absent or incomplete. Browser and plugin-facing responses expose readiness only, not binding values. This change uses controlled secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks. Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata. Server creation requires only the plugin type and server name; operators set a declared profile and complete logical bindings after creation, before any gated lifecycle/runtime action. Browser and plugin-facing responses expose readiness only, not binding values. Platform-owned Docker builds need no registered Run endpoint with `distribution.build`; component keys remain in platform-controlled per-job input. This change uses controlled secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks.
+24 -2
View File
@@ -291,7 +291,9 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
} }
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
router := newTestRouter() releaseBuilds := make(chan struct{})
t.Cleanup(func() { close(releaseBuilds) })
router := newTestRouterWithDistributionBuilder(apiTestDistributionBuilder{release: releaseBuilds})
adminSession := createAdminSession(t, router) adminSession := createAdminSession(t, router)
serverID := createRuntimeAPIFixtures(t, router, adminSession) serverID := createRuntimeAPIFixtures(t, router, adminSession)
@@ -1604,14 +1606,34 @@ func TestProductionOperationsGovernanceRoutesAreDurableAndRedacted(t *testing.T)
} }
} }
func newTestRouter() http.Handler { type apiTestDistributionBuilder struct {
release <-chan struct{}
}
func (builder apiTestDistributionBuilder) Readiness() (bool, string) {
return true, ""
}
func (builder apiTestDistributionBuilder) Build(input domain.DistributionBuildInput) ([]byte, error) {
if builder.release != nil {
<-builder.release
}
return []byte("api-platform-built-distribution:" + input.JobID), nil
}
func newTestRouterWithDistributionBuilder(builder service.DistributionBuilder) http.Handler {
core := service.NewCoreService(repo.NewMemoryStore()) core := service.NewCoreService(repo.NewMemoryStore())
core.ConfigureDistributionBuilder(builder)
if err := core.SeedLocalPlatformAdmin(); err != nil { if err := core.SeedLocalPlatformAdmin(); err != nil {
panic(err) panic(err)
} }
return NewTestRouterWithCore(core) return NewTestRouterWithCore(core)
} }
func newTestRouter() http.Handler {
return newTestRouterWithDistributionBuilder(nil)
}
func apiRouterWithoutSeededAdmin() http.Handler { func apiRouterWithoutSeededAdmin() http.Handler {
return NewTestRouterWithCore(service.NewCoreService(repo.NewMemoryStore())) return NewTestRouterWithCore(service.NewCoreService(repo.NewMemoryStore()))
} }
+8
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"browser.local/platform/config" "browser.local/platform/config"
"browser.local/platform/repo" "browser.local/platform/repo"
@@ -47,6 +48,13 @@ func NewRouterFromConfig(cfg config.Config) (http.Handler, error) {
if err := core.ConfigureSecretEnvelopeKey(cfg.SecretEnvelopeKey); err != nil { if err := core.ConfigureSecretEnvelopeKey(cfg.SecretEnvelopeKey); err != nil {
return nil, err return nil, err
} }
core.ConfigureDistributionBuilder(service.NewDockerDistributionBuilder(service.DockerDistributionBuilderConfig{
DockerBinary: cfg.BuilderDockerBinary,
Image: cfg.BuilderImage,
SourceDir: cfg.BuilderSourceDir,
WorkspaceDir: cfg.BuilderWorkspaceDir,
Timeout: time.Duration(cfg.BuilderTimeoutSeconds) * time.Second,
}))
if err := core.ConfigureAIProviderMode(cfg.AIProviderMode); err != nil { if err := core.ConfigureAIProviderMode(cfg.AIProviderMode); err != nil {
return nil, err return nil, err
} }
+4 -4
View File
@@ -137,7 +137,7 @@ Artifact bridge execution returns safe metadata and platform content routes only
## Implemented Server Lifecycle Actions ## Implemented Server Lifecycle Actions
- `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. A legacy `runEndpointId` creates and dispatches through the existing endpoint. A `deploymentTargetId` creates a target-bound draft with a reserved dedicated Run identity; no install job is dispatched until that Run registers and `deploy` is requested. - `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. Creation requires only `pluginId` and `name`; it creates an unbound instance without dispatching an install job. Runtime binding and optional deployment settings are configured after creation. Legacy `runEndpointId` and `deploymentTargetId` inputs remain accepted for compatible programmatic flows, but are never prerequisites for creation and do not select a distribution builder.
- `POST /api/v1/server-instances/{id}/start`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.start` job using `ServerLifecycleResponse`. - `POST /api/v1/server-instances/{id}/start`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.start` job using `ServerLifecycleResponse`.
- `POST /api/v1/server-instances/{id}/stop`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.stop` job using `ServerLifecycleResponse`. - `POST /api/v1/server-instances/{id}/stop`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.stop` job using `ServerLifecycleResponse`.
@@ -148,7 +148,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
- `GET /api/v1/server-instances/{id}/runtime-binding`: returns the visible server's selected profile and redacted logical binding readiness. Values are represented only by configured/secret-backed flags. - `GET /api/v1/server-instances/{id}/runtime-binding`: returns the visible server's selected profile and redacted logical binding readiness. Values are represented only by configured/secret-backed flags.
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs. Undeclared keys, unsafe paths/sockets/credentials, and changes to an existing active binding are rejected. - `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs. Undeclared keys, unsafe paths/sockets/credentials, and changes to an existing active binding are rejected.
- `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons. - `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons.
- `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, and redacted secret ref only. - `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, queues a platform-owned Docker build, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, build job ID, and redacted secret ref only.
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization. - `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
- `POST /api/v1/server-instances/{id}/run/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`. - `POST /api/v1/server-instances/{id}/run/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`.
- `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`. - `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`.
@@ -162,9 +162,9 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`. - `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`.
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results. - `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and run endpoint capability support for run-side jobs. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs. Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
`POST /api/v1/server-instances/workflows/create` requires `profileKey` and initial `bindings`. Platform validates completeness and persists the binding before dispatching the install job; the job `targetKey` identifies the selected declared profile. Existing servers without a binding remain readable, but lifecycle and runtime-dependent actions return a safe configuration-required reason. `POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding is set later through `PUT /api/v1/server-instances/{id}/runtime-binding`; until then, lifecycle and runtime-dependent actions return a safe configuration-required reason. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so.
## Private Run Dependency And Update Routes ## Private Run Dependency And Update Routes
+30
View File
@@ -4,12 +4,20 @@ import (
"bufio" "bufio"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
) )
const defaultAddr = ":8080" const defaultAddr = ":8080"
const defaultDataDir = ".platform-data" const defaultDataDir = ".platform-data"
const defaultStorageBackend = "file" const defaultStorageBackend = "file"
const defaultBuilderDockerBinary = "docker"
// defaultBuilderImage names an explicit toolchain version rather than a
// floating tag such as latest. Operators who want digest pinning override
// PLATFORM_BUILDER_IMAGE with an image@sha256:... reference.
const defaultBuilderImage = "browser-platform-distribution-builder:1.0.0"
const defaultBuilderTimeoutSeconds = 1800
type Config struct { type Config struct {
Addr string Addr string
@@ -24,6 +32,11 @@ type Config struct {
BootstrapAdminPassword string BootstrapAdminPassword string
SecretEnvelopeKey string SecretEnvelopeKey string
AIProviderMode string AIProviderMode string
BuilderDockerBinary string
BuilderImage string
BuilderSourceDir string
BuilderWorkspaceDir string
BuilderTimeoutSeconds int
} }
func Load() Config { func Load() Config {
@@ -54,6 +67,10 @@ func Load() Config {
storageBackend = defaultStorageBackend storageBackend = defaultStorageBackend
} }
logBodyBackend := strings.TrimSpace(os.Getenv("PLATFORM_LOG_BODY_BACKEND")) logBodyBackend := strings.TrimSpace(os.Getenv("PLATFORM_LOG_BODY_BACKEND"))
builderWorkspaceDir := strings.TrimSpace(os.Getenv("PLATFORM_BUILDER_WORKSPACE_DIR"))
if builderWorkspaceDir == "" {
builderWorkspaceDir = filepath.Join(dataDir, "distribution-builds")
}
return Config{ return Config{
Addr: addr, Addr: addr,
@@ -68,9 +85,22 @@ func Load() Config {
BootstrapAdminPassword: os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD"), BootstrapAdminPassword: os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD"),
SecretEnvelopeKey: os.Getenv("PLATFORM_SECRET_ENVELOPE_KEY"), SecretEnvelopeKey: os.Getenv("PLATFORM_SECRET_ENVELOPE_KEY"),
AIProviderMode: defaultString(strings.TrimSpace(os.Getenv("PLATFORM_AI_PROVIDER_MODE")), "live"), AIProviderMode: defaultString(strings.TrimSpace(os.Getenv("PLATFORM_AI_PROVIDER_MODE")), "live"),
BuilderDockerBinary: defaultString(strings.TrimSpace(os.Getenv("PLATFORM_BUILDER_DOCKER_BINARY")), defaultBuilderDockerBinary),
BuilderImage: defaultString(strings.TrimSpace(os.Getenv("PLATFORM_BUILDER_IMAGE")), defaultBuilderImage),
BuilderSourceDir: strings.TrimSpace(os.Getenv("PLATFORM_BUILDER_SOURCE_DIR")),
BuilderWorkspaceDir: builderWorkspaceDir,
BuilderTimeoutSeconds: defaultPositiveInt(strings.TrimSpace(os.Getenv("PLATFORM_BUILDER_TIMEOUT_SECONDS")), defaultBuilderTimeoutSeconds),
} }
} }
func defaultPositiveInt(value string, fallback int) int {
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func defaultString(value, fallback string) string { func defaultString(value, fallback string) string {
if value == "" { if value == "" {
return fallback return fallback
+21
View File
@@ -18,6 +18,11 @@ func TestLoadUsesDefaultAddress(t *testing.T) {
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL", "") t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL", "")
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD", "") t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD", "")
t.Setenv("PLATFORM_SECRET_ENVELOPE_KEY", "") t.Setenv("PLATFORM_SECRET_ENVELOPE_KEY", "")
t.Setenv("PLATFORM_BUILDER_DOCKER_BINARY", "")
t.Setenv("PLATFORM_BUILDER_IMAGE", "")
t.Setenv("PLATFORM_BUILDER_SOURCE_DIR", "")
t.Setenv("PLATFORM_BUILDER_WORKSPACE_DIR", "")
t.Setenv("PLATFORM_BUILDER_TIMEOUT_SECONDS", "")
cfg := Load() cfg := Load()
if cfg.Addr != defaultAddr { if cfg.Addr != defaultAddr {
@@ -29,6 +34,9 @@ func TestLoadUsesDefaultAddress(t *testing.T) {
if cfg.MetadataPath != filepath.Join(".platform-data", "metadata.json") || cfg.LogDir != filepath.Join(".platform-data", "logs") { if cfg.MetadataPath != filepath.Join(".platform-data", "metadata.json") || cfg.LogDir != filepath.Join(".platform-data", "logs") {
t.Fatalf("unexpected default storage paths: %+v", cfg) t.Fatalf("unexpected default storage paths: %+v", cfg)
} }
if cfg.BuilderDockerBinary != defaultBuilderDockerBinary || cfg.BuilderImage != defaultBuilderImage || cfg.BuilderSourceDir != "" || cfg.BuilderWorkspaceDir != filepath.Join(".platform-data", "distribution-builds") || cfg.BuilderTimeoutSeconds != defaultBuilderTimeoutSeconds {
t.Fatalf("unexpected default builder config: %+v", cfg)
}
} }
func TestLoadUsesConfiguredAddress(t *testing.T) { func TestLoadUsesConfiguredAddress(t *testing.T) {
@@ -42,6 +50,11 @@ func TestLoadUsesConfiguredAddress(t *testing.T) {
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL", "admin@example.test") t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL", "admin@example.test")
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD", "configured-secret") t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD", "configured-secret")
t.Setenv("PLATFORM_SECRET_ENVELOPE_KEY", "configured-envelope-key-at-least-32-bytes") t.Setenv("PLATFORM_SECRET_ENVELOPE_KEY", "configured-envelope-key-at-least-32-bytes")
t.Setenv("PLATFORM_BUILDER_DOCKER_BINARY", "/usr/local/bin/docker")
t.Setenv("PLATFORM_BUILDER_IMAGE", "registry.example.test/distribution-builder:2.0.0")
t.Setenv("PLATFORM_BUILDER_SOURCE_DIR", "/srv/run-source")
t.Setenv("PLATFORM_BUILDER_WORKSPACE_DIR", "/srv/distribution-builds")
t.Setenv("PLATFORM_BUILDER_TIMEOUT_SECONDS", "900")
cfg := Load() cfg := Load()
if cfg.Addr != ":18080" { if cfg.Addr != ":18080" {
@@ -50,6 +63,9 @@ func TestLoadUsesConfiguredAddress(t *testing.T) {
if cfg.StorageBackend != "mysql" || cfg.MySQLDSN != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" || cfg.DataDir != "/tmp/platform-data" || cfg.MetadataPath != "/tmp/platform-metadata.json" || cfg.LogDir != "/tmp/platform-logs" || cfg.LogBodyBackend != "file" || cfg.BootstrapAdminEmail != "admin@example.test" || cfg.BootstrapAdminPassword != "configured-secret" || cfg.SecretEnvelopeKey != "configured-envelope-key-at-least-32-bytes" { if cfg.StorageBackend != "mysql" || cfg.MySQLDSN != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" || cfg.DataDir != "/tmp/platform-data" || cfg.MetadataPath != "/tmp/platform-metadata.json" || cfg.LogDir != "/tmp/platform-logs" || cfg.LogBodyBackend != "file" || cfg.BootstrapAdminEmail != "admin@example.test" || cfg.BootstrapAdminPassword != "configured-secret" || cfg.SecretEnvelopeKey != "configured-envelope-key-at-least-32-bytes" {
t.Fatalf("unexpected configured storage: %+v", cfg) t.Fatalf("unexpected configured storage: %+v", cfg)
} }
if cfg.BuilderDockerBinary != "/usr/local/bin/docker" || cfg.BuilderImage != "registry.example.test/distribution-builder:2.0.0" || cfg.BuilderSourceDir != "/srv/run-source" || cfg.BuilderWorkspaceDir != "/srv/distribution-builds" || cfg.BuilderTimeoutSeconds != 900 {
t.Fatalf("unexpected configured builder: %+v", cfg)
}
} }
func TestLoadReadsPlatformEnvFile(t *testing.T) { func TestLoadReadsPlatformEnvFile(t *testing.T) {
@@ -106,6 +122,11 @@ func clearPlatformEnv(t *testing.T) {
"PLATFORM_BOOTSTRAP_ADMIN_EMAIL", "PLATFORM_BOOTSTRAP_ADMIN_EMAIL",
"PLATFORM_BOOTSTRAP_ADMIN_PASSWORD", "PLATFORM_BOOTSTRAP_ADMIN_PASSWORD",
"PLATFORM_SECRET_ENVELOPE_KEY", "PLATFORM_SECRET_ENVELOPE_KEY",
"PLATFORM_BUILDER_DOCKER_BINARY",
"PLATFORM_BUILDER_IMAGE",
"PLATFORM_BUILDER_SOURCE_DIR",
"PLATFORM_BUILDER_WORKSPACE_DIR",
"PLATFORM_BUILDER_TIMEOUT_SECONDS",
} { } {
t.Setenv(key, "") t.Setenv(key, "")
if err := os.Unsetenv(key); err != nil { if err := os.Unsetenv(key); err != nil {
+11
View File
@@ -0,0 +1,11 @@
# syntax=docker/dockerfile:1
FROM golang:1.25.1-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git \
&& rm -rf /var/lib/apt/lists/* \
&& go version \
&& git --version
WORKDIR /workspace
+15
View File
@@ -0,0 +1,15 @@
# Platform Distribution Builder
平台使用此专用镜像在一次性、只读 Docker 容器中构建 Run 和 client-manager distribution。镜像只提供固定版本的 Go 工具链、Git 和 CA certificates;源码、每个 job 的输入与输出均由平台在运行时挂载。
构建本地固定标签:
```bash
docker build --pull \
-t browser-platform-distribution-builder:1.0.0 \
platform/distribution-builder
```
运行时通过 `PLATFORM_BUILDER_IMAGE` 指定该显式版本标签;生产环境可以改用内部 registry 的 `image@sha256:...` 引用。平台使用 `--pull never` 执行构建,因此镜像必须预先存在于平台所连接的 Docker daemon 中。
组件 auth key 不应烘焙进镜像、Docker 参数或环境变量。平台仅通过权限为 `0600` 的 per-job input file 将其提供给构建脚本,并在构建结束后删除整个 job workspace。
+3 -2
View File
@@ -764,8 +764,9 @@ type ServerInstance struct {
ID string ID string
PluginID string PluginID string
PluginVersion string PluginVersion string
// DeploymentTargetID identifies the trusted existing worker used only to // DeploymentTargetID identifies an optional operator-selected deployment
// build a server's dedicated Run package. It is not the server Run itself. // target for post-creation deployment operations. It never selects a
// distribution builder or replaces the server's generated Run endpoint.
DeploymentTargetID string DeploymentTargetID string
RunEndpointID string RunEndpointID string
Name string Name string
+2 -1
View File
@@ -202,7 +202,8 @@ type ServerInstance struct {
PluginID string `json:"pluginId" db:"plugin_id"` PluginID string `json:"pluginId" db:"plugin_id"`
// PluginVersion records the plugin version used for creation or reconcile. // PluginVersion records the plugin version used for creation or reconcile.
PluginVersion string `json:"pluginVersion" db:"plugin_version"` PluginVersion string `json:"pluginVersion" db:"plugin_version"`
// DeploymentTargetID references the trusted worker used to build a dedicated Run. // DeploymentTargetID references an optional post-creation deployment target;
// platform-owned distribution builds never use it as a builder selector.
DeploymentTargetID string `json:"deploymentTargetId,omitempty" db:"deployment_target_id"` DeploymentTargetID string `json:"deploymentTargetId,omitempty" db:"deployment_target_id"`
// RunEndpointID references the dedicated server Run endpoint. // RunEndpointID references the dedicated server Run endpoint.
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"` RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
@@ -229,11 +229,13 @@ func buildLifecycleDistribution(t *testing.T, svc *CoreService, session string,
if err := svc.store.GamePlugins().Update(plugin); err != nil { if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update lifecycle version: %v", err) t.Fatalf("update lifecycle version: %v", err)
} }
payload := []byte("client-manager-package-" + version)
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: idempotency}) distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: idempotency})
if err != nil { if err != nil {
t.Fatalf("generate lifecycle distribution: %v", err) t.Fatalf("generate lifecycle distribution: %v", err)
} }
return completeClientDistributionBuild(t, svc, distribution, []byte("client-manager-package-"+version)) return completeClientDistributionBuild(t, svc, distribution, payload)
} }
func registerClientManagerRun(t *testing.T, svc *CoreService) string { func registerClientManagerRun(t *testing.T, svc *CoreService) string {
+2 -1
View File
@@ -195,11 +195,12 @@ func TestPluginBridgeDependencyInstallUsesReviewedPlanDigest(t *testing.T) {
func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.T) { func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
payload := []byte("compiled target-matched run archive")
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "run-update-build"}) distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "run-update-build"})
if err != nil { if err != nil {
t.Fatalf("generate update distribution: %v", err) t.Fatalf("generate update distribution: %v", err)
} }
payload := []byte("compiled target-matched run archive")
distribution = completeDistributionBuild(t, svc, distribution, payload) distribution = completeDistributionBuild(t, svc, distribution, payload)
otherInstance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-update-other", PluginID: instance.PluginID, RunEndpointID: instance.RunEndpointID, Name: "Other Update Server", State: domain.ServerInstanceStateReady}) otherInstance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-update-other", PluginID: instance.PluginID, RunEndpointID: instance.RunEndpointID, Name: "Other Update Server", State: domain.ServerInstanceStateReady})
if err != nil { if err != nil {
@@ -0,0 +1,320 @@
package service
import (
"errors"
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const platformDistributionBuilderEndpointID = "platform-distribution-builder"
// unconfiguredDistributionBuilder stands in when no platform builder has been
// installed. It reports a platform-builder reason so an operator is not sent
// looking at run endpoint capabilities.
type unconfiguredDistributionBuilder struct{}
func (unconfiguredDistributionBuilder) Readiness() (bool, string) {
return false, "platform builder is not configured"
}
func (unconfiguredDistributionBuilder) Build(domain.DistributionBuildInput) ([]byte, error) {
return nil, validationError("platform builder is not configured")
}
func (svc *CoreService) configuredDistributionBuilder() DistributionBuilder {
svc.distributionBuildMu.Lock()
defer svc.distributionBuildMu.Unlock()
if svc.distributionBuilder == nil {
return unconfiguredDistributionBuilder{}
}
return svc.distributionBuilder
}
// distributionBuilderReadiness reports platform builder readiness. The reason
// always names the platform builder, never a run endpoint capability.
func (svc *CoreService) distributionBuilderReadiness() (bool, string) {
builder := svc.configuredDistributionBuilder()
ready, reason := builder.Readiness()
if ready {
return true, ""
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "platform builder is unavailable"
} else if !strings.Contains(strings.ToLower(reason), "platform builder") {
reason = "platform builder is unavailable: " + reason
}
return false, reason
}
func (svc *CoreService) enqueueDistributionBuild(job domain.Job) {
svc.distributionBuildMu.Lock()
if _, exists := svc.distributionBuilds[job.ID]; exists {
svc.distributionBuildMu.Unlock()
return
}
svc.distributionBuilds[job.ID] = struct{}{}
svc.distributionBuildMu.Unlock()
go func() {
defer func() {
svc.distributionBuildMu.Lock()
delete(svc.distributionBuilds, job.ID)
svc.distributionBuildMu.Unlock()
}()
_ = svc.executeDistributionBuild(job)
}()
}
// executeDistributionBuild claims the build job internally and completes it with
// the artifact produced by the platform builder. Build work is never dispatched
// to a machine-side run endpoint, so the component auth key stays on the
// platform.
func (svc *CoreService) executeDistributionBuild(job domain.Job) error {
if job.Capability != domain.JobCapabilityDistributionBuild {
return validationError("job is not a distribution build")
}
input, err := svc.platformDistributionBuildInput(job)
if err != nil {
return svc.failDistributionBuildJob(job, "platform builder could not assemble build input")
}
if err := svc.markDistributionBuildRunning(&job); err != nil {
return err
}
if isTerminalJobState(job.State) {
return nil
}
payload, buildErr := svc.configuredDistributionBuilder().Build(input)
if buildErr != nil {
return svc.failDistributionBuildJob(job, builderJobFailureMessage(buildErr))
}
if _, err := svc.platformDistributionBuildInput(job); err != nil {
return svc.failDistributionBuildJob(job, "platform builder discarded output because the component key is no longer current")
}
if err := svc.storeDistributionBuildArtifact(input.ArtifactID, job.ID, payload); err != nil {
return svc.failDistributionBuildJob(job, "platform builder could not record the distribution artifact")
}
return svc.succeedDistributionBuildJob(job, input.ArtifactID)
}
// platformDistributionBuildInput resolves the build input, including the
// component auth key, inside the platform. Unlike GetDistributionBuildInput it
// never crosses the job channel.
func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.DistributionBuildInput, error) {
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return domain.DistributionBuildInput{}, err
}
for _, distribution := range runDistributions {
if distribution.BuildJobID != job.ID {
continue
}
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentRun, "")
if err != nil {
return domain.DistributionBuildInput{}, err
}
if key.Generation != distribution.KeyGeneration {
return domain.DistributionBuildInput{}, validationError("run build key generation is no longer current")
}
plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
if err != nil {
return domain.DistributionBuildInput{}, err
}
return domain.DistributionBuildInput{
JobID: job.ID,
ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
RunEndpointID: distribution.RunEndpointID,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID,
PlatformURL: runReleasePlatformURL(),
PackageFormat: distribution.PackageFormat,
ArtifactID: distribution.ArtifactID,
OutputFilename: executableFilename("run", distribution.TargetOS),
SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey,
}, nil
}
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return domain.DistributionBuildInput{}, err
}
for _, distribution := range clientDistributions {
if distribution.BuildJobID != job.ID {
continue
}
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
if err != nil {
return domain.DistributionBuildInput{}, err
}
if key.Generation != distribution.KeyGeneration {
return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current")
}
plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
if err != nil {
return domain.DistributionBuildInput{}, err
}
return domain.DistributionBuildInput{
JobID: job.ID,
ComponentKind: domain.DistributionComponentClientManager,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
RunEndpointID: job.RunEndpointID,
ProfileKey: distribution.ProfileKey,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
PlatformURL: runReleasePlatformURL(),
PackageFormat: packageFormatForTarget(distribution.TargetOS),
RepositoryURL: distribution.RepositoryURL,
SourceRevision: distribution.SourceRevision,
ArtifactID: distribution.ArtifactID,
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey,
}, nil
}
return domain.DistributionBuildInput{}, repo.ErrNotFound
}
// storeDistributionBuildArtifact records the built package under job ownership
// so the existing ArtifactOwnerKindJob scope assertions keep guarding it.
func (svc *CoreService) storeDistributionBuildArtifact(artifactID string, jobID string, payload []byte) error {
if strings.TrimSpace(artifactID) == "" {
return validationError("distribution build artifact id is required")
}
if len(payload) == 0 {
return validationError("distribution build produced no package bytes")
}
stamp := svc.now()
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
artifact, err := svc.store.Artifacts().Get(artifactID)
if err != nil && !errors.Is(err, repo.ErrNotFound) {
return err
}
create := errors.Is(err, repo.ErrNotFound)
if create {
artifact = domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: jobID, CreatedAt: stamp}
}
if artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != jobID {
return validationError("distribution build artifact is outside the job scope")
}
artifact.SizeBytes = int64(len(payload))
artifact.Checksum = validator.BytesChecksum(payload)
artifact.State = domain.ArtifactStateAvailable
artifact.UpdatedAt = stamp
if err := validator.ValidateArtifact(artifact); err != nil {
return err
}
if err := svc.artifactStore.PutPayload(artifact.ID, payload); err != nil {
return err
}
if create {
if err := svc.store.Artifacts().Create(artifact); err != nil {
return err
}
} else if err := svc.store.Artifacts().Update(artifact); err != nil {
return err
}
svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload)
return nil
}
func (svc *CoreService) markDistributionBuildRunning(job *domain.Job) error {
stamp := svc.now()
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
current, err := svc.store.Jobs().Get(job.ID)
if err != nil {
return err
}
if isTerminalJobState(current.State) {
*job = current
return nil
}
current.State = domain.JobStateRunning
current.Attempt = maxInt(current.Attempt, 1)
current.Progress = domain.JobProgress{Percent: 5, Phase: current.Progress.Phase, Message: "platform builder started"}
current.UpdatedAt = stamp
if err := svc.updateScheduledJob(current); err != nil {
return err
}
if err := svc.projectDistributionBuildProgress(current, stamp); err != nil {
return err
}
*job = current
return nil
}
func (svc *CoreService) succeedDistributionBuildJob(job domain.Job, artifactID string) error {
stamp := svc.now()
svc.jobMu.Lock()
current, err := svc.store.Jobs().Get(job.ID)
if err != nil {
svc.jobMu.Unlock()
return err
}
if isTerminalJobState(current.State) {
if current.State == domain.JobStateSucceeded && current.ResultRef == "artifact://"+artifactID {
svc.jobMu.Unlock()
return nil
}
svc.jobMu.Unlock()
_ = svc.expireDistributionArtifact(artifactID)
return nil
}
current.State = domain.JobStateSucceeded
current.ResultRef = "artifact://" + artifactID
current.Progress = domain.JobProgress{Percent: 100, Phase: current.Progress.Phase, Message: "platform builder completed"}
current.LeaseTokenHash = ""
current.TerminalAt = stamp
current.TerminalFingerprint = "platform-builder:succeeded:" + artifactID
current.UpdatedAt = stamp
if err := svc.updateScheduledJob(current); err != nil {
svc.jobMu.Unlock()
return err
}
svc.jobMu.Unlock()
return svc.projectDistributionBuildResult(current, stamp)
}
func (svc *CoreService) failDistributionBuildJob(job domain.Job, reason string) error {
stamp := svc.now()
if strings.TrimSpace(reason) == "" {
reason = "platform builder failed"
}
svc.jobMu.Lock()
current, err := svc.store.Jobs().Get(job.ID)
if err != nil {
svc.jobMu.Unlock()
return err
}
if isTerminalJobState(current.State) {
svc.jobMu.Unlock()
return nil
}
current.State = domain.JobStateFailed
current.Progress = domain.JobProgress{Percent: current.Progress.Percent, Phase: current.Progress.Phase, Message: reason}
current.LeaseTokenHash = ""
current.TerminalAt = stamp
current.TerminalFingerprint = "platform-builder:failed:" + reason
current.UpdatedAt = stamp
if err := svc.updateScheduledJob(current); err != nil {
svc.jobMu.Unlock()
return err
}
svc.jobMu.Unlock()
if err := svc.projectDistributionBuildResult(current, stamp); err != nil && !errors.Is(err, repo.ErrNotFound) {
return err
}
return validationError(reason)
}
@@ -0,0 +1,457 @@
package service
import (
"errors"
"strings"
"sync"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
type captureDistributionBuilder struct {
inputs chan domain.DistributionBuildInput
release <-chan struct{}
payload []byte
}
func (builder captureDistributionBuilder) Readiness() (bool, string) {
return true, ""
}
func (builder captureDistributionBuilder) Build(input domain.DistributionBuildInput) ([]byte, error) {
builder.inputs <- input
if builder.release != nil {
<-builder.release
}
return domain.CopyBytes(builder.payload), nil
}
func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
inputs := make(chan domain.DistributionBuildInput, 1)
release := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
svc.ConfigureDistributionBuilder(captureDistributionBuilder{inputs: inputs, release: release, payload: []byte("captured-platform-build")})
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "windows",
TargetArch: "amd64",
IdempotencyKey: "platform-secret-boundary",
})
if err != nil {
t.Fatalf("generate platform distribution: %v", err)
}
var platformInput domain.DistributionBuildInput
select {
case platformInput = <-inputs:
case <-time.After(time.Second):
t.Fatal("platform builder did not receive internal build input")
}
if platformInput.AuthKey == "" || platformInput.JobID != distribution.BuildJobID || platformInput.RunEndpointID != instance.RunEndpointID {
t.Fatalf("platform builder received incomplete internal input: %+v", platformInput)
}
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun,
Generation: platformInput.KeyGeneration,
Key: platformInput.AuthKey,
})
if err != nil || !auth.Allowed {
t.Fatalf("platform builder did not receive the active plaintext component key: auth=%+v err=%v", auth, err)
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityDistributionBuild)
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register machine endpoint: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{
RunEndpointID: instance.RunEndpointID,
SessionToken: hello.SessionToken,
Capabilities: []string{domain.JobCapabilityDistributionBuild},
Capacity: domain.RunCapacity{MaxJobs: 1},
})
if err != nil {
t.Fatalf("claim machine jobs: %v", err)
}
if claim.HasJob {
t.Fatalf("machine endpoint received platform-owned build job: %+v", claim)
}
machineInput, err := svc.GetDistributionBuildInput(domain.DistributionBuildInputRequest{
RunEndpointID: instance.RunEndpointID,
SessionToken: hello.SessionToken,
JobID: distribution.BuildJobID,
LeaseToken: "machine-cannot-hold-platform-build-lease",
Attempt: 1,
})
if err == nil {
t.Fatalf("machine endpoint unexpectedly read platform build input: %+v", machineInput)
}
if machineInput.AuthKey != "" || strings.Contains(err.Error(), platformInput.AuthKey) {
t.Fatalf("machine build-input rejection leaked plaintext auth key: input=%+v err=%v", machineInput, err)
}
releaseOnce.Do(func() { close(release) })
completeDistributionBuild(t, svc, distribution, nil)
}
func TestCoreServiceBuildsWithoutRegisteredDistributionWorker(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
bootstrapEndpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
t.Fatalf("get bootstrap endpoint: %v", err)
}
capabilities := bootstrapEndpoint.Capabilities[:0]
for _, capability := range bootstrapEndpoint.Capabilities {
if capability != domain.JobCapabilityDistributionBuild {
capabilities = append(capabilities, capability)
}
}
bootstrapEndpoint.Capabilities = capabilities
bootstrapEndpoint.Status = domain.RunEndpointStatusOffline
if err := svc.store.RunEndpoints().Update(bootstrapEndpoint); err != nil {
t.Fatalf("remove build worker capability: %v", err)
}
instance.RunEndpointID = dedicatedRunEndpointID(instance.ID)
instance.DeploymentTargetID = ""
if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatalf("prepare unregistered dedicated Run binding: %v", err)
}
if _, err := svc.store.RunEndpoints().Get(instance.RunEndpointID); !errors.Is(err, repo.ErrNotFound) {
t.Fatalf("dedicated Run must be unregistered before generation, got %v", err)
}
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "windows",
TargetArch: "amd64",
IdempotencyKey: "no-machine-distribution-worker",
})
if err != nil {
t.Fatalf("generate without registered distribution worker: %v", err)
}
distribution = completeDistributionBuild(t, svc, distribution, nil)
if distribution.RunEndpointID != instance.RunEndpointID || distribution.Status != domain.DistributionStatusAvailable {
t.Fatalf("unexpected platform-built distribution: %+v", distribution)
}
}
func TestCoreServiceGeneratedRunOnlyEndpointCanGenerateAnotherRun(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
instance.State = domain.ServerInstanceStateFailed
if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatalf("prepare server for dedicated Run generation: %v", err)
}
first, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "generated-run-only-first",
})
if err != nil {
t.Fatalf("generate first dedicated Run: %v", err)
}
first = completeDistributionBuild(t, svc, first, nil)
packageConfig := readGeneratedPackageConfig(t, svc, session, first.ArtifactID)
instance, err = svc.GetServerInstance(instance.ID)
if err != nil {
t.Fatalf("get dedicated Run binding: %v", err)
}
bootstrap, err := svc.store.RunEndpoints().Get(instance.DeploymentTargetID)
if err != nil {
t.Fatalf("get former bootstrap endpoint: %v", err)
}
bootstrap.Status = domain.RunEndpointStatusOffline
if err := svc.store.RunEndpoints().Update(bootstrap); err != nil {
t.Fatalf("take former bootstrap endpoint offline: %v", err)
}
helloRequest := validRunControlHello()
helloRequest.RunEndpointID = instance.RunEndpointID
helloRequest.RegistrationToken = packageConfig.AuthKey
helloRequest.ServerInstanceID = instance.ID
helloRequest.PluginID = instance.PluginID
helloRequest.ComponentKind = domain.DistributionComponentRun
helloRequest.KeyGeneration = packageConfig.KeyGeneration
capabilities := helloRequest.CapabilityReport.Capabilities[:0]
for _, capability := range helloRequest.CapabilityReport.Capabilities {
if capability != domain.JobCapabilityDistributionBuild {
capabilities = append(capabilities, capability)
}
}
helloRequest.CapabilityReport.Capabilities = capabilities
registered, err := svc.RegisterRunHello(helloRequest)
if err != nil || !registered.Accepted {
t.Fatalf("register generated Run: result=%+v err=%v", registered, err)
}
online, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{Status: domain.RunEndpointStatusOnline})
if err != nil || len(online) != 1 || online[0].ID != instance.RunEndpointID {
t.Fatalf("expected generated Run to be the only online endpoint: endpoints=%+v err=%v", online, err)
}
for _, capability := range online[0].Capabilities {
if capability == domain.JobCapabilityDistributionBuild {
t.Fatalf("generated Run must not advertise distribution build: %+v", online[0])
}
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{
RunEndpointID: instance.RunEndpointID,
SessionToken: registered.SessionToken,
Capabilities: []string{domain.JobCapabilityDistributionBuild},
Capacity: domain.RunCapacity{MaxJobs: 1},
})
if err != nil || claim.HasJob {
t.Fatalf("generated Run must not receive platform build work: claim=%+v err=%v", claim, err)
}
second, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "generated-run-only-second",
})
if err != nil {
t.Fatalf("regenerate with generated Run as only endpoint: %v", err)
}
second = completeDistributionBuild(t, svc, second, nil)
job, err := svc.GetJob(second.BuildJobID)
if err != nil || job.RunEndpointID != platformDistributionBuilderEndpointID || job.State != domain.JobStateSucceeded || second.Status != domain.DistributionStatusAvailable {
t.Fatalf("expected platform-built regenerated Run: job=%+v distribution=%+v err=%v", job, second, err)
}
}
func TestCoreServiceDoesNotDuplicateInFlightPlatformBuild(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
inputs := make(chan domain.DistributionBuildInput, 2)
release := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
svc.ConfigureDistributionBuilder(captureDistributionBuilder{inputs: inputs, release: release, payload: []byte("idempotent-platform-build")})
request := domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "same-platform-build",
}
first, err := svc.GenerateRunDistributionForSession(session, request)
if err != nil {
t.Fatalf("generate first distribution: %v", err)
}
select {
case <-inputs:
case <-time.After(time.Second):
t.Fatal("first platform build did not start")
}
second, err := svc.GenerateRunDistributionForSession(session, request)
if err != nil {
t.Fatalf("repeat idempotent generation: %v", err)
}
if second.ID != first.ID || second.BuildJobID != first.BuildJobID {
t.Fatalf("idempotent generation returned different work: first=%+v second=%+v", first, second)
}
select {
case duplicate := <-inputs:
t.Fatalf("idempotent generation started duplicate platform build: %+v", duplicate)
case <-time.After(20 * time.Millisecond):
}
releaseOnce.Do(func() { close(release) })
completeDistributionBuild(t, svc, first, nil)
}
func TestCoreServiceDiscardsBuildCompletedAfterKeyReset(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
inputs := make(chan domain.DistributionBuildInput, 1)
release := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
svc.ConfigureDistributionBuilder(captureDistributionBuilder{inputs: inputs, release: release, payload: []byte("stale-key-build")})
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "reset-during-platform-build",
})
if err != nil {
t.Fatalf("generate distribution: %v", err)
}
select {
case <-inputs:
case <-time.After(time.Second):
t.Fatal("platform builder did not start")
}
if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun,
}); err != nil {
t.Fatalf("reset component key during build: %v", err)
}
releaseOnce.Do(func() { close(release) })
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
job, jobErr := svc.GetJob(distribution.BuildJobID)
updated, distributionErr := svc.store.RunDistributions().Get(distribution.ID)
if jobErr != nil || distributionErr != nil {
t.Fatalf("read stale build state: jobErr=%v distributionErr=%v", jobErr, distributionErr)
}
if job.State == domain.JobStateFailed {
if updated.Status != domain.DistributionStatusRevoked || !strings.Contains(job.Progress.Message, "no longer current") {
t.Fatalf("stale build did not remain revoked: job=%+v distribution=%+v", job, updated)
}
if artifact, artifactErr := svc.GetArtifact(distribution.ArtifactID); artifactErr == nil && artifact.State == domain.ArtifactStateAvailable {
t.Fatalf("stale-key build published an available artifact: %+v", artifact)
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("stale-key platform build did not terminate")
}
func TestCoreServicePreservesSucceededArtifactOnDuplicatePlatformResult(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "preserve-duplicate-platform-result",
})
if err != nil {
t.Fatalf("generate distribution: %v", err)
}
distribution = completeDistributionBuild(t, svc, distribution, nil)
job, err := svc.GetJob(distribution.BuildJobID)
if err != nil {
t.Fatalf("get completed build job: %v", err)
}
if job.State != domain.JobStateSucceeded || job.ResultRef != "artifact://"+distribution.ArtifactID {
t.Fatalf("expected succeeded build job with distribution artifact ref, got %+v", job)
}
if err := svc.succeedDistributionBuildJob(job, distribution.ArtifactID); err != nil {
t.Fatalf("replay succeeded platform result: %v", err)
}
artifact, err := svc.GetArtifact(distribution.ArtifactID)
if err != nil {
t.Fatalf("get distribution artifact: %v", err)
}
if artifact.State != domain.ArtifactStateAvailable {
t.Fatalf("duplicate platform result expired active artifact: %+v", artifact)
}
}
func TestCoreServiceResumesPendingPlatformBuildAfterEnvelopeConfiguration(t *testing.T) {
store := repo.NewMemoryStore()
artifactStore := NewMemoryArtifactBodyStore()
first, err := NewCoreServiceWithDurableStores(store, NewMemoryLogBodyStore(), artifactStore)
if err != nil {
t.Fatalf("create first durable CoreService: %v", err)
}
const envelopeKey = "restart-test-secret-envelope-key-must-remain-stable"
if err := first.ConfigureSecretEnvelopeKey(envelopeKey); err != nil {
t.Fatalf("configure first envelope key: %v", err)
}
plugin, endpoint := createPluginAndRunEndpoint(t, first)
plugin.SupportedOS = []string{"linux"}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.run.distribution")
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRunDistribution))
if err := first.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("enable distribution fixture plugin: %v", err)
}
session := createServiceUserAndLogin(t, first, domain.User{
ID: "restart-distribution-owner",
DisplayName: "Restart Distribution Owner",
Email: "restart-distribution-owner@example.test",
Roles: []string{"server-owner"},
PasswordHash: "secret-password",
})
instance, err := first.CreateServerInstanceForSession(session, domain.ServerInstance{
ID: "server-restart-distribution",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "Restart Distribution Server",
State: domain.ServerInstanceStateReady,
})
if err != nil {
t.Fatalf("create restart distribution server: %v", err)
}
createCompleteRuntimeBinding(t, first, instance, "local")
inputs := make(chan domain.DistributionBuildInput, 1)
release := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
first.ConfigureDistributionBuilder(captureDistributionBuilder{inputs: inputs, release: release, payload: []byte("first-process-output")})
distribution, err := first.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "resume-after-envelope-configuration",
})
if err != nil {
t.Fatalf("queue durable platform build: %v", err)
}
select {
case <-inputs:
case <-time.After(time.Second):
t.Fatal("first CoreService did not begin the pending build")
}
restarted, err := NewCoreServiceWithDurableStores(store, NewMemoryLogBodyStore(), artifactStore)
if err != nil {
t.Fatalf("create restarted durable CoreService: %v", err)
}
if err := restarted.ConfigureSecretEnvelopeKey(envelopeKey); err != nil {
t.Fatalf("configure restarted envelope key: %v", err)
}
restarted.ConfigureDistributionBuilder(staticDistributionBuilder{payload: []byte("recovered-platform-build")})
distribution = completeDistributionBuild(t, restarted, distribution, nil)
job, err := restarted.GetJob(distribution.BuildJobID)
if err != nil || job.State != domain.JobStateSucceeded || job.RunEndpointID != platformDistributionBuilderEndpointID {
t.Fatalf("expected recovered platform build to succeed: job=%+v err=%v", job, err)
}
artifact, err := restarted.GetArtifact(distribution.ArtifactID)
if err != nil || artifact.State != domain.ArtifactStateAvailable || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != job.ID {
t.Fatalf("expected recovered job-owned artifact: artifact=%+v err=%v", artifact, err)
}
}
func TestCoreServiceReportsPlatformBuilderUnavailable(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
svc.ConfigureDistributionBuilder(staticDistributionBuilder{err: errors.New("platform builder image is unavailable")})
actions, err := svc.GetServerRuntimeActionsForSession(session, instance.ID)
if err != nil {
t.Fatalf("get runtime actions: %v", err)
}
seen := 0
for _, action := range actions.Actions {
if action.Key != "generate-run" && action.Key != "generate-client-manager" {
continue
}
seen++
if action.Available || !strings.Contains(action.Reason, "platform builder") || strings.Contains(action.Reason, "run endpoint") {
t.Fatalf("expected explicit platform builder unavailable reason, got %+v", action)
}
}
if seen != 2 {
t.Fatalf("expected both build actions, got %+v", actions.Actions)
}
_, err = svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "builder-unavailable",
})
if err == nil || !strings.Contains(err.Error(), "platform builder") || strings.Contains(err.Error(), "run endpoint") {
t.Fatalf("expected generation to report platform builder failure, got %v", err)
}
}
@@ -168,6 +168,12 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
if distribution.BuildJobID != job.ID { if distribution.BuildJobID != job.ID {
continue continue
} }
if distribution.Status == domain.DistributionStatusRevoked {
if artifact.ID != "" {
return svc.expireDistributionArtifact(artifact.ID)
}
return nil
}
if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID { if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID {
return validationError("distribution build returned an unexpected artifact") return validationError("distribution build returned an unexpected artifact")
} }
@@ -190,6 +196,12 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
if distribution.BuildJobID != job.ID { if distribution.BuildJobID != job.ID {
continue continue
} }
if distribution.Status == domain.DistributionStatusRevoked {
if artifact.ID != "" {
return svc.expireDistributionArtifact(artifact.ID)
}
return nil
}
if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID { if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID {
return validationError("client-manager build returned an unexpected artifact") return validationError("client-manager build returned an unexpected artifact")
} }
+400
View File
@@ -0,0 +1,400 @@
package service
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"browser.local/platform/domain"
)
// DistributionBuilder executes a distribution build inside a platform-owned
// container. Builds are a platform responsibility: they must not depend on a
// machine-side run endpoint being registered and online, and the component auth
// key must never leave the platform.
type DistributionBuilder interface {
// Readiness reports whether the platform builder can execute a build. The
// reason must name the platform builder rather than a run endpoint
// capability, so an operator is not sent looking at the wrong subsystem.
Readiness() (bool, string)
// Build assembles the package described by input and returns its bytes.
Build(input domain.DistributionBuildInput) ([]byte, error)
}
// DockerDistributionBuilderConfig configures a container-per-build builder.
type DockerDistributionBuilderConfig struct {
DockerBinary string
Image string
SourceDir string
WorkspaceDir string
Timeout time.Duration
PlatformURL string
CommandRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
}
// DockerDistributionBuilder runs each build in a container from a pinned image,
// with the run source mounted read-only and a per-job output directory mounted
// writable.
type DockerDistributionBuilder struct {
config DockerDistributionBuilderConfig
}
func NewDockerDistributionBuilder(config DockerDistributionBuilderConfig) *DockerDistributionBuilder {
if strings.TrimSpace(config.DockerBinary) == "" {
config.DockerBinary = "docker"
}
if config.Timeout <= 0 {
config.Timeout = 30 * time.Minute
}
if config.CommandRunner == nil {
config.CommandRunner = runCommandCombined
}
return &DockerDistributionBuilder{config: config}
}
func runCommandCombined(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).CombinedOutput()
}
func (builder *DockerDistributionBuilder) Readiness() (bool, string) {
if strings.TrimSpace(builder.config.Image) == "" {
return false, "platform builder image is not configured"
}
if !pinnedBuilderImage(builder.config.Image) {
return false, "platform builder image must be pinned to an explicit version or digest"
}
source := strings.TrimSpace(builder.config.SourceDir)
if source == "" {
return false, "platform builder run source directory is not configured"
}
source, err := filepath.Abs(strings.TrimSpace(builder.config.SourceDir))
if err != nil {
return false, "platform builder run source directory is invalid"
}
if _, err := os.Stat(filepath.Join(source, "go.mod")); err != nil {
return false, "platform builder run source directory does not contain a run checkout"
}
if strings.TrimSpace(builder.config.WorkspaceDir) == "" {
return false, "platform builder workspace directory is not configured"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := builder.config.CommandRunner(ctx, builder.config.DockerBinary, "version", "--format", "{{.Server.Version}}"); err != nil {
return false, "platform builder container runtime is unavailable"
}
if _, err := builder.config.CommandRunner(ctx, builder.config.DockerBinary, "image", "inspect", builder.config.Image); err != nil {
return false, "platform builder image is unavailable"
}
return true, ""
}
// pinnedBuilderImage rejects floating references. An unpinned builder image
// silently changes what the platform ships.
func pinnedBuilderImage(image string) bool {
image = strings.TrimSpace(image)
if name, digest, found := strings.Cut(image, "@sha256:"); found {
if strings.TrimSpace(name) == "" || len(digest) != 64 {
return false
}
_, err := hex.DecodeString(digest)
return err == nil
}
reference := image
if slash := strings.LastIndex(image, "/"); slash >= 0 {
reference = image[slash+1:]
}
_, tag, found := strings.Cut(reference, ":")
if !found {
return false
}
tag = strings.TrimSpace(tag)
return tag != "" && tag != "latest"
}
func (builder *DockerDistributionBuilder) Build(input domain.DistributionBuildInput) ([]byte, error) {
if ready, reason := builder.Readiness(); !ready {
return nil, validationError(reason)
}
sourceDir, err := filepath.Abs(strings.TrimSpace(builder.config.SourceDir))
if err != nil {
return nil, validationError("platform builder run source directory is invalid")
}
workspaceDir, err := filepath.Abs(strings.TrimSpace(builder.config.WorkspaceDir))
if err != nil {
return nil, validationError("platform builder workspace directory is invalid")
}
// Workspaces stay isolated per plugin and per job as required by
// run-build-download-flow.
jobDir := filepath.Join(workspaceDir, sanitizeIDPart(input.PluginID), sanitizeIDPart(input.JobID))
if err := os.RemoveAll(jobDir); err != nil {
return nil, err
}
outputDir := filepath.Join(jobDir, "output")
inputDir := filepath.Join(jobDir, "input")
buildDir := filepath.Join(jobDir, "build")
for _, directory := range []string{outputDir, inputDir, buildDir} {
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, err
}
}
defer func() { _ = os.RemoveAll(jobDir) }()
// The auth key reaches the container through a per-job input file, never
// through a job-channel response to a machine-side endpoint or a container
// command-line argument.
if strings.TrimSpace(input.AuthKey) == "" {
return nil, validationError("distribution build input is missing a component auth key")
}
if err := os.WriteFile(filepath.Join(inputDir, "auth-key"), []byte(input.AuthKey), 0o600); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil {
return nil, err
}
outputName := strings.TrimSpace(input.OutputFilename)
if outputName == "" || filepath.Base(outputName) != outputName {
return nil, validationError("distribution build input has an invalid output filename")
}
ctx, cancel := context.WithTimeout(context.Background(), builder.config.Timeout)
defer cancel()
args := builder.containerArgs(input, sourceDir, inputDir, buildDir, outputDir, outputName)
if output, err := builder.config.CommandRunner(ctx, builder.config.DockerBinary, args...); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, validationError("platform builder timed out while building the distribution")
}
return nil, validationError("platform builder failed: " + safeBuilderFailure(output, input.AuthKey, builder.config.SourceDir, sourceDir, jobDir))
}
binary, err := os.ReadFile(filepath.Join(outputDir, outputName))
if err != nil {
return nil, validationError("platform builder did not produce a distribution executable")
}
if len(binary) == 0 {
return nil, validationError("platform builder produced an empty distribution executable")
}
if input.ComponentKind == domain.DistributionComponentRun {
return binary, nil
}
configPayload, err := os.ReadFile(filepath.Join(outputDir, "config.yaml"))
if err != nil {
return nil, validationError("platform builder did not produce client-manager configuration")
}
return packageClientManagerDistribution(input.PackageFormat, outputName, binary, configPayload)
}
const distributionBuildScript = `#!/bin/sh
set -eu
auth_key="$(cat /workspace/input/auth-key)"
if [ "$COMPONENT_KIND" = "run" ]; then
cd /workspace/source
ldflags="-s -w"
ldflags="$ldflags -X browser.local/run/config.BuildMode=worker"
ldflags="$ldflags -X browser.local/run/config.BuildPlatformURL=$PLATFORM_URL"
ldflags="$ldflags -X browser.local/run/config.BuildRunEndpointID=$RUN_ENDPOINT_ID"
ldflags="$ldflags -X browser.local/run/config.BuildDisplayName=Run-$SERVER_INSTANCE_ID"
ldflags="$ldflags -X browser.local/run/config.BuildRegistrationToken=$auth_key"
ldflags="$ldflags -X browser.local/run/config.BuildServerInstanceID=$SERVER_INSTANCE_ID"
ldflags="$ldflags -X browser.local/run/config.BuildPluginID=$PLUGIN_ID"
ldflags="$ldflags -X browser.local/run/config.BuildComponentKind=$COMPONENT_KIND"
ldflags="$ldflags -X browser.local/run/config.BuildComponentKey=$PROFILE_KEY"
ldflags="$ldflags -X browser.local/run/config.BuildKeyGeneration=$KEY_GENERATION"
ldflags="$ldflags -X browser.local/run/config.BuildVersion=$TARGET_RELEASE"
go mod download
go build -trimpath -ldflags "$ldflags" -o "/workspace/output/$OUTPUT_FILENAME" ./cmd/run
exit 0
fi
if [ "$COMPONENT_KIND" != "client-manager" ]; then
printf 'unsupported component kind\n' >&2
exit 2
fi
case "$REPOSITORY_URL" in
https://*) ;;
*) printf 'client-manager repository must use https\n' >&2; exit 2 ;;
esac
cd /workspace/build
git init --quiet
git remote add origin "$REPOSITORY_URL"
git fetch --quiet --depth 1 origin "$SOURCE_REVISION"
git checkout --quiet --detach FETCH_HEAD
{
printf 'server_url: "%s"\n' "$PLATFORM_URL"
printf 'server_instance_id: "%s"\n' "$SERVER_INSTANCE_ID"
printf 'scum_client_credential: "%s"\n' "$auth_key"
printf 'scum_client_name: "%s"\n' "$PROFILE_KEY"
printf 'scum_client_version: "platform-build"\n'
printf 'scum_client_machine_label: "managed-client"\n'
printf 'ftp_provider: 3\n'
} > config.yaml
go mod download
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" .
cp config.yaml /workspace/output/config.yaml
`
func (builder *DockerDistributionBuilder) containerArgs(input domain.DistributionBuildInput, sourceDir string, inputDir string, buildDir string, outputDir string, outputName string) []string {
platformURL := strings.TrimSpace(input.PlatformURL)
if platformURL == "" {
platformURL = strings.TrimSpace(builder.config.PlatformURL)
}
return []string{
"run", "--rm",
"--pull", "never",
"--read-only",
"--tmpfs", "/tmp:rw,nosuid,size=2147483648",
"-v", sourceDir + ":/workspace/source:ro",
"-v", inputDir + ":/workspace/input:ro",
"-v", buildDir + ":/workspace/build",
"-v", outputDir + ":/workspace/output",
"-e", "CGO_ENABLED=0",
"-e", "GOOS=" + input.TargetOS,
"-e", "GOARCH=" + input.TargetArch,
"-e", "GOCACHE=/tmp/go-build",
"-e", "GOMODCACHE=/tmp/go-mod",
"-e", "COMPONENT_KIND=" + string(input.ComponentKind),
"-e", "SERVER_INSTANCE_ID=" + input.ServerInstanceID,
"-e", "PLUGIN_ID=" + input.PluginID,
"-e", "RUN_ENDPOINT_ID=" + input.RunEndpointID,
"-e", "PROFILE_KEY=" + input.ProfileKey,
"-e", "TARGET_RELEASE=" + input.TargetRelease,
"-e", "KEY_GENERATION=" + fmt.Sprint(input.KeyGeneration),
"-e", "PLATFORM_URL=" + platformURL,
"-e", "REPOSITORY_URL=" + input.RepositoryURL,
"-e", "SOURCE_REVISION=" + input.SourceRevision,
"-e", "OUTPUT_FILENAME=" + outputName,
builder.config.Image,
"/workspace/input/build.sh",
}
}
func packageClientManagerDistribution(packageFormat string, outputName string, binary []byte, configPayload []byte) ([]byte, error) {
switch packageFormat {
case "zip":
return zipDistributionFiles(outputName, binary, configPayload)
case "tar.gz":
return tarGzipDistributionFiles(outputName, binary, configPayload)
default:
return nil, validationError("platform builder received an unsupported client-manager package format")
}
}
func zipDistributionFiles(outputName string, binary []byte, configPayload []byte) ([]byte, error) {
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
files := []struct {
name string
mode os.FileMode
payload []byte
}{{outputName, 0o755, binary}, {"config.yaml", 0o600, configPayload}}
for _, file := range files {
header := &zip.FileHeader{Name: file.name, Method: zip.Deflate}
header.SetMode(file.mode)
header.SetModTime(time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC))
entry, err := writer.CreateHeader(header)
if err != nil {
return nil, err
}
if _, err := entry.Write(file.payload); err != nil {
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func tarGzipDistributionFiles(outputName string, binary []byte, configPayload []byte) ([]byte, error) {
var buffer bytes.Buffer
gzipWriter := gzip.NewWriter(&buffer)
gzipWriter.Header.ModTime = time.Unix(0, 0).UTC()
tarWriter := tar.NewWriter(gzipWriter)
files := []struct {
name string
mode int64
payload []byte
}{{outputName, 0o755, binary}, {"config.yaml", 0o600, configPayload}}
for _, file := range files {
header := &tar.Header{Name: file.name, Mode: file.mode, Size: int64(len(file.payload)), ModTime: time.Unix(0, 0).UTC()}
if err := tarWriter.WriteHeader(header); err != nil {
return nil, err
}
if _, err := tarWriter.Write(file.payload); err != nil {
return nil, err
}
}
if err := tarWriter.Close(); err != nil {
return nil, err
}
if err := gzipWriter.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
// safeBuilderFailure keeps host paths and secret values out of reported build
// failures.
func safeBuilderFailure(output []byte, sensitiveValues ...string) string {
text := strings.TrimSpace(string(output))
for _, sensitive := range sensitiveValues {
if strings.TrimSpace(sensitive) != "" {
text = strings.ReplaceAll(text, sensitive, "[redacted]")
}
}
if text == "" {
return "build command reported no diagnostic output"
}
lines := strings.Split(text, "\n")
kept := make([]string, 0, len(lines))
for index := len(lines) - 1; index >= 0 && len(kept) < 3; index-- {
line := strings.TrimSpace(lines[index])
if line == "" || strings.Contains(line, "/workspace/input") || strings.Contains(line, "auth-key") {
continue
}
line = redactBuilderHostPaths(line)
kept = append([]string{line}, kept...)
}
if len(kept) == 0 {
return "build command reported no shareable diagnostic output"
}
joined := strings.Join(kept, "; ")
if len(joined) > 400 {
joined = joined[:400]
}
return joined
}
func redactBuilderHostPaths(line string) string {
fields := strings.Fields(line)
for index, field := range fields {
trimmed := strings.TrimLeft(field, "(\"'[")
if strings.HasPrefix(trimmed, "/") && !strings.HasPrefix(trimmed, "/workspace/") {
fields[index] = "[redacted-path]"
}
}
return strings.Join(fields, " ")
}
func builderJobFailureMessage(err error) string {
if err == nil {
return "platform builder failed"
}
message := strings.TrimSpace(err.Error())
if message == "" {
return "platform builder failed"
}
if len(message) > 400 {
message = message[:400]
}
return message
}
@@ -0,0 +1,327 @@
package service
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
func TestPinnedBuilderImageRequiresExplicitTagOrDigest(t *testing.T) {
validDigest := strings.Repeat("a", 64)
for _, test := range []struct {
image string
want bool
}{
{image: "browser-platform-distribution-builder:1.0.0", want: true},
{image: "registry.example.test:5000/builders/distribution:v1", want: true},
{image: "registry.example.test/builders/distribution@sha256:" + validDigest, want: true},
{image: "browser-platform-distribution-builder", want: false},
{image: "browser-platform-distribution-builder:latest", want: false},
{image: "registry.example.test/builders/distribution@sha256:", want: false},
{image: "registry.example.test/builders/distribution@sha256:not-a-digest", want: false},
} {
t.Run(test.image, func(t *testing.T) {
if got := pinnedBuilderImage(test.image); got != test.want {
t.Fatalf("pinnedBuilderImage(%q) = %t, want %t", test.image, got, test.want)
}
})
}
}
func TestDockerDistributionBuilderReadinessNamesPlatformBuilderFailures(t *testing.T) {
sourceDir := createBuilderSource(t)
workspaceDir := t.TempDir()
for _, test := range []struct {
name string
config DockerDistributionBuilderConfig
reason string
}{
{
name: "floating image",
config: DockerDistributionBuilderConfig{Image: "golang:latest", SourceDir: sourceDir, WorkspaceDir: workspaceDir},
reason: "platform builder image must be pinned",
},
{
name: "missing source",
config: DockerDistributionBuilderConfig{Image: "builder:1.0.0", WorkspaceDir: workspaceDir},
reason: "platform builder run source directory is not configured",
},
{
name: "image unavailable",
config: DockerDistributionBuilderConfig{
Image: "builder:1.0.0",
SourceDir: sourceDir,
WorkspaceDir: workspaceDir,
CommandRunner: func(_ context.Context, _ string, args ...string) ([]byte, error) {
if len(args) > 0 && args[0] == "version" {
return []byte("27.0.0"), nil
}
return nil, errors.New("image missing")
},
},
reason: "platform builder image is unavailable",
},
{
name: "container runtime unavailable",
config: DockerDistributionBuilderConfig{
Image: "builder:1.0.0",
SourceDir: sourceDir,
WorkspaceDir: workspaceDir,
CommandRunner: func(context.Context, string, ...string) ([]byte, error) {
return nil, errors.New("docker unavailable")
},
},
reason: "platform builder container runtime is unavailable",
},
} {
t.Run(test.name, func(t *testing.T) {
ready, reason := NewDockerDistributionBuilder(test.config).Readiness()
if ready || !strings.Contains(reason, test.reason) || !strings.Contains(reason, "platform builder") {
t.Fatalf("expected platform builder readiness failure %q, ready=%t reason=%q", test.reason, ready, reason)
}
})
}
}
func TestDockerDistributionBuilderKeepsSecretInIsolatedInput(t *testing.T) {
sourceDir := createBuilderSource(t)
workspaceDir := t.TempDir()
secret := "component-auth-key-that-must-not-leave-input"
var dockerArgs []string
builder := NewDockerDistributionBuilder(DockerDistributionBuilderConfig{
DockerBinary: "docker-test",
Image: "browser-platform-distribution-builder:1.0.0",
SourceDir: sourceDir,
WorkspaceDir: workspaceDir,
CommandRunner: func(_ context.Context, name string, args ...string) ([]byte, error) {
if name != "docker-test" {
t.Fatalf("unexpected container runtime %q", name)
}
if len(args) > 0 && (args[0] == "version" || args[0] == "image") {
return []byte("27.0.0"), nil
}
dockerArgs = append([]string(nil), args...)
inputDir := builderMountHostPath(t, args, "/workspace/input:ro")
outputDir := builderMountHostPath(t, args, "/workspace/output")
authPath := filepath.Join(inputDir, "auth-key")
payload, err := os.ReadFile(authPath)
if err != nil {
t.Fatalf("read per-job auth input: %v", err)
}
if string(payload) != secret {
t.Fatalf("unexpected per-job auth input %q", payload)
}
info, err := os.Stat(authPath)
if err != nil {
t.Fatalf("stat per-job auth input: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("auth input mode = %o, want 600", info.Mode().Perm())
}
script, err := os.ReadFile(filepath.Join(inputDir, "build.sh"))
if err != nil {
t.Fatalf("read build script: %v", err)
}
if bytes.Contains(script, []byte(secret)) {
t.Fatal("build script must not embed the component auth key")
}
if err := os.WriteFile(filepath.Join(outputDir, "run.exe"), []byte("compiled-run"), 0o700); err != nil {
t.Fatalf("write fake build output: %v", err)
}
return nil, nil
},
})
input := domain.DistributionBuildInput{
JobID: "job/build:one",
ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: "server-one",
PluginID: "game.scum",
RunEndpointID: "server-run-server-one",
TargetOS: "windows",
TargetArch: "amd64",
TargetRelease: "release-one",
PlatformURL: "https://platform.example.test",
OutputFilename: "run.exe",
KeyGeneration: 1,
AuthKey: secret,
}
payload, err := builder.Build(input)
if err != nil {
t.Fatalf("build Run distribution: %v", err)
}
if string(payload) != "compiled-run" {
t.Fatalf("unexpected built payload %q", payload)
}
joinedArgs := strings.Join(dockerArgs, "\x00")
if strings.Contains(joinedArgs, secret) {
t.Fatal("component auth key leaked into Docker arguments or environment")
}
for _, required := range []string{"--read-only", "--pull\x00never", sourceDir + ":/workspace/source:ro", ":/workspace/input:ro", ":/workspace/output"} {
if !strings.Contains(joinedArgs, required) {
t.Fatalf("Docker arguments do not contain required isolation %q: %q", required, joinedArgs)
}
}
jobDir := filepath.Join(workspaceDir, "game.scum", "job-build-one")
if _, err := os.Stat(jobDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("per-job workspace was not removed: %v", err)
}
}
func TestDockerDistributionBuilderRedactsFailureAndTimeout(t *testing.T) {
sourceDir := createBuilderSource(t)
workspaceDir := t.TempDir()
secret := "sensitive-component-key"
input := domain.DistributionBuildInput{
JobID: "job-redaction",
ComponentKind: domain.DistributionComponentRun,
PluginID: "game.scum",
TargetOS: "linux",
TargetArch: "amd64",
OutputFilename: "run",
AuthKey: secret,
}
builder := NewDockerDistributionBuilder(DockerDistributionBuilderConfig{
Image: "builder:1.0.0",
SourceDir: sourceDir,
WorkspaceDir: workspaceDir,
CommandRunner: func(_ context.Context, _ string, args ...string) ([]byte, error) {
if len(args) > 0 && (args[0] == "version" || args[0] == "image") {
return []byte("27.0.0"), nil
}
jobDir := filepath.Join(workspaceDir, "game.scum", "job-redaction")
return []byte(secret + "\n" + sourceDir + "/go.mod: build failed\n" + jobDir + "/input/auth-key"), errors.New("exit 1")
},
})
_, err := builder.Build(input)
if err == nil {
t.Fatal("expected failed builder command")
}
if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), sourceDir) || strings.Contains(err.Error(), workspaceDir) || strings.Contains(err.Error(), "auth-key") {
t.Fatalf("builder failure leaked secret or host path: %v", err)
}
timeoutBuilder := NewDockerDistributionBuilder(DockerDistributionBuilderConfig{
Image: "builder:1.0.0",
SourceDir: sourceDir,
WorkspaceDir: workspaceDir,
Timeout: 5 * time.Millisecond,
CommandRunner: func(ctx context.Context, _ string, args ...string) ([]byte, error) {
if len(args) > 0 && (args[0] == "version" || args[0] == "image") {
return []byte("27.0.0"), nil
}
<-ctx.Done()
return nil, ctx.Err()
},
})
_, err = timeoutBuilder.Build(input)
if err == nil || !strings.Contains(err.Error(), "platform builder timed out") {
t.Fatalf("expected bounded platform builder timeout, got %v", err)
}
}
func TestPackageClientManagerDistributionProducesProtectedArchives(t *testing.T) {
for _, packageFormat := range []string{"zip", "tar.gz"} {
t.Run(packageFormat, func(t *testing.T) {
payload, err := packageClientManagerDistribution(packageFormat, "client-manager.exe", []byte("binary"), []byte("credential: protected"))
if err != nil {
t.Fatalf("package client manager: %v", err)
}
files := readDistributionArchive(t, packageFormat, payload)
if string(files["client-manager.exe"].payload) != "binary" || files["client-manager.exe"].mode.Perm() != 0o755 {
t.Fatalf("unexpected executable archive entry: %+v", files["client-manager.exe"])
}
if string(files["config.yaml"].payload) != "credential: protected" || files["config.yaml"].mode.Perm() != 0o600 {
t.Fatalf("unexpected config archive entry: %+v", files["config.yaml"])
}
})
}
}
type distributionArchiveFile struct {
mode os.FileMode
payload []byte
}
func createBuilderSource(t *testing.T) string {
t.Helper()
sourceDir := t.TempDir()
if err := os.WriteFile(filepath.Join(sourceDir, "go.mod"), []byte("module browser.local/run\n"), 0o600); err != nil {
t.Fatalf("write run source go.mod: %v", err)
}
return sourceDir
}
func builderMountHostPath(t *testing.T, args []string, containerSuffix string) string {
t.Helper()
for index := 0; index+1 < len(args); index++ {
if args[index] != "-v" || !strings.HasSuffix(args[index+1], ":"+containerSuffix) {
continue
}
return strings.TrimSuffix(args[index+1], ":"+containerSuffix)
}
t.Fatalf("Docker arguments do not mount %s: %+v", containerSuffix, args)
return ""
}
func readDistributionArchive(t *testing.T, packageFormat string, payload []byte) map[string]distributionArchiveFile {
t.Helper()
files := map[string]distributionArchiveFile{}
switch packageFormat {
case "zip":
reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
if err != nil {
t.Fatalf("open zip: %v", err)
}
for _, entry := range reader.File {
body, err := entry.Open()
if err != nil {
t.Fatalf("open zip entry %s: %v", entry.Name, err)
}
content, err := io.ReadAll(body)
if closeErr := body.Close(); err == nil {
err = closeErr
}
if err != nil {
t.Fatalf("read zip entry %s: %v", entry.Name, err)
}
files[entry.Name] = distributionArchiveFile{mode: entry.Mode(), payload: content}
}
case "tar.gz":
gzipReader, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("open gzip: %v", err)
}
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("read tar header: %v", err)
}
content, err := io.ReadAll(tarReader)
if err != nil {
t.Fatalf("read tar entry %s: %v", header.Name, err)
}
files[header.Name] = distributionArchiveFile{mode: os.FileMode(header.Mode), payload: content}
}
if err := gzipReader.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
default:
t.Fatalf("unsupported archive format %q", packageFormat)
}
return files
}
+29 -36
View File
@@ -47,19 +47,12 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
if err := svc.promoteLegacyRunBinding(&instance); err != nil { if err := svc.promoteLegacyRunBinding(&instance); err != nil {
return domain.RunDistribution{}, err return domain.RunDistribution{}, err
} }
builderEndpointID := instance.RunEndpointID if ready, reason := svc.distributionBuilderReadiness(); !ready {
if strings.TrimSpace(instance.DeploymentTargetID) != "" { _ = svc.recordAuditEvent(user.ID, "run.generate.denied", "server-instance", instance.ID, domain.AuditResultDenied, reason)
builderEndpointID = instance.DeploymentTargetID return domain.RunDistribution{}, validationError(reason)
}
endpoint, err := svc.store.RunEndpoints().Get(builderEndpointID)
if err != nil {
return domain.RunDistribution{}, err
}
if err := svc.validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
return domain.RunDistribution{}, err
} }
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "") key, _, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
if err != nil { if err != nil {
return domain.RunDistribution{}, err return domain.RunDistribution{}, err
} }
@@ -70,7 +63,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
return domain.RunDistribution{}, err return domain.RunDistribution{}, err
} }
_ = plainKey
artifactID := artifactIDForDistribution(distributionID + "-binary") artifactID := artifactIDForDistribution(distributionID + "-binary")
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, distributionID) buildJobID := jobIDFromParts("job-distribution-build", instance.ID, distributionID)
stamp := svc.now() stamp := svc.now()
@@ -106,7 +98,7 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
job, err := svc.CreateJob(domain.Job{ job, err := svc.CreateJob(domain.Job{
ID: buildJobID, ID: buildJobID,
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
RunEndpointID: builderEndpointID, RunEndpointID: platformDistributionBuilderEndpointID,
Capability: domain.JobCapabilityDistributionBuild, Capability: domain.JobCapabilityDistributionBuild,
TargetKey: "distribution/run", TargetKey: "distribution/run",
InputRef: "input://distribution-build/" + distribution.ID, InputRef: "input://distribution-build/" + distribution.ID,
@@ -122,26 +114,29 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild { if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
return domain.RunDistribution{}, validationError("distribution build idempotency key conflicts with another job") return domain.RunDistribution{}, validationError("distribution build idempotency key conflicts with another job")
} }
if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultQueued, "queued run binary build job with redacted runtime key ref"); err != nil { svc.enqueueDistributionBuild(job)
if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultQueued, "queued run binary build job in the platform builder with redacted runtime key ref"); err != nil {
return domain.RunDistribution{}, err return domain.RunDistribution{}, err
} }
return domain.CopyRunDistribution(distribution), nil return domain.CopyRunDistribution(distribution), nil
} }
// promoteLegacyRunBinding reserves a server-scoped endpoint for a generated // promoteLegacyRunBinding reserves the server-scoped endpoint used by a
// Run before a legacy server first requests a distribution. Its existing // generated Run. A legacy shared endpoint remains an optional deployment target
// endpoint remains the trusted build target; reusing it in the package would // for non-build workflows; distribution builds are always platform-owned.
// allow the generated Run to replace the builder registration.
func (svc *CoreService) promoteLegacyRunBinding(instance *domain.ServerInstance) error { func (svc *CoreService) promoteLegacyRunBinding(instance *domain.ServerInstance) error {
if instance == nil || strings.TrimSpace(instance.DeploymentTargetID) != "" || (instance.State != domain.ServerInstanceStateDraft && instance.State != domain.ServerInstanceStateFailed) { if instance == nil || strings.TrimSpace(instance.DeploymentTargetID) != "" || (instance.State != domain.ServerInstanceStateDraft && instance.State != domain.ServerInstanceStateFailed) {
return nil return nil
} }
builderEndpointID := strings.TrimSpace(instance.RunEndpointID) currentEndpointID := strings.TrimSpace(instance.RunEndpointID)
if builderEndpointID == "" { dedicatedEndpointID := dedicatedRunEndpointID(instance.ID)
return validationError("legacy Run generation requires a build target endpoint") if currentEndpointID == dedicatedEndpointID {
return nil
} }
instance.DeploymentTargetID = builderEndpointID if currentEndpointID != "" {
instance.RunEndpointID = dedicatedRunEndpointID(instance.ID) instance.DeploymentTargetID = currentEndpointID
}
instance.RunEndpointID = dedicatedEndpointID
instance.UpdatedAt = svc.now() instance.UpdatedAt = svc.now()
if err := validator.ValidateServerInstance(*instance); err != nil { if err := validator.ValidateServerInstance(*instance); err != nil {
return err return err
@@ -191,15 +186,12 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil { if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil {
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, err
} }
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) if ready, reason := svc.distributionBuilderReadiness(); !ready {
if err != nil { _ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, reason)
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, validationError(reason)
}
if err := svc.validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
return domain.ClientManagerDistribution{}, err
} }
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey) key, _, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey)
if err != nil { if err != nil {
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, err
} }
@@ -210,7 +202,6 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, err
} }
_ = plainKey
artifactID := artifactIDForDistribution(clientDistributionID + "-binary") artifactID := artifactIDForDistribution(clientDistributionID + "-binary")
stamp := svc.now() stamp := svc.now()
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, clientDistributionID) buildJobID := jobIDFromParts("job-distribution-build", instance.ID, clientDistributionID)
@@ -283,7 +274,7 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
job, err := svc.CreateJob(domain.Job{ job, err := svc.CreateJob(domain.Job{
ID: buildJobID, ID: buildJobID,
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID, RunEndpointID: platformDistributionBuilderEndpointID,
Capability: domain.JobCapabilityDistributionBuild, Capability: domain.JobCapabilityDistributionBuild,
TargetKey: "distribution/client-manager/" + request.ProfileKey, TargetKey: "distribution/client-manager/" + request.ProfileKey,
InputRef: "input://distribution-build/" + distribution.ID, InputRef: "input://distribution-build/" + distribution.ID,
@@ -303,7 +294,8 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild { if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job") return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job")
} }
if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultQueued, "queued client-manager source build with redacted runtime key ref"); err != nil { svc.enqueueDistributionBuild(job)
if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultQueued, "queued client-manager source build in the platform builder with redacted runtime key ref"); err != nil {
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, err
} }
return domain.CopyClientManagerDistribution(distribution), nil return domain.CopyClientManagerDistribution(distribution), nil
@@ -467,7 +459,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
if endpointErr != nil && strings.TrimSpace(instance.DeploymentTargetID) != "" { if endpointErr != nil && strings.TrimSpace(instance.DeploymentTargetID) != "" {
endpoint, endpointErr = svc.store.RunEndpoints().Get(instance.DeploymentTargetID) endpoint, endpointErr = svc.store.RunEndpoints().Get(instance.DeploymentTargetID)
} }
if endpointErr != nil { if endpointErr != nil && !errors.Is(endpointErr, repo.ErrNotFound) {
return domain.ServerRuntimeActions{}, endpointErr return domain.ServerRuntimeActions{}, endpointErr
} }
hasAvailableRunPackage := false hasAvailableRunPackage := false
@@ -493,6 +485,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
} }
} }
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID) bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
builderReady, builderReason := svc.distributionBuilderReadiness()
dependencyPermissionDeclared := pluginDeclares(plugin, "server.dependencies.manage") dependencyPermissionDeclared := pluginDeclares(plugin, "server.dependencies.manage")
actions := domain.ServerRuntimeActions{ actions := domain.ServerRuntimeActions{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
@@ -505,11 +498,11 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
return domain.RunEndpointStatusOffline return domain.RunEndpointStatusOffline
}(), }(),
Actions: []domain.ServerRuntimeAction{ Actions: []domain.ServerRuntimeAction{
runtimeAction("generate-run", "Generate run", pluginDeclares(plugin, "server.run.distribution") && svc.endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !svc.endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)), runtimeAction("generate-run", "Generate run", pluginDeclares(plugin, "server.run.distribution") && builderReady && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared", fallbackReason(!builderReady, builderReason, bindingReason))),
runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"), runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"),
runtimeAction("push-run-update", "Push run update", runRegistered && pluginDeclares(plugin, "server.run.distribution") && svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && bindingsComplete, fallbackReason(!runRegistered, "dedicated Run has not registered", fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", bindingReason))), runtimeAction("push-run-update", "Push run update", runRegistered && pluginDeclares(plugin, "server.run.distribution") && svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && bindingsComplete, fallbackReason(!runRegistered, "dedicated Run has not registered", fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", bindingReason))),
runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"), runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"),
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && svc.endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage") || !svc.endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)), runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && builderReady && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared", fallbackReason(!builderReady, builderReason, bindingReason))),
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"), runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"), runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
runtimeAction("dependencies-check", "Check dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!runRegistered, "dedicated Run has not registered", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)))), runtimeAction("dependencies-check", "Check dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!runRegistered, "dedicated Run has not registered", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)))),
+56 -98
View File
@@ -100,7 +100,7 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
} }
} }
func TestCoreServiceBuildsDedicatedRunOnDeploymentTarget(t *testing.T) { func TestCoreServiceBuildsDedicatedRunInPlatformBuilder(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
targetID := instance.RunEndpointID targetID := instance.RunEndpointID
instance.State = domain.ServerInstanceStateDraft instance.State = domain.ServerInstanceStateDraft
@@ -115,14 +115,14 @@ func TestCoreServiceBuildsDedicatedRunOnDeploymentTarget(t *testing.T) {
t.Fatalf("generate dedicated Run: %v", err) t.Fatalf("generate dedicated Run: %v", err)
} }
job, err := svc.GetJob(distribution.BuildJobID) job, err := svc.GetJob(distribution.BuildJobID)
if err != nil || job.RunEndpointID != targetID || distribution.RunEndpointID != instance.RunEndpointID { if err != nil || job.RunEndpointID != platformDistributionBuilderEndpointID || distribution.RunEndpointID != instance.RunEndpointID {
t.Fatalf("expected build on target %q for dedicated Run %q, job=%+v distribution=%+v err=%v", targetID, instance.RunEndpointID, job, distribution, err) t.Fatalf("expected platform build for dedicated Run %q, job=%+v distribution=%+v err=%v", instance.RunEndpointID, job, distribution, err)
} }
} }
func TestCoreServicePromotesLegacyRunBindingBeforeDistributionBuild(t *testing.T) { func TestCoreServicePromotesLegacyRunBindingBeforeDistributionBuild(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
builderID := instance.RunEndpointID legacyEndpointID := instance.RunEndpointID
instance.State = domain.ServerInstanceStateFailed instance.State = domain.ServerInstanceStateFailed
if err := svc.store.ServerInstances().Update(instance); err != nil { if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatalf("mark legacy server failed: %v", err) t.Fatalf("mark legacy server failed: %v", err)
@@ -136,36 +136,37 @@ func TestCoreServicePromotesLegacyRunBindingBeforeDistributionBuild(t *testing.T
if err != nil { if err != nil {
t.Fatalf("get migrated server: %v", err) t.Fatalf("get migrated server: %v", err)
} }
if migrated.DeploymentTargetID != builderID || migrated.RunEndpointID != "server-run-"+instance.ID { if migrated.DeploymentTargetID != legacyEndpointID || migrated.RunEndpointID != "server-run-"+instance.ID {
t.Fatalf("expected legacy binding promotion, got %+v", migrated) t.Fatalf("expected legacy binding promotion, got %+v", migrated)
} }
job, err := svc.GetJob(distribution.BuildJobID) job, err := svc.GetJob(distribution.BuildJobID)
if err != nil || job.RunEndpointID != builderID || distribution.RunEndpointID != migrated.RunEndpointID { if err != nil || job.RunEndpointID != platformDistributionBuilderEndpointID || distribution.RunEndpointID != migrated.RunEndpointID {
t.Fatalf("expected build target %q and dedicated package endpoint %q, job=%+v distribution=%+v err=%v", builderID, migrated.RunEndpointID, job, distribution, err) t.Fatalf("expected platform build and dedicated package endpoint %q, job=%+v distribution=%+v err=%v", migrated.RunEndpointID, job, distribution, err)
} }
} }
func TestCoreServiceRejectsDistributionBuildForStaleRunEndpoint(t *testing.T) { func TestCoreServiceDistributionBuildIgnoresStaleRunEndpoint(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
svc.now = func() time.Time { return fixedTime.Add(capacityHeartbeatStaleAfter + time.Second) } svc.now = func() time.Time { return fixedTime.Add(capacityHeartbeatStaleAfter + time.Second) }
_, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
TargetOS: "linux", TargetOS: "linux",
TargetArch: "amd64", TargetArch: "amd64",
IdempotencyKey: "idem-stale-run-endpoint", IdempotencyKey: "idem-stale-run-endpoint",
}) })
if err == nil || !strings.Contains(err.Error(), "heartbeat is stale") { if err != nil {
t.Fatalf("expected stale Run endpoint rejection, got %v", err) t.Fatalf("platform build must ignore stale Run endpoint: %v", err)
} }
completeDistributionBuild(t, svc, distribution, nil)
actions, err := svc.GetServerRuntimeActionsForSession(session, instance.ID) actions, err := svc.GetServerRuntimeActionsForSession(session, instance.ID)
if err != nil { if err != nil {
t.Fatalf("get runtime actions: %v", err) t.Fatalf("get runtime actions: %v", err)
} }
for _, action := range actions.Actions { for _, action := range actions.Actions {
if action.Key == "generate-run" && action.Available { if action.Key == "generate-run" && !action.Available {
t.Fatalf("stale Run endpoint must not expose generate-run as available: %+v", action) t.Fatalf("stale Run endpoint must not gate platform build availability: %+v", action)
} }
} }
} }
@@ -206,14 +207,13 @@ func TestCoreServiceRuntimeActionsGateDependenciesOnPluginPermission(t *testing.
} }
} }
func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUpload(t *testing.T) { func TestCoreServiceMachineEndpointCannotClaimPlatformDistributionBuild(t *testing.T) {
t.Setenv("PLATFORM_RUN_RELEASE_URL", "https://scum.npc0.com")
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
TargetOS: "linux", TargetOS: "linux",
TargetArch: "amd64", TargetArch: "amd64",
IdempotencyKey: "idem-premature-result", IdempotencyKey: "idem-platform-build-ownership",
}) })
if err != nil { if err != nil {
t.Fatalf("generate run distribution: %v", err) t.Fatalf("generate run distribution: %v", err)
@@ -223,7 +223,7 @@ func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUplo
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityDistributionBuild) helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityDistributionBuild)
hello, err := svc.RegisterRunHello(helloRequest) hello, err := svc.RegisterRunHello(helloRequest)
if err != nil { if err != nil {
t.Fatalf("register build worker: %v", err) t.Fatalf("register machine endpoint: %v", err)
} }
claim, err := svc.ClaimRunJob(domain.RunJobClaim{ claim, err := svc.ClaimRunJob(domain.RunJobClaim{
RunEndpointID: instance.RunEndpointID, RunEndpointID: instance.RunEndpointID,
@@ -231,50 +231,21 @@ func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUplo
Capabilities: []string{domain.JobCapabilityDistributionBuild}, Capabilities: []string{domain.JobCapabilityDistributionBuild},
Capacity: domain.RunCapacity{MaxJobs: 1}, Capacity: domain.RunCapacity{MaxJobs: 1},
}) })
if err != nil || !claim.HasJob || claim.Job.JobID != distribution.BuildJobID {
t.Fatalf("claim distribution build job: claim=%+v err=%v", claim, err)
}
buildInput, err := svc.GetDistributionBuildInput(domain.DistributionBuildInputRequest{
RunEndpointID: claim.Job.RunEndpointID,
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
})
if err != nil { if err != nil {
t.Fatalf("get distribution build input: %v", err) t.Fatalf("claim machine-side build: %v", err)
} }
if buildInput.PlatformURL != "https://scum.npc0.com" || buildInput.PackageFormat != "raw-executable" || buildInput.AuthKey == "" || len(buildInput.AuthKey) < 80 { if claim.HasJob {
t.Fatalf("expected raw executable build input with release URL and long key, got %+v", buildInput) t.Fatalf("machine-side endpoint must not receive platform build job: %+v", claim)
}
result := domain.RunJobResult{
RunEndpointID: instance.RunEndpointID,
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: domain.JobStateSucceeded,
Progress: domain.RunJobProgressReport{Percent: 100, Message: "package_finalize: done"},
ResultRef: "artifact://" + distribution.ArtifactID,
Message: "done",
}
if _, err := svc.CompleteRunJob(result); err == nil {
t.Fatal("expected premature success without uploaded artifact to be rejected")
}
stored, err := svc.GetJob(distribution.BuildJobID)
if err != nil || stored.State != domain.JobStateAccepted {
t.Fatalf("premature success must not make the job terminal, job=%+v err=%v", stored, err)
} }
if _, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, []byte("actual compiled archive")); err != nil { distribution = completeDistributionBuild(t, svc, distribution, nil)
t.Fatalf("publish uploaded build output: %v", err) job, err := svc.GetJob(distribution.BuildJobID)
if err != nil || job.State != domain.JobStateSucceeded || job.RunEndpointID != platformDistributionBuilderEndpointID {
t.Fatalf("expected completed platform build job, job=%+v err=%v", job, err)
} }
if _, err := svc.CompleteRunJob(result); err != nil { artifact, err := svc.GetArtifact(distribution.ArtifactID)
t.Fatalf("retry success after artifact upload: %v", err) if err != nil || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != job.ID {
} t.Fatalf("expected build job-owned artifact, artifact=%+v err=%v", artifact, err)
stored, err = svc.GetJob(distribution.BuildJobID)
if err != nil || stored.State != domain.JobStateSucceeded {
t.Fatalf("expected terminal success after upload, job=%+v err=%v", stored, err)
} }
} }
@@ -348,6 +319,8 @@ func TestCoreServiceRunDistributionRetryReusesPartialArtifact(t *testing.T) {
func TestCoreServicePushRunUpdateReusesExistingUpdateJob(t *testing.T) { func TestCoreServicePushRunUpdateReusesExistingUpdateJob(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
payload := []byte("compiled run archive")
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
TargetOS: "linux", TargetOS: "linux",
@@ -357,7 +330,7 @@ func TestCoreServicePushRunUpdateReusesExistingUpdateJob(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("generate run distribution: %v", err) t.Fatalf("generate run distribution: %v", err)
} }
distribution = completeDistributionBuild(t, svc, distribution, []byte("compiled run archive")) distribution = completeDistributionBuild(t, svc, distribution, payload)
request := domain.RunUpdateRequest{ request := domain.RunUpdateRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
@@ -624,7 +597,6 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
t.Fatalf("update plugin fixture: %v", err) t.Fatalf("update plugin fixture: %v", err)
} }
endpoint.Capabilities = append(endpoint.Capabilities, endpoint.Capabilities = append(endpoint.Capabilities,
domain.JobCapabilityDistributionBuild,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall, domain.JobCapabilityDependenciesInstall,
@@ -704,58 +676,44 @@ func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string,
return generatedPackageConfig{} return generatedPackageConfig{}
} }
func completeDistributionBuild(t *testing.T, svc *CoreService, distribution domain.RunDistribution, payload []byte) domain.RunDistribution { func completeDistributionBuild(t *testing.T, svc *CoreService, distribution domain.RunDistribution, _ []byte) domain.RunDistribution {
t.Helper() t.Helper()
artifact, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, payload) deadline := time.Now().Add(time.Second)
if err != nil { for time.Now().Before(deadline) {
t.Fatalf("publish run build artifact: %v", err)
}
job, err := svc.GetJob(distribution.BuildJobID)
if err != nil {
t.Fatalf("get run build job: %v", err)
}
job.State = domain.JobStateSucceeded
job.Progress = domain.JobProgress{Percent: 100, Message: "package_finalize: build artifact available"}
job.ResultRef = "artifact://" + artifact.ID
job.UpdatedAt = svc.now()
if err := svc.store.Jobs().Update(job); err != nil {
t.Fatalf("update run build job: %v", err)
}
if err := svc.projectDistributionBuildResult(job, svc.now()); err != nil {
t.Fatalf("project run build: %v", err)
}
updated, err := svc.store.RunDistributions().Get(distribution.ID) updated, err := svc.store.RunDistributions().Get(distribution.ID)
if err != nil { if err != nil {
t.Fatalf("get completed run distribution: %v", err) t.Fatalf("get run distribution: %v", err)
} }
if updated.Status == domain.DistributionStatusAvailable {
return updated return updated
}
if updated.Status == domain.DistributionStatusFailed {
t.Fatalf("platform run build failed: %+v", updated)
}
time.Sleep(time.Millisecond)
}
t.Fatalf("platform run build did not complete")
return domain.RunDistribution{}
} }
func completeClientDistributionBuild(t *testing.T, svc *CoreService, distribution domain.ClientManagerDistribution, payload []byte) domain.ClientManagerDistribution { func completeClientDistributionBuild(t *testing.T, svc *CoreService, distribution domain.ClientManagerDistribution, _ []byte) domain.ClientManagerDistribution {
t.Helper() t.Helper()
artifact, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, payload) deadline := time.Now().Add(time.Second)
if err != nil { for time.Now().Before(deadline) {
t.Fatalf("publish client build artifact: %v", err)
}
job, err := svc.GetJob(distribution.BuildJobID)
if err != nil {
t.Fatalf("get client build job: %v", err)
}
job.State = domain.JobStateSucceeded
job.Progress = domain.JobProgress{Percent: 100, Message: "package_finalize: build artifact available"}
job.ResultRef = "artifact://" + artifact.ID
job.UpdatedAt = svc.now()
if err := svc.store.Jobs().Update(job); err != nil {
t.Fatalf("update client build job: %v", err)
}
if err := svc.projectDistributionBuildResult(job, svc.now()); err != nil {
t.Fatalf("project client build: %v", err)
}
updated, err := svc.store.ClientManagerDistributions().Get(distribution.ID) updated, err := svc.store.ClientManagerDistributions().Get(distribution.ID)
if err != nil { if err != nil {
t.Fatalf("get completed client distribution: %v", err) t.Fatalf("get client distribution: %v", err)
} }
if updated.Status == domain.DistributionStatusAvailable {
return updated return updated
}
if updated.Status == domain.DistributionStatusFailed {
t.Fatalf("platform client build failed: %+v", updated)
}
time.Sleep(time.Millisecond)
}
t.Fatalf("platform client build did not complete")
return domain.ClientManagerDistribution{}
} }
func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) { func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) {
+35 -2
View File
@@ -249,6 +249,9 @@ type CoreService struct {
aiProviderClient AIProviderClient aiProviderClient AIProviderClient
secretEnvelope SecretEnvelope secretEnvelope SecretEnvelope
networkFingerprintKey []byte networkFingerprintKey []byte
distributionBuilder DistributionBuilder
distributionBuildMu sync.Mutex
distributionBuilds map[string]struct{}
} }
var _ Core = (*CoreService)(nil) var _ Core = (*CoreService)(nil)
@@ -284,10 +287,34 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
aiProviderClient: MockAIProviderClient{}, aiProviderClient: MockAIProviderClient{},
secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey), secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey),
networkFingerprintKey: []byte(developmentSecretEnvelopeKey), networkFingerprintKey: []byte(developmentSecretEnvelopeKey),
distributionBuilder: unconfiguredDistributionBuilder{},
distributionBuilds: map[string]struct{}{},
} }
return service return service
} }
// ConfigureDistributionBuilder installs the platform-owned builder that
// executes distribution builds. Build execution is a platform responsibility,
// so a nil builder leaves the platform reporting an unconfigured builder rather
// than falling back to a machine-side run endpoint.
func (svc *CoreService) ConfigureDistributionBuilder(builder DistributionBuilder) {
if builder == nil {
builder = unconfiguredDistributionBuilder{}
}
svc.distributionBuildMu.Lock()
svc.distributionBuilder = builder
svc.distributionBuildMu.Unlock()
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: platformDistributionBuilderEndpointID})
if err != nil {
return
}
for _, job := range jobs {
if job.Capability == domain.JobCapabilityDistributionBuild && !isTerminalJobState(job.State) {
svc.enqueueDistributionBuild(job)
}
}
}
func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, artifactStore ArtifactBodyStore) (*CoreService, error) { func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, artifactStore ArtifactBodyStore) (*CoreService, error) {
if artifactStore == nil { if artifactStore == nil {
artifactStore = NewMemoryArtifactBodyStore() artifactStore = NewMemoryArtifactBodyStore()
@@ -2195,6 +2222,11 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
return domain.Job{}, err return domain.Job{}, err
} }
if job.Capability == domain.JobCapabilityDistributionBuild {
if job.RunEndpointID != platformDistributionBuilderEndpointID {
return domain.Job{}, validationError("distribution build job must target the platform builder")
}
} else {
endpoint, err := svc.store.RunEndpoints().Get(job.RunEndpointID) endpoint, err := svc.store.RunEndpoints().Get(job.RunEndpointID)
if err != nil { if err != nil {
return domain.Job{}, fmt.Errorf("get run endpoint dependency: %w", err) return domain.Job{}, fmt.Errorf("get run endpoint dependency: %w", err)
@@ -2202,6 +2234,7 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
if err := svc.validateRunnableEndpoint(endpoint, job.Capability); err != nil { if err := svc.validateRunnableEndpoint(endpoint, job.Capability); err != nil {
return domain.Job{}, err return domain.Job{}, err
} }
}
if job.ServerInstanceID != "" { if job.ServerInstanceID != "" {
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil { if err != nil {
@@ -2346,8 +2379,8 @@ func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plu
if instance.State == domain.ServerInstanceStateDeleted { if instance.State == domain.ServerInstanceStateDeleted {
return validationError("server instance must not be deleted") return validationError("server instance must not be deleted")
} }
usesDeploymentTarget := job.Capability == domain.JobCapabilityDistributionBuild && instance.DeploymentTargetID != "" && instance.DeploymentTargetID == job.RunEndpointID usesPlatformBuilder := job.Capability == domain.JobCapabilityDistributionBuild && job.RunEndpointID == platformDistributionBuilderEndpointID
if instance.RunEndpointID != job.RunEndpointID && !usesDeploymentTarget { if instance.RunEndpointID != job.RunEndpointID && !usesPlatformBuilder {
return validationError("job runEndpointId must match server instance") return validationError("job runEndpointId must match server instance")
} }
if plugin.ID != instance.PluginID { if plugin.ID != instance.PluginID {
+22 -1
View File
@@ -1407,7 +1407,28 @@ func TestCoreServicePropagatesDuplicateErrors(t *testing.T) {
} }
func newTestCoreService() *CoreService { func newTestCoreService() *CoreService {
return newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime }) svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime })
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: []byte("platform-built-distribution")})
return svc
}
type staticDistributionBuilder struct {
payload []byte
err error
}
func (builder staticDistributionBuilder) Readiness() (bool, string) {
if builder.err != nil {
return false, builder.err.Error()
}
return true, ""
}
func (builder staticDistributionBuilder) Build(domain.DistributionBuildInput) ([]byte, error) {
if builder.err != nil {
return nil, builder.err
}
return domain.CopyBytes(builder.payload), nil
} }
func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlugin, domain.RunEndpoint) { func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlugin, domain.RunEndpoint) {
+45 -12
View File
@@ -40,6 +40,7 @@ const fallbackFragments = [
async function main() { async function main() {
await mkdir(evidenceDir, { recursive: true }); await mkdir(evidenceDir, { recursive: true });
const smokeSeed = await loadSmokeSeed();
const session = await loginApi(); const session = await loginApi();
const authHeaders = { Authorization: `Bearer ${session.sessionId}` }; const authHeaders = { Authorization: `Bearer ${session.sessionId}` };
await ensureAiProvider(authHeaders); await ensureAiProvider(authHeaders);
@@ -47,7 +48,7 @@ async function main() {
const [instances, endpoints, jobs, plugins, marketplace, users, providers, logStreams, artifacts, usage] = await Promise.all([ const [instances, endpoints, jobs, plugins, marketplace, users, providers, logStreams, artifacts, usage] = await Promise.all([
getJson("/server-instances", authHeaders), getJson("/server-instances", authHeaders),
getJson("/run/endpoints?status=online", authHeaders), getJson("/run/endpoints?status=online", authHeaders),
getJson("/jobs?serverInstanceId=server-local-debug", authHeaders), getJson(`/jobs?serverInstanceId=${encodeURIComponent(smokeSeed.serverLocalId)}`, authHeaders),
getJson("/game-plugins", authHeaders), getJson("/game-plugins", authHeaders),
getJson("/plugin-marketplace/plugins", authHeaders), getJson("/plugin-marketplace/plugins", authHeaders),
getJson("/users", authHeaders), getJson("/users", authHeaders),
@@ -57,15 +58,18 @@ async function main() {
getJson("/metrics/platform", authHeaders) getJson("/metrics/platform", authHeaders)
]); ]);
const server = findRequired(instances.items, (item) => item.id === "server-local-debug", "server-local-debug instance"); const server = findRequired(instances.items, (item) => item.id === smokeSeed.serverLocalId, `${smokeSeed.serverLocalId} instance`);
const runEndpoint = findRequired(endpoints.items, (item) => item.id === "run-local-debug", "run-local-debug endpoint"); const runEndpoint = findRequired(endpoints.items, (item) => item.id === server.runEndpointId, `${server.runEndpointId} generated endpoint`);
const plugin = findRequired(plugins.items, (item) => item.id === "game.example", "game.example plugin"); const plugin = findRequired(plugins.items, (item) => item.id === "game.example", "game.example plugin");
const marketplacePlugin = findRequired(marketplace.items, (item) => item.id === "game.example", "game.example marketplace plugin"); const marketplacePlugin = findRequired(marketplace.items, (item) => item.id === "game.example", "game.example marketplace plugin");
const operator = findRequired(users.items, (item) => item.email === "operator.local@example.test", "operator local user"); const operator = findRequired(users.items, (item) => item.email === "operator.local@example.test", "operator local user");
const aiProvider = findRequired(providers.items, (item) => item.id === "ai.openai" || item.apiKeyConfigured === true, "redacted AI provider"); const aiProvider = findRequired(providers.items, (item) => item.id === "ai.openai" || item.apiKeyConfigured === true, "redacted AI provider");
assertEqual(server.pluginId, "game.example", "server is backed by game.example"); assertEqual(server.pluginId, "game.example", "server is backed by game.example");
assertEqual(server.runEndpointId, "run-local-debug", "server is assigned to run-local-debug"); assertEqual(server.runEndpointId, smokeSeed.generatedRunEndpointId, "server is assigned to its generated Run");
if (runEndpoint.capabilities.includes("distribution.build")) {
throw new Error("generated Run unexpectedly advertises distribution.build");
}
assertIncludes(runEndpoint.capabilities, "process.install", "run endpoint exposes process.install"); assertIncludes(runEndpoint.capabilities, "process.install", "run endpoint exposes process.install");
assertIncludes(runEndpoint.capabilities, "process.start", "run endpoint exposes process.start"); assertIncludes(runEndpoint.capabilities, "process.start", "run endpoint exposes process.start");
assertIncludes(runEndpoint.capabilities, "process.stop", "run endpoint exposes process.stop"); assertIncludes(runEndpoint.capabilities, "process.stop", "run endpoint exposes process.stop");
@@ -90,6 +94,7 @@ async function main() {
platformUrl, platformUrl,
webUrl, webUrl,
localDebugRoot, localDebugRoot,
smokeSeed,
seedEvidenceDir: path.join(localDebugRoot, "smoke"), seedEvidenceDir: path.join(localDebugRoot, "smoke"),
session: { session: {
userId: session.user.id, userId: session.user.id,
@@ -169,7 +174,7 @@ async function main() {
}, },
{ {
name: "服务器详情", name: "服务器详情",
hash: "#/servers/server-local-debug", hash: `#/servers/${encodeURIComponent(server.id)}`,
markers: [ markers: [
server.name, server.name,
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`, `${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`,
@@ -225,6 +230,28 @@ async function main() {
console.log(`evidence file: ${evidencePath}`); console.log(`evidence file: ${evidencePath}`);
} }
async function loadSmokeSeed() {
const configPath = path.join(localDebugRoot, "smoke", "run-build-config.env");
const contents = await readFile(configPath, "utf8");
const values = {};
for (const line of contents.split(/\r?\n/)) {
const separator = line.indexOf("=");
if (separator <= 0) continue;
values[line.slice(0, separator)] = line.slice(separator + 1);
}
for (const key of ["SMOKE_INVOCATION_ID", "SERVER_LOCAL_ID", "SCUM_ALPHA_ID", "SCUM_BETA_ID", "SCUM_DYNAMIC_ID", "GENERATED_RUN_ENDPOINT_ID"]) {
if (!values[key]) throw new Error(`smoke seed configuration is missing ${key}`);
}
return {
invocationId: values.SMOKE_INVOCATION_ID,
serverLocalId: values.SERVER_LOCAL_ID,
scumAlphaId: values.SCUM_ALPHA_ID,
scumBetaId: values.SCUM_BETA_ID,
scumDynamicId: values.SCUM_DYNAMIC_ID,
generatedRunEndpointId: values.GENERATED_RUN_ENDPOINT_ID
};
}
async function loginApi() { async function loginApi() {
const response = await postJson("/auth/login", { const response = await postJson("/auth/login", {
account: "operator.local@example.test", account: "operator.local@example.test",
@@ -362,9 +389,9 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
} }
} }
await chrome.evaluate(() => { await chrome.evaluate((serverID) => {
window.location.hash = "#/servers/server-local-debug"; window.location.hash = `#/servers/${encodeURIComponent(serverID)}`;
}); }, server.id);
await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`); await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`);
const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]); const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
const pluginLayout = await chrome.layoutSnapshot(); const pluginLayout = await chrome.layoutSnapshot();
@@ -438,7 +465,7 @@ async function clickAndVerify(chrome, buttonText, markers) {
} }
async function verifyServerQuickRuntimeMenu(chrome, label) { async function verifyServerQuickRuntimeMenu(chrome, label) {
const markers = ["生成 run", "下载 run", "推送更新", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"]; const markers = ["生成 run", "下载 run", "更新 run", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"];
await chrome.evaluate(() => { await chrome.evaluate(() => {
const trigger = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes("运行操作")); const trigger = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes("运行操作"));
if (!(trigger instanceof HTMLButtonElement)) { if (!(trigger instanceof HTMLButtonElement)) {
@@ -787,13 +814,13 @@ async function verifyLifecycleOperation(headers, server, chrome) {
if (result.job.capability !== expectedCapability) { if (result.job.capability !== expectedCapability) {
throw new Error(`lifecycle job used unexpected capability ${result.job.capability}`); throw new Error(`lifecycle job used unexpected capability ${result.job.capability}`);
} }
if (result.job.runEndpointId !== "run-local-debug") { if (result.job.runEndpointId !== currentServer.runEndpointId) {
throw new Error(`lifecycle job used unexpected run endpoint ${result.job.runEndpointId}`); throw new Error(`lifecycle job used unexpected run endpoint ${result.job.runEndpointId}`);
} }
const job = await waitForJob(headers, currentServer.id, result.job.id); const job = await waitForJob(headers, currentServer.id, result.job.id);
await chrome.navigate(`${webUrl}/#/servers/server-local-debug`); await chrome.navigate(`${webUrl}/#/servers/${encodeURIComponent(currentServer.id)}`);
await chrome.waitForText([currentServer.name, "操作历史"], "server detail after lifecycle operation"); await chrome.waitForText([currentServer.name, "操作历史"], "server detail after lifecycle operation");
const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]); const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]);
@@ -953,6 +980,8 @@ async function startChrome() {
const clipped = intersection(rectFromDomRect(rect), clipForElement(element)); const clipped = intersection(rectFromDomRect(rect), clipForElement(element));
const visibleWidth = Math.max(0, clipped.right - clipped.left); const visibleWidth = Math.max(0, clipped.right - clipped.left);
const visibleHeight = Math.max(0, clipped.bottom - clipped.top); const visibleHeight = Math.max(0, clipped.bottom - clipped.top);
const floatingMenu = element.closest(".runtime-action-popover");
const floatingMenuRect = floatingMenu?.getBoundingClientRect();
return { return {
tag: element.tagName.toLowerCase(), tag: element.tagName.toLowerCase(),
text: (element.textContent || element.getAttribute("aria-label") || "").trim().slice(0, 60), text: (element.textContent || element.getAttribute("aria-label") || "").trim().slice(0, 60),
@@ -961,7 +990,8 @@ async function startChrome() {
right: Math.round(clipped.right), right: Math.round(clipped.right),
bottom: Math.round(clipped.bottom), bottom: Math.round(clipped.bottom),
width: Math.round(visibleWidth), width: Math.round(visibleWidth),
height: Math.round(visibleHeight) height: Math.round(visibleHeight),
boundedFloatingMenu: Boolean(floatingMenuRect && floatingMenuRect.width <= 320 && floatingMenuRect.height <= 320)
}; };
}) })
.filter((control) => control.width > 0 && control.height > 0); .filter((control) => control.width > 0 && control.height > 0);
@@ -969,6 +999,9 @@ async function startChrome() {
const overlappingControls = []; const overlappingControls = [];
for (let index = 0; index < controls.length; index += 1) { for (let index = 0; index < controls.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < controls.length; otherIndex += 1) { for (let otherIndex = index + 1; otherIndex < controls.length; otherIndex += 1) {
if (controls[index].boundedFloatingMenu !== controls[otherIndex].boundedFloatingMenu && (controls[index].boundedFloatingMenu || controls[otherIndex].boundedFloatingMenu)) {
continue;
}
const left = Math.max(controls[index].left, controls[otherIndex].left); const left = Math.max(controls[index].left, controls[otherIndex].left);
const top = Math.max(controls[index].top, controls[otherIndex].top); const top = Math.max(controls[index].top, controls[otherIndex].top);
const right = Math.min(controls[index].right, controls[otherIndex].right); const right = Math.min(controls[index].right, controls[otherIndex].right);
@@ -0,0 +1,116 @@
/** @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GamePluginResponse } from "../api/types";
import { defaultServerCreateForm } from "../contracts/serverManagement";
import { minimalServerCreateRequestFromForm } from "../schemas/serverManagement";
import { ServerDeploymentWorkflow } from "./ServerDeploymentWorkflow";
const plugin: GamePluginResponse = {
id: "game.runtime",
name: "Runtime Game",
version: "1.0.0",
serverType: "runtime",
manifestRef: "artifact://runtime-manifest",
createFormSchemaRef: "schemas/create.json",
createFields: [{ key: "serverRoot", label: "服务器目录", type: "text", required: true }],
requiredRunCapabilities: ["process.install"],
declaredPermissions: ["server.create"],
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false },
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
bridgeActions: [],
pages: [],
tags: [],
aiPurposes: [],
productionLifecycle: { operations: ["install"], dependencyPolicy: "optional", approvalRequired: [] },
status: "installed",
runtimeProfiles: {
transportProfiles: [{ key: "rcon", kind: "rcon", targetKey: "rcon.password", capabilities: ["remote.run.rcon.command"] }],
lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install"], transportKeys: ["rcon"] }]
}
};
let root: Root | null = null;
let container: HTMLDivElement | null = null;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(async () => {
if (root) {
await act(async () => root?.unmount());
}
container?.remove();
root = null;
container = null;
});
describe("ServerDeploymentWorkflow", () => {
it("submits plugin type and server name as a minimal create request", async () => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
const initialForm = defaultServerCreateForm([plugin], []);
let submitted: ReturnType<typeof minimalServerCreateRequestFromForm> | undefined;
const onSubmit = vi.fn(async (form: typeof initialForm) => {
submitted = minimalServerCreateRequestFromForm(form, 17);
});
await act(async () => {
root?.render(
<ServerDeploymentWorkflow
open
kind="create"
plugins={[plugin]}
endpoints={[]}
initialForm={initialForm}
onClose={() => undefined}
onSubmit={onSubmit}
/>
);
});
expect(container.querySelector('select[name="pluginId"]')).not.toBeNull();
expect(container.querySelector('input[name="name"]')).not.toBeNull();
for (const field of ["deploymentTargetId", "runEndpointId", "profileKey", "serverRoot", "startCommand"]) {
expect(container.querySelector(`[name="${field}"]`)).toBeNull();
}
expect(container.textContent).not.toContain("运行连接设置");
expect(container.querySelector('select[name="deploymentMode"]')).toBeNull();
const nameInput = container.querySelector<HTMLInputElement>('input[name="name"]');
if (!nameInput) throw new Error("server name input not found");
await act(async () => {
setInputValue(nameInput, "Minimal Runtime Server");
});
await submitWorkflow(container);
expect(container.textContent).toContain("本次只创建服务器记录");
expect(container.textContent).toContain("Minimal Runtime Server");
await submitWorkflow(container);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(submitted).toEqual({
id: "server-minimal-runtime-server-17",
pluginId: "game.runtime",
name: "Minimal Runtime Server",
idempotencyKey: "web:create:server-minimal-runtime-server-17:17"
});
});
});
async function submitWorkflow(target: HTMLElement) {
const form = target.querySelector<HTMLFormElement>('form[aria-label="创建服务器部署向导"]');
if (!form) throw new Error("create workflow form not found");
await act(async () => {
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
});
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
@@ -3,7 +3,7 @@ import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "
import type { GamePluginResponse, RunEndpointResponse, ServerDeploymentResponse, ServerDeploymentRevealResponse } from "../api/types"; import type { GamePluginResponse, RunEndpointResponse, ServerDeploymentResponse, ServerDeploymentRevealResponse } from "../api/types";
import { ManagementDialog } from "./OperationControls"; import { ManagementDialog } from "./OperationControls";
import { endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, type ServerCreateFormState } from "../contracts/serverManagement"; import { endpointLabel, pluginCreateInputDefaults, pluginLabel, type ServerCreateFormState } from "../contracts/serverManagement";
import { cx } from "../utils/classes"; import { cx } from "../utils/classes";
type WorkflowKind = "create" | "edit"; type WorkflowKind = "create" | "edit";
@@ -18,37 +18,32 @@ interface ServerDeploymentWorkflowProps {
busy?: boolean; busy?: boolean;
onReveal?: () => Promise<ServerDeploymentRevealResponse>; onReveal?: () => Promise<ServerDeploymentRevealResponse>;
onClose: () => void; onClose: () => void;
onSubmit: (form: ServerCreateFormState, saveAsDraft: boolean) => Promise<void>; onSubmit: (form: ServerCreateFormState) => Promise<void>;
} }
export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initialForm, deployment, busy = false, onReveal, onClose, onSubmit }: ServerDeploymentWorkflowProps) { export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initialForm, deployment, busy = false, onReveal, onClose, onSubmit }: ServerDeploymentWorkflowProps) {
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [form, setForm] = useState<ServerCreateFormState>(initialForm); const [form, setForm] = useState<ServerCreateFormState>(initialForm);
const [saveAsDraft, setSaveAsDraft] = useState(false);
const [revealBusy, setRevealBusy] = useState(false); const [revealBusy, setRevealBusy] = useState(false);
const [revealError, setRevealError] = useState(""); const [revealError, setRevealError] = useState("");
const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]); const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]);
const profileOptions = selectedPlugin?.runtimeProfiles?.lifecycleProfiles ?? [];
const pluginFields = selectedPlugin?.createFields ?? []; const pluginFields = selectedPlugin?.createFields ?? [];
const bindingFields = runtimeBindingFields(selectedPlugin, form.profileKey);
const activeServer = kind === "edit" && Boolean(deployment);
const isScum = selectedPlugin?.id === "game.scum"; const isScum = selectedPlugin?.id === "game.scum";
const needsTargetSelection = kind === "create" || !initialForm.runEndpointId; const needsTargetSelection = kind === "edit" && !initialForm.runEndpointId;
const selectedTargetID = kind === "create" ? form.deploymentTargetId : form.runEndpointId; const selectedTargetID = form.runEndpointId;
const workflowSteps = kind === "create" const workflowSteps = kind === "create"
? [{ label: "选择目标", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }] ? [{ label: "基本信息", icon: Compass }, { label: "确认", icon: Rocket }]
: needsTargetSelection : needsTargetSelection
? [{ label: "选择运行节点", icon: Compass }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }] ? [{ label: "选择运行节点", icon: Compass }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]
: [{ label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]; : [{ label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }];
const pluginStep = kind === "create" ? 0 : -1;
const targetStep = needsTargetSelection ? 0 : -1; const targetStep = needsTargetSelection ? 0 : -1;
const modeStep = kind === "create" ? 1 : -1; const configurationStep = kind === "create" ? -1 : needsTargetSelection ? 1 : 0;
const configurationStep = kind === "create" ? 2 : needsTargetSelection ? 1 : 0;
const reviewStep = workflowSteps.length - 1; const reviewStep = workflowSteps.length - 1;
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setStep(0); setStep(0);
setSaveAsDraft(kind === "create" && !initialForm.runEndpointId);
setForm(initialForm); setForm(initialForm);
setRevealBusy(false); setRevealBusy(false);
setRevealError(""); setRevealError("");
@@ -72,12 +67,11 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
} }
function updateCreateInput(key: string, value: string) { setForm((current) => ({ ...current, createInputs: { ...current.createInputs, [key]: value } })); } function updateCreateInput(key: string, value: string) { setForm((current) => ({ ...current, createInputs: { ...current.createInputs, [key]: value } })); }
function updateBinding(key: string, value: string) { setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } })); }
function canContinue() { function canContinue() {
if (step === targetStep) return kind === "create" ? Boolean(form.pluginId && (saveAsDraft || form.deploymentTargetId)) : Boolean(form.runEndpointId); if (step === pluginStep) return Boolean(form.pluginId) && Boolean(form.name.trim());
if (step === targetStep) return Boolean(form.runEndpointId);
if (step === configurationStep) { if (step === configurationStep) {
if (kind === "create" && !form.name.trim()) return false;
if (isScum && form.deploymentMode === "guided-install" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false; if (isScum && form.deploymentMode === "guided-install" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false;
if (form.deploymentMode === "existing-server" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false; if (form.deploymentMode === "existing-server" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false;
if (form.deploymentMode === "custom-command" && !form.startCommand.trim() && !deployment?.startCommandConfigured) return false; if (form.deploymentMode === "custom-command" && !form.startCommand.trim() && !deployment?.startCommandConfigured) return false;
@@ -89,7 +83,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
async function submit(event: FormEvent<HTMLFormElement>) { async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (step < reviewStep) { if (canContinue()) setStep((current) => current + 1); return; } if (step < reviewStep) { if (canContinue()) setStep((current) => current + 1); return; }
await onSubmit({ ...form, deploymentTargetId: saveAsDraft ? "" : form.deploymentTargetId, runEndpointId: saveAsDraft ? "" : form.runEndpointId }, saveAsDraft); await onSubmit(form);
} }
async function revealSavedInputs() { async function revealSavedInputs() {
@@ -114,24 +108,20 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
} }
const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置"; const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置";
const actionLabel = kind === "create" ? "保存草稿并准备专属 Run" : "保存部署设置"; const actionLabel = kind === "create" ? "创建服务器" : "保存部署设置";
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "按部署顺序完成设置;路径和命令始终受保护,不会在确认页或日志中回显。" : "仅停止中的服务器可以修改部署设置。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}> return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "只需选择插件类型并填写服务器名称;运行配置、部署方式和目录可在创建后的服务器详情中按需补充。" : "仅停止中的服务器可以修改部署设置。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}>
<form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}> <form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
<ol className="deployment-workflow-steps" style={{ gridTemplateColumns: `repeat(${workflowSteps.length}, minmax(0, 1fr))` }} aria-label="部署步骤">{workflowSteps.map((item, index) => { const Icon = item.icon; return <li key={item.label} className={cx(index === step && "deployment-workflow-step-active", index < step && "deployment-workflow-step-complete")}><span>{index < step ? <CheckCircle2 size={15} /> : <Icon size={15} />}</span><strong>{index + 1}. {item.label}</strong></li>; })}</ol> <ol className="deployment-workflow-steps" style={{ gridTemplateColumns: `repeat(${workflowSteps.length}, minmax(0, 1fr))` }} aria-label="部署步骤">{workflowSteps.map((item, index) => { const Icon = item.icon; return <li key={item.label} className={cx(index === step && "deployment-workflow-step-active", index < step && "deployment-workflow-step-complete")}><span>{index < step ? <CheckCircle2 size={15} /> : <Icon size={15} />}</span><strong>{index + 1}. {item.label}</strong></li>; })}</ol>
{step === pluginStep && <div className="deployment-workflow-body">
<div className="workflow-hint-grid"><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong> Run</strong><span></span></div></div>
<div className="form-grid"><label><select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label></div>
</div>}
{step === targetStep && <div className="deployment-workflow-body"> {step === targetStep && <div className="deployment-workflow-body">
{kind === "create" ? <div className="workflow-hint-grid"><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong></strong><span> Run Run</span></div><div className="workflow-hint-card"><strong> Run</strong><span>稿 Run</span></div></div> : <div className="form-guidance"><strong>稿</strong><span></span></div>} <div className="form-guidance"><strong>稿</strong><span></span></div>
<div className="form-grid">{kind === "create" && <label><select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label>}<label>{kind === "create" ? "部署目标" : "运行节点"}<select name={kind === "create" ? "deploymentTargetId" : "runEndpointId"} value={selectedTargetID} onChange={updateForm} disabled={kind === "create" && saveAsDraft} required={kind !== "create" || !saveAsDraft}><option value="">{kind === "create" ? "请选择部署目标" : "请选择运行节点"}</option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label></div> <div className="form-grid"><label><select name="runEndpointId" value={selectedTargetID} onChange={updateForm} required><option value=""></option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label></div>
{kind === "create" && <label className="deployment-draft-choice"><input type="checkbox" checked={saveAsDraft} onChange={(event) => setSaveAsDraft(event.target.checked)} /><span><strong></strong><small>稿 Run</small></span></label>}
</div>} </div>}
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy"></p>{isScum && <div className="form-guidance"><strong>SCUM </strong><span>Run </span></div>}<div className="deployment-mode-grid">
<ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} />
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由节点策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
</div></div>}
{step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong></strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}></button></>}</div>}<div className="form-grid"> {step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong></strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}></button></>}</div>}<div className="form-grid">
{kind === "create" && <label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label>}
{kind === "create" && <label><select name="profileKey" value={form.profileKey} onChange={updateForm}><option value="">使</option>{profileOptions.map((profile) => <option key={profile.key} value={profile.key}>{profile.key} · {profile.mode}</option>)}</select><small className="field-help"></small></label>}
{kind === "edit" && <label><select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install"></option><option value="existing-server"></option><option value="custom-command"></option></select><small className="field-help"></small></label>} {kind === "edit" && <label><select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install"></option><option value="existing-server"></option><option value="custom-command"></option></select><small className="field-help"></small></label>}
{form.deploymentMode === "guided-install" && <label>{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">SCUM </small></label>} {form.deploymentMode === "guided-install" && <label>{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">SCUM </small></label>}
{form.deploymentMode === "existing-server" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run </small></label>} {form.deploymentMode === "existing-server" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run </small></label>}
@@ -147,16 +137,13 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
{form.deploymentMode === "guided-install" && <GuidedInstallPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />} {form.deploymentMode === "guided-install" && <GuidedInstallPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />}
{form.deploymentMode === "existing-server" && <ExistingServerAdoptionPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />} {form.deploymentMode === "existing-server" && <ExistingServerAdoptionPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />}
{form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help"></p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label><select name="shell" value={form.shell} onChange={updateForm}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>} {form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help"></p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label><select name="shell" value={form.shell} onChange={updateForm}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>}
{kind === "create" && bindingFields.length > 0 && <details className="provider-advanced-settings"><summary></summary><p className="field-help"></p><div className="form-grid">{bindingFields.map((field) => <label key={field.key}>{field.key}{field.required ? "(必填)" : ""}<input type={field.sensitive ? "password" : "text"} autoComplete="off" value={form.bindings[field.key] ?? ""} onChange={(event) => updateBinding(field.key, event.target.value)} placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"} required={field.required} /></label>)}</div></details>}
</div>} </div>}
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span>{kind === "create" ? "部署目标" : "目标"}</span><strong>{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{kind === "create" ? "本次保存草稿并保留专属 Run" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "随后生成并启动专属 Run;新建并安装模式会在它注册后自动部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}</span></div></div>} {step === reviewStep && (kind === "create" ? <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span></span><strong>{form.name.trim() || "未填写"}</strong></div></div><div className="form-guidance"><strong></strong><span> Run</span></div></div> : <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong></strong><span>{form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}</span></div></div>)}
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? closeWorkflow() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div> <div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? closeWorkflow() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div>
</form> </form>
</ManagementDialog>; </ManagementDialog>;
} }
function ModeOption({ active, title, copy, onClick }: { active: boolean; title: string; copy: string; onClick: () => void }) { return <button type="button" className={cx("deployment-mode-option", active && "deployment-mode-option-active")} onClick={onClick}><strong>{title}</strong><span>{copy}</span></button>; }
function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) { function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) {
const steps = isScum ? [ const steps = isScum ? [
{ icon: ScanSearch, title: "预检目录与端口", copy: "确认安装目录可用、节点兼容且端口可绑定。" }, { icon: ScanSearch, title: "预检目录与端口", copy: "确认安装目录可用、节点兼容且端口可绑定。" },
+536
View File
@@ -16,11 +16,218 @@
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react-swc": "4.3.1", "@vitejs/plugin-react-swc": "4.3.1",
"jsdom": "29.1.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "7.3.1", "vite": "7.3.1",
"vitest": "4.0.18" "vitest": "4.0.18"
} }
}, },
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.10",
"resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.7",
"resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -463,6 +670,24 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmmirror.com/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -1244,6 +1469,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
@@ -1254,6 +1489,20 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -1261,6 +1510,40 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1363,6 +1646,78 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "1.23.0", "version": "1.23.0",
"resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.23.0.tgz", "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.23.0.tgz",
@@ -1382,6 +1737,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
@@ -1412,6 +1774,19 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
@@ -1469,6 +1844,16 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
@@ -1491,6 +1876,16 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz",
@@ -1536,6 +1931,19 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
@@ -1573,6 +1981,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
@@ -1617,6 +2032,52 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tldts": {
"version": "7.4.9",
"resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.9.tgz",
"integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.9"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.9",
"resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.9.tgz",
"integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -1631,6 +2092,16 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz",
@@ -1785,6 +2256,54 @@
} }
} }
}, },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -1801,6 +2320,23 @@
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
} }
} }
} }
+1
View File
@@ -20,6 +20,7 @@
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react-swc": "4.3.1", "@vitejs/plugin-react-swc": "4.3.1",
"jsdom": "29.1.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "7.3.1", "vite": "7.3.1",
"vitest": "4.0.18" "vitest": "4.0.18"
+21 -8
View File
@@ -168,17 +168,16 @@ describe("first-party console pages", () => {
expect(serversPageSource).toContain("旧 run 会话已失效"); expect(serversPageSource).toContain("旧 run 会话已失效");
}); });
it("uses a staged deployment workflow for create and edit without exposing protected inputs", () => { it("separates minimal creation from post-create deployment editing without exposing protected inputs", () => {
expect(serversPageSource).toContain("<ServerDeploymentWorkflow"); expect(serversPageSource).toContain("<ServerDeploymentWorkflow");
expect(serversPageSource).toContain("openEditDeployment"); expect(serversPageSource).toContain("openEditDeployment");
expect(serversPageSource).toContain("编辑部署"); expect(serversPageSource).toContain("编辑部署");
expect(serverDetailPageSource).toContain("<ServerDeploymentWorkflow"); expect(serverDetailPageSource).toContain("<ServerDeploymentWorkflow");
expect(serverDeploymentWorkflowSource).toContain("选择目标"); expect(serverDeploymentWorkflowSource).toContain("基本信息");
expect(serverDeploymentWorkflowSource).toContain("部署方式"); expect(serverDeploymentWorkflowSource).toContain("部署方式");
expect(serverDeploymentWorkflowSource).toContain("相关配置"); expect(serverDeploymentWorkflowSource).toContain("相关配置");
expect(serverDeploymentWorkflowSource).toContain("选择部署目标");
expect(serverDeploymentWorkflowSource).toContain("专属 Run"); expect(serverDeploymentWorkflowSource).toContain("专属 Run");
expect(serverDeploymentWorkflowSource).toContain("保存草稿并准备专属 Run"); expect(serverDeploymentWorkflowSource).toContain("创建服务器");
expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)"); expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)");
expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录"); expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录");
expect(serverDeploymentWorkflowSource).toContain("安装目录{isScum ? \"(必填)\" : \"(可选)\"}"); expect(serverDeploymentWorkflowSource).toContain("安装目录{isScum ? \"(必填)\" : \"(可选)\"}");
@@ -193,14 +192,14 @@ describe("first-party console pages", () => {
expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的受控升级任务"); expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的受控升级任务");
expect(serverDeploymentWorkflowSource).toContain("已绑定服务器编辑时会直接进入相关配置"); expect(serverDeploymentWorkflowSource).toContain("已绑定服务器编辑时会直接进入相关配置");
expect(serverDeploymentWorkflowSource).toContain("可在此调整部署方式;不会重复要求选择已绑定的运行节点"); expect(serverDeploymentWorkflowSource).toContain("可在此调整部署方式;不会重复要求选择已绑定的运行节点");
expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" })'); expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })');
expect(serverDeploymentWorkflowSource).toContain("运行连接设置"); expect(serverDetailPageSource).toContain("运行配置绑定");
expect(serverDeploymentWorkflowSource).toContain('type={field.sensitive ? "password" : "text"}'); expect(serverDetailPageSource).toContain('type={field.sensitive ? "password" : "text"}');
expect(serverDeploymentWorkflowSource).toContain("显示已保存配置"); expect(serverDeploymentWorkflowSource).toContain("显示已保存配置");
expect(serverDeploymentWorkflowSource).toContain("revealSavedInputs"); expect(serverDeploymentWorkflowSource).toContain("revealSavedInputs");
expect(serversPageSource).toContain("revealServerDeployment"); expect(serversPageSource).toContain("revealServerDeployment");
expect(serverDetailPageSource).toContain("最近 Run 调度"); expect(serverDetailPageSource).toContain("最近 Run 调度");
expect(serversPageSource).toContain("serverCreateRequestFromForm(nextForm)"); expect(serversPageSource).toContain("minimalServerCreateRequestFromForm(nextForm)");
expect(serverDeploymentWorkflowSource).not.toContain('name="id"'); expect(serverDeploymentWorkflowSource).not.toContain('name="id"');
expect(serverDeploymentWorkflowSource).not.toContain("实例 ID"); expect(serverDeploymentWorkflowSource).not.toContain("实例 ID");
for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://"]) { for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://"]) {
@@ -208,6 +207,20 @@ describe("first-party console pages", () => {
} }
}); });
it("keeps create-only controls structurally minimal", () => {
expect(serverDeploymentWorkflowSource).not.toContain('name="deploymentTargetId"');
expect(serverDeploymentWorkflowSource).not.toContain("请选择部署目标");
expect(serverDeploymentWorkflowSource).not.toContain("saveAsDraft");
expect(serverDeploymentWorkflowSource).not.toContain("暂不指定部署目标");
expect(serverDeploymentWorkflowSource).toContain('if (step === pluginStep) return Boolean(form.pluginId) && Boolean(form.name.trim());');
expect(serverDeploymentWorkflowSource).toContain("await onSubmit(form)");
expect(serverDeploymentWorkflowSource).not.toContain('kind === "create" && <label>运行预设');
expect(serverDeploymentWorkflowSource).not.toContain('kind === "create" && bindingFields');
expect(serverDeploymentWorkflowSource).toContain("插件类型和服务器名称是创建时仅有的必填信息");
expect(serverDeploymentWorkflowSource).toContain('const needsTargetSelection = kind === "edit" && !initialForm.runEndpointId;');
expect(serversPageSource).toContain("minimalServerCreateRequestFromForm(nextForm)");
});
it("renders server runtime actions as a compact popover trigger instead of an in-card details stack", () => { it("renders server runtime actions as a compact popover trigger instead of an in-card details stack", () => {
expect(serversPageSource).toContain('aria-haspopup="menu"'); expect(serversPageSource).toContain('aria-haspopup="menu"');
expect(serversPageSource).toContain("createPortal"); expect(serversPageSource).toContain("createPortal");
+4 -4
View File
@@ -35,7 +35,7 @@ import {
logBackfillRequest, logBackfillRequest,
runDistributionGenerateRequest, runDistributionGenerateRequest,
runUpdateRequest, runUpdateRequest,
serverCreateRequestFromForm, minimalServerCreateRequestFromForm,
serverDeleteConfirmation serverDeleteConfirmation
} from "../schemas/serverManagement"; } from "../schemas/serverManagement";
import { isPlatformAdmin } from "../contracts/workspace"; import { isPlatformAdmin } from "../contracts/workspace";
@@ -156,12 +156,12 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
async function handleCreate(nextForm: ServerCreateFormState) { async function handleCreate(nextForm: ServerCreateFormState) {
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName }); const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
try { try {
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(nextForm)); const result = await platformApiClient.createServerWorkflow(minimalServerCreateRequestFromForm(nextForm));
operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `保存草稿 ${result.instance.id}请生成并启动专属 Run,注册成功后平台会自动部署`, result.job.id ? result.job : undefined); operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `创建服务器 ${result.instance.id}可在详情页按需补充运行配置和部署设置,再生成专属 Run`, result.job.id ? result.job : undefined);
setForm(defaultServerCreateForm(plugins, endpoints)); setForm(defaultServerCreateForm(plugins, endpoints));
setShowCreate(false); setShowCreate(false);
await refresh(); await refresh();
onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" }); onNavigate("serverDetail", { serverId: result.instance.id });
} catch (error) { } catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId); operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId);
} }
+1158
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { GamePluginResponse } from "../api/types"; import type { GamePluginResponse } from "../api/types";
import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement"; import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
import { serverCreateRequestFromForm, serverInstanceIdFromName } from "./serverManagement"; import { minimalServerCreateRequestFromForm, serverCreateRequestFromForm, serverInstanceIdFromName } from "./serverManagement";
const plugin: GamePluginResponse = { const plugin: GamePluginResponse = {
id: "game.runtime", id: "game.runtime",
@@ -83,6 +83,25 @@ describe("runtime profile server creation contracts", () => {
}); });
}); });
it("maps the create UI to a minimal server request", () => {
const form = defaultServerCreateForm([plugin], []);
const request = minimalServerCreateRequestFromForm({
...form,
name: " Minimal Runtime Server ",
deploymentTargetId: "run-builder",
runEndpointId: "run-existing",
bindings: { "rcon.password": "secret://must-not-submit" },
serverRoot: "/srv/must-not-submit"
}, 16);
expect(request).toEqual({
id: "server-minimal-runtime-server-16",
pluginId: "game.runtime",
name: "Minimal Runtime Server",
idempotencyKey: "web:create:server-minimal-runtime-server-16:16"
});
});
it("generates server instance ids from the visible server name", () => { it("generates server instance ids from the visible server name", () => {
const form = defaultServerCreateForm([plugin], []); const form = defaultServerCreateForm([plugin], []);
const request = serverCreateRequestFromForm({ ...form, name: " Runtime Server ", bindings: {} }, 17); const request = serverCreateRequestFromForm({ ...form, name: " Runtime Server ", bindings: {} }, 17);
+10
View File
@@ -11,6 +11,16 @@ import type {
} from "../api/types"; } from "../api/types";
import type { ServerCreateFormState, ServerMetadataFormState, ServerRemovalConfirmationState } from "../contracts/serverManagement"; import type { ServerCreateFormState, ServerMetadataFormState, ServerRemovalConfirmationState } from "../contracts/serverManagement";
export function minimalServerCreateRequestFromForm(form: ServerCreateFormState, sequence = Date.now()): ServerLifecycleCreateRequest {
const id = form.id.trim() || serverInstanceIdFromName(form.name, sequence);
return {
id,
pluginId: form.pluginId.trim(),
name: form.name.trim(),
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence)
};
}
export function serverCreateRequestFromForm(form: ServerCreateFormState, sequence = Date.now()): ServerLifecycleCreateRequest { export function serverCreateRequestFromForm(form: ServerCreateFormState, sequence = Date.now()): ServerLifecycleCreateRequest {
const id = form.id.trim() || serverInstanceIdFromName(form.name, sequence); const id = form.id.trim() || serverInstanceIdFromName(form.name, sequence);
return { return {
+1 -1
View File
@@ -368,7 +368,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.icon-command:disabled,.primary-command:disabled{opacity:.55;cursor:not-allowed;box-shadow:inset 0 1px 0 var(--crystal-rim)} .icon-command:disabled,.primary-command:disabled{opacity:.55;cursor:not-allowed;box-shadow:inset 0 1px 0 var(--crystal-rim)}
.danger-command{border-color:rgba(214,51,92,.5);color:var(--danger)} .danger-command{border-color:rgba(214,51,92,.5);color:var(--danger)}
.danger-command:focus-visible,.danger-command:hover{border-color:var(--danger)} .danger-command:focus-visible,.danger-command:hover{border-color:var(--danger)}
.runtime-action-popover{position:fixed;z-index:45;display:grid;gap:10px;max-height:min(320px,calc(100vh - 24px));padding:10px;overflow:auto;border:1px solid color-mix(in srgb,var(--line-strong) 68%,rgba(255,255,255,.2));border-radius:8px;background:var(--corner-sparkle),linear-gradient(145deg,color-mix(in srgb,var(--surface-solid) 94%,rgba(255,255,255,.04)),color-mix(in srgb,var(--surface-solid) 86%,var(--accent-soft)) 72%,color-mix(in srgb,var(--surface-solid) 94%,#000 8%));background-size:48px 48px,auto;background-repeat:no-repeat,no-repeat;background-position:right 6px top 4px,center;-webkit-backdrop-filter:blur(18px) saturate(1.1);backdrop-filter:blur(18px) saturate(1.1);box-shadow:var(--jelly-inset),inset 0 0 0 1px color-mix(in srgb,var(--diamond-line) 52%,transparent),0 18px 42px rgba(0,0,0,.36),0 0 18px color-mix(in srgb,var(--accent) 18%,transparent)} .runtime-action-popover{position:fixed;z-index:45;display:grid;box-sizing:border-box;gap:10px;max-height:min(320px,calc(100vh - 24px));padding:10px;overflow:auto;border:1px solid color-mix(in srgb,var(--line-strong) 68%,rgba(255,255,255,.2));border-radius:8px;background:var(--corner-sparkle),linear-gradient(145deg,color-mix(in srgb,var(--surface-solid) 94%,rgba(255,255,255,.04)),color-mix(in srgb,var(--surface-solid) 86%,var(--accent-soft)) 72%,color-mix(in srgb,var(--surface-solid) 94%,#000 8%));background-size:48px 48px,auto;background-repeat:no-repeat,no-repeat;background-position:right 6px top 4px,center;-webkit-backdrop-filter:blur(18px) saturate(1.1);backdrop-filter:blur(18px) saturate(1.1);box-shadow:var(--jelly-inset),inset 0 0 0 1px color-mix(in srgb,var(--diamond-line) 52%,transparent),0 18px 42px rgba(0,0,0,.36),0 0 18px color-mix(in srgb,var(--accent) 18%,transparent)}
.runtime-action-group{display:grid;gap:6px} .runtime-action-group{display:grid;gap:6px}
.runtime-action-group-label{color:color-mix(in srgb,var(--ink) 76%,var(--accent-deep));font-size:11px;font-weight:850;line-height:1.1;text-shadow:0 1px 2px rgba(0,0,0,.5)} .runtime-action-group-label{color:color-mix(in srgb,var(--ink) 76%,var(--accent-deep));font-size:11px;font-weight:850;line-height:1.1;text-shadow:0 1px 2px rgba(0,0,0,.5)}
.runtime-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px} .runtime-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
+25 -1
View File
@@ -25,11 +25,12 @@ export PLATFORM_BOOTSTRAP_ADMIN_EMAIL="${PLATFORM_BOOTSTRAP_ADMIN_EMAIL:-operato
export PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="${PLATFORM_BOOTSTRAP_ADMIN_PASSWORD:-operator-local}" export PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="${PLATFORM_BOOTSTRAP_ADMIN_PASSWORD:-operator-local}"
export PLATFORM_SECRET_ENVELOPE_KEY="${PLATFORM_SECRET_ENVELOPE_KEY:-local-debug-secret-envelope-key-change-me}" export PLATFORM_SECRET_ENVELOPE_KEY="${PLATFORM_SECRET_ENVELOPE_KEY:-local-debug-secret-envelope-key-change-me}"
export PLATFORM_AI_PROVIDER_MODE="${PLATFORM_AI_PROVIDER_MODE:-mock}" export PLATFORM_AI_PROVIDER_MODE="${PLATFORM_AI_PROVIDER_MODE:-mock}"
export PLATFORM_RUN_RELEASE_URL="${PLATFORM_RUN_RELEASE_URL:-http://127.0.0.1:$LOCAL_DEBUG_PLATFORM_PORT}"
export RUN_SOURCE_DIR="${RUN_SOURCE_DIR:-${RUN_REPO_DIR:-$LOCAL_DEBUG_ROOT_DIR/run}}" export RUN_SOURCE_DIR="${RUN_SOURCE_DIR:-${RUN_REPO_DIR:-$LOCAL_DEBUG_ROOT_DIR/run}}"
export RUN_REPO_DIR="$RUN_SOURCE_DIR" export RUN_REPO_DIR="$RUN_SOURCE_DIR"
export RUN_MODE="${RUN_MODE:-worker}" export RUN_MODE="${RUN_MODE:-worker}"
export RUN_PLATFORM_URL="${RUN_PLATFORM_URL:-https://scum.npc0.com:$LOCAL_DEBUG_PLATFORM_PORT}" export RUN_PLATFORM_URL="${RUN_PLATFORM_URL:-http://127.0.0.1:$LOCAL_DEBUG_PLATFORM_PORT}"
export RUN_ENDPOINT_ID="${RUN_ENDPOINT_ID:-run-local-debug}" export RUN_ENDPOINT_ID="${RUN_ENDPOINT_ID:-run-local-debug}"
export RUN_DISPLAY_NAME="${RUN_DISPLAY_NAME:-Local Debug Run}" export RUN_DISPLAY_NAME="${RUN_DISPLAY_NAME:-Local Debug Run}"
export RUN_VERSION="${RUN_VERSION:-0.1.0-local-debug}" export RUN_VERSION="${RUN_VERSION:-0.1.0-local-debug}"
@@ -38,6 +39,11 @@ export RUN_WORKSPACE_ROOT="${RUN_WORKSPACE_ROOT:-$LOCAL_DEBUG_ROOT/run/workspace
export RUN_BUILD_BUCKET_ROOT="${RUN_BUILD_BUCKET_ROOT:-$LOCAL_DEBUG_ROOT/run/build-buckets}" export RUN_BUILD_BUCKET_ROOT="${RUN_BUILD_BUCKET_ROOT:-$LOCAL_DEBUG_ROOT/run/build-buckets}"
export RUN_BUILD_SOURCE_ROOT="${RUN_BUILD_SOURCE_ROOT:-$RUN_BUILD_BUCKET_ROOT/source/current}" export RUN_BUILD_SOURCE_ROOT="${RUN_BUILD_SOURCE_ROOT:-$RUN_BUILD_BUCKET_ROOT/source/current}"
export RUN_BOOTSTRAP_BIN="${RUN_BOOTSTRAP_BIN:-$RUN_BUILD_BUCKET_ROOT/bootstrap/bin/run}" export RUN_BOOTSTRAP_BIN="${RUN_BOOTSTRAP_BIN:-$RUN_BUILD_BUCKET_ROOT/bootstrap/bin/run}"
export PLATFORM_BUILDER_DOCKER_BINARY="${PLATFORM_BUILDER_DOCKER_BINARY:-docker}"
export PLATFORM_BUILDER_IMAGE="${PLATFORM_BUILDER_IMAGE:-browser-platform-distribution-builder:1.0.0}"
export PLATFORM_BUILDER_SOURCE_DIR="${PLATFORM_BUILDER_SOURCE_DIR:-$RUN_BUILD_SOURCE_ROOT}"
export PLATFORM_BUILDER_WORKSPACE_DIR="${PLATFORM_BUILDER_WORKSPACE_DIR:-$PLATFORM_DATA_DIR/distribution-builds}"
export PLATFORM_BUILDER_TIMEOUT_SECONDS="${PLATFORM_BUILDER_TIMEOUT_SECONDS:-1800}"
export RUN_SPOOL_ROOT="${RUN_SPOOL_ROOT:-$LOCAL_DEBUG_ROOT/run/spool}" export RUN_SPOOL_ROOT="${RUN_SPOOL_ROOT:-$LOCAL_DEBUG_ROOT/run/spool}"
export RUN_MAX_JOBS="${RUN_MAX_JOBS:-1}" export RUN_MAX_JOBS="${RUN_MAX_JOBS:-1}"
export RUN_HEARTBEAT_INTERVAL_MS="${RUN_HEARTBEAT_INTERVAL_MS:-2000}" export RUN_HEARTBEAT_INTERVAL_MS="${RUN_HEARTBEAT_INTERVAL_MS:-2000}"
@@ -162,6 +168,24 @@ local_debug_prepare_run_build_source() {
EOF EOF
} }
local_debug_prepare_distribution_builder() {
local_debug_prepare_run_build_source
if ! command -v "$PLATFORM_BUILDER_DOCKER_BINARY" >/dev/null 2>&1; then
printf 'platform distribution builder requires %s\n' "$PLATFORM_BUILDER_DOCKER_BINARY" >&2
return 1
fi
if [[ "$PLATFORM_BUILDER_IMAGE" == "browser-platform-distribution-builder:1.0.0" ]]; then
"$PLATFORM_BUILDER_DOCKER_BINARY" build \
-t "$PLATFORM_BUILDER_IMAGE" \
"$LOCAL_DEBUG_ROOT_DIR/platform/distribution-builder"
return
fi
if ! "$PLATFORM_BUILDER_DOCKER_BINARY" image inspect "$PLATFORM_BUILDER_IMAGE" >/dev/null 2>&1; then
printf 'configured platform builder image is not present: %s\n' "$PLATFORM_BUILDER_IMAGE" >&2
return 1
fi
}
local_debug_build_bootstrap_run() { local_debug_build_bootstrap_run() {
local_debug_prepare_run_build_source local_debug_prepare_run_build_source
mkdir -p "$(dirname "$RUN_BOOTSTRAP_BIN")" mkdir -p "$(dirname "$RUN_BOOTSTRAP_BIN")"
+384 -60
View File
@@ -8,14 +8,35 @@ source "$ROOT_DIR/scripts/local-debug/env.sh"
PLATFORM_URL="$(local_debug_platform_url)" PLATFORM_URL="$(local_debug_platform_url)"
API_URL="$PLATFORM_URL/api/v1" API_URL="$PLATFORM_URL/api/v1"
WORK_DIR="$LOCAL_DEBUG_ROOT/smoke" WORK_DIR="$LOCAL_DEBUG_ROOT/smoke"
SMOKE_INVOCATION_ID="${SMOKE_INVOCATION_ID:-$(date -u +%Y%m%d%H%M%S)-$$}"
SERVER_LOCAL_ID="server-local-debug-$SMOKE_INVOCATION_ID"
SCUM_ALPHA_ID="scum-alpha-$SMOKE_INVOCATION_ID"
SCUM_BETA_ID="scum-beta-$SMOKE_INVOCATION_ID"
SCUM_DYNAMIC_ID="scum-dynamic-$SMOKE_INVOCATION_ID"
GENERATED_RUN_ENDPOINT_ID="server-run-$SERVER_LOCAL_ID"
GENERATED_RUN_BIN="$WORK_DIR/generated-$SERVER_LOCAL_ID-run"
GENERATED_RUN_LOG="$LOCAL_DEBUG_LOG_DIR/generated-$SERVER_LOCAL_ID-run.log"
GENERATED_RUN_PID_FILE="$LOCAL_DEBUG_PID_DIR/generated-$SERVER_LOCAL_ID-run.pid"
mkdir -p "$WORK_DIR" mkdir -p "$WORK_DIR"
cat >"$WORK_DIR/run-build-config.env" <<EOF cat >"$WORK_DIR/run-build-config.env" <<EOF
SMOKE_INVOCATION_ID=$SMOKE_INVOCATION_ID
SERVER_LOCAL_ID=$SERVER_LOCAL_ID
SCUM_ALPHA_ID=$SCUM_ALPHA_ID
SCUM_BETA_ID=$SCUM_BETA_ID
SCUM_DYNAMIC_ID=$SCUM_DYNAMIC_ID
GENERATED_RUN_ENDPOINT_ID=$GENERATED_RUN_ENDPOINT_ID
PLATFORM_RUN_RELEASE_URL=$PLATFORM_RUN_RELEASE_URL
RUN_SOURCE_DIR=$RUN_SOURCE_DIR RUN_SOURCE_DIR=$RUN_SOURCE_DIR
RUN_REPO_DIR=$RUN_REPO_DIR RUN_REPO_DIR=$RUN_REPO_DIR
RUN_BUILD_BUCKET_ROOT=$RUN_BUILD_BUCKET_ROOT RUN_BUILD_BUCKET_ROOT=$RUN_BUILD_BUCKET_ROOT
RUN_BUILD_SOURCE_ROOT=$RUN_BUILD_SOURCE_ROOT RUN_BUILD_SOURCE_ROOT=$RUN_BUILD_SOURCE_ROOT
RUN_BOOTSTRAP_BIN=$RUN_BOOTSTRAP_BIN RUN_BOOTSTRAP_BIN=$RUN_BOOTSTRAP_BIN
PLATFORM_BUILDER_DOCKER_BINARY=$PLATFORM_BUILDER_DOCKER_BINARY
PLATFORM_BUILDER_IMAGE=$PLATFORM_BUILDER_IMAGE
PLATFORM_BUILDER_SOURCE_DIR=$PLATFORM_BUILDER_SOURCE_DIR
PLATFORM_BUILDER_WORKSPACE_DIR=$PLATFORM_BUILDER_WORKSPACE_DIR
PLATFORM_BUILDER_TIMEOUT_SECONDS=$PLATFORM_BUILDER_TIMEOUT_SECONDS
RUN_WORKSPACE_ROOT=$RUN_WORKSPACE_ROOT RUN_WORKSPACE_ROOT=$RUN_WORKSPACE_ROOT
RUN_SPOOL_ROOT=$RUN_SPOOL_ROOT RUN_SPOOL_ROOT=$RUN_SPOOL_ROOT
RUN_MAX_JOBS=$RUN_MAX_JOBS RUN_MAX_JOBS=$RUN_MAX_JOBS
@@ -23,10 +44,10 @@ EOF
prepare_run_workspace() { prepare_run_workspace() {
local_debug_prepare_run_lifecycle_templates local_debug_prepare_run_lifecycle_templates
local_debug_seed_server_lifecycle_workspace server-local-debug game.example run-local local_debug_seed_server_lifecycle_workspace "$SERVER_LOCAL_ID" game.example run-local
local_debug_seed_server_lifecycle_workspace scum-alpha game.scum run-local local_debug_seed_server_lifecycle_workspace "$SCUM_ALPHA_ID" game.scum run-local
local_debug_seed_server_lifecycle_workspace scum-beta game.scum run-local local_debug_seed_server_lifecycle_workspace "$SCUM_BETA_ID" game.scum run-local
local_debug_seed_server_lifecycle_workspace scum-dynamic game.scum run-local local_debug_seed_server_lifecycle_workspace "$SCUM_DYNAMIC_ID" game.scum run-local
} }
prepare_run_workspace prepare_run_workspace
@@ -58,8 +79,55 @@ wait_for_url() {
return 1 return 1
} }
assert_no_active_run_session() {
local endpoint_file="$WORK_DIR/bootstrap-run-endpoint.response.json"
local endpoint_url="${RUN_PLATFORM_URL%/}/api/v1/run/endpoints/$RUN_ENDPOINT_ID"
local status
status="$(curl -sS -o "$endpoint_file" -w '%{http_code}' "$endpoint_url" || true)"
case "$status" in
404)
return 0
;;
200)
if node - "$endpoint_file" <<'NODE'
const fs = require("fs");
const endpoint = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const heartbeat = endpoint.lastHeartbeatAt || endpoint.LastHeartbeatAt;
const ageMilliseconds = heartbeat ? Date.now() - Date.parse(heartbeat) : Number.POSITIVE_INFINITY;
process.exit(endpoint.status === "online" && Number.isFinite(ageMilliseconds) && ageMilliseconds >= 0 && ageMilliseconds < 30_000 ? 0 : 1);
NODE
then
printf 'refusing to self-start Run endpoint %s: an active session already exists at %s\n' "$RUN_ENDPOINT_ID" "$RUN_PLATFORM_URL" >&2
printf 'run the smoke against that existing stack, or stop the existing Run before using LOCAL_DEBUG_SELF_START=true\n' >&2
return 1
fi
return 0
;;
*)
printf 'refusing to self-start Run endpoint %s: could not verify its session state at %s (HTTP %s)\n' "$RUN_ENDPOINT_ID" "$RUN_PLATFORM_URL" "$status" >&2
return 1
;;
esac
}
assert_no_managed_run_process() {
local run_pid_file="$LOCAL_DEBUG_PID_DIR/run.pid"
if [[ ! -f "$run_pid_file" ]]; then
return 0
fi
local pid
pid="$(<"$run_pid_file")"
if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then
printf 'refusing to self-start a second Run while managed Run pid %s is active\n' "$pid" >&2
printf 'run the smoke against that existing stack, or stop it before using LOCAL_DEBUG_SELF_START=true\n' >&2
return 1
fi
rm -f "$run_pid_file"
}
start_self_hosted_stack() { start_self_hosted_stack() {
mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$PLATFORM_ARTIFACT_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$RUN_BUILD_BUCKET_ROOT" "$GOCACHE" mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$PLATFORM_ARTIFACT_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$RUN_BUILD_BUCKET_ROOT" "$GOCACHE"
local_debug_prepare_distribution_builder
trap cleanup_self_started EXIT trap cleanup_self_started EXIT
printf 'self-starting platform for local debug smoke\n' printf 'self-starting platform for local debug smoke\n'
@@ -77,6 +145,12 @@ start_self_hosted_stack() {
PLATFORM_BOOTSTRAP_ADMIN_EMAIL="$PLATFORM_BOOTSTRAP_ADMIN_EMAIL" \ PLATFORM_BOOTSTRAP_ADMIN_EMAIL="$PLATFORM_BOOTSTRAP_ADMIN_EMAIL" \
PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="$PLATFORM_BOOTSTRAP_ADMIN_PASSWORD" \ PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="$PLATFORM_BOOTSTRAP_ADMIN_PASSWORD" \
PLATFORM_SECRET_ENVELOPE_KEY="$PLATFORM_SECRET_ENVELOPE_KEY" \ PLATFORM_SECRET_ENVELOPE_KEY="$PLATFORM_SECRET_ENVELOPE_KEY" \
PLATFORM_RUN_RELEASE_URL="$PLATFORM_RUN_RELEASE_URL" \
PLATFORM_BUILDER_DOCKER_BINARY="$PLATFORM_BUILDER_DOCKER_BINARY" \
PLATFORM_BUILDER_IMAGE="$PLATFORM_BUILDER_IMAGE" \
PLATFORM_BUILDER_SOURCE_DIR="$PLATFORM_BUILDER_SOURCE_DIR" \
PLATFORM_BUILDER_WORKSPACE_DIR="$PLATFORM_BUILDER_WORKSPACE_DIR" \
PLATFORM_BUILDER_TIMEOUT_SECONDS="$PLATFORM_BUILDER_TIMEOUT_SECONDS" \
go run ./cmd/platform go run ./cmd/platform
) >"$LOCAL_DEBUG_LOG_DIR/platform.log" 2>&1 & ) >"$LOCAL_DEBUG_LOG_DIR/platform.log" 2>&1 &
SELF_STARTED_PIDS+=("$!") SELF_STARTED_PIDS+=("$!")
@@ -402,6 +476,108 @@ if (reference.artifactId !== artifactId || reference.checksum !== checksum || pa
NODE NODE
} }
local_debug_host_target() {
local os_name
local arch_name
case "$(uname -s)" in
Darwin) os_name="darwin" ;;
Linux) os_name="linux" ;;
*) printf 'unsupported local-debug generated Run host OS: %s\n' "$(uname -s)" >&2; return 1 ;;
esac
case "$(uname -m)" in
arm64 | aarch64) arch_name="arm64" ;;
x86_64 | amd64) arch_name="amd64" ;;
*) printf 'unsupported local-debug generated Run host architecture: %s\n' "$(uname -m)" >&2; return 1 ;;
esac
printf '%s/%s' "$os_name" "$arch_name"
}
launch_generated_run() {
local payload_file="$1"
mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$(dirname "$GENERATED_RUN_BIN")" "$RUN_SPOOL_ROOT/$GENERATED_RUN_ENDPOINT_ID"
cp "$payload_file" "$GENERATED_RUN_BIN"
chmod 700 "$GENERATED_RUN_BIN"
(
unset RUN_PLATFORM_URL RUN_ENDPOINT_ID RUN_DISPLAY_NAME RUN_VERSION RUN_REGISTRATION_TOKEN
unset RUN_SERVER_INSTANCE_ID RUN_PLUGIN_ID RUN_COMPONENT_KIND RUN_COMPONENT_KEY RUN_KEY_GENERATION RUN_PACKAGE_CONFIG
exec env \
GOCACHE="$GOCACHE" \
RUN_MODE=worker \
RUN_WORKSPACE_ROOT="$RUN_WORKSPACE_ROOT" \
RUN_BUILD_SOURCE_ROOT="$RUN_BUILD_SOURCE_ROOT" \
RUN_SPOOL_ROOT="$RUN_SPOOL_ROOT/$GENERATED_RUN_ENDPOINT_ID" \
RUN_MAX_JOBS="$RUN_MAX_JOBS" \
RUN_HEARTBEAT_INTERVAL_MS="$RUN_HEARTBEAT_INTERVAL_MS" \
RUN_POLL_INTERVAL_MS="$RUN_POLL_INTERVAL_MS" \
RUN_RETRY_BACKOFF_MS="$RUN_RETRY_BACKOFF_MS" \
"$GENERATED_RUN_BIN"
) >"$GENERATED_RUN_LOG" 2>&1 &
local generated_pid="$!"
printf '%s' "$generated_pid" >"$GENERATED_RUN_PID_FILE"
if [[ "${LOCAL_DEBUG_SELF_START:-false}" == "true" ]]; then
SELF_STARTED_PIDS+=("$generated_pid")
fi
}
wait_for_generated_run_registration_and_heartbeat() {
local registration_file="$WORK_DIR/generated-run-registration.response.json"
local heartbeat_file="$WORK_DIR/generated-run-heartbeat.response.json"
local first_heartbeat=""
rm -f "$registration_file" "$heartbeat_file"
printf 'waiting for generated Run endpoint %s registration\n' "$GENERATED_RUN_ENDPOINT_ID"
for _ in $(seq 1 45); do
if [[ -f "$GENERATED_RUN_PID_FILE" ]] && ! kill -0 "$(<"$GENERATED_RUN_PID_FILE")" 2>/dev/null; then
printf 'generated Run exited before registration; see %s\n' "$GENERATED_RUN_LOG" >&2
return 1
fi
if json_get "$API_URL/run/endpoints/$GENERATED_RUN_ENDPOINT_ID" "$registration_file" "${AUTH_HEADER[@]}" 2>/dev/null; then
if first_heartbeat="$(node - "$registration_file" "$GENERATED_RUN_ENDPOINT_ID" <<'NODE'
const fs = require("fs");
const endpoint = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const expectedID = process.argv[3];
const heartbeat = endpoint.lastHeartbeatAt || endpoint.LastHeartbeatAt || "";
const capabilities = endpoint.capabilities || endpoint.Capabilities || [];
if (endpoint.id !== expectedID || endpoint.status !== "online" || !heartbeat || capabilities.includes("distribution.build")) {
process.exit(1);
}
process.stdout.write(heartbeat);
NODE
)"; then
break
fi
fi
sleep 1
done
if [[ -z "$first_heartbeat" ]]; then
printf 'generated Run endpoint %s did not register with a safe capability report\n' "$GENERATED_RUN_ENDPOINT_ID" >&2
[[ -f "$GENERATED_RUN_LOG" ]] && sed -n '1,160p' "$GENERATED_RUN_LOG" >&2
return 1
fi
reject_forbidden_fragments "$registration_file"
printf 'waiting for generated Run endpoint %s heartbeat\n' "$GENERATED_RUN_ENDPOINT_ID"
for _ in $(seq 1 45); do
if json_get "$API_URL/run/endpoints/$GENERATED_RUN_ENDPOINT_ID" "$heartbeat_file" "${AUTH_HEADER[@]}" 2>/dev/null && node - "$heartbeat_file" "$GENERATED_RUN_ENDPOINT_ID" "$first_heartbeat" <<'NODE'
const fs = require("fs");
const endpoint = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const expectedID = process.argv[3];
const firstHeartbeat = Date.parse(process.argv[4]);
const heartbeat = Date.parse(endpoint.lastHeartbeatAt || endpoint.LastHeartbeatAt || "");
const capabilities = endpoint.capabilities || endpoint.Capabilities || [];
process.exit(endpoint.id === expectedID && endpoint.status === "online" && Number.isFinite(heartbeat) && heartbeat > firstHeartbeat && !capabilities.includes("distribution.build") ? 0 : 1);
NODE
then
reject_forbidden_fragments "$heartbeat_file"
return 0
fi
sleep 1
done
printf 'generated Run endpoint %s did not report a subsequent heartbeat\n' "$GENERATED_RUN_ENDPOINT_ID" >&2
[[ -f "$GENERATED_RUN_LOG" ]] && sed -n '1,160p' "$GENERATED_RUN_LOG" >&2
return 1
}
wait_for_lifecycle_install_success() { wait_for_lifecycle_install_success() {
local server_id="$1" local server_id="$1"
local output_file="$2" local output_file="$2"
@@ -476,7 +652,7 @@ const manifest = {
createFormSchema: source.server.createFormSchema createFormSchema: source.server.createFormSchema
}, },
capabilities: ["process.install", "process.start", "process.stop", "config.write"], capabilities: ["process.install", "process.start", "process.stop", "config.write"],
permissions: ["server.read", "server.lifecycle", "server.logs.read", "server.artifacts.read", "ai.invoke"], permissions: ["server.read", "server.lifecycle", "server.run.distribution", "server.logs.read", "server.artifacts.read", "ai.invoke"],
actions: { actions: {
install: source.actions.install, install: source.actions.install,
start: source.actions.start, start: source.actions.start,
@@ -488,11 +664,11 @@ const manifest = {
key: "logs", key: "logs",
title: "Logs", title: "Logs",
path: "/logs", path: "/logs",
permissions: ["server.read", "server.lifecycle", "server.logs.read", "server.artifacts.read", "ai.invoke"], permissions: ["server.read", "server.lifecycle", "server.run.distribution", "server.logs.read", "server.artifacts.read", "ai.invoke"],
bridgeActions: ["server.instances.read", "jobs.dispatch", "logs.query", "artifacts.open", "plugin-lifecycle.request", "ai.invoke"] bridgeActions: ["server.instances.read", "jobs.dispatch", "logs.query", "artifacts.open", "run.distribution.request", "plugin-lifecycle.request", "ai.invoke"]
} }
], ],
bridge: { actions: ["server.instances.read", "jobs.dispatch", "logs.query", "artifacts.open", "plugin-lifecycle.request", "ai.invoke"] }, bridge: { actions: ["server.instances.read", "jobs.dispatch", "logs.query", "artifacts.open", "run.distribution.request", "plugin-lifecycle.request", "ai.invoke"] },
ai: source.ai, ai: source.ai,
productionLifecycle: source.productionLifecycle, productionLifecycle: source.productionLifecycle,
runtimeProfiles: { runtimeProfiles: {
@@ -595,25 +771,45 @@ done
require_file_contains "$WORK_DIR/run-endpoints.response.json" "$RUN_ENDPOINT_ID" require_file_contains "$WORK_DIR/run-endpoints.response.json" "$RUN_ENDPOINT_ID"
reject_forbidden_fragments "$WORK_DIR/run-endpoints.response.json" reject_forbidden_fragments "$WORK_DIR/run-endpoints.response.json"
GENERATED_RUN_TARGET="$(local_debug_host_target)"
IFS=/ read -r GENERATED_RUN_TARGET_OS GENERATED_RUN_TARGET_ARCH <<<"$GENERATED_RUN_TARGET"
printf 'GENERATED_RUN_TARGET_OS=%s\nGENERATED_RUN_TARGET_ARCH=%s\n' "$GENERATED_RUN_TARGET_OS" "$GENERATED_RUN_TARGET_ARCH" >>"$WORK_DIR/run-build-config.env"
cat >"$WORK_DIR/create-server.request.json" <<JSON cat >"$WORK_DIR/create-server.request.json" <<JSON
{ {
"id": "server-local-debug", "id": "$SERVER_LOCAL_ID",
"pluginId": "game.example", "pluginId": "game.example",
"runEndpointId": "$RUN_ENDPOINT_ID", "name": "Local Debug Example Server $SMOKE_INVOCATION_ID",
"name": "Local Debug Example Server", "idempotencyKey": "local-debug-create-$SMOKE_INVOCATION_ID"
"idempotencyKey": "local-debug-create", }
JSON
cat >"$WORK_DIR/server-runtime-binding.request.json" <<JSON
{
"profileKey": "run-local", "profileKey": "run-local",
"bindings": {} "bindings": {}
} }
JSON JSON
cat >"$WORK_DIR/server-run-generate.request.json" <<JSON
{
"targetOs": "$GENERATED_RUN_TARGET_OS",
"targetArch": "$GENERATED_RUN_TARGET_ARCH",
"idempotencyKey": "local-debug-server-run-generate-$SMOKE_INVOCATION_ID"
}
JSON
cat >"$WORK_DIR/create-scum-alpha.request.json" <<JSON cat >"$WORK_DIR/create-scum-alpha.request.json" <<JSON
{ {
"id": "scum-alpha", "id": "$SCUM_ALPHA_ID",
"pluginId": "game.scum", "pluginId": "game.scum",
"runEndpointId": "$RUN_ENDPOINT_ID", "name": "SCUM Alpha $SMOKE_INVOCATION_ID",
"name": "SCUM Alpha", "idempotencyKey": "local-debug-scum-alpha-create-$SMOKE_INVOCATION_ID"
"idempotencyKey": "local-debug-scum-alpha-create", }
JSON
cat >"$WORK_DIR/scum-alpha-runtime-binding.request.json" <<JSON
{
"profileKey": "run-local", "profileKey": "run-local",
"bindings": {} "bindings": {}
} }
@@ -621,11 +817,11 @@ JSON
cat >"$WORK_DIR/create-scum-beta.request.json" <<JSON cat >"$WORK_DIR/create-scum-beta.request.json" <<JSON
{ {
"id": "scum-beta", "id": "$SCUM_BETA_ID",
"pluginId": "game.scum", "pluginId": "game.scum",
"runEndpointId": "$RUN_ENDPOINT_ID", "runEndpointId": "$RUN_ENDPOINT_ID",
"name": "SCUM Beta", "name": "SCUM Beta $SMOKE_INVOCATION_ID",
"idempotencyKey": "local-debug-scum-beta-create", "idempotencyKey": "local-debug-scum-beta-create-$SMOKE_INVOCATION_ID",
"profileKey": "run-local", "profileKey": "run-local",
"bindings": {} "bindings": {}
} }
@@ -633,11 +829,11 @@ JSON
cat >"$WORK_DIR/create-scum-dynamic.request.json" <<JSON cat >"$WORK_DIR/create-scum-dynamic.request.json" <<JSON
{ {
"id": "scum-dynamic", "id": "$SCUM_DYNAMIC_ID",
"pluginId": "game.scum", "pluginId": "game.scum",
"runEndpointId": "$RUN_ENDPOINT_ID", "runEndpointId": "$RUN_ENDPOINT_ID",
"name": "SCUM Dynamic", "name": "SCUM Dynamic $SMOKE_INVOCATION_ID",
"idempotencyKey": "local-debug-scum-dynamic-create", "idempotencyKey": "local-debug-scum-dynamic-create-$SMOKE_INVOCATION_ID",
"profileKey": "run-local", "profileKey": "run-local",
"bindings": {} "bindings": {}
} }
@@ -645,20 +841,20 @@ JSON
cat >"$WORK_DIR/scum-alpha-run-generate.request.json" <<JSON cat >"$WORK_DIR/scum-alpha-run-generate.request.json" <<JSON
{ {
"targetOs": "windows", "targetOs": "linux",
"targetArch": "amd64", "targetArch": "amd64",
"idempotencyKey": "local-debug-scum-alpha-run-generate" "idempotencyKey": "local-debug-scum-alpha-run-generate-$SMOKE_INVOCATION_ID"
} }
JSON JSON
printf 'creating server lifecycle workflow through platform API\n' printf 'creating server lifecycle workflow through platform API\n'
create_server_workflow "dev" "server-local-debug" "$WORK_DIR/create-server.request.json" "$WORK_DIR/create-server.response.json" create_server_workflow "dev" "$SERVER_LOCAL_ID" "$WORK_DIR/create-server.request.json" "$WORK_DIR/create-server.response.json"
reject_forbidden_fragments "$WORK_DIR/create-server.response.json" reject_forbidden_fragments "$WORK_DIR/create-server.response.json"
printf 'creating SCUM server lifecycle workflows through platform API\n' printf 'creating SCUM server lifecycle workflows through platform API\n'
create_server_workflow "SCUM alpha" "scum-alpha" "$WORK_DIR/create-scum-alpha.request.json" "$WORK_DIR/create-scum-alpha.response.json" create_server_workflow "SCUM alpha" "$SCUM_ALPHA_ID" "$WORK_DIR/create-scum-alpha.request.json" "$WORK_DIR/create-scum-alpha.response.json"
create_server_workflow "SCUM beta" "scum-beta" "$WORK_DIR/create-scum-beta.request.json" "$WORK_DIR/create-scum-beta.response.json" create_server_workflow "SCUM beta" "$SCUM_BETA_ID" "$WORK_DIR/create-scum-beta.request.json" "$WORK_DIR/create-scum-beta.response.json"
create_server_workflow "SCUM dynamic" "scum-dynamic" "$WORK_DIR/create-scum-dynamic.request.json" "$WORK_DIR/create-scum-dynamic.response.json" create_server_workflow "SCUM dynamic" "$SCUM_DYNAMIC_ID" "$WORK_DIR/create-scum-dynamic.request.json" "$WORK_DIR/create-scum-dynamic.response.json"
reject_forbidden_fragments "$WORK_DIR/create-scum-alpha.response.json" reject_forbidden_fragments "$WORK_DIR/create-scum-alpha.response.json"
reject_forbidden_fragments "$WORK_DIR/create-scum-beta.response.json" reject_forbidden_fragments "$WORK_DIR/create-scum-beta.response.json"
reject_forbidden_fragments "$WORK_DIR/create-scum-dynamic.response.json" reject_forbidden_fragments "$WORK_DIR/create-scum-dynamic.response.json"
@@ -667,9 +863,114 @@ require_file_contains "$WORK_DIR/create-scum-beta.response.json" '"pluginId"[[:s
require_file_contains "$WORK_DIR/create-scum-dynamic.response.json" '"pluginId"[[:space:]]*:[[:space:]]*"game.scum"' require_file_contains "$WORK_DIR/create-scum-dynamic.response.json" '"pluginId"[[:space:]]*:[[:space:]]*"game.scum"'
SERVER_ID="$(json_id "$WORK_DIR/create-server.response.json")" SERVER_ID="$(json_id "$WORK_DIR/create-server.response.json")"
SCUM_ALPHA_ID="$(json_id "$WORK_DIR/create-scum-alpha.response.json")" CREATED_SCUM_ALPHA_ID="$(json_id "$WORK_DIR/create-scum-alpha.response.json")"
SCUM_BETA_ID="$(json_id "$WORK_DIR/create-scum-beta.response.json")" CREATED_SCUM_BETA_ID="$(json_id "$WORK_DIR/create-scum-beta.response.json")"
SCUM_DYNAMIC_ID="$(json_id "$WORK_DIR/create-scum-dynamic.response.json")" CREATED_SCUM_DYNAMIC_ID="$(json_id "$WORK_DIR/create-scum-dynamic.response.json")"
if [[ "$SERVER_ID" != "$SERVER_LOCAL_ID" || "$CREATED_SCUM_ALPHA_ID" != "$SCUM_ALPHA_ID" || "$CREATED_SCUM_BETA_ID" != "$SCUM_BETA_ID" || "$CREATED_SCUM_DYNAMIC_ID" != "$SCUM_DYNAMIC_ID" ]]; then
printf 'created server IDs do not match invocation-scoped workspace IDs\n' >&2
exit 1
fi
node - "$WORK_DIR/create-server.request.json" <<'NODE'
const fs = require("fs");
const request = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
for (const forbidden of ["deploymentTargetId", "runEndpointId", "profileKey", "bindings", "deployment"]) {
if (Object.hasOwn(request, forbidden)) {
console.error(`minimal server creation unexpectedly included ${forbidden}`);
process.exit(1);
}
}
if (!request.pluginId || !request.name) {
console.error("minimal server creation omitted pluginId or name");
process.exit(1);
}
NODE
printf 'configuring example server runtime binding after minimal creation\n'
curl -fsS -X PUT -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" \
--data-binary "@$WORK_DIR/server-runtime-binding.request.json" \
"$API_URL/server-instances/$SERVER_ID/runtime-binding" >"$WORK_DIR/server-runtime-binding.response.json"
reject_forbidden_fragments "$WORK_DIR/server-runtime-binding.response.json"
require_file_contains "$WORK_DIR/server-runtime-binding.response.json" '"status"[[:space:]]*:[[:space:]]*"complete"'
printf 'checking example server platform-builder action\n'
json_get "$API_URL/server-instances/$SERVER_ID/runtime/actions" "$WORK_DIR/server-runtime-actions.response.json" "${AUTH_HEADER[@]}"
node - "$WORK_DIR/server-runtime-actions.response.json" <<'NODE'
const fs = require("fs");
const response = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const action = (response.actions || []).find((candidate) => candidate.key === "generate-run");
if (!action || action.available !== true || (action.reason || "").includes("run endpoint")) {
console.error("expected minimal example server to generate through the ready platform builder");
console.error(JSON.stringify(response, null, 2));
process.exit(1);
}
NODE
reject_forbidden_fragments "$WORK_DIR/server-runtime-actions.response.json"
printf 'generating host-native example Run through platform Docker builder\n'
curl -fsS -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" --data-binary "@$WORK_DIR/server-run-generate.request.json" "$API_URL/server-instances/$SERVER_ID/run/generate" >"$WORK_DIR/server-run-generate.response.json"
reject_forbidden_fragments "$WORK_DIR/server-run-generate.response.json"
node - "$WORK_DIR/server-run-generate.response.json" "$SERVER_ID" "$GENERATED_RUN_ENDPOINT_ID" "$GENERATED_RUN_TARGET_OS" "$GENERATED_RUN_TARGET_ARCH" <<'NODE'
const fs = require("fs");
const distribution = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const [serverID, endpointID, targetOS, targetArch] = process.argv.slice(3);
if (distribution.serverInstanceId !== serverID || distribution.runEndpointId !== endpointID || distribution.targetOs !== targetOS || distribution.targetArch !== targetArch || !distribution.buildJobId || !distribution.artifactId) {
console.error("host-native Run distribution identity did not match the minimal server");
console.error(JSON.stringify(distribution, null, 2));
process.exit(1);
}
NODE
wait_for_distribution_build "$WORK_DIR/server-run-generate.response.json" "$WORK_DIR/server-run-build-job.response.json" "$WORK_DIR/server-run-build-artifact.response.json"
node - "$WORK_DIR/server-run-build-job.response.json" <<'NODE'
const fs = require("fs");
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
if (job.runEndpointId !== "platform-distribution-builder" || job.capability !== "distribution.build" || job.state !== "succeeded") {
console.error("host-native Run was not completed by the platform distribution builder");
console.error(JSON.stringify(job, null, 2));
process.exit(1);
}
NODE
reject_forbidden_fragments "$WORK_DIR/server-run-build-job.response.json"
reject_forbidden_fragments "$WORK_DIR/server-run-build-artifact.response.json"
read_latest_run_download_content "$SERVER_ID" "$WORK_DIR/server-run-download.response.json" "$WORK_DIR/server-run-download-content.bin" "$WORK_DIR/server-run-download-chunks"
launch_generated_run "$WORK_DIR/server-run-download-content.bin"
wait_for_generated_run_registration_and_heartbeat
cat >"$WORK_DIR/server-deployment.request.json" <<JSON
{
"runEndpointId": "$GENERATED_RUN_ENDPOINT_ID",
"mode": "custom-command",
"profileKey": "run-local",
"createInputs": {},
"serverRoot": "$RUN_WORKSPACE_ROOT/$SERVER_ID",
"startCommand": "/usr/bin/true"
}
JSON
printf 'saving optional deployment settings after generated Run registration\n'
curl -fsS -X PUT -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" \
--data-binary "@$WORK_DIR/server-deployment.request.json" \
"$API_URL/server-instances/$SERVER_ID/deployment" >"$WORK_DIR/server-deployment.response.json"
reject_forbidden_fragments "$WORK_DIR/server-deployment.response.json"
json_get "$API_URL/server-instances/$SERVER_ID" "$WORK_DIR/server-before-deploy.response.json" "${AUTH_HEADER[@]}"
SERVER_CONFIG_VERSION="$(node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1], "utf8")).configVersion; if (!Number.isInteger(value) || value < 1) process.exit(2); process.stdout.write(String(value));' "$WORK_DIR/server-before-deploy.response.json")"
cat >"$WORK_DIR/server-deploy.request.json" <<JSON
{
"expectedConfigVersion": $SERVER_CONFIG_VERSION,
"idempotencyKey": "local-debug-server-deploy-$SMOKE_INVOCATION_ID"
}
JSON
json_post "$API_URL/server-instances/$SERVER_ID/deploy" "$WORK_DIR/server-deploy.request.json" "$WORK_DIR/server-deploy.response.json" "${AUTH_HEADER[@]}"
reject_forbidden_fragments "$WORK_DIR/server-deploy.response.json"
require_file_contains "$WORK_DIR/server-deploy.response.json" "\"runEndpointId\"[[:space:]]*:[[:space:]]*\"$GENERATED_RUN_ENDPOINT_ID\""
wait_for_lifecycle_install_success "$SERVER_ID" "$WORK_DIR/jobs.response.json"
printf 'configuring SCUM alpha runtime binding after minimal creation\n'
curl -fsS -X PUT -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" \
--data-binary "@$WORK_DIR/scum-alpha-runtime-binding.request.json" \
"$API_URL/server-instances/$SCUM_ALPHA_ID/runtime-binding" >"$WORK_DIR/scum-alpha-runtime-binding.response.json"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-runtime-binding.response.json"
require_file_contains "$WORK_DIR/scum-alpha-runtime-binding.response.json" '"status"[[:space:]]*:[[:space:]]*"complete"'
wait_for_lifecycle_install_success "$SCUM_DYNAMIC_ID" "$WORK_DIR/scum-dynamic-jobs.response.json" wait_for_lifecycle_install_success "$SCUM_DYNAMIC_ID" "$WORK_DIR/scum-dynamic-jobs.response.json"
@@ -677,27 +978,29 @@ printf 'checking SCUM runtime distribution action\n'
json_get "$API_URL/server-instances/$SCUM_ALPHA_ID/runtime/actions" "$WORK_DIR/scum-alpha-runtime-actions.response.json" "${AUTH_HEADER[@]}" json_get "$API_URL/server-instances/$SCUM_ALPHA_ID/runtime/actions" "$WORK_DIR/scum-alpha-runtime-actions.response.json" "${AUTH_HEADER[@]}"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-runtime-actions.response.json" reject_forbidden_fragments "$WORK_DIR/scum-alpha-runtime-actions.response.json"
require_file_contains "$WORK_DIR/scum-alpha-runtime-actions.response.json" '"key"[[:space:]]*:[[:space:]]*"generate-run"' require_file_contains "$WORK_DIR/scum-alpha-runtime-actions.response.json" '"key"[[:space:]]*:[[:space:]]*"generate-run"'
SCUM_BUILD_AVAILABLE="$(node - "$WORK_DIR/scum-alpha-runtime-actions.response.json" <<'NODE' node - "$WORK_DIR/scum-alpha-runtime-actions.response.json" <<'NODE'
const fs = require("fs"); const fs = require("fs");
const response = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); const response = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const action = (response.actions || []).find((candidate) => candidate.key === "generate-run"); const action = (response.actions || []).find((candidate) => candidate.key === "generate-run");
if (!action || (action.available !== true && action.reason !== "run endpoint cannot build distributions")) { if (!action || action.available !== true) {
console.error("expected SCUM generate-run action to match the Run capability report"); console.error("expected SCUM generate-run action to use the ready platform builder");
console.error(JSON.stringify(response, null, 2)); console.error(JSON.stringify(response, null, 2));
process.exit(1); process.exit(1);
} }
process.stdout.write(action.available === true ? "true" : "false"); if ((action.reason || "").includes("run endpoint")) {
console.error("platform build availability must not reference a Run endpoint capability");
console.error(JSON.stringify(action, null, 2));
process.exit(1);
}
NODE NODE
)"
if [[ "$SCUM_BUILD_AVAILABLE" == "true" ]]; then printf 'generating SCUM run package through platform API\n'
printf 'generating SCUM run package through platform API\n' curl -fsS -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" --data-binary "@$WORK_DIR/scum-alpha-run-generate.request.json" "$API_URL/server-instances/$SCUM_ALPHA_ID/run/generate" >"$WORK_DIR/scum-alpha-run-generate.response.json"
curl -fsS -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" --data-binary "@$WORK_DIR/scum-alpha-run-generate.request.json" "$API_URL/server-instances/$SCUM_ALPHA_ID/run/generate" >"$WORK_DIR/scum-alpha-run-generate.response.json" reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-generate.response.json"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-generate.response.json" require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" "\"serverInstanceId\"[[:space:]]*:[[:space:]]*\"$SCUM_ALPHA_ID\""
require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-alpha"' require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" "\"artifactId\"[[:space:]]*:[[:space:]]*\"artifact-run-dist-$SCUM_ALPHA_ID"
require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"artifactId"[[:space:]]*:[[:space:]]*"artifact-run-dist-scum-alpha' require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"buildJobId"[[:space:]]*:[[:space:]]*"job-distribution-build'
require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"buildJobId"[[:space:]]*:[[:space:]]*"job-distribution-build' node - "$WORK_DIR/scum-alpha-run-generate.response.json" <<'NODE'
node - "$WORK_DIR/scum-alpha-run-generate.response.json" <<'NODE'
const fs = require("fs"); const fs = require("fs");
const distribution = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); const distribution = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
if (distribution.status !== "building" && distribution.status !== "available") { if (distribution.status !== "building" && distribution.status !== "available") {
@@ -706,14 +1009,11 @@ if (distribution.status !== "building" && distribution.status !== "available") {
process.exit(1); process.exit(1);
} }
NODE NODE
wait_for_distribution_build "$WORK_DIR/scum-alpha-run-generate.response.json" "$WORK_DIR/scum-alpha-run-build-job.response.json" "$WORK_DIR/scum-alpha-run-build-artifact.response.json" wait_for_distribution_build "$WORK_DIR/scum-alpha-run-generate.response.json" "$WORK_DIR/scum-alpha-run-build-job.response.json" "$WORK_DIR/scum-alpha-run-build-artifact.response.json"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-job.response.json" reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-job.response.json"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-artifact.response.json" reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-artifact.response.json"
read_latest_run_download_content "$SCUM_ALPHA_ID" "$WORK_DIR/scum-alpha-run-download.response.json" "$WORK_DIR/scum-alpha-run-download-content.bin" "$WORK_DIR/scum-alpha-run-download-chunks" read_latest_run_download_content "$SCUM_ALPHA_ID" "$WORK_DIR/scum-alpha-run-download.response.json" "$WORK_DIR/scum-alpha-run-download-content.bin" "$WORK_DIR/scum-alpha-run-download-chunks"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-download.response.json" reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-download.response.json"
else
printf 'SCUM run package build is unavailable because Run did not advertise distribution.build; verified without fake success\n'
fi
printf 'checking jobs, logs, artifacts, and marketplace refs\n' printf 'checking jobs, logs, artifacts, and marketplace refs\n'
json_get "$API_URL/server-instances" "$WORK_DIR/server-instances.response.json" "${AUTH_HEADER[@]}" json_get "$API_URL/server-instances" "$WORK_DIR/server-instances.response.json" "${AUTH_HEADER[@]}"
@@ -726,14 +1026,34 @@ json_get "$API_URL/artifacts" "$WORK_DIR/artifacts.response.json" "${AUTH_HEADER
json_get "$API_URL/plugin-marketplace/plugins" "$WORK_DIR/marketplace.response.json" "${AUTH_HEADER[@]}" json_get "$API_URL/plugin-marketplace/plugins" "$WORK_DIR/marketplace.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/plugin-marketplace/plugins?serverType=scum&keyword=scum" "$WORK_DIR/scum-marketplace.response.json" "${AUTH_HEADER[@]}" json_get "$API_URL/plugin-marketplace/plugins?serverType=scum&keyword=scum" "$WORK_DIR/scum-marketplace.response.json" "${AUTH_HEADER[@]}"
require_file_contains "$WORK_DIR/scum-marketplace.response.json" '"id"[[:space:]]*:[[:space:]]*"game.scum"' require_file_contains "$WORK_DIR/scum-marketplace.response.json" '"id"[[:space:]]*:[[:space:]]*"game.scum"'
if [[ "$SCUM_BUILD_AVAILABLE" == "true" ]]; then require_file_contains "$WORK_DIR/artifacts.response.json" "\"id\"[[:space:]]*:[[:space:]]*\"artifact-run-dist-$SCUM_ALPHA_ID"
require_file_contains "$WORK_DIR/artifacts.response.json" '"id"[[:space:]]*:[[:space:]]*"artifact-run-dist-scum-alpha' require_file_contains "$WORK_DIR/scum-alpha-jobs.response.json" "\"serverInstanceId\"[[:space:]]*:[[:space:]]*\"$SCUM_ALPHA_ID\""
fi require_file_contains "$WORK_DIR/scum-beta-jobs.response.json" "\"serverInstanceId\"[[:space:]]*:[[:space:]]*\"$SCUM_BETA_ID\""
require_file_contains "$WORK_DIR/scum-alpha-jobs.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-alpha"'
require_file_contains "$WORK_DIR/scum-beta-jobs.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-beta"'
require_file_contains "$WORK_DIR/scum-dynamic-jobs.response.json" '"state"[[:space:]]*:[[:space:]]*"succeeded"' require_file_contains "$WORK_DIR/scum-dynamic-jobs.response.json" '"state"[[:space:]]*:[[:space:]]*"succeeded"'
for file in "$WORK_DIR"/server-instances.response.json "$WORK_DIR"/jobs.response.json "$WORK_DIR"/scum-alpha-jobs.response.json "$WORK_DIR"/scum-beta-jobs.response.json "$WORK_DIR"/scum-dynamic-jobs.response.json "$WORK_DIR"/log-streams.response.json "$WORK_DIR"/artifacts.response.json "$WORK_DIR"/marketplace.response.json "$WORK_DIR"/scum-marketplace.response.json "$WORK_DIR"/scum-alpha-runtime-actions.response.json "$WORK_DIR"/scum-alpha-run-generate.response.json "$WORK_DIR"/scum-alpha-run-download.response.json; do for file in \
"$WORK_DIR"/server-instances.response.json \
"$WORK_DIR"/jobs.response.json \
"$WORK_DIR"/server-runtime-binding.response.json \
"$WORK_DIR"/server-runtime-actions.response.json \
"$WORK_DIR"/server-run-generate.response.json \
"$WORK_DIR"/server-run-build-job.response.json \
"$WORK_DIR"/server-run-build-artifact.response.json \
"$WORK_DIR"/server-run-download.response.json \
"$WORK_DIR"/generated-run-registration.response.json \
"$WORK_DIR"/generated-run-heartbeat.response.json \
"$WORK_DIR"/server-deployment.response.json \
"$WORK_DIR"/server-deploy.response.json \
"$WORK_DIR"/scum-alpha-jobs.response.json \
"$WORK_DIR"/scum-beta-jobs.response.json \
"$WORK_DIR"/scum-dynamic-jobs.response.json \
"$WORK_DIR"/log-streams.response.json \
"$WORK_DIR"/artifacts.response.json \
"$WORK_DIR"/marketplace.response.json \
"$WORK_DIR"/scum-marketplace.response.json \
"$WORK_DIR"/scum-alpha-runtime-actions.response.json \
"$WORK_DIR"/scum-alpha-run-generate.response.json \
"$WORK_DIR"/scum-alpha-run-download.response.json; do
if [[ -f "$file" ]]; then if [[ -f "$file" ]]; then
reject_forbidden_fragments "$file" reject_forbidden_fragments "$file"
fi fi
@@ -743,6 +1063,10 @@ if [[ "$VITE_PLATFORM_API_BASE_URL" != "/api/v1" ]]; then
printf 'VITE_PLATFORM_API_BASE_URL must be /api/v1, got %s\n' "$VITE_PLATFORM_API_BASE_URL" >&2 printf 'VITE_PLATFORM_API_BASE_URL must be /api/v1, got %s\n' "$VITE_PLATFORM_API_BASE_URL" >&2
exit 1 exit 1
fi fi
if [[ "$PLATFORM_RUN_RELEASE_URL" != "$PLATFORM_URL" ]]; then
printf 'PLATFORM_RUN_RELEASE_URL must be %s for executable local Run proof, got %s\n' "$PLATFORM_URL" "$PLATFORM_RUN_RELEASE_URL" >&2
exit 1
fi
if [[ "$PLATFORM_API_PROXY" != "$PLATFORM_URL" ]]; then if [[ "$PLATFORM_API_PROXY" != "$PLATFORM_URL" ]]; then
printf 'PLATFORM_API_PROXY must be %s, got %s\n' "$PLATFORM_URL" "$PLATFORM_API_PROXY" >&2 printf 'PLATFORM_API_PROXY must be %s, got %s\n' "$PLATFORM_URL" "$PLATFORM_API_PROXY" >&2
exit 1 exit 1
@@ -759,8 +1083,8 @@ cat >"$WORK_DIR/local-ui-checklist.md" <<EOF
- Login with operator.local@example.test / operator-local. - Login with operator.local@example.test / operator-local.
- Confirm the login path is API-backed and no local fallback banner or fallback workspace appears. - Confirm the login path is API-backed and no local fallback banner or fallback workspace appears.
- Visit 首页, 服务器管理, 插件市场, 用户管理, AI 提供商管理. - Visit 首页, 服务器管理, 插件市场, 用户管理, AI 提供商管理.
- Open server-local-debug detail and inspect lifecycle history, plugin controls, logs, and artifact references. - Open $SERVER_ID detail and inspect lifecycle history, plugin controls, logs, and artifact references.
- Confirm 插件市场 can find SCUM Server / game.scum, then open scum-alpha, scum-beta, and scum-dynamic from 服务器管理. - Confirm 插件市场 can find SCUM Server / game.scum, then open $SCUM_ALPHA_ID, $SCUM_BETA_ID, and $SCUM_DYNAMIC_ID from 服务器管理.
- Confirm all SCUM servers are backed by game.scum and have separate lifecycle install jobs and operation history. - Confirm all SCUM servers are backed by game.scum and have separate lifecycle install jobs and operation history.
- Search visible text for forbidden fragments: /Users/, /private/, unix://, tcp://, Bearer , sk-, password=, apiKeyRef, rawApiKey, run session tokens, direct run URLs, plugin-owned transport details. - Search visible text for forbidden fragments: /Users/, /private/, unix://, tcp://, Bearer , sk-, password=, apiKeyRef, rawApiKey, run session tokens, direct run URLs, plugin-owned transport details.
- Acceptance requires platform routes, logical IDs, job refs, log refs, artifact refs, and safe metadata only. - Acceptance requires platform routes, logical IDs, job refs, log refs, artifact refs, and safe metadata only.
+10
View File
@@ -8,6 +8,7 @@ source "$ROOT_DIR/scripts/local-debug/env.sh"
mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$RUN_BUILD_BUCKET_ROOT" mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$RUN_BUILD_BUCKET_ROOT"
mkdir -p "$GOCACHE" mkdir -p "$GOCACHE"
local_debug_prepare_run_lifecycle_templates local_debug_prepare_run_lifecycle_templates
local_debug_prepare_distribution_builder
managed_pid_running() { managed_pid_running() {
local pid_file="$1" local pid_file="$1"
@@ -188,6 +189,8 @@ printf 'run source: %s\n' "$RUN_SOURCE_DIR"
printf 'run build bucket: %s\n' "$RUN_BUILD_BUCKET_ROOT" printf 'run build bucket: %s\n' "$RUN_BUILD_BUCKET_ROOT"
printf 'run build source snapshot: %s\n' "$RUN_BUILD_SOURCE_ROOT" printf 'run build source snapshot: %s\n' "$RUN_BUILD_SOURCE_ROOT"
printf 'run bootstrap binary: %s\n' "$RUN_BOOTSTRAP_BIN" printf 'run bootstrap binary: %s\n' "$RUN_BOOTSTRAP_BIN"
printf 'platform builder image: %s\n' "$PLATFORM_BUILDER_IMAGE"
printf 'platform builder workspace: %s\n' "$PLATFORM_BUILDER_WORKSPACE_DIR"
printf 'platform log: %s\n' "$LOCAL_DEBUG_LOG_DIR/platform.log" printf 'platform log: %s\n' "$LOCAL_DEBUG_LOG_DIR/platform.log"
printf 'run log: %s\n' "$LOCAL_DEBUG_LOG_DIR/run.log" printf 'run log: %s\n' "$LOCAL_DEBUG_LOG_DIR/run.log"
printf 'platform_web log: %s\n' "$LOCAL_DEBUG_LOG_DIR/platform_web.log" printf 'platform_web log: %s\n' "$LOCAL_DEBUG_LOG_DIR/platform_web.log"
@@ -203,9 +206,16 @@ start_service platform "$ROOT_DIR/platform" env \
PLATFORM_METADATA_PATH="$PLATFORM_METADATA_PATH" \ PLATFORM_METADATA_PATH="$PLATFORM_METADATA_PATH" \
PLATFORM_LOG_BODY_BACKEND="$PLATFORM_LOG_BODY_BACKEND" \ PLATFORM_LOG_BODY_BACKEND="$PLATFORM_LOG_BODY_BACKEND" \
PLATFORM_LOG_DIR="$PLATFORM_LOG_DIR" \ PLATFORM_LOG_DIR="$PLATFORM_LOG_DIR" \
PLATFORM_ARTIFACT_DIR="$PLATFORM_ARTIFACT_DIR" \
PLATFORM_BOOTSTRAP_ADMIN_EMAIL="$PLATFORM_BOOTSTRAP_ADMIN_EMAIL" \ PLATFORM_BOOTSTRAP_ADMIN_EMAIL="$PLATFORM_BOOTSTRAP_ADMIN_EMAIL" \
PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="$PLATFORM_BOOTSTRAP_ADMIN_PASSWORD" \ PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="$PLATFORM_BOOTSTRAP_ADMIN_PASSWORD" \
PLATFORM_SECRET_ENVELOPE_KEY="$PLATFORM_SECRET_ENVELOPE_KEY" \ PLATFORM_SECRET_ENVELOPE_KEY="$PLATFORM_SECRET_ENVELOPE_KEY" \
PLATFORM_RUN_RELEASE_URL="$PLATFORM_RUN_RELEASE_URL" \
PLATFORM_BUILDER_DOCKER_BINARY="$PLATFORM_BUILDER_DOCKER_BINARY" \
PLATFORM_BUILDER_IMAGE="$PLATFORM_BUILDER_IMAGE" \
PLATFORM_BUILDER_SOURCE_DIR="$PLATFORM_BUILDER_SOURCE_DIR" \
PLATFORM_BUILDER_WORKSPACE_DIR="$PLATFORM_BUILDER_WORKSPACE_DIR" \
PLATFORM_BUILDER_TIMEOUT_SECONDS="$PLATFORM_BUILDER_TIMEOUT_SECONDS" \
go run ./cmd/platform go run ./cmd/platform
wait_for_url platform "$(local_debug_platform_url)/healthz" wait_for_url platform "$(local_debug_platform_url)/healthz"
+9 -3
View File
@@ -8,12 +8,18 @@ source "$ROOT_DIR/scripts/local-debug/env.sh"
if [[ ! -d "$LOCAL_DEBUG_PID_DIR" ]]; then if [[ ! -d "$LOCAL_DEBUG_PID_DIR" ]]; then
printf 'no local debug pid directory at %s\n' "$LOCAL_DEBUG_PID_DIR" printf 'no local debug pid directory at %s\n' "$LOCAL_DEBUG_PID_DIR"
else else
for name in platform_web run platform; do pid_files=("$LOCAL_DEBUG_PID_DIR/platform_web.pid" "$LOCAL_DEBUG_PID_DIR/run.pid" "$LOCAL_DEBUG_PID_DIR/platform.pid")
pid_file="$LOCAL_DEBUG_PID_DIR/$name.pid" for generated_pid_file in "$LOCAL_DEBUG_PID_DIR"/generated-*-run.pid; do
if [[ -f "$generated_pid_file" ]]; then
pid_files+=("$generated_pid_file")
fi
done
for pid_file in "${pid_files[@]}"; do
if [[ ! -f "$pid_file" ]]; then if [[ ! -f "$pid_file" ]]; then
continue continue
fi fi
pid="$(cat "$pid_file")" name="$(basename "$pid_file" .pid)"
pid="$(<"$pid_file")"
if kill -0 "$pid" 2>/dev/null; then if kill -0 "$pid" 2>/dev/null; then
printf 'stopping %s pid %s\n' "$name" "$pid" printf 'stopping %s pid %s\n' "$name" "$pid"
kill "$pid" 2>/dev/null || true kill "$pid" 2>/dev/null || true