fix: separate run builders from generated workers

This commit is contained in:
npc0-hue
2026-07-28 11:05:38 +08:00
parent 2ae27bb746
commit e739dd9c79
10 changed files with 257 additions and 1 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-27
@@ -0,0 +1,42 @@
## Context
Platform dispatches `distribution.build` through a trusted Run endpoint. A newer server lifecycle already distinguishes `DeploymentTargetID` from a dedicated `RunEndpointID`, but legacy instances can still have only `RunEndpointID`. A generated Run for such an instance was built with the shared endpoint identity and its component key, so starting it remotely replaced the local build worker registration.
## Goals / Non-Goals
**Goals:**
- Preserve a local trusted Run worker as the only component that compiles generated Windows Run binaries.
- Give generated Run packages a deterministic server-scoped identity.
- Make legacy generation self-heal the unsafe endpoint binding before a build is queued.
- Prevent generated Run packages from advertising build-only worker capabilities.
**Non-Goals:**
- Do not move compilation into the browser, expose source trees to plugins, or require Go/Git on the game host.
- Do not alter deployment commands, game dependencies, or remote server files.
## Decisions
1. When a failed or draft legacy instance requests a Run distribution, Platform atomically promotes its existing endpoint to `DeploymentTargetID` and reserves `server-run-<server-id>` as the generated Run endpoint. Generation is an explicit operator action and is the safe migration boundary; it avoids changing active legacy instances merely because Platform starts.
2. Component-authenticated Run hello must match the instance's dedicated Run endpoint. A component token cannot register over a shared build endpoint.
3. Run capability reporting is role-aware. A component-authenticated generated Run keeps lifecycle/file/dependency capabilities but never reports distribution build or Run self-update capabilities. A generic worker remains build-capable.
4. Source-preparation failures return a bounded category that distinguishes unavailable source, invalid source layout, and unsafe source content without exposing host paths.
## Risks / Trade-offs
- [A failed legacy instance needs a fresh package] → Generation performs the migration before queueing and produces a package with the new identity.
- [A stale remotely deployed package continues to reconnect] → Platform rejects it after migration because its endpoint ID no longer matches the component's dedicated identity.
- [Role-aware capabilities change scheduling] → Only build-only capabilities are removed from component packages; normal server lifecycle capability reporting is preserved.
## Migration Plan
1. Deploy Platform and Run changes.
2. Start the local generic build worker with `run-local-debug` and its approved source snapshot.
3. Generate Run for the failed legacy server; Platform promotes `run-local-debug` to its deployment target and embeds `server-run-<id>` in the new Windows package.
4. Replace the old remote executable with the generated package; its stale shared-ID registration is rejected.
5. Roll back by retaining the old package and restoring the legacy binding only through an explicit recovery operation; Platform never silently maps a component token onto a shared builder endpoint.
## Open Questions
- None.
@@ -0,0 +1,25 @@
## Why
A legacy server can use one endpoint ID for both the trusted local Run builder and its generated Windows Run. When the generated Run starts on the target host it authenticates with the server component key as the builder endpoint, displacing the local builder. Later Windows distribution builds are consequently executed without the approved local source snapshot and fail at source preparation.
## What Changes
- Separate a legacy server's build-target endpoint from the endpoint identity embedded in a newly generated Run before queuing the build.
- Reject component-authenticated Run registration against a shared build endpoint.
- Make a generated server Run omit build-only capabilities, including `distribution.build`.
- Preserve a clear, safe source-preparation failure reason for operational diagnosis.
## Capabilities
### New Capabilities
- `run-build-worker-roles`: Separates trusted Run build workers from server-scoped generated Run workers and protects the endpoint identities used by each.
### Modified Capabilities
- `run-distribution-and-client-managers`: Run distribution generation assigns the generated package a server-scoped endpoint identity rather than reusing a legacy builder identity.
## Impact
- Affected roots: `platform/`, the independent `run/` checkout, and OpenSpec contracts.
- Existing failed legacy instances migrate on their next Run generation; no browser, plugin, or remote-host path/credential exposure is introduced.
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Generated Run identity is distinct from its build target
When Platform generates a Run package for a failed or draft legacy server without `deploymentTargetId`, it SHALL reserve `server-run-<server-instance-id>` as the package endpoint identity and persist the previous endpoint as the deployment target before the distribution build job is queued.
#### Scenario: Failed legacy server generates a new Windows Run
- **GIVEN** a failed legacy server has `runEndpointId=run-local-debug` and no deployment target
- **WHEN** an authorized owner generates a Windows amd64 Run package
- **THEN** the build job targets `run-local-debug`, the server persists `deploymentTargetId=run-local-debug`, and the generated package is assigned `runEndpointId=server-run-<server-id>`
### Requirement: Shared builders reject component Run identity
Platform SHALL reject a component-authenticated Run hello unless its endpoint identity equals the server's dedicated Run endpoint identity.
#### Scenario: Old package tries to claim the builder ID
- **GIVEN** an instance has a dedicated Run endpoint and a separate deployment target
- **WHEN** a Run component uses the instance key to register as the deployment target endpoint
- **THEN** Platform rejects the hello and leaves the build endpoint registration unchanged
### Requirement: Generated Run does not advertise build-only capabilities
A Run configured as a server component SHALL NOT advertise `distribution.build` or `run.self-update`.
#### Scenario: Generated Run registers on the target host
- **WHEN** a package with component kind `run` creates its capability report
- **THEN** lifecycle capabilities remain available and build-only capabilities are absent
@@ -0,0 +1,11 @@
## MODIFIED Requirements
### Requirement: Run distribution generation remains asynchronous and server-scoped
The platform SHALL create a bounded `distribution.build` job on the instance deployment target and return a `building` distribution with the real job ID. The generated Run package SHALL contain a server-scoped endpoint identity and component key; a legacy shared builder endpoint MUST be promoted to the deployment target before the job is queued.
#### Scenario: Generate from a legacy endpoint binding
- **GIVEN** an authorized server owner selects a legacy server whose only endpoint is an online build-capable worker
- **WHEN** the owner requests a target-matched Run distribution
- **THEN** Platform queues the build on that worker and assigns the generated package its deterministic dedicated endpoint identity
@@ -0,0 +1,15 @@
## 1. Binding migration and registration fence
- [x] 1.1 Promote a failed or draft legacy endpoint to a deployment target when Run generation is explicitly requested, reserving the deterministic server Run endpoint first.
- [x] 1.2 Reject component-authenticated registration that does not match the server's dedicated endpoint identity and prevent signed component sessions from claiming distribution builds.
- [x] 1.3 Add Platform regression coverage for legacy promotion, build targeting, stale package registration rejection, and signed-component claim rejection.
## 2. Role-aware Run worker capabilities
- [x] 2.1 Derive worker capabilities from the component role and omit build-only capabilities from generated Run packages.
- [x] 2.2 Add Run tests for generic builder and generated server Run capability reports.
## 3. Verification
- [x] 3.1 Run focused and full Platform/Run test suites, a Windows amd64 build-worker test, an approved-snapshot PE build proof, and isolated local debug smoke with generated artifact download evidence.
- [x] 3.2 Run `openspec validate fix-run-build-worker-role --strict` and `scripts/check-structure.sh`.
+67 -1
View File
@@ -172,7 +172,7 @@ func TestCoreServiceRunHelloRejectsStalePackageKeyAfterReset(t *testing.T) {
} }
pkg := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID) pkg := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
hello := validRunControlHello() hello := validRunControlHello()
hello.RunEndpointID = instance.RunEndpointID hello.RunEndpointID = distribution.RunEndpointID
hello.RegistrationToken = pkg.AuthKey hello.RegistrationToken = pkg.AuthKey
hello.ServerInstanceID = instance.ID hello.ServerInstanceID = instance.ID
hello.PluginID = instance.PluginID hello.PluginID = instance.PluginID
@@ -202,6 +202,72 @@ func TestCoreServiceRunHelloRejectsStalePackageKeyAfterReset(t *testing.T) {
} }
} }
func TestCoreServiceRunHelloRejectsGeneratedRunOnPromotedBuildEndpoint(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
builderID := instance.RunEndpointID
instance.State = domain.ServerInstanceStateFailed
if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatalf("mark legacy server failed: %v", err)
}
if _, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "windows", TargetArch: "amd64", IdempotencyKey: "promoted-hello-fence"}); err != nil {
t.Fatalf("generate promoted Run: %v", err)
}
migrated, err := svc.GetServerInstance(instance.ID)
if err != nil {
t.Fatalf("get migrated server: %v", err)
}
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
if err != nil {
t.Fatalf("get component key: %v", err)
}
hello := validRunControlHello()
hello.RunEndpointID = builderID
hello.RegistrationToken = plainKey
hello.ServerInstanceID = instance.ID
hello.PluginID = instance.PluginID
hello.ComponentKind = domain.DistributionComponentRun
hello.KeyGeneration = key.Generation
if _, err := svc.RegisterRunHello(hello); err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("expected shared builder registration rejection, got %v", err)
}
hello.RunEndpointID = migrated.RunEndpointID
if result, err := svc.RegisterRunHello(hello); err != nil || !result.Accepted {
t.Fatalf("expected dedicated Run registration acceptance, result=%+v err=%v", result, err)
}
}
func TestCoreServiceComponentRunCannotClaimDistributionBuild(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "windows", TargetArch: "amd64", IdempotencyKey: "component-build-claim"})
if err != nil {
t.Fatalf("generate legacy Run: %v", err)
}
packageConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
hello := validRunControlHello()
hello.RunEndpointID = instance.RunEndpointID
hello.RegistrationToken = packageConfig.AuthKey
hello.ServerInstanceID = instance.ID
hello.PluginID = instance.PluginID
hello.ComponentKind = domain.DistributionComponentRun
hello.KeyGeneration = packageConfig.KeyGeneration
registered, err := svc.RegisterRunHello(hello)
if err != nil || !registered.Accepted {
t.Fatalf("register legacy package: result=%+v err=%v", registered, err)
}
storedSession, err := svc.store.RunControlSessions().Get(instance.RunEndpointID)
if err != nil || !storedSession.RequireSignedRequests {
t.Fatalf("expected component session to require signatures, session=%+v err=%v", storedSession, err)
}
if activeSession := svc.runSessions[instance.RunEndpointID]; !activeSession.RequireSignedRequests {
t.Fatalf("expected in-memory component session to require signatures, session=%+v", activeSession)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: []string{domain.JobCapabilityDistributionBuild}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || claim.HasJob {
t.Fatalf("component Run must not claim distribution builds: claim=%+v err=%v", claim, err)
}
}
func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) { func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
hello, err := svc.RegisterRunHello(validRunControlHello()) hello, err := svc.RegisterRunHello(validRunControlHello())
+24
View File
@@ -44,6 +44,9 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.generate.denied"); err != nil { if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.generate.denied"); err != nil {
return domain.RunDistribution{}, err return domain.RunDistribution{}, err
} }
if err := svc.promoteLegacyRunBinding(&instance); err != nil {
return domain.RunDistribution{}, err
}
builderEndpointID := instance.RunEndpointID builderEndpointID := instance.RunEndpointID
if strings.TrimSpace(instance.DeploymentTargetID) != "" { if strings.TrimSpace(instance.DeploymentTargetID) != "" {
builderEndpointID = instance.DeploymentTargetID builderEndpointID = instance.DeploymentTargetID
@@ -125,6 +128,27 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
return domain.CopyRunDistribution(distribution), nil return domain.CopyRunDistribution(distribution), nil
} }
// promoteLegacyRunBinding reserves a server-scoped endpoint for a generated
// Run before a legacy server first requests a distribution. Its existing
// endpoint remains the trusted build target; reusing it in the package would
// allow the generated Run to replace the builder registration.
func (svc *CoreService) promoteLegacyRunBinding(instance *domain.ServerInstance) error {
if instance == nil || strings.TrimSpace(instance.DeploymentTargetID) != "" || (instance.State != domain.ServerInstanceStateDraft && instance.State != domain.ServerInstanceStateFailed) {
return nil
}
builderEndpointID := strings.TrimSpace(instance.RunEndpointID)
if builderEndpointID == "" {
return validationError("legacy Run generation requires a build target endpoint")
}
instance.DeploymentTargetID = builderEndpointID
instance.RunEndpointID = dedicatedRunEndpointID(instance.ID)
instance.UpdatedAt = svc.now()
if err := validator.ValidateServerInstance(*instance); err != nil {
return err
}
return svc.store.ServerInstances().Update(*instance)
}
func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID string, request domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error) { func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID string, request domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error) {
request = domain.CopyClientManagerBuildRequest(request) request = domain.CopyClientManagerBuildRequest(request)
if strings.TrimSpace(request.IdempotencyKey) == "" { if strings.TrimSpace(request.IdempotencyKey) == "" {
+25
View File
@@ -120,6 +120,31 @@ func TestCoreServiceBuildsDedicatedRunOnDeploymentTarget(t *testing.T) {
} }
} }
func TestCoreServicePromotesLegacyRunBindingBeforeDistributionBuild(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
builderID := instance.RunEndpointID
instance.State = domain.ServerInstanceStateFailed
if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatalf("mark legacy server failed: %v", err)
}
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "windows", TargetArch: "amd64", IdempotencyKey: "legacy-promote-build"})
if err != nil {
t.Fatalf("generate promoted legacy Run: %v", err)
}
migrated, err := svc.GetServerInstance(instance.ID)
if err != nil {
t.Fatalf("get migrated server: %v", err)
}
if migrated.DeploymentTargetID != builderID || migrated.RunEndpointID != "server-run-"+instance.ID {
t.Fatalf("expected legacy binding promotion, got %+v", migrated)
}
job, err := svc.GetJob(distribution.BuildJobID)
if err != nil || job.RunEndpointID != builderID || distribution.RunEndpointID != migrated.RunEndpointID {
t.Fatalf("expected build target %q and dedicated package endpoint %q, job=%+v distribution=%+v err=%v", builderID, migrated.RunEndpointID, job, distribution, err)
}
}
func TestCoreServiceRejectsDistributionBuildForStaleRunEndpoint(t *testing.T) { func TestCoreServiceRejectsDistributionBuildForStaleRunEndpoint(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
svc.now = func() time.Time { return fixedTime.Add(capacityHeartbeatStaleAfter + time.Second) } svc.now = func() time.Time { return fixedTime.Add(capacityHeartbeatStaleAfter + time.Second) }
+16
View File
@@ -29,6 +29,9 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
if err != nil { if err != nil {
return domain.RunJobClaimResult{}, err return domain.RunJobClaimResult{}, err
} }
if session.RequireSignedRequests {
claim.Capabilities = withoutCapability(claim.Capabilities, domain.JobCapabilityDistributionBuild)
}
stamp := svc.now() stamp := svc.now()
svc.jobMu.Lock() svc.jobMu.Lock()
@@ -40,6 +43,9 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs { if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs {
return emptyJobClaim(claim.RunEndpointID, stamp), nil return emptyJobClaim(claim.RunEndpointID, stamp), nil
} }
if session.RequireSignedRequests && len(claim.Capabilities) == 0 {
return emptyJobClaim(claim.RunEndpointID, stamp), nil
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID}) jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
if err != nil { if err != nil {
return domain.RunJobClaimResult{}, err return domain.RunJobClaimResult{}, err
@@ -82,6 +88,16 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
}), nil }), nil
} }
func withoutCapability(capabilities []string, forbidden string) []string {
filtered := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
if capability != forbidden {
filtered = append(filtered, capability)
}
}
return filtered
}
func (svc *CoreService) AckRunJob(ack domain.RunJobAck) (domain.RunJobAckResult, error) { func (svc *CoreService) AckRunJob(ack domain.RunJobAck) (domain.RunJobAckResult, error) {
if err := validator.ValidateRunJobAck(ack); err != nil { if err := validator.ValidateRunJobAck(ack); err != nil {
return domain.RunJobAckResult{}, err return domain.RunJobAckResult{}, err