feat: move distribution builds to platform Docker builder
This commit is contained in:
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- `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.
|
||||
|
||||
## 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 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.
|
||||
|
||||
@@ -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_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 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/`.
|
||||
- Logs live under `.local-debug/logs/`.
|
||||
- PIDs live under `.local-debug/pids/`.
|
||||
- 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 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
|
||||
|
||||
@@ -57,6 +57,11 @@ Key platform variables:
|
||||
- `PLATFORM_METADATA_PATH=.local-debug/platform/metadata.json`
|
||||
- `PLATFORM_LOG_BODY_BACKEND=file`
|
||||
- `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`
|
||||
|
||||
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`.
|
||||
- run endpoint heartbeat through `GET /api/v1/run/endpoints?status=online`.
|
||||
- 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`.
|
||||
- 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.
|
||||
@@ -146,7 +153,7 @@ Required walkthrough:
|
||||
- Open 插件市场.
|
||||
- Open 用户管理.
|
||||
- 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.
|
||||
|
||||
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
|
||||
(cd platform && go run ./cmd/platform)
|
||||
source scripts/local-debug/env.sh
|
||||
local_debug_prepare_distribution_builder
|
||||
local_debug_build_bootstrap_run
|
||||
"$RUN_BOOTSTRAP_BIN"
|
||||
npm --prefix platform_web run dev -- --port 5173
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-30
|
||||
+61
@@ -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.
|
||||
+31
@@ -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.
|
||||
+68
@@ -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
|
||||
@@ -27,3 +27,13 @@ PLATFORM_ARTIFACT_DIR=.platform-data/artifacts
|
||||
|
||||
# Required outside disposable local development. This protects persisted component-key ciphertext.
|
||||
# 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
@@ -54,6 +54,23 @@ Runtime configuration:
|
||||
- `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_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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -291,7 +291,9 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(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)
|
||||
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.ConfigureDistributionBuilder(builder)
|
||||
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return NewTestRouterWithCore(core)
|
||||
}
|
||||
|
||||
func newTestRouter() http.Handler {
|
||||
return newTestRouterWithDistributionBuilder(nil)
|
||||
}
|
||||
|
||||
func apiRouterWithoutSeededAdmin() http.Handler {
|
||||
return NewTestRouterWithCore(service.NewCoreService(repo.NewMemoryStore()))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/config"
|
||||
"browser.local/platform/repo"
|
||||
@@ -47,6 +48,13 @@ func NewRouterFromConfig(cfg config.Config) (http.Handler, error) {
|
||||
if err := core.ConfigureSecretEnvelopeKey(cfg.SecretEnvelopeKey); err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ Artifact bridge execution returns safe metadata and platform content routes only
|
||||
|
||||
## 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}/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.
|
||||
- `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.
|
||||
- `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/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`.
|
||||
@@ -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`.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -4,12 +4,20 @@ import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultAddr = ":8080"
|
||||
const defaultDataDir = ".platform-data"
|
||||
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 {
|
||||
Addr string
|
||||
@@ -24,6 +32,11 @@ type Config struct {
|
||||
BootstrapAdminPassword string
|
||||
SecretEnvelopeKey string
|
||||
AIProviderMode string
|
||||
BuilderDockerBinary string
|
||||
BuilderImage string
|
||||
BuilderSourceDir string
|
||||
BuilderWorkspaceDir string
|
||||
BuilderTimeoutSeconds int
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -54,6 +67,10 @@ func Load() Config {
|
||||
storageBackend = defaultStorageBackend
|
||||
}
|
||||
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{
|
||||
Addr: addr,
|
||||
@@ -68,9 +85,22 @@ func Load() Config {
|
||||
BootstrapAdminPassword: os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
SecretEnvelopeKey: os.Getenv("PLATFORM_SECRET_ENVELOPE_KEY"),
|
||||
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 {
|
||||
if value == "" {
|
||||
return fallback
|
||||
|
||||
@@ -18,6 +18,11 @@ func TestLoadUsesDefaultAddress(t *testing.T) {
|
||||
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL", "")
|
||||
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD", "")
|
||||
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()
|
||||
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") {
|
||||
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) {
|
||||
@@ -42,6 +50,11 @@ func TestLoadUsesConfiguredAddress(t *testing.T) {
|
||||
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL", "admin@example.test")
|
||||
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD", "configured-secret")
|
||||
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()
|
||||
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" {
|
||||
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) {
|
||||
@@ -106,6 +122,11 @@ func clearPlatformEnv(t *testing.T) {
|
||||
"PLATFORM_BOOTSTRAP_ADMIN_EMAIL",
|
||||
"PLATFORM_BOOTSTRAP_ADMIN_PASSWORD",
|
||||
"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, "")
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
|
||||
@@ -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
|
||||
@@ -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。
|
||||
@@ -764,8 +764,9 @@ type ServerInstance struct {
|
||||
ID string
|
||||
PluginID string
|
||||
PluginVersion string
|
||||
// DeploymentTargetID identifies the trusted existing worker used only to
|
||||
// build a server's dedicated Run package. It is not the server Run itself.
|
||||
// DeploymentTargetID identifies an optional operator-selected deployment
|
||||
// target for post-creation deployment operations. It never selects a
|
||||
// distribution builder or replaces the server's generated Run endpoint.
|
||||
DeploymentTargetID string
|
||||
RunEndpointID string
|
||||
Name string
|
||||
|
||||
@@ -202,7 +202,8 @@ type ServerInstance struct {
|
||||
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||
// PluginVersion records the plugin version used for creation or reconcile.
|
||||
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"`
|
||||
// RunEndpointID references the dedicated server Run endpoint.
|
||||
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 {
|
||||
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})
|
||||
if err != nil {
|
||||
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 {
|
||||
|
||||
@@ -195,11 +195,12 @@ func TestPluginBridgeDependencyInstallUsesReviewedPlanDigest(t *testing.T) {
|
||||
|
||||
func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.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"})
|
||||
if err != nil {
|
||||
t.Fatalf("generate update distribution: %v", err)
|
||||
}
|
||||
payload := []byte("compiled target-matched run archive")
|
||||
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})
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return validationError("client-manager build returned an unexpected artifact")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -47,19 +47,12 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
if err := svc.promoteLegacyRunBinding(&instance); err != nil {
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
builderEndpointID := instance.RunEndpointID
|
||||
if strings.TrimSpace(instance.DeploymentTargetID) != "" {
|
||||
builderEndpointID = instance.DeploymentTargetID
|
||||
}
|
||||
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
|
||||
if ready, reason := svc.distributionBuilderReadiness(); !ready {
|
||||
_ = svc.recordAuditEvent(user.ID, "run.generate.denied", "server-instance", instance.ID, domain.AuditResultDenied, reason)
|
||||
return domain.RunDistribution{}, validationError(reason)
|
||||
}
|
||||
|
||||
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
|
||||
key, _, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
|
||||
if err != nil {
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
@@ -70,7 +63,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
|
||||
_ = plainKey
|
||||
artifactID := artifactIDForDistribution(distributionID + "-binary")
|
||||
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, distributionID)
|
||||
stamp := svc.now()
|
||||
@@ -106,7 +98,7 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: buildJobID,
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: builderEndpointID,
|
||||
RunEndpointID: platformDistributionBuilderEndpointID,
|
||||
Capability: domain.JobCapabilityDistributionBuild,
|
||||
TargetKey: "distribution/run",
|
||||
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 {
|
||||
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.CopyRunDistribution(distribution), nil
|
||||
}
|
||||
|
||||
// promoteLegacyRunBinding reserves a server-scoped endpoint for a generated
|
||||
// Run before a legacy server first requests a distribution. Its existing
|
||||
// endpoint remains the trusted build target; reusing it in the package would
|
||||
// allow the generated Run to replace the builder registration.
|
||||
// promoteLegacyRunBinding reserves the server-scoped endpoint used by a
|
||||
// generated Run. A legacy shared endpoint remains an optional deployment target
|
||||
// for non-build workflows; distribution builds are always platform-owned.
|
||||
func (svc *CoreService) promoteLegacyRunBinding(instance *domain.ServerInstance) error {
|
||||
if instance == nil || strings.TrimSpace(instance.DeploymentTargetID) != "" || (instance.State != domain.ServerInstanceStateDraft && instance.State != domain.ServerInstanceStateFailed) {
|
||||
return nil
|
||||
}
|
||||
builderEndpointID := strings.TrimSpace(instance.RunEndpointID)
|
||||
if builderEndpointID == "" {
|
||||
return validationError("legacy Run generation requires a build target endpoint")
|
||||
currentEndpointID := strings.TrimSpace(instance.RunEndpointID)
|
||||
dedicatedEndpointID := dedicatedRunEndpointID(instance.ID)
|
||||
if currentEndpointID == dedicatedEndpointID {
|
||||
return nil
|
||||
}
|
||||
instance.DeploymentTargetID = builderEndpointID
|
||||
instance.RunEndpointID = dedicatedRunEndpointID(instance.ID)
|
||||
if currentEndpointID != "" {
|
||||
instance.DeploymentTargetID = currentEndpointID
|
||||
}
|
||||
instance.RunEndpointID = dedicatedEndpointID
|
||||
instance.UpdatedAt = svc.now()
|
||||
if err := validator.ValidateServerInstance(*instance); err != nil {
|
||||
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 {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if err := svc.validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
if ready, reason := svc.distributionBuilderReadiness(); !ready {
|
||||
_ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, reason)
|
||||
return domain.ClientManagerDistribution{}, validationError(reason)
|
||||
}
|
||||
|
||||
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey)
|
||||
key, _, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey)
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
@@ -210,7 +202,6 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
|
||||
_ = plainKey
|
||||
artifactID := artifactIDForDistribution(clientDistributionID + "-binary")
|
||||
stamp := svc.now()
|
||||
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, clientDistributionID)
|
||||
@@ -283,7 +274,7 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: buildJobID,
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
RunEndpointID: platformDistributionBuilderEndpointID,
|
||||
Capability: domain.JobCapabilityDistributionBuild,
|
||||
TargetKey: "distribution/client-manager/" + request.ProfileKey,
|
||||
InputRef: "input://distribution-build/" + distribution.ID,
|
||||
@@ -303,7 +294,8 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
|
||||
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.CopyClientManagerDistribution(distribution), nil
|
||||
@@ -467,7 +459,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
||||
if endpointErr != nil && strings.TrimSpace(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
|
||||
}
|
||||
hasAvailableRunPackage := false
|
||||
@@ -493,6 +485,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
||||
}
|
||||
}
|
||||
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
|
||||
builderReady, builderReason := svc.distributionBuilderReadiness()
|
||||
dependencyPermissionDeclared := pluginDeclares(plugin, "server.dependencies.manage")
|
||||
actions := domain.ServerRuntimeActions{
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -505,11 +498,11 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
||||
return domain.RunEndpointStatusOffline
|
||||
}(),
|
||||
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("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("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("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)))),
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceBuildsDedicatedRunOnDeploymentTarget(t *testing.T) {
|
||||
func TestCoreServiceBuildsDedicatedRunInPlatformBuilder(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
targetID := instance.RunEndpointID
|
||||
instance.State = domain.ServerInstanceStateDraft
|
||||
@@ -115,14 +115,14 @@ func TestCoreServiceBuildsDedicatedRunOnDeploymentTarget(t *testing.T) {
|
||||
t.Fatalf("generate dedicated Run: %v", err)
|
||||
}
|
||||
job, err := svc.GetJob(distribution.BuildJobID)
|
||||
if err != nil || job.RunEndpointID != targetID || 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)
|
||||
if err != nil || job.RunEndpointID != platformDistributionBuilderEndpointID || distribution.RunEndpointID != instance.RunEndpointID {
|
||||
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) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
builderID := instance.RunEndpointID
|
||||
legacyEndpointID := instance.RunEndpointID
|
||||
instance.State = domain.ServerInstanceStateFailed
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
t.Fatalf("mark legacy server failed: %v", err)
|
||||
@@ -136,36 +136,37 @@ func TestCoreServicePromotesLegacyRunBindingBeforeDistributionBuild(t *testing.T
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
job, err := svc.GetJob(distribution.BuildJobID)
|
||||
if err != nil || job.RunEndpointID != builderID || 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)
|
||||
if err != nil || job.RunEndpointID != platformDistributionBuilderEndpointID || distribution.RunEndpointID != migrated.RunEndpointID {
|
||||
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.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,
|
||||
TargetOS: "linux",
|
||||
TargetArch: "amd64",
|
||||
IdempotencyKey: "idem-stale-run-endpoint",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "heartbeat is stale") {
|
||||
t.Fatalf("expected stale Run endpoint rejection, got %v", err)
|
||||
if err != nil {
|
||||
t.Fatalf("platform build must ignore stale Run endpoint: %v", err)
|
||||
}
|
||||
completeDistributionBuild(t, svc, distribution, nil)
|
||||
|
||||
actions, err := svc.GetServerRuntimeActionsForSession(session, instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get runtime actions: %v", err)
|
||||
}
|
||||
for _, action := range actions.Actions {
|
||||
if action.Key == "generate-run" && action.Available {
|
||||
t.Fatalf("stale Run endpoint must not expose generate-run as available: %+v", action)
|
||||
if action.Key == "generate-run" && !action.Available {
|
||||
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) {
|
||||
t.Setenv("PLATFORM_RUN_RELEASE_URL", "https://scum.npc0.com")
|
||||
func TestCoreServiceMachineEndpointCannotClaimPlatformDistributionBuild(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
TargetOS: "linux",
|
||||
TargetArch: "amd64",
|
||||
IdempotencyKey: "idem-premature-result",
|
||||
IdempotencyKey: "idem-platform-build-ownership",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("generate run distribution: %v", err)
|
||||
@@ -223,7 +223,7 @@ func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUplo
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityDistributionBuild)
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register build worker: %v", err)
|
||||
t.Fatalf("register machine endpoint: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
@@ -231,50 +231,21 @@ func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUplo
|
||||
Capabilities: []string{domain.JobCapabilityDistributionBuild},
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("expected raw executable build input with release URL and long key, got %+v", buildInput)
|
||||
}
|
||||
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 claim.HasJob {
|
||||
t.Fatalf("machine-side endpoint must not receive platform build job: %+v", claim)
|
||||
}
|
||||
|
||||
if _, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, []byte("actual compiled archive")); err != nil {
|
||||
t.Fatalf("publish uploaded build output: %v", err)
|
||||
distribution = completeDistributionBuild(t, svc, distribution, nil)
|
||||
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 {
|
||||
t.Fatalf("retry success after artifact upload: %v", 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)
|
||||
artifact, err := svc.GetArtifact(distribution.ArtifactID)
|
||||
if err != nil || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != job.ID {
|
||||
t.Fatalf("expected build job-owned artifact, artifact=%+v err=%v", artifact, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +319,8 @@ func TestCoreServiceRunDistributionRetryReusesPartialArtifact(t *testing.T) {
|
||||
|
||||
func TestCoreServicePushRunUpdateReusesExistingUpdateJob(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
payload := []byte("compiled run archive")
|
||||
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
|
||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
TargetOS: "linux",
|
||||
@@ -357,7 +330,7 @@ func TestCoreServicePushRunUpdateReusesExistingUpdateJob(t *testing.T) {
|
||||
if err != nil {
|
||||
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{
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -624,7 +597,6 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
|
||||
t.Fatalf("update plugin fixture: %v", err)
|
||||
}
|
||||
endpoint.Capabilities = append(endpoint.Capabilities,
|
||||
domain.JobCapabilityDistributionBuild,
|
||||
domain.JobCapabilityRunSelfUpdate,
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
@@ -704,58 +676,44 @@ func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string,
|
||||
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()
|
||||
artifact, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("publish run build artifact: %v", err)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
updated, err := svc.store.RunDistributions().Get(distribution.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get run distribution: %v", err)
|
||||
}
|
||||
if updated.Status == domain.DistributionStatusAvailable {
|
||||
return updated
|
||||
}
|
||||
if updated.Status == domain.DistributionStatusFailed {
|
||||
t.Fatalf("platform run build failed: %+v", updated)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("get completed run distribution: %v", err)
|
||||
}
|
||||
return updated
|
||||
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()
|
||||
artifact, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("publish client build artifact: %v", err)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
updated, err := svc.store.ClientManagerDistributions().Get(distribution.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get client distribution: %v", err)
|
||||
}
|
||||
if updated.Status == domain.DistributionStatusAvailable {
|
||||
return updated
|
||||
}
|
||||
if updated.Status == domain.DistributionStatusFailed {
|
||||
t.Fatalf("platform client build failed: %+v", updated)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("get completed client distribution: %v", err)
|
||||
}
|
||||
return updated
|
||||
t.Fatalf("platform client build did not complete")
|
||||
return domain.ClientManagerDistribution{}
|
||||
}
|
||||
|
||||
func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) {
|
||||
|
||||
@@ -249,6 +249,9 @@ type CoreService struct {
|
||||
aiProviderClient AIProviderClient
|
||||
secretEnvelope SecretEnvelope
|
||||
networkFingerprintKey []byte
|
||||
distributionBuilder DistributionBuilder
|
||||
distributionBuildMu sync.Mutex
|
||||
distributionBuilds map[string]struct{}
|
||||
}
|
||||
|
||||
var _ Core = (*CoreService)(nil)
|
||||
@@ -284,10 +287,34 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
|
||||
aiProviderClient: MockAIProviderClient{},
|
||||
secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey),
|
||||
networkFingerprintKey: []byte(developmentSecretEnvelopeKey),
|
||||
distributionBuilder: unconfiguredDistributionBuilder{},
|
||||
distributionBuilds: map[string]struct{}{},
|
||||
}
|
||||
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) {
|
||||
if artifactStore == nil {
|
||||
artifactStore = NewMemoryArtifactBodyStore()
|
||||
@@ -2195,12 +2222,18 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
|
||||
return domain.Job{}, err
|
||||
}
|
||||
|
||||
endpoint, err := svc.store.RunEndpoints().Get(job.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.Job{}, fmt.Errorf("get run endpoint dependency: %w", err)
|
||||
}
|
||||
if err := svc.validateRunnableEndpoint(endpoint, job.Capability); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return domain.Job{}, fmt.Errorf("get run endpoint dependency: %w", err)
|
||||
}
|
||||
if err := svc.validateRunnableEndpoint(endpoint, job.Capability); err != nil {
|
||||
return domain.Job{}, err
|
||||
}
|
||||
}
|
||||
if job.ServerInstanceID != "" {
|
||||
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
||||
@@ -2346,8 +2379,8 @@ func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plu
|
||||
if instance.State == domain.ServerInstanceStateDeleted {
|
||||
return validationError("server instance must not be deleted")
|
||||
}
|
||||
usesDeploymentTarget := job.Capability == domain.JobCapabilityDistributionBuild && instance.DeploymentTargetID != "" && instance.DeploymentTargetID == job.RunEndpointID
|
||||
if instance.RunEndpointID != job.RunEndpointID && !usesDeploymentTarget {
|
||||
usesPlatformBuilder := job.Capability == domain.JobCapabilityDistributionBuild && job.RunEndpointID == platformDistributionBuilderEndpointID
|
||||
if instance.RunEndpointID != job.RunEndpointID && !usesPlatformBuilder {
|
||||
return validationError("job runEndpointId must match server instance")
|
||||
}
|
||||
if plugin.ID != instance.PluginID {
|
||||
|
||||
@@ -1407,7 +1407,28 @@ func TestCoreServicePropagatesDuplicateErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -40,6 +40,7 @@ const fallbackFragments = [
|
||||
async function main() {
|
||||
await mkdir(evidenceDir, { recursive: true });
|
||||
|
||||
const smokeSeed = await loadSmokeSeed();
|
||||
const session = await loginApi();
|
||||
const authHeaders = { Authorization: `Bearer ${session.sessionId}` };
|
||||
await ensureAiProvider(authHeaders);
|
||||
@@ -47,7 +48,7 @@ async function main() {
|
||||
const [instances, endpoints, jobs, plugins, marketplace, users, providers, logStreams, artifacts, usage] = await Promise.all([
|
||||
getJson("/server-instances", 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("/plugin-marketplace/plugins", authHeaders),
|
||||
getJson("/users", authHeaders),
|
||||
@@ -57,15 +58,18 @@ async function main() {
|
||||
getJson("/metrics/platform", authHeaders)
|
||||
]);
|
||||
|
||||
const server = findRequired(instances.items, (item) => item.id === "server-local-debug", "server-local-debug instance");
|
||||
const runEndpoint = findRequired(endpoints.items, (item) => item.id === "run-local-debug", "run-local-debug endpoint");
|
||||
const server = findRequired(instances.items, (item) => item.id === smokeSeed.serverLocalId, `${smokeSeed.serverLocalId} instance`);
|
||||
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 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 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.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.start", "run endpoint exposes process.start");
|
||||
assertIncludes(runEndpoint.capabilities, "process.stop", "run endpoint exposes process.stop");
|
||||
@@ -90,6 +94,7 @@ async function main() {
|
||||
platformUrl,
|
||||
webUrl,
|
||||
localDebugRoot,
|
||||
smokeSeed,
|
||||
seedEvidenceDir: path.join(localDebugRoot, "smoke"),
|
||||
session: {
|
||||
userId: session.user.id,
|
||||
@@ -169,7 +174,7 @@ async function main() {
|
||||
},
|
||||
{
|
||||
name: "服务器详情",
|
||||
hash: "#/servers/server-local-debug",
|
||||
hash: `#/servers/${encodeURIComponent(server.id)}`,
|
||||
markers: [
|
||||
server.name,
|
||||
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`,
|
||||
@@ -225,6 +230,28 @@ async function main() {
|
||||
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() {
|
||||
const response = await postJson("/auth/login", {
|
||||
account: "operator.local@example.test",
|
||||
@@ -362,9 +389,9 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
|
||||
}
|
||||
}
|
||||
|
||||
await chrome.evaluate(() => {
|
||||
window.location.hash = "#/servers/server-local-debug";
|
||||
});
|
||||
await chrome.evaluate((serverID) => {
|
||||
window.location.hash = `#/servers/${encodeURIComponent(serverID)}`;
|
||||
}, server.id);
|
||||
await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`);
|
||||
const pluginControls = await clickAndVerify(chrome, "插件控制", ["生产生命周期", "Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
|
||||
const pluginLayout = await chrome.layoutSnapshot();
|
||||
@@ -438,7 +465,7 @@ async function clickAndVerify(chrome, buttonText, markers) {
|
||||
}
|
||||
|
||||
async function verifyServerQuickRuntimeMenu(chrome, label) {
|
||||
const markers = ["生成 run", "下载 run", "推送更新", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"];
|
||||
const markers = ["生成 run", "下载 run", "更新 run", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"];
|
||||
await chrome.evaluate(() => {
|
||||
const trigger = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes("运行操作"));
|
||||
if (!(trigger instanceof HTMLButtonElement)) {
|
||||
@@ -787,13 +814,13 @@ async function verifyLifecycleOperation(headers, server, chrome) {
|
||||
if (result.job.capability !== expectedCapability) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
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");
|
||||
const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]);
|
||||
|
||||
@@ -953,6 +980,8 @@ async function startChrome() {
|
||||
const clipped = intersection(rectFromDomRect(rect), clipForElement(element));
|
||||
const visibleWidth = Math.max(0, clipped.right - clipped.left);
|
||||
const visibleHeight = Math.max(0, clipped.bottom - clipped.top);
|
||||
const floatingMenu = element.closest(".runtime-action-popover");
|
||||
const floatingMenuRect = floatingMenu?.getBoundingClientRect();
|
||||
return {
|
||||
tag: element.tagName.toLowerCase(),
|
||||
text: (element.textContent || element.getAttribute("aria-label") || "").trim().slice(0, 60),
|
||||
@@ -961,7 +990,8 @@ async function startChrome() {
|
||||
right: Math.round(clipped.right),
|
||||
bottom: Math.round(clipped.bottom),
|
||||
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);
|
||||
@@ -969,6 +999,9 @@ async function startChrome() {
|
||||
const overlappingControls = [];
|
||||
for (let index = 0; index < controls.length; index += 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 top = Math.max(controls[index].top, controls[otherIndex].top);
|
||||
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 { 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";
|
||||
|
||||
type WorkflowKind = "create" | "edit";
|
||||
@@ -18,37 +18,32 @@ interface ServerDeploymentWorkflowProps {
|
||||
busy?: boolean;
|
||||
onReveal?: () => Promise<ServerDeploymentRevealResponse>;
|
||||
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) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [form, setForm] = useState<ServerCreateFormState>(initialForm);
|
||||
const [saveAsDraft, setSaveAsDraft] = useState(false);
|
||||
const [revealBusy, setRevealBusy] = useState(false);
|
||||
const [revealError, setRevealError] = useState("");
|
||||
const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]);
|
||||
const profileOptions = selectedPlugin?.runtimeProfiles?.lifecycleProfiles ?? [];
|
||||
const pluginFields = selectedPlugin?.createFields ?? [];
|
||||
const bindingFields = runtimeBindingFields(selectedPlugin, form.profileKey);
|
||||
const activeServer = kind === "edit" && Boolean(deployment);
|
||||
const isScum = selectedPlugin?.id === "game.scum";
|
||||
const needsTargetSelection = kind === "create" || !initialForm.runEndpointId;
|
||||
const selectedTargetID = kind === "create" ? form.deploymentTargetId : form.runEndpointId;
|
||||
const needsTargetSelection = kind === "edit" && !initialForm.runEndpointId;
|
||||
const selectedTargetID = form.runEndpointId;
|
||||
const workflowSteps = kind === "create"
|
||||
? [{ label: "选择目标", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]
|
||||
? [{ label: "基本信息", icon: Compass }, { label: "确认", icon: Rocket }]
|
||||
: needsTargetSelection
|
||||
? [{ label: "选择运行节点", icon: Compass }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]
|
||||
: [{ label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }];
|
||||
const pluginStep = kind === "create" ? 0 : -1;
|
||||
const targetStep = needsTargetSelection ? 0 : -1;
|
||||
const modeStep = kind === "create" ? 1 : -1;
|
||||
const configurationStep = kind === "create" ? 2 : needsTargetSelection ? 1 : 0;
|
||||
const configurationStep = kind === "create" ? -1 : needsTargetSelection ? 1 : 0;
|
||||
const reviewStep = workflowSteps.length - 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStep(0);
|
||||
setSaveAsDraft(kind === "create" && !initialForm.runEndpointId);
|
||||
setForm(initialForm);
|
||||
setRevealBusy(false);
|
||||
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 updateBinding(key: string, value: string) { setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } })); }
|
||||
|
||||
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 (kind === "create" && !form.name.trim()) 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 === "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>) {
|
||||
event.preventDefault();
|
||||
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() {
|
||||
@@ -114,24 +108,20 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
|
||||
}
|
||||
|
||||
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" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
|
||||
<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">
|
||||
{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-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>
|
||||
{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 className="form-guidance"><strong>这个草稿尚未绑定运行节点</strong><span>只需在这里补选一次。已绑定服务器编辑时会直接进入相关配置,不会重复要求选择目标。</span></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>
|
||||
</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">
|
||||
{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>}
|
||||
{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>}
|
||||
@@ -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 === "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>}
|
||||
{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>}
|
||||
{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>
|
||||
</form>
|
||||
</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 }) {
|
||||
const steps = isScum ? [
|
||||
{ icon: ScanSearch, title: "预检目录与端口", copy: "确认安装目录可用、节点兼容且端口可绑定。" },
|
||||
|
||||
Generated
+536
@@ -16,11 +16,218 @@
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react-swc": "4.3.1",
|
||||
"jsdom": "29.1.1",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.1",
|
||||
"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": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
@@ -463,6 +670,24 @@
|
||||
"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": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -1244,6 +1469,16 @@
|
||||
"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": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
|
||||
@@ -1254,6 +1489,20 @@
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1261,6 +1510,40 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.7.0",
|
||||
"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_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": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.23.0.tgz",
|
||||
@@ -1382,6 +1737,13 @@
|
||||
"@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": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -1412,6 +1774,19 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -1469,6 +1844,16 @@
|
||||
"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": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
|
||||
@@ -1491,6 +1876,16 @@
|
||||
"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": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz",
|
||||
@@ -1536,6 +1931,19 @@
|
||||
"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": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -1573,6 +1981,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -1617,6 +2032,52 @@
|
||||
"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": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1631,6 +2092,16 @@
|
||||
"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": {
|
||||
"version": "7.3.1",
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -1801,6 +2320,23 @@
|
||||
"engines": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react-swc": "4.3.1",
|
||||
"jsdom": "29.1.1",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.1",
|
||||
"vitest": "4.0.18"
|
||||
|
||||
@@ -168,17 +168,16 @@ describe("first-party console pages", () => {
|
||||
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("openEditDeployment");
|
||||
expect(serversPageSource).toContain("编辑部署");
|
||||
expect(serverDetailPageSource).toContain("<ServerDeploymentWorkflow");
|
||||
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("创建服务器");
|
||||
expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)");
|
||||
expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录");
|
||||
expect(serverDeploymentWorkflowSource).toContain("安装目录{isScum ? \"(必填)\" : \"(可选)\"}");
|
||||
@@ -193,14 +192,14 @@ describe("first-party console pages", () => {
|
||||
expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的受控升级任务");
|
||||
expect(serverDeploymentWorkflowSource).toContain("已绑定服务器编辑时会直接进入相关配置");
|
||||
expect(serverDeploymentWorkflowSource).toContain("可在此调整部署方式;不会重复要求选择已绑定的运行节点");
|
||||
expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" })');
|
||||
expect(serverDeploymentWorkflowSource).toContain("运行连接设置");
|
||||
expect(serverDeploymentWorkflowSource).toContain('type={field.sensitive ? "password" : "text"}');
|
||||
expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })');
|
||||
expect(serverDetailPageSource).toContain("运行配置绑定");
|
||||
expect(serverDetailPageSource).toContain('type={field.sensitive ? "password" : "text"}');
|
||||
expect(serverDeploymentWorkflowSource).toContain("显示已保存配置");
|
||||
expect(serverDeploymentWorkflowSource).toContain("revealSavedInputs");
|
||||
expect(serversPageSource).toContain("revealServerDeployment");
|
||||
expect(serverDetailPageSource).toContain("最近 Run 调度");
|
||||
expect(serversPageSource).toContain("serverCreateRequestFromForm(nextForm)");
|
||||
expect(serversPageSource).toContain("minimalServerCreateRequestFromForm(nextForm)");
|
||||
expect(serverDeploymentWorkflowSource).not.toContain('name="id"');
|
||||
expect(serverDeploymentWorkflowSource).not.toContain("实例 ID");
|
||||
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", () => {
|
||||
expect(serversPageSource).toContain('aria-haspopup="menu"');
|
||||
expect(serversPageSource).toContain("createPortal");
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
logBackfillRequest,
|
||||
runDistributionGenerateRequest,
|
||||
runUpdateRequest,
|
||||
serverCreateRequestFromForm,
|
||||
minimalServerCreateRequestFromForm,
|
||||
serverDeleteConfirmation
|
||||
} from "../schemas/serverManagement";
|
||||
import { isPlatformAdmin } from "../contracts/workspace";
|
||||
@@ -156,12 +156,12 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
async function handleCreate(nextForm: ServerCreateFormState) {
|
||||
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
|
||||
try {
|
||||
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(nextForm));
|
||||
operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `已保存草稿 ${result.instance.id};请生成并启动专属 Run,注册成功后平台会自动部署。`, result.job.id ? result.job : undefined);
|
||||
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);
|
||||
setForm(defaultServerCreateForm(plugins, endpoints));
|
||||
setShowCreate(false);
|
||||
await refresh();
|
||||
onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" });
|
||||
onNavigate("serverDetail", { serverId: result.instance.id });
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId);
|
||||
}
|
||||
|
||||
Generated
+1158
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
|
||||
import { serverCreateRequestFromForm, serverInstanceIdFromName } from "./serverManagement";
|
||||
import { minimalServerCreateRequestFromForm, serverCreateRequestFromForm, serverInstanceIdFromName } from "./serverManagement";
|
||||
|
||||
const plugin: GamePluginResponse = {
|
||||
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", () => {
|
||||
const form = defaultServerCreateForm([plugin], []);
|
||||
const request = serverCreateRequestFromForm({ ...form, name: " Runtime Server ", bindings: {} }, 17);
|
||||
|
||||
@@ -11,6 +11,16 @@ import type {
|
||||
} from "../api/types";
|
||||
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 {
|
||||
const id = form.id.trim() || serverInstanceIdFromName(form.name, sequence);
|
||||
return {
|
||||
|
||||
@@ -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)}
|
||||
.danger-command{border-color:rgba(214,51,92,.5);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-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}
|
||||
|
||||
@@ -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_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_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_REPO_DIR="$RUN_SOURCE_DIR"
|
||||
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_DISPLAY_NAME="${RUN_DISPLAY_NAME:-Local Debug Run}"
|
||||
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_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 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_MAX_JOBS="${RUN_MAX_JOBS:-1}"
|
||||
export RUN_HEARTBEAT_INTERVAL_MS="${RUN_HEARTBEAT_INTERVAL_MS:-2000}"
|
||||
@@ -162,6 +168,24 @@ local_debug_prepare_run_build_source() {
|
||||
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_prepare_run_build_source
|
||||
mkdir -p "$(dirname "$RUN_BOOTSTRAP_BIN")"
|
||||
|
||||
+388
-64
@@ -8,14 +8,35 @@ source "$ROOT_DIR/scripts/local-debug/env.sh"
|
||||
PLATFORM_URL="$(local_debug_platform_url)"
|
||||
API_URL="$PLATFORM_URL/api/v1"
|
||||
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"
|
||||
|
||||
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_REPO_DIR=$RUN_REPO_DIR
|
||||
RUN_BUILD_BUCKET_ROOT=$RUN_BUILD_BUCKET_ROOT
|
||||
RUN_BUILD_SOURCE_ROOT=$RUN_BUILD_SOURCE_ROOT
|
||||
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_SPOOL_ROOT=$RUN_SPOOL_ROOT
|
||||
RUN_MAX_JOBS=$RUN_MAX_JOBS
|
||||
@@ -23,10 +44,10 @@ EOF
|
||||
|
||||
prepare_run_workspace() {
|
||||
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 scum-alpha game.scum run-local
|
||||
local_debug_seed_server_lifecycle_workspace scum-beta game.scum run-local
|
||||
local_debug_seed_server_lifecycle_workspace scum-dynamic game.scum run-local
|
||||
local_debug_seed_server_lifecycle_workspace "$SERVER_LOCAL_ID" game.example run-local
|
||||
local_debug_seed_server_lifecycle_workspace "$SCUM_ALPHA_ID" 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_ID" game.scum run-local
|
||||
}
|
||||
|
||||
prepare_run_workspace
|
||||
@@ -58,8 +79,55 @@ wait_for_url() {
|
||||
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() {
|
||||
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
|
||||
|
||||
printf 'self-starting platform for local debug smoke\n'
|
||||
@@ -73,10 +141,16 @@ start_self_hosted_stack() {
|
||||
PLATFORM_METADATA_PATH="$PLATFORM_METADATA_PATH" \
|
||||
PLATFORM_LOG_BODY_BACKEND="$PLATFORM_LOG_BODY_BACKEND" \
|
||||
PLATFORM_LOG_DIR="$PLATFORM_LOG_DIR" \
|
||||
PLATFORM_ARTIFACT_DIR="$PLATFORM_ARTIFACT_DIR" \
|
||||
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_ARTIFACT_DIR="$PLATFORM_ARTIFACT_DIR" \
|
||||
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_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
|
||||
) >"$LOCAL_DEBUG_LOG_DIR/platform.log" 2>&1 &
|
||||
SELF_STARTED_PIDS+=("$!")
|
||||
@@ -402,6 +476,108 @@ if (reference.artifactId !== artifactId || reference.checksum !== checksum || pa
|
||||
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() {
|
||||
local server_id="$1"
|
||||
local output_file="$2"
|
||||
@@ -476,7 +652,7 @@ const manifest = {
|
||||
createFormSchema: source.server.createFormSchema
|
||||
},
|
||||
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: {
|
||||
install: source.actions.install,
|
||||
start: source.actions.start,
|
||||
@@ -488,11 +664,11 @@ const manifest = {
|
||||
key: "logs",
|
||||
title: "Logs",
|
||||
path: "/logs",
|
||||
permissions: ["server.read", "server.lifecycle", "server.logs.read", "server.artifacts.read", "ai.invoke"],
|
||||
bridgeActions: ["server.instances.read", "jobs.dispatch", "logs.query", "artifacts.open", "plugin-lifecycle.request", "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", "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,
|
||||
productionLifecycle: source.productionLifecycle,
|
||||
runtimeProfiles: {
|
||||
@@ -595,25 +771,45 @@ done
|
||||
require_file_contains "$WORK_DIR/run-endpoints.response.json" "$RUN_ENDPOINT_ID"
|
||||
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
|
||||
{
|
||||
"id": "server-local-debug",
|
||||
"id": "$SERVER_LOCAL_ID",
|
||||
"pluginId": "game.example",
|
||||
"runEndpointId": "$RUN_ENDPOINT_ID",
|
||||
"name": "Local Debug Example Server",
|
||||
"idempotencyKey": "local-debug-create",
|
||||
"name": "Local Debug Example Server $SMOKE_INVOCATION_ID",
|
||||
"idempotencyKey": "local-debug-create-$SMOKE_INVOCATION_ID"
|
||||
}
|
||||
JSON
|
||||
|
||||
cat >"$WORK_DIR/server-runtime-binding.request.json" <<JSON
|
||||
{
|
||||
"profileKey": "run-local",
|
||||
"bindings": {}
|
||||
}
|
||||
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
|
||||
{
|
||||
"id": "scum-alpha",
|
||||
"id": "$SCUM_ALPHA_ID",
|
||||
"pluginId": "game.scum",
|
||||
"runEndpointId": "$RUN_ENDPOINT_ID",
|
||||
"name": "SCUM Alpha",
|
||||
"idempotencyKey": "local-debug-scum-alpha-create",
|
||||
"name": "SCUM Alpha $SMOKE_INVOCATION_ID",
|
||||
"idempotencyKey": "local-debug-scum-alpha-create-$SMOKE_INVOCATION_ID"
|
||||
}
|
||||
JSON
|
||||
|
||||
cat >"$WORK_DIR/scum-alpha-runtime-binding.request.json" <<JSON
|
||||
{
|
||||
"profileKey": "run-local",
|
||||
"bindings": {}
|
||||
}
|
||||
@@ -621,11 +817,11 @@ JSON
|
||||
|
||||
cat >"$WORK_DIR/create-scum-beta.request.json" <<JSON
|
||||
{
|
||||
"id": "scum-beta",
|
||||
"id": "$SCUM_BETA_ID",
|
||||
"pluginId": "game.scum",
|
||||
"runEndpointId": "$RUN_ENDPOINT_ID",
|
||||
"name": "SCUM Beta",
|
||||
"idempotencyKey": "local-debug-scum-beta-create",
|
||||
"name": "SCUM Beta $SMOKE_INVOCATION_ID",
|
||||
"idempotencyKey": "local-debug-scum-beta-create-$SMOKE_INVOCATION_ID",
|
||||
"profileKey": "run-local",
|
||||
"bindings": {}
|
||||
}
|
||||
@@ -633,11 +829,11 @@ JSON
|
||||
|
||||
cat >"$WORK_DIR/create-scum-dynamic.request.json" <<JSON
|
||||
{
|
||||
"id": "scum-dynamic",
|
||||
"id": "$SCUM_DYNAMIC_ID",
|
||||
"pluginId": "game.scum",
|
||||
"runEndpointId": "$RUN_ENDPOINT_ID",
|
||||
"name": "SCUM Dynamic",
|
||||
"idempotencyKey": "local-debug-scum-dynamic-create",
|
||||
"name": "SCUM Dynamic $SMOKE_INVOCATION_ID",
|
||||
"idempotencyKey": "local-debug-scum-dynamic-create-$SMOKE_INVOCATION_ID",
|
||||
"profileKey": "run-local",
|
||||
"bindings": {}
|
||||
}
|
||||
@@ -645,20 +841,20 @@ JSON
|
||||
|
||||
cat >"$WORK_DIR/scum-alpha-run-generate.request.json" <<JSON
|
||||
{
|
||||
"targetOs": "windows",
|
||||
"targetOs": "linux",
|
||||
"targetArch": "amd64",
|
||||
"idempotencyKey": "local-debug-scum-alpha-run-generate"
|
||||
"idempotencyKey": "local-debug-scum-alpha-run-generate-$SMOKE_INVOCATION_ID"
|
||||
}
|
||||
JSON
|
||||
|
||||
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"
|
||||
|
||||
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 beta" "scum-beta" "$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 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_ID" "$WORK_DIR/create-scum-beta.request.json" "$WORK_DIR/create-scum-beta.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-beta.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"'
|
||||
|
||||
SERVER_ID="$(json_id "$WORK_DIR/create-server.response.json")"
|
||||
SCUM_ALPHA_ID="$(json_id "$WORK_DIR/create-scum-alpha.response.json")"
|
||||
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_ALPHA_ID="$(json_id "$WORK_DIR/create-scum-alpha.response.json")"
|
||||
CREATED_SCUM_BETA_ID="$(json_id "$WORK_DIR/create-scum-beta.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"
|
||||
|
||||
@@ -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[@]}"
|
||||
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"'
|
||||
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 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 !== "run endpoint cannot build distributions")) {
|
||||
console.error("expected SCUM generate-run action to match the Run capability report");
|
||||
if (!action || action.available !== true) {
|
||||
console.error("expected SCUM generate-run action to use the ready platform builder");
|
||||
console.error(JSON.stringify(response, null, 2));
|
||||
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
|
||||
)"
|
||||
|
||||
if [[ "$SCUM_BUILD_AVAILABLE" == "true" ]]; then
|
||||
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"
|
||||
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"'
|
||||
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'
|
||||
node - "$WORK_DIR/scum-alpha-run-generate.response.json" <<'NODE'
|
||||
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"
|
||||
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" "\"artifactId\"[[:space:]]*:[[:space:]]*\"artifact-run-dist-$SCUM_ALPHA_ID"
|
||||
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'
|
||||
const fs = require("fs");
|
||||
const distribution = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
if (distribution.status !== "building" && distribution.status !== "available") {
|
||||
@@ -706,14 +1009,11 @@ if (distribution.status !== "building" && distribution.status !== "available") {
|
||||
process.exit(1);
|
||||
}
|
||||
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"
|
||||
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-job.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"
|
||||
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
|
||||
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-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"
|
||||
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-download.response.json"
|
||||
|
||||
printf 'checking jobs, logs, artifacts, and marketplace refs\n'
|
||||
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?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"'
|
||||
if [[ "$SCUM_BUILD_AVAILABLE" == "true" ]]; then
|
||||
require_file_contains "$WORK_DIR/artifacts.response.json" '"id"[[:space:]]*:[[:space:]]*"artifact-run-dist-scum-alpha'
|
||||
fi
|
||||
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/artifacts.response.json" "\"id\"[[:space:]]*:[[:space:]]*\"artifact-run-dist-$SCUM_ALPHA_ID"
|
||||
require_file_contains "$WORK_DIR/scum-alpha-jobs.response.json" "\"serverInstanceId\"[[:space:]]*:[[:space:]]*\"$SCUM_ALPHA_ID\""
|
||||
require_file_contains "$WORK_DIR/scum-beta-jobs.response.json" "\"serverInstanceId\"[[:space:]]*:[[:space:]]*\"$SCUM_BETA_ID\""
|
||||
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
|
||||
reject_forbidden_fragments "$file"
|
||||
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
|
||||
exit 1
|
||||
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
|
||||
printf 'PLATFORM_API_PROXY must be %s, got %s\n' "$PLATFORM_URL" "$PLATFORM_API_PROXY" >&2
|
||||
exit 1
|
||||
@@ -759,8 +1083,8 @@ cat >"$WORK_DIR/local-ui-checklist.md" <<EOF
|
||||
- Login with operator.local@example.test / operator-local.
|
||||
- Confirm the login path is API-backed and no local fallback banner or fallback workspace appears.
|
||||
- Visit 首页, 服务器管理, 插件市场, 用户管理, AI 提供商管理.
|
||||
- Open server-local-debug 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 服务器管理.
|
||||
- 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_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.
|
||||
- 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.
|
||||
|
||||
@@ -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 "$GOCACHE"
|
||||
local_debug_prepare_run_lifecycle_templates
|
||||
local_debug_prepare_distribution_builder
|
||||
|
||||
managed_pid_running() {
|
||||
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 source snapshot: %s\n' "$RUN_BUILD_SOURCE_ROOT"
|
||||
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 'run log: %s\n' "$LOCAL_DEBUG_LOG_DIR/run.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_LOG_BODY_BACKEND="$PLATFORM_LOG_BODY_BACKEND" \
|
||||
PLATFORM_LOG_DIR="$PLATFORM_LOG_DIR" \
|
||||
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_ARTIFACT_DIR="$PLATFORM_ARTIFACT_DIR" \
|
||||
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_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
|
||||
|
||||
wait_for_url platform "$(local_debug_platform_url)/healthz"
|
||||
|
||||
@@ -8,12 +8,18 @@ source "$ROOT_DIR/scripts/local-debug/env.sh"
|
||||
if [[ ! -d "$LOCAL_DEBUG_PID_DIR" ]]; then
|
||||
printf 'no local debug pid directory at %s\n' "$LOCAL_DEBUG_PID_DIR"
|
||||
else
|
||||
for name in platform_web run platform; do
|
||||
pid_file="$LOCAL_DEBUG_PID_DIR/$name.pid"
|
||||
pid_files=("$LOCAL_DEBUG_PID_DIR/platform_web.pid" "$LOCAL_DEBUG_PID_DIR/run.pid" "$LOCAL_DEBUG_PID_DIR/platform.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
|
||||
continue
|
||||
fi
|
||||
pid="$(cat "$pid_file")"
|
||||
name="$(basename "$pid_file" .pid)"
|
||||
pid="$(<"$pid_file")"
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
printf 'stopping %s pid %s\n' "$name" "$pid"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
|
||||
Reference in New Issue
Block a user