diff --git a/holo-daemon/src/northbound/client/api.rs b/holo-daemon/src/northbound/client/api.rs index 0b08345c..92688d10 100644 --- a/holo-daemon/src/northbound/client/api.rs +++ b/holo-daemon/src/northbound/client/api.rs @@ -42,6 +42,7 @@ pub mod client { pub struct GetRequest { pub data_type: DataType, pub path: Option, + pub exclude: Vec, pub responder: Responder>, } diff --git a/holo-daemon/src/northbound/client/gnmi.rs b/holo-daemon/src/northbound/client/gnmi.rs index b0213483..a5373085 100644 --- a/holo-daemon/src/northbound/client/gnmi.rs +++ b/holo-daemon/src/northbound/client/gnmi.rs @@ -133,6 +133,7 @@ impl proto::GNmi for GNmiService { let nb_request = api::client::GetRequest { data_type, path: Some(path), + exclude: vec![], responder: responder_tx, }; let nb_request = api::client::Request::Get(nb_request); @@ -400,6 +401,7 @@ impl GNmiService { let nb_request = api::client::GetRequest { data_type: api::DataType::Configuration, path: None, + exclude: vec![], responder: responder_tx, }; let nb_request = api::client::Request::Get(nb_request); @@ -417,6 +419,8 @@ impl From for Path { fn from(path: proto::Path) -> Self { Path { elems: path.elem.into_iter().map(PathElem::from).collect(), + // gNMI paths carry no depth limit. + max_depth: None, } } } diff --git a/holo-daemon/src/northbound/client/grpc.rs b/holo-daemon/src/northbound/client/grpc.rs index ed25a575..f977a466 100644 --- a/holo-daemon/src/northbound/client/grpc.rs +++ b/holo-daemon/src/northbound/client/grpc.rs @@ -174,9 +174,12 @@ impl proto::Northbound for NorthboundService { .map_err(|_| Status::invalid_argument("Invalid data encoding"))?; let with_defaults = grpc_request.with_defaults; let path = grpc_request.path.map(Path::from); + let exclude = + grpc_request.exclude.into_iter().map(Path::from).collect(); let nb_request = api::client::Request::Get(api::client::GetRequest { data_type, path, + exclude, responder: responder_tx, }); self.request_tx.send(nb_request).await.unwrap(); @@ -518,6 +521,8 @@ impl From for Path { fn from(path: proto::Path) -> Self { Path { elems: path.elem.into_iter().map(PathElem::from).collect(), + // Wire convention: 0 = unlimited. + max_depth: (path.max_depth != 0).then_some(path.max_depth), } } } diff --git a/holo-daemon/src/northbound/core.rs b/holo-daemon/src/northbound/core.rs index a3e1d30a..7bae0e98 100644 --- a/holo-daemon/src/northbound/core.rs +++ b/holo-daemon/src/northbound/core.rs @@ -180,7 +180,11 @@ impl Northbound { match request { capi::client::Request::Get(request) => { let response = self - .process_client_get(request.data_type, request.path) + .process_client_get( + request.data_type, + request.path, + request.exclude, + ) .await; let _ = request.responder.send(response); } @@ -236,13 +240,16 @@ impl Northbound { &self, data_type: capi::DataType, path: Option, + exclude: Vec, ) -> Result { let path = path.as_ref(); let dtree = match data_type { - capi::DataType::State => self.get_state(path).await?, + capi::DataType::State => self.get_state(path, exclude).await?, + // Filtering applies only to state data; configuration is + // returned as-is from the running datastore. capi::DataType::Configuration => self.get_configuration(path)?, capi::DataType::All => { - let mut dtree_state = self.get_state(path).await?; + let mut dtree_state = self.get_state(path, exclude).await?; let dtree_config = self.get_configuration(path)?; dtree_state .merge(&dtree_config) @@ -571,6 +578,7 @@ impl Northbound { async fn get_state( &self, path: Option<&Path>, + exclude: Vec, ) -> Result> { let yang_ctx = YANG_CTX.get().unwrap(); let mut dtree = DataTree::new(yang_ctx); @@ -581,6 +589,7 @@ impl Northbound { let request = papi::daemon::Request::Get(papi::daemon::GetRequest { path: path.cloned(), + exclude: exclude.clone(), responder: Some(responder_tx), }); daemon_tx.send(request).await.unwrap(); diff --git a/holo-northbound/src/api.rs b/holo-northbound/src/api.rs index 20ba5821..35e3ea1c 100644 --- a/holo-northbound/src/api.rs +++ b/holo-northbound/src/api.rs @@ -60,6 +60,8 @@ pub mod daemon { #[derive(Debug, Deserialize, Serialize)] pub struct GetRequest { pub path: Option, + #[serde(default)] + pub exclude: Vec, #[serde(skip)] pub responder: Option>>, } diff --git a/holo-northbound/src/lib.rs b/holo-northbound/src/lib.rs index 506f0062..80d3bb9d 100644 --- a/holo-northbound/src/lib.rs +++ b/holo-northbound/src/lib.rs @@ -74,6 +74,10 @@ pub struct YangPath(&'static str); pub struct Path { // Ordered list of elements forming the path. pub elems: Vec, + // Maximum traversal depth beyond the target. + // `None` = unlimited; `Some(0)` = target only, no descendants. + #[serde(default)] + pub max_depth: Option, } // A single element within a YANG data path. @@ -149,7 +153,10 @@ impl Path { elems.push(PathElem { name, keys }); } - Path { elems } + Path { + elems, + max_depth: None, + } } pub fn from_yang_path(s: &str) -> Path { @@ -182,7 +189,10 @@ impl Path { }) .collect(); - Path { elems } + Path { + elems, + max_depth: None, + } } } @@ -249,7 +259,8 @@ pub fn process_northbound_msg( } } api::daemon::Request::Get(request) => { - let response = state::process_get(provider, request.path); + let response = + state::process_get(provider, request.path, request.exclude); if let Some(responder) = request.responder { responder.send(response).unwrap(); } diff --git a/holo-northbound/src/state.rs b/holo-northbound/src/state.rs index b249541f..7f1c32fa 100644 --- a/holo-northbound/src/state.rs +++ b/holo-northbound/src/state.rs @@ -22,6 +22,15 @@ struct ResolvedPathElem<'a> { snode: SchemaNode<'static>, } +// Filter options applied during state retrieval. +struct GetFilter { + // Maximum traversal depth beyond the requested target. + // `None` = unlimited; `Some(0)` = target only, no descendants. + max_depth: Option, + // Subtrees to skip; matched by suffix on the schema path. + exclude: Vec, +} + // Northbound data provider. pub trait Provider where @@ -96,19 +105,28 @@ fn iterate_node<'a, P>( snode: &SchemaNode<'_>, list_entry: &P::ListEntry<'a>, relay_list: &mut Vec, + filter: &GetFilter, + depth: u32, ) -> Result<(), Error> where P: Provider, { match snode.kind() { SchemaNodeKind::List => { - iterate_list(provider, dnode, snode, list_entry, relay_list)?; + iterate_list( + provider, dnode, snode, list_entry, relay_list, filter, depth, + )?; } SchemaNodeKind::Container => { - iterate_container(provider, dnode, snode, list_entry, relay_list)?; + iterate_container( + provider, dnode, snode, list_entry, relay_list, filter, depth, + )?; } SchemaNodeKind::Choice | SchemaNodeKind::Case => { - iterate_children(provider, dnode, snode, list_entry, relay_list)?; + // Choice/case are transparent — no data node, depth unchanged. + iterate_children( + provider, dnode, snode, list_entry, relay_list, filter, depth, + )?; } _ => (), } @@ -122,6 +140,8 @@ fn iterate_list<'a, P>( snode: &SchemaNode<'_>, parent_list_entry: &P::ListEntry<'a>, relay_list: &mut Vec, + filter: &GetFilter, + depth: u32, ) -> Result<(), Error> where P: Provider, @@ -151,6 +171,8 @@ where snode, &list_entry, relay_list, + filter, + depth, )?; } } @@ -164,6 +186,8 @@ fn iterate_container<'a, P>( snode: &SchemaNode<'_>, list_entry: &P::ListEntry<'a>, relay_list: &mut Vec, + filter: &GetFilter, + depth: u32, ) -> Result<(), Error> where P: Provider, @@ -180,7 +204,15 @@ where obj.into_data_node(&mut child); } - iterate_children(provider, &mut child, snode, list_entry, relay_list)?; + iterate_children( + provider, + &mut child, + snode, + list_entry, + relay_list, + filter, + depth, + )?; // Remove empty containers that produced no children. if child.children().next().is_none() { @@ -196,10 +228,23 @@ fn iterate_children<'a, P>( snode: &SchemaNode<'_>, list_entry: &P::ListEntry<'a>, relay_list: &mut Vec, + filter: &GetFilter, + depth: u32, ) -> Result<(), Error> where P: Provider, { + // Depth check: stop descending once max_depth reached. Choice/case + // are transparent and do not consume depth. + let is_transparent = + matches!(snode.kind(), SchemaNodeKind::Choice | SchemaNodeKind::Case); + if !is_transparent + && let Some(max) = filter.max_depth + && depth >= max + { + return Ok(()); + } + for snode in snode.children().filter(|snode| { matches!( snode.kind(), @@ -209,6 +254,11 @@ where | SchemaNodeKind::Case ) }) { + // Skip excluded subtrees. + if exclude_matches_any(&snode, &filter.exclude) { + continue; + } + // Check if the provider implements the child node. let module = snode.module(); if let Some(child_nb_tx) = list_entry.child_task(module.name()) { @@ -217,21 +267,49 @@ where name: format!("{}:{}", module.name(), snode.name()), keys: HashMap::new(), }); - let relay_rx = relay_request(child_nb_tx, path); + // The relayed target is this child, which sits one level + // below `dnode` in the original tree. + let relay_rx = + relay_request(child_nb_tx, path, filter, depth + 1); relay_list.push(relay_rx); continue; } - iterate_node(provider, dnode, &snode, list_entry, relay_list)?; + // Choice/case are transparent: depth does not advance through them. + let child_depth = match snode.kind() { + SchemaNodeKind::Choice | SchemaNodeKind::Case => depth, + _ => depth + 1, + }; + iterate_node( + provider, + dnode, + &snode, + list_entry, + relay_list, + filter, + child_depth, + )?; } Ok(()) } -fn relay_request(nb_tx: NbDaemonSender, path: Path) -> GetReceiver { +fn relay_request( + nb_tx: NbDaemonSender, + mut path: Path, + filter: &GetFilter, + target_depth_in_original: u32, +) -> GetReceiver { + // Shrink the depth budget by the depth of the relayed target relative + // to the original request's target. `None` (unlimited) stays unlimited. + path.max_depth = filter + .max_depth + .map(|max| max.saturating_sub(target_depth_in_original)); + let (responder_tx, responder_rx) = oneshot::channel(); let request = api::daemon::GetRequest { path: Some(path), + exclude: filter.exclude.clone(), responder: Some(responder_tx), }; tokio::task::spawn(async move { @@ -243,6 +321,52 @@ fn relay_request(nb_tx: NbDaemonSender, path: Path) -> GetReceiver { responder_rx } +// Matches `pattern` against a candidate schema node. Pattern may be a bare +// node name ("rib") or module-qualified ("ietf-bgp:rib"). +fn name_matches(snode: &SchemaNode<'_>, pattern: &str) -> bool { + if let Some((module, name)) = pattern.split_once(':') { + snode.module().name() == module && snode.name() == name + } else { + snode.name() == pattern + } +} + +// Returns true if `snode`'s data path ends with the elements of `exclude`. +// +// Single-element excludes match the node by name at any depth. Multi-element +// excludes match a contiguous tail of the data path. Choice/case schema +// nodes are skipped during ancestor traversal so excludes mirror the data +// tree shape rather than the schema tree. +fn exclude_matches_path(snode: &SchemaNode<'_>, exclude: &Path) -> bool { + let n = exclude.elems.len(); + if n == 0 { + return false; + } + + let mut pat_iter = exclude.elems.iter().rev(); + let last = pat_iter.next().unwrap(); + if !name_matches(snode, &last.name) { + return false; + } + + let mut ancestors = snode.ancestors().filter(|a| { + !matches!(a.kind(), SchemaNodeKind::Choice | SchemaNodeKind::Case) + }); + for pat in pat_iter { + let Some(ancestor) = ancestors.next() else { + return false; + }; + if !name_matches(&ancestor, &pat.name) { + return false; + } + } + true +} + +fn exclude_matches_any(snode: &SchemaNode<'_>, excludes: &[Path]) -> bool { + excludes.iter().any(|e| exclude_matches_path(snode, e)) +} + // Resolves each path element to its schema node, validating key names. fn resolve_path<'a>( path: &'a Path, @@ -282,6 +406,7 @@ fn expand_path<'a, P>( remaining: &[ResolvedPathElem<'_>], list_entry: P::ListEntry<'a>, relay_list: &mut Vec, + filter: &GetFilter, ) -> Result<(), Error> where P: Provider, @@ -294,8 +419,11 @@ where .unwrap(); let module = snode.module(); if let Some(child_nb_tx) = list_entry.child_task(module.name()) { + // Path expansion exhausted at a foreign-module target — relay + // with the full filter budget intact (depth is measured from + // this target, which equals the original target). let path = Path::from_dnode(parent_dnode); - let relay_rx = relay_request(child_nb_tx, path); + let relay_rx = relay_request(child_nb_tx, path, filter, 0); relay_list.push(relay_rx); } else { if snode.kind() == SchemaNodeKind::Container { @@ -314,6 +442,8 @@ where &snode, &list_entry, relay_list, + filter, + 0, )?; } return Ok(()); @@ -325,9 +455,11 @@ where // Relay to child task if this node is owned by a different provider. let module = snode.module(); if let Some(child_nb_tx) = list_entry.child_task(module.name()) { + // We are still walking the requested path, so the full filter + // budget passes through unchanged. let mut path = Path::from_dnode(parent_dnode); path.elems.extend(remaining.iter().map(|r| r.elem.clone())); - let relay_rx = relay_request(child_nb_tx, path); + let relay_rx = relay_request(child_nb_tx, path, filter, 0); relay_list.push(relay_rx); return Ok(()); } @@ -368,7 +500,14 @@ where } let relay_count = relay_list.len(); - expand_path(provider, &mut child, rest, entry, relay_list)?; + expand_path( + provider, + &mut child, + rest, + entry, + relay_list, + filter, + )?; // Prune entries with only keys and no actual data. if relay_list.len() == relay_count @@ -393,6 +532,7 @@ where rest, Default::default(), relay_list, + filter, )?; if relay_list.len() == relay_count @@ -418,7 +558,14 @@ where } } - expand_path(provider, &mut child, rest, list_entry, relay_list)?; + expand_path( + provider, + &mut child, + rest, + list_entry, + relay_list, + filter, + )?; if child.children().next().is_none() { child.remove(); @@ -435,6 +582,7 @@ where pub(crate) fn process_get

( provider: &P, path: Option, + exclude: Vec, ) -> Result where P: Provider, @@ -446,6 +594,10 @@ where let path = path .filter(|path| !path.elems.is_empty()) .unwrap_or_else(|| Path::from_yang_path(&provider.top_level_node())); + let filter = GetFilter { + max_depth: path.max_depth, + exclude, + }; let resolved = resolve_path(&path)?; // Create the root data node and expand the remaining path. @@ -460,6 +612,7 @@ where &resolved[1..], Default::default(), &mut relay_list, + &filter, )?; // Merge responses from child tasks. diff --git a/holo-protocol/src/test/stub/northbound.rs b/holo-protocol/src/test/stub/northbound.rs index cb520f98..733fc227 100644 --- a/holo-protocol/src/test/stub/northbound.rs +++ b/holo-protocol/src/test/stub/northbound.rs @@ -127,6 +127,7 @@ impl NorthboundStub { let (responder_tx, responder_rx) = oneshot::channel(); let request = api::daemon::Request::Get(api::daemon::GetRequest { path: None, + exclude: vec![], responder: Some(responder_tx), }); diff --git a/proto/holo.proto b/proto/holo.proto index 4af9a94f..de334cad 100644 --- a/proto/holo.proto +++ b/proto/holo.proto @@ -132,6 +132,13 @@ message GetRequest { // Target YANG path. Path path = 4; + + // YANG paths to exclude from the response (matched at any depth). + // A single-element path with just a name (optionally module-qualified + // as "module:name") matches any node with that name. Multi-element + // paths match a specific subtree wherever it occurs. + // Applies only to state data. + repeated Path exclude = 5; } message GetResponse { @@ -313,6 +320,11 @@ enum SchemaFormat { message Path { // Ordered list of elements forming the path. repeated PathElem elem = 1; + + // Maximum depth of the YANG tree traversal beyond the requested path + // (0 = unlimited). A value of 1 returns only immediate children of + // the target. Applies only to state data. + uint32 max_depth = 2; } // A single element within a YANG data path.