Skip to content

Commit 19223a7

Browse files
committed
feat: config overrides for structured config files
1 parent 7486017 commit 19223a7

4 files changed

Lines changed: 393 additions & 37 deletions

File tree

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
//! Building-block types for strategy-based `configOverrides`.
2+
//!
3+
//! Operators declare typed override structs choosing patch strategies per file
4+
//! (e.g. [`JsonConfigOverrides`] for JSON files, [`KeyValueConfigOverrides`] for
5+
//! properties files). The types here are composed by each operator into its
6+
//! CRD-specific `configOverrides` struct.
7+
8+
use std::collections::{BTreeMap, HashMap};
9+
10+
use schemars::{JsonSchema, Schema, json_schema};
11+
use serde::{Deserialize, Serialize};
12+
use snafu::{ResultExt, Snafu};
13+
14+
/// Generates a JSON schema that accepts any JSON value.
15+
///
16+
/// Kubernetes CRDs do not support the `true` schema shorthand that
17+
/// `serde_json::Value` generates by default. Instead we emit a schema
18+
/// with `x-kubernetes-preserve-unknown-fields: true` which tells the
19+
/// API server to store the value as-is.
20+
fn arbitrary_json_value(_gen: &mut schemars::generate::SchemaGenerator) -> Schema {
21+
json_schema!({
22+
"x-kubernetes-preserve-unknown-fields": true,
23+
})
24+
}
25+
26+
/// Generates a JSON schema for a list of JSON patch operation strings (RFC 6902).
27+
fn json_patch_string_list(_gen: &mut schemars::generate::SchemaGenerator) -> Schema {
28+
json_schema!({
29+
"type": "array",
30+
"items": {
31+
"type": "string",
32+
},
33+
})
34+
}
35+
36+
#[derive(Debug, Snafu)]
37+
pub enum Error {
38+
#[snafu(display("failed to serialize base document to JSON"))]
39+
SerializeBaseDocument { source: serde_json::Error },
40+
41+
#[snafu(display("failed to apply JSON patch (RFC 6902)"))]
42+
ApplyJsonPatch { source: json_patch::PatchError },
43+
44+
#[snafu(display("failed to deserialize JSON patch operation {index} from string"))]
45+
DeserializeJsonPatchOperation {
46+
source: serde_json::Error,
47+
index: usize,
48+
},
49+
50+
#[snafu(display("failed to parse user-provided JSON content"))]
51+
ParseUserProvidedJson { source: serde_json::Error },
52+
}
53+
54+
/// Trait that allows the product config pipeline to extract flat key-value
55+
/// overrides from any `configOverrides` type.
56+
///
57+
/// The default `HashMap<String, HashMap<String, String>>` implements this
58+
/// by looking up the file name and returning its entries. Typed override
59+
/// structs that have no key-value files can use the default implementation,
60+
/// which returns an empty map.
61+
pub trait KeyValueOverridesProvider {
62+
fn get_key_value_overrides(&self, _file: &str) -> BTreeMap<String, Option<String>> {
63+
BTreeMap::new()
64+
}
65+
}
66+
67+
impl KeyValueOverridesProvider
68+
for HashMap<String, HashMap<String, String>>
69+
{
70+
fn get_key_value_overrides(&self, file: &str) -> BTreeMap<String, Option<String>> {
71+
self.get(file)
72+
.map(|entries| {
73+
entries
74+
.iter()
75+
.map(|(k, v)| (k.clone(), Some(v.clone())))
76+
.collect()
77+
})
78+
.unwrap_or_default()
79+
}
80+
}
81+
82+
/// Flat key-value overrides for `*.properties`, Hadoop XML, etc.
83+
///
84+
/// This is backwards-compatible with the existing flat key-value YAML format
85+
/// used by `HashMap<String, String>`.
86+
#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
87+
pub struct KeyValueConfigOverrides {
88+
#[serde(flatten)]
89+
pub overrides: BTreeMap<String, String>,
90+
}
91+
92+
impl KeyValueConfigOverrides {
93+
/// Returns the overrides as a `BTreeMap<String, Option<String>>`, matching
94+
/// the format expected by the product config pipeline.
95+
///
96+
/// This is useful when implementing [`KeyValueOverridesProvider`] for a
97+
/// typed override struct that contains [`KeyValueConfigOverrides`] fields.
98+
pub fn as_overrides(&self) -> BTreeMap<String, Option<String>> {
99+
self.overrides
100+
.iter()
101+
.map(|(k, v)| (k.clone(), Some(v.clone())))
102+
.collect()
103+
}
104+
}
105+
106+
/// ConfigOverrides that can be applied to a JSON file.
107+
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
108+
#[serde(rename_all = "camelCase")]
109+
pub enum JsonConfigOverrides {
110+
/// Can be set to arbitrary YAML content, which is converted to JSON and used as
111+
/// [RFC 7396](https://datatracker.ietf.org/doc/html/rfc7396) JSON merge patch.
112+
#[schemars(schema_with = "arbitrary_json_value")]
113+
JsonMergePatch(serde_json::Value),
114+
115+
/// List of [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) JSON patches.
116+
///
117+
/// Can be used when more flexibility is needed, e.g. to only modify elements
118+
/// in a list based on a condition.
119+
///
120+
/// A patch looks something like
121+
///
122+
/// `{"op": "test", "path": "/0/name", "value": "Andrew"}`
123+
///
124+
/// or
125+
///
126+
/// `{"op": "add", "path": "/0/happy", "value": true}`
127+
#[schemars(schema_with = "json_patch_string_list")]
128+
JsonPatches(Vec<String>),
129+
130+
/// Override the entire config file with the specified String.
131+
UserProvided(String),
132+
}
133+
134+
impl JsonConfigOverrides {
135+
/// Applies this override to a base JSON document and returns the patched
136+
/// document as a [`serde_json::Value`].
137+
///
138+
/// For [`JsonConfigOverrides::JsonMergePatch`] and
139+
/// [`JsonConfigOverrides::JsonPatches`], the base document is patched
140+
/// according to the respective RFC.
141+
///
142+
/// For [`JsonConfigOverrides::UserProvided`], the base document is ignored
143+
/// entirely and the user-provided string is parsed and returned.
144+
pub fn apply(&self, base: &serde_json::Value) -> Result<serde_json::Value, Error> {
145+
match self {
146+
JsonConfigOverrides::JsonMergePatch(patch) => {
147+
let mut doc = base.clone();
148+
json_patch::merge(&mut doc, patch);
149+
Ok(doc)
150+
}
151+
JsonConfigOverrides::JsonPatches(patches) => {
152+
let mut doc = base.clone();
153+
let operations: Vec<json_patch::PatchOperation> = patches
154+
.iter()
155+
.enumerate()
156+
.map(|(index, patch_str)| {
157+
serde_json::from_str(patch_str).context(
158+
DeserializeJsonPatchOperationSnafu { index },
159+
)
160+
})
161+
.collect::<Result<Vec<_>, _>>()?;
162+
json_patch::patch(&mut doc, &operations).context(ApplyJsonPatchSnafu)?;
163+
Ok(doc)
164+
}
165+
JsonConfigOverrides::UserProvided(content) => {
166+
serde_json::from_str(content).context(ParseUserProvidedJsonSnafu)
167+
}
168+
}
169+
}
170+
}
171+
172+
#[cfg(test)]
173+
mod tests {
174+
use std::collections::HashMap;
175+
176+
use serde_json::json;
177+
178+
use super::*;
179+
180+
#[test]
181+
fn json_merge_patch_add_and_overwrite_fields() {
182+
let base = json!({
183+
"bundles": {
184+
"authz": {
185+
"polling": {
186+
"min_delay_seconds": 10,
187+
"max_delay_seconds": 20
188+
}
189+
}
190+
}
191+
});
192+
193+
let overrides = JsonConfigOverrides::JsonMergePatch(json!({
194+
"bundles": {
195+
"authz": {
196+
"polling": {
197+
"min_delay_seconds": 3,
198+
"max_delay_seconds": 5
199+
}
200+
}
201+
},
202+
"default_decision": "/http/example/authz/allow"
203+
}));
204+
205+
let result = overrides.apply(&base).expect("merge patch should succeed");
206+
207+
assert_eq!(result["bundles"]["authz"]["polling"]["min_delay_seconds"], 3);
208+
assert_eq!(result["bundles"]["authz"]["polling"]["max_delay_seconds"], 5);
209+
assert_eq!(
210+
result["default_decision"],
211+
"/http/example/authz/allow"
212+
);
213+
}
214+
215+
#[test]
216+
fn json_merge_patch_remove_field_with_null() {
217+
let base = json!({
218+
"keep": "this",
219+
"remove": "this"
220+
});
221+
222+
let overrides = JsonConfigOverrides::JsonMergePatch(json!({
223+
"remove": null
224+
}));
225+
226+
let result = overrides.apply(&base).expect("merge patch should succeed");
227+
228+
assert_eq!(result["keep"], "this");
229+
assert!(result.get("remove").is_none());
230+
}
231+
232+
#[test]
233+
fn json_patch_add_remove_replace() {
234+
let base = json!({
235+
"foo": "bar",
236+
"baz": "qux"
237+
});
238+
239+
let overrides = JsonConfigOverrides::JsonPatches(vec![
240+
r#"{"op": "replace", "path": "/foo", "value": "replaced"}"#.to_owned(),
241+
r#"{"op": "remove", "path": "/baz"}"#.to_owned(),
242+
r#"{"op": "add", "path": "/new_key", "value": "new_value"}"#.to_owned(),
243+
]);
244+
245+
let result = overrides.apply(&base).expect("JSON patch should succeed");
246+
247+
assert_eq!(result["foo"], "replaced");
248+
assert!(result.get("baz").is_none());
249+
assert_eq!(result["new_key"], "new_value");
250+
}
251+
252+
#[test]
253+
fn json_patch_invalid_path_returns_error() {
254+
let base = json!({"foo": "bar"});
255+
256+
let overrides = JsonConfigOverrides::JsonPatches(vec![
257+
r#"{"op": "remove", "path": "/nonexistent"}"#.to_owned(),
258+
]);
259+
260+
let result = overrides.apply(&base);
261+
assert!(result.is_err(), "removing a nonexistent path should fail");
262+
}
263+
264+
#[test]
265+
fn json_patch_invalid_operation_returns_error() {
266+
let base = json!({"foo": "bar"});
267+
268+
let overrides = JsonConfigOverrides::JsonPatches(vec![
269+
r#"{"not_an_op": true}"#.to_owned(),
270+
]);
271+
272+
let result = overrides.apply(&base);
273+
assert!(
274+
result.is_err(),
275+
"invalid patch operation should return an error"
276+
);
277+
}
278+
279+
#[test]
280+
fn user_provided_ignores_base() {
281+
let base = json!({"foo": "bar"});
282+
let content = "{\"custom\": true}";
283+
284+
let overrides = JsonConfigOverrides::UserProvided(content.to_owned());
285+
286+
let result = overrides.apply(&base).expect("user provided should succeed");
287+
assert_eq!(result, json!({"custom": true}));
288+
}
289+
290+
#[test]
291+
fn user_provided_invalid_json_returns_error() {
292+
let base = json!({"foo": "bar"});
293+
294+
let overrides = JsonConfigOverrides::UserProvided("not valid json".to_owned());
295+
296+
let result = overrides.apply(&base);
297+
assert!(result.is_err(), "invalid JSON should return an error");
298+
}
299+
300+
#[test]
301+
fn key_value_config_overrides_as_overrides() {
302+
let mut overrides = BTreeMap::new();
303+
overrides.insert("key1".to_owned(), "value1".to_owned());
304+
overrides.insert("key2".to_owned(), "value2".to_owned());
305+
306+
let kv = KeyValueConfigOverrides { overrides };
307+
let result = kv.as_overrides();
308+
309+
assert_eq!(result.len(), 2);
310+
assert_eq!(result.get("key1"), Some(&Some("value1".to_owned())));
311+
assert_eq!(result.get("key2"), Some(&Some("value2".to_owned())));
312+
}
313+
314+
#[test]
315+
fn key_value_overrides_provider_for_hashmap() {
316+
let mut config_overrides =
317+
HashMap::<String, HashMap<String, String>>::new();
318+
let mut file_overrides = HashMap::new();
319+
file_overrides.insert("key1".to_owned(), "value1".to_owned());
320+
file_overrides.insert("key2".to_owned(), "value2".to_owned());
321+
config_overrides.insert("myfile.properties".to_owned(), file_overrides);
322+
323+
let result = config_overrides.get_key_value_overrides("myfile.properties");
324+
assert_eq!(result.len(), 2);
325+
assert_eq!(result.get("key1"), Some(&Some("value1".to_owned())));
326+
assert_eq!(result.get("key2"), Some(&Some("value2".to_owned())));
327+
328+
let empty = config_overrides.get_key_value_overrides("nonexistent.properties");
329+
assert!(empty.is_empty());
330+
}
331+
}

crates/stackable-operator/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod client;
1212
pub mod cluster_resources;
1313
pub mod commons;
1414
pub mod config;
15+
pub mod config_overrides;
1516
pub mod constants;
1617
pub mod cpu;
1718
#[cfg(feature = "crds")]

0 commit comments

Comments
 (0)