diff --git a/openspec/changes/separate-server-run-binding/.openspec.yaml b/openspec/changes/separate-server-run-binding/.openspec.yaml new file mode 100644 index 0000000..3a03821 --- /dev/null +++ b/openspec/changes/separate-server-run-binding/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-25 diff --git a/openspec/changes/separate-server-run-binding/design.md b/openspec/changes/separate-server-run-binding/design.md new file mode 100644 index 0000000..9f26791 --- /dev/null +++ b/openspec/changes/separate-server-run-binding/design.md @@ -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-`). 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. diff --git a/openspec/changes/separate-server-run-binding/proposal.md b/openspec/changes/separate-server-run-binding/proposal.md new file mode 100644 index 0000000..b4b83e5 --- /dev/null +++ b/openspec/changes/separate-server-run-binding/proposal.md @@ -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. diff --git a/openspec/changes/separate-server-run-binding/specs/server-run-bootstrap-workflow/spec.md b/openspec/changes/separate-server-run-binding/specs/server-run-bootstrap-workflow/spec.md new file mode 100644 index 0000000..4fe3764 --- /dev/null +++ b/openspec/changes/separate-server-run-binding/specs/server-run-bootstrap-workflow/spec.md @@ -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 diff --git a/openspec/changes/separate-server-run-binding/specs/server-scoped-run-binding/spec.md b/openspec/changes/separate-server-run-binding/specs/server-scoped-run-binding/spec.md new file mode 100644 index 0000000..f2c2816 --- /dev/null +++ b/openspec/changes/separate-server-run-binding/specs/server-scoped-run-binding/spec.md @@ -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 diff --git a/openspec/changes/separate-server-run-binding/tasks.md b/openspec/changes/separate-server-run-binding/tasks.md new file mode 100644 index 0000000..4d0e964 --- /dev/null +++ b/openspec/changes/separate-server-run-binding/tasks.md @@ -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. diff --git a/platform/api/routes.md b/platform/api/routes.md index 9287163..c4278a1 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -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`. diff --git a/platform/api/server_lifecycle_handlers.go b/platform/api/server_lifecycle_handlers.go index 5baee3b..320b565 100644 --- a/platform/api/server_lifecycle_handlers.go +++ b/platform/api/server_lifecycle_handlers.go @@ -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 diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 3d4161e..ade0f2c 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -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 diff --git a/platform/domain/server_lifecycle.go b/platform/domain/server_lifecycle.go index 1f696e0..ee79e3c 100644 --- a/platform/domain/server_lifecycle.go +++ b/platform/domain/server_lifecycle.go @@ -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 { diff --git a/platform/dto/resources.go b/platform/dto/resources.go index bc66d99..4ab7167 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -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, diff --git a/platform/dto/server_lifecycle.go b/platform/dto/server_lifecycle.go index 8da5c1f..8919641 100644 --- a/platform/dto/server_lifecycle.go +++ b/platform/dto/server_lifecycle.go @@ -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(), } } diff --git a/platform/model/resources.go b/platform/model/resources.go index c79a1e3..2904309 100644 --- a/platform/model/resources.go +++ b/platform/model/resources.go @@ -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, } } diff --git a/platform/protocol/run-contracts.md b/platform/protocol/run-contracts.md index e5c5215..e98b9c3 100644 --- a/platform/protocol/run-contracts.md +++ b/platform/protocol/run-contracts.md @@ -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. diff --git a/platform/service/control.go b/platform/service/control.go index b193c5c..f31a712 100644 --- a/platform/service/control.go +++ b/platform/service/control.go @@ -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() diff --git a/platform/service/control_test.go b/platform/service/control_test.go index 9abfd58..5966d0d 100644 --- a/platform/service/control_test.go +++ b/platform/service/control_test.go @@ -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()) diff --git a/platform/service/distributions.go b/platform/service/distributions.go index e29a215..ef017b6 100644 --- a/platform/service/distributions.go +++ b/platform/service/distributions.go @@ -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 } diff --git a/platform/service/distributions_test.go b/platform/service/distributions_test.go index e3af62c..d04ed49 100644 --- a/platform/service/distributions_test.go +++ b/platform/service/distributions_test.go @@ -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) } diff --git a/platform/service/resources.go b/platform/service/resources.go index 3c594c0..c9ba3d7 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -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 { diff --git a/platform/service/server_deployment.go b/platform/service/server_deployment.go index 8496091..90a8e4e 100644 --- a/platform/service/server_deployment.go +++ b/platform/service/server_deployment.go @@ -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 diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index 5d19b7b..236ecda 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -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 { diff --git a/platform/service/server_lifecycle_test.go b/platform/service/server_lifecycle_test.go index 12286b5..f427a11 100644 --- a/platform/service/server_lifecycle_test.go +++ b/platform/service/server_lifecycle_test.go @@ -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") diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 11db9cb..fb0b72c 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -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; diff --git a/platform_web/components/ServerDeploymentWorkflow.tsx b/platform_web/components/ServerDeploymentWorkflow.tsx index 32c7818..19db854 100644 --- a/platform_web/components/ServerDeploymentWorkflow.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.tsx @@ -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) { 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 { if (!busy) onClose(); }}>
void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
    {workflowSteps.map((item, index) => { const Icon = item.icon; return
  1. {index < step ? : }{index + 1}. {item.label}
  2. ; })}
{step === targetStep &&
- {kind === "create" ?
选择游戏插件决定新建时需要的游戏配置与启动预设。
选择运行节点Run 在目标主机预检并执行;平台不会直接访问主机。
准备服务器目录下一步选择新建、接管或自定义方式,再填写对应目录。
:
这个草稿尚未绑定运行节点只需在这里补选一次。已绑定服务器编辑时会直接进入相关配置,不会重复要求选择目标。
} -
{kind === "create" && }
- {kind === "create" && } + {kind === "create" ?
选择游戏插件决定新建时需要的游戏配置与启动预设。
选择部署目标受信任的构建节点会生成专属 Run;它不是这台服务器的 Run。
注册专属 Run草稿保存后生成并启动专属 Run,注册成功才可部署。
:
这个草稿尚未绑定运行节点只需在这里补选一次。已绑定服务器编辑时会直接进入相关配置,不会重复要求选择目标。
} +
{kind === "create" && }
+ {kind === "create" && }
} {step === modeStep &&

选择你想让平台承担的方式。后续只显示这一方式需要的字段。

{isScum &&
SCUM 受控链路Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。
}
setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} /> @@ -117,7 +118,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi {form.deploymentMode === "custom-command" &&
高级启动设置

只有自定义启动器需要这些设置。执行目录留空时,节点以服务器目录执行。

} {kind === "create" && bindingFields.length > 0 &&
运行连接设置

用于插件声明的逻辑连接,不是服务器目录或游戏配置。

{bindingFields.map((field) => )}
}
} - {step === reviewStep &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
目标{saveAsDraft ? "保存为未绑定草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === form.runEndpointId), form.runEndpointId)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
{saveAsDraft ? "本次只保存定义" : activeServer ? "本次只保存部署设置" : "确认后将创建、部署并打开 Run 构建"}{saveAsDraft ? "后续从服务器详情选择节点并部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : activeServer ? "保存后可在详情中明确发起部署;路径和命令不会显示原文。" : isScum ? "Run 只有在 SCUM 配置映射与健康验证通过后才会报告安装成功。" : "Run 会在领取任务后执行本机预检,再进行安装、配置与启动。"}
} + {step === reviewStep &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
{kind === "create" ? "部署目标" : "目标"}{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
{kind === "create" ? "本次保存草稿并保留专属 Run 身份" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}{kind === "create" ? "随后生成并启动专属 Run;它完成注册后,才能明确发起部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后可在详情中明确发起部署;路径和命令不会显示原文。"}
}
{step < reviewStep ? : }
; diff --git a/platform_web/contracts/serverManagement.ts b/platform_web/contracts/serverManagement.ts index c28cc96..f82c113 100644 --- a/platform_web/contracts/serverManagement.ts +++ b/platform_web/contracts/serverManagement.ts @@ -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; createInputs: Record; @@ -63,7 +64,8 @@ export interface ServerManagementSummary { export const emptyServerCreateForm: ServerCreateFormState = { id: "", name: "", - pluginId: "", + pluginId: "", + deploymentTargetId: "", runEndpointId: "", profileKey: "", bindings: {}, diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index 695f0cd..88fcc98 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -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 ? \"(必填)\" : \"(可选)\"}"); diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index 4733826..febd977 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -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); } diff --git a/platform_web/schemas/serverManagement.ts b/platform_web/schemas/serverManagement.ts index b61febd..7381deb 100644 --- a/platform_web/schemas/serverManagement.ts +++ b/platform_web/schemas/serverManagement.ts @@ -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),