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
+5 -5
View File
@@ -85,17 +85,17 @@ Do not define business structs inside functions. Do not define request/response
The external run executor must not expose host paths, raw credentials, or direct sockets to plugins or platform_web. The external run executor must not expose host paths, raw credentials, or direct sockets to plugins or platform_web.
Platform, plugin, and run lifecycle ownership must stay separated. Platform records desired lifecycle intent and persisted projections, not observed process truth: Platform, plugin, and run lifecycle ownership must stay separated. Run is the lifecycle authority for machine execution; Platform records desired lifecycle intent, registration/auth, audit, generated package inputs, and persisted projections from Run-reported facts, not observed process truth:
- Platform owns server instances, plugin manifest validation, distribution builds, run registration binding, authorization, desired lifecycle job dispatch, and persisted lifecycle projections. - Platform owns server instances, plugin manifest validation, platform-side distribution builds, generated Run package inputs, run registration binding, authorization/audit, and persisted lifecycle projections.
- Plugins own game-specific lifecycle behavior: install, update, pre-start checks, start arguments, stop logic, status probes, executable paths, Steam app IDs, and game-specific dependency commands. - Plugins own game-specific lifecycle declarations: init/install/update/pre-start checks, dependency probes/install plans, start arguments, stop logic, status/readiness probes, executable paths, Steam app IDs, and game-specific dependency commands.
- Run owns generic machine execution and the observed runtime/process state it supervises: scoped file operations, bounded process execution/supervision, declared capability enforcement, logs, artifacts, and channel transport. - Run owns generic machine lifecycle execution and the observed runtime/process state it supervises: local bootstrap from generated package plans, scoped file operations, bounded process execution/supervision, declared capability enforcement, logs, artifacts, and channel transport.
Observed machine/runtime status must flow from run reports, heartbeats, supervised process facts, and job/log channels. Platform must not treat stale persisted server state, such as `running`, as authoritative when evaluating the current machine process state. Observed machine/runtime status must flow from run reports, heartbeats, supervised process facts, and job/log channels. Platform must not treat stale persisted server state, such as `running`, as authoritative when evaluating the current machine process state.
Do not hardcode game-specific deployment behavior in run or platform services. Values such as `SCUMServer.exe`, Steam app `3792580`, `steamcmd +app_update`, SCUM install directories, `-port`, `-MaxPlayers`, or `-log` belong in the SCUM plugin's manifests, action specs, templates, or scripts. Do not hardcode game-specific deployment behavior in run or platform services. Values such as `SCUMServer.exe`, Steam app `3792580`, `steamcmd +app_update`, SCUM install directories, `-port`, `-MaxPlayers`, or `-log` belong in the SCUM plugin's manifests, action specs, templates, or scripts.
When a game needs "install if missing, update if present, then start" behavior, implement it as plugin-owned lifecycle actions. Platform may package and dispatch those actions, and run may execute them through generic capabilities, but neither platform nor run should special-case a game by name to perform those steps. When a game needs "install if missing, update if present, then start" behavior, implement it as plugin-owned lifecycle actions and package those declarations into the generated Run autonomous lifecycle plan. Run executes the plan through generic capabilities; neither Platform nor Run should special-case a game by name to perform those steps.
Do not add extra platform/frontend lifecycle states just to represent game-specific setup checks. A plugin-owned start action should verify its declared files and dependencies, create missing directories, install or update missing server bits, then start the service through the same plugin-declared lifecycle script. Run should only execute that declared script through generic supervision, hide the started process window where the operating system supports it, and return the supervised process output through the declared stdout/stderr log channels. Do not add extra platform/frontend lifecycle states just to represent game-specific setup checks. A plugin-owned start action should verify its declared files and dependencies, create missing directories, install or update missing server bits, then start the service through the same plugin-declared lifecycle script. Run should only execute that declared script through generic supervision, hide the started process window where the operating system supports it, and return the supervised process output through the declared stdout/stderr log channels.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-06
@@ -0,0 +1,3 @@
# make-run-autonomous-lifecycle-owner
Make generated Run own autonomous plugin-declared lifecycle bootstrap and make Platform follow Run-reported state.
@@ -0,0 +1,46 @@
## Context
Generated Run packages currently register with Platform and then wait for Platform to enqueue lifecycle work before any game bootstrap can happen. That makes Platform the practical lifecycle starter even though the machine-side Run is the only component that can observe process truth, supervise local execution, and safely decide whether install/update/start work is needed.
The corrected ownership model is: Platform builds and authenticates a server-scoped Run package, the package contains plugin-declared lifecycle assets plus a bounded autonomous lifecycle plan, Run executes that plan locally on startup, and Platform updates persisted projections from Run-reported lifecycle facts. Plugin manifests remain the source of game-specific instructions; Platform and Run remain generic.
## Goals / Non-Goals
**Goals:**
- Stop generated Run registration from enqueueing `process.start` or `process.status` jobs as a bootstrap side effect.
- Add a generated-package autonomous lifecycle plan containing plugin lifecycle action refs, dependency declarations, safe deployment inputs, log sources, and selected runtime profile metadata.
- Keep Platform responsible for server records, registration binding, auth, distribution builds, audit, and visible projections from Run reports.
- Update governance and protocol docs so future work treats Run as the lifecycle authority.
**Non-Goals:**
- Implement the independent `run` repository's plan executor in this repo.
- Add game-specific SCUM install/start behavior to Platform.
- Move distribution builds to machine-side Run endpoints or expose distribution-build authority to generated Runs.
- Remove explicit operator lifecycle command APIs in this change.
## Decisions
- **Embed lifecycle intent at package-build time.** Platform already has the server instance, plugin manifest, selected profile, lifecycle assets, and deployment definition when it builds a generated Run package. Encoding those into the package avoids waiting on `/run/jobs/claim` after registration and keeps the startup path deterministic.
- **Use plugin declarations, not Platform logic, for game behavior.** The plan references action files, dependency probes/install plans, process log sources, DLL extension declarations, and sanitized deployment inputs already declared by the plugin. It does not include SCUM executable names, Steam app IDs, ports, or platform-side command synthesis.
- **Make registration binding-only for generated Run bootstrap.** `RegisterRunHello` continues to authenticate the component, upsert endpoint metadata, and issue a session token. It does not dispatch lifecycle or reconciliation work merely because a generated Run appeared.
- **Keep Platform projections report-driven.** Existing terminal job/result projection can remain for explicit lifecycle commands, but generated Run startup state must converge through Run reports rather than Platform's stale stored state or registration-time probes.
- **Preserve builder security boundaries.** The platform-owned builder receives the plaintext component auth key internally and the autonomous plan as build input. Machine-side run endpoints still cannot claim `distribution.build` jobs or fetch plaintext build input.
## Risks / Trade-offs
- **Run repository lag** -> The generated package can carry the plan before the independent Run executable consumes it. Mitigation: protocol and build input are explicit, and this repo does not re-add Run source.
- **Stored Platform state may look stale until Run reports** -> Registration no longer paper-over probes with Platform jobs. Mitigation: UI/API must treat persisted lifecycle state as projection, not observed process truth.
- **Plan drift between build and execution** -> A package carries the plugin declarations and deployment revision available at build time. Mitigation: include plugin version, profile key, deployment revision, and target release so Run and Platform can report stale-plan evidence.
- **Operator command APIs still dispatch jobs** -> This change fixes generated Run autonomous startup first. Explicit commands remain auditable Platform requests until a later change redesigns command transport around Run-owned intent handling.
## Migration Plan
- Stop queuing registration-time lifecycle/status jobs for generated Runs.
- Extend run distribution build input and DTOs with `autonomousLifecycle` for Run packages only.
- Update platform builder input materialization so the generated package has a serialized plan alongside the existing workspace seed.
- Update service tests to assert registration does not enqueue bootstrap/reconciliation jobs and build input includes the plan.
## Open Questions
- The independent `run` repository must define exactly how it consumes `autonomousLifecycle`, persists local bootstrap state, and reports lifecycle phases back to Platform.
- A future change may replace explicit Platform-dispatched start/stop jobs with Run-owned desired-intent handling for all lifecycle commands.
@@ -0,0 +1,28 @@
## Why
Generated Run currently comes online, registers, and then waits for Platform to assign lifecycle jobs before a game process can exist. That inverts the intended ownership model: Run is the machine-side lifecycle owner, Platform should follow Run-reported facts, and plugins should only declare how Run initializes, installs dependencies, verifies readiness, and starts the game.
## What Changes
- **BREAKING** Treat generated Run startup as an autonomous lifecycle bootstrap driven by plugin-declared lifecycle assets and the server deployment definition embedded in the generated package.
- Stop using accepted generated Run registration as a Platform trigger to enqueue `process.start` or `process.status` reconciliation jobs.
- Add a bounded autonomous lifecycle plan to generated Run distribution build input so the independent Run package can self-bootstrap without waiting for `/run/jobs/claim` work.
- Keep Platform as the registry, authorization, audit, and projection surface: Platform receives Run heartbeats, logs, lifecycle reports, and process facts, then updates visible server state from those Run-owned facts.
- Preserve Platform-owned distribution builds and component key security; generated Runs still never receive distribution-build authority.
## Capabilities
### New Capabilities
- `run-autonomous-lifecycle-owner`: Generated Run owns plugin-declared bootstrap/start behavior and Platform follows Run-reported observed state.
### Modified Capabilities
- `platform-side-distribution-builds`: Generated Run packages must include the bounded autonomous lifecycle plan required for Run to bootstrap itself.
## Impact
- `AGENTS.md`, `platform/protocol/*`, and OpenSpec contracts must stop describing Platform as the lifecycle bootstrap dispatcher for generated Runs.
- `platform/` distribution build input and tests gain an autonomous lifecycle plan sourced from plugin lifecycle declarations and server deployment settings.
- `platform/service/control.go` stops enqueueing registration-time lifecycle/status jobs for generated Runs.
- The independent `run` repository must implement plan consumption and autonomous execution; this repository must not re-add a `run/` source tree.
@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Generated Run build input includes autonomous lifecycle plan
The platform-owned distribution builder SHALL receive a generated Run autonomous lifecycle plan for `run` component packages and SHALL keep that plan inside platform-side build input rather than requiring a machine-side Run endpoint to claim lifecycle bootstrap work.
#### Scenario: Platform builder assembles Run package input
- **WHEN** an owner requests Run generation for a server instance
- **THEN** the platform builder input and generated workspace seed include plugin lifecycle action refs, selected profile key, dependency probes, install plans, process log sources, deployment revision, and redacted deployment execution inputs for that server
#### Scenario: Client-manager build input
- **WHEN** an owner requests client-manager generation
- **THEN** the build input does not include a server Run autonomous lifecycle plan
#### Scenario: Build input remains platform-owned
- **WHEN** a machine-side Run endpoint attempts to claim or read a platform-owned distribution build
- **THEN** Platform denies that access and does not expose the plaintext component auth key or autonomous lifecycle plan through the machine job channel
@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Generated Run startup is autonomous
Generated Run packages SHALL carry a bounded autonomous lifecycle plan that lets Run bootstrap the server from plugin-declared lifecycle assets without waiting for Platform to enqueue `process.start`, `process.install`, or `process.status` work after registration.
#### Scenario: Generated Run registers after startup
- **WHEN** a server-scoped generated Run registers with valid component authentication
- **THEN** Platform accepts the registration and does not enqueue lifecycle or status jobs solely because the registration occurred
#### Scenario: Generated Run package starts locally
- **WHEN** the generated Run executable starts on its host
- **THEN** Run can read the embedded autonomous lifecycle plan and execute plugin-declared init, dependency verification/install, install-if-needed, readiness/status, and start behavior locally
### Requirement: Platform follows Run-reported lifecycle facts
Platform SHALL treat persisted server lifecycle state as a projection of Run-reported lifecycle facts, heartbeats, logs, and terminal process reports rather than as authoritative observed process truth.
#### Scenario: Run reports no managed process
- **WHEN** Run reports that the server process is stopped, not started, or exited
- **THEN** Platform updates the visible server projection from that Run-owned fact instead of preserving stale `running` state
#### Scenario: Run reports a live managed process
- **WHEN** Run reports that the managed process is running
- **THEN** Platform projects the server as running based on the Run report
### Requirement: Plugins declare game-specific lifecycle behavior
Plugins SHALL declare lifecycle action refs, dependency probes, install plans, runtime profiles, log sources, and deployment templates needed by Run, and Platform SHALL NOT hardcode game-specific install, update, status, or startup behavior.
#### Scenario: SCUM lifecycle bootstrap
- **WHEN** a SCUM generated Run package is built
- **THEN** Platform packages the plugin-declared lifecycle refs and deployment inputs without hardcoding SCUM executable names, Steam app IDs, ports, or install directories in Platform code
### Requirement: Run registration is binding and authentication only
Generated Run registration SHALL authenticate the component, bind or confirm the dedicated endpoint identity, upsert endpoint metadata, and issue a control session, but SHALL NOT be used as a Platform-side lifecycle bootstrap dispatcher.
#### Scenario: Guided draft generated Run registers
- **WHEN** a guided draft server's generated Run registers
- **THEN** the server remains awaiting Run-owned lifecycle reports and Platform does not create a bootstrap start job
#### Scenario: Stale running generated Run registers
- **WHEN** a generated Run registers for a server whose persisted state is `running`
- **THEN** Platform does not create a registration-time `process.status` reconciliation job and instead waits for Run-owned status/lifecycle reporting
@@ -0,0 +1,22 @@
## Prompt Boundaries
- [x] 0.1 正向提示词: Make generated Run packages self-bootstrap from plugin-declared lifecycle plans so the first-party server management area projects state from Run-owned facts.
- [x] 0.2 方向提示词: Update `platform/` build input, registration handling, tests, and protocol docs while preserving existing domain/service/DTO separation; verify with Go tests, OpenSpec validation, and `scripts/check-structure.sh`.
- [x] 0.3 任务边界: Do not add a `run/` source tree, hardcode SCUM behavior in Platform, expose component keys to machine endpoints, add cloud/billing/provider workflows, or touch unrelated frontend styling.
## 1. OpenSpec Contract
- [x] 1.1 Add design and delta specs for Run-owned autonomous lifecycle startup.
- [x] 1.2 Validate the OpenSpec change strictly before completion.
## 2. Platform Implementation
- [x] 2.1 Add autonomous lifecycle plan domain/build-input structures without exposing the plan through machine job-channel DTOs.
- [x] 2.2 Populate the plan from plugin lifecycle declarations, runtime profile data, dependency declarations, log sources, DLL extensions, and deployment definition.
- [x] 2.3 Stop generated Run registration from enqueueing bootstrap start or status reconciliation jobs.
- [x] 2.4 Update protocol and governance docs to make Run the lifecycle authority.
## 3. Verification
- [x] 3.1 Update service tests for autonomous build input and no registration-time lifecycle dispatch.
- [x] 3.2 Run targeted Go tests plus repository structure checks.
@@ -2,14 +2,14 @@
Platform currently stores `serverInstances.state` as both desired state and observed runtime state. `CompleteRunJob` projects successful lifecycle jobs directly into that field, so a previous `process.start` success can leave a server as `running` even after the actual Run-managed process is gone. A manually started generated Run can register and heartbeat, but platform will not dispatch another start job because it trusts the stale stored state. Platform currently stores `serverInstances.state` as both desired state and observed runtime state. `CompleteRunJob` projects successful lifecycle jobs directly into that field, so a previous `process.start` success can leave a server as `running` even after the actual Run-managed process is gone. A manually started generated Run can register and heartbeat, but platform will not dispatch another start job because it trusts the stale stored state.
Run already has the safer primitive: plugin-declared `process.status` executes inside the generated Run workspace and returns a redacted `processState`. This change uses that existing channel as the observed runtime source. Run already has the safer primitive: it owns the generated package startup path and can report redacted `processState` facts from inside the generated Run workspace. This change uses Run reports as the observed runtime source and avoids Platform registration-time probes.
## Goals / Non-Goals ## Goals / Non-Goals
**Goals:** **Goals:**
- Make generated Run startup reconcile stale platform lifecycle state through a platform-dispatched, Run-executed `process.status` job. - Stop generated Run startup from relying on a platform-dispatched registration-time `process.status` job.
- Project server state from Run `processState` for status/start/stop lifecycle results. - Project server state from Run `processState` for status/start/stop lifecycle results.
- Preserve platform ownership of authorization, command dispatch, leases, and audit. - Preserve Platform ownership of authorization, leases, and audit for explicit operator requests while treating generated Run bootstrap as Run-owned.
**Non-Goals:** **Non-Goals:**
- Add a new live telemetry protocol or raw process list to heartbeat. - Add a new live telemetry protocol or raw process list to heartbeat.
@@ -18,9 +18,8 @@ Run already has the safer primitive: plugin-declared `process.status` executes i
## Decisions ## Decisions
- Use `process.status` rather than adding heartbeat fields. This keeps state reconciliation inside the existing job lease, capability, audit, and plugin-declared action model. - Do not queue status reconciliation on generated Run registration. Registration confirms identity and session only; Run-owned lifecycle/status reports correct stale Platform projections.
- Queue status reconciliation on generated Run registration when stored state is `running` or `failed`. Those states are the ones most likely to be stale after a manually restarted Run or process crash. - Keep explicit `process.status` result projection for operator-requested or Run-reported status flows that are not registration bootstrap side effects.
- Skip reconciliation when an active lifecycle job already exists for the server. The active job is already the current control operation and should not be raced by a status probe.
- Project `process.status` into lifecycle state with conservative mapping: `running` => `running`, `stopped/not-started` => `stopped`, unexpected `exited` => `failed`, operator-stopped `exited` => `stopped`. - Project `process.status` into lifecycle state with conservative mapping: `running` => `running`, `stopped/not-started` => `stopped`, unexpected `exited` => `failed`, operator-stopped `exited` => `stopped`.
## Risks / Trade-offs ## Risks / Trade-offs
@@ -4,10 +4,10 @@ Manual generated Run execution exposed a stale lifecycle design: platform persis
## What Changes ## What Changes
- Add runtime-state reconciliation for generated Run registration so a Run endpoint can report the actual managed process state for its server after reconnect/startup. - Remove registration-time runtime-state reconciliation jobs for generated Run; Run reports the actual managed process state from its own lifecycle authority instead of waiting for Platform probes.
- Project `process.status` results into server lifecycle state using Run-reported `processState` values such as `running`, `stopped`, `not-started`, and `exited`. - Project `process.status` results into server lifecycle state using Run-reported `processState` values such as `running`, `stopped`, `not-started`, and `exited`.
- Prevent stale platform `running` from surviving when the active Run reports no managed process for that server. - Prevent stale platform `running` from surviving when the active Run reports no managed process for that server.
- Keep lifecycle command authorization and job dispatch platform-owned; only observed runtime/process state becomes Run-authoritative. - Keep Platform authorization/audit for explicit operator requests while making observed runtime/process state and generated Run bootstrap Run-authoritative.
## Capabilities ## Capabilities
@@ -1,15 +1,15 @@
## ADDED Requirements ## ADDED Requirements
### Requirement: Generated Run registration reconciles observed process state ### Requirement: Generated Run registration does not dispatch observed-state probes
When a generated Run registers for a bound server instance, the platform SHALL enqueue a scoped `process.status` reconciliation job when the stored server state says the game process is running or failed and no active lifecycle job already covers that server. The reconciliation job SHALL use the plugin-declared status action and the same scoped workspace metadata as normal lifecycle jobs. When a generated Run registers for a bound server instance, the platform SHALL NOT enqueue a scoped `process.status` reconciliation job merely because the stored server state says the game process is running or failed. Registration SHALL confirm identity, binding, and session state only; observed process state SHALL come from Run-owned lifecycle/status reports.
#### Scenario: Stale running state is checked after manual Run startup #### Scenario: Stale running state waits for Run report after manual Run startup
- **WHEN** a generated Run registers for a server whose stored state is `running` - **WHEN** a generated Run registers for a server whose stored state is `running`
- **THEN** the platform enqueues one `process.status` job for that server and Run endpoint - **THEN** the platform does not enqueue a `process.status` job solely from registration
#### Scenario: Existing active lifecycle job avoids duplicate status checks #### Scenario: Existing active lifecycle job remains untouched
- **WHEN** a generated Run registers while the same server already has an active lifecycle job - **WHEN** a generated Run registers while the same server already has an active lifecycle job
- **THEN** the platform does not enqueue an additional status reconciliation job - **THEN** the platform leaves the existing job unchanged and does not add a registration-time status probe
### Requirement: Run process status is authoritative for observed lifecycle state ### Requirement: Run process status is authoritative for observed lifecycle state
The platform SHALL project terminal `process.status` results from Run into the server instance state. A Run-reported `processState` of `running` SHALL mark the server `running`; `stopped` or `not-started` SHALL mark it `stopped`; `exited` SHALL mark it `failed` unless the exit classification is an operator stop such as `requested-stop`, `forced-stop`, or `already-stopped`, in which case it SHALL mark the server `stopped`. The platform SHALL project terminal `process.status` results from Run into the server instance state. A Run-reported `processState` of `running` SHALL mark the server `running`; `stopped` or `not-started` SHALL mark it `stopped`; `exited` SHALL mark it `failed` unless the exit classification is an operator stop such as `requested-stop`, `forced-stop`, or `already-stopped`, in which case it SHALL mark the server `stopped`.
@@ -1,9 +1,9 @@
## 1. Runtime State Reconciliation ## 1. Runtime State Reconciliation
- [x] 1.1 Queue generated Run status reconciliation on registration when stored server state may be stale - [x] 1.1 Prevent generated Run registration from queuing status reconciliation when stored server state may be stale
- [x] 1.2 Project `process.status` execution results into server lifecycle state using Run `processState` - [x] 1.2 Project `process.status` execution results into server lifecycle state using Run `processState`
## 2. Verification ## 2. Verification
- [x] 2.1 Add service tests for stale running correction and active-job dedupe - [x] 2.1 Add service tests for no registration-time status dispatch and Run-fact state projection
- [x] 2.2 Run targeted Go tests, `openspec validate make-run-runtime-state-authoritative --strict`, and `scripts/check-structure.sh` - [x] 2.2 Run targeted Go tests, `openspec validate make-run-runtime-state-authoritative --strict`, and `scripts/check-structure.sh`
+144 -19
View File
@@ -109,25 +109,119 @@ type DistributionBuildInputRequest struct {
} }
type DistributionBuildInput struct { type DistributionBuildInput struct {
JobID string JobID string
ComponentKind DistributionComponentKind ComponentKind DistributionComponentKind
ServerInstanceID string ServerInstanceID string
PluginID string PluginID string
RunEndpointID string RunEndpointID string
ProfileKey string ProfileKey string
TargetOS string TargetOS string
TargetArch string TargetArch string
TargetRelease string TargetRelease string
PlatformURL string PlatformURL string
PackageFormat string PackageFormat string
RepositoryURL string RepositoryURL string
SourceRevision string SourceRevision string
ArtifactID string ArtifactID string
OutputFilename string OutputFilename string
SecretRef string SecretRef string
KeyGeneration int KeyGeneration int
AuthKey string AuthKey string
WorkspaceSeed 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 { type DependencyExecutionInputRequest struct {
@@ -381,6 +475,37 @@ func CopyDependencyExecutionInput(input DependencyExecutionInput) DependencyExec
return input 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 { func CopySourceRCONExecutionInput(input SourceRCONExecutionInput) SourceRCONExecutionInput {
return input return input
} }
+8 -2
View File
@@ -18,7 +18,7 @@ Named control DTOs:
- `RunCapabilityReport` - `RunCapabilityReport`
- `RunCapacityReport` - `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. 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. 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. 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. 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 ## Log Ingest
Implemented HTTP JSON routes: 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 ## 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 ### States
- `draft`: instance record exists but the first bootstrap job has not completed. - `draft`: instance record exists and is awaiting Run-owned lifecycle bootstrap or reports.
- `installing`: the first plugin-owned bootstrap job is active. - `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. - `ready`: install/bootstrap succeeded without starting a supervised process, and the server can start.
- `running`: server process is running. - `running`: server process is running.
- `stopped`: server process is stopped. - `stopped`: server process is stopped.
@@ -36,9 +36,9 @@ A server instance is created from one installed game management plugin and bound
## Lifecycle Actions ## Lifecycle Actions
- `create`: validate plugin, create instance record, dispatch the plugin-owned bootstrap job. - `create`: validate plugin and create the instance record without requiring a run endpoint, deployment target, or runtime profile.
- `start`: dispatch process start job through the bound run endpoint. - `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`: dispatch process stop job through the bound run endpoint. - `stop`: record/authorize operator intent and route bounded control to the bound Run when applicable.
- `restart`: dispatch stop/start or plugin-defined restart job. - `restart`: dispatch stop/start or plugin-defined restart job.
- `update`: dispatch server update job and record version/result. - `update`: dispatch server update job and record version/result.
- `delete`: stop server when needed, preserve or remove artifacts according to policy, mark deleted. - `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` 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. - `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/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}/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. - `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. - `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. 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.install` + `succeeded` marks the instance `ready`.
- `process.start` + `succeeded` marks the instance `running`. - `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 return domain.RunControlHelloResult{}, err
} }
svc.runSessions[hello.RunEndpointID] = session 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"} featureFlags := []string{"control.hello", "control.heartbeat", "signed-envelope.v1.optional"}
if session.RequireSignedRequests { if session.RequireSignedRequests {
featureFlags[2] = "signed-envelope.v1.required" featureFlags[2] = "signed-envelope.v1.required"
@@ -132,96 +126,6 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
}), nil }), 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 { func (svc *CoreService) validateDedicatedRunHello(hello domain.RunControlHello) error {
if hello.ComponentKind != domain.DistributionComponentRun { if hello.ComponentKind != domain.DistributionComponentRun {
return validationError("component-authenticated run hello must use the run component") 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) svc, _ := newLifecycleRunService(t)
plugin := createLifecyclePlugin(t, svc) plugin := createLifecyclePlugin(t, svc)
endpoint, err := svc.store.RunEndpoints().Get("run-local") endpoint, err := svc.store.RunEndpoints().Get("run-local")
@@ -290,17 +290,17 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
} }
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID) registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
stored, err := svc.GetServerInstance(guided.Instance.ID) stored, err := svc.GetServerInstance(guided.Instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateInstalling { if err != nil || stored.State != domain.ServerInstanceStateDraft {
t.Fatalf("guided registration should queue bootstrap start, server=%+v err=%v", stored, err) 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}) 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 { if err != nil || len(jobs) != 0 {
t.Fatalf("expected one automatic supervised start job, jobs=%+v err=%v", jobs, err) t.Fatalf("registration must not enqueue automatic lifecycle jobs, jobs=%+v err=%v", jobs, err)
} }
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID) registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID}) jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID})
if len(jobs) != 1 { if len(jobs) != 0 {
t.Fatalf("Run reconnect must not duplicate automatic bootstrap, jobs=%+v", jobs) t.Fatalf("Run reconnect must not enqueue bootstrap jobs, jobs=%+v", jobs)
} }
generatedRunDraft := domain.ServerInstance{ generatedRunDraft := domain.ServerInstance{
@@ -318,12 +318,12 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
} }
registerDedicatedRunForTest(t, svc, generatedRunDraft, plugin.ID) registerDedicatedRunForTest(t, svc, generatedRunDraft, plugin.ID)
storedGenerated, err := svc.GetServerInstance(generatedRunDraft.ID) storedGenerated, err := svc.GetServerInstance(generatedRunDraft.ID)
if err != nil || storedGenerated.State != domain.ServerInstanceStateInstalling { if err != nil || storedGenerated.State != domain.ServerInstanceStateDraft {
t.Fatalf("generated Run registration should queue supervised start without deployment target, server=%+v err=%v", storedGenerated, err) 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}) 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" { if err != nil || len(jobs) != 0 {
t.Fatalf("expected one scoped automatic generated Run start job, jobs=%+v err=%v", jobs, err) 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"}}) 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() svc := newTestCoreService()
plugin := scumDeploymentTestPlugin() plugin := scumDeploymentTestPlugin()
plugin.Name = "SCUM" plugin.Name = "SCUM"
@@ -423,52 +423,34 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedStart(t *testing.T)
} }
stored, err := svc.GetServerInstance(instance.ID) stored, err := svc.GetServerInstance(instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateInstalling { if err != nil || stored.State != domain.ServerInstanceStateDraft {
t.Fatalf("generated SCUM registration should queue supervised bootstrap start, server=%+v err=%v", stored, err) 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}) jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 1 { if err != nil || len(jobs) != 0 {
t.Fatalf("expected one SCUM start job, jobs=%+v err=%v", jobs, err) t.Fatalf("generated SCUM registration must not enqueue start/status jobs, 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)
} }
} }
func TestCoreServiceGeneratedRunRegistrationReconcilesStaleRunningState(t *testing.T) { func TestCoreServiceGeneratedRunRegistrationDoesNotDispatchStatusReconciliation(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
plugin := createGeneratedRunStatusPlugin(t, svc) 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}} 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 { if err := svc.store.ServerInstances().Create(instance); err != nil {
t.Fatalf("create stale running server: %v", err) 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}) 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" { if err != nil || len(jobs) != 0 {
t.Fatalf("expected one status reconciliation job, jobs=%+v err=%v", jobs, err) t.Fatalf("registration must not enqueue status reconciliation, 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)
} }
stored, err := svc.GetServerInstance(instance.ID) stored, err := svc.GetServerInstance(instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateStopped { if err != nil || stored.State != domain.ServerInstanceStateRunning {
t.Fatalf("Run-reported not-started should correct stale running state, server=%+v err=%v", stored, err) 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() svc := newTestCoreService()
plugin := createGeneratedRunStatusPlugin(t, svc) 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}} 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) registerGeneratedRunForStatusTest(t, svc, instance, plugin.ID)
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart { 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 { if err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
} }
profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(distribution) packageInput, err := svc.runDistributionPackageContext(distribution)
if err != nil { if err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
} }
return domain.DistributionBuildInput{ return domain.DistributionBuildInput{
JobID: job.ID, JobID: job.ID,
ComponentKind: domain.DistributionComponentRun, ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: distribution.ServerInstanceID, ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID, PluginID: distribution.PluginID,
RunEndpointID: distribution.RunEndpointID, RunEndpointID: distribution.RunEndpointID,
ProfileKey: profileKey, ProfileKey: packageInput.profileKey,
TargetOS: distribution.TargetOS, TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch, TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID, TargetRelease: distribution.ID,
PlatformURL: runReleasePlatformURL(), PlatformURL: runReleasePlatformURL(),
PackageFormat: distribution.PackageFormat, PackageFormat: distribution.PackageFormat,
ArtifactID: distribution.ArtifactID, ArtifactID: distribution.ArtifactID,
OutputFilename: executableFilename("run", distribution.TargetOS), OutputFilename: executableFilename("run", distribution.TargetOS),
SecretRef: distribution.SecretRef, SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration, KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey, AuthKey: plainKey,
WorkspaceSeed: workspaceSeed, WorkspaceSeed: packageInput.workspaceSeed,
AutonomousLifecycle: packageInput.autonomousLifecycle,
}, nil }, nil
} }
@@ -191,26 +192,160 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
return domain.DistributionBuildInput{}, repo.ErrNotFound 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) instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID)
if err != nil { if err != nil {
return "", "", err return runDistributionPackageContext{}, err
} }
plugin, err := svc.store.GamePlugins().Get(distribution.PluginID) plugin, err := svc.store.GamePlugins().Get(distribution.PluginID)
if err != nil { if err != nil {
return "", "", err return runDistributionPackageContext{}, err
}
seed, err := encodePluginWorkspaceSeed(plugin.LifecycleAssets)
if err != nil {
return "", "", err
} }
profileKey := instance.Deployment.ProfileKey profileKey := instance.Deployment.ProfileKey
bindings := map[string]string(nil)
if binding, bindingErr := svc.runtimeBindingForServer(distribution.ServerInstanceID); bindingErr == nil { if binding, bindingErr := svc.runtimeBindingForServer(distribution.ServerInstanceID); bindingErr == nil {
profileKey = binding.ProfileKey profileKey = binding.ProfileKey
bindings = domain.CopyStringMap(binding.Bindings)
} else if !errors.Is(bindingErr, repo.ErrNotFound) { } 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) { 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: "actions/install.json", Content: `{"version":1,"action":"install","mode":"oneshot"}`, Mode: 0o600},
{Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700}, {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 { if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("seed plugin lifecycle assets: %v", err) t.Fatalf("seed plugin lifecycle assets: %v", err)
} }
@@ -52,7 +53,7 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
TargetOS: "windows", TargetOS: "linux",
TargetArch: "amd64", TargetArch: "amd64",
IdempotencyKey: "platform-secret-boundary", IdempotencyKey: "platform-secret-boundary",
}) })
@@ -76,9 +77,26 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
if err := json.Unmarshal(decodedSeed, &seedFiles); err != nil { if err := json.Unmarshal(decodedSeed, &seedFiles); err != nil {
t.Fatalf("unmarshal workspace seed: %v", err) 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) 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{ auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
ComponentKind: domain.DistributionComponentRun, ComponentKind: domain.DistributionComponentRun,
+19 -18
View File
@@ -51,28 +51,29 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
if err != nil { if err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
} }
profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(distribution) packageInput, err := svc.runDistributionPackageContext(distribution)
if err != nil { if err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
} }
return domain.DistributionBuildInput{ return domain.DistributionBuildInput{
JobID: job.ID, JobID: job.ID,
ComponentKind: domain.DistributionComponentRun, ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: distribution.ServerInstanceID, ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID, PluginID: distribution.PluginID,
RunEndpointID: distribution.RunEndpointID, RunEndpointID: distribution.RunEndpointID,
ProfileKey: profileKey, ProfileKey: packageInput.profileKey,
TargetOS: distribution.TargetOS, TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch, TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID, TargetRelease: distribution.ID,
PlatformURL: runReleasePlatformURL(), PlatformURL: runReleasePlatformURL(),
PackageFormat: distribution.PackageFormat, PackageFormat: distribution.PackageFormat,
ArtifactID: distribution.ArtifactID, ArtifactID: distribution.ArtifactID,
OutputFilename: executableFilename("run", distribution.TargetOS), OutputFilename: executableFilename("run", distribution.TargetOS),
SecretRef: distribution.SecretRef, SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration, KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey, AuthKey: plainKey,
WorkspaceSeed: workspaceSeed, WorkspaceSeed: packageInput.workspaceSeed,
AutonomousLifecycle: packageInput.autonomousLifecycle,
}, nil }, nil
} }
+12
View File
@@ -8,6 +8,7 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os" "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 { if err := os.WriteFile(filepath.Join(inputDir, "workspace-seed.json"), seedPayload, 0o600); err != nil {
return nil, err 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 { if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil {
return nil, err return nil, err
} }
@@ -136,6 +136,13 @@ func TestDockerDistributionBuilderKeepsSecretInIsolatedInput(t *testing.T) {
if bytes.Contains(script, []byte(secret)) { if bytes.Contains(script, []byte(secret)) {
t.Fatal("build script must not embed the component auth key") 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 { if err := os.WriteFile(filepath.Join(outputDir, "run.exe"), []byte("compiled-run"), 0o700); err != nil {
t.Fatalf("write fake build output: %v", err) t.Fatalf("write fake build output: %v", err)
} }