Make generated run lifecycle autonomous

This commit is contained in:
npc0-hue
2026-08-06 18:57:42 +08:00
parent acec5e4367
commit 4e78957a60
22 changed files with 583 additions and 236 deletions
-96
View File
@@ -111,12 +111,6 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
return domain.RunControlHelloResult{}, err
}
svc.runSessions[hello.RunEndpointID] = session
if err := svc.queueManagedGuidedDeploymentAfterRegistration(hello); err != nil {
return domain.RunControlHelloResult{}, err
}
if err := svc.queueRuntimeStateReconciliationAfterRegistration(hello, generation); err != nil {
return domain.RunControlHelloResult{}, err
}
featureFlags := []string{"control.hello", "control.heartbeat", "signed-envelope.v1.optional"}
if session.RequireSignedRequests {
featureFlags[2] = "signed-envelope.v1.required"
@@ -132,96 +126,6 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
}), nil
}
// queueRuntimeStateReconciliationAfterRegistration lets a generated Run correct
// stale observed state after reconnecting. Platform still owns command dispatch,
// but the Run-owned process supervisor is authoritative for whether the local
// managed process exists.
func (svc *CoreService) queueRuntimeStateReconciliationAfterRegistration(hello domain.RunControlHello, generation int) error {
if hello.ComponentKind != domain.DistributionComponentRun || strings.TrimSpace(hello.ServerInstanceID) == "" {
return nil
}
instance, err := svc.store.ServerInstances().Get(hello.ServerInstanceID)
if err != nil {
return err
}
if instance.RunEndpointID != hello.RunEndpointID || instance.RunEndpointID != dedicatedRunEndpointID(instance.ID) {
return nil
}
if instance.State != domain.ServerInstanceStateRunning && instance.State != domain.ServerInstanceStateFailed {
return nil
}
if !containsString(hello.CapabilityReport.Capabilities, domain.LifecycleCapabilityStatus) {
return nil
}
active, err := svc.hasActiveServerLifecycleJob(instance.ID)
if err != nil || active {
return err
}
plugin, endpoint, err := svc.lifecycleDependencies(instance.PluginID, instance.RunEndpointID)
if err != nil {
return err
}
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionStatus); err != nil {
return nil
}
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionStatus); err != nil {
return nil
}
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityStatus); err != nil {
return nil
}
_, err = svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionStatus, fmt.Sprintf("runtime-state-reconcile:%s:g%d", instance.ID, generation))
return err
}
func (svc *CoreService) hasActiveServerLifecycleJob(serverInstanceID string) (bool, error) {
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return false, err
}
for _, job := range jobs {
if !isLifecycleJobCapability(job.Capability) {
continue
}
if job.State == domain.JobStateQueued || job.State == domain.JobStateAccepted || job.State == domain.JobStateRunning || job.State == domain.JobStateRetrying {
return true, nil
}
}
return false, nil
}
func isLifecycleJobCapability(capability string) bool {
switch capability {
case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus:
return true
default:
return false
}
}
// queueManagedGuidedDeploymentAfterRegistration advances only a newly-created,
// dedicated guided server. Selecting guided-install is the owner's prior
// authorization for the plugin-declared bootstrap action; reconnects remain
// idempotent.
func (svc *CoreService) queueManagedGuidedDeploymentAfterRegistration(hello domain.RunControlHello) error {
if hello.ComponentKind != domain.DistributionComponentRun || strings.TrimSpace(hello.ServerInstanceID) == "" {
return nil
}
instance, err := svc.store.ServerInstances().Get(hello.ServerInstanceID)
if err != nil {
return err
}
if instance.RunEndpointID != hello.RunEndpointID || instance.RunEndpointID != dedicatedRunEndpointID(instance.ID) || instance.State != domain.ServerInstanceStateDraft || instance.Deployment.Mode != domain.ServerDeploymentModeGuided {
return nil
}
_, err = svc.deployServerInstance(domain.ServerLifecycleCommand{
ServerInstanceID: instance.ID,
ExpectedConfigVersion: instance.ConfigVersion,
IdempotencyKey: fmt.Sprintf("managed-deploy:%s:r%d", instance.ID, instance.Deployment.Revision),
})
return err
}
func (svc *CoreService) validateDedicatedRunHello(hello domain.RunControlHello) error {
if hello.ComponentKind != domain.DistributionComponentRun {
return validationError("component-authenticated run hello must use the run component")
+24 -42
View File
@@ -268,7 +268,7 @@ func TestCoreServiceComponentRunCannotClaimDistributionBuild(t *testing.T) {
}
}
func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(t *testing.T) {
func TestCoreServiceDedicatedRunRegistrationDoesNotDispatchLifecycleBootstrap(t *testing.T) {
svc, _ := newLifecycleRunService(t)
plugin := createLifecyclePlugin(t, svc)
endpoint, err := svc.store.RunEndpoints().Get("run-local")
@@ -290,17 +290,17 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
}
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
stored, err := svc.GetServerInstance(guided.Instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateInstalling {
t.Fatalf("guided registration should queue bootstrap start, server=%+v err=%v", stored, err)
if err != nil || stored.State != domain.ServerInstanceStateDraft {
t.Fatalf("guided registration must leave lifecycle authority with Run, server=%+v err=%v", stored, err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID})
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].TargetKey != "actions/start.json" || len(jobs[0].ExecutionInput.LogSources) != 2 {
t.Fatalf("expected one automatic supervised start job, jobs=%+v err=%v", jobs, err)
if err != nil || len(jobs) != 0 {
t.Fatalf("registration must not enqueue automatic lifecycle jobs, jobs=%+v err=%v", jobs, err)
}
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID})
if len(jobs) != 1 {
t.Fatalf("Run reconnect must not duplicate automatic bootstrap, jobs=%+v", jobs)
if len(jobs) != 0 {
t.Fatalf("Run reconnect must not enqueue bootstrap jobs, jobs=%+v", jobs)
}
generatedRunDraft := domain.ServerInstance{
@@ -318,12 +318,12 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
}
registerDedicatedRunForTest(t, svc, generatedRunDraft, plugin.ID)
storedGenerated, err := svc.GetServerInstance(generatedRunDraft.ID)
if err != nil || storedGenerated.State != domain.ServerInstanceStateInstalling {
t.Fatalf("generated Run registration should queue supervised start without deployment target, server=%+v err=%v", storedGenerated, err)
if err != nil || storedGenerated.State != domain.ServerInstanceStateDraft {
t.Fatalf("generated Run registration must not platform-dispatch supervised start, server=%+v err=%v", storedGenerated, err)
}
jobs, err = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: generatedRunDraft.ID})
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].ExecutionInput.WorkspaceScope != "local" {
t.Fatalf("expected one scoped automatic generated Run start job, jobs=%+v err=%v", jobs, err)
if err != nil || len(jobs) != 0 {
t.Fatalf("expected generated Run startup to be autonomous, jobs=%+v err=%v", jobs, err)
}
existing, err := svc.CreateServerInstanceWorkflowForSession(owner, domain.ServerLifecycleCreate{ID: "managed-existing", PluginID: plugin.ID, DeploymentTargetID: "run-local", Name: "Managed Existing", IdempotencyKey: "managed-existing-create", ProfileKey: "local", Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: "C:\\existing-scum"}})
@@ -337,7 +337,7 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
}
}
func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedStart(t *testing.T) {
func TestCoreServiceGeneratedSCUMRunRegistrationDoesNotQueueGuidedStart(t *testing.T) {
svc := newTestCoreService()
plugin := scumDeploymentTestPlugin()
plugin.Name = "SCUM"
@@ -423,52 +423,34 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedStart(t *testing.T)
}
stored, err := svc.GetServerInstance(instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateInstalling {
t.Fatalf("generated SCUM registration should queue supervised bootstrap start, server=%+v err=%v", stored, err)
if err != nil || stored.State != domain.ServerInstanceStateDraft {
t.Fatalf("generated SCUM registration must leave startup to Run, server=%+v err=%v", stored, err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 1 {
t.Fatalf("expected one SCUM start job, jobs=%+v err=%v", jobs, err)
}
job := jobs[0]
if job.Capability != domain.LifecycleCapabilityStart || job.TargetKey != "actions/start.json" || job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil || len(job.ExecutionInput.LogSources) == 0 {
t.Fatalf("expected SCUM supervised start job with scoped plugin action and generic deployment inputs, job=%+v", job)
}
if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
t.Fatalf("SCUM start job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/start.json" || claim.Job.ExecutionInput.WorkspaceScope != "run-local" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
t.Fatalf("generated SCUM Run should claim scoped plugin-owned start action, claim=%+v err=%v", claim, err)
if err != nil || len(jobs) != 0 {
t.Fatalf("generated SCUM registration must not enqueue start/status jobs, jobs=%+v err=%v", jobs, err)
}
}
func TestCoreServiceGeneratedRunRegistrationReconcilesStaleRunningState(t *testing.T) {
func TestCoreServiceGeneratedRunRegistrationDoesNotDispatchStatusReconciliation(t *testing.T) {
svc := newTestCoreService()
plugin := createGeneratedRunStatusPlugin(t, svc)
instance := domain.ServerInstance{ID: "managed-stale-running", PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: dedicatedRunEndpointID("managed-stale-running"), Name: "Managed Stale Running", State: domain.ServerInstanceStateRunning, ConfigVersion: 1, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ProfileKey: "run-local", ServerRoot: `D:\scum-stale`, Revision: 1}}
if err := svc.store.ServerInstances().Create(instance); err != nil {
t.Fatalf("create stale running server: %v", err)
}
registered := registerGeneratedRunForStatusTest(t, svc, instance, plugin.ID)
registerGeneratedRunForStatusTest(t, svc, instance, plugin.ID)
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStatus || jobs[0].TargetKey != "actions/status.json" {
t.Fatalf("expected one status reconciliation job, jobs=%+v err=%v", jobs, err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: []string{domain.LifecycleCapabilityStatus}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.JobID != jobs[0].ID {
t.Fatalf("claim status reconciliation: claim=%+v err=%v", claim, err)
}
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "process status queried"}, Message: "process status queried", ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "not-started", ExitClassification: "not-started", AuditSummary: "bounded process state"}}); err != nil {
t.Fatalf("complete status reconciliation: %v", err)
if err != nil || len(jobs) != 0 {
t.Fatalf("registration must not enqueue status reconciliation, jobs=%+v err=%v", jobs, err)
}
stored, err := svc.GetServerInstance(instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateStopped {
t.Fatalf("Run-reported not-started should correct stale running state, server=%+v err=%v", stored, err)
if err != nil || stored.State != domain.ServerInstanceStateRunning {
t.Fatalf("registration must not mutate projected state without Run report, server=%+v err=%v", stored, err)
}
}
func TestCoreServiceGeneratedRunRegistrationSkipsStatusWhenLifecycleJobActive(t *testing.T) {
func TestCoreServiceGeneratedRunRegistrationLeavesExistingLifecycleJobUntouched(t *testing.T) {
svc := newTestCoreService()
plugin := createGeneratedRunStatusPlugin(t, svc)
instance := domain.ServerInstance{ID: "managed-active-start", PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: dedicatedRunEndpointID("managed-active-start"), Name: "Managed Active Start", State: domain.ServerInstanceStateRunning, ConfigVersion: 1, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ProfileKey: "run-local", ServerRoot: `D:\scum-active`, Revision: 1}}
@@ -484,7 +466,7 @@ func TestCoreServiceGeneratedRunRegistrationSkipsStatusWhenLifecycleJobActive(t
registerGeneratedRunForStatusTest(t, svc, instance, plugin.ID)
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart {
t.Fatalf("active lifecycle job should suppress status reconciliation, jobs=%+v err=%v", jobs, err)
t.Fatalf("registration should leave pre-existing lifecycle jobs untouched, jobs=%+v err=%v", jobs, err)
}
}
+162 -27
View File
@@ -124,28 +124,29 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
if err != nil {
return domain.DistributionBuildInput{}, err
}
profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(distribution)
packageInput, err := svc.runDistributionPackageContext(distribution)
if err != nil {
return domain.DistributionBuildInput{}, err
}
return domain.DistributionBuildInput{
JobID: job.ID,
ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
RunEndpointID: distribution.RunEndpointID,
ProfileKey: profileKey,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID,
PlatformURL: runReleasePlatformURL(),
PackageFormat: distribution.PackageFormat,
ArtifactID: distribution.ArtifactID,
OutputFilename: executableFilename("run", distribution.TargetOS),
SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey,
WorkspaceSeed: workspaceSeed,
JobID: job.ID,
ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
RunEndpointID: distribution.RunEndpointID,
ProfileKey: packageInput.profileKey,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID,
PlatformURL: runReleasePlatformURL(),
PackageFormat: distribution.PackageFormat,
ArtifactID: distribution.ArtifactID,
OutputFilename: executableFilename("run", distribution.TargetOS),
SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey,
WorkspaceSeed: packageInput.workspaceSeed,
AutonomousLifecycle: packageInput.autonomousLifecycle,
}, nil
}
@@ -191,26 +192,160 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
return domain.DistributionBuildInput{}, repo.ErrNotFound
}
func (svc *CoreService) runDistributionWorkspaceSeed(distribution domain.RunDistribution) (string, string, error) {
type runDistributionPackageContext struct {
profileKey string
workspaceSeed string
autonomousLifecycle *domain.RunAutonomousLifecyclePlan
}
func (svc *CoreService) runDistributionPackageContext(distribution domain.RunDistribution) (runDistributionPackageContext, error) {
instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID)
if err != nil {
return "", "", err
return runDistributionPackageContext{}, err
}
plugin, err := svc.store.GamePlugins().Get(distribution.PluginID)
if err != nil {
return "", "", err
}
seed, err := encodePluginWorkspaceSeed(plugin.LifecycleAssets)
if err != nil {
return "", "", err
return runDistributionPackageContext{}, err
}
profileKey := instance.Deployment.ProfileKey
bindings := map[string]string(nil)
if binding, bindingErr := svc.runtimeBindingForServer(distribution.ServerInstanceID); bindingErr == nil {
profileKey = binding.ProfileKey
bindings = domain.CopyStringMap(binding.Bindings)
} else if !errors.Is(bindingErr, repo.ErrNotFound) {
return "", "", bindingErr
return runDistributionPackageContext{}, bindingErr
}
return profileKey, seed, nil
plan, err := runAutonomousLifecyclePlan(distribution, instance, plugin, profileKey, bindings)
if err != nil {
return runDistributionPackageContext{}, err
}
seed, err := encodeRunWorkspaceSeed(plugin.LifecycleAssets, plan)
if err != nil {
return runDistributionPackageContext{}, err
}
return runDistributionPackageContext{profileKey: profileKey, workspaceSeed: seed, autonomousLifecycle: plan}, nil
}
func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance domain.ServerInstance, plugin domain.GamePlugin, profileKey string, bindings map[string]string) (*domain.RunAutonomousLifecyclePlan, error) {
plan := &domain.RunAutonomousLifecyclePlan{
SchemaVersion: "1",
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
PluginVersion: plugin.Version,
RunEndpointID: distribution.RunEndpointID,
ProfileKey: profileKey,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID,
DeploymentRevision: instance.Deployment.Revision,
RuntimeBindings: domain.CopyStringMap(bindings),
Deployment: autonomousDeploymentFromDefinition(instance.Deployment, profileKey, bindings),
}
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey)
for _, action := range []domain.ServerLifecycleAction{domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionStatus} {
entry := autonomousLifecycleAction(plugin, profile, hasProfile, action)
if entry.TargetKey == "" {
continue
}
plan.Actions = append(plan.Actions, entry)
}
bootstrap := autonomousLifecycleAction(plugin, profile, hasProfile, autonomousBootstrapLifecycleAction(plugin, profile, hasProfile))
if bootstrap.TargetKey != "" {
plan.Bootstrap = &bootstrap
}
for _, probe := range plugin.RuntimeProfiles.DependencyProbes {
if runtimePlatformsContain(probe.Platforms, distribution.TargetOS) {
plan.DependencyProbes = append(plan.DependencyProbes, autonomousDependencyProbe(probe))
}
}
for _, installPlan := range plugin.RuntimeProfiles.InstallPlans {
if runtimePlatformsContain(installPlan.Platforms, distribution.TargetOS) {
plan.InstallPlans = append(plan.InstallPlans, autonomousInstallPlan(installPlan))
}
}
for _, source := range plugin.RuntimeProfiles.LogSources {
if source.Kind == "process.stdout" || source.Kind == "process.stderr" {
plan.LogSources = append(plan.LogSources, autonomousLogSource(source))
}
}
if hasProfile && len(profile.DLLExtensionRefs) > 0 {
endpoint := domain.RunEndpoint{ID: distribution.RunEndpointID, Platform: distribution.TargetOS, Architecture: distribution.TargetArch}
extensions, err := lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint)
if err != nil {
return nil, err
}
for _, extension := range extensions {
plan.DLLExtensions = append(plan.DLLExtensions, autonomousDLLExtension(extension))
}
}
return domain.CopyRunAutonomousLifecyclePlanPtr(plan), nil
}
func autonomousLifecycleAction(plugin domain.GamePlugin, profile domain.RuntimeLifecycleProfile, hasProfile bool, action domain.ServerLifecycleAction) domain.RunAutonomousLifecycleAction {
targetKey := ""
if hasProfile {
targetKey = runtimeProfileActionRef(profile.ActionRefs, action)
}
if targetKey == "" {
targetKey = lifecycleActionRef(plugin, action)
}
if targetKey == "" {
return domain.RunAutonomousLifecycleAction{}
}
return domain.RunAutonomousLifecycleAction{Action: action, Operation: lifecycleExecutionOperation(action), Capability: domain.LifecycleCapabilityForAction(action), TargetKey: targetKey}
}
func autonomousBootstrapLifecycleAction(plugin domain.GamePlugin, profile domain.RuntimeLifecycleProfile, hasProfile bool) domain.ServerLifecycleAction {
if autonomousLifecycleAction(plugin, profile, hasProfile, domain.ServerLifecycleActionStart).TargetKey != "" {
return domain.ServerLifecycleActionStart
}
return domain.ServerLifecycleActionCreate
}
func autonomousDependencyProbe(probe domain.RuntimeDependencyProbe) domain.RunAutonomousDependencyProbe {
return domain.RunAutonomousDependencyProbe{Key: probe.Key, Kind: probe.Kind, TargetKey: probe.TargetKey, Required: probe.Required, MinimumVersion: probe.MinimumVersion, Platforms: domain.CopyStringSlice(probe.Platforms)}
}
func autonomousInstallPlan(plan domain.RuntimeInstallPlan) domain.RunAutonomousInstallPlan {
steps := make([]domain.RunAutonomousInstallStep, len(plan.Steps))
for i, step := range plan.Steps {
steps[i] = domain.RunAutonomousInstallStep{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadRef: step.DownloadRef, Checksum: step.Checksum}
}
return domain.RunAutonomousInstallPlan{Key: plan.Key, Title: plan.Title, Platforms: domain.CopyStringSlice(plan.Platforms), Steps: steps}
}
func autonomousLogSource(source domain.RuntimeLogSource) domain.RunAutonomousLogSource {
return domain.RunAutonomousLogSource{Key: source.Key, Kind: source.Kind, TargetKey: source.TargetKey, StreamKey: source.StreamKey, CursorKind: source.CursorKind, RetentionDays: source.RetentionDays}
}
func autonomousDLLExtension(extension domain.RuntimeDLLExtensionPlan) domain.RunAutonomousDLLExtension {
return domain.RunAutonomousDLLExtension{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort}
}
func autonomousDeploymentFromDefinition(definition domain.ServerDeploymentDefinition, profileKey string, bindings map[string]string) *domain.RunAutonomousDeployment {
if definition.Mode == "" {
return nil
}
copy := domain.CopyServerDeploymentDefinition(definition)
if copy.ProfileKey == "" {
copy.ProfileKey = profileKey
}
if len(copy.RuntimeBindings) == 0 {
copy.RuntimeBindings = domain.CopyStringMap(bindings)
}
return &domain.RunAutonomousDeployment{SchemaVersion: "1", Mode: copy.Mode, ProfileKey: copy.ProfileKey, RuntimeBindings: copy.RuntimeBindings, CreateInputs: copy.CreateInputs, ServerRoot: copy.ServerRoot, WorkingDirectory: copy.WorkingDirectory, InstallCommand: copy.InstallCommand, StartCommand: copy.StartCommand, StopCommand: copy.StopCommand, StatusCommand: copy.StatusCommand, Shell: copy.Shell, Revision: copy.Revision}
}
func encodeRunWorkspaceSeed(files []domain.PluginAssetFile, plan *domain.RunAutonomousLifecyclePlan) (string, error) {
seedFiles := append([]domain.PluginAssetFile(nil), files...)
if plan != nil {
payload, err := json.Marshal(plan)
if err != nil {
return "", err
}
seedFiles = append(seedFiles, domain.PluginAssetFile{Path: ".platform/autonomous-lifecycle-plan.json", Content: string(payload), Mode: 0o600})
}
return encodePluginWorkspaceSeed(seedFiles)
}
func encodePluginWorkspaceSeed(files []domain.PluginAssetFile) (string, error) {
@@ -41,6 +41,7 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
{Path: "actions/install.json", Content: `{"version":1,"action":"install","mode":"oneshot"}`, Mode: 0o600},
{Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700},
}
plugin.RuntimeProfiles.LogSources = append(plugin.RuntimeProfiles.LogSources, domain.RuntimeLogSource{Key: "console", Kind: "process.stdout", TargetKey: "server/process", StreamKey: "console", CursorKind: "sequence", RetentionDays: 14})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("seed plugin lifecycle assets: %v", err)
}
@@ -52,7 +53,7 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "windows",
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "platform-secret-boundary",
})
@@ -76,9 +77,26 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
if err := json.Unmarshal(decodedSeed, &seedFiles); err != nil {
t.Fatalf("unmarshal workspace seed: %v", err)
}
if platformInput.ProfileKey != "local" || len(seedFiles) != 2 || seedFiles[1].Path != "bin/install-server" || seedFiles[1].Content == "" {
if platformInput.ProfileKey != "local" || len(seedFiles) != 3 || seedFiles[1].Path != "bin/install-server" || seedFiles[1].Content == "" {
t.Fatalf("platform builder received incomplete plugin workspace seed: profile=%q seed=%+v", platformInput.ProfileKey, seedFiles)
}
if seedFiles[2].Path != ".platform/autonomous-lifecycle-plan.json" || seedFiles[2].Content == "" || seedFiles[2].Mode != 0o600 {
t.Fatalf("workspace seed did not include autonomous lifecycle plan file: %+v", seedFiles)
}
plan := platformInput.AutonomousLifecycle
if plan == nil || plan.SchemaVersion != "1" || plan.ServerInstanceID != instance.ID || plan.PluginID != plugin.ID || plan.ProfileKey != "local" || plan.Bootstrap == nil || plan.Bootstrap.Action != domain.ServerLifecycleActionStart || plan.Bootstrap.TargetKey != "actions/start.json" {
t.Fatalf("platform builder received incomplete autonomous lifecycle plan: %+v", plan)
}
if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 1 || plan.LogSources[0].Kind != "process.stdout" || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" {
t.Fatalf("autonomous lifecycle plan lost plugin runtime declarations: %+v", plan)
}
var seededPlan domain.RunAutonomousLifecyclePlan
if err := json.Unmarshal([]byte(seedFiles[2].Content), &seededPlan); err != nil {
t.Fatalf("unmarshal seeded autonomous lifecycle plan: %v", err)
}
if seededPlan.ServerInstanceID != plan.ServerInstanceID || seededPlan.Bootstrap == nil || seededPlan.Bootstrap.TargetKey != plan.Bootstrap.TargetKey {
t.Fatalf("seeded lifecycle plan differs from build input: seed=%+v input=%+v", seededPlan, plan)
}
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun,
+19 -18
View File
@@ -51,28 +51,29 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
if err != nil {
return domain.DistributionBuildInput{}, err
}
profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(distribution)
packageInput, err := svc.runDistributionPackageContext(distribution)
if err != nil {
return domain.DistributionBuildInput{}, err
}
return domain.DistributionBuildInput{
JobID: job.ID,
ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
RunEndpointID: distribution.RunEndpointID,
ProfileKey: profileKey,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID,
PlatformURL: runReleasePlatformURL(),
PackageFormat: distribution.PackageFormat,
ArtifactID: distribution.ArtifactID,
OutputFilename: executableFilename("run", distribution.TargetOS),
SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey,
WorkspaceSeed: workspaceSeed,
JobID: job.ID,
ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
RunEndpointID: distribution.RunEndpointID,
ProfileKey: packageInput.profileKey,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID,
PlatformURL: runReleasePlatformURL(),
PackageFormat: distribution.PackageFormat,
ArtifactID: distribution.ArtifactID,
OutputFilename: executableFilename("run", distribution.TargetOS),
SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey,
WorkspaceSeed: packageInput.workspaceSeed,
AutonomousLifecycle: packageInput.autonomousLifecycle,
}, nil
}
+12
View File
@@ -8,6 +8,7 @@ import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
@@ -170,6 +171,17 @@ func (builder *DockerDistributionBuilder) Build(input domain.DistributionBuildIn
if err := os.WriteFile(filepath.Join(inputDir, "workspace-seed.json"), seedPayload, 0o600); err != nil {
return nil, err
}
lifecyclePlanPayload := []byte("{}")
if input.AutonomousLifecycle != nil {
encoded, err := json.Marshal(input.AutonomousLifecycle)
if err != nil {
return nil, validationError("distribution build input has an invalid autonomous lifecycle plan")
}
lifecyclePlanPayload = encoded
}
if err := os.WriteFile(filepath.Join(inputDir, "autonomous-lifecycle-plan.json"), lifecyclePlanPayload, 0o600); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil {
return nil, err
}
@@ -136,6 +136,13 @@ func TestDockerDistributionBuilderKeepsSecretInIsolatedInput(t *testing.T) {
if bytes.Contains(script, []byte(secret)) {
t.Fatal("build script must not embed the component auth key")
}
planPayload, err := os.ReadFile(filepath.Join(inputDir, "autonomous-lifecycle-plan.json"))
if err != nil {
t.Fatalf("read autonomous lifecycle plan input: %v", err)
}
if strings.TrimSpace(string(planPayload)) != "{}" {
t.Fatalf("unexpected empty autonomous lifecycle plan payload %q", planPayload)
}
if err := os.WriteFile(filepath.Join(outputDir, "run.exe"), []byte("compiled-run"), 0o700); err != nil {
t.Fatalf("write fake build output: %v", err)
}