separate server run bindings from deployment targets

This commit is contained in:
npc0-hue
2026-07-25 19:48:26 +08:00
parent cea7472517
commit e5c94be1db
28 changed files with 465 additions and 104 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-25
@@ -0,0 +1,59 @@
## Context
`ServerInstance.RunEndpointID` currently points at a global endpoint selected while creating the server. That endpoint both executes the initial install/build jobs and is later treated as the endpoint for a generated, server-scoped Run. The type has no server ownership, registration accepts any endpoint ID after validating only the component key, and reset revokes the shared endpoint session. The browser consequently asks for a Run before the server-specific Run exists.
## Goals / Non-Goals
**Goals:**
- Keep a server bound to exactly one dedicated Run endpoint after registration.
- Preserve an explicit deployment target for target OS, architecture, and trusted build work without treating it as the server Run.
- Make draft creation, reserved endpoint identity, Run registration, deployment, replacement, and revocation stateful and auditable.
- Prevent authenticated Run binaries from claiming another server endpoint or jobs.
**Non-Goals:**
- Adding host provisioning, SSH, a second executor source tree, or browser access to host paths and credentials.
- Migrating already-active legacy server endpoints automatically; they remain readable and are handled through an explicit future migration.
- Changing plugin runtime-profile, channel isolation, or artifact confidentiality rules.
## Decisions
### 1. Store deployment target and dedicated Run separately
`ServerInstance` gains `DeploymentTargetID` while `RunEndpointID` is reserved for the dedicated endpoint. A server draft can have a target but no Run endpoint. The target must be an online compatible endpoint with `distribution.build`; it is used only to build the dedicated Run package. Lifecycle deployment and subsequent runtime work require the dedicated endpoint.
This avoids overloading an existing field. Reusing `RunEndpointID` with a role enum would leave historical jobs and control sessions ambiguous.
### 2. Reserve deterministic endpoint ownership before package generation
The dedicated Run endpoint ID is deterministic (`server-run-<server instance id>`). Package generation persists that reserved identity. A component-authenticated hello must use this identity, name the same server and Run component, and is rejected if the endpoint belongs to another server. Control registration records the ownership only after all checks pass.
This avoids a browser-chosen endpoint ID and permits Run to self-register without exposing a registration credential separate from the component key.
### 3. Draft first; deployment is explicit
The create workflow always creates a `draft`; choosing a deployment target does not queue installation. After a Run package is generated, downloaded, and registers online, the operator explicitly deploys the definition. This removes the bootstrap loop and keeps all execution behind an authenticated Run.
### 4. Use a focused additive migration
The new fields are additive. Legacy instances with a populated `RunEndpointID` and no `DeploymentTargetID` keep their existing behavior through a compatibility branch. New server workflow requests use the new draft path. A later migration can opt legacy records into exclusive ownership only after operational review.
## Risks / Trade-offs
- [A build target must already exist] → The UI calls it a deployment target and explains that it is a trusted build worker, not the dedicated Run.
- [Legacy endpoints can be shared] → New exclusive ownership checks apply only to newly reserved endpoints; legacy records are not silently broken.
- [Registration failure leaves a package unusable] → The server remains a recoverable draft with a redacted reason and can regenerate/reset the Run key.
- [Run repository must adopt the identity] → Document the revised hello contract and gate deployment until matching registration occurs.
## Migration Plan
1. Add additive domain/model/DTO fields and persistence compatibility.
2. Create new servers as target-bound drafts and generate packages through their deployment target.
3. Require reserved identity checks at component hello and only dispatch deployment to the dedicated endpoint.
4. Update the independent Run implementation to send the reserved endpoint ID, then enable the new workflow in environments that have it.
5. Roll back by leaving new servers as drafts; no browser or platform fallback executes work directly on a target host.
## Open Questions
- The independent Run repository must consume the returned/reserved endpoint identity before an end-to-end real-machine rollout.
@@ -0,0 +1,28 @@
## Why
The current server workflow uses one `RunEndpoint` both as a pre-existing worker that builds and installs software and as the server-scoped Run binary generated after creation. This creates a bootstrap loop, permits multiple servers to share one endpoint despite the intended one-server/one-Run model, and leaves component registration insufficiently bound to its server.
## What Changes
- Add a server-scoped Run binding with an explicit lifecycle from draft, through target selection and Run registration, to deployment.
- **BREAKING** Separate a deployment target from the dedicated Run endpoint that controls one server; a selected target no longer immediately dispatches installation.
- Require a generated Run to register with the endpoint identity reserved for its server before deployment or runtime jobs can be dispatched.
- Reject endpoint reuse, mismatched component registration, and cross-server job access; revoke only the Run bound to the affected server.
- Update the server creation workflow to save a draft, describe the selected target accurately, and guide the operator through Run generation, registration, and deployment.
## Capabilities
### New Capabilities
- `server-scoped-run-binding`: Securely reserve, register, validate, replace, and revoke one dedicated Run endpoint for a server instance.
- `server-run-bootstrap-workflow`: Create a server as a draft and progress it through deployment-target selection, dedicated Run registration, and explicit deployment.
### Modified Capabilities
- None.
## Impact
- `platform/`: domain, DTOs, models/repositories, services, control registration, lifecycle dispatch, distribution build selection, validation, API documentation, and tests.
- `platform_web/`: API types, server creation/deployment workflow, Run builder status and focused tests.
- `run/`: independent repository coordination is required for the revised registration identity; this repository will only update its public contract.
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Server creation is a draft-to-deployment workflow
The management console SHALL create a server definition as a draft, even when a deployment target is selected. It MUST NOT dispatch installation until the dedicated Run has registered and the operator explicitly deploys.
#### Scenario: Create with a deployment target
- **WHEN** an operator selects a compatible deployment target and confirms server creation
- **THEN** the console reports a saved draft and guides the operator to generate and register the dedicated Run
#### Scenario: Deploy before Run registration
- **WHEN** an operator tries to deploy a draft before its dedicated Run is online
- **THEN** the platform rejects the request with a safe registration-required reason and dispatches no job
### Requirement: The console distinguishes target from dedicated Run
The console SHALL label the initial selection as a deployment target and SHALL explain that it is a trusted build target, not the server's dedicated Run.
#### Scenario: Review server creation
- **WHEN** an operator reviews a new server definition
- **THEN** the review identifies the deployment target and says that a dedicated Run must be generated and registered before deployment
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: A dedicated Run endpoint is exclusively bound to one server
The platform SHALL reserve one dedicated Run endpoint identity for each newly created server and SHALL reject attempts to bind that identity to another server. The deployment target SHALL be stored separately from the dedicated endpoint.
#### Scenario: A target is selected for a new server
- **WHEN** an operator creates a server with a compatible deployment target
- **THEN** the platform saves the target and a reserved dedicated Run identity on a draft without dispatching an install job
#### Scenario: A dedicated endpoint is reused
- **WHEN** a caller attempts to register or bind a dedicated endpoint reserved for another server
- **THEN** the platform rejects the request without changing either server binding or control session
### Requirement: Component hello is constrained to its server binding
The platform SHALL accept a component-authenticated Run hello only when the server ID, component kind, current key generation, and reserved endpoint ID all match the server binding.
#### Scenario: A generated Run registers correctly
- **WHEN** a generated Run presents the current key and its reserved endpoint identity
- **THEN** the platform records the endpoint online and allows server-scoped work to be dispatched
#### Scenario: A generated Run claims another endpoint
- **WHEN** a generated Run presents a valid key but an endpoint identity that is not reserved for its server
- **THEN** the platform rejects the hello and does not replace any endpoint session
### Requirement: Revocation affects only the bound dedicated Run
The platform SHALL revoke a Run control session only when the endpoint is exclusively bound to the server whose Run key is reset.
#### Scenario: Reset a dedicated Run key
- **WHEN** an operator resets a server Run key
- **THEN** the platform revokes that server's dedicated Run session and leaves unrelated server endpoints unchanged
@@ -0,0 +1,20 @@
## 1. Server binding contract
- [x] 1.1 Add deployment-target and dedicated-Run ownership fields to domain, persistence, DTO, and safe server projections.
- [x] 1.2 Validate exclusive dedicated endpoint ownership and draft/deployment state transitions.
## 2. Secure lifecycle and control behavior
- [x] 2.1 Create target-bound drafts, reserve their dedicated Run identity, and dispatch deployment only to a registered dedicated Run.
- [x] 2.2 Build Run distributions through the deployment target while recording the reserved dedicated endpoint identity.
- [x] 2.3 Constrain component hello and revocation to the matching server-owned endpoint.
## 3. Management console workflow
- [x] 3.1 Update API types and the creation workflow to label deployment targets and save target-bound drafts.
- [x] 3.2 Guide draft operators through dedicated Run registration before deployment and update focused UI tests.
## 4. Verification
- [x] 4.1 Add backend tests for exclusive ownership, registration mismatch rejection, scoped revocation, and draft deployment gating.
- [x] 4.2 Run formatting, focused tests, full structural validation, strict OpenSpec validation, and build/type checks.
+1 -1
View File
@@ -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`: accept `ServerLifecycleCreateRequest`, validate plugin/run dependencies and idempotency, create an `installing` server instance, and queue a `process.install` job using `ServerLifecycleResponse`.
- `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/{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`.
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// serverInstanceCreateWorkflow godoc
// @Summary Create server instance workflow
// @Description Creates a server instance through the platform-mediated lifecycle workflow and queues an install job for the selected run endpoint.
// @Description Creates a server definition. A deployment target saves a draft and reserves a dedicated Run identity; installation is queued only after that Run registers and deployment is requested.
// @Tags server-instances
// @Accept json
// @Produce json
+18 -15
View File
@@ -718,21 +718,24 @@ type PluginBridgeExecuteResponse struct {
}
type ServerInstance struct {
ID string
PluginID string
PluginVersion string
RunEndpointID string
Name string
OwnerUserID string
AdminUserIDs []string
State ServerInstanceState
ConfigVersion int
ConfigKey string
ConfigContent string
ConfigChecksum string
ConfigUpdatedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
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 string
RunEndpointID string
Name string
OwnerUserID string
AdminUserIDs []string
State ServerInstanceState
ConfigVersion int
ConfigKey string
ConfigContent string
ConfigChecksum string
ConfigUpdatedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
// Deployment stores operator-supplied deployment inputs. Its protected path
// and command values are never included in normal platform read projections.
Deployment ServerDeploymentDefinition
+10 -9
View File
@@ -17,15 +17,16 @@ const (
)
type ServerLifecycleCreate struct {
ID string
PluginID string
RunEndpointID string
Name string
OwnerUserID string
IdempotencyKey string
ProfileKey string
Bindings map[string]string
Deployment ServerDeploymentDefinition
ID string
PluginID string
DeploymentTargetID string
RunEndpointID string
Name string
OwnerUserID string
IdempotencyKey string
ProfileKey string
Bindings map[string]string
Deployment ServerDeploymentDefinition
}
type ServerLifecycleCommand struct {
+2
View File
@@ -495,6 +495,7 @@ type ServerInstanceResponse struct {
ID string `json:"id"`
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
DeploymentTargetID string `json:"deploymentTargetId,omitempty"`
RunEndpointID string `json:"runEndpointId"`
Name string `json:"name"`
OwnerUserID string `json:"ownerUserId,omitempty"`
@@ -1445,6 +1446,7 @@ func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstanceResp
ID: instance.ID,
PluginID: instance.PluginID,
PluginVersion: instance.PluginVersion,
DeploymentTargetID: instance.DeploymentTargetID,
RunEndpointID: instance.RunEndpointID,
Name: instance.Name,
OwnerUserID: instance.OwnerUserID,
+20 -18
View File
@@ -55,15 +55,16 @@ type ServerDeploymentProjectionBody struct {
}
type ServerLifecycleCreateRequest struct {
ID string `json:"id"`
PluginID string `json:"pluginId"`
RunEndpointID string `json:"runEndpointId"`
Name string `json:"name"`
OwnerUserID string `json:"ownerUserId,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
ProfileKey string `json:"profileKey"`
Bindings map[string]string `json:"bindings,omitempty"`
Deployment ServerDeploymentRequest `json:"deployment,omitempty"`
ID string `json:"id"`
PluginID string `json:"pluginId"`
DeploymentTargetID string `json:"deploymentTargetId,omitempty"`
RunEndpointID string `json:"runEndpointId"`
Name string `json:"name"`
OwnerUserID string `json:"ownerUserId,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
ProfileKey string `json:"profileKey"`
Bindings map[string]string `json:"bindings,omitempty"`
Deployment ServerDeploymentRequest `json:"deployment,omitempty"`
}
type ServerLifecycleCommandRequest struct {
@@ -80,15 +81,16 @@ type ServerLifecycleResponse struct {
func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCreate {
return domain.ServerLifecycleCreate{
ID: request.ID,
PluginID: request.PluginID,
RunEndpointID: request.RunEndpointID,
Name: request.Name,
OwnerUserID: request.OwnerUserID,
IdempotencyKey: request.IdempotencyKey,
ProfileKey: request.ProfileKey,
Bindings: domain.CopyStringMap(request.Bindings),
Deployment: request.Deployment.deploymentDefinition(),
ID: request.ID,
PluginID: request.PluginID,
DeploymentTargetID: request.DeploymentTargetID,
RunEndpointID: request.RunEndpointID,
Name: request.Name,
OwnerUserID: request.OwnerUserID,
IdempotencyKey: request.IdempotencyKey,
ProfileKey: request.ProfileKey,
Bindings: domain.CopyStringMap(request.Bindings),
Deployment: request.Deployment.deploymentDefinition(),
}
}
+35 -31
View File
@@ -202,7 +202,9 @@ 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"`
// RunEndpointID references the selected run endpoint.
// DeploymentTargetID references the trusted worker used to build a dedicated Run.
DeploymentTargetID string `json:"deploymentTargetId,omitempty" db:"deployment_target_id"`
// RunEndpointID references the dedicated server Run endpoint.
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
// Name is the server display name.
Name string `json:"name" db:"name"`
@@ -728,41 +730,43 @@ func remoteAccessFromDomain(remote domain.GamePluginRemoteAccess) GamePluginRemo
func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstance {
return ServerInstance{
ID: instance.ID,
PluginID: instance.PluginID,
PluginVersion: instance.PluginVersion,
RunEndpointID: instance.RunEndpointID,
Name: instance.Name,
OwnerUserID: instance.OwnerUserID,
AdminUserIDs: domain.CopyStringSlice(instance.AdminUserIDs),
State: instance.State,
ConfigVersion: instance.ConfigVersion,
ConfigKey: instance.ConfigKey,
ConfigContent: instance.ConfigContent,
ConfigChecksum: instance.ConfigChecksum,
ConfigUpdatedAt: instance.ConfigUpdatedAt,
CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt,
ID: instance.ID,
PluginID: instance.PluginID,
PluginVersion: instance.PluginVersion,
DeploymentTargetID: instance.DeploymentTargetID,
RunEndpointID: instance.RunEndpointID,
Name: instance.Name,
OwnerUserID: instance.OwnerUserID,
AdminUserIDs: domain.CopyStringSlice(instance.AdminUserIDs),
State: instance.State,
ConfigVersion: instance.ConfigVersion,
ConfigKey: instance.ConfigKey,
ConfigContent: instance.ConfigContent,
ConfigChecksum: instance.ConfigChecksum,
ConfigUpdatedAt: instance.ConfigUpdatedAt,
CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt,
}
}
func (instance ServerInstance) ToDomain() domain.ServerInstance {
return domain.ServerInstance{
ID: instance.ID,
PluginID: instance.PluginID,
PluginVersion: instance.PluginVersion,
RunEndpointID: instance.RunEndpointID,
Name: instance.Name,
OwnerUserID: instance.OwnerUserID,
AdminUserIDs: domain.CopyStringSlice(instance.AdminUserIDs),
State: instance.State,
ConfigVersion: instance.ConfigVersion,
ConfigKey: instance.ConfigKey,
ConfigContent: instance.ConfigContent,
ConfigChecksum: instance.ConfigChecksum,
ConfigUpdatedAt: instance.ConfigUpdatedAt,
CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt,
ID: instance.ID,
PluginID: instance.PluginID,
PluginVersion: instance.PluginVersion,
DeploymentTargetID: instance.DeploymentTargetID,
RunEndpointID: instance.RunEndpointID,
Name: instance.Name,
OwnerUserID: instance.OwnerUserID,
AdminUserIDs: domain.CopyStringSlice(instance.AdminUserIDs),
State: instance.State,
ConfigVersion: instance.ConfigVersion,
ConfigKey: instance.ConfigKey,
ConfigContent: instance.ConfigContent,
ConfigChecksum: instance.ConfigChecksum,
ConfigUpdatedAt: instance.ConfigUpdatedAt,
CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt,
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ Named control DTOs:
- `RunCapabilityReport`
- `RunCapacityReport`
Control payloads must remain small and must not include logs, artifact chunks, host paths, raw credentials, direct sockets, or long task results. Hello creates or updates run endpoint metadata and issues an in-memory platform session token. Heartbeat requires that active session token and may request capability refresh when the fingerprint changes.
Control payloads must remain small and must not include logs, artifact chunks, host paths, raw credentials, direct sockets, or long task results. Hello creates or updates run endpoint metadata and issues an in-memory platform session token. A server-scoped generated Run must use the endpoint identity reserved for its server; Platform rejects a valid component key presented for another endpoint. Heartbeat requires that active session token and may request capability refresh when the fingerprint changes.
Capacity reports include bounded `maxJobs`, `runningJobs`, `queuedJobs`, `logBacklogBatches`, `artifactBacklogChunks`, and enumerated pressure codes. They report queue and spool counts only, never log bodies, artifact chunks, machine paths, PIDs, sockets, credentials, or transport endpoints.
+38
View File
@@ -29,6 +29,9 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
return domain.RunControlHelloResult{}, err
}
if hasComponentAuthIdentity(hello) {
if err := svc.validateDedicatedRunHello(hello); err != nil {
return domain.RunControlHelloResult{}, err
}
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: hello.ServerInstanceID,
ComponentKind: hello.ComponentKind,
@@ -122,6 +125,32 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
}), nil
}
func (svc *CoreService) validateDedicatedRunHello(hello domain.RunControlHello) error {
if hello.ComponentKind != domain.DistributionComponentRun {
return validationError("component-authenticated run hello must use the run component")
}
instance, err := svc.store.ServerInstances().Get(hello.ServerInstanceID)
if err != nil {
return err
}
if strings.TrimSpace(instance.DeploymentTargetID) == "" {
return nil // legacy Run registrations keep their historical endpoint contract.
}
if hello.PluginID != instance.PluginID || hello.RunEndpointID != instance.RunEndpointID {
return validationError("run endpoint identity does not match the server binding")
}
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: hello.RunEndpointID})
if err != nil {
return err
}
for _, candidate := range instances {
if candidate.ID != instance.ID && candidate.State != domain.ServerInstanceStateDeleted {
return validationError("run endpoint is already bound to another server")
}
}
return nil
}
func hasComponentAuthIdentity(hello domain.RunControlHello) bool {
return hello.ServerInstanceID != "" || hello.PluginID != "" || hello.ComponentKind != "" || hello.ComponentKey != "" || hello.KeyGeneration != 0
}
@@ -214,6 +243,15 @@ func (svc *CoreService) revokeRunControlSessionForInstance(instance domain.Serve
if strings.TrimSpace(instance.RunEndpointID) == "" {
return nil
}
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: instance.RunEndpointID})
if err != nil {
return err
}
for _, candidate := range instances {
if candidate.ID != instance.ID && candidate.State != domain.ServerInstanceStateDeleted {
return nil
}
}
svc.controlMu.Lock()
defer svc.controlMu.Unlock()
+25
View File
@@ -32,6 +32,31 @@ func TestCoreServiceRegistersNewRunControlSession(t *testing.T) {
}
}
func TestCoreServiceDoesNotRevokeSharedLegacyRunEndpoint(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
hello := validRunControlHello()
hello.CapabilityReport.Capabilities = append(hello.CapabilityReport.Capabilities, plugin.RequiredRunCapabilities...)
registered, err := svc.RegisterRunHello(hello)
if err != nil || !registered.Accepted {
t.Fatalf("register shared endpoint: result=%+v err=%v", registered, err)
}
first, err := svc.CreateServerInstance(domain.ServerInstance{ID: "shared-first", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Shared First", State: domain.ServerInstanceStateReady})
if err != nil {
t.Fatalf("create first legacy server: %v", err)
}
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "shared-second", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Shared Second", State: domain.ServerInstanceStateReady}); err != nil {
t.Fatalf("create second legacy server: %v", err)
}
if err := svc.revokeRunControlSessionForInstance(first); err != nil {
t.Fatalf("revoke first legacy Run: %v", err)
}
session, err := svc.store.RunControlSessions().Get(endpoint.ID)
if err != nil || session.Status != domain.AuthSessionStatusActive {
t.Fatalf("shared legacy endpoint session must remain active, session=%+v err=%v", session, err)
}
}
func TestCoreServiceReRegistersExistingRunEndpoint(t *testing.T) {
svc := newTestCoreService()
first, err := svc.RegisterRunHello(validRunControlHello())
+28 -13
View File
@@ -44,7 +44,11 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.generate.denied"); err != nil {
return domain.RunDistribution{}, err
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
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
}
@@ -99,7 +103,7 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
job, err := svc.CreateJob(domain.Job{
ID: buildJobID,
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
RunEndpointID: builderEndpointID,
Capability: domain.JobCapabilityDistributionBuild,
TargetKey: "distribution/run",
InputRef: "input://distribution-build/" + distribution.ID,
@@ -434,9 +438,13 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
if err != nil {
return domain.ServerRuntimeActions{}, err
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.ServerRuntimeActions{}, err
endpoint, endpointErr := svc.store.RunEndpoints().Get(instance.RunEndpointID)
runRegistered := endpointErr == nil
if endpointErr != nil && strings.TrimSpace(instance.DeploymentTargetID) != "" {
endpoint, endpointErr = svc.store.RunEndpoints().Get(instance.DeploymentTargetID)
}
if endpointErr != nil {
return domain.ServerRuntimeActions{}, endpointErr
}
hasAvailableRunPackage := false
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable})
@@ -465,23 +473,30 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
actions := domain.ServerRuntimeActions{
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
RunStatus: endpoint.Status,
RunEndpointID: instance.RunEndpointID,
RunStatus: func() domain.RunEndpointStatus {
if runRegistered {
return endpoint.Status
}
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("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"),
runtimeAction("push-run-update", "Push run update", pluginDeclares(plugin, "server.run.distribution") && svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", bindingReason)),
runtimeAction("push-run-update", "Push run update", runRegistered && pluginDeclares(plugin, "server.run.distribution") && svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && bindingsComplete, fallbackReason(!runRegistered, "dedicated Run has not registered", fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", bindingReason))),
runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"),
runtimeAction("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("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", dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason))),
runtimeAction("dependencies-install", "Install dependencies", dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason))),
runtimeAction("live-logs", "Live logs", pluginSupports(plugin, "logs.read"), "plugin does not declare live logs"),
runtimeAction("historical-logs", "Historical logs", svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason)),
runtimeAction("dependencies-check", "Check dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!runRegistered, "dedicated Run has not registered", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)))),
runtimeAction("dependencies-install", "Install dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!runRegistered, "dedicated Run has not registered", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason)))),
runtimeAction("live-logs", "Live logs", runRegistered && pluginSupports(plugin, "logs.read"), fallbackReason(!runRegistered, "dedicated Run has not registered", "plugin does not declare live logs")),
runtimeAction("historical-logs", "Historical logs", runRegistered && svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!runRegistered, "dedicated Run has not registered", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason))),
},
}
actions.Actions = append(actions.Actions, svc.clientManagerRuntimeActionProjection(instance, plugin, endpoint, bindingsComplete, bindingReason)...)
if runRegistered {
actions.Actions = append(actions.Actions, svc.clientManagerRuntimeActionProjection(instance, plugin, endpoint, bindingsComplete, bindingReason)...)
}
return domain.CopyServerRuntimeActions(actions), nil
}
+20
View File
@@ -100,6 +100,26 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
}
}
func TestCoreServiceBuildsDedicatedRunOnDeploymentTarget(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
targetID := instance.RunEndpointID
instance.State = domain.ServerInstanceStateDraft
instance.DeploymentTargetID = targetID
instance.RunEndpointID = "server-run-" + instance.ID
if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatalf("prepare target-bound draft: %v", err)
}
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "dedicated-target-build"})
if err != nil {
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)
}
}
func TestCoreServiceRejectsDistributionBuildForStaleRunEndpoint(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
svc.now = func() time.Time { return fixedTime.Add(capacityHeartbeatStaleAfter + time.Second) }
+2 -1
View File
@@ -2323,7 +2323,8 @@ func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plu
if instance.State == domain.ServerInstanceStateDeleted {
return validationError("server instance must not be deleted")
}
if instance.RunEndpointID != job.RunEndpointID {
usesDeploymentTarget := job.Capability == domain.JobCapabilityDistributionBuild && instance.DeploymentTargetID != "" && instance.DeploymentTargetID == job.RunEndpointID
if instance.RunEndpointID != job.RunEndpointID && !usesDeploymentTarget {
return validationError("job runEndpointId must match server instance")
}
if plugin.ID != instance.PluginID {
+6
View File
@@ -96,6 +96,12 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command
if strings.TrimSpace(instance.RunEndpointID) == "" {
return domain.ServerLifecycleResult{}, validationError("run endpoint must be selected before deployment")
}
if _, err := svc.store.RunEndpoints().Get(instance.RunEndpointID); err != nil {
if errors.Is(err, repo.ErrNotFound) && strings.TrimSpace(instance.DeploymentTargetID) != "" {
return domain.ServerLifecycleResult{}, validationError("dedicated Run must register before deployment")
}
return domain.ServerLifecycleResult{}, err
}
plugin, endpoint, err := svc.lifecycleDependencies(instance.PluginID, instance.RunEndpointID)
if err != nil {
return domain.ServerLifecycleResult{}, err
+38
View File
@@ -53,6 +53,11 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
UpdatedAt: stamp,
Deployment: create.Deployment,
}
if strings.TrimSpace(create.DeploymentTargetID) != "" {
instance.DeploymentTargetID = create.DeploymentTargetID
instance.RunEndpointID = dedicatedRunEndpointID(create.ID)
instance.State = domain.ServerInstanceStateDraft
}
if instance.Deployment.Mode != "" {
instance.Deployment.ProfileKey = create.ProfileKey
instance.Deployment.RuntimeBindings = domain.CopyStringMap(create.Bindings)
@@ -71,6 +76,35 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
instance.ConfigContent = buildLogicalServerConfig(instance)
instance.ConfigChecksum = validator.BytesChecksum([]byte(instance.ConfigContent))
instance.ConfigUpdatedAt = stamp
if strings.TrimSpace(create.DeploymentTargetID) != "" {
target, targetErr := svc.store.RunEndpoints().Get(create.DeploymentTargetID)
if targetErr != nil {
return domain.ServerLifecycleResult{}, fmt.Errorf("get deployment target dependency: %w", targetErr)
}
if err := svc.validateRunnableEndpoint(target, domain.JobCapabilityDistributionBuild); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validator.ValidateServerInstance(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
var binding domain.RuntimeBinding
if strings.TrimSpace(create.ProfileKey) != "" {
var bindingErr error
binding, bindingErr = svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: create.ProfileKey, Bindings: create.Bindings}, true)
if bindingErr != nil {
return domain.ServerLifecycleResult{}, bindingErr
}
}
if err := svc.store.ServerInstances().Create(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
if binding.ID != "" {
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
return domain.ServerLifecycleResult{}, err
}
}
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{Accepted: true, Action: domain.ServerLifecycleActionCreate, Instance: instance}), nil
}
if strings.TrimSpace(create.RunEndpointID) == "" {
instance.State = domain.ServerInstanceStateDraft
}
@@ -142,6 +176,10 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
}), nil
}
func dedicatedRunEndpointID(serverInstanceID string) string {
return "server-run-" + serverInstanceID
}
func (svc *CoreService) CreateServerInstanceWorkflowForSession(sessionID string, create domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
+40
View File
@@ -85,6 +85,46 @@ func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
}
}
func TestCoreServiceCreatesTargetBoundDraftAndRequiresDedicatedRunRegistration(t *testing.T) {
svc, _ := newLifecycleRunService(t)
plugin := createLifecyclePlugin(t, svc)
draft, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{
ID: "server-dedicated", PluginID: plugin.ID, DeploymentTargetID: "run-local", Name: "Dedicated SCUM", IdempotencyKey: "dedicated-draft", ProfileKey: "local",
})
if err != nil {
t.Fatalf("create target-bound draft: %v", err)
}
if draft.Instance.State != domain.ServerInstanceStateDraft || draft.Job.ID != "" || draft.Instance.DeploymentTargetID != "run-local" || draft.Instance.RunEndpointID != "server-run-server-dedicated" {
t.Fatalf("expected draft with separate target and reserved Run identity, got %+v", draft)
}
if _, err := svc.DeployServerInstanceForSession("", domain.ServerLifecycleCommand{ServerInstanceID: draft.Instance.ID, ExpectedConfigVersion: draft.Instance.ConfigVersion, IdempotencyKey: "before-register"}); err == nil {
t.Fatal("expected deployment without a registered dedicated Run to fail")
}
key, plainKey, err := svc.ensureActiveComponentKey(draft.Instance.ID, domain.DistributionComponentRun, "")
if err != nil {
t.Fatalf("create Run key: %v", err)
}
wrong := validRunControlHello()
wrong.ServerInstanceID = draft.Instance.ID
wrong.PluginID = plugin.ID
wrong.ComponentKind = domain.DistributionComponentRun
wrong.KeyGeneration = key.Generation
wrong.RegistrationToken = plainKey
wrong.RunEndpointID = "run-local"
if _, err := svc.RegisterRunHello(wrong); err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("expected mismatched endpoint registration rejection, got %v", err)
}
correct := wrong
correct.RunEndpointID = draft.Instance.RunEndpointID
correct.DisplayName = "Dedicated SCUM Run"
if registered, err := svc.RegisterRunHello(correct); err != nil || !registered.Accepted {
t.Fatalf("register dedicated Run: result=%+v err=%v", registered, err)
}
}
func TestCoreServiceFreezesReadyDLLExtensionIntoWindowsStartJob(t *testing.T) {
svc, sessionToken := newLifecycleRunService(t)
setLifecycleEndpointTarget(t, svc, "windows", "amd64")
+2
View File
@@ -438,6 +438,7 @@ export interface ServerInstanceResponse {
id: string;
pluginId: string;
pluginVersion: string;
deploymentTargetId?: string;
runEndpointId: string;
name: string;
ownerUserId?: string;
@@ -484,6 +485,7 @@ export interface ServerInstanceListResponse {
export interface ServerLifecycleCreateRequest {
id: string;
pluginId: string;
deploymentTargetId?: string;
runEndpointId?: string;
name: string;
idempotencyKey: string;
@@ -31,6 +31,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
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 workflowSteps = kind === "create"
? [{ label: "选择目标", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]
: needsTargetSelection
@@ -64,7 +65,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
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.runEndpointId)) : Boolean(form.runEndpointId);
if (step === targetStep) return kind === "create" ? Boolean(form.pluginId && (saveAsDraft || form.deploymentTargetId)) : 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;
@@ -78,19 +79,19 @@ 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, runEndpointId: saveAsDraft ? "" : form.runEndpointId }, saveAsDraft);
await onSubmit({ ...form, deploymentTargetId: saveAsDraft ? "" : form.deploymentTargetId, runEndpointId: saveAsDraft ? "" : form.runEndpointId }, saveAsDraft);
}
const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置";
const actionLabel = kind === "create" ? (saveAsDraft ? "保存草稿" : "创建、部署并前往构建 Run") : "保存部署设置";
const actionLabel = kind === "create" ? "保存草稿并准备专属 Run" : "保存部署设置";
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "按部署顺序完成设置;路径和命令始终受保护,不会在确认页或日志中回显。" : "仅停止中的服务器可以修改部署设置。受保护路径和命令留空会保持原值。"} wide onClose={() => { if (!busy) onClose(); }}>
<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 === 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 访</span></div><div className="workflow-hint-card"><strong></strong><span></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><select name="runEndpointId" value={form.runEndpointId} onChange={updateForm} disabled={kind === "create" && saveAsDraft} required={kind !== "create" || !saveAsDraft}><option value=""></option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label></div>
{kind === "create" && <label className="deployment-draft-choice"><input type="checkbox" checked={saveAsDraft} onChange={(event) => setSaveAsDraft(event.target.checked)} /><span><strong>稿</strong><small></small></span></label>}
{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>}
{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" }))} />
@@ -117,7 +118,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
{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></span><strong>{saveAsDraft ? "保存为未绑定草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === form.runEndpointId), form.runEndpointId)}</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>{saveAsDraft ? "本次保存定义" : activeServer ? "本次只保存部署设置" : "确认后将创建、部署并打开 Run 构建"}</strong><span>{saveAsDraft ? "后续从服务器详情选择节点并部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : activeServer ? "保存后可在详情中明确发起部署;路径和命令不会显示原文。" : isScum ? "Run 只有在 SCUM 配置映射与健康验证通过后才会报告安装成功。" : "Run 会在领取任务后执行本机预检,再进行安装、配置与启动。"}</span></div></div>}
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span>{kind === "create" ? "部署目标" : "目标"}</span><strong>{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{kind === "create" ? "本次保存草稿并保留专属 Run 身份" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "随后生成并启动专属 Run;它完成注册后,才能明确发起部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后可在详情中明确发起部署;路径和命令不会显示原文。"}</span></div></div>}
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? onClose() : 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>;
+5 -3
View File
@@ -14,8 +14,9 @@ export type ServerRemovalAction = "delete";
export interface ServerCreateFormState {
id: string;
name: string;
pluginId: string;
runEndpointId: string;
pluginId: string;
deploymentTargetId: string;
runEndpointId: string;
profileKey: string;
bindings: Record<string, string>;
createInputs: Record<string, string>;
@@ -63,7 +64,8 @@ export interface ServerManagementSummary {
export const emptyServerCreateForm: ServerCreateFormState = {
id: "",
name: "",
pluginId: "",
pluginId: "",
deploymentTargetId: "",
runEndpointId: "",
profileKey: "",
bindings: {},
+3 -1
View File
@@ -176,7 +176,9 @@ describe("first-party console pages", () => {
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("安装目录{isScum ? \"(必填)\" : \"(可选)\"}");
+2 -2
View File
@@ -157,11 +157,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
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);
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();
if (result.job.id) onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" });
onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId);
}
+2 -1
View File
@@ -15,7 +15,8 @@ export function serverCreateRequestFromForm(form: ServerCreateFormState, sequenc
const id = form.id.trim() || serverInstanceIdFromName(form.name, sequence);
return {
id,
pluginId: form.pluginId.trim(),
pluginId: form.pluginId.trim(),
deploymentTargetId: form.deploymentTargetId.trim() || undefined,
runEndpointId: form.runEndpointId.trim() || undefined,
name: form.name.trim(),
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence),