From 3cb50079d52da3e47b72c5267c270064169ca84f Mon Sep 17 00:00:00 2001 From: Drew Bloechl Date: Wed, 27 May 2026 14:00:38 -0700 Subject: [PATCH 1/2] nvue-client: Add hash method to NvueConfig --- crates/nvue-client/src/config.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/nvue-client/src/config.rs b/crates/nvue-client/src/config.rs index 274801a973..92b294275d 100644 --- a/crates/nvue-client/src/config.rs +++ b/crates/nvue-client/src/config.rs @@ -15,9 +15,11 @@ * limitations under the License. */ +use std::hash::{DefaultHasher, Hash, Hasher}; + use crate::client::NvueClientError; -#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, Hash, serde::Deserialize, serde::Serialize)] #[serde(transparent)] pub struct NvueConfig { // FIXME: Replace this with a more strongly typed inner representation @@ -67,6 +69,12 @@ impl NvueConfig { Ok(None) } } + + pub fn u64_hash(&self) -> u64 { + let mut h = DefaultHasher::new(); + self.hash(&mut h); + h.finish() + } } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] From 4899766d7d7b5341771dcb1099dd6bb053cd3e42 Mon Sep 17 00:00:00 2001 From: Drew Bloechl Date: Wed, 27 May 2026 14:10:40 -0700 Subject: [PATCH 2/2] agent: Avoid re-applying unchanged NVUE config --- crates/agent/src/ethernet_virtualization.rs | 68 ++++++++++++++++++--- crates/agent/src/main_loop.rs | 28 +++++---- 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/crates/agent/src/ethernet_virtualization.rs b/crates/agent/src/ethernet_virtualization.rs index 18ced96b16..e1495eb82c 100644 --- a/crates/agent/src/ethernet_virtualization.rs +++ b/crates/agent/src/ethernet_virtualization.rs @@ -34,6 +34,7 @@ use carbide_network::ip::prefix::Ipv4Net; use carbide_network::virtualization::{VpcVirtualizationType, build_dual_stack_list}; use eyre::WrapErr; use mac_address::MacAddress; +use nvue_client::client::NvueClientError; use nvue_client::{NvueClient, NvueConfig}; use serde::Deserialize; use tokio::process::Command as TokioCommand; @@ -145,8 +146,54 @@ struct PostAction { } pub enum NvueUpdateFlavor<'a> { - StartupFile { hbn_root: &'a Path, skip_post: bool }, - RestApi { nvue_client: &'a NvueClient }, + StartupFile { + hbn_root: &'a Path, + skip_post: bool, + }, + RestApi { + nvue_context: &'a mut NvueClientContext, + }, +} + +/// The NVUE client and other information associated with it. +pub struct NvueClientContext { + pub nvue_client: NvueClient, + pub last_applied_hash: Option, +} + +impl NvueClientContext { + pub fn new(nvue_client: NvueClient) -> Self { + let last_applied_hash = None; + Self { + nvue_client, + last_applied_hash, + } + } + + // Wrap the inner nvue_client's `push_config()` and try to avoid re-applying + // a configuration we're already using. Returns Ok(Some(revision_id)) on + // a change, Ok(None) if the config was unchanged, and otherwise passes + // through errors from the inner client. + pub async fn update_config( + &mut self, + config: &NvueConfig, + ) -> Result, NvueClientError> { + let new_hash = config.u64_hash(); + + if let Some(last_applied_hash) = self.last_applied_hash + && new_hash == last_applied_hash + { + Ok(None) + } else { + self.nvue_client + .push_config(config) + .await + .map(|revision_id| { + self.last_applied_hash.replace(new_hash); + Some(revision_id) + }) + } + } } /// Converts an RPC routing profile into the NVUE renderer model. @@ -197,7 +244,8 @@ pub async fn update_nvue( ) -> eyre::Result { let hbn_version = match update_flavor { NvueUpdateFlavor::StartupFile { .. } => hbn::read_version().await?, - NvueUpdateFlavor::RestApi { nvue_client } => nvue_client + NvueUpdateFlavor::RestApi { ref nvue_context } => nvue_context + .nvue_client .system_build_info() .await .map_err(|e| eyre::eyre!("Couldn't get HBN version from NVUE: {e}")) @@ -570,15 +618,19 @@ pub async fn update_nvue( } Ok(true) } - NvueUpdateFlavor::RestApi { nvue_client } => { + NvueUpdateFlavor::RestApi { nvue_context } => { let config = NvueConfig::from_yaml(&next_contents) .map_err(|e| eyre::eyre!("Couldn't parse NVUE config as YAML: {e}"))?; - let revision_id = nvue_client - .push_config(&config) + let revision_id = nvue_context + .update_config(&config) .await .map_err(|e| eyre::eyre!("Couldn't push new config to NVUE server: {e}"))?; - tracing::debug!(revision_id, "Applied NVUE config via REST API"); - Ok(true) + if let Some(revision_id) = revision_id { + tracing::debug!(revision_id, "Applied NVUE config via REST API"); + Ok(true) + } else { + Ok(false) + } } } } diff --git a/crates/agent/src/main_loop.rs b/crates/agent/src/main_loop.rs index 262f95214f..a02e307249 100644 --- a/crates/agent/src/main_loop.rs +++ b/crates/agent/src/main_loop.rs @@ -49,7 +49,7 @@ use crate::dpu::interface::Interface; use crate::dpu::route::{DpuRoutePlan, IpRoute, Route}; use crate::duppet::{SummaryFormat, SyncOptions}; use crate::ethernet_virtualization::{ - InterfaceTranslationMode, NvueUpdateFlavor, ServiceAddresses, + InterfaceTranslationMode, NvueClientContext, NvueUpdateFlavor, ServiceAddresses, }; use crate::fmds_client::FmdsUpdater; use crate::health::HealthCheckParams; @@ -221,11 +221,12 @@ pub async fn setup_and_run( // We have eight cores. Letting ovs_vswitchd have one is OK. }; - let nvue_client = match options.hbn_config_mode { + let nvue_context = match options.hbn_config_mode { HbnConfigMode::ContainerExec => None, HbnConfigMode::NvueRest => { let nvue_client = nvue_client::NvueClient::new_https_from_env()?; - Some(nvue_client) + let nvue_context = NvueClientContext::new(nvue_client); + Some(nvue_context) } }; @@ -376,7 +377,7 @@ pub async fn setup_and_run( close_sender, network_monitor_handle, extension_service_manager, - nvue_client, + nvue_context, dhcp_interface_translation_mode, }; @@ -409,7 +410,7 @@ struct MainLoop { network_monitor_handle: Option>, close_sender: watch::Sender, extension_service_manager: extension_services::ExtensionServiceManager, - nvue_client: Option, + nvue_context: Option, dhcp_interface_translation_mode: Option, } @@ -558,10 +559,11 @@ impl MainLoop { if self.is_hbn_up { // First thing is to read the existing HBN version and properly set the hbn device names // associated with that version. - let hbn_version = match self.nvue_client.as_mut() { + let hbn_version = match self.nvue_context.as_mut() { None => hbn::read_version().await?, - Some(nvue_client) => { - let nvue_system_build = nvue_client.system_build_info().await?; + Some(nvue_context) => { + let nvue_system_build = + nvue_context.nvue_client.system_build_info().await?; match nvue_system_build.strip_prefix("HBN ") { Some(hbn_version) => Ok(hbn_version.into()), None => Err(eyre::format_err!( @@ -680,8 +682,8 @@ impl MainLoop { }; if bridging_result.is_ok() { - let update_flavor = match self.nvue_client.as_ref() { - Some(nvue_client) => NvueUpdateFlavor::RestApi { nvue_client }, + let update_flavor = match self.nvue_context.as_mut() { + Some(nvue_context) => NvueUpdateFlavor::RestApi { nvue_context }, None => NvueUpdateFlavor::StartupFile { hbn_root: &self.agent_config.hbn.root_dir, skip_post: self.agent_config.hbn.skip_reload, @@ -783,7 +785,7 @@ impl MainLoop { match ethernet_virtualization::interfaces( &conf, self.factory_mac_address, - self.nvue_client.as_ref(), + self.nvue_context.as_ref().map(|c| &c.nvue_client), ) .await { @@ -826,7 +828,7 @@ impl MainLoop { current_instance_config_version = status_out.instance_config_version.clone(); current_instance_id = status_out.instance_id.as_ref().map(|id| id.to_string()); - let health_report = match self.nvue_client.as_ref() { + let health_report = match self.nvue_context.as_ref() { None => { health::health_check(HealthCheckParams { hbn_root: &self.agent_config.hbn.root_dir, @@ -840,7 +842,7 @@ impl MainLoop { }) .await } - Some(nvue_client) => health::nvue_api_health(nvue_client).await, + Some(nvue_context) => health::nvue_api_health(&nvue_context.nvue_client).await, }; is_healthy = !health_report.successes.is_empty() && health_report.alerts.is_empty(); self.is_hbn_up = health::is_up(&health_report);