diff --git a/Cargo.lock b/Cargo.lock index 4a8aad42c..1695140e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,9 +154,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" @@ -256,9 +256,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -281,9 +281,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" @@ -293,9 +293,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.63" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "shlex", @@ -525,9 +525,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "delegate" @@ -608,9 +608,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", @@ -671,9 +671,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "elliptic-curve" @@ -925,9 +925,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.4" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" @@ -1037,9 +1037,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -1076,9 +1076,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -1108,9 +1108,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -1159,9 +1159,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.10.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1339,9 +1339,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -1354,7 +1354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1388,6 +1388,16 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1422,9 +1432,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.28" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -1432,14 +1442,14 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-link", + "windows-sys 0.61.2", ] [[package]] name = "jiff-static" -version = "0.2.28" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", @@ -1463,9 +1473,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "cfg-if", "futures-util", @@ -1475,14 +1485,15 @@ dependencies = [ [[package]] name = "json-patch" -version = "4.2.0" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" dependencies = [ "jsonptr", + "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 1.0.69", ] [[package]] @@ -1711,9 +1722,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.31" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "matchers" @@ -1732,9 +1743,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" @@ -1754,9 +1765,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -1790,9 +1801,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -2063,18 +2074,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.13" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.13" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", @@ -2516,9 +2527,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "log", "once_cell", @@ -2531,9 +2542,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2543,9 +2554,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "zeroize", ] @@ -2728,9 +2739,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -2817,9 +2828,9 @@ dependencies = [ [[package]] name = "shlex" -version = "2.0.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" @@ -2930,9 +2941,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2998,6 +3009,7 @@ dependencies = [ "http", "indexmap", "indoc", + "java-properties", "jiff", "json-patch", "k8s-openapi", @@ -3011,6 +3023,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "snafu 0.9.1", "stackable-certs", "stackable-operator-derive", @@ -3025,7 +3038,9 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "url", + "uuid", "winnow", + "xml", ] [[package]] @@ -3380,9 +3395,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -3478,9 +3493,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", @@ -3505,9 +3520,9 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tonic" -version = "0.14.6" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "base64", @@ -3532,9 +3547,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.6" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", "prost", @@ -3543,9 +3558,9 @@ dependencies = [ [[package]] name = "tonic-types" -version = "0.14.6" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +checksum = "2a875a902255423d34c1f20838ab374126db8eb41625b7947a1d54113b0b7399" dependencies = [ "prost", "prost-types", @@ -3573,9 +3588,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.11" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "base64", "bitflags", @@ -3583,13 +3598,13 @@ dependencies = [ "futures-util", "http", "http-body", + "iri-string", "mime", "pin-project-lite", "tower", "tower-layer", "tower-service", "tracing", - "url", ] [[package]] @@ -3748,9 +3763,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -3766,9 +3781,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -3819,6 +3834,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3876,9 +3901,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -3889,9 +3914,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ "js-sys", "wasm-bindgen", @@ -3899,9 +3924,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3909,9 +3934,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -3922,9 +3947,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -3965,9 +3990,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4247,18 +4272,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -4267,9 +4292,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 406f95d2d..8d3409700 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ insta = { version = "1.40", features = ["glob"] } hyper = { version = "1.4.1", features = ["full"] } hyper-util = "0.1.8" itertools = "0.14.0" +java-properties = "2.0" json-patch = "4.0.0" k8s-openapi = { version = "0.27.0", default-features = false, features = ["schemars", "v1_35"] } # 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 @@ -86,8 +87,10 @@ tracing-opentelemetry = "0.33.0" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] } trybuild = "1.0.99" url = { version = "2.5.2", features = ["serde"] } +uuid = "1.23" winnow = "1.0.3" x509-cert = { version = "0.2.5", features = ["builder"] } +xml = "1.3" zeroize = "1.8.1" [workspace.lints.clippy] diff --git a/crates/stackable-operator/Cargo.toml b/crates/stackable-operator/Cargo.toml index 080f31425..65a87596e 100644 --- a/crates/stackable-operator/Cargo.toml +++ b/crates/stackable-operator/Cargo.toml @@ -36,8 +36,9 @@ educe.workspace = true futures.workspace = true http.workspace = true indexmap.workspace = true +java-properties.workspace = true jiff.workspace = true -json-patch.workspace = true +json-patch = { workspace = true, features = ["schemars"] } k8s-openapi.workspace = true kube.workspace = true product-config.workspace = true @@ -48,6 +49,7 @@ semver.workspace = true serde_json.workspace = true serde_yaml.workspace = true serde.workspace = true +sha2.workspace = true snafu.workspace = true strum.workspace = true tokio.workspace = true @@ -55,7 +57,9 @@ tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true url.workspace = true +uuid.workspace = true winnow = { workspace = true, optional = true } +xml.workspace = true [dev-dependencies] indoc.workspace = true diff --git a/crates/stackable-operator/crds/Scaler.yaml b/crates/stackable-operator/crds/Scaler.yaml index d764f36f2..221d9e84b 100644 --- a/crates/stackable-operator/crds/Scaler.yaml +++ b/crates/stackable-operator/crds/Scaler.yaml @@ -33,8 +33,8 @@ spec: Upstream issues: - - https://github.com/kubernetes/kubernetes/issues/105533 - - https://github.com/Arnavion/k8s-openapi/issues/136 + - + - format: uint16 maximum: 65535.0 minimum: 0.0 diff --git a/crates/stackable-operator/src/crd/scaler/mod.rs b/crates/stackable-operator/src/crd/scaler/mod.rs index 9583061d9..9fdd300b7 100644 --- a/crates/stackable-operator/src/crd/scaler/mod.rs +++ b/crates/stackable-operator/src/crd/scaler/mod.rs @@ -31,8 +31,8 @@ pub mod versioned { /// /// Upstream issues: /// - /// - https://github.com/kubernetes/kubernetes/issues/105533 - /// - https://github.com/Arnavion/k8s-openapi/issues/136 + /// - + /// - pub replicas: u16, } } diff --git a/crates/stackable-operator/src/lib.rs b/crates/stackable-operator/src/lib.rs index bacfa3e9e..f459ff79e 100644 --- a/crates/stackable-operator/src/lib.rs +++ b/crates/stackable-operator/src/lib.rs @@ -33,6 +33,7 @@ pub mod role_utils; pub mod status; pub mod test_utils; pub mod utils; +pub mod v2; pub mod validation; // External re-exports diff --git a/crates/stackable-operator/src/v2/builder.rs b/crates/stackable-operator/src/v2/builder.rs new file mode 100644 index 000000000..e1f5525be --- /dev/null +++ b/crates/stackable-operator/src/v2/builder.rs @@ -0,0 +1,4 @@ +pub mod meta; +pub mod pdb; +pub mod pod; +pub mod statefulset; diff --git a/crates/stackable-operator/src/v2/builder/meta.rs b/crates/stackable-operator/src/v2/builder/meta.rs new file mode 100644 index 000000000..c41ff6e89 --- /dev/null +++ b/crates/stackable-operator/src/v2/builder/meta.rs @@ -0,0 +1,123 @@ +use crate::{ + builder::meta::OwnerReferenceBuilder, + k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference, + kube::Resource, + v2::{HasName, HasUid}, +}; + +/// Infallible variant of +/// [`crate::builder::meta::ObjectMetaBuilder::ownerreference_from_resource`] +pub fn ownerreference_from_resource( + resource: &(impl Resource + HasName + HasUid), + block_owner_deletion: Option, + controller: Option, +) -> OwnerReference { + OwnerReferenceBuilder::new() + // Set api_version, kind, name and additionally the UID if it exists. + .initialize_from_resource(resource) + // Ensure that the name is set. + .name(resource.to_name()) + // Ensure that the UID is set. + .uid(resource.to_uid().to_string()) + .block_owner_deletion_opt(block_owner_deletion) + .controller_opt(controller) + .build() + .expect( + "OwnerReference should be created because the resource has an api_version, kind, name \ + and uid.", + ) +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use crate::{ + k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, OwnerReference}, + kube::Resource, + v2::{HasName, HasUid, Uid, builder::meta::ownerreference_from_resource}, + }; + + struct Cluster { + object_meta: ObjectMeta, + } + + impl Cluster { + fn new() -> Self { + Self { + object_meta: ObjectMeta { + name: Some("cluster-name".to_owned()), + uid: Some("a6b89911-d48e-4328-88d6-b9251226583d".to_owned()), + ..ObjectMeta::default() + }, + } + } + } + + impl Resource for Cluster { + type DynamicType = (); + type Scope = (); + + fn kind(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("kind") + } + + fn group(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("group") + } + + fn version(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("version") + } + + fn plural(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("plural") + } + + fn meta(&self) -> &ObjectMeta { + &self.object_meta + } + + fn meta_mut(&mut self) -> &mut ObjectMeta { + &mut self.object_meta + } + } + + impl HasName for Cluster { + fn to_name(&self) -> String { + self.object_meta + .name + .clone() + .expect("should be set in Cluster::new") + } + } + + impl HasUid for Cluster { + fn to_uid(&self) -> Uid { + Uid::from_str_unsafe( + &self + .object_meta + .uid + .clone() + .expect("should be set in Cluster::new"), + ) + } + } + + #[test] + fn test_ownerreference_from_resource() { + let actual_owner_reference = + ownerreference_from_resource(&Cluster::new(), Some(true), Some(true)); + + let expected_owner_reference = OwnerReference { + api_version: "group/version".to_owned(), + block_owner_deletion: Some(true), + controller: Some(true), + kind: "kind".to_owned(), + name: "cluster-name".to_owned(), + uid: "a6b89911-d48e-4328-88d6-b9251226583d".to_owned(), + }; + + assert_eq!(expected_owner_reference, actual_owner_reference); + } +} diff --git a/crates/stackable-operator/src/v2/builder/pdb.rs b/crates/stackable-operator/src/v2/builder/pdb.rs new file mode 100644 index 000000000..783265d12 --- /dev/null +++ b/crates/stackable-operator/src/v2/builder/pdb.rs @@ -0,0 +1,187 @@ +use crate::{ + builder::pdb::PodDisruptionBudgetBuilder, + k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector, + kube::{Resource, api::ObjectMeta}, + v2::{ + HasName, HasUid, NameIsValidLabelValue, + types::operator::{ControllerName, OperatorName, ProductName, RoleName}, + }, +}; + +/// Infallible variant of +/// [`crate::builder::pdb::PodDisruptionBudgetBuilder::new_with_role`] +pub fn pod_disruption_budget_builder_with_role( + owner: &(impl Resource + HasName + NameIsValidLabelValue + HasUid), + product_name: &ProductName, + role_name: &RoleName, + operator_name: &OperatorName, + controller_name: &ControllerName, +) -> PodDisruptionBudgetBuilder { + PodDisruptionBudgetBuilder::new_with_role( + owner, + &product_name.to_label_value(), + &role_name.to_label_value(), + &operator_name.to_label_value(), + &controller_name.to_label_value(), + ) + .expect( + "PodDisruptionBudgetBuilder should be created because the owner has an object name and UID \ + and all given parameters produce valid label values.", + ) +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use crate::{ + k8s_openapi::{ + api::policy::v1::{PodDisruptionBudget, PodDisruptionBudgetSpec}, + apimachinery::pkg::{ + apis::meta::v1::{LabelSelector, ObjectMeta, OwnerReference}, + util::intstr::IntOrString, + }, + }, + kube::Resource, + v2::{ + HasName, HasUid, NameIsValidLabelValue, + builder::pdb::pod_disruption_budget_builder_with_role, + types::{ + kubernetes::Uid, + operator::{ControllerName, OperatorName, ProductName, RoleName}, + }, + }, + }; + + struct Cluster { + object_meta: ObjectMeta, + } + + impl Cluster { + fn new() -> Self { + Self { + object_meta: ObjectMeta { + name: Some("cluster-name".to_owned()), + uid: Some("a6b89911-d48e-4328-88d6-b9251226583d".to_owned()), + ..ObjectMeta::default() + }, + } + } + } + + impl Resource for Cluster { + type DynamicType = (); + type Scope = (); + + fn kind(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("kind") + } + + fn group(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("group") + } + + fn version(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("version") + } + + fn plural(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("plural") + } + + fn meta(&self) -> &ObjectMeta { + &self.object_meta + } + + fn meta_mut(&mut self) -> &mut ObjectMeta { + &mut self.object_meta + } + } + + impl HasName for Cluster { + fn to_name(&self) -> String { + self.object_meta + .name + .clone() + .expect("should be set in Cluster::new") + } + } + + impl HasUid for Cluster { + fn to_uid(&self) -> Uid { + Uid::from_str_unsafe( + &self + .object_meta + .uid + .clone() + .expect("should be set in Cluster::new"), + ) + } + } + + impl NameIsValidLabelValue for Cluster { + fn to_label_value(&self) -> String { + self.object_meta + .name + .clone() + .expect("should be set in Cluster::new") + } + } + + #[test] + fn test_pod_disruption_budget_builder_with_role() { + let actual_pdb = pod_disruption_budget_builder_with_role( + &Cluster::new(), + &ProductName::from_str_unsafe("my-product"), + &RoleName::from_str_unsafe("my-role"), + &OperatorName::from_str_unsafe("my-operator"), + &ControllerName::from_str_unsafe("my-controller"), + ) + .with_max_unavailable(2) + .build(); + + let expected_pdb = PodDisruptionBudget { + metadata: ObjectMeta { + labels: Some( + [ + ("app.kubernetes.io/component", "my-role"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/managed-by", "my-operator_my-controller"), + ("app.kubernetes.io/name", "my-product"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(), + ), + name: Some("cluster-name-my-role".to_owned()), + owner_references: Some(vec![OwnerReference { + api_version: "group/version".to_owned(), + controller: Some(true), + kind: "kind".to_owned(), + name: "cluster-name".to_owned(), + uid: "a6b89911-d48e-4328-88d6-b9251226583d".to_owned(), + ..OwnerReference::default() + }]), + ..ObjectMeta::default() + }, + spec: Some(PodDisruptionBudgetSpec { + max_unavailable: Some(IntOrString::Int(2)), + selector: Some(LabelSelector { + match_labels: Some( + [ + ("app.kubernetes.io/component", "my-role"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/name", "my-product"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(), + ), + ..LabelSelector::default() + }), + ..PodDisruptionBudgetSpec::default() + }), + ..PodDisruptionBudget::default() + }; + + assert_eq!(expected_pdb, actual_pdb); + } +} diff --git a/crates/stackable-operator/src/v2/builder/pod.rs b/crates/stackable-operator/src/v2/builder/pod.rs new file mode 100644 index 000000000..df93bd44c --- /dev/null +++ b/crates/stackable-operator/src/v2/builder/pod.rs @@ -0,0 +1,2 @@ +pub mod container; +pub mod volume; diff --git a/crates/stackable-operator/src/v2/builder/pod/container.rs b/crates/stackable-operator/src/v2/builder/pod/container.rs new file mode 100644 index 000000000..29229b58d --- /dev/null +++ b/crates/stackable-operator/src/v2/builder/pod/container.rs @@ -0,0 +1,379 @@ +use std::{ + collections::{BTreeMap, btree_map}, + fmt::Display, + str::FromStr, +}; + +use snafu::Snafu; +use strum::{EnumDiscriminants, IntoStaticStr}; + +use crate::{ + builder::pod::container::{ContainerBuilder, FieldPathEnvVar}, + k8s_openapi::api::core::v1::{ConfigMapKeySelector, EnvVar, EnvVarSource, ObjectFieldSelector}, + v2::types::kubernetes::{ConfigMapKey, ConfigMapName, ContainerName}, +}; + +#[derive(Snafu, Debug, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr))] +pub enum Error { + #[snafu(display( + "invalid environment variable name: a valid environment variable name must not be empty \ + and must consist only of printable ASCII characters other than '='" + ))] + ParseEnvVarName { env_var_name: String }, +} + +/// Infallible variant of [`crate::builder::pod::container::ContainerBuilder::new`] +pub fn new_container_builder(container_name: &ContainerName) -> ContainerBuilder { + ContainerBuilder::new(container_name.as_ref()).expect("should be a valid container name") +} + +// TODO Use attributed_string_type instead +/// Validated environment variable name +#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EnvVarName(String); + +impl EnvVarName { + /// Creates an [`EnvVarName`] from the given string and panics if the validation failed + /// + /// Use this only with constant names that are also tested in unit tests! + pub fn from_str_unsafe(s: &str) -> Self { + Self::from_str(s).expect("should be a valid environment variable name") + } +} + +impl Display for EnvVarName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl FromStr for EnvVarName { + type Err = Error; + + fn from_str(s: &str) -> Result { + // The length of environment variable names seems not to be restricted. + + if !s.is_empty() && s.chars().all(|c| matches!(c, ' '..='<' | '>'..='~')) { + Ok(Self(s.to_owned())) + } else { + Err(Error::ParseEnvVarName { + env_var_name: s.to_owned(), + }) + } + } +} + +/// A set of [`EnvVar`]s +/// +/// The environment variable names in the set are unique. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct EnvVarSet(BTreeMap); + +impl EnvVarSet { + /// Creates an empty [`EnvVarSet`] + pub fn new() -> Self { + Self::default() + } + + /// Returns a reference to the [`EnvVar`] with the given name + pub fn get(&self, env_var_name: &EnvVarName) -> Option<&EnvVar> { + self.0.get(env_var_name) + } + + /// Moves all [`EnvVar`]s from the given set into this one. + /// + /// [`EnvVar`]s with the same name are overridden. + pub fn merge(mut self, mut env_var_set: Self) -> Self { + self.0.append(&mut env_var_set.0); + + self + } + + /// Adds the given [`EnvVar`]s to this set + /// + /// [`EnvVar`]s with the same name are overridden. + pub fn with_values(self, env_vars: I) -> Self + where + I: IntoIterator, + V: Into, + { + env_vars + .into_iter() + .fold(self, |extended_env_vars, (name, value)| { + extended_env_vars.with_value(&name, value) + }) + } + + /// Adds an environment variable with the given name and string value to this set + /// + /// An [`EnvVar`] with the same name is overridden. + pub fn with_value(mut self, name: &EnvVarName, value: impl Into) -> Self { + self.0.insert( + name.clone(), + EnvVar { + name: name.to_string(), + value: Some(value.into()), + value_from: None, + }, + ); + + self + } + + /// Adds an environment variable with the given name and field path to this set + /// + /// An [`EnvVar`] with the same name is overridden. + pub fn with_field_path(mut self, name: &EnvVarName, field_path: &FieldPathEnvVar) -> Self { + self.0.insert( + name.clone(), + EnvVar { + name: name.to_string(), + value: None, + value_from: Some(EnvVarSource { + field_ref: Some(ObjectFieldSelector { + field_path: field_path.to_string(), + ..ObjectFieldSelector::default() + }), + ..EnvVarSource::default() + }), + }, + ); + + self + } + + /// Adds an environment variable with the given ConfigMap key reference to this set + /// + /// An [`EnvVar`] with the same name is overridden. + pub fn with_config_map_key_ref( + mut self, + name: &EnvVarName, + config_map_name: &ConfigMapName, + config_map_key: &ConfigMapKey, + ) -> Self { + self.0.insert( + name.clone(), + EnvVar { + name: name.to_string(), + value: None, + value_from: Some(EnvVarSource { + config_map_key_ref: Some(ConfigMapKeySelector { + key: config_map_key.to_string(), + name: config_map_name.to_string(), + ..ConfigMapKeySelector::default() + }), + ..EnvVarSource::default() + }), + }, + ); + + self + } +} + +impl From for Vec { + fn from(value: EnvVarSet) -> Self { + value.0.values().cloned().collect() + } +} + +impl IntoIterator for EnvVarSet { + type IntoIter = btree_map::IntoValues; + type Item = EnvVar; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_values() + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::{EnvVarName, EnvVarSet}; + use crate::{ + builder::pod::container::FieldPathEnvVar, + k8s_openapi::api::core::v1::{ + ConfigMapKeySelector, EnvVar, EnvVarSource, ObjectFieldSelector, + }, + v2::{ + builder::pod::container::new_container_builder, + types::kubernetes::{ConfigMapKey, ConfigMapName, ContainerName}, + }, + }; + + #[test] + fn test_envvarname_fromstr() { + // actually accepted by Kubernetes + assert!(EnvVarName::from_str(" !\"#$%&'()*+,-./0123456789:;<>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~").is_ok()); + + // empty string + assert!(EnvVarName::from_str("").is_err()); + // non-printable ASCII characters + assert!(EnvVarName::from_str("\n").is_err()); + assert!(EnvVarName::from_str("€").is_err()); + // equals sign + assert!(EnvVarName::from_str("=").is_err()); + } + + #[test] + fn test_new_container_builder() { + // Test that the function does not panic + new_container_builder(&ContainerName::from_str_unsafe("valid-container-name")); + } + + #[test] + fn test_envvarname_format() { + assert_eq!( + "TEST".to_owned(), + format!("{}", EnvVarName::from_str_unsafe("TEST")) + ); + } + + #[test] + fn test_envvarset_merge() { + let env_var_set1 = EnvVarSet::new().with_values([ + ( + EnvVarName::from_str_unsafe("ENV1"), + "value1 from env_var_set1", + ), + ( + EnvVarName::from_str_unsafe("ENV2"), + "value2 from env_var_set1", + ), + ( + EnvVarName::from_str_unsafe("ENV3"), + "value3 from env_var_set1", + ), + ]); + let env_var_set2 = EnvVarSet::new() + .with_value( + &EnvVarName::from_str_unsafe("ENV2"), + "value2 from env_var_set2", + ) + .with_field_path(&EnvVarName::from_str_unsafe("ENV3"), &FieldPathEnvVar::Name) + .with_value( + &EnvVarName::from_str_unsafe("ENV4"), + "value4 from env_var_set2", + ); + + let merged_env_var_set = env_var_set1.merge(env_var_set2); + + assert_eq!( + vec![ + EnvVar { + name: "ENV1".to_owned(), + value: Some("value1 from env_var_set1".to_owned()), + value_from: None + }, + EnvVar { + name: "ENV2".to_owned(), + value: Some("value2 from env_var_set2".to_owned()), + value_from: None + }, + EnvVar { + name: "ENV3".to_owned(), + value: None, + value_from: Some(EnvVarSource { + field_ref: Some(ObjectFieldSelector { + field_path: "metadata.name".to_owned(), + ..ObjectFieldSelector::default() + }), + ..EnvVarSource::default() + }), + }, + EnvVar { + name: "ENV4".to_owned(), + value: Some("value4 from env_var_set2".to_owned()), + value_from: None + } + ], + Vec::from(merged_env_var_set) + ); + } + + #[test] + fn test_envvarset_with_values() { + let env_var_set = EnvVarSet::new().with_values([ + (EnvVarName::from_str_unsafe("ENV1"), "value1"), + (EnvVarName::from_str_unsafe("ENV2"), "value2"), + ]); + + assert_eq!( + vec![ + EnvVar { + name: "ENV1".to_owned(), + value: Some("value1".to_owned()), + value_from: None + }, + EnvVar { + name: "ENV2".to_owned(), + value: Some("value2".to_owned()), + value_from: None + } + ], + Vec::from(env_var_set) + ); + } + + #[test] + fn test_envvarset_with_value() { + let env_var_set = EnvVarSet::new().with_value(&EnvVarName::from_str_unsafe("ENV"), "value"); + + assert_eq!( + Some(&EnvVar { + name: "ENV".to_owned(), + value: Some("value".to_owned()), + value_from: None + }), + env_var_set.get(&EnvVarName::from_str_unsafe("ENV")) + ); + } + + #[test] + fn test_envvarset_with_field_path() { + let env_var_set = EnvVarSet::new() + .with_field_path(&EnvVarName::from_str_unsafe("ENV"), &FieldPathEnvVar::Name); + + assert_eq!( + Some(&EnvVar { + name: "ENV".to_owned(), + value: None, + value_from: Some(EnvVarSource { + field_ref: Some(ObjectFieldSelector { + field_path: "metadata.name".to_owned(), + ..ObjectFieldSelector::default() + }), + ..EnvVarSource::default() + }), + }), + env_var_set.get(&EnvVarName::from_str_unsafe("ENV")) + ); + } + + #[test] + fn test_envvarset_with_config_map_key_ref() { + let env_var_set = EnvVarSet::new().with_config_map_key_ref( + &EnvVarName::from_str_unsafe("ENV"), + &ConfigMapName::from_str_unsafe("config-map"), + &ConfigMapKey::from_str_unsafe("key"), + ); + + assert_eq!( + Some(&EnvVar { + name: "ENV".to_owned(), + value: None, + value_from: Some(EnvVarSource { + config_map_key_ref: Some(ConfigMapKeySelector { + key: "key".to_owned(), + name: "config-map".to_owned(), + ..ConfigMapKeySelector::default() + }), + ..EnvVarSource::default() + }), + }), + env_var_set.get(&EnvVarName::from_str_unsafe("ENV")) + ); + } +} diff --git a/crates/stackable-operator/src/v2/builder/pod/volume.rs b/crates/stackable-operator/src/v2/builder/pod/volume.rs new file mode 100644 index 000000000..5e9307e39 --- /dev/null +++ b/crates/stackable-operator/src/v2/builder/pod/volume.rs @@ -0,0 +1,42 @@ +use crate::{ + builder::pod::volume::ListenerOperatorVolumeSourceBuilder, + k8s_openapi::api::core::v1::PersistentVolumeClaim, + kvp::Labels, + v2::types::kubernetes::{ListenerClassName, ListenerName, PersistentVolumeClaimName}, +}; + +/// Infallible variant of [`crate::builder::pod::volume::ListenerReference`] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ListenerReference { + ListenerClass(ListenerClassName), + Listener(ListenerName), +} + +impl From<&ListenerReference> for crate::builder::pod::volume::ListenerReference { + fn from(value: &ListenerReference) -> Self { + match value { + ListenerReference::ListenerClass(listener_class_name) => { + Self::ListenerClass(listener_class_name.to_string()) + } + ListenerReference::Listener(listener_name) => { + Self::ListenerName(listener_name.to_string()) + } + } + } +} + +/// Infallible variant of +/// [`crate::builder::pod::volume::ListenerOperatorVolumeSourceBuilder::build_pvc`] +pub fn listener_operator_volume_source_builder_build_pvc( + listener_reference: &ListenerReference, + labels: &Labels, + pvc_name: &PersistentVolumeClaimName, +) -> PersistentVolumeClaim { + ListenerOperatorVolumeSourceBuilder::new(&listener_reference.into(), labels) + .build_pvc(pvc_name.to_string()) + .expect( + "should return a PersistentVolumeClaim, because the only check is that \ + listener_reference is a valid annotation value and there are no restrictions on single \ + annotation values", + ) +} diff --git a/crates/stackable-operator/src/v2/builder/statefulset.rs b/crates/stackable-operator/src/v2/builder/statefulset.rs new file mode 100644 index 000000000..5f883ad54 --- /dev/null +++ b/crates/stackable-operator/src/v2/builder/statefulset.rs @@ -0,0 +1,119 @@ +use std::collections::BTreeMap; + +use crate::{ + kvp::Annotations, + v2::types::kubernetes::{ConfigMapName, SecretName}, +}; + +/// Creates `restarter.stackable.tech/ignore-configmap.{i}` annotations for each given ConfigMap. +/// +/// The restarter uses these annotations to skip restarting Pods when specific ConfigMaps change. +/// Indices start at 0 and are assigned in iteration order, so **do not merge the result with +/// annotations from another call** — duplicate indices would overwrite each other. +pub fn restarter_ignore_configmap_annotations( + ignored_config_maps: impl IntoIterator, +) -> Annotations { + let annotation_key_values = ignored_config_maps + .into_iter() + .enumerate() + .map(|(i, config_map_name)| { + ( + format!("restarter.stackable.tech/ignore-configmap.{i}"), + config_map_name.to_string(), + ) + }) + .collect::>(); + + Annotations::try_from(annotation_key_values).expect( + "should contain only valid annotations because the annotation keys are statically \ + defined apart from the index number and the names of ConfigMaps are valid annotation \ + values.", + ) +} + +/// Creates `restarter.stackable.tech/ignore-secret.{i}` annotations for each given Secret. +/// +/// The restarter uses these annotations to skip restarting Pods when specific Secrets change. +/// Indices start at 0 and are assigned in iteration order, so **do not merge the result with +/// annotations from another call** — duplicate indices would overwrite each other. +pub fn restarter_ignore_secret_annotations( + ignored_secrets: impl IntoIterator, +) -> Annotations { + let annotation_key_values = ignored_secrets + .into_iter() + .enumerate() + .map(|(i, secret_name)| { + ( + format!("restarter.stackable.tech/ignore-secret.{i}"), + secret_name.to_string(), + ) + }) + .collect::>(); + + Annotations::try_from(annotation_key_values).expect( + "should contain only valid annotations because the annotation keys are statically \ + defined apart from the index number and the names of Secrets are valid annotation \ + values.", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn multiple_config_maps_produce_indexed_annotations() { + let ignored_config_maps = [ + ConfigMapName::from_str_unsafe("first-config"), + ConfigMapName::from_str_unsafe("second-config"), + ConfigMapName::from_str_unsafe("third-config"), + ]; + + let actual_annotations = restarter_ignore_configmap_annotations(ignored_config_maps); + + let expected_annotations = BTreeMap::from([ + ( + "restarter.stackable.tech/ignore-configmap.0".to_owned(), + "first-config".to_owned(), + ), + ( + "restarter.stackable.tech/ignore-configmap.1".to_owned(), + "second-config".to_owned(), + ), + ( + "restarter.stackable.tech/ignore-configmap.2".to_owned(), + "third-config".to_owned(), + ), + ]); + + assert_eq!(expected_annotations, actual_annotations.into()); + } + + #[test] + fn multiple_secrets_produce_indexed_annotations() { + let ignored_secrets = [ + SecretName::from_str_unsafe("first-secret"), + SecretName::from_str_unsafe("second-secret"), + SecretName::from_str_unsafe("third-secret"), + ]; + + let actual_annotations = restarter_ignore_secret_annotations(ignored_secrets); + + let expected_annotations = BTreeMap::from([ + ( + "restarter.stackable.tech/ignore-secret.0".to_owned(), + "first-secret".to_owned(), + ), + ( + "restarter.stackable.tech/ignore-secret.1".to_owned(), + "second-secret".to_owned(), + ), + ( + "restarter.stackable.tech/ignore-secret.2".to_owned(), + "third-secret".to_owned(), + ), + ]); + + assert_eq!(expected_annotations, actual_annotations.into()); + } +} diff --git a/crates/stackable-operator/src/v2/cluster_resources.rs b/crates/stackable-operator/src/v2/cluster_resources.rs new file mode 100644 index 000000000..8cea401c4 --- /dev/null +++ b/crates/stackable-operator/src/v2/cluster_resources.rs @@ -0,0 +1,47 @@ +use super::types::{ + kubernetes::{NamespaceName, Uid}, + operator::{ClusterName, ControllerName, OperatorName, ProductName}, +}; +use crate::{ + cluster_resources::{ClusterResourceApplyStrategy, ClusterResources}, + deep_merger::ObjectOverrides, + k8s_openapi::api::core::v1::ObjectReference, + v2::{NameIsValidLabelValue, macros::attributed_string_type::MAX_LABEL_VALUE_LENGTH}, +}; + +/// Infallible variant of [`crate::cluster_resources::ClusterResources::new`] +#[allow(clippy::too_many_arguments)] +pub fn cluster_resources_new<'a>( + product_name: &ProductName, + operator_name: &OperatorName, + controller_name: &ControllerName, + cluster_name: &ClusterName, + cluster_namespace: &NamespaceName, + cluster_uid: &Uid, + apply_strategy: ClusterResourceApplyStrategy, + object_overrides: &'a ObjectOverrides, +) -> ClusterResources<'a> { + // compile-time check + // ClusterResources::new creates a label value from the given app name by appending + // `-operator`. For the resulting label value to be valid, it must not exceed + // MAX_LABEL_VALUE_LENGTH. + const _: () = assert!( + ProductName::MAX_LENGTH + "-operator".len() <= MAX_LABEL_VALUE_LENGTH, + "The string `-operator` must not exceed the limit of Label names." + ); + + ClusterResources::new( + &product_name.to_label_value(), + &operator_name.to_label_value(), + &controller_name.to_label_value(), + &ObjectReference { + name: Some(cluster_name.to_string()), + namespace: Some(cluster_namespace.to_string()), + uid: Some(cluster_uid.to_string()), + ..Default::default() + }, + apply_strategy, + object_overrides, + ) + .expect("ClusterResources should be created because the cluster object reference contains name, namespace and uid.") +} diff --git a/crates/stackable-operator/src/v2/config_file_writer.rs b/crates/stackable-operator/src/v2/config_file_writer.rs new file mode 100644 index 000000000..77f1e0d6d --- /dev/null +++ b/crates/stackable-operator/src/v2/config_file_writer.rs @@ -0,0 +1,128 @@ +//! Writers for Hadoop XML config files and Java `.properties` files. + +use std::{fmt::Write as _, io::Write}; + +use java_properties::{PropertiesError, PropertiesWriter}; +use snafu::{ResultExt, Snafu}; +use xml::escape::escape_str_attribute; + +#[derive(Debug, Snafu)] +pub enum PropertiesWriterError { + #[snafu(display("failed to create properties file"))] + Properties { source: PropertiesError }, + + #[snafu(display("failed to convert properties file byte array to UTF-8"))] + FromUtf8 { source: std::string::FromUtf8Error }, +} + +/// Creates a common Java properties file string in the format: +/// `property_1=value_1\nproperty_2=value_2\n`. +pub fn to_java_properties_string<'a, T>(properties: T) -> Result +where + T: Iterator, +{ + let mut output = Vec::new(); + write_java_properties(&mut output, properties)?; + String::from_utf8(output).context(FromUtf8Snafu) +} + +/// Writes Java properties to the given writer. +fn write_java_properties<'a, W, T>(writer: W, properties: T) -> Result<(), PropertiesWriterError> +where + W: Write, + T: Iterator, +{ + let mut writer = PropertiesWriter::new(writer); + for (k, v) in properties { + writer.write(k, v).context(PropertiesSnafu)?; + } + writer.flush().context(PropertiesSnafu)?; + Ok(()) +} + +/// Converts properties into a Hadoop configuration XML, including the wrapping +/// `...` elements. +pub fn to_hadoop_xml<'a, T>(properties: T) -> String +where + T: Iterator, +{ + let mut snippet = String::new(); + for (k, v) in properties { + let escaped_key = escape_str_attribute(k); + let escaped_value = escape_str_attribute(v); + write!( + snippet, + " \n {escaped_key}\n {escaped_value}\n \n" + ) + .expect("writing to a String is infallible"); + } + format!("\n\n{snippet}") +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + + fn xml(pairs: &[(&str, &str)]) -> String { + let map: BTreeMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + to_hadoop_xml(map.iter()) + } + + fn props(pairs: &[(&str, &str)]) -> String { + let map: BTreeMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + to_java_properties_string(map.iter()).unwrap() + } + + #[test] + fn hadoop_xml_wraps_empty_configuration() { + assert_eq!( + xml(&[]), + "\n\n" + ); + } + + #[test] + fn hadoop_xml_renders_single_property() { + assert_eq!( + xml(&[("fs.defaultFS", "hdfs://hdfs/")]), + "\n\n \ + \n fs.defaultFS\n \ + hdfs://hdfs/\n \n" + ); + } + + #[test] + fn hadoop_xml_escapes_special_characters() { + let rendered = xml(&[("k", "&b")]); + assert!( + rendered.contains("<a>&b"), + "{rendered}" + ); + } + + #[test] + fn java_properties_renders_key_value() { + assert_eq!(props(&[("a", "1"), ("b", "2")]), "a=1\nb=2\n"); + } + + #[test] + fn java_properties_renders_empty() { + assert_eq!(props(&[("empty", "")]), "empty=\n"); + } + + #[test] + fn java_properties_escapes_colon_in_value() { + assert_eq!( + props(&[("url", "file://this/location/file.abc")]), + "url=file\\://this/location/file.abc\n" + ); + } +} diff --git a/crates/stackable-operator/src/v2/config_overrides.rs b/crates/stackable-operator/src/v2/config_overrides.rs new file mode 100644 index 000000000..851662f53 --- /dev/null +++ b/crates/stackable-operator/src/v2/config_overrides.rs @@ -0,0 +1,496 @@ +use std::{ + collections::{BTreeMap, btree_map}, + mem, +}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::warn; + +use crate::{ + config::merge::Merge, k8s_openapi::DeepMerge, schemars, utils::crds::raw_object_schema, +}; + +// Variant of [`crate::config_overrides::KeyValueConfigOverrides`] that implements +// Merge +/// Flat key-value overrides for `*.properties`, Hadoop XML, etc. +#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +pub struct KeyValueConfigOverrides { + #[serde(flatten)] + pub overrides: BTreeMap, +} + +impl<'a> KeyValueConfigOverrides { + pub fn iter(&'a self) -> btree_map::Iter<'a, String, String> { + self.into_iter() + } +} + +impl Merge for KeyValueConfigOverrides { + fn merge(&mut self, defaults: &Self) { + let mut overrides = defaults.overrides.clone(); + overrides.extend(mem::take(&mut self.overrides)); + self.overrides = overrides; + } +} + +impl IntoIterator for KeyValueConfigOverrides { + type IntoIter = btree_map::IntoIter; + type Item = (String, String); + + fn into_iter(self) -> Self::IntoIter { + self.overrides.into_iter() + } +} + +impl<'a> IntoIterator for &'a KeyValueConfigOverrides { + type IntoIter = btree_map::Iter<'a, String, String>; + type Item = (&'a String, &'a String); + + fn into_iter(self) -> Self::IntoIter { + self.overrides.iter() + } +} + +impl From<[(K, V); N]> for KeyValueConfigOverrides +where + K: Into, + V: Into, +{ + fn from(value: [(K, V); N]) -> Self { + Self { + overrides: value.map(|(k, v)| (k.into(), v.into())).into(), + } + } +} + +// Variant of [`crate::config_overrides::JsonConfigOverrides`] with the following +// changes: +// - Implements Default +// - Implements Merge by using a Sequence variant which is not exposed in the CRD +// - `JsonPatches` was renamed to `JsonPatch` because it is one patch consisting of multiple +// operations. +// - `JsonPatch` contains a `json_patch::Patch` instead of a vector of strings +/// ConfigOverrides that can be applied to a JSON file. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum JsonConfigOverrides { + /// Can be set to arbitrary YAML content, which is converted to JSON and used as + /// [RFC 7396](https://datatracker.ietf.org/doc/html/rfc7396) JSON merge patch. + JsonMergePatch(serde_json::Value), + + /// An [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) JSON patch. + /// + /// Can be used when more flexibility is needed, e.g. to only modify elements + /// in a list based on a condition. + /// + /// A patch looks something like + /// + /// `- {"op": "test", "path": "/0/name", "value": "Andrew"}` + /// + /// or + /// + /// `- {"op": "add", "path": "/0/happy", "value": true}` + JsonPatch(json_patch::Patch), + + /// Override the entire config file with the specified JSON value. + UserProvided(serde_json::Value), + + /// Sequence of [`JsonConfigOverrides`] starting with the latest patch + /// + /// This variant is used internally to combine the role and role group configOverrides. They + /// cannot be merged right away because the order of JsonPatch application affects the result. + #[serde(skip)] + Sequence(Vec), +} + +impl JsonConfigOverrides { + // Infallible variant of [`crate::config_overrides::JsonConfigOverrides::apply`] + pub fn apply(&self, base: &serde_json::Value) -> serde_json::Value { + match self { + Self::JsonMergePatch(patch) => { + let mut doc = base.clone(); + doc.merge_from(patch.clone()); + doc + } + Self::JsonPatch(patch) => { + let mut doc = base.clone(); + if let Err(error) = json_patch::patch(&mut doc, patch) { + warn!("The JSON patch could not be applied: {error}"); + } + doc + } + Self::UserProvided(content) => content.clone(), + Self::Sequence(sequence) => { + let mut doc = base.clone(); + // `sequence` starts with the latest patch. Iterate in reverse order, to apply the + // patches from the first to the last one. + for patch in sequence.iter().rev() { + doc = patch.apply(&doc); + } + doc + } + } + } +} + +impl Default for JsonConfigOverrides { + fn default() -> Self { + // There are several options to represent an empty patch, e.g. + // `JsonConfigOverrides::Sequence(vec![])`. As this is exposed as the default in the CRD, + // an empty JSON merge patch is returned, because JSON merge patches are the preferred way + // to override the configuration. + Self::JsonMergePatch(json!({})) + } +} + +impl Merge for JsonConfigOverrides { + fn merge(&mut self, defaults: &Self) { + let mut sequence = if let Self::Sequence(sequence) = self { + sequence.clone() + } else { + vec![self.clone()] + }; + + if let Self::Sequence(base) = defaults { + sequence.extend(base.clone()); + } else { + sequence.push(defaults.clone()); + } + + *self = Self::Sequence(sequence); + } +} + +impl From for JsonConfigOverrides { + fn from(value: KeyValueConfigOverrides) -> Self { + Self::JsonMergePatch(value.overrides.into_iter().collect()) + } +} + +/// ConfigOverrides as key-value pairs, JSON merge patch, JSON patch or JSON object. +/// +/// The key-value pairs, JSON merge patch and JSON patch are merged with/applied to the +/// configuration provided by the operator. The user-provided JSON object replaces the +/// configuration of the operator. +/// +/// Example for key-value pairs: +/// +/// ```yaml +/// stringProperty: new value +/// booleanProperty: "true" +/// ``` +/// +/// Example for a JSON merge patch: +/// +/// ```yaml +/// jsonMergePatch: +/// stringProperty: new value +/// booleanProperty: true +/// nestedProperty: +/// key: value +/// ``` +/// +/// Example for a JSON patch: +/// +/// ```yaml +/// jsonPatch: +/// - op: replace +/// path: /stringProperty +/// value: new value +/// ``` +/// +/// Example for a JSON object: +/// +/// ```yaml +/// userProvided: +/// stringProperty: new value +/// booleanProperty: true +/// nestedProperty: +/// key: value +/// ``` +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(untagged)] +#[schemars(schema_with = "raw_object_schema")] +pub enum JsonOrKeyValueConfigOverrides { + Json(JsonConfigOverrides), + KeyValue(KeyValueConfigOverrides), +} + +impl Default for JsonOrKeyValueConfigOverrides { + fn default() -> Self { + Self::Json(JsonConfigOverrides::default()) + } +} + +impl From for JsonConfigOverrides { + fn from(value: JsonOrKeyValueConfigOverrides) -> Self { + match value { + JsonOrKeyValueConfigOverrides::KeyValue(key_value_config_overrides) => { + key_value_config_overrides.into() + } + JsonOrKeyValueConfigOverrides::Json(json_config_overrides) => json_config_overrides, + } + } +} + +impl Merge for JsonOrKeyValueConfigOverrides { + fn merge(&mut self, defaults: &Self) { + let mut self_json_config_overrides: JsonConfigOverrides = self.clone().into(); + let defaults_json_config_overrides = defaults.clone().into(); + + self_json_config_overrides.merge(&defaults_json_config_overrides); + + *self = Self::Json(self_json_config_overrides); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::config::merge; + + #[test] + fn test_json_config_overrides_apply() { + let base = json!({ + "keyA": "base A", + "keyB": "base B" + }); + + let json_merge_patch = JsonConfigOverrides::JsonMergePatch(json!({ + "keyB": "patch B", + "keyC": "patch C" + })); + + assert_eq!( + json!({ + "keyA": "base A", + "keyB": "patch B", + "keyC": "patch C" + }), + json_merge_patch.apply(&base) + ); + + let json_patch = JsonConfigOverrides::JsonPatch( + serde_json::from_value(json!([ + { "op": "replace", "path": "/keyB", "value": "patch B" }, + { "op": "add", "path": "/keyC", "value": "patch C" }, + ])) + .expect("should contain valid JSON patch operations"), + ); + + assert_eq!( + json!({ + "keyA": "base A", + "keyB": "patch B", + "keyC": "patch C", + }), + json_patch.apply(&base) + ); + + let invalid_json_patch = JsonConfigOverrides::JsonPatch( + serde_json::from_value(json!([ + { "op": "replace", "path": "/keyB", "value": "patch B" }, + { "op": "remove", "path": "/keyD" } + ])) + .expect("should contain valid JSON patch operations"), + ); + + // invalid_json_patch cannot be applied because the path "/keyD" does not exist in base. + // A warning should be logged and the changes should be rolled back, i.e. "keyB" should be + // "base B" instead of "patch B". + assert_eq!( + json!({ + "keyA": "base A", + "keyB": "base B", + }), + invalid_json_patch.apply(&base) + ); + + let user_provided = JsonConfigOverrides::UserProvided(json!({ + "keyB": "patch B", + "keyC": "patch C" + })); + + assert_eq!( + json!({ + "keyB": "patch B", + "keyC": "patch C" + }), + user_provided.apply(&base) + ); + + let sequence = JsonConfigOverrides::Sequence(vec![ + // There should be no nested sequences, but as it is not technically prevented, it is + // tested nevertheless. + JsonConfigOverrides::Sequence(vec![JsonConfigOverrides::JsonMergePatch(json!({ + "keyC": "patch C.2", + "keyD": "patch D.2" + }))]), + JsonConfigOverrides::JsonMergePatch(json!({ + "keyB": "patch B.1", + "keyC": "patch C.1" + })), + ]); + + assert_eq!( + json!({ + "keyA": "base A", + "keyB": "patch B.1", + "keyC": "patch C.2", + "keyD": "patch D.2" + }), + sequence.apply(&base) + ); + } + + #[test] + fn test_json_config_overrides_merge() { + let sequence1 = JsonConfigOverrides::Sequence(vec![ + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 1.2", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 1.1", + })), + ]); + + let sequence2 = JsonConfigOverrides::Sequence(vec![ + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 2.2", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 2.1", + })), + ]); + + // It does not matter for the test case if the JsonMergePatch, JsonPatch or UserProvided + // variant is chosen. + let json_merge_patch1 = JsonConfigOverrides::JsonMergePatch(json!({ + "key": "patch 1", + })); + + let json_merge_patch2 = JsonConfigOverrides::JsonMergePatch(json!({ + "key": "patch 2", + })); + + assert_eq!( + JsonConfigOverrides::Sequence(vec![ + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 2.2", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 2.1", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 1.2", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 1.1", + })), + ]), + merge::merge(sequence2.clone(), &sequence1) + ); + + assert_eq!( + JsonConfigOverrides::Sequence(vec![ + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "patch 2", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 1.2", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 1.1", + })), + ]), + merge::merge(json_merge_patch2.clone(), &sequence1) + ); + + assert_eq!( + JsonConfigOverrides::Sequence(vec![ + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 2.2", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "sequence 2.1", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "patch 1", + })), + ]), + merge::merge(sequence2.clone(), &json_merge_patch1) + ); + + assert_eq!( + JsonConfigOverrides::Sequence(vec![ + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "patch 2", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "patch 1", + })), + ]), + merge::merge(json_merge_patch2.clone(), &json_merge_patch1) + ); + } + + #[test] + fn test_json_config_overrides_from_key_value_config_overrides() { + let key_value_config_overrides = KeyValueConfigOverrides { + overrides: [("key".to_owned(), "value".to_owned())].into(), + }; + + let actual_json_config_overrides: JsonConfigOverrides = key_value_config_overrides.into(); + + let expected_json_config_overrides = + JsonConfigOverrides::JsonMergePatch(json!({"key": "value"})); + + assert_eq!(expected_json_config_overrides, actual_json_config_overrides); + } + + #[test] + fn test_json_config_overrides_from_json_or_key_value_config_overrides() { + let key_value_config_overrides = + JsonOrKeyValueConfigOverrides::KeyValue(KeyValueConfigOverrides { + overrides: [("key".to_owned(), "value".to_owned())].into(), + }); + + let actual_json_config_overrides: JsonConfigOverrides = key_value_config_overrides.into(); + + let expected_json_config_overrides = + JsonConfigOverrides::JsonMergePatch(json!({"key": "value"})); + + assert_eq!(expected_json_config_overrides, actual_json_config_overrides); + } + + #[test] + fn test_json_or_key_value_config_overrides_merge() { + let base = JsonOrKeyValueConfigOverrides::KeyValue(KeyValueConfigOverrides { + overrides: [("key".to_owned(), "base".to_owned())].into(), + }); + + let patch = JsonOrKeyValueConfigOverrides::KeyValue(KeyValueConfigOverrides { + overrides: [("key".to_owned(), "patch".to_owned())].into(), + }); + + // The merge implementation internally converts KeyValueConfigOverrides to + // JsonConfigOverrides. It is already tested in [`test_json_config_overrides_merge`] that + // merging JsonConfigOverrides works. Therefore, one test case with KeyValueConfigOverrides + // is sufficient. + assert_eq!( + JsonOrKeyValueConfigOverrides::Json(JsonConfigOverrides::Sequence(vec![ + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "patch", + })), + JsonConfigOverrides::JsonMergePatch(json!({ + "key": "base", + })) + ])), + merge::merge(patch, &base) + ); + } +} diff --git a/crates/stackable-operator/src/v2/controller_utils.rs b/crates/stackable-operator/src/v2/controller_utils.rs new file mode 100644 index 000000000..54ee5ea81 --- /dev/null +++ b/crates/stackable-operator/src/v2/controller_utils.rs @@ -0,0 +1,215 @@ +//! Helper functions which are not tied to a specific controller step + +use std::str::FromStr; + +use snafu::{OptionExt, ResultExt, Snafu}; +use strum::{EnumDiscriminants, IntoStaticStr}; + +use crate::{ + kube::runtime::reflector::Lookup, + v2::types::{ + kubernetes::{NamespaceName, Uid}, + operator::ClusterName, + }, +}; + +#[derive(Snafu, Debug, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr))] +pub enum Error { + #[snafu(display("failed to get the cluster name"))] + GetClusterName {}, + + #[snafu(display("failed to get the namespace"))] + GetNamespace {}, + + #[snafu(display("failed to get the UID"))] + GetUid {}, + + #[snafu(display("failed to set the cluster name"))] + ParseClusterName { + source: crate::v2::macros::attributed_string_type::Error, + }, + + #[snafu(display("failed to set the namespace"))] + ParseNamespace { + source: crate::v2::macros::attributed_string_type::Error, + }, + + #[snafu(display("failed to set the UID"))] + ParseUid { + source: crate::v2::macros::attributed_string_type::Error, + }, +} + +type Result = std::result::Result; + +/// Get the cluster name from the given resource +pub fn get_cluster_name(cluster: &impl Lookup) -> Result { + let raw_cluster_name = cluster.name().context(GetClusterNameSnafu)?; + let cluster_name = ClusterName::from_str(&raw_cluster_name).context(ParseClusterNameSnafu)?; + + Ok(cluster_name) +} + +/// Get the namespace from the given resource +pub fn get_namespace(resource: &impl Lookup) -> Result { + let raw_namespace = resource.namespace().context(GetNamespaceSnafu)?; + let namespace = NamespaceName::from_str(&raw_namespace).context(ParseNamespaceSnafu)?; + + Ok(namespace) +} + +/// Get the UID from the given resource +pub fn get_uid(resource: &impl Lookup) -> Result { + let raw_uid = resource.uid().context(GetUidSnafu)?; + let uid = Uid::from_str(&raw_uid).context(ParseUidSnafu)?; + + Ok(uid) +} + +#[cfg(test)] +mod tests { + use uuid::uuid; + + use super::{ErrorDiscriminants, get_cluster_name, get_namespace, get_uid}; + use crate::{ + kube::runtime::reflector::Lookup, + v2::types::{ + kubernetes::{NamespaceName, Uid}, + operator::ClusterName, + }, + }; + + #[derive(Debug, Default)] + struct TestResource { + name: Option<&'static str>, + namespace: Option<&'static str>, + uid: Option<&'static str>, + } + + impl Lookup for TestResource { + type DynamicType = (); + + fn kind(_dyntype: &Self::DynamicType) -> std::borrow::Cow<'_, str> { + "TestResource".into() + } + + fn group(_dyntype: &Self::DynamicType) -> std::borrow::Cow<'_, str> { + "stackable.tech".into() + } + + fn version(_dyntype: &Self::DynamicType) -> std::borrow::Cow<'_, str> { + "v1".into() + } + + fn plural(_dyntype: &Self::DynamicType) -> std::borrow::Cow<'_, str> { + "testresources".into() + } + + fn name(&self) -> Option> { + self.name.map(std::borrow::Cow::Borrowed) + } + + fn namespace(&self) -> Option> { + self.namespace.map(std::borrow::Cow::Borrowed) + } + + fn resource_version(&self) -> Option> { + Some("1".into()) + } + + fn uid(&self) -> Option> { + self.uid.map(std::borrow::Cow::Borrowed) + } + } + + #[test] + fn test_get_cluster_name() { + assert_eq!( + ClusterName::from_str_unsafe("test-cluster"), + get_cluster_name(&TestResource { + name: Some("test-cluster"), + ..TestResource::default() + }) + .expect("should contain a valid cluster name") + ); + + assert_eq!( + Err(ErrorDiscriminants::GetClusterName), + get_cluster_name(&TestResource { + name: None, + ..TestResource::default() + }) + .map_err(ErrorDiscriminants::from) + ); + + assert_eq!( + Err(ErrorDiscriminants::ParseClusterName), + get_cluster_name(&TestResource { + name: Some("invalid cluster name"), + ..TestResource::default() + }) + .map_err(ErrorDiscriminants::from) + ); + } + + #[test] + fn test_get_namespace() { + assert_eq!( + NamespaceName::from_str_unsafe("test-namespace"), + get_namespace(&TestResource { + namespace: Some("test-namespace"), + ..TestResource::default() + }) + .expect("should contain a valid namespace") + ); + + assert_eq!( + Err(ErrorDiscriminants::GetNamespace), + get_namespace(&TestResource { + namespace: None, + ..TestResource::default() + }) + .map_err(ErrorDiscriminants::from) + ); + + assert_eq!( + Err(ErrorDiscriminants::ParseNamespace), + get_namespace(&TestResource { + namespace: Some("invalid namespace"), + ..TestResource::default() + }) + .map_err(ErrorDiscriminants::from) + ); + } + + #[test] + fn test_get_uid() { + assert_eq!( + Uid::from(uuid!("e6ac237d-a6d4-43a1-8135-f36506110912")), + get_uid(&TestResource { + uid: Some("e6ac237d-a6d4-43a1-8135-f36506110912"), + ..TestResource::default() + }) + .expect("should contain a valid UID") + ); + + assert_eq!( + Err(ErrorDiscriminants::GetUid), + get_uid(&TestResource { + uid: None, + ..TestResource::default() + }) + .map_err(ErrorDiscriminants::from) + ); + + assert_eq!( + Err(ErrorDiscriminants::ParseUid), + get_uid(&TestResource { + uid: Some("invalid UID"), + ..TestResource::default() + }) + .map_err(ErrorDiscriminants::from) + ); + } +} diff --git a/crates/stackable-operator/src/v2/flask_config_writer.rs b/crates/stackable-operator/src/v2/flask_config_writer.rs new file mode 100644 index 000000000..41f41e513 --- /dev/null +++ b/crates/stackable-operator/src/v2/flask_config_writer.rs @@ -0,0 +1,314 @@ +//! Writer for Flask App configurations (Python config files). +//! +//! Primitive types are escaped accordingly. Python expressions are written as-is; +//! invalid expressions produce invalid configuration files. Config overrides that do +//! not map to a known option are treated as plain expressions. + +use std::{ + io::{self, Write}, + num::ParseIntError, + str::{FromStr, ParseBoolError}, +}; + +use snafu::{ResultExt, Snafu}; + +/// Errors which can occur when using this module +#[derive(Debug, Snafu)] +pub enum FlaskAppConfigWriterError { + #[snafu(display("failed to convert '{value}' into a identifier"))] + ConvertIdentifier { value: String }, + + #[snafu(display("failed to convert '{value}' into a boolean literal"))] + ConvertBoolLiteral { + value: String, + source: ParseBoolError, + }, + + #[snafu(display("failed to convert '{value}' into an integer literal"))] + ConvertIntLiteral { + value: String, + source: ParseIntError, + }, + + #[snafu(display("failed to convert '{value}' into an ASCII string literal"))] + ConvertStringLiteral { value: String }, + + #[snafu(display("failed to convert '{value}' into a Python expression"))] + ConvertExpression { value: String }, + + #[snafu(display("Configuration cannot be written."))] + WriteConfig { source: io::Error }, +} + +/// Mapping from configuration options to Python types. +pub trait FlaskAppConfigOptions { + fn python_type(&self) -> PythonType; +} + +/// All supported Python types +pub enum PythonType { + /// Python identifier + Identifier, + /// Boolean literal + BoolLiteral, + /// Integer literal + IntLiteral, + /// ASCII string literal + StringLiteral, + /// Python expression + Expression, +} + +impl PythonType { + /// Converts the given string to Python. + fn convert_to_python(&self, value: &str) -> Result { + let convert = match self { + Self::Identifier => Self::convert_to_python_identifier, + Self::BoolLiteral => Self::convert_to_python_bool_literal, + Self::IntLiteral => Self::convert_to_python_int_literal, + Self::StringLiteral => Self::convert_to_python_string_literal, + Self::Expression => Self::convert_to_python_expression, + }; + + convert(value) + } + + fn convert_to_python_identifier(value: &str) -> Result { + if value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && value + .chars() + .next() + .filter(|c| !c.is_ascii_digit()) + .is_some() + { + Ok(value.to_string()) + } else { + ConvertIdentifierSnafu { value }.fail() + } + } + + fn convert_to_python_bool_literal(value: &str) -> Result { + value + .parse::() + .map(|b| if b { "True".into() } else { "False".into() }) + .context(ConvertBoolLiteralSnafu { value }) + } + + fn convert_to_python_int_literal(value: &str) -> Result { + value + .parse::() + .map(|i| i.to_string()) + .context(ConvertIntLiteralSnafu { value }) + } + + fn convert_to_python_string_literal(value: &str) -> Result { + if value.is_ascii() { + Ok(format!("\"{}\"", value.escape_default())) + } else { + ConvertStringLiteralSnafu { value }.fail() + } + } + + fn convert_to_python_expression(value: &str) -> Result { + if value.trim().is_empty() { + ConvertExpressionSnafu { value }.fail() + } else { + Ok(value.to_string()) + } + } +} + +/// Writes a configuration file according to the given `FlaskAppConfigOptions` type. +pub fn write<'a, O, P, W>( + writer: &mut W, + properties: P, + imports: &[&str], +) -> Result<(), FlaskAppConfigWriterError> +where + O: FlaskAppConfigOptions + FromStr, + P: Iterator, + W: Write, +{ + for import in imports { + writeln!(writer, "{import}").context(WriteConfigSnafu)?; + } + + writeln!(writer).context(WriteConfigSnafu)?; + + for (name, value) in properties { + let variable = PythonType::Identifier.convert_to_python(name)?; + + // If an option cannot be mapped to a Python type then it is a config override and treated + // as Python expression. + let content = O::from_str(name) + .map(|option| option.python_type()) + .unwrap_or(PythonType::Expression) + .convert_to_python(value)?; + + writeln!(writer, "{variable} = {content}").context(WriteConfigSnafu)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + str::{FromStr, from_utf8}, + }; + + use rstest::*; + + use super::{FlaskAppConfigOptions, FlaskAppConfigWriterError, PythonType, write}; + + #[rstest] + #[case::valid_identifiers_are_converted_to_python( + PythonType::Identifier, &[ + ("_", "_"), + ("a", "a"), + ("A", "A"), + ("__", "__"), + ("_a", "_a"), + ("_A", "_A"), + ("_0", "_0"), + ("SECRET_KEY", "SECRET_KEY"), + ] + )] + #[case::valid_booleans_are_converted_to_python( + PythonType::BoolLiteral, &[ + ("False", "false"), + ("True", "true"), + ] + )] + #[case::valid_integers_are_converted_to_python( + PythonType::IntLiteral, &[ + ("-9223372036854775808", "-9223372036854775808"), + ("0", "0"), + ("9223372036854775807", "9223372036854775807"), + ] + )] + #[case::valid_strings_are_converted_to_python( + PythonType::StringLiteral, &[ + (r#""""#, ""), + (r#"" ~""#, " ~"), + (r#""\t\r\n\'\"\\""#, "\t\r\n'\"\\"), + ] + )] + #[case::valid_expressions_are_converted_to_python( + PythonType::Expression, &[ + ("os.environ[\"HOME\"]", "os.environ[\"HOME\"]"), + ] + )] + fn valid_values_are_converted_to_python( + #[case] python_type: PythonType, + #[case] values: &[(&str, &str)], + ) -> Result<(), FlaskAppConfigWriterError> { + for (expected, input) in values { + assert_eq!(*expected, python_type.convert_to_python(input)?); + } + + Ok(()) + } + + #[rstest] + #[case::invalid_identifiers_are_not_converted_to_python( + PythonType::Identifier, &[ + "", "0", "-", "\n", "_-", "_\n", + ] + )] + #[case::invalid_booleans_are_not_converted_to_python( + PythonType::BoolLiteral, &[ + "", "False", "True", "0", "1", + ] + )] + #[case::invalid_integers_are_not_converted_to_python( + PythonType::IntLiteral, &[ + "", "a", "0x10", "inf", + ] + )] + #[case::invalid_strings_are_not_converted_to_python( + PythonType::StringLiteral, &[ + "ä", "❤" + ] + )] + #[case::invalid_expressions_are_not_converted_to_python( + PythonType::Expression, &[ + "" + ] + )] + fn invalid_values_are_converted_to_python( + #[case] python_type: PythonType, + #[case] values: &[&str], + ) { + for input in values { + assert!(python_type.convert_to_python(input).is_err()); + } + } + + #[test] + fn valid_options_are_written_into_a_configuration() { + #[allow(clippy::enum_variant_names)] + enum Options { + BoolOption, + IntOption, + StringOption, + ExpressionOption, + _UnusedOption, + } + + impl FromStr for Options { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "BOOL_OPTION" => Ok(Self::BoolOption), + "INT_OPTION" => Ok(Self::IntOption), + "STRING_OPTION" => Ok(Self::StringOption), + "EXPRESSION_OPTION" => Ok(Self::ExpressionOption), + _ => Err("unknown option"), + } + } + } + + impl FlaskAppConfigOptions for Options { + fn python_type(&self) -> PythonType { + match self { + Self::BoolOption => PythonType::BoolLiteral, + Self::IntOption => PythonType::IntLiteral, + Self::StringOption => PythonType::StringLiteral, + Self::ExpressionOption | Self::_UnusedOption => PythonType::Expression, + } + } + } + + let config: BTreeMap<_, _> = [ + ("BOOL_OPTION", "true"), + ("INT_OPTION", "0"), + ("STRING_OPTION", ""), + ("EXPRESSION_OPTION", "{ \"key\": \"value\" }"), + ("OVERRIDDEN_OPTION", "None"), + ] + .map(|(k, v)| (k.to_string(), v.to_string())) + .into(); + + let imports = ["import module", "from module import member"]; + + let mut config_file = Vec::new(); + write::(&mut config_file, config.iter(), &imports) + .expect("writing the test configuration should succeed"); + + assert_eq!( + r#"import module +from module import member + +BOOL_OPTION = True +EXPRESSION_OPTION = { "key": "value" } +INT_OPTION = 0 +OVERRIDDEN_OPTION = None +STRING_OPTION = "" +"#, + from_utf8(&config_file).expect("the Flask config writer only emits valid UTF-8") + ); + } +} diff --git a/crates/stackable-operator/src/v2/jvm_argument_overrides.rs b/crates/stackable-operator/src/v2/jvm_argument_overrides.rs new file mode 100644 index 000000000..c6f0623b3 --- /dev/null +++ b/crates/stackable-operator/src/v2/jvm_argument_overrides.rs @@ -0,0 +1,268 @@ +use std::{borrow::Cow, collections::HashSet}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize, Serializer, de::Error}; + +use crate::config::merge::Merge; + +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JvmArgumentOverrides { + /// JVM arguments to be added + #[serde(default)] + add: Vec, + + /// JVM arguments to be removed by exact match + // + // HashSet to be optimized for quick lookup + #[serde(default)] + remove: HashSet, + + /// JVM arguments matching any of this regexes will be removed + #[serde(default)] + remove_regex: RegexSet, + + /// Sequence of [`JvmArgumentOverrides`] which must be applied before this one + /// + /// This field is used internally to combine the role and role group overrides. The fields of + /// the role group cannot just be appended to the ones of the role because the fields `remove`, + /// `remove_regex` and `add` of the role must be applied before the ones of the role group. + #[serde(skip)] + preceding_overrides: Vec, +} + +impl Merge for JvmArgumentOverrides { + fn merge(&mut self, defaults: &Self) { + self.preceding_overrides.push(defaults.clone()); + } +} + +impl JvmArgumentOverrides { + pub fn apply_to(&self, jvm_arguments: impl IntoIterator) -> Vec { + // 1. Apply the preceding overrides + self.preceding_overrides + .iter() + // The vector should only contain one element, but if it contains more than one then + // start with the one that was added last. + .rev() + .fold( + jvm_arguments.into_iter().collect(), + |jvm_arguments, overrides| overrides.apply_to(jvm_arguments), + ) + .into_iter() + // 2. Remove exact matches + .filter(|arg| !self.remove.contains(arg)) + // 3. Remove arguments matching the regexes + .filter(|arg| !self.remove_regex.0.is_match(arg)) + // 4. Add arguments + .chain(self.add.clone()) + .collect() + } +} + +#[derive(Clone, Debug, Default)] +struct RegexSet(regex::RegexSet); + +impl<'de> Deserialize<'de> for RegexSet { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let regexes = >>::deserialize(deserializer)?; + + let anchored_regexes = regexes + .iter() + .map(|maybe_anchored_regex| { + maybe_anchored_regex + .trim_start_matches('^') + .trim_end_matches('$') + }) + .map(|unanchored_regex| format!("^{unanchored_regex}$")); + + match regex::RegexSet::new(anchored_regexes) { + Ok(regexset) => Ok(Self(regexset)), + Err(err) => Err(D::Error::custom(err)), + } + } +} + +impl Serialize for RegexSet { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.patterns().serialize(serializer) + } +} + +impl Eq for RegexSet {} + +impl PartialEq for RegexSet { + fn eq(&self, other: &Self) -> bool { + self.0.patterns() == other.0.patterns() + } +} + +impl JsonSchema for RegexSet { + fn schema_name() -> std::borrow::Cow<'static, str> { + "RegexSet".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "array", + "items": { + "type": "string" + } + }) + } +} + +#[cfg(test)] +mod tests { + use stackable_operator_derive::Fragment; + + use super::*; + use crate::{ + role_utils::{GenericRoleConfig, Role, RoleGroup}, + v2::role_utils::{JavaCommonConfig, with_validated_config}, + }; + + // #[derive( + // Clone, Debug, Default, Deserialize, Fragment, JsonSchema, Merge, PartialEq, Serialize, + // )] + #[derive(Debug, Fragment, PartialEq)] + #[fragment_attrs(derive(Clone, Debug, Default, Deserialize, Eq, PartialEq))] + #[fragment(path_overrides(fragment = "crate::config::fragment",))] + struct EmptyConfig {} + + impl Merge for EmptyConfigFragment { + fn merge(&mut self, _defaults: &Self) {} + } + + #[derive(Clone, Debug, Default, Deserialize, JsonSchema, Merge, PartialEq, Serialize)] + #[merge(path_overrides(merge = "crate::config::merge"))] + struct EmptyConfigOverrides {} + + #[test] + fn test_merge_java_common_config() { + // The operator generates some JVM arguments + let operator_generated = [ + "-Xms34406m".to_owned(), + "-Xmx34406m".to_owned(), + "-XX:+UseG1GC".to_owned(), + "-XX:+ExitOnOutOfMemoryError".to_owned(), + "-Djava.protocol.handler.pkgs=sun.net.www.protocol".to_owned(), + "-Dsun.net.http.allowRestrictedHeaders=true".to_owned(), + "-Djava.security.properties=/stackable/nifi/conf/security.properties".to_owned(), + ]; + + let entire_role: Role< + EmptyConfigFragment, + EmptyConfigOverrides, + GenericRoleConfig, + JavaCommonConfig, + > = serde_yaml::from_str( + " + # Let's say we want to set some additional HTTP Proxy and IPv4 settings + # And we don't like the garbage collector for some reason... + jvmArgumentOverrides: + remove: + - -XX:+UseG1GC + add: # Add some networking arguments + - -Dhttps.proxyHost=proxy.my.corp + - -Dhttps.proxyPort=8080 + - -Djava.net.preferIPv4Stack=true + roleGroups: + default: + # For the roleGroup, let's say we need a different memory config. + # For that to work we first remove the flags generated by the operator and add our own. + # Also we override the proxy port to test that the roleGroup config takes precedence over the role config. + jvmArgumentOverrides: + removeRegex: + - -Xmx.* + - -Dhttps.proxyPort=.* + add: + - -Xmx40000m + - -Dhttps.proxyPort=1234 + ") + .expect("Failed to parse role"); + + let role_group = entire_role + .role_groups + .get("default") + .expect("role group should be defined"); + + let validated_config: RoleGroup = + with_validated_config(role_group, &entire_role, &EmptyConfigFragment {}) + .expect("role spec should be valid"); + + let effective_jvm_config = validated_config + .config + .product_specific_common_config + .jvm_argument_overrides + .apply_to(operator_generated); + + let expected = vec![ + "-Xms34406m".to_owned(), + "-XX:+ExitOnOutOfMemoryError".to_owned(), + "-Djava.protocol.handler.pkgs=sun.net.www.protocol".to_owned(), + "-Dsun.net.http.allowRestrictedHeaders=true".to_owned(), + "-Djava.security.properties=/stackable/nifi/conf/security.properties".to_owned(), + "-Dhttps.proxyHost=proxy.my.corp".to_owned(), + "-Djava.net.preferIPv4Stack=true".to_owned(), + "-Xmx40000m".to_owned(), + "-Dhttps.proxyPort=1234".to_owned(), + ]; + + assert_eq!(effective_jvm_config, expected); + } + + #[test] + fn test_merge_java_common_config_keep_order() { + let operator_generated = ["-Xms1m".to_owned()]; + + let entire_role: Role< + EmptyConfigFragment, + EmptyConfigOverrides, + GenericRoleConfig, + JavaCommonConfig, + > = serde_yaml::from_str( + " + jvmArgumentOverrides: + add: + - -Xms2m + roleGroups: + default: + jvmArgumentOverrides: + add: + - -Xms3m + ", + ) + .expect("Failed to parse role"); + + let role_group = entire_role + .role_groups + .get("default") + .expect("role group should be defined"); + + let validated_config: RoleGroup = + with_validated_config(role_group, &entire_role, &EmptyConfigFragment {}) + .expect("role spec should be valid"); + + let effective_jvm_config = validated_config + .config + .product_specific_common_config + .jvm_argument_overrides + .apply_to(operator_generated); + + assert_eq!( + effective_jvm_config, + &[ + "-Xms1m".to_owned(), + "-Xms2m".to_owned(), + "-Xms3m".to_owned() + ] + ); + } +} diff --git a/crates/stackable-operator/src/v2/kvp.rs b/crates/stackable-operator/src/v2/kvp.rs new file mode 100644 index 000000000..0006163a0 --- /dev/null +++ b/crates/stackable-operator/src/v2/kvp.rs @@ -0,0 +1 @@ +pub mod label; diff --git a/crates/stackable-operator/src/v2/kvp/label.rs b/crates/stackable-operator/src/v2/kvp/label.rs new file mode 100644 index 000000000..d2681413f --- /dev/null +++ b/crates/stackable-operator/src/v2/kvp/label.rs @@ -0,0 +1,211 @@ +use crate::{ + kube::Resource, + kvp::{Labels, ObjectLabels}, + v2::{ + HasName, NameIsValidLabelValue, + types::operator::{ + ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, RoleName, + }, + }, +}; + +/// Infallible variant of [`crate::kvp::Labels::recommended`] +pub fn recommended_labels( + owner: &(impl Resource + HasName + NameIsValidLabelValue), + product_name: &ProductName, + product_version: &ProductVersion, + operator_name: &OperatorName, + controller_name: &ControllerName, + role_name: &RoleName, + role_group_name: &RoleGroupName, +) -> Labels { + let object_labels = ObjectLabels { + owner, + app_name: &product_name.to_label_value(), + app_version: &product_version.to_label_value(), + operator_name: &operator_name.to_label_value(), + controller_name: &controller_name.to_label_value(), + role: &role_name.to_label_value(), + role_group: &role_group_name.to_label_value(), + }; + Labels::recommended(&object_labels).expect( + "Labels should be created because the owner has an object name and all given parameters \ + produce valid label values.", + ) +} + +/// Infallible variant of [`crate::kvp::Labels::role_selector`] +pub fn role_selector( + owner: &(impl Resource + HasName + NameIsValidLabelValue), + product_name: &ProductName, + role_name: &RoleName, +) -> Labels { + Labels::role_selector( + owner, + &product_name.to_label_value(), + &role_name.to_label_value(), + ) + .expect("Labels should be created because all given parameters produce valid label values") +} + +/// Infallible variant of [`crate::kvp::Labels::role_group_selector`] +pub fn role_group_selector( + owner: &(impl Resource + HasName + NameIsValidLabelValue), + product_name: &ProductName, + role_name: &RoleName, + role_group_name: &RoleGroupName, +) -> Labels { + Labels::role_group_selector( + owner, + &product_name.to_label_value(), + &role_name.to_label_value(), + &role_group_name.to_label_value(), + ) + .expect("Labels should be created because all given parameters produce valid label values") +} + +#[cfg(test)] +mod tests { + use std::{borrow::Cow, collections::BTreeMap}; + + use crate::{ + k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta, + kube::Resource, + v2::{ + HasName, NameIsValidLabelValue, + kvp::label::{recommended_labels, role_group_selector, role_selector}, + types::operator::{ + ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, RoleName, + }, + }, + }; + + struct Cluster { + object_meta: ObjectMeta, + } + + impl Cluster { + fn new() -> Self { + Self { + object_meta: ObjectMeta { + name: Some("cluster-name".to_owned()), + ..ObjectMeta::default() + }, + } + } + } + + impl Resource for Cluster { + type DynamicType = (); + type Scope = (); + + fn kind(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("kind") + } + + fn group(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("group") + } + + fn version(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("version") + } + + fn plural(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("plural") + } + + fn meta(&self) -> &ObjectMeta { + &self.object_meta + } + + fn meta_mut(&mut self) -> &mut ObjectMeta { + &mut self.object_meta + } + } + + impl HasName for Cluster { + fn to_name(&self) -> String { + self.object_meta + .name + .clone() + .expect("should be set in Cluster::new") + } + } + + impl NameIsValidLabelValue for Cluster { + fn to_label_value(&self) -> String { + self.object_meta + .name + .clone() + .expect("should be set in Cluster::new") + } + } + + #[test] + fn test_recommended_labels() { + let actual_labels = recommended_labels( + &Cluster::new(), + &ProductName::from_str_unsafe("my-product"), + &ProductVersion::from_str_unsafe("1.0.0"), + &OperatorName::from_str_unsafe("my-operator"), + &ControllerName::from_str_unsafe("my-controller"), + &RoleName::from_str_unsafe("my-role"), + &RoleGroupName::from_str_unsafe("my-role-group"), + ); + + let expected_labels: BTreeMap = [ + ("app.kubernetes.io/component", "my-role"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/managed-by", "my-operator_my-controller"), + ("app.kubernetes.io/name", "my-product"), + ("app.kubernetes.io/role-group", "my-role-group"), + ("app.kubernetes.io/version", "1.0.0"), + ("stackable.tech/vendor", "Stackable"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(); + + assert_eq!(expected_labels, actual_labels.into()); + } + + #[test] + fn test_role_selector() { + let actual_labels = role_selector( + &Cluster::new(), + &ProductName::from_str_unsafe("my-product"), + &RoleName::from_str_unsafe("my-role"), + ); + + let expected_labels: BTreeMap = [ + ("app.kubernetes.io/component", "my-role"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/name", "my-product"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(); + + assert_eq!(expected_labels, actual_labels.into()); + } + + #[test] + fn test_role_group_selector() { + let actual_labels = role_group_selector( + &Cluster::new(), + &ProductName::from_str_unsafe("my-product"), + &RoleName::from_str_unsafe("my-role"), + &RoleGroupName::from_str_unsafe("my-role-group"), + ); + + let expected_labels: BTreeMap = [ + ("app.kubernetes.io/component", "my-role"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/name", "my-product"), + ("app.kubernetes.io/role-group", "my-role-group"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(); + + assert_eq!(expected_labels, actual_labels.into()); + } +} diff --git a/crates/stackable-operator/src/v2/macros.rs b/crates/stackable-operator/src/v2/macros.rs new file mode 100644 index 000000000..c25def95a --- /dev/null +++ b/crates/stackable-operator/src/v2/macros.rs @@ -0,0 +1,2 @@ +pub mod attributed_string_type; +pub mod constant; diff --git a/crates/stackable-operator/src/v2/macros/attributed_string_type.rs b/crates/stackable-operator/src/v2/macros/attributed_string_type.rs new file mode 100644 index 000000000..7c84b8212 --- /dev/null +++ b/crates/stackable-operator/src/v2/macros/attributed_string_type.rs @@ -0,0 +1,922 @@ +use snafu::Snafu; +use strum::{EnumDiscriminants, IntoStaticStr}; + +/// Maximum length of label values +/// +/// Duplicates the private constant [`crate::kvp::LABEL_VALUE_MAX_LEN`] +pub const MAX_LABEL_VALUE_LENGTH: usize = 63; + +#[derive(Debug, EnumDiscriminants, Snafu)] +#[snafu(visibility(pub))] +#[strum_discriminants(derive(IntoStaticStr))] +pub enum Error { + #[snafu(display("minimum length not met"))] + MinimumLengthNotMet { length: usize, min_length: usize }, + + #[snafu(display("maximum length exceeded"))] + LengthExceeded { length: usize, max_length: usize }, + + #[snafu(display("invalid regular expression"))] + InvalidRegex { source: regex::Error }, + + #[snafu(display("regular expression not matched"))] + RegexNotMatched { value: String, regex: &'static str }, + + #[snafu(display("not a valid label value"))] + InvalidLabelValue { source: crate::kvp::LabelValueError }, + + #[snafu(display("not a valid label name as defined in RFC 1035"))] + InvalidRfc1035LabelName { source: crate::validation::Errors }, + + #[snafu(display("not a valid DNS subdomain name as defined in RFC 1123"))] + InvalidRfc1123DnsSubdomainName { source: crate::validation::Errors }, + + #[snafu(display("not a valid label name as defined in RFC 1123"))] + InvalidRfc1123LabelName { source: crate::validation::Errors }, + + #[snafu(display("not a valid UUID"))] + InvalidUid { source: uuid::Error }, +} + +/// Helper data type to determine combined regular expressions +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Regex { + /// There is a regular expression but it is unknown (because it was too complicated to + /// calculate it). + Unknown, + + /// `MatchAll` equals `Expression(".*")`, but `MatchAll` can be pattern matched in a const + /// context, whereas `Expression(...)` cannot. + MatchAll, + + /// A regular expression + Expression(&'static str), +} + +impl Regex { + /// Combine this regular expression with the given one. + pub const fn combine(self, other: Self) -> Self { + match (self, other) { + (_, Self::MatchAll) => self, + (Self::MatchAll, _) => other, + // It is hard to combine two regular expressions and nearly impossible to do this in a + // const context. Fortunately, for most of the data types, only one regular expression + // is set. + _ => Self::Unknown, + } + } +} + +/// Restricted string type with attributes like maximum length. +/// +/// Fully-qualified types are used to ease the import into other modules. +/// +/// # Examples +/// +/// ```rust +/// use std::str::FromStr; +/// +/// use stackable_operator::attributed_string_type; +/// attributed_string_type! { +/// ConfigMapName, +/// "The name of a ConfigMap", +/// "opensearch-nodes-default", +/// is_rfc_1123_dns_subdomain_name +/// } +/// ``` +#[macro_export(local_inner_macros)] +macro_rules! attributed_string_type { + ($name:ident, $description:literal, $example:literal $(, $attribute:tt)*) => { + #[doc = std::concat!($description, ", e.g. \"", $example, "\"")] + #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] + pub struct $name(String); + + impl $name { + /// The minimum length + pub const MIN_LENGTH: usize = attributed_string_type!(@min_length $($attribute)*); + + /// The maximum length + pub const MAX_LENGTH: usize = attributed_string_type!(@max_length $($attribute)*); + + /// The regular expression + /// + /// This field is not meant to be used outside of this macro. + pub const REGEX: $crate::v2::macros::attributed_string_type::Regex = attributed_string_type!(@regex $($attribute)*); + } + + impl $crate::config::merge::Atomic for $name {} + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } + } + + impl From<$name> for String { + fn from(value: $name) -> Self { + value.0 + } + } + + impl From<&$name> for String { + fn from(value: &$name) -> Self { + value.0.clone() + } + } + + impl AsRef for $name { + fn as_ref(&self) -> &str { + &self.0 + } + } + + impl std::str::FromStr for $name { + type Err = $crate::v2::macros::attributed_string_type::Error; + + fn from_str(s: &str) -> std::result::Result { + // ResultExt::context is used on most but not all usages of this macro + #[allow(unused_imports)] + use snafu::ResultExt; + + $(attributed_string_type!(@from_str $name, s, $attribute);)* + + Ok(Self(s.to_owned())) + } + } + + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let string: String = serde::Deserialize::deserialize(deserializer)?; + $name::from_str(&string).map_err(|err| serde::de::Error::custom(&err)) + } + } + + impl serde::Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } + } + + // The JsonSchema implementation requires `max_length`. + impl $crate::schemars::JsonSchema for $name { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::stringify!($name).into() + } + + fn json_schema(_generator: &mut $crate::schemars::generate::SchemaGenerator) -> $crate::schemars::Schema { + $crate::schemars::json_schema!({ + "type": "string", + "minLength": $name::MIN_LENGTH, + "maxLength": if $name::MAX_LENGTH != usize::MAX { + Some($name::MAX_LENGTH) + } else { + // Do not set maxLength if it is usize::MAX. + None + }, + "pattern": match $name::REGEX { + $crate::v2::macros::attributed_string_type::Regex::Expression(regex) => Some(regex), + _ => None + } + }) + } + } + + #[cfg(test)] + impl $name { + #[allow(dead_code)] + pub fn from_str_unsafe(s: &str) -> Self { + std::str::FromStr::from_str(s).expect("should be a valid {name}") + } + + // A dead_code warning is emitted if there is no unit test that calls this function. + pub fn test_example() { + Self::from_str_unsafe($example); + } + } + + $(attributed_string_type!(@trait_impl $name, $attribute);)* + }; + + // std::str::FromStr + + (@from_str $name:ident, $s:expr, (min_length = $min_length:expr)) => { + let length = $s.len() as usize; + snafu::ensure!( + length >= $name::MIN_LENGTH, + $crate::v2::macros::attributed_string_type::MinimumLengthNotMetSnafu { + length, + min_length: $name::MIN_LENGTH, + } + ); + }; + (@from_str $name:ident, $s:expr, (max_length = $max_length:expr)) => { + let length = $s.len() as usize; + snafu::ensure!( + length <= $name::MAX_LENGTH, + $crate::v2::macros::attributed_string_type::LengthExceededSnafu { + length, + max_length: $name::MAX_LENGTH, + } + ); + }; + (@from_str $name:ident, $s:expr, (regex = $regex:expr)) => { + let regex = regex::Regex::new($regex).context($crate::v2::macros::attributed_string_type::InvalidRegexSnafu)?; + snafu::ensure!( + regex.is_match($s), + $crate::v2::macros::attributed_string_type::RegexNotMatchedSnafu { + value: $s, + regex: $regex + } + ); + }; + (@from_str $name:ident, $s:expr, is_rfc_1035_label_name) => { + $crate::validation::is_lowercase_rfc_1035_label($s).context($crate::v2::macros::attributed_string_type::InvalidRfc1035LabelNameSnafu)?; + }; + (@from_str $name:ident, $s:expr, is_rfc_1123_dns_subdomain_name) => { + $crate::validation::is_lowercase_rfc_1123_subdomain($s).context($crate::v2::macros::attributed_string_type::InvalidRfc1123DnsSubdomainNameSnafu)?; + }; + (@from_str $name:ident, $s:expr, is_rfc_1123_label_name) => { + $crate::validation::is_lowercase_rfc_1123_label($s).context($crate::v2::macros::attributed_string_type::InvalidRfc1123LabelNameSnafu)?; + }; + (@from_str $name:ident, $s:expr, is_valid_label_value) => { + $crate::kvp::LabelValue::from_str($s).context($crate::v2::macros::attributed_string_type::InvalidLabelValueSnafu)?; + }; + (@from_str $name:ident, $s:expr, is_uid) => { + uuid::Uuid::try_parse($s).context($crate::v2::macros::attributed_string_type::InvalidUidSnafu)?; + }; + + // MIN_LENGTH + + (@min_length) => { + // The minimum String length is 0. + 0 + }; + (@min_length (min_length = $min_length:expr) $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::max( + $min_length, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length (max_length = $max_length:expr) $($attribute:tt)*) => { + // max_length has no opinion on the min_length. + attributed_string_type!(@min_length $($attribute)*) + }; + (@min_length (regex = $regex:expr) $($attribute:tt)*) => { + // regex has no influence on the min_length. + attributed_string_type!(@min_length $($attribute)*) + }; + (@min_length is_rfc_1035_label_name $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_rfc_1123_label_name $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_valid_label_value $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_uid $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::max( + uuid::fmt::Hyphenated::LENGTH, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + + // MAX_LENGTH + + (@max_length) => { + // If there is no other max_length defined, then the upper bound is usize::MAX. + usize::MAX + }; + (@max_length (min_length = $min_length:expr) $($attribute:tt)*) => { + // min_length has no opinion on the max_length. + attributed_string_type!(@max_length $($attribute)*) + }; + (@max_length (max_length = $max_length:expr) $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::min( + $max_length, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length (regex = $regex:expr) $($attribute:tt)*) => { + // regex has no influence on the max_length. + attributed_string_type!(@max_length $($attribute)*) + }; + (@max_length is_rfc_1035_label_name $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::min( + $crate::validation::RFC_1035_LABEL_MAX_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::min( + $crate::validation::RFC_1123_SUBDOMAIN_MAX_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_rfc_1123_label_name $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::min( + $crate::validation::RFC_1123_LABEL_MAX_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_valid_label_value $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::min( + $crate::v2::macros::attributed_string_type::MAX_LABEL_VALUE_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_uid $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::min( + uuid::fmt::Hyphenated::LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + + // REGEX + + (@regex) => { + // Everything is allowed if there is no other regular expression. + $crate::v2::macros::attributed_string_type::Regex::MatchAll + }; + (@regex (min_length = $min_length:expr) $($attribute:tt)*) => { + // min_length has no influence on the regular expression. + attributed_string_type!(@regex $($attribute)*) + }; + (@regex (max_length = $max_length:expr) $($attribute:tt)*) => { + // max_length has no influence on the regular expression. + attributed_string_type!(@regex $($attribute)*) + }; + (@regex (regex = $regex:expr) $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::Regex::Expression($regex) + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_rfc_1035_label_name $($attribute:tt)*) => { + // see https://github.com/kubernetes/kubernetes/blob/v1.35.0/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L228 + $crate::v2::macros::attributed_string_type::Regex::Expression("^[a-z]([-a-z0-9]*[a-z0-9])?$") + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { + // see https://github.com/kubernetes/kubernetes/blob/v1.35.0/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L193 + $crate::v2::macros::attributed_string_type::Regex::Expression("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$") + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_rfc_1123_label_name $($attribute:tt)*) => { + // see https://github.com/kubernetes/kubernetes/blob/v1.35.0/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L163 + $crate::v2::macros::attributed_string_type::Regex::Expression("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$") + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_valid_label_value $($attribute:tt)*) => { + // regular expression from crate::kvp::label::LABEL_VALUE_REGEX + $crate::v2::macros::attributed_string_type::Regex::Expression("^[a-z0-9A-Z]([a-z0-9A-Z-_.]*[a-z0-9A-Z]+)?$") + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_uid $($attribute:tt)*) => { + $crate::v2::macros::attributed_string_type::Regex::Expression("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + + // additional constants and trait implementations + + (@trait_impl $name:ident, (min_length = $max_length:expr)) => { + }; + (@trait_impl $name:ident, (max_length = $max_length:expr)) => { + }; + (@trait_impl $name:ident, (regex = $regex:expr)) => { + }; + (@trait_impl $name:ident, is_rfc_1035_label_name) => { + impl $name { + pub const IS_RFC_1035_LABEL_NAME: bool = true; + pub const IS_RFC_1123_LABEL_NAME: bool = true; + pub const IS_RFC_1123_SUBDOMAIN_NAME: bool = true; + } + }; + (@trait_impl $name:ident, is_rfc_1123_dns_subdomain_name) => { + impl $name { + pub const IS_RFC_1123_SUBDOMAIN_NAME: bool = true; + } + }; + (@trait_impl $name:ident, is_rfc_1123_label_name) => { + impl $name { + pub const IS_RFC_1123_LABEL_NAME: bool = true; + pub const IS_RFC_1123_SUBDOMAIN_NAME: bool = true; + } + }; + (@trait_impl $name:ident, is_valid_label_value) => { + impl $name { + pub const IS_VALID_LABEL_VALUE: bool = true; + } + + impl $crate::v2::NameIsValidLabelValue for $name { + fn to_label_value(&self) -> String { + self.0.clone() + } + } + }; + (@trait_impl $name:ident, is_uid) => { + impl From for $name { + fn from(value: uuid::Uuid) -> Self { + Self(value.to_string()) + } + } + + impl From<&uuid::Uuid> for $name { + fn from(value: &uuid::Uuid) -> Self { + Self(value.to_string()) + } + } + }; +} + +/// Returns the minimum of the given values. +/// +/// As opposed to [`std::cmp::min`], this function can be used at compile-time. +/// +/// # Examples +/// +/// ```rust +/// use stackable_operator::v2::macros::attributed_string_type::min; +/// assert_eq!(2, min(2, 3)); +/// assert_eq!(4, min(5, 4)); +/// assert_eq!(1, min(1, 1)); +/// ``` +pub const fn min(x: usize, y: usize) -> usize { + if x < y { x } else { y } +} + +/// Returns the maximum of the given values. +/// +/// As opposed to [`std::cmp::max`], this function can be used at compile-time. +/// +/// # Examples +/// +/// ```rust +/// use stackable_operator::{attributed_string_type, v2::macros::attributed_string_type::max}; +/// assert_eq!(3, max(2, 3)); +/// assert_eq!(5, max(5, 4)); +/// assert_eq!(1, max(1, 1)); +/// ``` +pub const fn max(x: usize, y: usize) -> usize { + if x < y { y } else { x } +} + +#[cfg(test)] +// `InvalidRegexTest` intentionally contains an invalid regular expression. +#[allow(clippy::invalid_regex)] +mod tests { + use std::str::FromStr; + + use serde_json::{Number, Value, json}; + use uuid::uuid; + + use super::{ErrorDiscriminants, Regex}; + use crate::{ + schemars::{JsonSchema, SchemaGenerator}, + v2::NameIsValidLabelValue, + }; + + attributed_string_type! { + MinLengthWithoutConstraintsTest, + "min_length test without constraints", + "" + } + + #[test] + fn test_attributed_string_type_min_length_without_constraints() { + type T = MinLengthWithoutConstraintsTest; + + T::test_example(); + assert_eq!(0, T::MIN_LENGTH); + } + + attributed_string_type! { + MinLengthWithConstraintsTest, + "min_length test with constraints", + "test", + (min_length = 2), // should set the minimum length to 2 + (max_length = 8), // should not affect the minimum length + (regex = "^.{4}$"), // should not affect the minimum length + is_rfc_1035_label_name, // should be overruled by the greater min_length + is_valid_label_value // should be overruled by the greater min_length + } + + #[test] + fn test_attributed_string_type_min_length_with_constraints() { + type T = MinLengthWithConstraintsTest; + + T::test_example(); + assert_eq!(2, T::MIN_LENGTH); + assert_eq!( + Err(ErrorDiscriminants::MinimumLengthNotMet), + T::from_str("a").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + MaxLengthWithoutConstraintsTest, + "max_length test without constraints", + "" + } + + #[test] + fn test_attributed_string_type_max_length_without_constraints() { + type T = MaxLengthWithoutConstraintsTest; + + T::test_example(); + assert_eq!(usize::MAX, T::MAX_LENGTH); + } + + attributed_string_type! { + MaxLengthWithConstraintsTest, + "max_length test with constraints", + "test", + (min_length = 2), // should not affect the maximum length + (max_length = 8), // should set the maximum length to 8 + (regex = "^.{4}$"), // should not affect the maximum length + is_rfc_1035_label_name, // should be overruled by the lower max_length + is_valid_label_value // should be overruled by the lower max_length + } + + #[test] + fn test_attributed_string_type_max_length_with_constraints() { + type T = MaxLengthWithConstraintsTest; + + T::test_example(); + assert_eq!(8, T::MAX_LENGTH); + assert_eq!( + Err(ErrorDiscriminants::LengthExceeded), + T::from_str("test-12345").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + RegexWithoutConstraintsTest, + "regex test without constraints", + "" + } + + #[test] + fn test_attributed_string_type_regex_without_constraints() { + type T = RegexWithoutConstraintsTest; + + T::test_example(); + assert_eq!(Regex::MatchAll, T::REGEX); + } + + attributed_string_type! { + RegexWithOneConstraintTest, + "regex test with one constraint", + "test", + (min_length = 2), // should not affect the regular expression + (max_length = 8), // should not affect the regular expression + (regex = "^[est]{4}$") // should set the regular expression to "[est]{4}" + } + + #[test] + fn test_attributed_string_type_regex_with_one_constraint() { + type T = RegexWithOneConstraintTest; + + T::test_example(); + assert_eq!(Regex::Expression("^[est]{4}$"), T::REGEX); + assert_eq!( + Err(ErrorDiscriminants::RegexNotMatched), + T::from_str("t-st").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + RegexWithMultipleConstraintsTest, + "regex test with multiple constraints", + "test", + (min_length = 2), // should not affect the regular expression + (max_length = 8), // should not affect the regular expression + (regex = "^[est]{4}$"), // should not be combinable with is_rfc_1123_dns_subdomain_name + is_rfc_1123_dns_subdomain_name // should not be combinable with regex + } + + #[test] + fn test_attributed_string_type_regex_with_multiple_constraints() { + type T = RegexWithMultipleConstraintsTest; + + T::test_example(); + assert_eq!(Regex::Unknown, T::REGEX); + assert_eq!( + Err(ErrorDiscriminants::RegexNotMatched), + T::from_str("t-st").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + InvalidRegexTest, + "regex test with invalid expression", + "test", + (min_length = 2), // should not affect the regular expression + (max_length = 8), // should not affect the regular expression + (regex = "{") // should throw an error at runtime + } + + #[test] + fn test_attributed_string_type_regex_with_invalid_expression() { + type T = InvalidRegexTest; + + // It is not known yet at compile-time that this expression is invalid. + assert_eq!(Regex::Expression("{"), T::REGEX); + assert_eq!( + Err(ErrorDiscriminants::InvalidRegex), + T::from_str("test").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + DisplayFmtTest, + "Display::fmt test", + "test" + } + + #[test] + fn test_attributed_string_type_display_fmt() { + type T = DisplayFmtTest; + + assert_eq!("test", format!("{}", T::from_str_unsafe("test"))); + } + + attributed_string_type! { + StringFromTest, + "String::from test", + "test" + } + + #[test] + fn test_attributed_string_type_string_from() { + type T = StringFromTest; + + T::test_example(); + assert_eq!("test", String::from(T::from_str_unsafe("test"))); + assert_eq!("test", String::from(&T::from_str_unsafe("test"))); + } + + attributed_string_type! { + DeserializeTest, + "serde::Deserialize test", + "test", + (min_length = 2), + (max_length = 4), + (regex = "^[est-]+$"), + is_rfc_1035_label_name + } + + #[test] + fn test_attributed_string_type_deserialize() { + type T = DeserializeTest; + + T::test_example(); + assert_eq!( + T::from_str_unsafe("test"), + serde_json::from_value(Value::String("test".to_owned())) + .expect("should be deserializable") + ); + assert_eq!( + Err("minimum length not met".to_owned()), + serde_json::from_value::(Value::String("e".to_owned())) + .map_err(|err| err.to_string()) + ); + assert_eq!( + Err("maximum length exceeded".to_owned()), + serde_json::from_value::(Value::String("testt".to_owned())) + .map_err(|err| err.to_string()) + ); + assert_eq!( + Err("regular expression not matched".to_owned()), + serde_json::from_value::(Value::String("abc".to_owned())) + .map_err(|err| err.to_string()) + ); + assert_eq!( + Err("not a valid label name as defined in RFC 1035".to_owned()), + serde_json::from_value::(Value::String("-tst".to_owned())) + .map_err(|err| err.to_string()) + ); + assert_eq!( + Err("invalid type: null, expected a string".to_owned()), + serde_json::from_value::(Value::Null).map_err(|err| err.to_string()) + ); + assert_eq!( + Err("invalid type: boolean `true`, expected a string".to_owned()), + serde_json::from_value::(Value::Bool(true)).map_err(|err| err.to_string()) + ); + assert_eq!( + Err("invalid type: integer `1`, expected a string".to_owned()), + serde_json::from_value::(Value::Number( + Number::from_i128(1).expect("should be a valid number") + )) + .map_err(|err| err.to_string()) + ); + assert_eq!( + Err("invalid type: sequence, expected a string".to_owned()), + serde_json::from_value::(Value::Array(vec![])).map_err(|err| err.to_string()) + ); + assert_eq!( + Err("invalid type: map, expected a string".to_owned()), + serde_json::from_value::(Value::Object(serde_json::Map::new())) + .map_err(|err| err.to_string()) + ); + } + + attributed_string_type! { + SerializeTest, + "serde::Serialize test", + "test" + } + + #[test] + fn test_attributed_string_type_serialize() { + type T = SerializeTest; + + T::test_example(); + assert_eq!( + "\"test\"".to_owned(), + serde_json::to_string(&T::from_str_unsafe("test")).expect("should be serializable") + ); + } + + attributed_string_type! { + JsonSchemaWithoutConstraintsTest, + "JsonSchema test with constraints", + "test" + } + + #[test] + fn test_attributed_string_type_json_schema_without_constaints() { + type T = JsonSchemaWithoutConstraintsTest; + + T::test_example(); + assert_eq!("JsonSchemaWithoutConstraintsTest", T::schema_name()); + assert_eq!( + json!({ + "type": "string", + "minLength": 0, + "maxLength": None::, + "pattern": None:: + }), + T::json_schema(&mut SchemaGenerator::default()) + ); + } + + attributed_string_type! { + JsonSchemaWithConstraintsTest, + "JsonSchema test with constraints", + "test", + (min_length = 4), + (max_length = 8), + (regex = "^[est]+$") + } + + #[test] + fn test_attributed_string_type_json_schema_with_constraints() { + type T = JsonSchemaWithConstraintsTest; + + T::test_example(); + assert_eq!("JsonSchemaWithConstraintsTest", T::schema_name()); + assert_eq!( + json!({ + "type": "string", + "minLength": 4, + "maxLength": 8, + "pattern": "^[est]+$" + }), + T::json_schema(&mut SchemaGenerator::default()) + ); + } + + attributed_string_type! { + IsRfc1035LabelNameTest, + "is_rfc_1035_label_name test", + "a-b", + is_rfc_1035_label_name + } + + #[test] + fn test_attributed_string_type_is_rfc_1035_label_name() { + type T = IsRfc1035LabelNameTest; + + let _ = T::IS_RFC_1035_LABEL_NAME; + let _ = T::IS_RFC_1123_LABEL_NAME; + let _ = T::IS_RFC_1123_SUBDOMAIN_NAME; + + T::test_example(); + assert_eq!( + Err(ErrorDiscriminants::InvalidRfc1035LabelName), + T::from_str("A").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + IsRfc1123DnsSubdomainNameTest, + "is_rfc_1123_dns_subdomain_name test", + "a-b.c", + is_rfc_1123_dns_subdomain_name + } + + #[test] + fn test_attributed_string_type_is_rfc_1123_dns_subdomain_name() { + type T = IsRfc1123DnsSubdomainNameTest; + + let _ = T::IS_RFC_1123_SUBDOMAIN_NAME; + + T::test_example(); + assert_eq!( + Err(ErrorDiscriminants::InvalidRfc1123DnsSubdomainName), + T::from_str("A").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + IsRfc1123LabelNameTest, + "is_rfc_1123_label_name test", + "1-a", + is_rfc_1123_label_name + } + + #[test] + fn test_attributed_string_type_is_rfc_1123_label_name() { + type T = IsRfc1123LabelNameTest; + + let _ = T::IS_RFC_1123_LABEL_NAME; + let _ = T::IS_RFC_1123_SUBDOMAIN_NAME; + + T::test_example(); + assert_eq!( + Err(ErrorDiscriminants::InvalidRfc1123LabelName), + T::from_str("A").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + IsValidLabelValueTest, + "is_valid_label_value test", + "a-_.1", + is_valid_label_value + } + + #[test] + fn test_attributed_string_type_is_valid_label_value() { + type T = IsValidLabelValueTest; + + let _ = T::IS_VALID_LABEL_VALUE; + + T::test_example(); + assert_eq!( + Err(ErrorDiscriminants::InvalidLabelValue), + T::from_str("invalid label value").map_err(ErrorDiscriminants::from) + ); + assert_eq!( + "label-value", + T::from_str_unsafe("label-value").to_label_value() + ); + } + + attributed_string_type! { + IsUidTest, + "is_uid test", + "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", + is_uid + } + + #[test] + fn test_attributed_string_type_is_uid() { + type T = IsUidTest; + + T::test_example(); + assert_eq!( + Err(ErrorDiscriminants::InvalidUid), + T::from_str("invalid UID").map_err(ErrorDiscriminants::from) + ); + assert_eq!( + "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", + T::from(uuid!("c27b3971-ca72-42c1-80a4-abdfc1db0ddd")).to_string() + ); + assert_eq!( + "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", + T::from(&uuid!("c27b3971-ca72-42c1-80a4-abdfc1db0ddd")).to_string() + ); + } +} diff --git a/crates/stackable-operator/src/v2/macros/constant.rs b/crates/stackable-operator/src/v2/macros/constant.rs new file mode 100644 index 000000000..84297a26d --- /dev/null +++ b/crates/stackable-operator/src/v2/macros/constant.rs @@ -0,0 +1,21 @@ +/// Use [`std::sync::LazyLock`] to define a static "constant" from a string. +/// +/// The string is converted into the given type with [`std::str::FromStr::from_str`]. +/// +/// # Examples +/// +/// ```rust +/// use std::str::FromStr; +/// +/// use stackable_operator::constant; +/// use stackable_operator::v2::types::kubernetes::VolumeName; +/// constant!(DATA_VOLUME_NAME: VolumeName = "data"); +/// constant!(pub CONFIG_VOLUME_NAME: VolumeName = "config"); +/// ``` +#[macro_export(local_inner_macros)] +macro_rules! constant { + ($qualifier:vis $name:ident: $type:ident = $value:literal) => { + $qualifier static $name: std::sync::LazyLock<$type> = + std::sync::LazyLock::new(|| $type::from_str($value).expect("should be a valid $name")); + }; +} diff --git a/crates/stackable-operator/src/v2/mod.rs b/crates/stackable-operator/src/v2/mod.rs new file mode 100644 index 000000000..fdde3bc3c --- /dev/null +++ b/crates/stackable-operator/src/v2/mod.rs @@ -0,0 +1,34 @@ +use crate::v2::types::kubernetes::Uid; + +pub mod builder; +pub mod cluster_resources; +pub mod config_file_writer; +pub mod config_overrides; +pub mod controller_utils; +pub mod flask_config_writer; +pub mod jvm_argument_overrides; +pub mod kvp; +pub mod macros; +pub mod product_logging; +pub mod role_group_utils; +pub mod role_utils; +pub mod types; + +/// Has a non-empty name +/// +/// Useful as an object reference; Should not be used to create an object because the name could +/// violate the naming constraints (e.g. maximum length) of the object. +pub trait HasName { + #[allow(dead_code)] + fn to_name(&self) -> String; +} + +/// Has a Kubernetes UID +pub trait HasUid { + fn to_uid(&self) -> Uid; +} + +/// The name is a valid label value +pub trait NameIsValidLabelValue { + fn to_label_value(&self) -> String; +} diff --git a/crates/stackable-operator/src/v2/product_logging.rs b/crates/stackable-operator/src/v2/product_logging.rs new file mode 100644 index 000000000..0c717499e --- /dev/null +++ b/crates/stackable-operator/src/v2/product_logging.rs @@ -0,0 +1 @@ +pub mod framework; diff --git a/crates/stackable-operator/src/v2/product_logging/framework.rs b/crates/stackable-operator/src/v2/product_logging/framework.rs new file mode 100644 index 000000000..d68ac114b --- /dev/null +++ b/crates/stackable-operator/src/v2/product_logging/framework.rs @@ -0,0 +1,501 @@ +use std::{fmt::Display, str::FromStr}; + +use snafu::{OptionExt, ResultExt, Snafu}; +use strum::{EnumDiscriminants, IntoStaticStr}; + +use crate::{ + builder::pod::{container::FieldPathEnvVar, resources::ResourceRequirementsBuilder}, + commons::product_image_selection::ResolvedProductImage, + constant, + k8s_openapi::api::core::v1::{Container, VolumeMount}, + product_logging::{ + framework::VECTOR_CONFIG_FILE, + spec::{ + AppenderConfig, AutomaticContainerLogConfig, ConfigMapLogConfig, + ContainerLogConfigChoice, CustomContainerLogConfig, LogLevel, Logging, + }, + }, + v2::{ + builder::pod::container::{EnvVarName, EnvVarSet, new_container_builder}, + role_group_utils, + types::kubernetes::{ConfigMapKey, ConfigMapName, ContainerName, VolumeName}, + }, +}; + +// Copy of the private constant `stackable_operator::product_logging::framework::STACKABLE_CONFIG_DIR` +const STACKABLE_CONFIG_DIR: &str = "/stackable/config"; + +// Copy of the private constant `stackable_operator::product_logging::framework::VECTOR_LOG_DIR` +const VECTOR_CONTROL_DIR: &str = "_vector"; + +// Copy of the private constant `stackable_operator::product_logging::framework::VECTOR_STATE_DIR` +const VECTOR_STATE_DIR: &str = "_vector-state"; + +// Copy of the private constant `stackable_operator::product_logging::framework::SHUTDOWN_FILE` +const SHUTDOWN_FILE: &str = "shutdown"; + +// Public variant of `stackable_operator::product_logging::framework::STACKABLE_LOG_DIR` +/// Directory where the logs are stored +pub const STACKABLE_LOG_DIR: &str = "/stackable/log"; + +// Copy of the private constant `stackable_operator::product_logging::framework::VECTOR_AGGREGATOR_CM_KEY` +constant!(VECTOR_AGGREGATOR_CM_KEY: ConfigMapKey = "ADDRESS"); + +// Copy of the private constant `stackable_operator::product_logging::framework::VECTOR_AGGREGATOR_ADDRESS` +constant!(VECTOR_AGGREGATOR_ENV_NAME: EnvVarName = "VECTOR_AGGREGATOR_ADDRESS"); + +#[derive(Debug, EnumDiscriminants, Snafu)] +#[strum_discriminants(derive(IntoStaticStr))] +pub enum Error { + #[snafu(display("failed to get the container log configuration"))] + GetContainerLogConfiguration { container: String }, + + #[snafu(display("failed to parse the container name"))] + ParseContainerName { + source: crate::v2::macros::attributed_string_type::Error, + }, +} + +type Result = std::result::Result; + +/// Validated [`ContainerLogConfigChoice`] +/// +/// The ConfigMap name in the Custom variant is valid. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ValidatedContainerLogConfigChoice { + Automatic(AutomaticContainerLogConfig), + Custom(ConfigMapName), +} + +/// Validated [`ContainerLogConfigChoice`] for the Vector container +/// +/// It includes the discovery ConfigMap name of the Vector aggregator. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VectorContainerLogConfig { + pub log_config: ValidatedContainerLogConfigChoice, + pub vector_aggregator_config_map_name: ConfigMapName, +} + +/// Validates the log configuration of the container +pub fn validate_logging_configuration_for_container( + logging: &Logging, + container: &T, +) -> Result +where + T: Clone + Display + Ord, +{ + let container_log_config_choice = logging + .containers + .get(container) + .and_then(|container_log_config| container_log_config.choice.as_ref()) + // This should never happen because default configurations should have been set for all + // containers. + .context(GetContainerLogConfigurationSnafu { + container: container.to_string(), + })?; + + let validated_container_log_config_choice = match container_log_config_choice { + ContainerLogConfigChoice::Automatic(automatic_log_config) => { + ValidatedContainerLogConfigChoice::Automatic(automatic_log_config.clone()) + } + ContainerLogConfigChoice::Custom(CustomContainerLogConfig { + custom: ConfigMapLogConfig { config_map }, + }) => ValidatedContainerLogConfigChoice::Custom( + ConfigMapName::from_str(config_map).context(ParseContainerNameSnafu)?, + ), + }; + + Ok(validated_container_log_config_choice) +} + +/// Builds the Vector container +#[expect(clippy::too_many_lines)] +pub fn vector_container( + container_name: &ContainerName, + image: &ResolvedProductImage, + vector_container_log_config: &VectorContainerLogConfig, + resource_names: &role_group_utils::ResourceNames, + log_config_volume_name: &VolumeName, + log_volume_name: &VolumeName, + extra_env_vars: EnvVarSet, +) -> Container { + let log_level = if let ValidatedContainerLogConfigChoice::Automatic(log_config) = + &vector_container_log_config.log_config + { + log_config.root_log_level() + } else { + LogLevel::default() + }; + let vector_file_log_level = + if let ValidatedContainerLogConfigChoice::Automatic(AutomaticContainerLogConfig { + file: Some(AppenderConfig { + level: Some(log_level), + }), + .. + }) = vector_container_log_config.log_config + { + log_level + } else { + LogLevel::default() + }; + + let env_vars = EnvVarSet::new() + .with_value( + &EnvVarName::from_str_unsafe("CLUSTER_NAME"), + &resource_names.cluster_name, + ) + .with_value( + &EnvVarName::from_str_unsafe("DATA_DIR"), + format!("{STACKABLE_LOG_DIR}/{VECTOR_STATE_DIR}"), + ) + .with_value(&EnvVarName::from_str_unsafe("LOG_DIR"), STACKABLE_LOG_DIR) + .with_field_path( + &EnvVarName::from_str_unsafe("NAMESPACE"), + &FieldPathEnvVar::Namespace, + ) + .with_value( + &EnvVarName::from_str_unsafe("ROLE_GROUP_NAME"), + &resource_names.role_group_name, + ) + .with_value( + &EnvVarName::from_str_unsafe("ROLE_NAME"), + &resource_names.role_name, + ) + .with_config_map_key_ref( + &VECTOR_AGGREGATOR_ENV_NAME, + &vector_container_log_config.vector_aggregator_config_map_name, + &VECTOR_AGGREGATOR_CM_KEY, + ) + .with_value( + &EnvVarName::from_str_unsafe("VECTOR_CONFIG_YAML"), + format!("{STACKABLE_CONFIG_DIR}/{VECTOR_CONFIG_FILE}"), + ) + .with_value( + &EnvVarName::from_str_unsafe("VECTOR_FILE_LOG_LEVEL"), + vector_file_log_level.to_vector_literal(), + ) + .with_value( + &EnvVarName::from_str_unsafe("VECTOR_LOG"), + log_level.to_vector_literal(), + ) + .merge(extra_env_vars); + + let resources = ResourceRequirementsBuilder::new() + .with_cpu_request("250m") + .with_cpu_limit("500m") + .with_memory_request("128Mi") + .with_memory_limit("128Mi") + .build(); + + new_container_builder(container_name) + .image_from_product_image(image) + .command(vec![ + "/bin/bash".to_string(), + "-x".to_string(), + "-euo".to_string(), + "pipefail".to_string(), + "-c".to_string(), + ]) + .args(vec![format!( + "mkdir --parents {STACKABLE_LOG_DIR}/{VECTOR_STATE_DIR}\n\ + # Vector will ignore SIGTERM (as PID != 1) and must be shut down by writing a shutdown trigger file\n\ + vector & vector_pid=$!\n\ + if [ ! -f \"{vector_control_directory}/{SHUTDOWN_FILE}\" ]; then\n\ + mkdir -p {vector_control_directory}\n\ + inotifywait -qq --event create {vector_control_directory};\n\ + fi\n\ + sleep 1\n\ + kill $vector_pid", + vector_control_directory = format!("{STACKABLE_LOG_DIR}/{VECTOR_CONTROL_DIR}"), + )]) + .add_env_vars(env_vars) + .add_volume_mounts([ + VolumeMount { + mount_path: format!( + "{STACKABLE_CONFIG_DIR}/{VECTOR_CONFIG_FILE}" + ), + name: log_config_volume_name.to_string(), + read_only: Some(true), + sub_path: Some(VECTOR_CONFIG_FILE.to_owned()), + ..VolumeMount::default() + }, + VolumeMount { + mount_path: STACKABLE_LOG_DIR.to_owned(), + name: log_volume_name.to_string(), + ..VolumeMount::default() + }, + ]) + .expect("The mount paths are statically defined and there should be no duplicates.") + .resources(resources) + .build() +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use serde_json::json; + + use super::{ + ErrorDiscriminants, ValidatedContainerLogConfigChoice, VectorContainerLogConfig, + validate_logging_configuration_for_container, vector_container, + }; + use crate::{ + commons::product_image_selection::ResolvedProductImage, + kvp::LabelValue, + product_logging::spec::{ + AutomaticContainerLogConfig, ConfigMapLogConfig, ContainerLogConfig, + ContainerLogConfigChoice, CustomContainerLogConfig, Logging, + }, + v2::{ + builder::pod::container::{EnvVarName, EnvVarSet}, + role_group_utils, + types::{ + kubernetes::{ConfigMapName, ContainerName, VolumeName}, + operator::{ClusterName, RoleGroupName, RoleName}, + }, + }, + }; + + #[test] + fn test_validate_logging_configuration_for_container_ok_automatic_log_config() { + let logging = Logging { + enable_vector_agent: false, + containers: [( + "container", + ContainerLogConfig { + choice: Some(ContainerLogConfigChoice::Automatic( + AutomaticContainerLogConfig::default(), + )), + }, + )] + .into(), + }; + + assert_eq!( + ValidatedContainerLogConfigChoice::Automatic(AutomaticContainerLogConfig::default()), + validate_logging_configuration_for_container(&logging, &"container") + .expect("should be a valid log config") + ); + } + + #[test] + fn test_validate_logging_configuration_for_container_ok_custom_log_config() { + let logging = Logging { + enable_vector_agent: false, + containers: [( + "container", + ContainerLogConfig { + choice: Some(ContainerLogConfigChoice::Custom(CustomContainerLogConfig { + custom: ConfigMapLogConfig { + config_map: "valid-config-map-name".to_owned(), + }, + })), + }, + )] + .into(), + }; + + assert_eq!( + ValidatedContainerLogConfigChoice::Custom(ConfigMapName::from_str_unsafe( + "valid-config-map-name" + )), + validate_logging_configuration_for_container(&logging, &"container") + .expect("should be a valid log config") + ); + } + + #[test] + fn test_validate_logging_configuration_for_container_err_get_container_log_configuration() { + let logging_without_container = Logging { + enable_vector_agent: false, + containers: [].into(), + }; + let logging_without_container_log_config_choice = Logging { + enable_vector_agent: false, + containers: [("container", ContainerLogConfig { choice: None })].into(), + }; + + assert_eq!( + Err(ErrorDiscriminants::GetContainerLogConfiguration), + validate_logging_configuration_for_container(&logging_without_container, &"container") + .map_err(ErrorDiscriminants::from) + ); + + assert_eq!( + Err(ErrorDiscriminants::GetContainerLogConfiguration), + validate_logging_configuration_for_container( + &logging_without_container_log_config_choice, + &"container" + ) + .map_err(ErrorDiscriminants::from) + ); + } + + #[test] + fn test_validate_logging_configuration_for_container_err_parse_container_name() { + let logging = Logging { + enable_vector_agent: false, + containers: [( + "container", + ContainerLogConfig { + choice: Some(ContainerLogConfigChoice::Custom(CustomContainerLogConfig { + custom: ConfigMapLogConfig { + config_map: "invalid ConfigMap name".to_owned(), + }, + })), + }, + )] + .into(), + }; + + assert_eq!( + Err(ErrorDiscriminants::ParseContainerName), + validate_logging_configuration_for_container(&logging, &"container") + .map_err(ErrorDiscriminants::from) + ); + } + + #[test] + #[expect(clippy::too_many_lines)] + fn test_vector_container() { + let image = ResolvedProductImage { + product_version: "1.0.0".to_owned(), + app_version_label_value: LabelValue::from_str("1.0.0-stackable0.0.0-dev") + .expect("should be a valid label value"), + image: "oci.stackable.tech/sdp/product:1.0.0-stackable0.0.0-dev".to_string(), + image_pull_policy: "Always".to_owned(), + pull_secrets: None, + }; + + let vector_container_log_config = VectorContainerLogConfig { + log_config: ValidatedContainerLogConfigChoice::Automatic( + AutomaticContainerLogConfig::default(), + ), + vector_aggregator_config_map_name: ConfigMapName::from_str_unsafe("vector-aggregator"), + }; + + let resource_names = role_group_utils::ResourceNames { + cluster_name: ClusterName::from_str_unsafe("test-cluster"), + role_name: RoleName::from_str_unsafe("role"), + role_group_name: RoleGroupName::from_str_unsafe("role-group"), + }; + + let vector_container = vector_container( + &ContainerName::from_str_unsafe("vector"), + &image, + &vector_container_log_config, + &resource_names, + &VolumeName::from_str_unsafe("config"), + &VolumeName::from_str_unsafe("log"), + EnvVarSet::new().with_value(&EnvVarName::from_str_unsafe("CUSTOM_ENV"), "test"), + ); + + assert_eq!( + json!( + { + "args": [ + concat!( + "mkdir --parents /stackable/log/_vector-state\n", + "# Vector will ignore SIGTERM (as PID != 1) and must be shut down by writing a shutdown trigger file\n", + "vector & vector_pid=$!\n", + "if [ ! -f \"/stackable/log/_vector/shutdown\" ]; then\n", + "mkdir -p /stackable/log/_vector\n", + "inotifywait -qq --event create /stackable/log/_vector;\n", + "fi\n", + "sleep 1\n", + "kill $vector_pid" + ), + ], + "command": [ + "/bin/bash", + "-x", + "-euo", + "pipefail", + "-c", + ], + "env": [ + { + "name": "CLUSTER_NAME", + "value": "test-cluster", + }, + { + "name": "CUSTOM_ENV", + "value": "test", + }, + { + "name": "DATA_DIR", + "value": "/stackable/log/_vector-state", + }, + { + "name": "LOG_DIR", + "value": "/stackable/log", + }, + { + "name": "NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace", + }, + }, + }, + { + "name": "ROLE_GROUP_NAME", + "value": "role-group", + }, + { + "name": "ROLE_NAME", + "value": "role", + }, + { + "name": "VECTOR_AGGREGATOR_ADDRESS", + "valueFrom": { + "configMapKeyRef": { + "key": "ADDRESS", + "name": "vector-aggregator", + }, + }, + }, + { + "name": "VECTOR_CONFIG_YAML", + "value": "/stackable/config/vector.yaml", + }, + { + "name": "VECTOR_FILE_LOG_LEVEL", + "value": "info", + }, + { + "name": "VECTOR_LOG", + "value": "info", + }, + ], + "image": "oci.stackable.tech/sdp/product:1.0.0-stackable0.0.0-dev", + "imagePullPolicy": "Always", + "name": "vector", + "resources": { + "limits": { + "cpu": "500m", + "memory": "128Mi", + }, + "requests": { + "cpu": "250m", + "memory": "128Mi", + }, + }, + "volumeMounts": [ + { + "mountPath": "/stackable/config/vector.yaml", + "name": "config", + "readOnly": true, + "subPath": "vector.yaml", + }, + { + "mountPath": "/stackable/log", + "name": "log", + }, + ], + }), + serde_json::to_value(vector_container).expect("should be serializable") + ); + } +} diff --git a/crates/stackable-operator/src/v2/role_group_utils.rs b/crates/stackable-operator/src/v2/role_group_utils.rs new file mode 100644 index 000000000..84b1dbced --- /dev/null +++ b/crates/stackable-operator/src/v2/role_group_utils.rs @@ -0,0 +1,353 @@ +use std::str::FromStr; + +use sha2::{Digest, Sha256}; + +use super::types::{ + kubernetes::{ConfigMapName, ListenerName, ServiceName, StatefulSetName}, + operator::{ClusterName, RoleGroupName, RoleName}, +}; +use crate::attributed_string_type; + +attributed_string_type! { + QualifiedRoleGroupName, + "A qualified role group name consisting of the cluster name, role name and role-group name. It is a valid label name as defined in RFC 1035 that can be used e.g. as a name for a Service or a StatefulSet.", + "opensearch-nodes-default", + // Suffixes are added to produce resource names. According compile-time checks ensure that + // max_length cannot be set higher. + (max_length = 52), + is_rfc_1035_label_name, + is_valid_label_value +} + +/// Type-safe names for role-group resources +pub struct ResourceNames { + pub cluster_name: ClusterName, + pub role_name: RoleName, + pub role_group_name: RoleGroupName, +} + +impl ResourceNames { + /// Creates a qualified role group name in the format + /// `--` + /// + /// If the result would exceed the maximum length of qualified role group names, then it is + /// truncated and a hash is appended. The maximum length of the cluster name is short enough, + /// so that a part of the role name is always rendered. The role group name is barely used and + /// often set to "default", so that the qualified role group name is still meaningful: + /// + /// ```rust + /// # use std::str::FromStr; + /// # use stackable_operator::v2::role_group_utils::ResourceNames; + /// # use stackable_operator::v2::types::operator::{ClusterName, RoleGroupName, RoleName}; + /// + /// let resource_names = ResourceNames { + /// cluster_name: ClusterName::from_str("an-exceptional-long-cluster-name").unwrap(), + /// role_name: RoleName::from_str("dagprocessor").unwrap(), + /// role_group_name: RoleGroupName::from_str("default").unwrap(), + /// }; + /// + /// assert_eq!( + /// "an-exceptional-long-cluster-name-dagprocessor-6cc08b", + /// resource_names.qualified_role_group_name().to_string() + /// ); + /// ``` + pub fn qualified_role_group_name(&self) -> QualifiedRoleGroupName { + // compile-time checks + const HASH_LENGTH: usize = 6; + + // At least the cluster name should be short enough to not be replaced by the hash. + const _: () = assert!( + ClusterName::MAX_LENGTH + + 1 // dash + + HASH_LENGTH + <= QualifiedRoleGroupName::MAX_LENGTH, + "The string `-` must not exceed the limit of qualified role group \ + names." + ); + + // qualified_role_group_name is only an RFC 1035 label name if it starts with an + // alphabetic character, therefore cluster_name must also be an RFC 1035 label name. + // role_name and role_group_name and the middle of the qualified_role_group_name can + // be RFC 1123 label names because digits are allowed there. + let _ = ClusterName::IS_RFC_1035_LABEL_NAME; + let _ = RoleName::IS_RFC_1123_LABEL_NAME; + let _ = RoleGroupName::IS_RFC_1123_LABEL_NAME; + + let concatenated_name = format!( + "{}-{}-{}", + self.cluster_name, self.role_name, self.role_group_name, + ); + // `concatenated_name` contains only ASCII characters. + let sanitized_name = Self::ensure_max_length( + concatenated_name, + QualifiedRoleGroupName::MAX_LENGTH, + HASH_LENGTH, + ); + + QualifiedRoleGroupName::from_str(&sanitized_name) + .expect("should be a valid QualifiedRoleGroupName") + } + + /// Ensures that the given resource name does not exceed the given maximum length. + /// If required, the resource name is truncated and a hex encoded hash is appended with a dash. + /// + /// # Panics + /// + /// Panics if `resource_name` contains non-ASCII characters or if + /// `max_length < 1 /* character */ + 1 /* dash */ + hash_length`. + /// + /// Kubernetes object names cannot contain non-ASCII characters. + fn ensure_max_length(resource_name: String, max_length: usize, hash_length: usize) -> String { + assert!(resource_name.is_ascii()); + assert!(max_length >= 1 /* character */ + 1 /* dash */ + hash_length); + + if resource_name.len() <= max_length { + resource_name + } else if hash_length == 0 { + let mut truncated_name = resource_name; + truncated_name.truncate(max_length); + truncated_name + } else { + let mut hash = format!("{:x}", Sha256::digest(resource_name.as_bytes())); + hash.truncate(hash_length); + + let mut truncated_name = resource_name; + // Truncate the name so that the hash can be appended without exceeding the maximum + // length. + truncated_name.truncate(max_length - hash_length); + + let last_char = truncated_name + .pop() + .expect("should be guaranteed by the assertion above"); + let second_to_last_char = truncated_name + .pop() + .expect("should be guaranteed by the assertion above"); + + // If the truncated name already ends with a dash then do not add another one, + // otherwise replace the last character with a dash. + if second_to_last_char == '-' && last_char != '-' { + format!("{truncated_name}{second_to_last_char}{hash}") + } else { + format!("{truncated_name}{second_to_last_char}-{hash}") + } + } + } + + pub fn role_group_config_map(&self) -> ConfigMapName { + // compile-time check + const _: () = assert!( + QualifiedRoleGroupName::MAX_LENGTH <= ConfigMapName::MAX_LENGTH, + "The string `` must not exceed the limit of ConfigMap names." + ); + let _ = QualifiedRoleGroupName::IS_RFC_1123_SUBDOMAIN_NAME; + + ConfigMapName::from_str(self.qualified_role_group_name().as_ref()) + .expect("should be a valid ConfigMap name") + } + + pub fn stateful_set_name(&self) -> StatefulSetName { + // compile-time checks + const _: () = assert!( + QualifiedRoleGroupName::MAX_LENGTH <= StatefulSetName::MAX_LENGTH, + "The string `` must not exceed the limit of StatefulSet \ + names." + ); + let _ = QualifiedRoleGroupName::IS_RFC_1123_LABEL_NAME; + let _ = QualifiedRoleGroupName::IS_VALID_LABEL_VALUE; + + StatefulSetName::from_str(self.qualified_role_group_name().as_ref()) + .expect("should be a valid StatefulSet name") + } + + pub fn headless_service_name(&self) -> ServiceName { + const SUFFIX: &str = "-headless"; + + // compile-time checks + const _: () = assert!( + QualifiedRoleGroupName::MAX_LENGTH + SUFFIX.len() <= ServiceName::MAX_LENGTH, + "The string `-headless` must not exceed the limit of \ + Service names." + ); + let _ = QualifiedRoleGroupName::IS_RFC_1035_LABEL_NAME; + let _ = QualifiedRoleGroupName::IS_VALID_LABEL_VALUE; + + ServiceName::from_str(&format!("{}{SUFFIX}", self.qualified_role_group_name())) + .expect("should be a valid Service name") + } + + pub fn metrics_service_name(&self) -> ServiceName { + const SUFFIX: &str = "-metrics"; + + // compile-time checks + const _: () = assert!( + QualifiedRoleGroupName::MAX_LENGTH + SUFFIX.len() <= ServiceName::MAX_LENGTH, + "The string `---metrics` must not exceed the \ + limit of Service names." + ); + let _ = QualifiedRoleGroupName::IS_RFC_1035_LABEL_NAME; + let _ = QualifiedRoleGroupName::IS_VALID_LABEL_VALUE; + + ServiceName::from_str(&format!("{}{SUFFIX}", self.qualified_role_group_name())) + .expect("should be a valid Service name") + } + + pub fn listener_name(&self) -> ListenerName { + // compile-time checks + const _: () = assert!( + QualifiedRoleGroupName::MAX_LENGTH <= ListenerName::MAX_LENGTH, + "The string `` must not exceed the limit of Listener names." + ); + let _ = QualifiedRoleGroupName::IS_RFC_1123_SUBDOMAIN_NAME; + + ListenerName::from_str(self.qualified_role_group_name().as_ref()) + .expect("should be a valid Listener name") + } +} + +#[cfg(test)] +mod tests { + use super::{ClusterName, RoleGroupName, RoleName}; + use crate::v2::{ + role_group_utils::{QualifiedRoleGroupName, ResourceNames}, + types::kubernetes::{ConfigMapName, ListenerName, ServiceName, StatefulSetName}, + }; + + #[test] + fn test_resource_names() { + QualifiedRoleGroupName::test_example(); + + let resource_names = ResourceNames { + cluster_name: ClusterName::from_str_unsafe("test-cluster"), + role_name: RoleName::from_str_unsafe("data-nodes"), + role_group_name: RoleGroupName::from_str_unsafe("ssd-storage"), + }; + + assert_eq!( + QualifiedRoleGroupName::from_str_unsafe("test-cluster-data-nodes-ssd-storage"), + resource_names.qualified_role_group_name() + ); + assert_eq!( + ConfigMapName::from_str_unsafe("test-cluster-data-nodes-ssd-storage"), + resource_names.role_group_config_map() + ); + assert_eq!( + StatefulSetName::from_str_unsafe("test-cluster-data-nodes-ssd-storage"), + resource_names.stateful_set_name() + ); + assert_eq!( + ServiceName::from_str_unsafe("test-cluster-data-nodes-ssd-storage-headless"), + resource_names.headless_service_name() + ); + assert_eq!( + ServiceName::from_str_unsafe("test-cluster-data-nodes-ssd-storage-metrics"), + resource_names.metrics_service_name() + ); + assert_eq!( + ListenerName::from_str_unsafe("test-cluster-data-nodes-ssd-storage"), + resource_names.listener_name() + ); + } + + #[test] + fn test_fitting_qualified_role_group_name() { + let cluster_name_length = ClusterName::MAX_LENGTH; + let role_name_and_role_group_name_length = QualifiedRoleGroupName::MAX_LENGTH - cluster_name_length - 2 /* dashes */; + let role_name_length = role_name_and_role_group_name_length / 2; + let role_group_name_length = role_name_and_role_group_name_length - role_name_length; + + let resource_names = ResourceNames { + cluster_name: ClusterName::from_str_unsafe(&"c".repeat(cluster_name_length)), + role_name: RoleName::from_str_unsafe(&"r".repeat(role_name_length)), + role_group_name: RoleGroupName::from_str_unsafe(&"g".repeat(role_group_name_length)), + }; + + let qualified_role_group_name = resource_names.qualified_role_group_name(); + + assert_eq!( + QualifiedRoleGroupName::MAX_LENGTH, + qualified_role_group_name.to_string().len() + ); + assert_eq!( + QualifiedRoleGroupName::from_str_unsafe( + "cccccccccccccccccccccccccccccccccccccccc-rrrrr-ggggg" + ), + qualified_role_group_name + ); + } + + #[test] + fn test_hashed_qualified_role_group_name() { + let resource_names = ResourceNames { + cluster_name: ClusterName::from_str_unsafe(&"c".repeat(ClusterName::MAX_LENGTH)), + role_name: RoleName::from_str_unsafe(&"r".repeat(RoleName::MAX_LENGTH)), + role_group_name: RoleGroupName::from_str_unsafe(&"g".repeat(RoleGroupName::MAX_LENGTH)), + }; + + let qualified_role_group_name = resource_names.qualified_role_group_name(); + + assert_eq!( + QualifiedRoleGroupName::MAX_LENGTH, + qualified_role_group_name.to_string().len() + ); + assert_eq!( + QualifiedRoleGroupName::from_str_unsafe( + "cccccccccccccccccccccccccccccccccccccccc-rrrr-a12cc0" + ), + qualified_role_group_name + ); + } + + #[test] + fn test_ensure_max_length() { + // empty resource name, no hash length + assert_eq!( + String::new(), + ResourceNames::ensure_max_length(String::new(), 2, 0) + ); + + // resource_name.len() <= max_length + assert_eq!( + "abcdef".to_owned(), + ResourceNames::ensure_max_length("abcdef".to_owned(), 6, 4) + ); + + // hash_length == 0 + assert_eq!( + "abcdef".to_owned(), + ResourceNames::ensure_max_length("abcdefg".to_owned(), 6, 0) + ); + + // hash appended with dash + assert_eq!( + "a-7d1a".to_owned(), + ResourceNames::ensure_max_length("abcdefg".to_owned(), 6, 4) + ); + + // hash appended without an extra dash + assert_eq!( + "ab-a1b1".to_owned(), + ResourceNames::ensure_max_length("ab-defgh".to_owned(), 7, 4) + ); + + // hash appended without an extra dash + // In this case, the result is one character shorter than the maximum length. + assert_eq!( + "a-3951".to_owned(), + ResourceNames::ensure_max_length("a-cdefgh".to_owned(), 7, 4) + ); + + // hash appended without an extra dash + // The two dashes in the given resource name are intentionally kept. + assert_eq!( + "a--f7a0".to_owned(), + ResourceNames::ensure_max_length("a--defgh".to_owned(), 7, 4) + ); + + // A hash_length longer than the produced hash string may not produce the desired result. + // Just use sensible values! + assert_eq!( + "aaaaaaaaa-d476ce01c3787bcab054a2cf48d6af6dd303a0eb549e21a74125132f79d90c36".to_owned(), + ResourceNames::ensure_max_length("a".repeat(1011), 1010, 1000) + ); + } +} diff --git a/crates/stackable-operator/src/v2/role_utils.rs b/crates/stackable-operator/src/v2/role_utils.rs new file mode 100644 index 000000000..366951226 --- /dev/null +++ b/crates/stackable-operator/src/v2/role_utils.rs @@ -0,0 +1,394 @@ +use std::{ + collections::{BTreeMap, HashMap}, + str::FromStr, +}; + +use serde::{Deserialize, Serialize}; + +use super::{ + builder::pod::container::EnvVarSet, + jvm_argument_overrides::JvmArgumentOverrides, + types::{ + kubernetes::{ClusterRoleName, RoleBindingName, ServiceAccountName}, + operator::{ClusterName, ProductName}, + }, +}; +use crate::{ + config::{ + fragment::{self, FromFragment}, + merge::{self, Merge, merge}, + }, + k8s_openapi::{DeepMerge, api::core::v1::PodTemplateSpec}, + role_utils::{CommonConfiguration, Role, RoleGroup}, + schemars::{self, JsonSchema}, +}; + +// Variant of [`crate::role_utils::GenericCommonConfig`] that implements [`Merge`] +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Eq, Merge, PartialEq, Serialize)] +#[merge(path_overrides(merge = "crate::config::merge"))] +pub struct GenericCommonConfig {} + +// Variant of [`crate::role_utils::JavaCommonConfig`] that implements [`Merge`] +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Merge, PartialEq, Eq, Serialize)] +#[merge(path_overrides(merge = "crate::config::merge"))] +#[serde(rename_all = "camelCase")] +pub struct JavaCommonConfig { + /// Allows overriding JVM arguments. + // + /// Please read on the [JVM argument overrides documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/overrides#jvm-argument-overrides) + /// for details on the usage. + #[serde(default)] + pub jvm_argument_overrides: JvmArgumentOverrides, +} + +/// Variant of [`crate::role_utils::RoleGroup`] that is easier to work with +/// +/// Differences are: +/// * `replicas` is non-optional. +/// * `config` is flattened. +/// * The [`HashMap`] in `env_overrides` is replaced with an [`EnvVarSet`]. +#[derive(Clone, Debug, PartialEq)] +pub struct RoleGroupConfig { + pub replicas: u16, + pub config: Config, + pub config_overrides: ConfigOverrides, + pub env_overrides: EnvVarSet, + pub cli_overrides: BTreeMap, + pub pod_overrides: PodTemplateSpec, + // allow(dead_code) is not necessary anymore when moved to operator-rs + #[allow(dead_code)] + pub product_specific_common_config: CommonConfig, +} + +impl RoleGroupConfig { + pub fn cli_overrides_to_vec(&self) -> Vec { + self.cli_overrides + .clone() + .into_iter() + .flat_map(|(option, value)| [option, value]) + .collect() + } +} + +/// Merges and validates the [`RoleGroup`] with the given `role` and `default_config` +pub fn with_validated_config( + role_group: &RoleGroup, + role: &Role, + default_config: &Config, +) -> Result, fragment::ValidationError> +where + ValidatedConfig: FromFragment, + CommonConfig: Clone + Default + JsonSchema + Merge + Serialize, + Config: Clone + Merge, + RoleConfig: Default + JsonSchema + Serialize, + ConfigOverrides: Clone + Default + JsonSchema + Merge + Serialize, +{ + let validated_config = role_group.validate_config(role, default_config)?; + Ok(RoleGroup { + config: CommonConfiguration { + config: validated_config, + config_overrides: merged_config_overrides( + &role.config.config_overrides, + role_group.config.config_overrides.clone(), + ), + env_overrides: merged_env_overrides( + role.config.env_overrides.clone(), + role_group.config.env_overrides.clone(), + ), + cli_overrides: merged_cli_overrides( + role.config.cli_overrides.clone(), + role_group.config.cli_overrides.clone(), + ), + pod_overrides: merged_pod_overrides( + role.config.pod_overrides.clone(), + role_group.config.pod_overrides.clone(), + ), + product_specific_common_config: merged_product_specific_common_config( + &role.config.product_specific_common_config, + role_group.config.product_specific_common_config.clone(), + ), + }, + replicas: role_group.replicas, + }) +} + +fn merged_config_overrides( + role_config_overrides: &ConfigOverrides, + role_group_config_overrides: ConfigOverrides, +) -> ConfigOverrides +where + ConfigOverrides: Merge, +{ + merge::merge(role_group_config_overrides, role_config_overrides) +} + +fn merged_env_overrides( + role_env_overrides: HashMap, + role_group_env_overrides: HashMap, +) -> HashMap { + let mut merged_env_overrides = role_env_overrides; + merged_env_overrides.extend(role_group_env_overrides); + merged_env_overrides +} + +fn merged_cli_overrides( + role_cli_overrides: BTreeMap, + role_group_cli_overrides: BTreeMap, +) -> BTreeMap { + let mut merged_cli_overrides = role_cli_overrides; + merged_cli_overrides.extend(role_group_cli_overrides); + merged_cli_overrides +} + +fn merged_pod_overrides( + role_pod_overrides: PodTemplateSpec, + role_group_pod_overrides: PodTemplateSpec, +) -> PodTemplateSpec { + let mut merged_pod_overrides = role_pod_overrides; + merged_pod_overrides.merge_from(role_group_pod_overrides); + merged_pod_overrides +} + +fn merged_product_specific_common_config(role_config: &T, role_group_config: T) -> T +where + T: Merge, +{ + merge(role_group_config, role_config) +} + +/// Type-safe names for role resources +pub struct ResourceNames { + pub cluster_name: ClusterName, + pub product_name: ProductName, +} + +impl ResourceNames { + pub fn service_account_name(&self) -> ServiceAccountName { + const SUFFIX: &str = "-serviceaccount"; + + // compile-time checks + const _: () = assert!( + ClusterName::MAX_LENGTH + SUFFIX.len() <= ServiceAccountName::MAX_LENGTH, + "The string `-serviceaccount` must not exceed the limit of ServiceAccount names." + ); + let _ = ClusterName::IS_RFC_1123_SUBDOMAIN_NAME; + + ServiceAccountName::from_str(&format!("{}{SUFFIX}", self.cluster_name)) + .expect("should be a valid ServiceAccount name") + } + + pub fn role_binding_name(&self) -> RoleBindingName { + const SUFFIX: &str = "-rolebinding"; + + // compile-time checks + const _: () = assert!( + ClusterName::MAX_LENGTH + SUFFIX.len() <= RoleBindingName::MAX_LENGTH, + "The string `-rolebinding` must not exceed the limit of RoleBinding names." + ); + let _ = ClusterName::IS_RFC_1123_SUBDOMAIN_NAME; + + RoleBindingName::from_str(&format!("{}{SUFFIX}", self.cluster_name)) + .expect("should be a valid RoleBinding name") + } + + pub fn cluster_role_name(&self) -> ClusterRoleName { + const SUFFIX: &str = "-clusterrole"; + + // compile-time checks + const _: () = assert!( + ProductName::MAX_LENGTH + SUFFIX.len() <= ClusterRoleName::MAX_LENGTH, + "The string `-clusterrole` must not exceed the limit of cluster role names." + ); + let _ = ProductName::IS_RFC_1123_SUBDOMAIN_NAME; + + ClusterRoleName::from_str(&format!("{}{SUFFIX}", self.product_name)) + .expect("should be a valid cluster role name") + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap}; + + use rstest::*; + use serde::Serialize; + + use super::ResourceNames; + use crate::{ + config::{fragment::Fragment, merge::Merge}, + k8s_openapi::api::core::v1::PodTemplateSpec, + kube::api::ObjectMeta, + role_utils::{CommonConfiguration, GenericRoleConfig, Role, RoleGroup}, + schemars::{self, JsonSchema}, + v2::{ + config_overrides::KeyValueConfigOverrides, + role_utils::with_validated_config, + types::{ + kubernetes::{ClusterRoleName, RoleBindingName, ServiceAccountName}, + operator::{ClusterName, ProductName}, + }, + }, + }; + + #[derive(Debug, Fragment, PartialEq)] + #[fragment(path_overrides(fragment = "crate::config::fragment"))] + #[fragment_attrs( + derive(Clone, Debug, Default, Merge, Eq, PartialEq), + merge(path_overrides(merge = "crate::config::merge")) + )] + struct Config { + property: String, + } + + impl Config { + fn new(value: &str) -> Self { + Self { + property: value.to_owned(), + } + } + } + + impl ConfigFragment { + fn new(value: Option<&str>) -> Self { + Self { + property: value.map(str::to_owned), + } + } + } + + #[derive(Clone, Debug, Default, JsonSchema, Merge, PartialEq, Serialize)] + #[merge(path_overrides(merge = "crate::config::merge"))] + struct CommonConfig { + property: Option, + } + + fn new_common_config( + config: Config, + override_value: Option<&str>, + ) -> CommonConfiguration { + let mut config_file_overrides = BTreeMap::new(); + let mut env_overrides = HashMap::new(); + let mut cli_overrides = BTreeMap::new(); + + if let Some(value) = override_value { + config_file_overrides.insert("property".to_owned(), value.to_owned()); + env_overrides.insert("PROPERTY".to_owned(), value.to_owned()); + cli_overrides.insert("--property".to_owned(), value.to_owned()); + } + + CommonConfiguration { + config, + config_overrides: KeyValueConfigOverrides { + overrides: config_file_overrides, + }, + env_overrides, + cli_overrides, + pod_overrides: PodTemplateSpec { + metadata: Some(ObjectMeta { + name: override_value.map(str::to_owned), + ..ObjectMeta::default() + }), + ..PodTemplateSpec::default() + }, + product_specific_common_config: CommonConfig { + property: override_value.map(str::to_owned), + }, + } + } + + #[rstest] + #[case( + "role-group", + Some("role-group"), + Some("role-group"), + Some("role"), + Some("default") + )] + #[case( + "role-group", + Some("role-group"), + Some("role-group"), + Some("role"), + None + )] + #[case( + "role-group", + Some("role-group"), + Some("role-group"), + None, + Some("default") + )] + #[case("role-group", Some("role-group"), Some("role-group"), None, None)] + #[case("role", Some("role"), None, Some("role"), Some("default"))] + #[case("role", Some("role"), None, Some("role"), None)] + #[case("default", None, None, None, Some("default"))] + fn test_with_validated_config_and_result_ok( + #[case] expected_config_value: &str, + #[case] expected_override_value: Option<&str>, + #[case] role_group_value: Option<&str>, + #[case] role_value: Option<&str>, + #[case] default_value: Option<&str>, + ) { + let role_group = RoleGroup { + config: new_common_config(ConfigFragment::new(role_group_value), role_group_value), + replicas: Some(3), + }; + let role = Role::<_, _, GenericRoleConfig, _> { + config: new_common_config(ConfigFragment::new(role_value), role_value), + ..Role::default() + }; + let default_config = ConfigFragment::new(default_value); + + let result = with_validated_config(&role_group, &role, &default_config); + + assert_eq!( + Some(RoleGroup { + config: new_common_config( + Config::new(expected_config_value), + expected_override_value + ), + replicas: Some(3) + }), + result.ok() + ); + } + + #[test] + fn test_with_validated_config_and_result_err() { + let role_group = RoleGroup { + config: new_common_config(ConfigFragment::new(None), None), + replicas: None, + }; + let role = Role::<_, _, GenericRoleConfig, _> { + config: new_common_config(ConfigFragment::new(None), None), + ..Role::default() + }; + let default_config = ConfigFragment::new(None); + + let result: Result, _> = + with_validated_config(&role_group, &role, &default_config); + + assert!(result.is_err()); + } + + #[test] + fn test_resource_names() { + let resource_names = ResourceNames { + cluster_name: ClusterName::from_str_unsafe("my-cluster"), + product_name: ProductName::from_str_unsafe("my-product"), + }; + + assert_eq!( + ServiceAccountName::from_str_unsafe("my-cluster-serviceaccount"), + resource_names.service_account_name() + ); + assert_eq!( + RoleBindingName::from_str_unsafe("my-cluster-rolebinding"), + resource_names.role_binding_name() + ); + assert_eq!( + ClusterRoleName::from_str_unsafe("my-product-clusterrole"), + resource_names.cluster_role_name() + ); + } +} diff --git a/crates/stackable-operator/src/v2/types.rs b/crates/stackable-operator/src/v2/types.rs new file mode 100644 index 000000000..65f61166a --- /dev/null +++ b/crates/stackable-operator/src/v2/types.rs @@ -0,0 +1,3 @@ +pub mod common; +pub mod kubernetes; +pub mod operator; diff --git a/crates/stackable-operator/src/v2/types/common.rs b/crates/stackable-operator/src/v2/types/common.rs new file mode 100644 index 000000000..f63d35f48 --- /dev/null +++ b/crates/stackable-operator/src/v2/types/common.rs @@ -0,0 +1,68 @@ +//! Common types that do not belong (yet) to a more specific module +use snafu::{ResultExt, Snafu}; +use strum::{EnumDiscriminants, IntoStaticStr}; + +#[derive(Snafu, Debug, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr))] +pub enum Error { + #[snafu(display("failed to convert to port number"))] + ConvertToPortNumber { source: std::num::TryFromIntError }, +} + +/// A port number +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Port(pub u16); + +impl std::fmt::Display for Port { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl From for Port { + fn from(value: u16) -> Self { + Self(value) + } +} + +impl From for i32 { + fn from(value: Port) -> Self { + Self::from(value.0) + } +} + +impl TryFrom for Port { + type Error = Error; + + fn try_from(value: i32) -> Result { + Ok(Self( + u16::try_from(value).context(ConvertToPortNumberSnafu)?, + )) + } +} + +#[cfg(test)] +mod tests { + + use super::{ErrorDiscriminants, Port}; + + #[test] + fn test_port_fmt() { + assert_eq!("0".to_owned(), Port(0).to_string()); + assert_eq!("65535".to_owned(), Port(65535).to_string()); + } + + #[test] + fn test_port_try_from_i32() { + assert_eq!(Some(Port(0)), Port::try_from(0).ok()); + assert_eq!(Some(Port(65535)), Port::try_from(65535).ok()); + assert_eq!( + Err(ErrorDiscriminants::ConvertToPortNumber), + Port::try_from(-1).map_err(ErrorDiscriminants::from) + ); + assert_eq!( + Err(ErrorDiscriminants::ConvertToPortNumber), + Port::try_from(65536).map_err(ErrorDiscriminants::from) + ); + } +} diff --git a/crates/stackable-operator/src/v2/types/kubernetes.rs b/crates/stackable-operator/src/v2/types/kubernetes.rs new file mode 100644 index 000000000..f7380f327 --- /dev/null +++ b/crates/stackable-operator/src/v2/types/kubernetes.rs @@ -0,0 +1,192 @@ +//! Kubernetes (resource) names +use std::str::FromStr; + +use crate::{ + attributed_string_type, + validation::{RFC_1123_LABEL_MAX_LENGTH, RFC_1123_SUBDOMAIN_MAX_LENGTH}, +}; + +attributed_string_type! { + ConfigMapName, + "The name of a ConfigMap", + "opensearch-nodes-default", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + ConfigMapKey, + "The key for a ConfigMap", + "log4j2.properties", + (min_length = 1), + // see https://github.com/kubernetes/kubernetes/blob/v1.34.1/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L435-L451 + (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), + (regex = "^[-._a-zA-Z0-9]+$") +} + +attributed_string_type! { + ContainerName, + "The name of a container in a Pod", + "opensearch", + is_rfc_1123_label_name +} + +attributed_string_type! { + ClusterRoleName, + "The name of a ClusterRole", + "opensearch-clusterrole", + // On the one hand, ClusterRoles must only contain characters that are allowed for DNS + // subdomain names, on the other hand, their length does not seem to be restricted – at least + // on Kind. However, 253 characters are sufficient for the Stackable operators, and to avoid + // problems on other Kubernetes providers, the length is restricted here. + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + Hostname, + "A hostname", + "example.com", + (min_length = 1), + (max_length = 253), + // see https://en.wikipedia.org/wiki/Hostname#Syntax + (regex = "^[a-zA-Z0-9]([-a-zA-Z0-9]{0,60}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([-a-zA-Z0-9]{0,60}[a-zA-Z0-9])?)*\\.?$") +} + +attributed_string_type! { + ListenerName, + "The name of a Listener", + "opensearch-nodes-default", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + ListenerClassName, + "The name of a Listener", + "external-stable", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + NamespaceName, + "The name of a Namespace", + "stackable-operators", + is_rfc_1123_label_name, + is_valid_label_value +} + +attributed_string_type! { + PersistentVolumeClaimName, + "The name of a PersistentVolumeClaim", + "config", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + RoleBindingName, + "The name of a RoleBinding", + "opensearch-rolebinding", + // On the one hand, RoleBindings must only contain characters that are allowed for DNS + // subdomain names, on the other hand, their length does not seem to be restricted – at least + // on Kind. However, 253 characters are sufficient for the Stackable operators, and to avoid + // problems on other Kubernetes providers, the length is restricted here. + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + SecretClassName, + "The name of a SecretClass", + "tls", + // The secret class name is used in an annotation on the tls volume. + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + SecretKey, + "The key for a Secret", + "accessKey", + (min_length = 1), + // see https://github.com/kubernetes/kubernetes/blob/v1.34.1/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L435-L451 + (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), + (regex = "^[-._a-zA-Z0-9]+$") +} + +attributed_string_type! { + SecretName, + "The name of a Secret", + "opensearch-security-config", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + ServiceAccountName, + "The name of a ServiceAccount", + "opensearch-serviceaccount", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + ServiceName, + "The name of a Service", + "opensearch-nodes-default-headless", + is_rfc_1035_label_name, + is_valid_label_value +} + +attributed_string_type! { + StatefulSetName, + "The name of a StatefulSet", + "opensearch-nodes-default", + (max_length = + // see https://github.com/kubernetes/kubernetes/issues/64023 + RFC_1123_LABEL_MAX_LENGTH + - 1 /* dash */ + - 10 /* digits for the controller-revision-hash label */), + is_rfc_1123_label_name, + is_valid_label_value +} + +attributed_string_type! { + Uid, + "A UID", + "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", + is_uid, + is_valid_label_value +} + +attributed_string_type! { + VolumeName, + "The name of a Volume", + "opensearch-nodes-default", + is_rfc_1123_label_name, + is_valid_label_value +} + +#[cfg(test)] +mod tests { + use super::{ + ClusterRoleName, ConfigMapKey, ConfigMapName, ContainerName, Hostname, ListenerClassName, + ListenerName, NamespaceName, PersistentVolumeClaimName, RoleBindingName, SecretClassName, + SecretKey, SecretName, ServiceAccountName, ServiceName, StatefulSetName, Uid, VolumeName, + }; + + #[test] + fn test_attributed_string_type_examples() { + ConfigMapName::test_example(); + ConfigMapKey::test_example(); + ContainerName::test_example(); + ClusterRoleName::test_example(); + Hostname::test_example(); + ListenerName::test_example(); + ListenerClassName::test_example(); + NamespaceName::test_example(); + PersistentVolumeClaimName::test_example(); + RoleBindingName::test_example(); + SecretClassName::test_example(); + SecretKey::test_example(); + SecretName::test_example(); + ServiceAccountName::test_example(); + ServiceName::test_example(); + StatefulSetName::test_example(); + Uid::test_example(); + VolumeName::test_example(); + } +} diff --git a/crates/stackable-operator/src/v2/types/operator.rs b/crates/stackable-operator/src/v2/types/operator.rs new file mode 100644 index 000000000..eb6ec22bc --- /dev/null +++ b/crates/stackable-operator/src/v2/types/operator.rs @@ -0,0 +1,86 @@ +//! Names for operators + +use std::str::FromStr; + +use crate::attributed_string_type; + +attributed_string_type! { + ProductName, + "The name of a product", + "opensearch", + // A suffix is added to produce a label value. An according compile-time check ensures that + // max_length cannot be set higher. + (max_length = 54), + is_rfc_1123_dns_subdomain_name, + is_valid_label_value +} + +attributed_string_type! { + ProductVersion, + "The version of a product", + "3.4.0", + is_valid_label_value +} + +attributed_string_type! { + ClusterName, + "The name of a cluster/stacklet", + "my-opensearch-cluster", + // Suffixes are added to produce resource names. + // + // 40 characters for cluster names should be sufficient and still allow the operators to append + // custom suffixes to build resource names. Increasing this value could break existing operator + // code. + (max_length = 40), + is_rfc_1035_label_name, + is_valid_label_value +} + +attributed_string_type! { + ControllerName, + "The name of a controller in an operator", + "opensearchcluster", + is_valid_label_value +} + +attributed_string_type! { + OperatorName, + "The name of an operator", + "opensearch.stackable.tech", + is_valid_label_value +} + +attributed_string_type! { + RoleGroupName, + "The name of a role-group name", + "cluster-manager", + is_rfc_1123_label_name, + is_valid_label_value +} + +attributed_string_type! { + RoleName, + "The name of a role name", + "nodes", + is_rfc_1123_label_name, + is_valid_label_value +} + +#[cfg(test)] +mod tests { + use super::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, + RoleName, + }; + + #[test] + fn test_attributed_string_type_examples() { + ProductName::test_example(); + ProductVersion::test_example(); + ClusterName::test_example(); + ControllerName::test_example(); + OperatorName::test_example(); + RoleGroupName::test_example(); + RoleName::test_example(); + } +} diff --git a/crates/stackable-telemetry/src/tracing/mod.rs b/crates/stackable-telemetry/src/tracing/mod.rs index 9ab820b9f..64b75e4be 100644 --- a/crates/stackable-telemetry/src/tracing/mod.rs +++ b/crates/stackable-telemetry/src/tracing/mod.rs @@ -133,7 +133,7 @@ pub enum Error { /// ## Builders /// /// When choosing the builder, there are two different styles to configure individual subscribers: -/// Using the sophisticated [`SettingsBuilder`] or the simplified tuple style for basic +/// Using the sophisticated [`SettingsBuilder`](settings::SettingsBuilder) or the simplified tuple style for basic /// configuration. Currently, three different subscribers are supported: console output, OTLP log /// export, and OTLP trace export. /// @@ -173,9 +173,9 @@ pub enum Error { /// subscriber provides specific settings based on a common set of options. These options can be /// customized via the following methods: /// -/// - [`SettingsBuilder::console_log_settings_builder`] -/// - [`SettingsBuilder::otlp_log_settings_builder`] -/// - [`SettingsBuilder::otlp_trace_settings_builder`] +/// - [`SettingsBuilder::console_log_settings_builder`](settings::SettingsBuilder::console_log_settings_builder) +/// - [`SettingsBuilder::otlp_log_settings_builder`](settings::SettingsBuilder::otlp_log_settings_builder) +/// - [`SettingsBuilder::otlp_trace_settings_builder`](settings::SettingsBuilder::otlp_trace_settings_builder) /// /// ``` /// # use stackable_telemetry::tracing::{Tracing, Error, settings::Settings};