From 6f22cab8e48c46ca42c937d7039d7a810a65c421 Mon Sep 17 00:00:00 2001 From: Zibi Braniecki Date: Wed, 21 Oct 2026 08:35:59 -0700 Subject: [PATCH] node-manager: enforce and report explicit execution policy Use SPACE_ALLOWED_EXECUTION_ENVIRONMENTS and SPACE_DEFAULT_EXECUTION_ENVIRONMENT instead of deployment-name approval or version overrides. Startup validates names and eligibility, and prepare/launch recheck the effective policy. An unavailable candidate catalog preserves explicit and implicit initial-policy configurations equally, without silently dropping requested candidates. Cold-boot and restore selection acknowledge their intent for mixed-version callers. Ordinary resolution supplies the configured default; no separate template-build request is needed. Publish effective policy with its independent receipt time so an older cluster writer cannot refresh stale policy evidence. Protobuf and stored JSON share one validated observation type, including schema, duplicate-reference and default-membership checks. Deploy the schema and cluster readers before publishers. Keep policy uniform within role/platform cohorts and shared fleet defaults at E0 until placement consumes environment evidence. Audit historical workloads before removing legacy permission. Publication refreshes executable inventory only for E0 or allowed environments; artifact removal protection still covers every compiled binding. Validation: offline cargo check passed for runtime-core, node-manager, build-manager, common-core and execution-common, including their test targets. Protobuf descriptor generation and formatting passed. Added focused configuration, identity and stored-JSON regressions; test execution and KVM validation remain with CI. --- crates/apps/node/mgr/cli/src/main.rs | 1 + .../libs/api/src/routes/admin/node_store.rs | 5 + crates/libs/api/src/routes/admin/nodes.rs | 2 + .../libs/api/src/routes/admin/nodes/tests.rs | 2 + .../libs/build/mgr/src/execution_binding.rs | 1 + .../build/mgr/src/execution_binding/tests.rs | 2 + crates/libs/common/core/src/types/admin.rs | 6 + .../libs/common/core/src/types/environment.rs | 5 + .../core/src/types/environment/policy.rs | 102 ++++++++ .../src/types/environment/policy/tests.rs | 48 ++++ .../common/src/environment/binding.rs | 24 ++ crates/libs/execution/common/src/lib.rs | 1 + crates/libs/execution/common/src/policy.rs | 52 ++++ .../libs/execution/common/src/policy/tests.rs | 62 +++++ .../api/server/src/execution_observation.rs | 20 +- .../server/src/execution_observation/tests.rs | 83 ++++++ .../api/server/src/published_state_tests.rs | 1 + crates/libs/internal/api/server/src/status.rs | 25 +- crates/libs/metrics/src/node_manager.rs | 7 + .../src/facets/execution_inventory/tests.rs | 8 + crates/libs/node/mgr/client/Cargo.toml | 1 + .../mgr/client/src/environment_selection.rs | 85 +++++++ .../client/src/environment_selection/tests.rs | 84 +++++++ crates/libs/node/mgr/client/src/lib.rs | 1 + crates/libs/node/mgr/server/src/config.rs | 93 +++++-- .../libs/node/mgr/server/src/config/tests.rs | 97 ++++--- crates/libs/node/mgr/server/src/run.rs | 4 +- crates/libs/node/mgr/server/src/service.rs | 18 +- .../mgr/server/src/service/node_artifacts.rs | 152 +++++------ .../src/service/node_artifacts/selection.rs | 54 ++++ .../src/service/node_artifacts/tests.rs | 193 +++++++------- .../src/service/snapshots/environment.rs | 4 +- .../service/snapshots/environment/tests.rs | 5 +- .../libs/node/mgr/server/src/service/tests.rs | 10 +- .../runtime/core/src/runtime_inventory.rs | 1 + .../sbx/mgr/server/src/service/grpc_core.rs | 1 + .../grpc_core/create_after_lookup_tests.rs | 2 + docs/execution-environments.md | 98 +++++++- proto/execution/v1/execution.proto | 12 + proto/node/v1/node.proto | 21 +- shim/proto/execution/v1/execution.pb.go | 238 ++++++++++++------ 41 files changed, 1287 insertions(+), 344 deletions(-) create mode 100644 crates/libs/common/core/src/types/environment/policy.rs create mode 100644 crates/libs/common/core/src/types/environment/policy/tests.rs create mode 100644 crates/libs/execution/common/src/policy.rs create mode 100644 crates/libs/execution/common/src/policy/tests.rs create mode 100644 crates/libs/node/mgr/client/src/environment_selection.rs create mode 100644 crates/libs/node/mgr/client/src/environment_selection/tests.rs create mode 100644 crates/libs/node/mgr/server/src/service/node_artifacts/selection.rs diff --git a/crates/apps/node/mgr/cli/src/main.rs b/crates/apps/node/mgr/cli/src/main.rs index 95a9a668e..eae2e895d 100644 --- a/crates/apps/node/mgr/cli/src/main.rs +++ b/crates/apps/node/mgr/cli/src/main.rs @@ -314,6 +314,7 @@ async fn run(cli: Cli) -> Result<(), String> { let response = client .resolve_execution_environment( pplx_space_node_mgr_client::proto::ResolveExecutionEnvironmentRequest { + compatible: None, reference: reference.map(|value| value.to_string()), }, ) diff --git a/crates/libs/api/src/routes/admin/node_store.rs b/crates/libs/api/src/routes/admin/node_store.rs index 98b47025d..0634c145a 100644 --- a/crates/libs/api/src/routes/admin/node_store.rs +++ b/crates/libs/api/src/routes/admin/node_store.rs @@ -70,6 +70,9 @@ pub(super) struct AdminNodeRow { pub(super) metadata: Option>, pub(super) last_heartbeat: Option, pub(super) status_observed_at: Option, + pub(super) execution_policy: + Option>, + pub(super) execution_policy_received_at: Option, pub(super) execution_inventory_status: Option, pub(super) execution_inventory_received_at: Option, pub(super) status_health_healthy: Option, @@ -285,6 +288,8 @@ const ADMIN_NODE_SELECT: &str = "SELECT n.id, nd.metadata, n.last_heartbeat, nss.observed_at AS status_observed_at, + nss.execution_policy, + nss.execution_policy_received_at, nss.execution_inventory_status, nss.execution_inventory_received_at, nss.health_healthy AS status_health_healthy, diff --git a/crates/libs/api/src/routes/admin/nodes.rs b/crates/libs/api/src/routes/admin/nodes.rs index 7b747a656..d1086602a 100644 --- a/crates/libs/api/src/routes/admin/nodes.rs +++ b/crates/libs/api/src/routes/admin/nodes.rs @@ -429,6 +429,8 @@ fn admin_node_status(row: &AdminNodeRow) -> Option { .as_ref() .zip(row.execution_inventory_received_at) .map(|(status, received_at)| AdminExecutionInventoryObservation { + policy: row.execution_policy.as_ref().map(|value| value.0.clone()), + policy_received_at: row.execution_policy_received_at.map(format_time), status: status.clone(), received_at: format_time(received_at), }), diff --git a/crates/libs/api/src/routes/admin/nodes/tests.rs b/crates/libs/api/src/routes/admin/nodes/tests.rs index 753428571..083c722e5 100644 --- a/crates/libs/api/src/routes/admin/nodes/tests.rs +++ b/crates/libs/api/src/routes/admin/nodes/tests.rs @@ -43,6 +43,8 @@ fn test_admin_node_row() -> AdminNodeRow { metadata: None, last_heartbeat: None, status_observed_at: None, + execution_policy: None, + execution_policy_received_at: None, execution_inventory_status: None, execution_inventory_received_at: None, status_health_healthy: None, diff --git a/crates/libs/build/mgr/src/execution_binding.rs b/crates/libs/build/mgr/src/execution_binding.rs index d7b484ffc..e698240d3 100644 --- a/crates/libs/build/mgr/src/execution_binding.rs +++ b/crates/libs/build/mgr/src/execution_binding.rs @@ -112,6 +112,7 @@ impl BuildExecutionBinding { let mut node = pplx_space_node_mgr_client::connect().await?; Ok::<_, BuildExecutionBindingError>( node.resolve_execution_environment(ResolveExecutionEnvironmentRequest { + compatible: None, reference: reference.map(|value| value.to_string()), }) .await? diff --git a/crates/libs/build/mgr/src/execution_binding/tests.rs b/crates/libs/build/mgr/src/execution_binding/tests.rs index 72dbe281c..81eba3b3b 100644 --- a/crates/libs/build/mgr/src/execution_binding/tests.rs +++ b/crates/libs/build/mgr/src/execution_binding/tests.rs @@ -29,6 +29,7 @@ fn fixture() -> ResolveExecutionEnvironmentResponse { ) .unwrap(); ResolveExecutionEnvironmentResponse { + accepted_selection: 0, definition: Some((&definition).into()), execution: Some((&execution).into()), } @@ -257,6 +258,7 @@ async fn derived_vm_requests_keep_unbound_releases_on_the_legacy_protocol() { let other = BuildExecutionBinding::from_response( None, ResolveExecutionEnvironmentResponse { + accepted_selection: 0, definition: Some((&definition).into()), execution: Some((&requirements).into()), }, diff --git a/crates/libs/common/core/src/types/admin.rs b/crates/libs/common/core/src/types/admin.rs index 5deb149ba..9cb126b48 100644 --- a/crates/libs/common/core/src/types/admin.rs +++ b/crates/libs/common/core/src/types/admin.rs @@ -353,6 +353,12 @@ pub struct AdminNodeStatusSnapshot { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdminExecutionInventoryObservation { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, + /// Independent receipt: older cluster writers may refresh inventory without + /// understanding policy. Their publication must not renew policy evidence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_received_at: Option, /// Diagnostic label: complete, unavailable, or legacy. Preserve future /// labels for display during mixed-version rollout; never infer eligibility. pub status: String, diff --git a/crates/libs/common/core/src/types/environment.rs b/crates/libs/common/core/src/types/environment.rs index b7dfa7507..b9fa0de54 100644 --- a/crates/libs/common/core/src/types/environment.rs +++ b/crates/libs/common/core/src/types/environment.rs @@ -213,3 +213,8 @@ impl From for String { value.to_string() } } + +mod policy; +pub use policy::{ + EXECUTION_POLICY_SCHEMA_VERSION, ExecutionPolicyObservation, ExecutionPolicyObservationError, +}; diff --git a/crates/libs/common/core/src/types/environment/policy.rs b/crates/libs/common/core/src/types/environment/policy.rs new file mode 100644 index 000000000..5b1b63aa9 --- /dev/null +++ b/crates/libs/common/core/src/types/environment/policy.rs @@ -0,0 +1,102 @@ +//! Validated policy observations shared by protobuf and stored JSON readers. +use super::EnvironmentReference; + +pub const EXECUTION_POLICY_SCHEMA_VERSION: u32 = 1; + +/// Observed eligibility is not proof that the artifacts are installed or usable. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "PolicyObservationDocument"))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExecutionPolicyObservation { + schema_version: u32, + allowed_environment_references: Vec, + default_environment_reference: Option, + legacy_unbound_allowed: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum ExecutionPolicyObservationError { + #[error("unsupported execution policy schema {0}")] + Schema(u32), + #[error("execution policy repeats environment {0}")] + Duplicate(EnvironmentReference), + #[error("execution policy default {0} is outside its allowlist")] + DefaultNotAllowed(EnvironmentReference), + #[error("execution policy with allowed environments requires a default")] + MissingDefault, +} + +impl ExecutionPolicyObservation { + pub fn new( + schema_version: u32, + allowed: Vec, + default: Option, + legacy_unbound_allowed: bool, + ) -> Result { + if schema_version != EXECUTION_POLICY_SCHEMA_VERSION { + return Err(ExecutionPolicyObservationError::Schema(schema_version)); + } + let mut unique = std::collections::BTreeSet::new(); + for reference in allowed { + if !unique.insert(reference) { + return Err(ExecutionPolicyObservationError::Duplicate(reference)); + } + } + match default { + Some(reference) if !unique.contains(&reference) => { + return Err(ExecutionPolicyObservationError::DefaultNotAllowed( + reference, + )); + } + None if !unique.is_empty() => { + return Err(ExecutionPolicyObservationError::MissingDefault); + } + _ => {} + } + Ok(Self { + schema_version, + allowed_environment_references: unique.into_iter().collect(), + default_environment_reference: default, + legacy_unbound_allowed, + }) + } + + pub fn schema_version(&self) -> u32 { + self.schema_version + } + pub fn allowed_environment_references(&self) -> &[EnvironmentReference] { + &self.allowed_environment_references + } + pub fn default_environment_reference(&self) -> Option { + self.default_environment_reference + } + pub fn legacy_unbound_allowed(&self) -> bool { + self.legacy_unbound_allowed + } +} + +#[cfg(feature = "serde")] +#[derive(serde::Deserialize)] +struct PolicyObservationDocument { + schema_version: u32, + allowed_environment_references: Vec, + default_environment_reference: Option, + #[serde(default)] + legacy_unbound_allowed: bool, +} + +#[cfg(feature = "serde")] +impl TryFrom for ExecutionPolicyObservation { + type Error = ExecutionPolicyObservationError; + fn try_from(value: PolicyObservationDocument) -> Result { + Self::new( + value.schema_version, + value.allowed_environment_references, + value.default_environment_reference, + value.legacy_unbound_allowed, + ) + } +} + +#[cfg(all(test, feature = "serde"))] +mod tests; diff --git a/crates/libs/common/core/src/types/environment/policy/tests.rs b/crates/libs/common/core/src/types/environment/policy/tests.rs new file mode 100644 index 000000000..b7b3b0b4a --- /dev/null +++ b/crates/libs/common/core/src/types/environment/policy/tests.rs @@ -0,0 +1,48 @@ +use super::*; + +#[test] +fn stored_policy_requires_valid_schema_references_and_default() { + let reference = format!("sha256:{}", "11".repeat(32)); + let other = format!("sha256:{}", "22".repeat(32)); + for (schema, allowed, default, valid) in [ + (1, vec![reference.clone()], Some(reference.clone()), true), + (1, vec![], None, true), + (2, vec![reference.clone()], Some(reference.clone()), false), + ( + 1, + vec![reference.clone(), reference.clone()], + Some(reference.clone()), + false, + ), + (1, vec![reference.clone()], Some(other), false), + (1, vec![], Some(reference.clone()), false), + (1, vec![reference], None, false), + ] { + let document = serde_json::json!({ + "schema_version": schema, + "allowed_environment_references": allowed, + "default_environment_reference": default, + }); + let result = serde_json::from_value::(document.clone()); + if valid { + let observation = result.unwrap(); + assert!(!observation.legacy_unbound_allowed()); + let serialized = serde_json::to_value(&observation).unwrap(); + assert_eq!(serialized["schema_version"], document["schema_version"]); + assert_eq!( + serialized["allowed_environment_references"], + document["allowed_environment_references"] + ); + assert_eq!( + serialized["default_environment_reference"], + document["default_environment_reference"] + ); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + observation + ); + } else { + result.unwrap_err(); + } + } +} diff --git a/crates/libs/execution/common/src/environment/binding.rs b/crates/libs/execution/common/src/environment/binding.rs index a3c8920ce..4fb4a7cab 100644 --- a/crates/libs/execution/common/src/environment/binding.rs +++ b/crates/libs/execution/common/src/environment/binding.rs @@ -53,6 +53,22 @@ impl EnvironmentDefinition { .with_environment(self.reference())) } + /// Check a cold-boot consumer against the source image contract. Runtime + /// version, state format and environment reference describe the producer; + /// only explicit compatible selection may replace those with a consumer. + /// This check is not installation evidence or execution authorization. + pub fn check_cold_boot_source( + &self, + source: &ExecutionMetadata, + ) -> Result<(), EnvironmentBindingError> { + let spec = self.descriptor().spec(); + let RuntimeBinding::ManagedRelease(release) = &spec.runtime; + if source.runtime().kind != release.runtime.kind || source.guest() != spec.guest { + return Err(EnvironmentBindingError::RuntimeOrGuest); + } + self.check_boot_components(source) + } + /// Bind observations only after checking their complete supported shape. /// Kernel bytes take precedence over historical directory-name aliases, /// matching the existing runtime component resolver's identity semantics. @@ -87,6 +103,14 @@ impl EnvironmentDefinition { if execution.state_format() != Some(state_format) { return Err(EnvironmentBindingError::StateFormat); } + self.check_boot_components(execution) + } + + fn check_boot_components( + &self, + execution: &ExecutionMetadata, + ) -> Result<(), EnvironmentBindingError> { + let spec = self.descriptor().spec(); let BootProfile::LinuxDirectV1 { kernel } = &spec.boot; let [observed] = execution.components() else { return Err(EnvironmentBindingError::BootComponents); diff --git a/crates/libs/execution/common/src/lib.rs b/crates/libs/execution/common/src/lib.rs index 66338186d..1aa92f518 100644 --- a/crates/libs/execution/common/src/lib.rs +++ b/crates/libs/execution/common/src/lib.rs @@ -8,6 +8,7 @@ use pplx_space_common_core::types::{ }; pub mod environment; +pub mod policy; pub mod proto { tonic::include_proto!("space.execution.v1"); diff --git a/crates/libs/execution/common/src/policy.rs b/crates/libs/execution/common/src/policy.rs new file mode 100644 index 000000000..18d263e5e --- /dev/null +++ b/crates/libs/execution/common/src/policy.rs @@ -0,0 +1,52 @@ +//! Validate portable policy observations without assigning approval authority. +use pplx_space_common_core::types::{DigestParseError, ExecutionPolicyObservation}; + +#[derive(Debug, thiserror::Error)] +pub enum PolicyObservationError { + #[error("invalid execution policy reference: {0}")] + Reference(#[from] DigestParseError), + #[error(transparent)] + Policy(#[from] pplx_space_common_core::types::ExecutionPolicyObservationError), +} + +impl TryFrom<&crate::proto::ExecutionPolicyObservation> for ExecutionPolicyObservation { + type Error = PolicyObservationError; + fn try_from(value: &crate::proto::ExecutionPolicyObservation) -> Result { + let allowed = value + .allowed_environment_references + .iter() + .map(|reference| reference.parse()) + .collect::, _>>()?; + let default = value + .default_environment_reference + .as_deref() + .map(str::parse) + .transpose()?; + Ok(Self::new( + value.schema_version, + allowed, + default, + value.legacy_unbound_allowed, + )?) + } +} + +impl From<&ExecutionPolicyObservation> for crate::proto::ExecutionPolicyObservation { + fn from(value: &ExecutionPolicyObservation) -> Self { + Self { + schema_version: value.schema_version(), + legacy_unbound_allowed: value.legacy_unbound_allowed(), + allowed_environment_references: value + .allowed_environment_references() + .iter() + .map(ToString::to_string) + .collect(), + default_environment_reference: value + .default_environment_reference() + .map(|reference| reference.to_string()), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/libs/execution/common/src/policy/tests.rs b/crates/libs/execution/common/src/policy/tests.rs new file mode 100644 index 000000000..9cfe846ca --- /dev/null +++ b/crates/libs/execution/common/src/policy/tests.rs @@ -0,0 +1,62 @@ +use super::*; +use prost::Message; + +#[test] +fn policy_observation_validates_shape_and_old_inventory_readers_omit_it() { + let reference = format!("sha256:{}", "11".repeat(32)); + let wire = crate::proto::ExecutionPolicyObservation { + schema_version: 1, + legacy_unbound_allowed: true, + allowed_environment_references: vec![reference.clone()], + default_environment_reference: Some(reference.clone()), + }; + let legacy_only = crate::proto::ExecutionPolicyObservation { + schema_version: 1, + legacy_unbound_allowed: true, + allowed_environment_references: vec![], + default_environment_reference: None, + }; + let legacy_policy = ExecutionPolicyObservation::try_from(&legacy_only).unwrap(); + assert!(legacy_policy.legacy_unbound_allowed()); + assert_eq!( + crate::proto::ExecutionPolicyObservation::from(&legacy_policy), + legacy_only + ); + let value = ExecutionPolicyObservation::try_from(&wire).unwrap(); + assert_eq!(crate::proto::ExecutionPolicyObservation::from(&value), wire); + for bad in [ + crate::proto::ExecutionPolicyObservation { + schema_version: 2, + ..wire.clone() + }, + crate::proto::ExecutionPolicyObservation { + allowed_environment_references: vec![reference.clone(), reference], + ..wire.clone() + }, + crate::proto::ExecutionPolicyObservation { + default_environment_reference: None, + ..wire.clone() + }, + crate::proto::ExecutionPolicyObservation { + allowed_environment_references: vec![], + ..wire.clone() + }, + ] { + ExecutionPolicyObservation::try_from(&bad).unwrap_err(); + } + #[derive(Clone, PartialEq, Message)] + struct OldInventory { + #[prost(message, repeated, tag = "1")] + runtimes: Vec, + #[prost(message, repeated, tag = "2")] + components: Vec, + } + let inventory = crate::proto::NodeExecutionInventory { + policy: Some(wire), + ..Default::default() + }; + let old = OldInventory::decode(inventory.encode_to_vec().as_slice()).unwrap(); + let forwarded = + crate::proto::NodeExecutionInventory::decode(old.encode_to_vec().as_slice()).unwrap(); + assert!(forwarded.policy.is_none()); +} diff --git a/crates/libs/internal/api/server/src/execution_observation.rs b/crates/libs/internal/api/server/src/execution_observation.rs index de23026da..a48b80165 100644 --- a/crates/libs/internal/api/server/src/execution_observation.rs +++ b/crates/libs/internal/api/server/src/execution_observation.rs @@ -104,10 +104,15 @@ pub(super) async fn prepare<'a>( Ok(Some(PreparedObservation { node_id, report })) } +pub(super) struct PublishedExecutionObservation { + pub status: InventoryStatus, + pub policy: Option, +} + pub(super) async fn publish( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, prepared: Option>, -) -> Result, Status> { +) -> Result, Status> { let Some(PreparedObservation { node_id, report }) = prepared else { return Ok(None); }; @@ -119,7 +124,18 @@ pub(super) async fn publish( if let Some(inventory) = inventory { publish_inventory(tx, node_id, inventory).await?; } - Ok(Some(status)) + let policy = inventory + .and_then(|inventory| inventory.policy.as_ref()) + .and_then(|policy| { + match pplx_space_common_core::types::ExecutionPolicyObservation::try_from(policy) { + Ok(policy) => Some(policy), + Err(error) => { + tracing::warn!(%error, %node_id, "invalid execution policy observation; withholding policy evidence"); + None + } + } + }); + Ok(Some(PublishedExecutionObservation { status, policy })) } /// Reconcile a complete inventory without rewriting unchanged artifact rows. diff --git a/crates/libs/internal/api/server/src/execution_observation/tests.rs b/crates/libs/internal/api/server/src/execution_observation/tests.rs index eab4b1059..03d7d932b 100644 --- a/crates/libs/internal/api/server/src/execution_observation/tests.rs +++ b/crates/libs/internal/api/server/src/execution_observation/tests.rs @@ -15,6 +15,7 @@ struct LegacyPublication { fn installed() -> NodeExecutionInventory { NodeExecutionInventory { + policy: None, runtimes: vec![RuntimeInstallation { runtime: Some(RuntimeIdentity { kind: RuntimeKind::Firecracker.into(), @@ -182,6 +183,7 @@ async fn observation_and_projection_follow_publication_outcomes_atomically() -> tx.rollback().await?; assert_eq!(observed(&db.pool, node_id).await?, before); let malformed = complete(NodeExecutionInventory { + policy: None, runtimes: vec![RuntimeInstallation::default()], components: vec![], }); @@ -434,3 +436,84 @@ async fn changed_samples_revoke_capabilities_and_remove_absent_rows() -> TestRes } Ok(()) } + +#[tokio::test] +async fn policy_receipts_clear_unknown_evidence_and_commit_atomically() -> TestResult { + let Some(db) = try_pg_test_db().await? else { + return Ok(()); + }; + let node = NodeId::new(); + insert_ready_worker_node(&db.pool, node).await?; + let reference = format!("sha256:{}", "11".repeat(32)); + let valid = pplx_space_execution_common::proto::ExecutionPolicyObservation { + schema_version: 1, + legacy_unbound_allowed: true, + allowed_environment_references: vec![reference.clone()], + default_environment_reference: Some(reference), + }; + let invalid = pplx_space_execution_common::proto::ExecutionPolicyObservation { + schema_version: 99, + ..valid.clone() + }; + for (policy, unavailable_report, expected) in [ + (Some(valid.clone()), false, true), + (Some(invalid), false, false), + (Some(valid.clone()), false, true), + (None, false, false), + (Some(valid), false, true), + (None, true, false), + ] { + let report = if unavailable_report { + unavailable() + } else { + complete(NodeExecutionInventory { + policy, + ..Default::default() + }) + }; + let mut tx = db.pool.begin().await?; + publish_with_status(&mut tx, node, Some(&report), None).await?; + tx.commit().await?; + let read = || { + sqlx::query_scalar::< + _, + Option< + sqlx::types::Json, + >, + >(sql!( + "SELECT execution_policy FROM node_status_snapshots WHERE node_id = $1" + )) + .bind(node) + }; + let before = read().fetch_one(&db.pool).await?; + assert_eq!(before.is_some(), expected); + let receipt = || { + sqlx::query_scalar::<_, Option>(sql!( + "SELECT execution_policy_received_at FROM node_status_snapshots WHERE node_id = $1" + )) + .bind(node) + }; + let received_at = receipt().fetch_one(&db.pool).await?; + assert_eq!(received_at.is_some(), expected); + let mut tx = db.pool.begin().await?; + publish_with_status(&mut tx, node, None, None).await?; + tx.commit().await?; + assert_eq!(read().fetch_one(&db.pool).await?, before); + let mut tx = db.pool.begin().await?; + publish_with_status(&mut tx, node, Some(&unavailable()), None).await?; + tx.rollback().await?; + assert_eq!(read().fetch_one(&db.pool).await?, before); + assert_eq!(receipt().fetch_one(&db.pool).await?, received_at); + // A rolled-back cluster writer understands only the older inventory + // receipt. It must not make retained policy evidence appear fresher. + sqlx::query(sql!( + "UPDATE node_status_snapshots SET execution_inventory_received_at = clock_timestamp() + WHERE node_id = $1" + )) + .bind(node) + .execute(&db.pool) + .await?; + assert_eq!(receipt().fetch_one(&db.pool).await?, received_at); + } + Ok(()) +} diff --git a/crates/libs/internal/api/server/src/published_state_tests.rs b/crates/libs/internal/api/server/src/published_state_tests.rs index baffc481b..173bb8e77 100644 --- a/crates/libs/internal/api/server/src/published_state_tests.rs +++ b/crates/libs/internal/api/server/src/published_state_tests.rs @@ -113,6 +113,7 @@ async fn execution_metadata_and_node_capabilities_are_queryable() -> TestResult insert_active_sandbox(&db.pool, sandbox_id, node_id, "started").await?; let execution = test_execution(); let inventory = NodeExecutionInventory { + policy: None, runtimes: vec![RuntimeInstallation { runtime: Some(execution.runtime().into()), supported_guests: vec![execution.guest().into()], diff --git a/crates/libs/internal/api/server/src/status.rs b/crates/libs/internal/api/server/src/status.rs index a7a9adbee..473b3ac4e 100644 --- a/crates/libs/internal/api/server/src/status.rs +++ b/crates/libs/internal/api/server/src/status.rs @@ -230,7 +230,7 @@ pub(super) async fn upsert_node_status_from_publish( node_status: Option, inventory_prune: SandboxInventoryPrune, local_backup_report: LocalBackupInventoryReport, - execution_observation: Option, + execution_observation: Option, ) -> Result<(), Status> { let ( scheduling_status, @@ -281,6 +281,8 @@ pub(super) async fn upsert_node_status_from_publish( node_ctl, sandbox_inventory_pruned_at, local_backup_inventory_reported_at, + execution_policy, + execution_policy_received_at, execution_inventory_status, execution_inventory_received_at, updated_at @@ -306,6 +308,8 @@ pub(super) async fn upsert_node_status_from_publish( $12::JSONB, CASE WHEN $13::BOOLEAN THEN NOW() ELSE NULL END, CASE WHEN $14::BOOLEAN THEN NOW() ELSE NULL END, + $17::JSONB, + CASE WHEN $17::JSONB IS NOT NULL THEN clock_timestamp() ELSE NULL END, $16::TEXT, CASE WHEN $16::TEXT IS NOT NULL THEN clock_timestamp() ELSE NULL END, NOW() @@ -385,6 +389,14 @@ pub(super) async fn upsert_node_status_from_publish( node_status_snapshots.local_backup_inventory_reported_at ) END, + execution_policy = CASE + WHEN $16::TEXT IS NOT NULL THEN EXCLUDED.execution_policy + ELSE node_status_snapshots.execution_policy + END, + execution_policy_received_at = CASE + WHEN $16::TEXT IS NOT NULL THEN EXCLUDED.execution_policy_received_at + ELSE node_status_snapshots.execution_policy_received_at + END, execution_inventory_status = COALESCE( EXCLUDED.execution_inventory_status, node_status_snapshots.execution_inventory_status @@ -410,7 +422,16 @@ pub(super) async fn upsert_node_status_from_publish( .bind(inventory_prune.stamps_pruned_at()) .bind(local_backup_report.stamps_reported_at()) .bind(local_backup_report.clears_reported_at()) - .bind(execution_observation.map(crate::execution_observation::InventoryStatus::as_str)) + .bind( + execution_observation + .as_ref() + .map(|observation| observation.status.as_str()), + ) + .bind( + execution_observation + .and_then(|observation| observation.policy) + .map(Json), + ) .execute(&mut *conn) .await .map_err(|err| Status::internal(format!("upsert node status from publish: {err}")))?; diff --git a/crates/libs/metrics/src/node_manager.rs b/crates/libs/metrics/src/node_manager.rs index 92658b153..94c7c8ea4 100644 --- a/crates/libs/metrics/src/node_manager.rs +++ b/crates/libs/metrics/src/node_manager.rs @@ -268,3 +268,10 @@ pub fn btrfs_send_stream_rejected(reason: BtrfsSendStreamRejection) { &[tag("reason", reason)], ); } + +/// Successful resolver decisions selecting a consumer other than the recorded +/// source. Counts attempts (including retries), not completed launches; no +/// sandbox or environment identifiers are metric tags. +pub fn execution_consumer_fallback() { + counter("node_mgr.execution.consumer_fallback", 1, &[]); +} diff --git a/crates/libs/node/ctl/server/src/facets/execution_inventory/tests.rs b/crates/libs/node/ctl/server/src/facets/execution_inventory/tests.rs index c07fc1e56..b9675a27e 100644 --- a/crates/libs/node/ctl/server/src/facets/execution_inventory/tests.rs +++ b/crates/libs/node/ctl/server/src/facets/execution_inventory/tests.rs @@ -8,6 +8,14 @@ use std::sync::{ #[tokio::test] async fn collection_outcomes_keep_failure_distinct_from_empty_success() { let installed = NodeExecutionInventory { + policy: Some( + pplx_space_execution_common::proto::ExecutionPolicyObservation { + schema_version: 1, + legacy_unbound_allowed: true, + allowed_environment_references: vec![format!("sha256:{}", "11".repeat(32))], + default_environment_reference: Some(format!("sha256:{}", "11".repeat(32))), + }, + ), runtimes: vec![RuntimeInstallation { runtime: Some(RuntimeIdentity { kind: RuntimeKind::Firecracker.into(), diff --git a/crates/libs/node/mgr/client/Cargo.toml b/crates/libs/node/mgr/client/Cargo.toml index d949ea06c..d088af530 100644 --- a/crates/libs/node/mgr/client/Cargo.toml +++ b/crates/libs/node/mgr/client/Cargo.toml @@ -27,4 +27,5 @@ pplx-space-common-proto-build = { workspace = true } tonic-prost-build = { workspace = true } [dev-dependencies] +pplx-space-execution-common = { workspace = true, features = ["serde"] } pplx-space-common-svc = { workspace = true } diff --git a/crates/libs/node/mgr/client/src/environment_selection.rs b/crates/libs/node/mgr/client/src/environment_selection.rs new file mode 100644 index 000000000..aeebdeb74 --- /dev/null +++ b/crates/libs/node/mgr/client/src/environment_selection.rs @@ -0,0 +1,85 @@ +//! Validate compatible-resolution acknowledgement before starting any work. +use pplx_space_common_core::types::ExecutionMetadata; +use pplx_space_execution_common::{ + ExecutionProtoError, + environment::{EnvironmentBindingError, EnvironmentDecodeError, EnvironmentDefinition}, +}; + +use crate::proto::{ + ExecutionEnvironmentSelection, ResolveExecutionEnvironmentResponse, + resolve_execution_environment_request::Compatible, +}; + +#[derive(Debug)] +pub struct SelectedEnvironment { + pub definition: EnvironmentDefinition, + pub execution: ExecutionMetadata, +} + +#[derive(Debug, thiserror::Error)] +pub enum SelectionResponseError { + #[error("node does not acknowledge compatible environment selection")] + Unsupported, + #[error("node acknowledged a different environment selection intent")] + Intent, + #[error("environment resolution response lacks {0}")] + Missing(&'static str), + #[error("invalid environment definition: {0}")] + Definition(#[from] EnvironmentDecodeError), + #[error("invalid execution metadata: {0}")] + Execution(#[from] ExecutionProtoError), + #[error("environment binding mismatch: {0}")] + Binding(#[from] EnvironmentBindingError), + #[error("selected execution does not satisfy the source requirements")] + Requirements, +} + +/// An old reader may return a perfectly valid default binding while ignoring +/// the new request. It is not a compatible-selection acknowledgement. Callers +/// may explicitly retain their old exact contract on `Unsupported` only. +pub fn decode( + request: &Compatible, + response: ResolveExecutionEnvironmentResponse, +) -> Result { + let expected = match request { + Compatible::ColdBootSource(_) => ExecutionEnvironmentSelection::ColdBoot, + Compatible::RestoreSource(_) => ExecutionEnvironmentSelection::Restore, + }; + if response.accepted_selection == 0 { + return Err(SelectionResponseError::Unsupported); + } + if response.accepted_selection != expected as i32 { + return Err(SelectionResponseError::Intent); + } + let definition = EnvironmentDefinition::try_from( + response + .definition + .as_ref() + .ok_or(SelectionResponseError::Missing("definition"))?, + )?; + let execution = ExecutionMetadata::try_from( + response + .execution + .as_ref() + .ok_or(SelectionResponseError::Missing("execution"))?, + )?; + if execution.environment() != Some(definition.reference()) { + return Err(SelectionResponseError::Requirements); + } + definition.check_execution(&execution)?; + let (Compatible::ColdBootSource(source) | Compatible::RestoreSource(source)) = request; + let source = ExecutionMetadata::try_from(source)?; + definition.check_cold_boot_source(&source)?; + if matches!(request, Compatible::RestoreSource(_)) + && source.state_format() != execution.state_format() + { + return Err(SelectionResponseError::Requirements); + } + Ok(SelectedEnvironment { + definition, + execution, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/libs/node/mgr/client/src/environment_selection/tests.rs b/crates/libs/node/mgr/client/src/environment_selection/tests.rs new file mode 100644 index 000000000..dc5c45417 --- /dev/null +++ b/crates/libs/node/mgr/client/src/environment_selection/tests.rs @@ -0,0 +1,84 @@ +use super::*; +use prost::Message; + +fn fixture() -> ResolveExecutionEnvironmentResponse { + let definition = EnvironmentDefinition::from_document(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../../../proto/execution/v1/fixtures/e0.json" + ))) + .unwrap(); + let execution = definition.execution_requirements().unwrap(); + ResolveExecutionEnvironmentResponse { + definition: Some((&definition).into()), + execution: Some((&execution).into()), + accepted_selection: ExecutionEnvironmentSelection::ColdBoot as i32, + } +} + +#[test] +fn compatible_selection_requires_intent_and_exact_execution_acknowledgement() { + let response = fixture(); + let request = Compatible::ColdBootSource(response.execution.clone().unwrap()); + decode(&request, response.clone()).unwrap(); + for acknowledgement in [0, ExecutionEnvironmentSelection::Restore as i32, 99] { + let mut response = response.clone(); + response.accepted_selection = acknowledgement; + decode(&request, response).unwrap_err(); + } + for mutation in [ + (|response: &mut ResolveExecutionEnvironmentResponse| { + response.execution.as_mut().unwrap().environment = None + }) as fn(&mut ResolveExecutionEnvironmentResponse), + |response| { + response + .execution + .as_mut() + .unwrap() + .runtime + .as_mut() + .unwrap() + .version = "99.0.0".into() + }, + |response| response.execution.as_mut().unwrap().components.clear(), + ] { + let mut response = response.clone(); + mutation(&mut response); + decode(&request, response).unwrap_err(); + } +} + +#[test] +fn old_readers_ignore_selection_and_new_readers_refuse_missing_acknowledgement() { + #[derive(Clone, PartialEq, Message)] + struct OldRequest { + #[prost(string, optional, tag = "1")] + reference: Option, + } + #[derive(Clone, PartialEq, Message)] + struct OldResponse { + #[prost(message, optional, tag = "1")] + definition: Option, + #[prost(message, optional, tag = "2")] + execution: Option, + } + let response = fixture(); + let compatible = Compatible::ColdBootSource(response.execution.clone().unwrap()); + let request = crate::proto::ResolveExecutionEnvironmentRequest { + reference: None, + compatible: Some(compatible.clone()), + }; + assert_eq!( + OldRequest::decode(request.encode_to_vec().as_slice()) + .unwrap() + .reference, + None + ); + let old_response = OldResponse::decode(response.encode_to_vec().as_slice()).unwrap(); + let response = + ResolveExecutionEnvironmentResponse::decode(old_response.encode_to_vec().as_slice()) + .unwrap(); + assert!(matches!( + decode(&compatible, response), + Err(SelectionResponseError::Unsupported) + )); +} diff --git a/crates/libs/node/mgr/client/src/lib.rs b/crates/libs/node/mgr/client/src/lib.rs index 13742339b..1c747f169 100644 --- a/crates/libs/node/mgr/client/src/lib.rs +++ b/crates/libs/node/mgr/client/src/lib.rs @@ -2,6 +2,7 @@ pub mod proto { tonic::include_proto!("space.node.v1"); } +pub mod environment_selection; pub mod execution_fence; /// One sequential inventory walk. An expired/consumed cursor is an error, never EOF. diff --git a/crates/libs/node/mgr/server/src/config.rs b/crates/libs/node/mgr/server/src/config.rs index 11b738edc..7ac285ab1 100644 --- a/crates/libs/node/mgr/server/src/config.rs +++ b/crates/libs/node/mgr/server/src/config.rs @@ -16,7 +16,7 @@ pub(crate) struct ServiceSettings { pub lifecycle_limiter: crate::lifecycle_limiter::LifecycleLimiter, pub runtime_paths: RuntimePaths, pub guest_console_logs: bool, - pub production: bool, + pub execution_policy: pplx_space_node_runtime_core::selection::ExecutionPolicy, pub memory_admission_sources: crate::memory_admission::MemoryAdmissionSources, pub runtime_supervisor: crate::runtime_supervisor_client::RuntimeSupervisorLazyClient, pub node_status_dirty: std::sync::Arc, @@ -54,8 +54,9 @@ impl Default for ArtifactStorePaths { } impl ServiceSettings { - pub fn from_env() -> Self { + pub fn from_env() -> Result { let settings = Self { + execution_policy: execution_policy_from_env()?, disk_admission: CacheDiskAdmissionConfig::from_env(), lifecycle_limiter: crate::lifecycle_limiter::LifecycleLimiter::from_env(), runtime_paths: RuntimePaths { @@ -70,9 +71,6 @@ impl ServiceSettings { ), store: ArtifactStorePaths::default(), }, - production: deployment_is_production( - std::env::var_os(pplx_space_observability::logging::SPACE_ENV).as_deref(), - ), guest_console_logs: std::env::var("SPACE_ENABLE_GUEST_CONSOLE_LOGS").is_ok_and( |value| { matches!( @@ -92,10 +90,12 @@ impl ServiceSettings { tracing::info!( disk_admission = ?settings.disk_admission, guest_console_logs = settings.guest_console_logs, - production = settings.production, + allowed_environments = ?settings.execution_policy.allowed_references().collect::>(), + default_environment = ?settings.execution_policy.default_reference(), + legacy_unbound_allowed = settings.execution_policy.legacy_unbound_allowed(), "resolved startup-only node-manager host settings" ); - settings + Ok(settings) } } @@ -108,24 +108,65 @@ fn path(name: &str, default: &str) -> PathBuf { path } -/// Only explicit non-production deployment identities relax the E0 restriction. -/// Missing, malformed, or unfamiliar deployment identities retain production admission. -fn deployment_is_production(value: Option<&std::ffi::OsStr>) -> bool { - !value - .and_then(std::ffi::OsStr::to_str) - .is_some_and(|value| { - let normalized = value.trim().to_ascii_lowercase(); - normalized.strip_prefix("local-").is_some_and(|suffix| { - !suffix.is_empty() - && suffix.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' - }) - }) || matches!( - normalized.as_str(), - "dev" | "development" | "local" | "kind-local" | "test" | "testing" | "staging" - ) - }) -} - #[cfg(test)] mod tests; + +#[derive(Debug, thiserror::Error)] +pub enum SelectionConfigurationError { + #[error("invalid execution selection setting: {0}")] + Environment(#[from] std::env::VarError), + #[error("invalid execution selection: {0}")] + Policy(#[from] pplx_space_node_runtime_core::selection::PolicyError), + #[error("cannot load execution catalog: {0}")] + Catalog(#[from] pplx_space_node_runtime_core::catalog::CatalogError), + #[error("unsupported host architecture: {0}")] + Architecture(#[from] pplx_space_common_core::types::ArchParseError), +} + +fn execution_policy_from_env() +-> Result { + use pplx_space_node_runtime_core::{catalog::CompiledEnvironment, selection::*}; + let read = |name| match std::env::var(name) { + Ok(value) => Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(error) => Err(error), + }; + let allowed = read(ALLOWED_ENVIRONMENTS)?; + let default = read(DEFAULT_ENVIRONMENT)?; + let architecture = std::env::consts::ARCH.parse()?; + execution_policy_with_catalog( + allowed.as_deref(), + default.as_deref(), + architecture, + CompiledEnvironment::for_architecture(architecture), + ) +} + +fn execution_policy_with_catalog( + allowed: Option<&str>, + default: Option<&str>, + architecture: pplx_space_common_core::types::Arch, + catalog: Result< + Vec, + pplx_space_node_runtime_core::catalog::CatalogError, + >, +) -> Result { + use pplx_space_node_runtime_core::{ + catalog::{CatalogError, InitialEnvironment}, + selection::ExecutionPolicy, + }; + match catalog { + Ok(catalog) => Ok(ExecutionPolicy::parse(allowed, default, &catalog)?), + Err(error) => { + let initial = InitialEnvironment::for_architecture(architecture)?; + let fallback = [initial + .compiled_environment() + .map_err(CatalogError::EnvironmentDefinition)?]; + // Parse against the independently loaded initial binding. This accepts + // explicit spellings of the default but never drops requested candidates. + tracing::warn!(%error, "candidate catalog unavailable; validating policy against initial binding"); + let policy = ExecutionPolicy::parse(allowed, default, &fallback)?; + Ok(policy) + } + } +} diff --git a/crates/libs/node/mgr/server/src/config/tests.rs b/crates/libs/node/mgr/server/src/config/tests.rs index 3b064bb39..56097c0fe 100644 --- a/crates/libs/node/mgr/server/src/config/tests.rs +++ b/crates/libs/node/mgr/server/src/config/tests.rs @@ -1,53 +1,72 @@ use super::*; #[test] -fn deployment_admission_defaults_to_production() { - use std::ffi::OsStr; - assert!(deployment_is_production(None)); - for value in [ - "prod", - "production", - "", - " ", - "unknown", - "dev-typo", - "local-", - "local-../", - "local-💥", - ] { - assert!(deployment_is_production(Some(OsStr::new(value))), "{value}"); +fn deployment_name_does_not_authorize_execution() { + if !pplx_space_test_env::enter_test_process(&[ + ("SPACE_ENV", Some(std::ffi::OsStr::new("staging"))), + ("SPACE_ALLOWED_EXECUTION_ENVIRONMENTS", None), + ("SPACE_DEFAULT_EXECUTION_ENVIRONMENT", None), + ]) { + return; } - for value in [ - "dev", - "development", - "local", - "kind-local", - "test", - "testing", - "staging", - " TESTING ", - "local-devbox", - "local-kind-owner-devbox", - ] { - assert!( - !deployment_is_production(Some(OsStr::new(value))), - "{value}" + let settings = ServiceSettings::from_env().unwrap(); + let catalog = settings.execution_policy.catalog(); + if let Some(initial) = catalog.iter().find(|entry| entry.name() == "e0") { + assert_eq!( + settings + .execution_policy + .allowed_references() + .collect::>(), + vec![initial.definition().reference()] ); - } - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - assert!(deployment_is_production(Some(OsStr::from_bytes(&[0xff])))); + } else { + assert_eq!(settings.execution_policy.default_reference(), None); } } #[test] -fn startup_uses_deployment_identity_for_admission() { +fn malformed_policy_prevents_startup() { if !pplx_space_test_env::enter_test_process(&[ - ("SPACE_ENV", Some(std::ffi::OsStr::new("testing"))), - ("DD_ENV", Some(std::ffi::OsStr::new("prod"))), + ( + "SPACE_ALLOWED_EXECUTION_ENVIRONMENTS", + Some(std::ffi::OsStr::new("unknown")), + ), + ("SPACE_DEFAULT_EXECUTION_ENVIRONMENT", None), ]) { return; } - assert!(!ServiceSettings::from_env().production); + ServiceSettings::from_env().unwrap_err(); +} + +#[test] +fn candidate_catalog_failure_preserves_all_spellings_of_initial_policy() { + use pplx_space_common_core::types::Arch; + use pplx_space_node_runtime_core::catalog::CatalogError; + let resolve = |allowed, default| { + execution_policy_with_catalog( + allowed, + default, + Arch::X86_64, + Err(CatalogError::MissingEnvironmentPins { + runtime: "1.17.0", + kernel: "6.18.44", + }), + ) + }; + let implicit = resolve(None, None).unwrap(); + for allowed in ["e0,legacy", "legacy,e0"] { + for default in [None, Some("e0")] { + let explicit = resolve(Some(allowed), default).unwrap(); + assert_eq!( + explicit.allowed_references().collect::>(), + implicit.allowed_references().collect::>() + ); + assert_eq!(explicit.default_reference(), implicit.default_reference()); + assert!(explicit.legacy_unbound_allowed()); + } + } + assert!(!resolve(Some("e0"), None).unwrap().legacy_unbound_allowed()); + for allowed in ["e0,fc-1.17.0-linux-6.1.155", "e0,legacy,legacy", "e0,"] { + resolve(Some(allowed), None).unwrap_err(); + } } diff --git a/crates/libs/node/mgr/server/src/run.rs b/crates/libs/node/mgr/server/src/run.rs index c28f0d974..c0563d54e 100644 --- a/crates/libs/node/mgr/server/src/run.rs +++ b/crates/libs/node/mgr/server/src/run.rs @@ -117,6 +117,8 @@ pub struct NodeMgrRunConfig { /// variable, or value that was misread. #[derive(Debug, thiserror::Error)] pub enum RunNodeMgrError { + #[error("execution selection configuration: {0}")] + SelectionConfiguration(#[from] crate::config::SelectionConfigurationError), #[error(transparent)] DiskIoCeiling(#[from] crate::service::disk_io_ceiling::InvalidCeiling), #[error("failed to read node config {path}: {source}")] @@ -577,7 +579,7 @@ pub async fn run( let memory_admission_config = resolve_memory_admission_config(&cli, &file.memory_admission)?; let mut disk_io_reservation_config = resolve_disk_io_reservation_config(file.disk_io_reservation)?; - let service_settings = crate::config::ServiceSettings::from_env(); + let service_settings = crate::config::ServiceSettings::from_env()?; let disk_io_ceiling = crate::service::disk_io_ceiling::DiskIoCeiling::from_env()?; tracing::info!( maximum_iops = disk_io_ceiling.configured(), diff --git a/crates/libs/node/mgr/server/src/service.rs b/crates/libs/node/mgr/server/src/service.rs index 8305f8ce8..9b2e9853e 100644 --- a/crates/libs/node/mgr/server/src/service.rs +++ b/crates/libs/node/mgr/server/src/service.rs @@ -216,7 +216,7 @@ pub struct NodeManagerService { runtime_inventory: std::sync::Arc>, runtime_inventory_refresh_lock: std::sync::Arc>, runtime_paths: crate::config::RuntimePaths, - production: bool, + execution_policy: pplx_space_node_runtime_core::selection::ExecutionPolicy, guest_console_logs: bool, lifecycle_limiter: std::sync::Arc, disk_admission: std::sync::Arc, @@ -313,7 +313,15 @@ impl NodeManagerService { ) -> Result, Status> { self.authorize_peer(&request, "GetExecutionInventory", ALLOW_NODE_STATUS)?; let inventory = self.runtime_inventory().await?; - let report = inventory.to_proto(); + let mut report = inventory.to_proto(); + let policy = pplx_space_common_core::types::ExecutionPolicyObservation::new( + pplx_space_common_core::types::EXECUTION_POLICY_SCHEMA_VERSION, + self.execution_policy.allowed_references().collect(), + self.execution_policy.default_reference(), + self.execution_policy.legacy_unbound_allowed(), + ) + .map_err(|error| Status::internal(error.to_string()))?; + report.policy = Some((&policy).into()); Ok(Response::new(report)) } @@ -395,7 +403,7 @@ impl NodeManagerService { runtime_inventory: std::sync::Arc::default(), runtime_inventory_refresh_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())), runtime_paths: settings.runtime_paths, - production: settings.production, + execution_policy: settings.execution_policy, guest_console_logs: settings.guest_console_logs, lifecycle_limiter: std::sync::Arc::new( settings @@ -451,7 +459,7 @@ impl NodeManagerService { .await; let snapshot = self.runtime_inventory.clone(); let paths = self.runtime_paths.clone(); - let production = self.production; + let policy = self.execution_policy.clone(); self.off_runtime("discover runtime inventory", move || { let _guard = guard; if let Some(inventory) = snapshot.load_full() @@ -461,7 +469,7 @@ impl NodeManagerService { } snapshot.store(None); let inventory = - std::sync::Arc::new(node_artifacts::execution_inventory(&paths, production)?); + std::sync::Arc::new(node_artifacts::execution_inventory(&paths, &policy)?); snapshot.store(Some(inventory.clone())); Ok(inventory) }) diff --git a/crates/libs/node/mgr/server/src/service/node_artifacts.rs b/crates/libs/node/mgr/server/src/service/node_artifacts.rs index 7a53502af..e6c564a45 100644 --- a/crates/libs/node/mgr/server/src/service/node_artifacts.rs +++ b/crates/libs/node/mgr/server/src/service/node_artifacts.rs @@ -26,6 +26,8 @@ use crate::proto::{ use super::NodeManagerService; +mod selection; + use pplx_space_node_runtime_core::runtime_inventory::{ ExecutionSelectionMode, ExecutionSourceIntegrity, ResolvedExecutionEnvironment, RuntimeInstallationFiles, @@ -37,13 +39,7 @@ impl NodeManagerService { reference: Option, mode: ExecutionSelectionMode, ) -> Result { - select_definition( - initial_environment()?, - compiled_environments, - self.production, - reference, - mode, - ) + select_definition(&self.execution_policy, reference, mode) } async fn require_verified_environment_layout(&self) -> Result<(), Status> { @@ -69,8 +65,19 @@ impl NodeManagerService { super::ALLOW_NODE_STATUS, )?; self.require_verified_environment_layout().await?; + let request = request.into_inner(); + if let Some(compatible) = request.compatible { + if request.reference.is_some() { + return Err(Status::invalid_argument( + "exact and compatible environment selection are mutually exclusive", + )); + } + return self + .resolve_compatible_environment(compatible) + .await + .map(Response::new); + } let reference = request - .into_inner() .reference .map(|reference| reference.parse()) .transpose() @@ -92,8 +99,12 @@ impl NodeManagerService { let execution = definition .bind_execution(resolved.execution) .map_err(|error| Status::failed_precondition(describe(&error)))?; + self.execution_policy + .check_execution(&execution, ExecutionSelectionMode::NewColdBoot) + .map_err(|error| Status::failed_precondition(error.to_string()))?; Ok(Response::new( crate::proto::ResolveExecutionEnvironmentResponse { + accepted_selection: 0, definition: Some((&definition).into()), execution: Some((&execution).into()), }, @@ -155,47 +166,37 @@ impl NodeManagerService { .bind_execution(resolved.execution) .map_err(|error| Status::failed_precondition(describe(&error)))?; } + self.execution_policy + .check_execution(&resolved.execution, mode) + .map_err(|error| Status::failed_precondition(error.to_string()))?; Ok(resolved) } } -fn select_definition<'a>( - initial: &InitialEnvironment, - environments: impl FnOnce() -> Result< - &'a [pplx_space_node_runtime_core::catalog::CompiledEnvironment], - Status, - >, - production: bool, +fn select_definition( + policy: &pplx_space_node_runtime_core::selection::ExecutionPolicy, reference: Option, mode: ExecutionSelectionMode, ) -> Result { - let initial = initial - .definition() - .map_err(|error| Status::failed_precondition(describe(&error)))?; - if reference.is_none_or(|reference| reference == initial.reference()) { - return Ok(initial); - } - if production { + let reference = reference.or(policy.default_reference()).ok_or_else(|| { + Status::failed_precondition("host has no qualified initial execution environment") + })?; + if !policy.allows(reference) { return Err(Status::failed_precondition( - "production execution is restricted to the initial environment", + "execution environment is not allowed on this node", )); } - for environment in environments()? { - let definition = environment.definition(); - if reference == Some(definition.reference()) { - if mode == ExecutionSelectionMode::PersistedStateRestore - && !environment.supports_memory_state() - { - return Err(Status::failed_precondition( - "execution environment does not support memory-state restore", - )); - } - return Ok(definition.clone()); - } + let entry = policy + .catalog() + .iter() + .find(|entry| entry.definition().reference() == reference) + .ok_or_else(|| Status::failed_precondition("unknown compiled execution environment"))?; + if mode == ExecutionSelectionMode::PersistedStateRestore && !entry.supports_memory_state() { + return Err(Status::failed_precondition( + "execution environment does not support memory-state restore", + )); } - Err(Status::failed_precondition( - "unknown compiled execution environment", - )) + Ok(entry.definition().clone()) } const ALLOW_ROOT_OR_RTMGR: &[UdsPeerPrincipal] = &[ @@ -224,22 +225,27 @@ impl Artifact { } } - fn belongs_to_environment(&self) -> Result { - Ok(compiled_environments()? - .iter() - .any(|environment| match self { - Self::Release(release, digest) => { - release == &environment.release().release - && *digest == environment.release().digest() - } - Self::Kernel(_, digest) => *digest == environment.kernel().sha256, - })) + fn belongs_to_environment( + &self, + environment: &pplx_space_node_runtime_core::catalog::CompiledEnvironment, + ) -> bool { + match self { + Self::Release(release, digest) => { + release == &environment.release().release + && *digest == environment.release().digest() + } + Self::Kernel(_, digest) => *digest == environment.kernel().sha256, + } } fn check_removal(&self, initial: &InitialEnvironment) -> Result<(), Status> { // Compiled bindings remain protected across restarts and deployment changes. // Releasing them requires a future reference-aware retirement mechanism. - if self.belongs_to_initial(initial) || self.belongs_to_environment()? { + if self.belongs_to_initial(initial) + || compiled_environments()? + .iter() + .any(|environment| self.belongs_to_environment(environment)) + { Err(Status::failed_precondition( "artifact belongs to compiled E0 or another approved execution environment", )) @@ -291,7 +297,7 @@ impl Artifact { pub(super) fn execution_inventory( paths: &crate::config::RuntimePaths, - production: bool, + policy: &pplx_space_node_runtime_core::selection::ExecutionPolicy, ) -> Result { if execution_roots_overlap(paths) .map_err(|error| Status::failed_precondition(describe(&error)))? @@ -326,41 +332,18 @@ pub(super) fn execution_inventory( &paths.kernels, ) .map_err(super::RuntimeInventoryError::into_status)?; - if !production { - match non_default_environments() { - Ok(environments) => { - for environment in environments { - if let Err(error) = - inventory.include_environment(&store, environment, &paths.kernels) - { - tracing::warn!(%error, "environment inventory unavailable; retaining other bindings"); - } - } - } - Err(error) => { - tracing::warn!(%error, "additional environment catalog unavailable; retaining initial execution") - } + for environment in policy + .catalog() + .iter() + .filter(|entry| !entry.is_initial() && policy.allows(entry.definition().reference())) + { + if let Err(error) = inventory.include_environment(&store, environment, &paths.kernels) { + tracing::warn!(%error, "environment inventory unavailable; retaining other bindings"); } } Ok(inventory) } -pub(super) fn non_default_environments() --> Result, Status> { - let environments = compiled_environments()?; - if environments.is_empty() { - return Ok(Vec::new()); - } - let initial = initial_environment()? - .definition() - .map_err(|error| Status::failed_precondition(describe(&error)))? - .reference(); - Ok(environments - .iter() - .filter(|entry| entry.definition().reference() != initial) - .collect()) -} - pub(super) fn compiled_environments() -> Result<&'static [pplx_space_node_runtime_core::catalog::CompiledEnvironment], Status> { pplx_space_node_runtime_core::catalog::CompiledEnvironment::cached_for_architecture(host_arch()?) @@ -612,9 +595,12 @@ impl NodeManagerService { publish: impl FnOnce() -> Result + Send + 'static, ) -> Result { let initial = initial_environment()?; - let production = self.production; + let policy = self.execution_policy.clone(); let affects_execution = artifact.belongs_to_initial(initial) - || (!production && artifact.belongs_to_environment()?); + || policy.catalog().iter().any(|environment| { + policy.allows(environment.definition().reference()) + && artifact.belongs_to_environment(environment) + }); let slot = self.artifact_slot()?; // Serialize selected publication with discovery, while readers keep the // previous immutable snapshot. Unselected artifacts skip this refresh lock, but @@ -640,7 +626,7 @@ impl NodeManagerService { if affects_execution && !unchanged { // Build first, then swap. E0 verification failures withdraw only // that binding; a scan failure prevents advertising stale state. - match execution_inventory(&paths, production) { + match execution_inventory(&paths, &policy) { Ok(inventory) => snapshot.store(Some(std::sync::Arc::new(inventory))), Err(error) => { snapshot.store(None); diff --git a/crates/libs/node/mgr/server/src/service/node_artifacts/selection.rs b/crates/libs/node/mgr/server/src/service/node_artifacts/selection.rs new file mode 100644 index 000000000..c5f540eb8 --- /dev/null +++ b/crates/libs/node/mgr/server/src/service/node_artifacts/selection.rs @@ -0,0 +1,54 @@ +//! Read-only compatible selection; launches continue to admit exact identities. +use pplx_space_common_core::types::ExecutionMetadata; +use pplx_space_node_runtime_core::selection::{self, CompatibleSelection}; +use tonic::Status; + +use super::NodeManagerService; +use crate::proto::{ + ExecutionEnvironmentSelection, ResolveExecutionEnvironmentResponse, + resolve_execution_environment_request::Compatible, +}; + +impl NodeManagerService { + pub(super) async fn resolve_compatible_environment( + &self, + request: Compatible, + ) -> Result { + let (Compatible::ColdBootSource(execution) | Compatible::RestoreSource(execution)) = + &request; + let source = ExecutionMetadata::try_from(execution) + .map_err(|error| Status::invalid_argument(error.to_string()))?; + let (intent, acknowledgement) = match request { + Compatible::ColdBootSource(_) => ( + CompatibleSelection::ColdBoot { source: &source }, + ExecutionEnvironmentSelection::ColdBoot, + ), + Compatible::RestoreSource(_) => ( + CompatibleSelection::Restore { source: &source }, + ExecutionEnvironmentSelection::Restore, + ), + }; + let inventory = self.runtime_inventory().await?; + let (definition, selected) = selection::select(&inventory, &self.execution_policy, intent) + .map_err(|error| match error { + selection::SelectionError::Unavailable(error) => error.into_status(), + error => Status::failed_precondition(error.to_string()), + })?; + let source = intent.source(); + if source.environment() != Some(definition.reference()) { + pplx_space_metrics::node_manager::execution_consumer_fallback(); + tracing::warn!( + source_environment = ?source.environment(), + consumer_environment = %definition.reference(), + mode = ?intent.mode(), + source_allowed = source.environment().is_some_and(|reference| self.execution_policy.allows(reference)), + "selected compatible consumer after exact source was absent, revoked or permanently unavailable" + ); + } + Ok(ResolveExecutionEnvironmentResponse { + definition: Some(definition.into()), + execution: Some((&selected.execution).into()), + accepted_selection: acknowledgement as i32, + }) + } +} diff --git a/crates/libs/node/mgr/server/src/service/node_artifacts/tests.rs b/crates/libs/node/mgr/server/src/service/node_artifacts/tests.rs index 5b6f48d56..6d4a4e534 100644 --- a/crates/libs/node/mgr/server/src/service/node_artifacts/tests.rs +++ b/crates/libs/node/mgr/server/src/service/node_artifacts/tests.rs @@ -194,7 +194,7 @@ fn prototype_overlap_keeps_external_execution_but_refuses_mutations() { let (_fixture, mut paths) = installed_runtime(); paths.store.releases = paths.vendor.clone(); assert!(separate_store(&paths).is_err()); - let inventory = execution_inventory(&paths, true).unwrap(); + let inventory = execution_inventory(&paths, &initial_policy()).unwrap(); let selected = inventory .resolve( None, @@ -217,15 +217,27 @@ async fn publication_preserves_available_inventory_until_replacement_is_ready() let initial = InitialEnvironment::for_architecture(host_arch().unwrap()).unwrap(); let candidate = RuntimeCatalog::compiled() .unwrap() - .releases(RuntimeKind::Firecracker, host_arch().unwrap()) + .releases(RuntimeKind::Firecracker, Arch::X86_64) .find(|pin| pin.release.version.as_str() == "1.17.0") .unwrap() .clone(); - for pin in [initial.release().clone(), candidate] { - let is_candidate = pin.release.version.as_str() == "1.17.0"; + let catalog = + pplx_space_node_runtime_core::catalog::CompiledEnvironment::for_architecture(Arch::X86_64) + .unwrap(); + for (pin, allowed, replaces_inventory) in [ + (initial.release().clone(), "e0", true), + (candidate.clone(), "e0", false), + (candidate, "e0,fc-1.17.0-linux-6.1.155", true), + ] { let (_fixture, paths) = installed_runtime(); let mut service = service_for_unit_tests().await; service.runtime_paths = paths; + service.execution_policy = pplx_space_node_runtime_core::selection::ExecutionPolicy::parse( + Some(allowed), + None, + &catalog, + ) + .unwrap(); let inventory = Arc::new( RuntimeInventory::discover( [RuntimeInstallationRoot::new( @@ -271,8 +283,8 @@ async fn publication_preserves_available_inventory_until_replacement_is_ready() let after = service.runtime_inventory.load_full().unwrap(); assert_eq!( Arc::ptr_eq(&after, &inventory), - is_candidate, - "only an E0 publication replaces executable inventory" + !replaces_inventory, + "only E0 or an allowed environment publication replaces executable inventory: {allowed}" ); } } @@ -281,7 +293,10 @@ async fn publication_preserves_available_inventory_until_replacement_is_ready() async fn environment_resolution_requires_authorization_and_verified_inventory() { use crate::service::tests::service_tests::{root_request, service_for_unit_tests}; let service = service_for_unit_tests().await; - let request = || crate::proto::ResolveExecutionEnvironmentRequest { reference: None }; + let request = || crate::proto::ResolveExecutionEnvironmentRequest { + reference: None, + compatible: None, + }; let error = service .resolve_execution_environment(Request::new(request())) .await @@ -295,95 +310,54 @@ async fn environment_resolution_requires_authorization_and_verified_inventory() assert_eq!(error.code(), tonic::Code::FailedPrecondition); } +fn initial_policy() -> pplx_space_node_runtime_core::selection::ExecutionPolicy { + pplx_space_node_runtime_core::selection::ExecutionPolicy::parse( + None, + None, + &pplx_space_node_runtime_core::catalog::CompiledEnvironment::for_architecture(Arch::X86_64) + .unwrap(), + ) + .unwrap() +} + #[test] -fn production_admission_restricts_selection_without_changing_defaults() { - use pplx_space_common_core::types::{Digest, EnvironmentReference}; - use pplx_space_node_runtime_core::catalog::CompiledEnvironment; - let initial = InitialEnvironment::for_architecture(Arch::X86_64).unwrap(); - let environments = CompiledEnvironment::for_architecture(Arch::X86_64).unwrap(); - let candidate = &environments[1]; - let e0 = initial.definition().unwrap().reference(); - let c = candidate.definition().reference(); - let same_kernel = environments[2].definition().reference(); - for production in [false, true] { - for (reference, mode, accepted) in [ - (None, ExecutionSelectionMode::NewColdBoot, true), - (Some(e0), ExecutionSelectionMode::NewColdBoot, true), - (Some(c), ExecutionSelectionMode::NewColdBoot, !production), - ( - Some(same_kernel), +fn policy_checks_exact_admission_and_configured_default() { + use pplx_space_node_runtime_core::{catalog::CompiledEnvironment, selection::ExecutionPolicy}; + let catalog = CompiledEnvironment::for_architecture(Arch::X86_64).unwrap(); + for (allowed, default) in [ + ("e0", None), + ("e0,fc-1.17.0-linux-6.1.155", None), + ( + "e0,fc-1.17.0-linux-6.1.155", + Some("fc-1.17.0-linux-6.1.155"), + ), + ] { + let policy = ExecutionPolicy::parse(Some(allowed), default, &catalog).unwrap(); + for entry in &catalog { + for mode in [ ExecutionSelectionMode::NewColdBoot, - !production, - ), - ( - Some(same_kernel), ExecutionSelectionMode::ExistingColdBoot, - !production, - ), - ( - Some(same_kernel), - ExecutionSelectionMode::PersistedStateRestore, - !production, - ), - ( - Some(c), - ExecutionSelectionMode::ExistingColdBoot, - !production, - ), - ( - Some(c), ExecutionSelectionMode::PersistedStateRestore, - !production, - ), - ( - Some(EnvironmentReference::new(Digest::sha256([1; 32]))), - ExecutionSelectionMode::NewColdBoot, - false, - ), - ] { - let result = - select_definition(&initial, || Ok(&environments), production, reference, mode); - assert_eq!( - result.is_ok(), - accepted, - "{production} {reference:?} {mode:?}" - ); - if let Ok(definition) = result { - assert_eq!(definition.reference(), reference.unwrap_or(e0)); + ] { + let result = select_definition(&policy, Some(entry.definition().reference()), mode); + if policy.allows(entry.definition().reference()) { + result.unwrap(); + } else { + result.unwrap_err(); + } } } + assert_eq!( + select_definition(&policy, None, ExecutionSelectionMode::NewColdBoot) + .unwrap() + .reference(), + policy.default_reference().unwrap() + ); } } -#[test] -fn candidate_catalog_failure_does_not_change_initial_selection() { - let initial = InitialEnvironment::for_architecture(Arch::X86_64).unwrap(); - let e0 = initial.definition().unwrap().reference(); - for reference in [None, Some(e0)] { - let selected = select_definition( - &initial, - || Err(Status::failed_precondition("candidate pins missing")), - true, - reference, - ExecutionSelectionMode::NewColdBoot, - ) - .unwrap(); - assert_eq!(selected.reference(), e0); - } - select_definition( - &initial, - || Err(Status::failed_precondition("candidate pins missing")), - true, - Some(pplx_space_common_core::types::EnvironmentReference::new( - pplx_space_common_core::types::Digest::sha256([8; 32]), - )), - ExecutionSelectionMode::NewColdBoot, - ) - .unwrap_err(); -} - #[tokio::test] -async fn legacy_custom_kernels_survive_managed_runtime_migration() { +async fn legacy_custom_inputs_survive_unless_legacy_permission_is_removed() { use crate::service::tests::service_tests::{installed_runtime, service_for_unit_tests}; use pplx_space_common_core::types::{Digest, ExecutionMetadata, RuntimeKind}; use pplx_space_node_runtime_core::runtime_inventory::{ @@ -447,11 +421,11 @@ async fn legacy_custom_kernels_survive_managed_runtime_migration() { let resolved = service .resolve_bound_execution(Some(&legacy), RuntimeKind::Firecracker, mode) .await - .unwrap_or_else(|error| panic!("managed={managed} {mode:?}: {error}")); + .unwrap(); assert_eq!(resolved.execution, legacy); } - // A restore retains its recorded boot-kernel evidence even when those - // bytes are absent; only the compatible snapshot loader is required. + // Historical restores retain custom kernel provenance without requiring + // a boot image; cold starts still require that exact image. let mut absent_components = legacy.components().to_vec(); absent_components[0].digest = Some(Digest::sha256([99; 32])); let absent = ExecutionMetadata::new( @@ -461,7 +435,7 @@ async fn legacy_custom_kernels_survive_managed_runtime_migration() { absent_components, ) .unwrap(); - let restored = service + service .resolve_bound_execution( Some(&absent), RuntimeKind::Firecracker, @@ -469,7 +443,6 @@ async fn legacy_custom_kernels_survive_managed_runtime_migration() { ) .await .unwrap(); - assert_eq!(restored.execution.components(), absent.components()); service .resolve_bound_execution( Some(&absent), @@ -507,13 +480,37 @@ async fn legacy_custom_kernels_survive_managed_runtime_migration() { let result = service .resolve_bound_execution(Some(&requested), RuntimeKind::Firecracker, mode) .await; - if version == "1.16.0" { - result.unwrap(); - } else { + if version == "1.17.0" { + // Legacy permission never activates managed candidates. assert_eq!(result.unwrap_err().code(), tonic::Code::FailedPrecondition); + } else { + result.unwrap(); } } } + service.execution_policy = pplx_space_node_runtime_core::selection::ExecutionPolicy::parse( + Some("e0"), + None, + &pplx_space_node_runtime_core::catalog::CompiledEnvironment::for_architecture( + pplx_space_common_core::types::Arch::X86_64, + ) + .unwrap(), + ) + .unwrap(); + for mode in [ + ExecutionSelectionMode::NewColdBoot, + ExecutionSelectionMode::ExistingColdBoot, + ExecutionSelectionMode::PersistedStateRestore, + ] { + assert_eq!( + service + .resolve_bound_execution(Some(&legacy), RuntimeKind::Firecracker, mode) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + } } } @@ -542,7 +539,13 @@ async fn legacy_runtime_placement_is_not_hidden_by_a_compiled_environment() { ) .unwrap(); let mut service = service_for_unit_tests().await; - service.production = false; + service.execution_policy = pplx_space_node_runtime_core::selection::ExecutionPolicy::parse( + Some("e0,fc-1.17.0-linux-6.1.155"), + None, + &pplx_space_node_runtime_core::catalog::CompiledEnvironment::for_architecture(Arch::X86_64) + .unwrap(), + ) + .unwrap(); service.runtime_paths = paths; service.runtime_inventory.store(Some(Arc::new(inventory))); let report = service diff --git a/crates/libs/node/mgr/server/src/service/snapshots/environment.rs b/crates/libs/node/mgr/server/src/service/snapshots/environment.rs index e3cd3cf6f..57a0da433 100644 --- a/crates/libs/node/mgr/server/src/service/snapshots/environment.rs +++ b/crates/libs/node/mgr/server/src/service/snapshots/environment.rs @@ -103,7 +103,9 @@ impl NodeManagerService { }; }; if let Some(actual) = execution.environment() { - let initial = self.allowed_definition(None, ExecutionSelectionMode::NewColdBoot)?; + let initial = super::super::node_artifacts::initial_environment()? + .definition() + .map_err(|error| Status::failed_precondition(error.to_string()))?; validate_binding(reference, actual, initial.reference())?; } else { return if reference.is_some() { diff --git a/crates/libs/node/mgr/server/src/service/snapshots/environment/tests.rs b/crates/libs/node/mgr/server/src/service/snapshots/environment/tests.rs index 73df6daec..4cc0c7b04 100644 --- a/crates/libs/node/mgr/server/src/service/snapshots/environment/tests.rs +++ b/crates/libs/node/mgr/server/src/service/snapshots/environment/tests.rs @@ -44,8 +44,7 @@ fn selected_snapshot_identity_rejects_conflicts_and_malformed_values() { #[tokio::test] async fn unapproved_snapshot_environment_is_refused_before_host_io() { - let mut service = crate::service::tests::service_tests::service_for_unit_tests().await; - service.production = true; + let service = crate::service::tests::service_tests::service_for_unit_tests().await; let reference = "sha256:959f1181ac69f0dde7125fbc81be6fcd75b64e9e06e7a393f067b96eb6fb2ac4" .parse() .unwrap(); @@ -54,7 +53,7 @@ async fn unapproved_snapshot_environment_is_refused_before_host_io() { .await .unwrap_err(); assert_eq!(error.code(), tonic::Code::FailedPrecondition); - assert!(error.message().contains("production execution")); + assert!(error.message().contains("not allowed")); } #[test] diff --git a/crates/libs/node/mgr/server/src/service/tests.rs b/crates/libs/node/mgr/server/src/service/tests.rs index 374cd9555..4c791196a 100644 --- a/crates/libs/node/mgr/server/src/service/tests.rs +++ b/crates/libs/node/mgr/server/src/service/tests.rs @@ -685,7 +685,15 @@ pub(super) mod service_tests { }, }, guest_console_logs: false, - production: true, + execution_policy: pplx_space_node_runtime_core::selection::ExecutionPolicy::parse( + None, + None, + &pplx_space_node_runtime_core::catalog::CompiledEnvironment::for_architecture( + pplx_space_common_core::types::Arch::X86_64, + ) + .unwrap(), + ) + .unwrap(), lifecycle_limiter: std::sync::Arc::new( crate::lifecycle_limiter::tests::default_limiter(), ), diff --git a/crates/libs/node/runtime/core/src/runtime_inventory.rs b/crates/libs/node/runtime/core/src/runtime_inventory.rs index 692a4dde7..8150a569a 100644 --- a/crates/libs/node/runtime/core/src/runtime_inventory.rs +++ b/crates/libs/node/runtime/core/src/runtime_inventory.rs @@ -1306,6 +1306,7 @@ impl RuntimeInventory { }) .collect(); proto::NodeExecutionInventory { + policy: None, runtimes, components, } diff --git a/crates/libs/sbx/mgr/server/src/service/grpc_core.rs b/crates/libs/sbx/mgr/server/src/service/grpc_core.rs index 55dab70f9..0ac31e055 100644 --- a/crates/libs/sbx/mgr/server/src/service/grpc_core.rs +++ b/crates/libs/sbx/mgr/server/src/service/grpc_core.rs @@ -175,6 +175,7 @@ impl SandboxManagerService { .map_err(|error| Status::unavailable(error.to_string()))?; node.resolve_execution_environment( pplx_space_node_mgr_client::proto::ResolveExecutionEnvironmentRequest { + compatible: None, reference: Some(reference.to_string()), }, ) diff --git a/crates/libs/sbx/mgr/server/src/service/grpc_core/create_after_lookup_tests.rs b/crates/libs/sbx/mgr/server/src/service/grpc_core/create_after_lookup_tests.rs index 9583ffabe..a2437274c 100644 --- a/crates/libs/sbx/mgr/server/src/service/grpc_core/create_after_lookup_tests.rs +++ b/crates/libs/sbx/mgr/server/src/service/grpc_core/create_after_lookup_tests.rs @@ -752,6 +752,7 @@ async fn explicit_selection_checks_legacy_base_evidence_before_create() { .unwrap(); let verified = definition.bind_execution(requirements.clone()).unwrap(); let response = ResolveExecutionEnvironmentResponse { + accepted_selection: 0, definition: Some((&definition).into()), execution: Some((&verified).into()), }; @@ -813,6 +814,7 @@ async fn selected_create_persists_the_binding_used_by_first_start() { std::fs::write(build.dir.path().join("metadata.json"), &metadata_bytes).unwrap(); let bound = definition.bind_execution(execution).unwrap(); let response = ResolveExecutionEnvironmentResponse { + accepted_selection: 0, definition: Some((&definition).into()), execution: Some((&bound).into()), }; diff --git a/docs/execution-environments.md b/docs/execution-environments.md index 1c384332d..fb8f06d63 100644 --- a/docs/execution-environments.md +++ b/docs/execution-environments.md @@ -16,8 +16,7 @@ Firecracker and jailer binaries, not their download archive. The implementation includes domain types, protobuf messages, a versioned JSON codec, and explicit seed/build binding. E0 remains the default. Designated nodes -can select other approved compiled bindings through the regular APIs in -non-production deployments; +can select other approved compiled bindings through the regular APIs when the node execution policy allows them; installation alone never authorizes selection. Cluster policy tables and fleet selection are separate operations. @@ -547,18 +546,17 @@ The 1.17.0 / 6.1.155 binding has reference: sha256:6e97c03b2523af6cc7b8e67bcde3fd89cf756c1958b10c24472aeadd68a32e0a ``` -Installation does not select an environment. Node-manager uses the existing -`SPACE_ENV` deployment identity for its production restriction: explicit -`dev`, `development`, `local`, `kind-local`, `test`, `testing`, `staging` and -well-formed `local-*` identities may select any compiled binding; production, missing, malformed and -unrecognized identities restrict explicit selection to E0. This adds no -experiment-specific configuration, API, or stored policy. +Installation does not select an environment. Node-manager loads an explicit +allowed/default policy at startup. Absent settings retain E0 and historical +unbound contracts; `SPACE_ENV` is not an admission signal. Compiled names resolve +to complete references, while `legacy` separately permits historical contracts. +The policy is rechecked on exact prepare and launch admission. Artifacts belonging to compiled bindings are removal-protected independently of deployment mode, installation state and service restarts. Retiring a binding will require reference-aware fleet and node guards. Keep compatible readers while -bound templates and sandbox records remain. Changing deployment identity is not -a workload retirement operation. +bound templates and sandbox records remain. Removing execution permission is not +a workload retirement or artifact deletion operation. Node-manager verifies each environment's release and kernel from the managed store and stages its kernel into the execution cache. Cache loss triggers @@ -594,7 +592,7 @@ the returned job/sandbox identity if available before retrying. Roll out readers first: node-manager, template-manager, sandbox-manager, then builder and operator clients. Ordinary unselected E0 traffic continues during mixed versions. Submit non-default work only after compatible readers are -deployed on the target non-production nodes. Work sent early fails explicitly; it does +deployed on the target nodes with the requested policy. Work sent early fails explicitly; it does not require a cluster-wide outage. A queued selected build retains its reference in a versioned job payload. A downgraded worker cannot decode that payload as an ordinary build and silently substitute E0; such work must be inspected/resubmitted @@ -671,3 +669,81 @@ boot-component availability. A missing or damaged local kernel therefore blocks cold boots using that kernel, without preventing restore through an otherwise usable, approved runtime. Restore never substitutes a local default kernel into the snapshot's execution evidence. Pause/resume also keeps the resident kernel. + +### Execution policy and compatible resolution + +Scratch builds call `ResolveExecutionEnvironment` without a reference to resolve +the configured default, then validate the verified runtime and guest locally. +Cold boots from recorded execution and snapshot restores use compatible-selection +requests. The resolver acknowledges the intent and returns a complete verified +consumer. Callers persist the exact result before effects; prepare and launch +recheck eligibility. Old resolvers +without the acknowledgement retain the previous exact contract during rollout, +never authorize an unacknowledged upgrade. + +Node-manager reads two startup settings: + +```text +SPACE_ALLOWED_EXECUTION_ENVIRONMENTS=e0,legacy +SPACE_DEFAULT_EXECUTION_ENVIRONMENT=e0 +``` + +On x86_64, absent settings mean `e0,legacy` and default `e0`. Compiled names +are `e0`, `fc-1.17.0-linux-6.1.155`, and `fc-1.17.0-linux-6.18.44`. +`legacy` is a separate permission for unbound historical contracts, not an +environment reference or a selectable default on x86_64. The default must be +allowed. Empty, duplicate or unknown names fail startup. Compiled names resolve +to immutable references and cannot approve arbitrary downloads or unqualified +pairs. On aarch64, where no environment descriptor is qualified, absent settings +mean `legacy` for both settings: builders and activations retain the existing +unbound initial contract. The report has no compiled references or default but +has `legacy_unbound_allowed=true`. Explicit compiled names fail startup on that +architecture; existing artifact pins do not establish environment qualification. + +The configured default is used only for work without source or exact selection. +A bound source prefers its exact environment, then compatible fallback if it is +revoked, absent or permanently unavailable. A transient exact-source failure +returns the existing retryable inventory error (`Internal`) without choosing or +persisting a new consumer. +Cold fallback preserves the guest kernel; restore fallback +also requires the same compiled state-format profile and qualified memory-state +support. CPU, memory and device checks remain in the restore path. Firecracker +1.17 cannot load E0 format 9 snapshots. Ordering of fallback candidates belongs +to the runtime adapter, and never assembles unqualified runtime/kernel pairs. + +Bound activations always require an allowed compiled environment. Unbound +metadata that exactly identifies a compiled contract also obeys its eligibility; +other historical contracts require `legacy`. Existing inventory validation, +custom-kernel matching and the ban on unbound managed non-default runtimes still +apply. A historical restore retains its kernel provenance without requiring a +boot image. Removing `legacy` deliberately disables custom unbound contracts; +audit templates, sandboxes and stored snapshots before doing so. Removing a +compiled environment can strand its bound work without a compatible consumer. +A runtime/kernel incident affecting unbound work also requires removing `legacy`; +revoking a compiled pair alone cannot classify every historical custom contract. +Neither permission affects artifact retention or rewrites producer metadata. + +Successful fallback decisions emit a warning with source and consumer references +and increment `node_mgr.execution.consumer_fallback`. This counts resolver +attempts, including retries, rather than completed launches. Transient exact-source +errors follow the existing inventory refresh backoff and the caller's activation +retry/deadline; selection itself does not wait or write a consumer. + +The temporary node settings will yield authority to cluster desired policy. +Until placement consumes environment eligibility and fresh usability evidence, +keep allowed/default policy uniform within each role/host-platform cohort and +verify that builder outputs work on every worker cohort that may receive them. +Keep shared fleet defaults at E0: cluster-owned non-default Create remains refused. +Additional bindings can be exercised through designated node-local paths without +publishing their templates for shared fleet consumption. + +`GetExecutionInventory` reports resolved references and the separate legacy permission. +Node-ctl forwards it through periodic publication; the cluster records it with +its own database receipt time in node status, and the admin node response exposes +both. Older cluster writers can refresh inventory without refreshing policy; use +`policy_received_at` to assess policy freshness independently. +Missing, invalid, future-schema or failed policy observations withhold policy +evidence. An unrelated publication does not renew it. Policy is not proof of +installation or usability, and reporting does not implement placement gating. +Deploy the additive migration and cluster readers before node publishers. Inspect +cohort drift and collection freshness before enabling workloads. diff --git a/proto/execution/v1/execution.proto b/proto/execution/v1/execution.proto index 575b973db..45b257ba6 100644 --- a/proto/execution/v1/execution.proto +++ b/proto/execution/v1/execution.proto @@ -95,6 +95,18 @@ message ArtifactComponentInstallation { message NodeExecutionInventory { repeated RuntimeInstallation runtimes = 1; repeated ArtifactComponentInstallation components = 2; + // Effective startup policy, independent of installed/usable artifacts. Absence + // means unknown support, never implicit E0 approval. + ExecutionPolicyObservation policy = 3; +} + +message ExecutionPolicyObservation { + uint32 schema_version = 1; + repeated string allowed_environment_references = 2; + // Absent with an empty list on architectures using the legacy default. + optional string default_environment_reference = 3; + // Permission for unbound historical contracts, not an environment identity. + bool legacy_unbound_allowed = 4; } // Environment definitions are additive data contracts. Sending one to a peer diff --git a/proto/node/v1/node.proto b/proto/node/v1/node.proto index 923c4ba4e..fa721cf1a 100644 --- a/proto/node/v1/node.proto +++ b/proto/node/v1/node.proto @@ -354,14 +354,33 @@ service NodeManager { message GetExecutionInventoryRequest {} message ResolveExecutionEnvironmentRequest { - // Omission requests the compiled default. A supplied reference must match an + // Omission requests the configured default. A supplied reference must match an // approved environment exactly; it never changes the node default. optional string reference = 1; + reserved 2; + reserved "template_build"; + // Mutually exclusive with reference. This read-only request selects a complete + // binding; callers persist it and use exact admission for all effectful calls. + // An old peer ignores this oneof. Require the matching response acknowledgement + // before using the result as compatible selection. + oneof compatible { + space.execution.v1.ExecutionMetadata cold_boot_source = 3; + space.execution.v1.ExecutionMetadata restore_source = 4; + } +} + +enum ExecutionEnvironmentSelection { + EXECUTION_ENVIRONMENT_SELECTION_UNSPECIFIED = 0; + reserved 1; + reserved "EXECUTION_ENVIRONMENT_SELECTION_TEMPLATE_BUILD"; + EXECUTION_ENVIRONMENT_SELECTION_COLD_BOOT = 2; + EXECUTION_ENVIRONMENT_SELECTION_RESTORE = 3; } message ResolveExecutionEnvironmentResponse { space.execution.v1.EnvironmentDefinition definition = 1; space.execution.v1.ExecutionMetadata execution = 2; + ExecutionEnvironmentSelection accepted_selection = 3; } enum NetworkProtocol { diff --git a/shim/proto/execution/v1/execution.pb.go b/shim/proto/execution/v1/execution.pb.go index ed43598a8..b71476414 100644 --- a/shim/proto/execution/v1/execution.pb.go +++ b/shim/proto/execution/v1/execution.pb.go @@ -973,9 +973,12 @@ func (x *ArtifactComponentInstallation) GetLegacyDefault() bool { } type NodeExecutionInventory struct { - state protoimpl.MessageState `protogen:"open.v1"` - Runtimes []*RuntimeInstallation `protobuf:"bytes,1,rep,name=runtimes,proto3" json:"runtimes,omitempty"` - Components []*ArtifactComponentInstallation `protobuf:"bytes,2,rep,name=components,proto3" json:"components,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Runtimes []*RuntimeInstallation `protobuf:"bytes,1,rep,name=runtimes,proto3" json:"runtimes,omitempty"` + Components []*ArtifactComponentInstallation `protobuf:"bytes,2,rep,name=components,proto3" json:"components,omitempty"` + // Effective startup policy, independent of installed/usable artifacts. Absence + // means unknown support, never implicit E0 approval. + Policy *ExecutionPolicyObservation `protobuf:"bytes,3,opt,name=policy,proto3" json:"policy,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1024,6 +1027,83 @@ func (x *NodeExecutionInventory) GetComponents() []*ArtifactComponentInstallatio return nil } +func (x *NodeExecutionInventory) GetPolicy() *ExecutionPolicyObservation { + if x != nil { + return x.Policy + } + return nil +} + +type ExecutionPolicyObservation struct { + state protoimpl.MessageState `protogen:"open.v1"` + SchemaVersion uint32 `protobuf:"varint,1,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + AllowedEnvironmentReferences []string `protobuf:"bytes,2,rep,name=allowed_environment_references,json=allowedEnvironmentReferences,proto3" json:"allowed_environment_references,omitempty"` + // Absent with an empty list on architectures using the legacy default. + DefaultEnvironmentReference *string `protobuf:"bytes,3,opt,name=default_environment_reference,json=defaultEnvironmentReference,proto3,oneof" json:"default_environment_reference,omitempty"` + // Permission for unbound historical contracts, not an environment identity. + LegacyUnboundAllowed bool `protobuf:"varint,4,opt,name=legacy_unbound_allowed,json=legacyUnboundAllowed,proto3" json:"legacy_unbound_allowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecutionPolicyObservation) Reset() { + *x = ExecutionPolicyObservation{} + mi := &file_execution_v1_execution_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecutionPolicyObservation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecutionPolicyObservation) ProtoMessage() {} + +func (x *ExecutionPolicyObservation) ProtoReflect() protoreflect.Message { + mi := &file_execution_v1_execution_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecutionPolicyObservation.ProtoReflect.Descriptor instead. +func (*ExecutionPolicyObservation) Descriptor() ([]byte, []int) { + return file_execution_v1_execution_proto_rawDescGZIP(), []int{9} +} + +func (x *ExecutionPolicyObservation) GetSchemaVersion() uint32 { + if x != nil { + return x.SchemaVersion + } + return 0 +} + +func (x *ExecutionPolicyObservation) GetAllowedEnvironmentReferences() []string { + if x != nil { + return x.AllowedEnvironmentReferences + } + return nil +} + +func (x *ExecutionPolicyObservation) GetDefaultEnvironmentReference() string { + if x != nil && x.DefaultEnvironmentReference != nil { + return *x.DefaultEnvironmentReference + } + return "" +} + +func (x *ExecutionPolicyObservation) GetLegacyUnboundAllowed() bool { + if x != nil { + return x.LegacyUnboundAllowed + } + return false +} + type HostPlatform struct { state protoimpl.MessageState `protogen:"open.v1"` Os HostOperatingSystem `protobuf:"varint,1,opt,name=os,proto3,enum=space.execution.v1.HostOperatingSystem" json:"os,omitempty"` @@ -1034,7 +1114,7 @@ type HostPlatform struct { func (x *HostPlatform) Reset() { *x = HostPlatform{} - mi := &file_execution_v1_execution_proto_msgTypes[9] + mi := &file_execution_v1_execution_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1046,7 +1126,7 @@ func (x *HostPlatform) String() string { func (*HostPlatform) ProtoMessage() {} func (x *HostPlatform) ProtoReflect() protoreflect.Message { - mi := &file_execution_v1_execution_proto_msgTypes[9] + mi := &file_execution_v1_execution_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1059,7 +1139,7 @@ func (x *HostPlatform) ProtoReflect() protoreflect.Message { // Deprecated: Use HostPlatform.ProtoReflect.Descriptor instead. func (*HostPlatform) Descriptor() ([]byte, []int) { - return file_execution_v1_execution_proto_rawDescGZIP(), []int{9} + return file_execution_v1_execution_proto_rawDescGZIP(), []int{10} } func (x *HostPlatform) GetOs() HostOperatingSystem { @@ -1089,7 +1169,7 @@ type ManagedRuntimeRelease struct { func (x *ManagedRuntimeRelease) Reset() { *x = ManagedRuntimeRelease{} - mi := &file_execution_v1_execution_proto_msgTypes[10] + mi := &file_execution_v1_execution_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1101,7 +1181,7 @@ func (x *ManagedRuntimeRelease) String() string { func (*ManagedRuntimeRelease) ProtoMessage() {} func (x *ManagedRuntimeRelease) ProtoReflect() protoreflect.Message { - mi := &file_execution_v1_execution_proto_msgTypes[10] + mi := &file_execution_v1_execution_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1114,7 +1194,7 @@ func (x *ManagedRuntimeRelease) ProtoReflect() protoreflect.Message { // Deprecated: Use ManagedRuntimeRelease.ProtoReflect.Descriptor instead. func (*ManagedRuntimeRelease) Descriptor() ([]byte, []int) { - return file_execution_v1_execution_proto_rawDescGZIP(), []int{10} + return file_execution_v1_execution_proto_rawDescGZIP(), []int{11} } func (x *ManagedRuntimeRelease) GetRuntime() *RuntimeIdentity { @@ -1157,7 +1237,7 @@ type RuntimeBinding struct { func (x *RuntimeBinding) Reset() { *x = RuntimeBinding{} - mi := &file_execution_v1_execution_proto_msgTypes[11] + mi := &file_execution_v1_execution_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1169,7 +1249,7 @@ func (x *RuntimeBinding) String() string { func (*RuntimeBinding) ProtoMessage() {} func (x *RuntimeBinding) ProtoReflect() protoreflect.Message { - mi := &file_execution_v1_execution_proto_msgTypes[11] + mi := &file_execution_v1_execution_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1182,7 +1262,7 @@ func (x *RuntimeBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use RuntimeBinding.ProtoReflect.Descriptor instead. func (*RuntimeBinding) Descriptor() ([]byte, []int) { - return file_execution_v1_execution_proto_rawDescGZIP(), []int{11} + return file_execution_v1_execution_proto_rawDescGZIP(), []int{12} } func (x *RuntimeBinding) GetProvider() isRuntimeBinding_Provider { @@ -1221,7 +1301,7 @@ type LinuxDirectBootV1 struct { func (x *LinuxDirectBootV1) Reset() { *x = LinuxDirectBootV1{} - mi := &file_execution_v1_execution_proto_msgTypes[12] + mi := &file_execution_v1_execution_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1233,7 +1313,7 @@ func (x *LinuxDirectBootV1) String() string { func (*LinuxDirectBootV1) ProtoMessage() {} func (x *LinuxDirectBootV1) ProtoReflect() protoreflect.Message { - mi := &file_execution_v1_execution_proto_msgTypes[12] + mi := &file_execution_v1_execution_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1246,7 +1326,7 @@ func (x *LinuxDirectBootV1) ProtoReflect() protoreflect.Message { // Deprecated: Use LinuxDirectBootV1.ProtoReflect.Descriptor instead. func (*LinuxDirectBootV1) Descriptor() ([]byte, []int) { - return file_execution_v1_execution_proto_rawDescGZIP(), []int{12} + return file_execution_v1_execution_proto_rawDescGZIP(), []int{13} } func (x *LinuxDirectBootV1) GetKernel() *EnvironmentComponent { @@ -1267,7 +1347,7 @@ type EnvironmentComponent struct { func (x *EnvironmentComponent) Reset() { *x = EnvironmentComponent{} - mi := &file_execution_v1_execution_proto_msgTypes[13] + mi := &file_execution_v1_execution_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1279,7 +1359,7 @@ func (x *EnvironmentComponent) String() string { func (*EnvironmentComponent) ProtoMessage() {} func (x *EnvironmentComponent) ProtoReflect() protoreflect.Message { - mi := &file_execution_v1_execution_proto_msgTypes[13] + mi := &file_execution_v1_execution_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1292,7 +1372,7 @@ func (x *EnvironmentComponent) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvironmentComponent.ProtoReflect.Descriptor instead. func (*EnvironmentComponent) Descriptor() ([]byte, []int) { - return file_execution_v1_execution_proto_rawDescGZIP(), []int{13} + return file_execution_v1_execution_proto_rawDescGZIP(), []int{14} } func (x *EnvironmentComponent) GetKind() ArtifactComponentKind { @@ -1328,7 +1408,7 @@ type BootProfile struct { func (x *BootProfile) Reset() { *x = BootProfile{} - mi := &file_execution_v1_execution_proto_msgTypes[14] + mi := &file_execution_v1_execution_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1340,7 +1420,7 @@ func (x *BootProfile) String() string { func (*BootProfile) ProtoMessage() {} func (x *BootProfile) ProtoReflect() protoreflect.Message { - mi := &file_execution_v1_execution_proto_msgTypes[14] + mi := &file_execution_v1_execution_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1353,7 +1433,7 @@ func (x *BootProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use BootProfile.ProtoReflect.Descriptor instead. func (*BootProfile) Descriptor() ([]byte, []int) { - return file_execution_v1_execution_proto_rawDescGZIP(), []int{14} + return file_execution_v1_execution_proto_rawDescGZIP(), []int{15} } func (x *BootProfile) GetProfile() isBootProfile_Profile { @@ -1391,7 +1471,7 @@ type FirecrackerSnapshotCompatibilityV1 struct { func (x *FirecrackerSnapshotCompatibilityV1) Reset() { *x = FirecrackerSnapshotCompatibilityV1{} - mi := &file_execution_v1_execution_proto_msgTypes[15] + mi := &file_execution_v1_execution_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1403,7 +1483,7 @@ func (x *FirecrackerSnapshotCompatibilityV1) String() string { func (*FirecrackerSnapshotCompatibilityV1) ProtoMessage() {} func (x *FirecrackerSnapshotCompatibilityV1) ProtoReflect() protoreflect.Message { - mi := &file_execution_v1_execution_proto_msgTypes[15] + mi := &file_execution_v1_execution_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1416,7 +1496,7 @@ func (x *FirecrackerSnapshotCompatibilityV1) ProtoReflect() protoreflect.Message // Deprecated: Use FirecrackerSnapshotCompatibilityV1.ProtoReflect.Descriptor instead. func (*FirecrackerSnapshotCompatibilityV1) Descriptor() ([]byte, []int) { - return file_execution_v1_execution_proto_rawDescGZIP(), []int{15} + return file_execution_v1_execution_proto_rawDescGZIP(), []int{16} } func (x *FirecrackerSnapshotCompatibilityV1) GetStateFormat() *StateFormatIdentity { @@ -1438,7 +1518,7 @@ type ExecutionCompatibilityProfile struct { func (x *ExecutionCompatibilityProfile) Reset() { *x = ExecutionCompatibilityProfile{} - mi := &file_execution_v1_execution_proto_msgTypes[16] + mi := &file_execution_v1_execution_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1450,7 +1530,7 @@ func (x *ExecutionCompatibilityProfile) String() string { func (*ExecutionCompatibilityProfile) ProtoMessage() {} func (x *ExecutionCompatibilityProfile) ProtoReflect() protoreflect.Message { - mi := &file_execution_v1_execution_proto_msgTypes[16] + mi := &file_execution_v1_execution_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1463,7 +1543,7 @@ func (x *ExecutionCompatibilityProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecutionCompatibilityProfile.ProtoReflect.Descriptor instead. func (*ExecutionCompatibilityProfile) Descriptor() ([]byte, []int) { - return file_execution_v1_execution_proto_rawDescGZIP(), []int{16} + return file_execution_v1_execution_proto_rawDescGZIP(), []int{17} } func (x *ExecutionCompatibilityProfile) GetProfile() isExecutionCompatibilityProfile_Profile { @@ -1508,7 +1588,7 @@ type EnvironmentDescriptor struct { func (x *EnvironmentDescriptor) Reset() { *x = EnvironmentDescriptor{} - mi := &file_execution_v1_execution_proto_msgTypes[17] + mi := &file_execution_v1_execution_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1520,7 +1600,7 @@ func (x *EnvironmentDescriptor) String() string { func (*EnvironmentDescriptor) ProtoMessage() {} func (x *EnvironmentDescriptor) ProtoReflect() protoreflect.Message { - mi := &file_execution_v1_execution_proto_msgTypes[17] + mi := &file_execution_v1_execution_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1533,7 +1613,7 @@ func (x *EnvironmentDescriptor) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvironmentDescriptor.ProtoReflect.Descriptor instead. func (*EnvironmentDescriptor) Descriptor() ([]byte, []int) { - return file_execution_v1_execution_proto_rawDescGZIP(), []int{17} + return file_execution_v1_execution_proto_rawDescGZIP(), []int{18} } func (x *EnvironmentDescriptor) GetHost() *HostPlatform { @@ -1597,7 +1677,7 @@ type EnvironmentDefinition struct { func (x *EnvironmentDefinition) Reset() { *x = EnvironmentDefinition{} - mi := &file_execution_v1_execution_proto_msgTypes[18] + mi := &file_execution_v1_execution_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1609,7 +1689,7 @@ func (x *EnvironmentDefinition) String() string { func (*EnvironmentDefinition) ProtoMessage() {} func (x *EnvironmentDefinition) ProtoReflect() protoreflect.Message { - mi := &file_execution_v1_execution_proto_msgTypes[18] + mi := &file_execution_v1_execution_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1622,7 +1702,7 @@ func (x *EnvironmentDefinition) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvironmentDefinition.ProtoReflect.Descriptor instead. func (*EnvironmentDefinition) Descriptor() ([]byte, []int) { - return file_execution_v1_execution_proto_rawDescGZIP(), []int{18} + return file_execution_v1_execution_proto_rawDescGZIP(), []int{19} } func (x *EnvironmentDefinition) GetSchemaVersion() uint32 { @@ -1693,12 +1773,19 @@ const file_execution_v1_execution_proto_rawDesc = "" + "\x1dArtifactComponentInstallation\x12C\n" + "\tcomponent\x18\x01 \x01(\v2%.space.execution.v1.ArtifactComponentR\tcomponent\x12&\n" + "\x0fdefault_for_new\x18\x02 \x01(\bR\rdefaultForNew\x12%\n" + - "\x0elegacy_default\x18\x03 \x01(\bR\rlegacyDefault\"\xb0\x01\n" + + "\x0elegacy_default\x18\x03 \x01(\bR\rlegacyDefault\"\xf8\x01\n" + "\x16NodeExecutionInventory\x12C\n" + "\bruntimes\x18\x01 \x03(\v2'.space.execution.v1.RuntimeInstallationR\bruntimes\x12Q\n" + "\n" + "components\x18\x02 \x03(\v21.space.execution.v1.ArtifactComponentInstallationR\n" + - "components\"\x90\x01\n" + + "components\x12F\n" + + "\x06policy\x18\x03 \x01(\v2..space.execution.v1.ExecutionPolicyObservationR\x06policy\"\xaa\x02\n" + + "\x1aExecutionPolicyObservation\x12%\n" + + "\x0eschema_version\x18\x01 \x01(\rR\rschemaVersion\x12D\n" + + "\x1eallowed_environment_references\x18\x02 \x03(\tR\x1callowedEnvironmentReferences\x12G\n" + + "\x1ddefault_environment_reference\x18\x03 \x01(\tH\x00R\x1bdefaultEnvironmentReference\x88\x01\x01\x124\n" + + "\x16legacy_unbound_allowed\x18\x04 \x01(\bR\x14legacyUnboundAllowedB \n" + + "\x1e_default_environment_reference\"\x90\x01\n" + "\fHostPlatform\x127\n" + "\x02os\x18\x01 \x01(\x0e2'.space.execution.v1.HostOperatingSystemR\x02os\x12G\n" + "\farchitecture\x18\x02 \x01(\x0e2#.space.execution.v1.CpuArchitectureR\farchitecture\"\xea\x01\n" + @@ -1794,7 +1881,7 @@ func file_execution_v1_execution_proto_rawDescGZIP() []byte { } var file_execution_v1_execution_proto_enumTypes = make([]protoimpl.EnumInfo, 9) -var file_execution_v1_execution_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_execution_v1_execution_proto_msgTypes = make([]protoimpl.MessageInfo, 20) var file_execution_v1_execution_proto_goTypes = []any{ (RuntimeKind)(0), // 0: space.execution.v1.RuntimeKind (GuestOperatingSystem)(0), // 1: space.execution.v1.GuestOperatingSystem @@ -1814,16 +1901,17 @@ var file_execution_v1_execution_proto_goTypes = []any{ (*RuntimeInstallation)(nil), // 15: space.execution.v1.RuntimeInstallation (*ArtifactComponentInstallation)(nil), // 16: space.execution.v1.ArtifactComponentInstallation (*NodeExecutionInventory)(nil), // 17: space.execution.v1.NodeExecutionInventory - (*HostPlatform)(nil), // 18: space.execution.v1.HostPlatform - (*ManagedRuntimeRelease)(nil), // 19: space.execution.v1.ManagedRuntimeRelease - (*RuntimeBinding)(nil), // 20: space.execution.v1.RuntimeBinding - (*LinuxDirectBootV1)(nil), // 21: space.execution.v1.LinuxDirectBootV1 - (*EnvironmentComponent)(nil), // 22: space.execution.v1.EnvironmentComponent - (*BootProfile)(nil), // 23: space.execution.v1.BootProfile - (*FirecrackerSnapshotCompatibilityV1)(nil), // 24: space.execution.v1.FirecrackerSnapshotCompatibilityV1 - (*ExecutionCompatibilityProfile)(nil), // 25: space.execution.v1.ExecutionCompatibilityProfile - (*EnvironmentDescriptor)(nil), // 26: space.execution.v1.EnvironmentDescriptor - (*EnvironmentDefinition)(nil), // 27: space.execution.v1.EnvironmentDefinition + (*ExecutionPolicyObservation)(nil), // 18: space.execution.v1.ExecutionPolicyObservation + (*HostPlatform)(nil), // 19: space.execution.v1.HostPlatform + (*ManagedRuntimeRelease)(nil), // 20: space.execution.v1.ManagedRuntimeRelease + (*RuntimeBinding)(nil), // 21: space.execution.v1.RuntimeBinding + (*LinuxDirectBootV1)(nil), // 22: space.execution.v1.LinuxDirectBootV1 + (*EnvironmentComponent)(nil), // 23: space.execution.v1.EnvironmentComponent + (*BootProfile)(nil), // 24: space.execution.v1.BootProfile + (*FirecrackerSnapshotCompatibilityV1)(nil), // 25: space.execution.v1.FirecrackerSnapshotCompatibilityV1 + (*ExecutionCompatibilityProfile)(nil), // 26: space.execution.v1.ExecutionCompatibilityProfile + (*EnvironmentDescriptor)(nil), // 27: space.execution.v1.EnvironmentDescriptor + (*EnvironmentDefinition)(nil), // 28: space.execution.v1.EnvironmentDefinition } var file_execution_v1_execution_proto_depIdxs = []int32{ 0, // 0: space.execution.v1.RuntimeIdentity.kind:type_name -> space.execution.v1.RuntimeKind @@ -1841,30 +1929,31 @@ var file_execution_v1_execution_proto_depIdxs = []int32{ 12, // 12: space.execution.v1.ArtifactComponentInstallation.component:type_name -> space.execution.v1.ArtifactComponent 15, // 13: space.execution.v1.NodeExecutionInventory.runtimes:type_name -> space.execution.v1.RuntimeInstallation 16, // 14: space.execution.v1.NodeExecutionInventory.components:type_name -> space.execution.v1.ArtifactComponentInstallation - 5, // 15: space.execution.v1.HostPlatform.os:type_name -> space.execution.v1.HostOperatingSystem - 2, // 16: space.execution.v1.HostPlatform.architecture:type_name -> space.execution.v1.CpuArchitecture - 9, // 17: space.execution.v1.ManagedRuntimeRelease.runtime:type_name -> space.execution.v1.RuntimeIdentity - 18, // 18: space.execution.v1.ManagedRuntimeRelease.target:type_name -> space.execution.v1.HostPlatform - 8, // 19: space.execution.v1.ManagedRuntimeRelease.layout:type_name -> space.execution.v1.RuntimePayloadLayout - 19, // 20: space.execution.v1.RuntimeBinding.managed_release:type_name -> space.execution.v1.ManagedRuntimeRelease - 22, // 21: space.execution.v1.LinuxDirectBootV1.kernel:type_name -> space.execution.v1.EnvironmentComponent - 3, // 22: space.execution.v1.EnvironmentComponent.kind:type_name -> space.execution.v1.ArtifactComponentKind - 21, // 23: space.execution.v1.BootProfile.linux_direct_v1:type_name -> space.execution.v1.LinuxDirectBootV1 - 11, // 24: space.execution.v1.FirecrackerSnapshotCompatibilityV1.state_format:type_name -> space.execution.v1.StateFormatIdentity - 24, // 25: space.execution.v1.ExecutionCompatibilityProfile.firecracker_snapshot_v1:type_name -> space.execution.v1.FirecrackerSnapshotCompatibilityV1 - 18, // 26: space.execution.v1.EnvironmentDescriptor.host:type_name -> space.execution.v1.HostPlatform - 10, // 27: space.execution.v1.EnvironmentDescriptor.guest:type_name -> space.execution.v1.GuestPlatform - 20, // 28: space.execution.v1.EnvironmentDescriptor.runtime:type_name -> space.execution.v1.RuntimeBinding - 6, // 29: space.execution.v1.EnvironmentDescriptor.backend:type_name -> space.execution.v1.HypervisorBackend - 23, // 30: space.execution.v1.EnvironmentDescriptor.boot:type_name -> space.execution.v1.BootProfile - 7, // 31: space.execution.v1.EnvironmentDescriptor.isolation:type_name -> space.execution.v1.IsolationClass - 25, // 32: space.execution.v1.EnvironmentDescriptor.compatibility:type_name -> space.execution.v1.ExecutionCompatibilityProfile - 26, // 33: space.execution.v1.EnvironmentDefinition.descriptor:type_name -> space.execution.v1.EnvironmentDescriptor - 34, // [34:34] is the sub-list for method output_type - 34, // [34:34] is the sub-list for method input_type - 34, // [34:34] is the sub-list for extension type_name - 34, // [34:34] is the sub-list for extension extendee - 0, // [0:34] is the sub-list for field type_name + 18, // 15: space.execution.v1.NodeExecutionInventory.policy:type_name -> space.execution.v1.ExecutionPolicyObservation + 5, // 16: space.execution.v1.HostPlatform.os:type_name -> space.execution.v1.HostOperatingSystem + 2, // 17: space.execution.v1.HostPlatform.architecture:type_name -> space.execution.v1.CpuArchitecture + 9, // 18: space.execution.v1.ManagedRuntimeRelease.runtime:type_name -> space.execution.v1.RuntimeIdentity + 19, // 19: space.execution.v1.ManagedRuntimeRelease.target:type_name -> space.execution.v1.HostPlatform + 8, // 20: space.execution.v1.ManagedRuntimeRelease.layout:type_name -> space.execution.v1.RuntimePayloadLayout + 20, // 21: space.execution.v1.RuntimeBinding.managed_release:type_name -> space.execution.v1.ManagedRuntimeRelease + 23, // 22: space.execution.v1.LinuxDirectBootV1.kernel:type_name -> space.execution.v1.EnvironmentComponent + 3, // 23: space.execution.v1.EnvironmentComponent.kind:type_name -> space.execution.v1.ArtifactComponentKind + 22, // 24: space.execution.v1.BootProfile.linux_direct_v1:type_name -> space.execution.v1.LinuxDirectBootV1 + 11, // 25: space.execution.v1.FirecrackerSnapshotCompatibilityV1.state_format:type_name -> space.execution.v1.StateFormatIdentity + 25, // 26: space.execution.v1.ExecutionCompatibilityProfile.firecracker_snapshot_v1:type_name -> space.execution.v1.FirecrackerSnapshotCompatibilityV1 + 19, // 27: space.execution.v1.EnvironmentDescriptor.host:type_name -> space.execution.v1.HostPlatform + 10, // 28: space.execution.v1.EnvironmentDescriptor.guest:type_name -> space.execution.v1.GuestPlatform + 21, // 29: space.execution.v1.EnvironmentDescriptor.runtime:type_name -> space.execution.v1.RuntimeBinding + 6, // 30: space.execution.v1.EnvironmentDescriptor.backend:type_name -> space.execution.v1.HypervisorBackend + 24, // 31: space.execution.v1.EnvironmentDescriptor.boot:type_name -> space.execution.v1.BootProfile + 7, // 32: space.execution.v1.EnvironmentDescriptor.isolation:type_name -> space.execution.v1.IsolationClass + 26, // 33: space.execution.v1.EnvironmentDescriptor.compatibility:type_name -> space.execution.v1.ExecutionCompatibilityProfile + 27, // 34: space.execution.v1.EnvironmentDefinition.descriptor:type_name -> space.execution.v1.EnvironmentDescriptor + 35, // [35:35] is the sub-list for method output_type + 35, // [35:35] is the sub-list for method input_type + 35, // [35:35] is the sub-list for extension type_name + 35, // [35:35] is the sub-list for extension extendee + 0, // [0:35] is the sub-list for field type_name } func init() { file_execution_v1_execution_proto_init() } @@ -1874,13 +1963,14 @@ func file_execution_v1_execution_proto_init() { } file_execution_v1_execution_proto_msgTypes[3].OneofWrappers = []any{} file_execution_v1_execution_proto_msgTypes[4].OneofWrappers = []any{} - file_execution_v1_execution_proto_msgTypes[11].OneofWrappers = []any{ + file_execution_v1_execution_proto_msgTypes[9].OneofWrappers = []any{} + file_execution_v1_execution_proto_msgTypes[12].OneofWrappers = []any{ (*RuntimeBinding_ManagedRelease)(nil), } - file_execution_v1_execution_proto_msgTypes[14].OneofWrappers = []any{ + file_execution_v1_execution_proto_msgTypes[15].OneofWrappers = []any{ (*BootProfile_LinuxDirectV1)(nil), } - file_execution_v1_execution_proto_msgTypes[16].OneofWrappers = []any{ + file_execution_v1_execution_proto_msgTypes[17].OneofWrappers = []any{ (*ExecutionCompatibilityProfile_FirecrackerSnapshotV1)(nil), } type x struct{} @@ -1889,7 +1979,7 @@ func file_execution_v1_execution_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_execution_v1_execution_proto_rawDesc), len(file_execution_v1_execution_proto_rawDesc)), NumEnums: 9, - NumMessages: 19, + NumMessages: 20, NumExtensions: 0, NumServices: 0, },