Skip to content

Commit cfe9fde

Browse files
authored
Merge pull request bottlerocket-os#716 from bcressey/canonical-settings
add apiclient support for canonical settings
2 parents 0ce5c01 + 54c4cd1 commit cfe9fde

File tree

6 files changed

+428
-33
lines changed

6 files changed

+428
-33
lines changed

sources/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sources/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ num = "0.4"
166166
num-derive = "0.4"
167167
num-traits = "0.2"
168168
num_cpus = "1"
169+
olpc-cjson = "0.1"
169170
once_cell = "1"
170171
pathdiff = "0.2"
171172
pentacle = "1"

sources/api/apiclient/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ libc.workspace = true
3030
log.workspace = true
3131
models.workspace = true
3232
nix.workspace = true
33+
olpc-cjson.workspace = true
3334
rand = { workspace = true, features = ["default"] }
3435
reqwest.workspace = true
3536
retry-read.workspace = true

sources/api/apiclient/src/get.rs

Lines changed: 268 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@ use merge_json::merge_json;
66

77
/// Fetches the given prefixes from the API and merges them into a single Value. (It's not
88
/// expected that given prefixes would overlap, but if they do, later ones take precedence.)
9-
pub async fn get_prefixes<P>(socket_path: P, prefixes: Vec<String>) -> Result<serde_json::Value>
9+
/// Excludes any keys matching the exclude prefixes from the final result.
10+
pub async fn get_prefixes<P>(
11+
socket_path: P,
12+
include: Vec<String>,
13+
exclude: Vec<String>,
14+
) -> Result<serde_json::Value>
1015
where
1116
P: AsRef<Path>,
1217
{
13-
let mut results: Vec<serde_json::Value> = Vec::with_capacity(prefixes.len());
18+
let mut results: Vec<serde_json::Value> = Vec::with_capacity(include.len());
1419

1520
// Fetch all given prefixes into separate Values.
16-
for prefix in prefixes {
21+
for prefix in include {
1722
let uri = format!("/?prefix={prefix}");
1823
let method = "GET";
1924
let (_status, body) = crate::raw_request(&socket_path, &uri, method, None)
@@ -24,13 +29,79 @@ where
2429
}
2530

2631
// Merge results together.
27-
results
32+
let mut merged = results
2833
.into_iter()
2934
.reduce(|mut merge_into, merge_from| {
3035
merge_json(&mut merge_into, merge_from);
3136
merge_into
3237
})
33-
.context(error::NoPrefixesSnafu)
38+
.context(error::NoPrefixesSnafu)?;
39+
40+
// Remove excluded prefixes
41+
if !exclude.is_empty() {
42+
remove_prefixes(&mut merged, &exclude);
43+
}
44+
45+
Ok(merged)
46+
}
47+
48+
/// Removes keys matching any of the given prefixes from the JSON value.
49+
/// Uses dotted key notation (e.g., "settings.network" matches "settings.network.hostname").
50+
/// Prefixes without trailing dots match any key starting with that prefix.
51+
/// Prefixes with trailing dots only match keys with that exact prefix followed by more path segments.
52+
fn remove_prefixes(value: &mut serde_json::Value, prefixes: &[String]) {
53+
remove_prefixes_recursive(value, "", prefixes);
54+
}
55+
56+
/// Recursively removes keys from JSON, tracking the dotted path as we traverse.
57+
/// Returns true if the object is empty after removing any prefix matches, and should be removed.
58+
/// Returns false otherwise.
59+
fn remove_prefixes_recursive(
60+
value: &mut serde_json::Value,
61+
parent_path: &str,
62+
prefixes: &[String],
63+
) -> bool {
64+
let serde_json::Value::Object(map) = value else {
65+
// Non-object values shouldn't be removed and can't have children
66+
return false;
67+
};
68+
69+
// Build paths once and determine which keys to remove
70+
let mut keys_to_remove = Vec::new();
71+
let mut keys_to_recurse = Vec::new();
72+
73+
for key in map.keys() {
74+
let full_path = if parent_path.is_empty() {
75+
key.to_string()
76+
} else {
77+
format!("{}.{}", parent_path, key)
78+
};
79+
80+
let should_remove = prefixes.iter().any(|prefix| full_path.starts_with(prefix));
81+
82+
if should_remove {
83+
keys_to_remove.push(key.clone());
84+
} else {
85+
keys_to_recurse.push((key.clone(), full_path));
86+
}
87+
}
88+
89+
// Remove matching keys
90+
for key in keys_to_remove {
91+
map.remove(&key);
92+
}
93+
94+
// Recursively process remaining nested objects and remove if empty
95+
for (key, full_path) in keys_to_recurse {
96+
if let Some(nested_value) = map.get_mut(&key) {
97+
let should_remove = remove_prefixes_recursive(nested_value, &full_path, prefixes);
98+
if should_remove {
99+
map.remove(&key);
100+
}
101+
}
102+
}
103+
104+
map.is_empty()
34105
}
35106

36107
/// Fetches the given URI from the API and returns the result as an untyped Value.
@@ -71,3 +142,195 @@ mod error {
71142
}
72143
pub use error::Error;
73144
pub type Result<T> = std::result::Result<T, error::Error>;
145+
146+
#[cfg(test)]
147+
mod test {
148+
use super::*;
149+
use serde_json::json;
150+
151+
#[test]
152+
fn test_remove_prefixes_simple() {
153+
let mut value = json!({
154+
"settings": {
155+
"motd": "hello",
156+
"network": {"hostname": "test"},
157+
"kubernetes": {"cluster-name": "dev"}
158+
}
159+
});
160+
161+
remove_prefixes(&mut value, &["settings.network".to_string()]);
162+
163+
assert_eq!(
164+
value,
165+
json!({
166+
"settings": {
167+
"motd": "hello",
168+
"kubernetes": {"cluster-name": "dev"}
169+
}
170+
})
171+
);
172+
}
173+
174+
#[test]
175+
fn test_remove_prefixes_nested() {
176+
let mut value = json!({
177+
"settings": {
178+
"motd": "hello",
179+
"network": {"hostname": "test"},
180+
"host-containers": {
181+
"admin": {
182+
"enabled": true,
183+
"source": "example.com/admin:v1"
184+
},
185+
"control": {
186+
"enabled": true,
187+
"source": "example.com/control:v1"
188+
}
189+
},
190+
"kubernetes": {
191+
"cluster-name": "dev"
192+
}
193+
}
194+
});
195+
196+
remove_prefixes(
197+
&mut value,
198+
&[
199+
"settings.network".to_string(),
200+
"settings.host-containers.admin".to_string(),
201+
],
202+
);
203+
204+
assert_eq!(
205+
value,
206+
json!({
207+
"settings": {
208+
"motd": "hello",
209+
"host-containers": {
210+
"control": {
211+
"enabled": true,
212+
"source": "example.com/control:v1"
213+
}
214+
},
215+
"kubernetes": {
216+
"cluster-name": "dev"
217+
}
218+
}
219+
})
220+
);
221+
}
222+
223+
#[test]
224+
fn test_remove_prefixes_no_match() {
225+
let mut value = json!({
226+
"settings": {
227+
"motd": "hello"
228+
}
229+
});
230+
231+
let expected = value.clone();
232+
remove_prefixes(&mut value, &["settings.network".to_string()]);
233+
234+
assert_eq!(value, expected);
235+
}
236+
237+
#[test]
238+
fn test_remove_prefixes_exact_dotted_path() {
239+
// Test the specific case: {"settings":{"network":{"hostname":"foo"}}}
240+
// with exclude prefix "settings.network" should remove the entire network subtree
241+
let mut value = json!({
242+
"settings": {
243+
"network": {
244+
"hostname": "foo"
245+
}
246+
}
247+
});
248+
249+
remove_prefixes(&mut value, &["settings.network".to_string()]);
250+
251+
// After removing "network", "settings" becomes empty and is also removed
252+
assert_eq!(value, json!({}));
253+
}
254+
255+
#[test]
256+
fn test_remove_prefixes_trailing_dot() {
257+
// Test that "settings.network." with trailing dot only matches children, not the key itself
258+
let mut value = json!({
259+
"settings": {
260+
"network": {
261+
"hostname": "foo"
262+
},
263+
"network-connections": {
264+
"eth0": "up"
265+
},
266+
"motd": "hello"
267+
}
268+
});
269+
270+
remove_prefixes(&mut value, &["settings.network.".to_string()]);
271+
272+
// Trailing dot means only children of "network" are removed
273+
// Since "network" becomes empty, it should also be removed
274+
assert_eq!(
275+
value,
276+
json!({
277+
"settings": {
278+
"network-connections": {
279+
"eth0": "up"
280+
},
281+
"motd": "hello"
282+
}
283+
})
284+
);
285+
}
286+
287+
#[test]
288+
fn test_remove_prefixes_similar_names() {
289+
// Test that "settings.network" (no trailing dot) matches both "network" and "network-connections"
290+
let mut value = json!({
291+
"settings": {
292+
"network": {
293+
"hostname": "foo"
294+
},
295+
"network-connections": {
296+
"eth0": "up"
297+
},
298+
"motd": "hello"
299+
}
300+
});
301+
302+
remove_prefixes(&mut value, &["settings.network".to_string()]);
303+
304+
// Without trailing dot, matches "network" exactly and "network-connections" by prefix
305+
assert_eq!(
306+
value,
307+
json!({
308+
"settings": {
309+
"motd": "hello"
310+
}
311+
})
312+
);
313+
}
314+
315+
#[test]
316+
fn test_remove_prefixes_trailing_dot_non_map() {
317+
// Test that "settings.motd." with trailing dot should NOT remove "settings.motd" when motd is a string
318+
let mut value = json!({
319+
"settings": {
320+
"motd": "hello"
321+
}
322+
});
323+
324+
remove_prefixes(&mut value, &["settings.motd.".to_string()]);
325+
326+
// motd is not a map, so "settings.motd." should not match it
327+
assert_eq!(
328+
value,
329+
json!({
330+
"settings": {
331+
"motd": "hello"
332+
}
333+
})
334+
);
335+
}
336+
}

0 commit comments

Comments
 (0)