Skip to content

Commit a3d29c5

Browse files
adwk67claude
andcommitted
feat(v2): add shared config-file writers (java-properties/Hadoop-XML + Flask)
Adds v2::config_file_writer (to_java_properties_string + to_hadoop_xml, backed by the java-properties and xml crates) and v2::flask_config_writer (the Flask App Builder Python config writer), both originally from the product-config crate's writer modules and until now vendored separately into the operators (hdfs/hbase/hive byte-identical full copies; kafka/nifi/zookeeper java-only subsets; airflow/superset identical Flask copies). Unit tests moved along with the code; minor lint-driven cleanups only (use_self, format_push_string, identical match arms, no unwrap in Result-returning tests) — rendered output is unchanged and pinned by the tests. Operators will migrate to these in follow-up commits, deleting their vendored copies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 128e1af commit a3d29c5

6 files changed

Lines changed: 482 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ insta = { version = "1.40", features = ["glob"] }
3838
hyper = { version = "1.4.1", features = ["full"] }
3939
hyper-util = "0.1.8"
4040
itertools = "0.14.0"
41+
java-properties = "2.0"
4142
json-patch = "4.0.0"
4243
k8s-openapi = { version = "0.27.0", default-features = false, features = ["schemars", "v1_35"] }
4344
# We use rustls instead of openssl for easier portability, e.g. so that we can build stackablectl without the need to vendor (build from source) openssl
@@ -89,6 +90,7 @@ url = { version = "2.5.2", features = ["serde"] }
8990
uuid = "1.23"
9091
winnow = "1.0.3"
9192
x509-cert = { version = "0.2.5", features = ["builder"] }
93+
xml = "1.3"
9294
zeroize = "1.8.1"
9395

9496
[workspace.lints.clippy]

crates/stackable-operator/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ educe.workspace = true
3636
futures.workspace = true
3737
http.workspace = true
3838
indexmap.workspace = true
39+
java-properties.workspace = true
3940
jiff.workspace = true
4041
json-patch = { workspace = true, features = ["schemars"] }
4142
k8s-openapi.workspace = true
@@ -57,6 +58,7 @@ tracing-subscriber.workspace = true
5758
url.workspace = true
5859
uuid.workspace = true
5960
winnow = { workspace = true, optional = true }
61+
xml.workspace = true
6062

6163
[dev-dependencies]
6264
indoc.workspace = true
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//! Writers for Hadoop XML config files and Java `.properties` files.
2+
//!
3+
//! Originally part of the `product-config` crate's `writer` module; previously
4+
//! vendored into the individual operators, now provided here as the shared home so
5+
//! operators do not depend on `product-config` for rendering.
6+
7+
use std::{fmt::Write as _, io::Write};
8+
9+
use java_properties::{PropertiesError, PropertiesWriter};
10+
use snafu::{ResultExt, Snafu};
11+
use xml::escape::escape_str_attribute;
12+
13+
#[derive(Debug, Snafu)]
14+
pub enum PropertiesWriterError {
15+
#[snafu(display("failed to create properties file"))]
16+
Properties { source: PropertiesError },
17+
18+
#[snafu(display("failed to convert properties file byte array to UTF-8"))]
19+
FromUtf8 { source: std::string::FromUtf8Error },
20+
}
21+
22+
/// Creates a common Java properties file string in the format:
23+
/// `property_1=value_1\nproperty_2=value_2\n`.
24+
pub fn to_java_properties_string<'a, T>(properties: T) -> Result<String, PropertiesWriterError>
25+
where
26+
T: Iterator<Item = (&'a String, &'a Option<String>)>,
27+
{
28+
let mut output = Vec::new();
29+
write_java_properties(&mut output, properties)?;
30+
String::from_utf8(output).context(FromUtf8Snafu)
31+
}
32+
33+
/// Writes Java properties to the given writer. A `None` value is written as an
34+
/// empty value (`key=`).
35+
fn write_java_properties<'a, W, T>(writer: W, properties: T) -> Result<(), PropertiesWriterError>
36+
where
37+
W: Write,
38+
T: Iterator<Item = (&'a String, &'a Option<String>)>,
39+
{
40+
let mut writer = PropertiesWriter::new(writer);
41+
for (k, v) in properties {
42+
let property_value = v.as_deref().unwrap_or_default();
43+
writer.write(k, property_value).context(PropertiesSnafu)?;
44+
}
45+
writer.flush().context(PropertiesSnafu)?;
46+
Ok(())
47+
}
48+
49+
/// Converts properties into a Hadoop configuration XML, including the wrapping
50+
/// `<configuration>...</configuration>` elements. Properties with a `None` value
51+
/// are skipped. Keys and values are XML-escaped.
52+
pub fn to_hadoop_xml<'a, T>(properties: T) -> String
53+
where
54+
T: Iterator<Item = (&'a String, &'a Option<String>)>,
55+
{
56+
let mut snippet = String::new();
57+
for (k, v) in properties {
58+
let escaped_value = match v {
59+
Some(value) => escape_str_attribute(value),
60+
None => continue,
61+
};
62+
let escaped_key = escape_str_attribute(k);
63+
write!(
64+
snippet,
65+
" <property>\n <name>{escaped_key}</name>\n <value>{escaped_value}</value>\n </property>\n"
66+
)
67+
.expect("writing to a String is infallible");
68+
}
69+
format!("<?xml version=\"1.0\"?>\n<configuration>\n{snippet}</configuration>")
70+
}
71+
72+
#[cfg(test)]
73+
mod tests {
74+
use std::collections::BTreeMap;
75+
76+
use super::*;
77+
78+
fn xml(pairs: &[(&str, Option<&str>)]) -> String {
79+
let map: BTreeMap<String, Option<String>> = pairs
80+
.iter()
81+
.map(|(k, v)| (k.to_string(), v.map(str::to_string)))
82+
.collect();
83+
to_hadoop_xml(map.iter())
84+
}
85+
86+
fn props(pairs: &[(&str, Option<&str>)]) -> String {
87+
let map: BTreeMap<String, Option<String>> = pairs
88+
.iter()
89+
.map(|(k, v)| (k.to_string(), v.map(str::to_string)))
90+
.collect();
91+
to_java_properties_string(map.iter()).unwrap()
92+
}
93+
94+
#[test]
95+
fn hadoop_xml_wraps_empty_configuration() {
96+
assert_eq!(
97+
xml(&[]),
98+
"<?xml version=\"1.0\"?>\n<configuration>\n</configuration>"
99+
);
100+
}
101+
102+
#[test]
103+
fn hadoop_xml_renders_single_property() {
104+
assert_eq!(
105+
xml(&[("fs.defaultFS", Some("hdfs://hdfs/"))]),
106+
"<?xml version=\"1.0\"?>\n<configuration>\n \
107+
<property>\n <name>fs.defaultFS</name>\n \
108+
<value>hdfs://hdfs/</value>\n </property>\n</configuration>"
109+
);
110+
}
111+
112+
#[test]
113+
fn hadoop_xml_skips_none_values() {
114+
assert_eq!(
115+
xml(&[("kept", Some("1")), ("dropped", None)]),
116+
"<?xml version=\"1.0\"?>\n<configuration>\n \
117+
<property>\n <name>kept</name>\n \
118+
<value>1</value>\n </property>\n</configuration>"
119+
);
120+
}
121+
122+
#[test]
123+
fn hadoop_xml_escapes_special_characters() {
124+
let rendered = xml(&[("k", Some("<a>&b"))]);
125+
assert!(
126+
rendered.contains("<value>&lt;a&gt;&amp;b</value>"),
127+
"{rendered}"
128+
);
129+
}
130+
131+
#[test]
132+
fn java_properties_renders_key_value() {
133+
assert_eq!(props(&[("a", Some("1")), ("b", Some("2"))]), "a=1\nb=2\n");
134+
}
135+
136+
#[test]
137+
fn java_properties_renders_none_as_empty() {
138+
assert_eq!(props(&[("none", None)]), "none=\n");
139+
}
140+
141+
#[test]
142+
fn java_properties_escapes_colon_in_value() {
143+
assert_eq!(
144+
props(&[("url", Some("file://this/location/file.abc"))]),
145+
"url=file\\://this/location/file.abc\n"
146+
);
147+
}
148+
}

0 commit comments

Comments
 (0)