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, },