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