From fc67209815b0a15df79c0928a726f6876ea259d5 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Fri, 19 Jun 2026 16:08:16 +0200 Subject: [PATCH 1/4] feat(operator): implement basic fact operator At this point the operator is a PoC, meant only as a small experiment to see if it is even possible to implement. TODO: * Expose more configuration options. * Implement some sort of test that will ensure the operator works and deploys fact correctly. --- Cargo.lock | 741 ++++++++++++++++++++++++++++++++++++-- Cargo.toml | 1 + Containerfile | 27 +- Makefile | 13 +- constants.mk | 2 + fact-operator/Cargo.toml | 17 + fact-operator/src/lib.rs | 76 ++++ fact-operator/src/main.rs | 4 + fact-operator/src/spec.rs | 167 +++++++++ k8s/example-cr.yml | 9 + k8s/operator.yml | 129 +++++++ 11 files changed, 1154 insertions(+), 32 deletions(-) create mode 100644 fact-operator/Cargo.toml create mode 100644 fact-operator/src/lib.rs create mode 100644 fact-operator/src/main.rs create mode 100644 fact-operator/src/spec.rs create mode 100644 k8s/example-cr.yml create mode 100644 k8s/operator.yml diff --git a/Cargo.lock b/Cargo.lock index 48dccd5d..03cdef77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,19 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,6 +30,17 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + [[package]] name = "anstream" version = "1.0.0" @@ -85,6 +109,40 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -102,6 +160,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -153,7 +217,7 @@ checksum = "66e644424fada9fff4fdc63848db1732fb69b626e8328202ef55c03df1f4d939" dependencies = [ "assert_matches", "aya-obj", - "bitflags 2.13.1", + "bitflags 2.13.0", "hashbrown 0.17.1", "libc", "log", @@ -175,6 +239,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "base64" version = "0.22.1" @@ -187,7 +262,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -209,9 +284,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bstr" @@ -284,9 +359,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -294,9 +369,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -328,6 +403,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -368,6 +452,40 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -413,6 +531,27 @@ dependencies = [ "thiserror", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -430,6 +569,24 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.16.0" @@ -445,6 +602,35 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "env_filter" version = "2.0.0" @@ -482,6 +668,27 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fact" version = "0.4.0-dev" @@ -547,6 +754,22 @@ dependencies = [ "serde", ] +[[package]] +name = "fact-operator" +version = "0.1.0" +dependencies = [ + "anyhow", + "env_logger", + "futures", + "k8s-openapi", + "kube", + "log", + "schemars", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -607,6 +830,20 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -675,6 +912,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -719,9 +957,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ "aho-corasick", "bstr", @@ -730,6 +968,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "governor" version = "0.10.4" @@ -753,6 +1003,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "granit-parser" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50ba32164f9e098d5da618776a32afbb32270adcbe3d3d006107dae11e37c91" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "h2" version = "0.4.15" @@ -823,6 +1083,17 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "1.4.2" @@ -890,6 +1161,25 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-openssl" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527d4d619ca2c2aafa31ec139a3d1d60bf557bf7578a1f20f743637eccd9ca19" +dependencies = [ + "http", + "hyper", + "hyper-util", + "linked_hash_set", + "once_cell", + "openssl", + "openssl-sys", + "parking_lot", + "pin-project", + "tower-layer", + "tower-service", +] + [[package]] name = "hyper-timeout" version = "0.5.2" @@ -1024,6 +1314,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1127,6 +1423,162 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "jsonpath-rust" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4222e00941bfe18bf81b79fa23ad933e233bfa18f3ee27254c5dd9b1543b5d5f" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "k8s-openapi" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c6922f6afe80418dd6019818af5d0d34584c371780ff09b9752370c25b4abb" +dependencies = [ + "base64", + "jiff", + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "kube" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb9108095346a7096d11feeaff419c75dddcac1b2f59acb38d7bf3d13c3e146" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f628e05bc2264c21fe10d3d675117dc9b43ea3bf4fb07262a222679757537b" +dependencies = [ + "base64", + "bytes", + "either", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-openssl", + "hyper-timeout", + "hyper-util", + "jiff", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "openssl", + "pem", + "secrecy", + "serde", + "serde-saphyr", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b02f5933ba06140d58c7d6727f6c319f0962ec6a344aa5e21e475e891deaa8" +dependencies = [ + "derive_more", + "form_urlencoded", + "http", + "jiff", + "json-patch", + "k8s-openapi", + "schemars", + "serde", + "serde-value", + "serde_json", + "thiserror", +] + +[[package]] +name = "kube-derive" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe171898707dadf1818ef94e81ef57f6beb7edf9ba87b9e814c045dad356c7aa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", +] + +[[package]] +name = "kube-runtime" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ddec66c540c7cf29a5b41fe4a657a53687f95c346e03bdf00585b70a1bab21" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "backon", + "educe", + "futures", + "hashbrown 0.16.1", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "libc" version = "0.2.186" @@ -1143,6 +1595,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1228,6 +1695,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -1244,6 +1717,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.39.1" @@ -1274,7 +1756,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -1381,6 +1863,21 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1404,12 +1901,64 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -1587,7 +2136,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", "memchr", "unicase", ] @@ -1689,7 +2238,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", ] [[package]] @@ -1698,14 +2247,34 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] name = "regex" -version = "1.13.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -1715,9 +2284,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -1767,13 +2336,22 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -1795,19 +2373,53 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", "core-foundation", "core-foundation-sys", "libc", @@ -1824,6 +2436,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1834,6 +2452,35 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "0.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5897b4c3faadadd35fdb6689f015641f3bc481d5adaaac56231ea15aeb243db3" +dependencies = [ + "ahash", + "annotate-snippets", + "base64", + "encoding_rs_io", + "getrandom 0.3.4", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde", + "smallvec", + "zmij", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1854,6 +2501,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -2008,9 +2666,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -2024,9 +2682,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -2065,6 +2723,7 @@ dependencies = [ "futures-core", "futures-sink", "pin-project-lite", + "slab", "tokio", ] @@ -2161,15 +2820,18 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "base64", + "bitflags 2.13.0", "bytes", "futures-util", "http", "http-body", + "mime", "pin-project-lite", "tower", "tower-layer", "tower-service", + "tracing", "url", ] @@ -2191,6 +2853,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2222,6 +2885,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -2234,6 +2903,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "url" version = "2.5.8" @@ -2260,9 +2935,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -2275,6 +2950,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -2498,6 +3179,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index c4f525a2..71f5cee9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "fact", "fact-api", "fact-ebpf", + "fact-operator", ] default-members = ["fact"] diff --git a/Containerfile b/Containerfile index e296dc64..7ab96155 100644 --- a/Containerfile +++ b/Containerfile @@ -43,10 +43,11 @@ ARG FACT_VERSION ARG CARGO_ARGS="" RUN --mount=type=cache,target=/root/.cargo/registry \ --mount=type=cache,target=/app/target \ - cargo build --release $CARGO_ARGS && \ - cp target/release/fact fact + cargo build --release $CARGO_ARGS --all && \ + cp target/release/fact fact && \ + cp target/release/fact-operator fact-operator -FROM ubi-micro-base +FROM ubi-micro-base AS fact ARG FACT_VERSION LABEL name="fact" \ @@ -65,3 +66,23 @@ COPY LICENSE-APACHE LICENSE-MIT LICENSE-GPL2 /licenses/ RUN update-crypto-policies --set DEFAULT:PQ ENTRYPOINT ["fact"] + +FROM ubi-micro-base AS fact-operator + +ARG FACT_VERSION +LABEL name="fact-operator" \ + vendor="StackRox" \ + maintainer="support@stackrox.com" \ + summary="File activity operator" \ + description="This image supports an operator for deploying the file activity daemonset" \ + io.stackrox.fact-operator.version="${FACT_VERSION}" + +COPY --from=package_installer /out/ / + +COPY --from=build /app/fact-operator /usr/local/bin + +COPY LICENSE-APACHE LICENSE-MIT LICENSE-GPL2 /licenses/ + +RUN update-crypto-policies --set DEFAULT:PQ + +ENTRYPOINT ["fact-operator"] diff --git a/Makefile b/Makefile index ef94eb3d..f0555713 100644 --- a/Makefile +++ b/Makefile @@ -12,18 +12,26 @@ image-name: mock-server: make -C mock-server +BUILD_TARGET ?= fact +IMAGE_NAME ?= $(FACT_IMAGE_NAME) + image: $(DOCKER) build \ -f Containerfile \ --build-arg FACT_VERSION=$(FACT_VERSION) \ --build-arg RUST_VERSION=$(RUST_VERSION) \ --build-arg CARGO_ARGS="$(CARGO_ARGS)" \ - -t $(FACT_IMAGE_NAME) \ + --target $(BUILD_TARGET) \ + -t $(IMAGE_NAME) \ $(CURDIR) image-otel: CARGO_ARGS = --features otel image-otel: image +operator: BUILD_TARGET = fact-operator +operator: IMAGE_NAME = $(FACT_OPERATOR_NAME) +operator: image + licenses:THIRD_PARTY_LICENSES.html THIRD_PARTY_LICENSES.html:Cargo.lock @@ -58,4 +66,5 @@ format: make -C fact-ebpf format ruff format tests/ -.PHONY: tag mock-server integration-tests image image-otel image-name licenses coverage lint clean +.PHONY: tag mock-server integration-tests image image-otel image-name +.PHONY: operator licenses coverage lint clean diff --git a/constants.mk b/constants.mk index c624c597..6e2f9427 100644 --- a/constants.mk +++ b/constants.mk @@ -4,6 +4,8 @@ FACT_TAG ?= $(shell git describe --always --tags --abbrev=10 --dirty) FACT_VERSION ?= $(FACT_TAG) FACT_REGISTRY ?= quay.io/stackrox-io/fact FACT_IMAGE_NAME ?= $(FACT_REGISTRY):$(FACT_TAG) +FACT_OPERATOR_REGISTRY ?= quay.io/stackrox-io/fact-operator +FACT_OPERATOR_NAME ?= $(FACT_OPERATOR_REGISTRY):$(FACT_TAG) CLANG_FMT ?= $(shell which clang-format) diff --git a/fact-operator/Cargo.toml b/fact-operator/Cargo.toml new file mode 100644 index 00000000..ff342942 --- /dev/null +++ b/fact-operator/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "fact-operator" +version = "0.1.0" +edition = "2024" +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +env_logger = { workspace = true } +futures = { version = "0.3.32", default-features = false } +kube = { version = "4.0.0", default-features = false, features = ["client", "openssl-tls", "derive", "runtime"] } +k8s-openapi = { version = "0.28.0", features = ["latest", "schemars"] } +log = { workspace = true } +schemars = { version = "1" } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/fact-operator/src/lib.rs b/fact-operator/src/lib.rs new file mode 100644 index 00000000..57ee19fd --- /dev/null +++ b/fact-operator/src/lib.rs @@ -0,0 +1,76 @@ +use std::{sync::Arc, time::Duration}; + +use futures::StreamExt; +use k8s_openapi::api::{apps::v1::DaemonSet, core::v1::ConfigMap}; +use kube::{ + Api, Client, ResourceExt, + api::{Patch, PatchParams}, + runtime::{Controller, controller::Action, watcher}, +}; +use log::{info, warn}; + +use crate::spec::Fact; + +mod spec; + +struct Context { + client: Client, +} + +async fn reconcile(fact: Arc, ctx: Arc) -> Result { + info!("Starting reconciliation loop"); + let ns = fact.namespace().unwrap(); + + let cm = spec::build_configmap(&fact); + let api = Api::::namespaced(ctx.client.clone(), &ns); + api.patch( + &format!("{}-config", fact.name_any()), + &PatchParams::apply("fact-operator"), + &Patch::Apply(cm), + ) + .await?; + + let ds = spec::build_daemonset(&fact); + let api = Api::::namespaced(ctx.client.clone(), &ns); + api.patch( + &fact.name_any(), + &PatchParams::apply("fact-operator"), + &Patch::Apply(ds), + ) + .await?; + + info!("Reconciliation done"); + Ok(Action::requeue(Duration::from_secs(300))) +} + +fn error_policy(_obj: Arc, _err: &kube::Error, _ctx: Arc) -> Action { + Action::requeue(Duration::from_secs(60)) +} + +pub async fn run() -> anyhow::Result<()> { + env_logger::init(); + info!("Operator starting..."); + let client = Client::try_default().await?; + let fact = Api::::all(client.clone()); + + Controller::new(fact, watcher::Config::default()) + .owns( + Api::::all(client.clone()), + watcher::Config::default(), + ) + .owns( + Api::::all(client.clone()), + watcher::Config::default(), + ) + .run(reconcile, error_policy, Arc::new(Context { client })) + .for_each(|res| async move { + match res { + Ok(o) => info!("reconciled: {o:?}"), + Err(e) => warn!("reconciler failed: {e:?}"), + } + }) + .await; + + info!("Operator done"); + Ok(()) +} diff --git a/fact-operator/src/main.rs b/fact-operator/src/main.rs new file mode 100644 index 00000000..289b177b --- /dev/null +++ b/fact-operator/src/main.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() -> anyhow::Result<()> { + fact_operator::run().await +} diff --git a/fact-operator/src/spec.rs b/fact-operator/src/spec.rs new file mode 100644 index 00000000..621c6724 --- /dev/null +++ b/fact-operator/src/spec.rs @@ -0,0 +1,167 @@ +use std::collections::BTreeMap; + +use k8s_openapi::{ + api::{ + apps::v1::{DaemonSet, DaemonSetSpec}, + core::v1::{ + Capabilities, ConfigMap, ConfigMapVolumeSource, Container, ContainerPort, EnvVar, + HostPathVolumeSource, PodSpec, PodTemplateSpec, SecurityContext, Volume, VolumeMount, + }, + }, + apimachinery::pkg::apis::meta::v1::LabelSelector, +}; +use kube::{CustomResource, Resource, ResourceExt, api::ObjectMeta}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, CustomResource)] +#[serde(rename_all = "camelCase")] +#[kube( + group = "fact.stackrox.io", + version = "v1alpha1", + kind = "Fact", + namespaced +)] +pub(crate) struct FactSpec { + pub image: String, + #[serde(default = "default_log_level")] + pub log_level: String, + #[serde(default = "default_rate_limit")] + pub rate_limit: u64, +} + +fn default_log_level() -> String { + String::from("info") +} + +fn default_rate_limit() -> u64 { + 0 +} + +pub(crate) fn build_configmap(fact: &Fact) -> ConfigMap { + let data = BTreeMap::from([( + "fact.yml".into(), + format!("rate_limit: {}", fact.spec.rate_limit), + )]); + ConfigMap { + data: Some(data), + metadata: ObjectMeta { + name: Some(format!("{}-config", fact.name_any())), + namespace: fact.namespace(), + owner_references: fact.controller_owner_ref(&()).map(|r| vec![r]), + ..Default::default() + }, + ..Default::default() + } +} + +pub(crate) fn build_daemonset(fact: &Fact) -> DaemonSet { + let spec = &fact.spec; + let name = fact.name_any(); + let namespace = fact.namespace(); + let labels = BTreeMap::from([("app".into(), "fact".into())]); + + let metadata = ObjectMeta { + labels: Some(labels.clone()), + name: Some(name), + namespace, + owner_references: fact.controller_owner_ref(&()).map(|r| vec![r]), + ..Default::default() + }; + + let container = Container { + name: "fact".into(), + image: Some(spec.image.clone()), + image_pull_policy: Some("IfNotPresent".into()), + args: Some(vec![ + "--paths".into(), + "/etc:/bin:/sbin:/usr/bin:/usr/sbin".into(), + ]), + ports: Some(vec![ContainerPort { + container_port: 9000, + name: Some("monitoring".into()), + ..Default::default() + }]), + env: Some(vec![ + EnvVar { + name: "FACT_LOGLEVEL".into(), + value: Some(spec.log_level.clone()), + ..Default::default() + }, + EnvVar { + name: "FACT_HOST_MOUNT".into(), + value: Some("/host".into()), + ..Default::default() + }, + ]), + security_context: Some(SecurityContext { + capabilities: Some(Capabilities { + drop: Some(vec!["NET_RAW".into()]), + ..Default::default() + }), + privileged: Some(true), + read_only_root_filesystem: Some(true), + ..Default::default() + }), + volume_mounts: Some(vec![ + VolumeMount { + name: "root-ro".into(), + mount_path: "/host".into(), + read_only: Some(true), + mount_propagation: Some("HostToContainer".into()), + ..Default::default() + }, + VolumeMount { + mount_path: "/etc/stackrox".into(), + name: "fact-config".into(), + read_only: Some(true), + ..Default::default() + }, + ]), + ..Default::default() + }; + + let volumes = vec![ + Volume { + host_path: Some(HostPathVolumeSource { + path: "/".into(), + ..Default::default() + }), + name: "root-ro".into(), + ..Default::default() + }, + Volume { + name: "fact-config".into(), + config_map: Some(ConfigMapVolumeSource { + name: format!("{}-config", fact.name_any()), + ..Default::default() + }), + ..Default::default() + }, + ]; + + let spec = DaemonSetSpec { + selector: LabelSelector { + match_labels: Some(labels.clone()), + ..Default::default() + }, + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some(labels), + ..Default::default() + }), + spec: Some(PodSpec { + containers: vec![container], + volumes: Some(volumes), + ..Default::default() + }), + }, + ..Default::default() + }; + + DaemonSet { + metadata, + spec: Some(spec), + ..Default::default() + } +} diff --git a/k8s/example-cr.yml b/k8s/example-cr.yml new file mode 100644 index 00000000..6ecd32fd --- /dev/null +++ b/k8s/example-cr.yml @@ -0,0 +1,9 @@ +apiVersion: fact.stackrox.io/v1alpha1 +kind: Fact +metadata: + name: fact + namespace: default +spec: + image: quay.io/stackrox-io/fact:0.3.0 + logLevel: debug + rateLimit: 60000 diff --git a/k8s/operator.yml b/k8s/operator.yml new file mode 100644 index 00000000..0d71ee37 --- /dev/null +++ b/k8s/operator.yml @@ -0,0 +1,129 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: facts.fact.stackrox.io +spec: + group: fact.stackrox.io + names: + kind: Fact + plural: facts + singular: fact + shortNames: + - ft + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [image] + properties: + image: + type: string + logLevel: + type: string + default: info + rateLimit: + type: integer + default: 0 + status: + type: object + properties: + readyNodes: + type: integer + desiredNodes: + type: integer + subresources: + status: {} + additionalPrinterColumns: + - name: Image + type: string + jsonPath: .spec.image + - name: Ready + type: string + jsonPath: .status.readyNodes + - name: Desired + type: string + jsonPath: .status.desiredNodes +--- +apiVersion: v1 +kind: Namespace +metadata: + name: fact-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: fact-operator + namespace: fact-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: fact-operator +rules: + # Watch and update our CRD instances + - apiGroups: [fact.stackrox.io] + resources: [facts, facts/status] + verbs: [get, list, watch, patch, update] + # Manage ConfigMaps + - apiGroups: [""] + resources: [configmaps] + verbs: [get, list, watch, create, update, patch, delete] + # Manage DaemonSets + - apiGroups: [apps] + resources: [daemonsets] + verbs: [get, list, watch, create, update, patch, delete] + # Emit events + - apiGroups: [""] + resources: [events] + verbs: [create, patch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: fact-operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: fact-operator +subjects: + - kind: ServiceAccount + name: fact-operator + namespace: fact-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fact-operator + namespace: fact-system +spec: + replicas: 1 + selector: + matchLabels: + app: fact-operator + template: + metadata: + labels: + app: fact-operator + spec: + serviceAccountName: fact-operator + containers: + - name: fact-operator + image: quay.io/mmoltras/fact:operator + imagePullPolicy: Always + env: + - name: RUST_LOG + value: debug + resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi From 88a03007bcc5f9705352eddf0e845ddc6c60ce03 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Fri, 19 Jun 2026 17:05:48 +0200 Subject: [PATCH 2/4] feat(operator): expose all fact configuration values This requires some refactoring to re-use the FactConfig struct as part of the CRD spec for fact. We also make all configuration apply through a config map, which means we can hotreload the configuration as required. Still needs more testing, but so far it looks good. --- Cargo.lock | 16 ++ Cargo.toml | 2 + Makefile | 5 +- fact-core/Cargo.toml | 17 ++ {fact => fact-core}/build.rs | 0 {fact => fact-core}/src/config/mod.rs | 225 ++++++++++++++++++++- {fact => fact-core}/src/config/reloader.rs | 0 {fact => fact-core}/src/config/tests.rs | 0 fact-core/src/lib.rs | 11 + fact-operator/Cargo.toml | 5 +- fact-operator/src/spec.rs | 25 +-- fact/Cargo.toml | 1 + fact/src/bpf/mod.rs | 6 +- fact/src/endpoints.rs | 4 +- fact/src/lib.rs | 12 +- fact/src/main.rs | 2 +- fact/src/output/grpc.rs | 8 +- fact/src/output/mod.rs | 8 +- fact/src/output/otel.rs | 4 +- k8s/example-cr.yml | 16 +- k8s/operator.yml | 66 +++++- 21 files changed, 378 insertions(+), 55 deletions(-) create mode 100644 fact-core/Cargo.toml rename {fact => fact-core}/build.rs (100%) rename {fact => fact-core}/src/config/mod.rs (80%) rename {fact => fact-core}/src/config/reloader.rs (100%) rename {fact => fact-core}/src/config/tests.rs (100%) create mode 100644 fact-core/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 03cdef77..5ea2f7b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -698,6 +698,7 @@ dependencies = [ "clap", "env_logger", "fact-api", + "fact-core", "fact-ebpf", "glob", "globset", @@ -743,6 +744,19 @@ dependencies = [ "tonic-prost-build", ] +[[package]] +name = "fact-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "log", + "schemars", + "serde", + "tokio", + "yaml-rust2", +] + [[package]] name = "fact-ebpf" version = "0.1.0" @@ -760,6 +774,7 @@ version = "0.1.0" dependencies = [ "anyhow", "env_logger", + "fact-core", "futures", "k8s-openapi", "kube", @@ -768,6 +783,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "yaml-rust2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 71f5cee9..6686bf89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "fact", "fact-api", + "fact-core", "fact-ebpf", "fact-operator", ] @@ -32,6 +33,7 @@ prometheus-client = { version = "0.25.0", default-features = false } prost = "0.14.0" prost-types = "0.14.0" rand = { version = "0.10.1", default-features = false, features = ["thread_rng"] } +schemars = { version = "1" } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" shlex = "2.0.1" diff --git a/Makefile b/Makefile index f0555713..5859c680 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,9 @@ version: image-name: @echo "$(FACT_IMAGE_NAME)" +operator-name: + @echo "$(FACT_OPERATOR_NAME)" + mock-server: make -C mock-server @@ -67,4 +70,4 @@ format: ruff format tests/ .PHONY: tag mock-server integration-tests image image-otel image-name -.PHONY: operator licenses coverage lint clean +.PHONY: operator operator-name licenses coverage lint clean diff --git a/fact-core/Cargo.toml b/fact-core/Cargo.toml new file mode 100644 index 00000000..3cbdc801 --- /dev/null +++ b/fact-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "fact-core" +version = "0.1.0" +edition = "2024" +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +log = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +yaml-rust2 = { workspace = true } + +[build-dependencies] +anyhow = { workspace = true } diff --git a/fact/build.rs b/fact-core/build.rs similarity index 100% rename from fact/build.rs rename to fact-core/build.rs diff --git a/fact/src/config/mod.rs b/fact-core/src/config/mod.rs similarity index 80% rename from fact/src/config/mod.rs rename to fact-core/src/config/mod.rs index 88daa519..33152cb4 100644 --- a/fact/src/config/mod.rs +++ b/fact-core/src/config/mod.rs @@ -10,6 +10,8 @@ use std::{ use anyhow::{Context, bail}; use clap::Parser; use log::info; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use yaml_rust2::{Yaml, YamlLoader, yaml}; pub mod reloader; @@ -30,12 +32,25 @@ fn yaml_to_duration_secs(v: &Yaml) -> Option { .map(Duration::from_secs_f64) } -#[derive(Debug, Default, PartialEq, Clone)] +fn duration_to_yaml(v: Duration) -> Yaml { + let v = v.as_secs_f64(); + if v.fract() != 0.0 { + Yaml::Real(v.to_string()) + } else { + Yaml::Integer(v as i64) + } +} + +#[derive(Debug, Default, PartialEq, Clone, JsonSchema, Serialize, Deserialize)] pub struct FactConfig { paths: Option>, + #[serde(default)] pub grpc: GrpcConfig, + #[serde(default)] pub otel: OTelConfig, + #[serde(default)] pub endpoint: EndpointConfig, + #[serde(default)] pub bpf: BpfConfig, skip_pre_flight: Option, json: Option, @@ -134,10 +149,68 @@ impl FactConfig { self.rate_limit.unwrap_or(0) } - #[cfg(test)] pub fn set_paths(&mut self, paths: Vec) { self.paths = Some(paths); } + + pub fn to_yaml(&self) -> Yaml { + let mut config = yaml::Hash::new(); + + if let Some(paths) = &self.paths { + let paths = paths + .iter() + .filter_map(|p| p.to_str().map(|p| Yaml::String(p.to_string()))) + .collect::>(); + config.insert(Yaml::String("paths".into()), Yaml::Array(paths)); + } + + if let Some(grpc) = self.grpc.to_yaml() { + config.insert(Yaml::String("grpc".into()), grpc); + } + + if let Some(otel) = self.otel.to_yaml() { + config.insert(Yaml::String("otel".into()), otel); + } + + if let Some(endpoint) = self.endpoint.to_yaml() { + config.insert(Yaml::String("endpoint".into()), endpoint); + } + + if let Some(bpf) = self.bpf.to_yaml() { + config.insert(Yaml::String("bpf".into()), bpf); + } + + if let Some(skip_pre_flight) = self.skip_pre_flight { + config.insert( + Yaml::String("skip_pre_flight".into()), + Yaml::Boolean(skip_pre_flight), + ); + } + + if let Some(json) = self.json { + config.insert(Yaml::String("json".into()), Yaml::Boolean(json)); + } + + if let Some(hotreload) = self.hotreload { + config.insert(Yaml::String("hotreload".into()), Yaml::Boolean(hotreload)); + } + + if let Some(scan_interval) = self.scan_interval { + config.insert( + Yaml::String("scan_interval".into()), + duration_to_yaml(scan_interval), + ); + } + + if let Some(rate_limit) = self.rate_limit { + config.insert( + Yaml::String("rate_limit".into()), + Yaml::Integer(rate_limit as i64), + ); + } + + Yaml::Hash(config) + } } impl TryFrom<&str> for FactConfig { @@ -218,10 +291,8 @@ impl TryFrom> for FactConfig { }; config.json = Some(json); } - "bpf" => { - let Some(bpf) = v.as_hash() else { - bail!("bpf section has incorrect type: {v:#?}"); - }; + "bpf" if v.is_hash() => { + let bpf = v.as_hash().unwrap(); config.bpf = BpfConfig::try_from(bpf)?; } "hotreload" => { @@ -255,7 +326,7 @@ impl TryFrom> for FactConfig { } } -#[derive(Debug, Default, PartialEq, Eq, Clone)] +#[derive(Debug, Default, PartialEq, Eq, Clone, JsonSchema, Serialize, Deserialize)] pub struct EndpointConfig { address: Option, expose_metrics: Option, @@ -289,6 +360,37 @@ impl EndpointConfig { pub fn health_check(&self) -> bool { self.health_check.unwrap_or(false) } + + fn to_yaml(&self) -> Option { + let mut endpoint = yaml::Hash::new(); + + if let Some(address) = self.address { + endpoint.insert( + Yaml::String("address".into()), + Yaml::String(address.to_string()), + ); + } + + if let Some(expose_metrics) = self.expose_metrics { + endpoint.insert( + Yaml::String("expose_metrics".into()), + Yaml::Boolean(expose_metrics), + ); + } + + if let Some(health_check) = self.health_check { + endpoint.insert( + Yaml::String("health_check".into()), + Yaml::Boolean(health_check), + ); + } + + if !endpoint.is_empty() { + Some(Yaml::Hash(endpoint)) + } else { + None + } + } } impl TryFrom<&yaml::Hash> for EndpointConfig { @@ -332,7 +434,7 @@ impl TryFrom<&yaml::Hash> for EndpointConfig { } } -#[derive(Debug, Default, PartialEq, Clone)] +#[derive(Debug, Default, PartialEq, Clone, JsonSchema, Serialize, Deserialize)] pub struct BackoffConfig { initial: Option, max: Option, @@ -379,6 +481,42 @@ impl BackoffConfig { pub fn retries(&self) -> u64 { self.retries_max.unwrap_or(10) } + + fn to_yaml(&self) -> Option { + let mut backoff = yaml::Hash::new(); + + if let Some(initial) = self.initial { + backoff.insert(Yaml::String("initial".into()), duration_to_yaml(initial)); + } + + if let Some(max) = self.max { + backoff.insert(Yaml::String("max".into()), duration_to_yaml(max)); + } + + if let Some(jitter) = self.jitter { + backoff.insert(Yaml::String("jitter".into()), Yaml::Boolean(jitter)); + } + + if let Some(multiplier) = self.multiplier { + backoff.insert( + Yaml::String("multiplier".into()), + Yaml::Real(multiplier.to_string()), + ); + } + + if let Some(retries_max) = self.retries_max { + backoff.insert( + Yaml::String("retries_max".into()), + Yaml::Integer(retries_max as i64), + ); + } + + if !backoff.is_empty() { + Some(Yaml::Hash(backoff)) + } else { + None + } + } } impl TryFrom<&yaml::Hash> for BackoffConfig { @@ -432,7 +570,7 @@ impl TryFrom<&yaml::Hash> for BackoffConfig { } } -#[derive(Debug, Default, PartialEq, Clone)] +#[derive(Debug, Default, PartialEq, Clone, JsonSchema, Serialize, Deserialize)] pub struct GrpcConfig { url: Option, certs: Option, @@ -459,6 +597,30 @@ impl GrpcConfig { pub fn certs(&self) -> Option<&Path> { self.certs.as_deref() } + + fn to_yaml(&self) -> Option { + let mut grpc = yaml::Hash::new(); + + if let Some(url) = &self.url { + grpc.insert(Yaml::String("url".into()), Yaml::String(url.clone())); + } + + if let Some(certs) = &self.certs + && let Some(certs) = certs.to_str() + { + grpc.insert(Yaml::String("certs".into()), Yaml::String(certs.into())); + } + + if let Some(backoff) = self.backoff.to_yaml() { + grpc.insert(Yaml::String("backoff".into()), backoff); + } + + if !grpc.is_empty() { + Some(Yaml::Hash(grpc)) + } else { + None + } + } } impl TryFrom<&yaml::Hash> for GrpcConfig { @@ -498,7 +660,7 @@ impl TryFrom<&yaml::Hash> for GrpcConfig { } } -#[derive(Debug, Default, PartialEq, Eq, Clone)] +#[derive(Debug, Default, PartialEq, Eq, Clone, JsonSchema, Serialize, Deserialize)] pub struct OTelConfig { endpoint: Option, } @@ -513,6 +675,23 @@ impl OTelConfig { pub fn endpoint(&self) -> Option<&str> { self.endpoint.as_deref() } + + fn to_yaml(&self) -> Option { + let mut otel = yaml::Hash::new(); + + if let Some(endpoint) = &self.endpoint { + otel.insert( + Yaml::String("endpoint".into()), + Yaml::String(endpoint.clone()), + ); + } + + if !otel.is_empty() { + Some(Yaml::Hash(otel)) + } else { + None + } + } } impl TryFrom<&yaml::Hash> for OTelConfig { @@ -540,7 +719,7 @@ impl TryFrom<&yaml::Hash> for OTelConfig { } } -#[derive(Debug, Default, PartialEq, Eq, Clone)] +#[derive(Debug, Default, PartialEq, Eq, Clone, JsonSchema, Serialize, Deserialize)] pub struct BpfConfig { ringbuf_size: Option, inodes_max: Option, @@ -564,6 +743,30 @@ impl BpfConfig { pub fn inodes_max(&self) -> u32 { self.inodes_max.unwrap_or(65536) } + + fn to_yaml(&self) -> Option { + let mut bpf = yaml::Hash::new(); + + if let Some(ringbuf_size) = self.ringbuf_size { + bpf.insert( + Yaml::String("ringbuf_size".into()), + Yaml::Integer(ringbuf_size as i64), + ); + } + + if let Some(inodes_max) = self.inodes_max { + bpf.insert( + Yaml::String("inodes_max".into()), + Yaml::Integer(inodes_max as i64), + ); + } + + if !bpf.is_empty() { + Some(Yaml::Hash(bpf)) + } else { + None + } + } } impl TryFrom<&yaml::Hash> for BpfConfig { diff --git a/fact/src/config/reloader.rs b/fact-core/src/config/reloader.rs similarity index 100% rename from fact/src/config/reloader.rs rename to fact-core/src/config/reloader.rs diff --git a/fact/src/config/tests.rs b/fact-core/src/config/tests.rs similarity index 100% rename from fact/src/config/tests.rs rename to fact-core/src/config/tests.rs diff --git a/fact-core/src/lib.rs b/fact-core/src/lib.rs new file mode 100644 index 00000000..70e47fd6 --- /dev/null +++ b/fact-core/src/lib.rs @@ -0,0 +1,11 @@ +use crate::version::FACT_VERSION; + +pub mod config; + +mod version { + include!(concat!(env!("OUT_DIR"), "/version.rs")); +} + +pub fn version() -> &'static str { + FACT_VERSION +} diff --git a/fact-operator/Cargo.toml b/fact-operator/Cargo.toml index ff342942..a244564c 100644 --- a/fact-operator/Cargo.toml +++ b/fact-operator/Cargo.toml @@ -11,7 +11,10 @@ futures = { version = "0.3.32", default-features = false } kube = { version = "4.0.0", default-features = false, features = ["client", "openssl-tls", "derive", "runtime"] } k8s-openapi = { version = "0.28.0", features = ["latest", "schemars"] } log = { workspace = true } -schemars = { version = "1" } +schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } +yaml-rust2 = { workspace = true } + +fact-core = { path = "../fact-core" } diff --git a/fact-operator/src/spec.rs b/fact-operator/src/spec.rs index 621c6724..b9e4e1e3 100644 --- a/fact-operator/src/spec.rs +++ b/fact-operator/src/spec.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; +use fact_core::config::FactConfig; use k8s_openapi::{ api::{ apps::v1::{DaemonSet, DaemonSetSpec}, @@ -13,6 +14,7 @@ use k8s_openapi::{ use kube::{CustomResource, Resource, ResourceExt, api::ObjectMeta}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use yaml_rust2::YamlEmitter; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, CustomResource)] #[serde(rename_all = "camelCase")] @@ -23,26 +25,25 @@ use serde::{Deserialize, Serialize}; namespaced )] pub(crate) struct FactSpec { + // Configuration requiring restarts pub image: String, #[serde(default = "default_log_level")] pub log_level: String, - #[serde(default = "default_rate_limit")] - pub rate_limit: u64, + + // Configuration hot-reloaded via configmap + pub config: FactConfig, } fn default_log_level() -> String { String::from("info") } -fn default_rate_limit() -> u64 { - 0 -} - pub(crate) fn build_configmap(fact: &Fact) -> ConfigMap { - let data = BTreeMap::from([( - "fact.yml".into(), - format!("rate_limit: {}", fact.spec.rate_limit), - )]); + let config = fact.spec.config.to_yaml(); + let mut output = String::new(); + YamlEmitter::new(&mut output).dump(&config).unwrap(); + + let data = BTreeMap::from([("fact.yml".into(), output)]); ConfigMap { data: Some(data), metadata: ObjectMeta { @@ -73,10 +74,6 @@ pub(crate) fn build_daemonset(fact: &Fact) -> DaemonSet { name: "fact".into(), image: Some(spec.image.clone()), image_pull_policy: Some("IfNotPresent".into()), - args: Some(vec![ - "--paths".into(), - "/etc:/bin:/sbin:/usr/bin:/usr/sbin".into(), - ]), ports: Some(vec![ContainerPort { container_port: 9000, name: Some("monitoring".into()), diff --git a/fact/Cargo.toml b/fact/Cargo.toml index 4e649315..121dcae0 100644 --- a/fact/Cargo.toml +++ b/fact/Cargo.toml @@ -39,6 +39,7 @@ uuid = { workspace = true } yaml-rust2 = { workspace = true } fact-api = { path = "../fact-api" } +fact-core = { path = "../fact-core" } fact-ebpf = { path = "../fact-ebpf" } [dev-dependencies] diff --git a/fact/src/bpf/mod.rs b/fact/src/bpf/mod.rs index b07c86c8..0b393fdb 100644 --- a/fact/src/bpf/mod.rs +++ b/fact/src/bpf/mod.rs @@ -16,8 +16,9 @@ use tokio::{ task::JoinHandle, }; -use crate::{config::BpfConfig, event::Event, host_info, metrics::EventCounter}; +use crate::{event::Event, host_info, metrics::EventCounter}; +use fact_core::config::BpfConfig; use fact_ebpf::{LPM_SIZE_MAX, event_t, inode_key_t, inode_value_t, metrics_t, path_prefix_t}; mod checks; @@ -295,12 +296,13 @@ mod bpf_tests { use tokio::{sync::watch, time::timeout}; use crate::{ - config::{FactConfig, reloader::Reloader}, event::{EventTestData, process::Process}, host_info, metrics::exporter::Exporter, }; + use fact_core::config::{FactConfig, reloader::Reloader}; + use super::*; #[tokio::test] diff --git a/fact/src/endpoints.rs b/fact/src/endpoints.rs index cc97415a..c23946a8 100644 --- a/fact/src/endpoints.rs +++ b/fact/src/endpoints.rs @@ -11,7 +11,9 @@ use hyper_util::rt::TokioIo; use log::{info, warn}; use tokio::{net::TcpListener, sync::watch, task::JoinHandle}; -use crate::{config::EndpointConfig, metrics::exporter::Exporter}; +use crate::metrics::exporter::Exporter; + +use fact_core::config::EndpointConfig; #[derive(Clone)] pub struct Server { diff --git a/fact/src/lib.rs b/fact/src/lib.rs index 94fe1cc4..828ab050 100644 --- a/fact/src/lib.rs +++ b/fact/src/lib.rs @@ -14,7 +14,6 @@ use tokio::{ }; mod bpf; -pub mod config; mod endpoints; mod event; mod host_info; @@ -24,9 +23,10 @@ mod output; mod pre_flight; mod rate_limiter; -use config::FactConfig; use pre_flight::pre_flight; +use fact_core::config::{FactConfig, reloader::Reloader}; + pub fn init_log() -> anyhow::Result<()> { let log_level = std::env::var("FACT_LOGLEVEL").unwrap_or("info".to_owned()); let log_level = LevelFilter::from_str(&log_level)?; @@ -48,12 +48,8 @@ pub fn init_log() -> anyhow::Result<()> { Ok(()) } -mod version { - include!(concat!(env!("OUT_DIR"), "/version.rs")); -} - pub fn log_system_information() { - info!("fact version: {}", version::FACT_VERSION); + info!("fact version: {}", fact_core::version()); info!("OS: {}", get_distro()); match SystemInfo::new() { Ok(sysinfo) => { @@ -91,7 +87,7 @@ pub async fn run(config: FactConfig) -> anyhow::Result<()> { debug!("Skipping pre-flight checks"); } - let reloader = config::reloader::Reloader::from(config); + let reloader = Reloader::from(config); let config_trigger = reloader.get_trigger(); let (mut bpf, rx) = Bpf::new(reloader.paths(), &reloader.config().bpf)?; diff --git a/fact/src/main.rs b/fact/src/main.rs index 1ae88408..a5fcca06 100644 --- a/fact/src/main.rs +++ b/fact/src/main.rs @@ -1,4 +1,4 @@ -use fact::config::FactConfig; +use fact_core::config::FactConfig; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/fact/src/output/grpc.rs b/fact/src/output/grpc.rs index b643b363..b565b542 100644 --- a/fact/src/output/grpc.rs +++ b/fact/src/output/grpc.rs @@ -19,11 +19,9 @@ use tokio_stream::{ }; use tonic::transport::Channel; -use crate::{ - config::{BackoffConfig, GrpcConfig}, - metrics::EventCounter, - output::EventReceiver, -}; +use crate::{metrics::EventCounter, output::EventReceiver}; + +use fact_core::config::{BackoffConfig, GrpcConfig}; struct Backoff { initial: Duration, diff --git a/fact/src/output/mod.rs b/fact/src/output/mod.rs index 8879b431..58097039 100644 --- a/fact/src/output/mod.rs +++ b/fact/src/output/mod.rs @@ -6,11 +6,9 @@ use tokio::{ task::{JoinHandle, JoinSet}, }; -use crate::{ - config::{GrpcConfig, OTelConfig}, - event::Event, - metrics::OutputMetrics, -}; +use crate::{event::Event, metrics::OutputMetrics}; + +use fact_core::config::{GrpcConfig, OTelConfig}; mod grpc; #[cfg(feature = "otel")] diff --git a/fact/src/output/otel.rs b/fact/src/output/otel.rs index fda0abe2..65afa19c 100644 --- a/fact/src/output/otel.rs +++ b/fact/src/output/otel.rs @@ -11,7 +11,9 @@ use tokio::{ task::JoinSet, }; -use crate::{config::OTelConfig, metrics::EventCounter, output::EventReceiver}; +use crate::{metrics::EventCounter, output::EventReceiver}; + +use fact_core::config::OTelConfig; pub(super) struct Client { subscriber: mpsc::Sender>, diff --git a/k8s/example-cr.yml b/k8s/example-cr.yml index 6ecd32fd..7a141599 100644 --- a/k8s/example-cr.yml +++ b/k8s/example-cr.yml @@ -4,6 +4,18 @@ metadata: name: fact namespace: default spec: - image: quay.io/stackrox-io/fact:0.3.0 + image: quay.io/mmoltras/fact:operator-test logLevel: debug - rateLimit: 60000 + config: + rateLimit: 50000 + paths: + - /etc + - /etc/**/* + - /bin + - /bin/**/* + - /sbin + - /sbin/**/* + - /usr/bin + - /usr/bin/**/* + - /usr/sbin + - /usr/sbin/**/* diff --git a/k8s/operator.yml b/k8s/operator.yml index 0d71ee37..9db0bd3f 100644 --- a/k8s/operator.yml +++ b/k8s/operator.yml @@ -28,9 +28,69 @@ spec: logLevel: type: string default: info - rateLimit: - type: integer - default: 0 + config: + type: object + properties: + paths: + type: array + items: + type: string + default: null + grpc: + type: object + default: null + properties: + url: + type: string + default: null + certs: + type: string + default: null + otel: + type: object + default: null + properties: + endpoint: + type: string + default: null + endpoint: + type: object + default: null + properties: + address: + type: string + default: null + exposeMetrics: + type: boolean + default: null + healthCheck: + type: boolean + default: null + bpf: + type: object + default: null + properties: + ringbufSize: + type: integer + default: null + inodesMax: + type: integer + default: null + skipPreFlight: + type: boolean + default: null + json: + type: boolean + default: null + hotreload: + type: boolean + default: null + scanInterval: + type: integer + default: null + rateLimit: + type: integer + default: 0 status: type: object properties: From 3f8dd3660c8d353bfdd3a1a4daca464ab24dae93 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Mon, 22 Jun 2026 18:26:43 +0200 Subject: [PATCH 3/4] test(config): add tests for yaml serialization --- fact-core/src/config/tests.rs | 567 ++++++++++++++++++++++++++++++++++ 1 file changed, 567 insertions(+) diff --git a/fact-core/src/config/tests.rs b/fact-core/src/config/tests.rs index bd47960e..2e7df050 100644 --- a/fact-core/src/config/tests.rs +++ b/fact-core/src/config/tests.rs @@ -1709,6 +1709,10 @@ fn defaults() { assert_eq!(config.paths(), default_paths); assert_eq!(config.grpc.url(), None); assert_eq!(config.grpc.certs(), None); + assert_eq!(config.grpc.backoff.initial(), Duration::from_secs(1)); + assert_eq!(config.grpc.backoff.max(), Duration::from_secs(60)); + assert!(config.grpc.backoff.jitter()); + assert_eq!(config.grpc.backoff.multiplier(), 1.5); assert_eq!( config.endpoint.address(), SocketAddr::from(([0, 0, 0, 0], 9000)) @@ -1730,6 +1734,569 @@ fn defaults() { assert_eq!(config.rate_limit(), 0); } +#[test] +fn test_to_yaml() { + let tests = [ + ( + FactConfig { + paths: Some(Vec::new()), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + paths: [] + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + paths: Some(vec![PathBuf::from("/etc"), PathBuf::from("/bin")]), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + paths: + - /etc + - /bin + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + grpc: GrpcConfig { + url: Some(String::from("http://localhost:9090")), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + grpc: + url: 'http://localhost:9090' + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + grpc: GrpcConfig { + certs: Some(PathBuf::from("/etc/stackrox/certs")), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + grpc: + certs: /etc/stackrox/certs + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + grpc: GrpcConfig { + backoff: BackoffConfig { + initial: Some(Duration::from_secs(5)), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + grpc: + backoff: + initial: 5 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + grpc: GrpcConfig { + backoff: BackoffConfig { + max: Some(Duration::from_secs(120)), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + grpc: + backoff: + max: 120 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + grpc: GrpcConfig { + backoff: BackoffConfig { + jitter: Some(false), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + grpc: + backoff: + jitter: false + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + grpc: GrpcConfig { + backoff: BackoffConfig { + multiplier: Some(3.5), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + grpc: + backoff: + multiplier: 3.5 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + grpc: GrpcConfig { + backoff: BackoffConfig { + retries_max: Some(20), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + grpc: + backoff: + retries_max: 20 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + otel: OTelConfig { + endpoint: Some("http://localhost:4317".into()), + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + otel: + endpoint: http://localhost:4317 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + endpoint: EndpointConfig { + address: Some(SocketAddr::from(([0, 0, 0, 0], 8080))), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + endpoint: + address: 0.0.0.0:8080 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + endpoint: EndpointConfig { + address: Some(SocketAddr::from(([127, 0, 0, 1], 8080))), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + endpoint: + address: 127.0.0.1:8080 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + endpoint: EndpointConfig { + address: Some(SocketAddr::from(( + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + 8080, + ))), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + endpoint: + address: '[::]:8080' + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + endpoint: EndpointConfig { + address: Some(SocketAddr::from(( + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + 8080, + ))), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + endpoint: + address: '[::1]:8080' + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + endpoint: EndpointConfig { + expose_metrics: Some(true), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + endpoint: + expose_metrics: true + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + endpoint: EndpointConfig { + expose_metrics: Some(false), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + endpoint: + expose_metrics: false + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + endpoint: EndpointConfig { + health_check: Some(true), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + endpoint: + health_check: true + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + endpoint: EndpointConfig { + health_check: Some(false), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + endpoint: + health_check: false + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + skip_pre_flight: Some(true), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + skip_pre_flight: true + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + skip_pre_flight: Some(false), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + skip_pre_flight: false + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + json: Some(false), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + json: false + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + json: Some(false), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + json: false + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + bpf: BpfConfig { + ringbuf_size: Some(64), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + bpf: + ringbuf_size: 64 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + bpf: BpfConfig { + inodes_max: Some(64), + ..Default::default() + }, + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + bpf: + inodes_max: 64 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + hotreload: Some(true), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + hotreload: true + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + hotreload: Some(false), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + hotreload: false + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + scan_interval: Some(Duration::from_secs(60)), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + scan_interval: 60 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + scan_interval: Some(Duration::from_secs_f64(30.5)), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + scan_interval: 30.5 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + rate_limit: Some(0), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + rate_limit: 0 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + rate_limit: Some(1000), + ..Default::default() + }, + YamlLoader::load_from_str( + r#" + rate_limit: 1000 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ( + FactConfig { + paths: Some(vec!["/etc".into()]), + grpc: GrpcConfig { + url: Some("https://svc.sensor.stackrox:9090".into()), + certs: Some("/etc/stackrox/certs".into()), + backoff: BackoffConfig { + initial: Some(Duration::from_secs_f64(0.5)), + max: Some(Duration::from_secs(120)), + jitter: Some(false), + multiplier: Some(3.5), + retries_max: Some(5), + }, + }, + otel: OTelConfig { + endpoint: Some("http://localhost:4317".into()), + }, + endpoint: EndpointConfig { + address: Some(SocketAddr::from(([0, 0, 0, 0], 8080))), + expose_metrics: Some(true), + health_check: Some(true), + }, + skip_pre_flight: Some(false), + json: Some(false), + bpf: BpfConfig { + ringbuf_size: Some(8192), + inodes_max: Some(64), + }, + hotreload: Some(false), + scan_interval: Some(Duration::from_secs(60)), + rate_limit: Some(1000), + }, + YamlLoader::load_from_str( + r#" + paths: + - /etc + grpc: + url: 'https://svc.sensor.stackrox:9090' + certs: /etc/stackrox/certs + backoff: + initial: 0.5 + max: 120 + jitter: false + multiplier: 3.5 + retries_max: 5 + otel: + endpoint: 'http://localhost:4317' + endpoint: + address: 0.0.0.0:8080 + expose_metrics: true + health_check: true + bpf: + ringbuf_size: 8192 + inodes_max: 64 + skip_pre_flight: false + json: false + hotreload: false + scan_interval: 60 + rate_limit: 1000 + "#, + ) + .unwrap()[0] + .to_owned(), + ), + ]; + + for (config, expected) in tests { + assert_eq!(config.to_yaml(), expected, "expected: {expected:#?}"); + } +} + static ENV_MUTEX: Mutex<()> = Mutex::new(()); /// RAII guard that holds the `ENV_MUTEX` lock and removes the named environment From 186f1b4984761c52a6dae28126cd25f1834823f6 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Tue, 23 Jun 2026 13:26:19 +0200 Subject: [PATCH 4/4] test(operator): add unit tests for configmap and daemonset generation The tests are a bit basic, they simply generate a Fact object from a fixed spec, build the objects and compare them agains expected values. In order to simplify the daemonset tests, a DaemonSetBuilder object is created. This object can be built with default values or from a Fact object. Ultimately, the tests use both approaches to compare the final result is the same. --- fact-operator/src/lib.rs | 4 +- fact-operator/src/spec.rs | 390 +++++++++++++++++++++++++++++--------- 2 files changed, 304 insertions(+), 90 deletions(-) diff --git a/fact-operator/src/lib.rs b/fact-operator/src/lib.rs index 57ee19fd..41121823 100644 --- a/fact-operator/src/lib.rs +++ b/fact-operator/src/lib.rs @@ -9,7 +9,7 @@ use kube::{ }; use log::{info, warn}; -use crate::spec::Fact; +use crate::spec::{DaemonSetBuilder, Fact}; mod spec; @@ -30,7 +30,7 @@ async fn reconcile(fact: Arc, ctx: Arc) -> Result::namespaced(ctx.client.clone(), &ns); api.patch( &fact.name_any(), diff --git a/fact-operator/src/spec.rs b/fact-operator/src/spec.rs index b9e4e1e3..4056dedd 100644 --- a/fact-operator/src/spec.rs +++ b/fact-operator/src/spec.rs @@ -9,7 +9,7 @@ use k8s_openapi::{ HostPathVolumeSource, PodSpec, PodTemplateSpec, SecurityContext, Volume, VolumeMount, }, }, - apimachinery::pkg::apis::meta::v1::LabelSelector, + apimachinery::pkg::apis::meta::v1::{LabelSelector, OwnerReference}, }; use kube::{CustomResource, Resource, ResourceExt, api::ObjectMeta}; use schemars::JsonSchema; @@ -56,109 +56,323 @@ pub(crate) fn build_configmap(fact: &Fact) -> ConfigMap { } } -pub(crate) fn build_daemonset(fact: &Fact) -> DaemonSet { - let spec = &fact.spec; - let name = fact.name_any(); - let namespace = fact.namespace(); - let labels = BTreeMap::from([("app".into(), "fact".into())]); - - let metadata = ObjectMeta { - labels: Some(labels.clone()), - name: Some(name), - namespace, - owner_references: fact.controller_owner_ref(&()).map(|r| vec![r]), - ..Default::default() - }; - - let container = Container { - name: "fact".into(), - image: Some(spec.image.clone()), - image_pull_policy: Some("IfNotPresent".into()), - ports: Some(vec![ContainerPort { - container_port: 9000, - name: Some("monitoring".into()), +#[derive(Debug, Default)] +pub(crate) struct DaemonSetBuilder { + name: String, + namespace: Option, + owner_references: Option>, + + image: Option, + log_level: String, +} + +impl DaemonSetBuilder { + #[cfg(test)] + fn set_name(self, name: &str) -> Self { + DaemonSetBuilder { + name: name.to_string(), + ..self + } + } + + #[cfg(test)] + fn set_image(self, image: &str) -> Self { + DaemonSetBuilder { + image: Some(image.to_string()), + ..self + } + } + + #[cfg(test)] + fn set_log_level(self, log_level: &str) -> Self { + DaemonSetBuilder { + log_level: log_level.to_string(), + ..self + } + } + + pub fn build(self) -> DaemonSet { + let DaemonSetBuilder { + name, + namespace, + owner_references, + image, + log_level, + } = self; + let labels = BTreeMap::from([("app".into(), "fact".into())]); + + let metadata = ObjectMeta { + labels: Some(labels.clone()), + name: Some(name.clone()), + namespace, + owner_references, ..Default::default() - }]), - env: Some(vec![ - EnvVar { - name: "FACT_LOGLEVEL".into(), - value: Some(spec.log_level.clone()), - ..Default::default() - }, - EnvVar { - name: "FACT_HOST_MOUNT".into(), - value: Some("/host".into()), + }; + + let container = Container { + name: "fact".into(), + image, + image_pull_policy: Some("IfNotPresent".into()), + ports: Some(vec![ContainerPort { + container_port: 9000, + name: Some("monitoring".into()), ..Default::default() - }, - ]), - security_context: Some(SecurityContext { - capabilities: Some(Capabilities { - drop: Some(vec!["NET_RAW".into()]), + }]), + env: Some(vec![ + EnvVar { + name: "FACT_LOGLEVEL".into(), + value: Some(log_level), + ..Default::default() + }, + EnvVar { + name: "FACT_HOST_MOUNT".into(), + value: Some("/host".into()), + ..Default::default() + }, + ]), + security_context: Some(SecurityContext { + capabilities: Some(Capabilities { + drop: Some(vec!["NET_RAW".into()]), + ..Default::default() + }), + privileged: Some(true), + read_only_root_filesystem: Some(true), ..Default::default() }), - privileged: Some(true), - read_only_root_filesystem: Some(true), + volume_mounts: Some(vec![ + VolumeMount { + name: "root-ro".into(), + mount_path: "/host".into(), + read_only: Some(true), + mount_propagation: Some("HostToContainer".into()), + ..Default::default() + }, + VolumeMount { + mount_path: "/etc/stackrox".into(), + name: "fact-config".into(), + read_only: Some(true), + ..Default::default() + }, + ]), ..Default::default() - }), - volume_mounts: Some(vec![ - VolumeMount { + }; + + let volumes = vec![ + Volume { + host_path: Some(HostPathVolumeSource { + path: "/".into(), + ..Default::default() + }), name: "root-ro".into(), - mount_path: "/host".into(), - read_only: Some(true), - mount_propagation: Some("HostToContainer".into()), ..Default::default() }, - VolumeMount { - mount_path: "/etc/stackrox".into(), + Volume { name: "fact-config".into(), - read_only: Some(true), + config_map: Some(ConfigMapVolumeSource { + name: format!("{name}-config"), + ..Default::default() + }), ..Default::default() }, - ]), - ..Default::default() - }; + ]; - let volumes = vec![ - Volume { - host_path: Some(HostPathVolumeSource { - path: "/".into(), - ..Default::default() - }), - name: "root-ro".into(), - ..Default::default() - }, - Volume { - name: "fact-config".into(), - config_map: Some(ConfigMapVolumeSource { - name: format!("{}-config", fact.name_any()), + let spec = DaemonSetSpec { + selector: LabelSelector { + match_labels: Some(labels.clone()), ..Default::default() - }), + }, + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some(labels), + ..Default::default() + }), + spec: Some(PodSpec { + containers: vec![container], + volumes: Some(volumes), + ..Default::default() + }), + }, ..Default::default() - }, - ]; + }; - let spec = DaemonSetSpec { - selector: LabelSelector { - match_labels: Some(labels.clone()), + DaemonSet { + metadata, + spec: Some(spec), ..Default::default() - }, - template: PodTemplateSpec { - metadata: Some(ObjectMeta { - labels: Some(labels), - ..Default::default() - }), - spec: Some(PodSpec { - containers: vec![container], - volumes: Some(volumes), - ..Default::default() - }), - }, - ..Default::default() - }; + } + } +} - DaemonSet { - metadata, - spec: Some(spec), - ..Default::default() +impl From<&Fact> for DaemonSetBuilder { + fn from(fact: &Fact) -> Self { + let name = fact.name_any(); + let namespace = fact.namespace(); + let owner_references = fact.controller_owner_ref(&()).map(|r| vec![r]); + let image = fact.spec.image.clone(); + let log_level = fact.spec.log_level.clone(); + + DaemonSetBuilder { + name, + namespace, + owner_references, + image: Some(image), + log_level, + } + } +} + +#[cfg(test)] +mod tests { + use yaml_rust2::Yaml; + + use super::*; + + fn yaml_to_string(input: &Yaml) -> String { + let mut output = String::new(); + YamlEmitter::new(&mut output).dump(input).unwrap(); + output + } + + #[test] + fn test_build_configmap() { + fn full_config() -> FactConfig { + FactConfig::try_from( + r#" + paths: + - /etc + grpc: + url: 'https://svc.sensor.stackrox:9090' + certs: /etc/stackrox/certs + endpoint: + address: 0.0.0.0:8080 + expose_metrics: true + health_check: true + skip_pre_flight: false + json: false + bpf: + ringbuf_size: 8192 + inodes_max: 64 + hotreload: false + scan_interval: 60 + rate_limit: 1000 + "#, + ) + .unwrap() + } + + let tests = [ + ( + Fact::new( + "fact", + FactSpec { + image: "quay.io/stackrox-io/fact:0.3.0".into(), + log_level: "debug".into(), + config: FactConfig::default(), + }, + ), + ConfigMap { + data: Some(BTreeMap::from([( + "fact.yml".to_string(), + yaml_to_string(&FactConfig::default().to_yaml()), + )])), + metadata: ObjectMeta { + name: Some("fact-config".into()), + namespace: None, + ..Default::default() + }, + ..Default::default() + }, + ), + ( + Fact::new( + "not-fact", + FactSpec { + image: "quay.io/stackrox-io/fact:0.3.0".into(), + log_level: "debug".into(), + config: FactConfig::default(), + }, + ), + ConfigMap { + data: Some(BTreeMap::from([( + "fact.yml".to_string(), + yaml_to_string(&FactConfig::default().to_yaml()), + )])), + metadata: ObjectMeta { + name: Some("not-fact-config".into()), + namespace: None, + ..Default::default() + }, + ..Default::default() + }, + ), + ( + Fact::new( + "fact", + FactSpec { + image: "quay.io/stackrox-io/fact:0.3.0".into(), + log_level: "debug".into(), + config: full_config(), + }, + ), + ConfigMap { + data: Some(BTreeMap::from([( + "fact.yml".to_string(), + yaml_to_string(&full_config().to_yaml()), + )])), + metadata: ObjectMeta { + name: Some("fact-config".into()), + namespace: None, + ..Default::default() + }, + ..Default::default() + }, + ), + ]; + + for (fact, expected) in tests { + let cm = build_configmap(&fact); + assert_eq!(cm, expected); + } + } + + #[test] + fn test_build_daemonset() { + let tests = [ + ( + Fact::new( + "fact", + FactSpec { + image: "quay.io/stackrox-io/fact:0.3.0".into(), + log_level: "info".into(), + config: FactConfig::default(), + }, + ), + DaemonSetBuilder::default() + .set_image("quay.io/stackrox-io/fact:0.3.0") + .set_name("fact") + .set_log_level("info") + .build(), + ), + ( + Fact::new( + "other-fact", + FactSpec { + image: "quay.io/rhacs-eng/fact:0.3.0".into(), + log_level: "warn".into(), + config: FactConfig::default(), + }, + ), + DaemonSetBuilder::default() + .set_image("quay.io/rhacs-eng/fact:0.3.0") + .set_name("other-fact") + .set_log_level("warn") + .build(), + ), + ]; + + for (fact, expected) in tests { + let res = DaemonSetBuilder::from(&fact).build(); + assert_eq!(res, expected); + } } }