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
+144 -19
View File
@@ -109,25 +109,119 @@ type DistributionBuildInputRequest struct {
}
type DistributionBuildInput struct {
JobID string
ComponentKind DistributionComponentKind
ServerInstanceID string
PluginID string
RunEndpointID string
ProfileKey string
TargetOS string
TargetArch string
TargetRelease string
PlatformURL string
PackageFormat string
RepositoryURL string
SourceRevision string
ArtifactID string
OutputFilename string
SecretRef string
KeyGeneration int
AuthKey string
WorkspaceSeed string
JobID string
ComponentKind DistributionComponentKind
ServerInstanceID string
PluginID string
RunEndpointID string
ProfileKey string
TargetOS string
TargetArch string
TargetRelease string
PlatformURL string
PackageFormat string
RepositoryURL string
SourceRevision string
ArtifactID string
OutputFilename string
SecretRef string
KeyGeneration int
AuthKey string
WorkspaceSeed string
AutonomousLifecycle *RunAutonomousLifecyclePlan
}
type RunAutonomousLifecyclePlan struct {
SchemaVersion string `json:"schemaVersion"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
RunEndpointID string `json:"runEndpointId"`
ProfileKey string `json:"profileKey,omitempty"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
TargetRelease string `json:"targetRelease"`
DeploymentRevision int `json:"deploymentRevision,omitempty"`
Bootstrap *RunAutonomousLifecycleAction `json:"bootstrap,omitempty"`
Actions []RunAutonomousLifecycleAction `json:"actions,omitempty"`
DependencyProbes []RunAutonomousDependencyProbe `json:"dependencyProbes,omitempty"`
InstallPlans []RunAutonomousInstallPlan `json:"installPlans,omitempty"`
LogSources []RunAutonomousLogSource `json:"logSources,omitempty"`
DLLExtensions []RunAutonomousDLLExtension `json:"dllExtensions,omitempty"`
RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"`
Deployment *RunAutonomousDeployment `json:"deployment,omitempty"`
}
type RunAutonomousLifecycleAction struct {
Action ServerLifecycleAction `json:"action"`
Operation string `json:"operation"`
Capability string `json:"capability"`
TargetKey string `json:"targetKey"`
}
type RunAutonomousDependencyProbe struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey"`
Required bool `json:"required,omitempty"`
MinimumVersion string `json:"minimumVersion,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
type RunAutonomousInstallPlan struct {
Key string `json:"key"`
Title string `json:"title,omitempty"`
Platforms []string `json:"platforms,omitempty"`
Steps []RunAutonomousInstallStep `json:"steps,omitempty"`
}
type RunAutonomousInstallStep struct {
Type string `json:"type"`
TargetKey string `json:"targetKey"`
PackageManager string `json:"packageManager,omitempty"`
PackageName string `json:"packageName,omitempty"`
Version string `json:"version,omitempty"`
DownloadRef string `json:"downloadRef,omitempty"`
Checksum string `json:"checksum,omitempty"`
}
type RunAutonomousLogSource struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey"`
StreamKey string `json:"streamKey,omitempty"`
CursorKind string `json:"cursorKind,omitempty"`
RetentionDays int `json:"retentionDays,omitempty"`
}
type RunAutonomousDLLExtension struct {
Key string `json:"key"`
Version string `json:"version"`
ReleaseURL string `json:"releaseUrl"`
Checksum string `json:"checksum"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
TargetKey string `json:"targetKey"`
ModKey string `json:"modKey"`
DLLRef string `json:"dllRef"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"`
UE4SSABI string `json:"ue4ssAbi,omitempty"`
RCONPort int `json:"rconPort,omitempty"`
}
type RunAutonomousDeployment struct {
SchemaVersion string `json:"schemaVersion"`
Mode ServerDeploymentMode `json:"mode"`
ProfileKey string `json:"profileKey,omitempty"`
RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"`
CreateInputs map[string]string `json:"createInputs,omitempty"`
ServerRoot string `json:"serverRoot,omitempty"`
WorkingDirectory string `json:"workingDirectory,omitempty"`
InstallCommand string `json:"installCommand,omitempty"`
StartCommand string `json:"startCommand,omitempty"`
StopCommand string `json:"stopCommand,omitempty"`
StatusCommand string `json:"statusCommand,omitempty"`
Shell ServerCommandShell `json:"shell,omitempty"`
Revision int `json:"revision,omitempty"`
}
type DependencyExecutionInputRequest struct {
@@ -381,6 +475,37 @@ func CopyDependencyExecutionInput(input DependencyExecutionInput) DependencyExec
return input
}
func CopyRunAutonomousLifecyclePlanPtr(plan *RunAutonomousLifecyclePlan) *RunAutonomousLifecyclePlan {
if plan == nil {
return nil
}
copy := *plan
if plan.Bootstrap != nil {
bootstrap := *plan.Bootstrap
copy.Bootstrap = &bootstrap
}
copy.Actions = append([]RunAutonomousLifecycleAction(nil), plan.Actions...)
copy.DependencyProbes = append([]RunAutonomousDependencyProbe(nil), plan.DependencyProbes...)
for i := range copy.DependencyProbes {
copy.DependencyProbes[i].Platforms = CopyStringSlice(plan.DependencyProbes[i].Platforms)
}
copy.InstallPlans = append([]RunAutonomousInstallPlan(nil), plan.InstallPlans...)
for i := range copy.InstallPlans {
copy.InstallPlans[i].Platforms = CopyStringSlice(plan.InstallPlans[i].Platforms)
copy.InstallPlans[i].Steps = append([]RunAutonomousInstallStep(nil), plan.InstallPlans[i].Steps...)
}
copy.LogSources = append([]RunAutonomousLogSource(nil), plan.LogSources...)
copy.DLLExtensions = append([]RunAutonomousDLLExtension(nil), plan.DLLExtensions...)
copy.RuntimeBindings = CopyStringMap(plan.RuntimeBindings)
if plan.Deployment != nil {
deployment := *plan.Deployment
deployment.RuntimeBindings = CopyStringMap(plan.Deployment.RuntimeBindings)
deployment.CreateInputs = CopyStringMap(plan.Deployment.CreateInputs)
copy.Deployment = &deployment
}
return &copy
}
func CopySourceRCONExecutionInput(input SourceRCONExecutionInput) SourceRCONExecutionInput {
return input
}
+8 -2
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. 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.
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. Registration is binding/authentication only for generated Run bootstrap and must not enqueue lifecycle or status jobs merely because Run appeared. 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.
@@ -51,12 +51,18 @@ Named job DTOs:
Jobs must carry bounded metadata such as `jobId`, `runEndpointId`, `serverInstanceId`, `capability`, `idempotencyKey`, lease token, attempt, progress, terminal state, message, error code, and result reference. Job payloads must not carry logs, artifact chunks, host paths, raw credentials, direct sockets, or large inline result bodies.
Plugin lifecycle assignments may add only a validated plugin identifier, enumerated lifecycle operation, target version, and logical workspace scope. Install, enable, disable, upgrade, rollback, retire, and dependency-check remain Platform-authorized jobs; assignments cannot carry arbitrary shell, provider configuration, raw credentials, host paths, PIDs, sockets, DSNs, or RCON secrets.
Plugin lifecycle assignments may add only a validated plugin identifier, enumerated lifecycle operation, target version, and logical workspace scope. Explicit operator-requested install, enable, disable, upgrade, rollback, retire, dependency-check, and bounded lifecycle commands remain Platform-authorized jobs. Generated Run package startup is not dependent on registration-time job assignment; it is driven by the autonomous lifecycle plan embedded by the platform builder. Assignments cannot carry arbitrary shell, provider configuration, raw credentials, host paths, PIDs, sockets, DSNs, or RCON secrets.
Approved `config.write` and bounded `files.read`/`files.write` assignments carry logical keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local.
Job ack, progress, cancellation polling, reconciliation, and terminal result calls are lightweight lifecycle metadata. They must remain valid while artifact chunks or log retries are pending, and duplicate equivalent terminal results remain idempotent under channel pressure.
## Generated Run autonomous lifecycle plan
Platform-owned Run distribution builds embed an autonomous lifecycle plan for the server-scoped generated Run. The plan carries the server/plugin identity, selected runtime profile, target OS/architecture/release, plugin lifecycle action refs, dependency probes/install plans, process log sources, optional DLL extension plans, runtime bindings, and redacted deployment inputs. The builder includes the same JSON as internal build input and as `.platform/autonomous-lifecycle-plan.json` in the generated workspace seed. Run reads this package-local plan on startup and performs plugin-declared init, dependency verification/install, install-if-needed, readiness/status, and start behavior locally before reporting observed state back to Platform.
The plan is build input for the generated package, not a machine-side job-channel payload. Generated Run registration must not be treated as a trigger to enqueue `process.start`, `process.install`, or `process.status` work; Platform state converges from Run heartbeats, logs, lifecycle reports, supervised process facts, and terminal job/report messages. Platform and Run must not add game-specific hardcoding to interpret the plan.
## Log Ingest
Implemented HTTP JSON routes:
+9 -9
View File
@@ -15,12 +15,12 @@ The plugin marketplace API is a platform-facing projection over this installed r
## Server Instance
A server instance is created from one installed game management plugin and bound to one run endpoint.
A server instance is created from one installed game management plugin and is later bound to the generated Run endpoint when that Run registers. Platform stores the instance and projections; Run owns observed lifecycle execution on the machine.
### States
- `draft`: instance record exists but the first bootstrap job has not completed.
- `installing`: the first plugin-owned bootstrap job is active.
- `draft`: instance record exists and is awaiting Run-owned lifecycle bootstrap or reports.
- `installing`: Run reports that plugin-owned install/bootstrap work is active.
- `ready`: install/bootstrap succeeded without starting a supervised process, and the server can start.
- `running`: server process is running.
- `stopped`: server process is stopped.
@@ -36,9 +36,9 @@ A server instance is created from one installed game management plugin and bound
## Lifecycle Actions
- `create`: validate plugin, create instance record, dispatch the plugin-owned bootstrap job.
- `start`: dispatch process start job through the bound run endpoint.
- `stop`: dispatch process stop job through the bound run endpoint.
- `create`: validate plugin and create the instance record without requiring a run endpoint, deployment target, or runtime profile.
- `start`: record/authorize operator intent and route bounded control to the bound Run when applicable; generated Run startup is driven by its package-local autonomous lifecycle plan.
- `stop`: record/authorize operator intent and route bounded control to the bound Run when applicable.
- `restart`: dispatch stop/start or plugin-defined restart job.
- `update`: dispatch server update job and record version/result.
- `delete`: stop server when needed, preserve or remove artifacts according to policy, mark deleted.
@@ -48,7 +48,7 @@ A server instance is created from one installed game management plugin and bound
- `GET /api/v1/plugin-marketplace/plugins` lists plugin marketplace summaries from registry metadata with status, server type, capability, and keyword filters.
- `GET /api/v1/plugin-marketplace/plugins/{id}` returns one registry-backed marketplace detail.
- `POST /api/v1/plugin-marketplace/plugins/{id}/state` applies metadata-only `install`, `enable`, or `disable` state changes.
- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, a compatible run endpoint, a non-empty idempotency key, and required lifecycle action references. It creates the instance in `installing` state and queues either `process.install` or, for guided deployments whose selected lifecycle profile supports supervised start, `process.start` so the plugin start script can install-if-missing and stream process logs.
- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, server name, idempotency key, and plugin-declared create inputs when provided. It creates the instance without requiring a deployment target, run endpoint, or runtime profile. Generated Run packages carry the autonomous lifecycle plan that Run consumes on startup; registration confirms binding/auth and does not enqueue bootstrap lifecycle jobs.
- `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job.
- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job.
- `GET /api/v1/server-instances/{id}/config` returns logical read-only config content for an authorized server instance with config version, format, key, source, and update timestamp metadata.
@@ -65,9 +65,9 @@ Config write approval and file dispatch are platform-mediated. They carry logica
Marketplace state actions update only registry install state. They do not download packages, dispatch run jobs, execute plugin bridge code, write server files, expose package bytes, or contact external services. Package acquisition and runtime execution remain deferred to explicit future changes.
## Lifecycle Job Projection
## Lifecycle Projection
Terminal run job results update the associated server instance when the job capability is a lifecycle capability:
Platform-visible lifecycle state is a projection from Run-reported facts. Terminal run job results update the associated server instance when the job capability is a lifecycle capability for explicit Platform-authorized operations:
- `process.install` + `succeeded` marks the instance `ready`.
- `process.start` + `succeeded` marks the instance `running`.
-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)
}