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
+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")