Skip to content

Commit 46097fe

Browse files
authored
fix(nvue-client): Don't apply an unchanged config (#5789)
This is a backport to v2.1 of #5772, whose original text follows: Previously (#1965, #3047) we've tried to avoid updating the NVUE config via the API by detecting changes in the input data and skipping the apply logic when it's unchanged from the last time we updated it. However, there was still a gap in this logic since the agent process had to complete at least one NVUE config update before it would update its cache, so a newly-started agent process could still send NVUE an unchanged config, and now that we're actually polling the revision state (#4729), that turns out to be a big problem. This branch adds additional logic to the NVUE client so that we also detect a no-change config revision by asking NVUE for its own diff between what we're planning to apply and what it's currently running. We then skip the apply operation if the diff indicates a no-op. We do still potentially abandon one NVUE revision per agent process lifetime; I haven't convinced myself it's safe to try to remove the revision since HBN's OpenAPI spec flavor has patched this endpoint out. 😕 ## Related issues - #1965 - #3047 - #4729 - NVBugs ID 6718057 ## Type of Change - [ ] **Add** - New feature or capability - [ ] **Change** - Changes in existing functionality - [X] **Fix** - Bug fixes - [ ] **Remove** - Removed features or deprecated functionality - [ ] **Internal** - Internal changes (refactoring, tests, docs, etc.) ## Breaking Changes - [ ] **This PR contains breaking changes** ## Testing - [X] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] No testing required (docs, internal refactor, etc.) ## Additional Notes
1 parent 824b9f6 commit 46097fe

3 files changed

Lines changed: 141 additions & 18 deletions

File tree

crates/agent/src/ethernet_virtualization.rs

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,10 @@ impl NvueClientContext {
232232
}
233233

234234
// Wrap the inner nvue_client's `push_config()` and try to avoid re-applying
235-
// a configuration we're already using. Returns Ok(Some(revision_id)) on
236-
// a change, Ok(None) if the config was unchanged, and otherwise passes
237-
// through errors from the inner client.
238-
pub async fn update_config(
235+
// a configuration we're already using. Returns Ok(Some(revision_id)) when
236+
// a revision was applied, Ok(None) if the config was unchanged, and
237+
// otherwise passes through errors from the inner client.
238+
async fn update_config(
239239
&mut self,
240240
config: &NvueConfig,
241241
) -> Result<Option<String>, NvueClientError> {
@@ -246,13 +246,9 @@ impl NvueClientContext {
246246
{
247247
Ok(None)
248248
} else {
249-
self.nvue_client
250-
.push_config(config)
251-
.await
252-
.map(|revision_id| {
253-
self.last_applied_hash.replace(new_hash);
254-
Some(revision_id)
255-
})
249+
let revision_id = self.nvue_client.push_config(config).await?;
250+
self.last_applied_hash.replace(new_hash);
251+
Ok(revision_id)
256252
}
257253
}
258254
}

crates/nvue-client/src/client.rs

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ pub use serde_json::Value as JsonValue;
2626

2727
use crate::config::{NvueConfig, NvueConfigWithHeader, NvueRevision};
2828
use crate::types::bgp::{BgpNeighbors, BgpVrfInfo};
29-
use crate::types::revision::{RevisionApplyStatus, RevisionData, RevisionIssueSummary};
29+
use crate::types::revision::{
30+
RevisionApplyStatus, RevisionConfigDiff, RevisionData, RevisionIssueSummary,
31+
};
3032

3133
/// Repeated NVUE field-selection query parameters.
3234
///
@@ -199,6 +201,27 @@ impl NvueClient {
199201
Ok(nvue_config)
200202
}
201203

204+
/// Get the config diff between two NVUE revisions. The order of the
205+
/// arguments is significant; NVUE will return the set of changes necessary
206+
/// to turn `base_revision` into `target_revision`.
207+
pub async fn get_revision_config_diff(
208+
&self,
209+
base_revision: &str,
210+
target_revision: &str,
211+
) -> Result<RevisionConfigDiff, NvueClientError> {
212+
let mut request = self.request(Method::GET, "/nvue_v1/")?.build()?;
213+
request
214+
.url_mut()
215+
.query_pairs_mut()
216+
.append_pair("diff", base_revision)
217+
.append_pair("rev", target_revision)
218+
.append_pair("filled", "false");
219+
220+
let response = self.execute("get_revision_config_diff", request).await?;
221+
let diff = response.json().await?;
222+
Ok(diff)
223+
}
224+
202225
/// Return BGP data for a VRF.
203226
///
204227
/// This calls `GET /nvue_v1/vrf/{vrf-id}/router/bgp` without field filters.
@@ -360,15 +383,34 @@ impl NvueClient {
360383
}
361384
}
362385

363-
/// Create a new configuration using the values from `config`, then apply
364-
/// it, returning the revision ID. This is a convenience method that
365-
/// creates, replaces, and then applies the configuration (which a caller
366-
/// could do manually if more control is desired).
367-
pub async fn push_config(&self, config: &NvueConfig) -> Result<String, NvueClientError> {
386+
/// Perform all of the NVUE operations required to apply a new config. Under
387+
/// the hood, we create a new revision, patch it with this config, diff it
388+
/// against the "applied" revision, and then apply it if it differed.
389+
///
390+
/// Returns a `Some(revision_id)` if we applied the new revision, and `None`
391+
/// if NVUE indicated no change compared to the "applied" revision.
392+
pub async fn push_config(
393+
&self,
394+
config: &NvueConfig,
395+
) -> Result<Option<String>, NvueClientError> {
368396
let revision_id = self.create_config_revision().await?;
369397
self.replace_config_revision(&revision_id, config).await?;
398+
399+
// NVUE will not actually apply a revision if it's unchanged from what's
400+
// currently applied, we need to check this before we try.
401+
let diff = self
402+
.get_revision_config_diff("applied", &revision_id)
403+
.await?;
404+
if diff.is_empty() {
405+
// Note that we're leaving the old revision sitting around unused!
406+
// NVUE provides a `DELETE` operation on a revision ID, but HBN
407+
// patches this out in its OpenAPI spec For Some Reason(tm) so it's
408+
// unclear if it's safe to try it.
409+
return Ok(None);
410+
}
411+
370412
self.apply_config_revision(&revision_id).await?;
371-
Ok(revision_id)
413+
Ok(Some(revision_id))
372414
}
373415

374416
// Retrieve the system information from the NVUE server. The fields returned

crates/nvue-client/src/types/revision.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@ use std::collections::BTreeMap;
22

33
use serde_json::Value as JsonValue;
44

5+
/// The configuration difference returned by NVUE's config diff operation.
6+
#[derive(Clone, Debug, serde::Deserialize)]
7+
#[serde(transparent)]
8+
pub struct RevisionConfigDiff(
9+
// We're intentionally typing this fairly loosely for code brevity, since the
10+
// sole user of this is concerned only with whether the diff is empty.
11+
serde_json::Map<String, serde_json::Value>,
12+
);
13+
14+
impl RevisionConfigDiff {
15+
/// Return `true` for an empty diff, which means a no-op change between the
16+
/// revisions.
17+
pub fn is_empty(&self) -> bool {
18+
self.0.is_empty()
19+
}
20+
}
21+
522
#[derive(Clone, Debug, serde::Deserialize)]
623
#[serde(rename_all = "kebab-case")]
724
pub struct RevisionData {
@@ -97,6 +114,74 @@ pub enum RevisionIssueSeverity {
97114
mod tests {
98115
use super::*;
99116

117+
#[test]
118+
fn revision_config_diff_requires_object_root_and_reports_empty_root() {
119+
struct Case {
120+
name: &'static str,
121+
json: &'static str,
122+
expected: Option<(bool, JsonValue)>,
123+
}
124+
125+
let cases = [
126+
Case {
127+
name: "empty root object means no difference",
128+
json: r#"{}"#,
129+
expected: Some((true, serde_json::json!({}))),
130+
},
131+
Case {
132+
name: "nested null remains a significant removal",
133+
json: r#"{"interface":{"swp1":{"link":{"state":null}}}}"#,
134+
expected: Some((
135+
false,
136+
serde_json::json!({
137+
"interface": {
138+
"swp1": {
139+
"link": {
140+
"state": null
141+
}
142+
}
143+
}
144+
}),
145+
)),
146+
},
147+
Case {
148+
name: "null root is invalid",
149+
json: "null",
150+
expected: None,
151+
},
152+
Case {
153+
name: "array root is invalid",
154+
json: "[]",
155+
expected: None,
156+
},
157+
Case {
158+
name: "scalar root is invalid",
159+
json: r#""value""#,
160+
expected: None,
161+
},
162+
];
163+
164+
for case in cases {
165+
match (
166+
serde_json::from_str::<RevisionConfigDiff>(case.json),
167+
case.expected,
168+
) {
169+
(Ok(diff), Some((expected_empty, expected_value))) => {
170+
assert_eq!(diff.is_empty(), expected_empty, "{}", case.name);
171+
assert_eq!(JsonValue::Object(diff.0), expected_value, "{}", case.name);
172+
}
173+
(Err(_), None) => {}
174+
(Ok(_), None) => panic!("{}: expected deserialization to fail", case.name),
175+
(Err(error), Some(_)) => {
176+
panic!(
177+
"{}: expected deserialization to succeed: {error}",
178+
case.name
179+
)
180+
}
181+
}
182+
}
183+
}
184+
100185
#[test]
101186
fn classifies_revision_apply_status() {
102187
struct Case {

0 commit comments

Comments
 (0)