From e2b31aaa631e231797129f345da79e867fb46f68 Mon Sep 17 00:00:00 2001 From: Sam Day Date: Mon, 30 Mar 2026 14:49:50 +1100 Subject: [PATCH 01/17] etcdetcetc: initial project with design docs and Rust skeleton Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/.gitignore | 1 + apps/etcdetcetc/Cargo.lock | 2561 +++++++++++++++++++++++++++++++++ apps/etcdetcetc/Cargo.toml | 18 + apps/etcdetcetc/README.md | 61 + apps/etcdetcetc/doc/design.md | 185 +++ apps/etcdetcetc/src/crd.rs | 193 +++ apps/etcdetcetc/src/main.rs | 24 + 7 files changed, 3043 insertions(+) create mode 100644 apps/etcdetcetc/.gitignore create mode 100644 apps/etcdetcetc/Cargo.lock create mode 100644 apps/etcdetcetc/Cargo.toml create mode 100644 apps/etcdetcetc/README.md create mode 100644 apps/etcdetcetc/doc/design.md create mode 100644 apps/etcdetcetc/src/crd.rs create mode 100644 apps/etcdetcetc/src/main.rs diff --git a/apps/etcdetcetc/.gitignore b/apps/etcdetcetc/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/apps/etcdetcetc/.gitignore @@ -0,0 +1 @@ +/target diff --git a/apps/etcdetcetc/Cargo.lock b/apps/etcdetcetc/Cargo.lock new file mode 100644 index 00000000..5ccfeb25 --- /dev/null +++ b/apps/etcdetcetc/Cargo.lock @@ -0,0 +1,2561 @@ +# This file is automatically @generated by Cargo. +# 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "num-traits", + "serde", +] + +[[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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[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.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcd-client" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f998f8294bc5e7d4f8ea31bd08a1e4a69f94e79d88bc289ea48e9eb7c33b39" +dependencies = [ + "http", + "prost", + "tokio", + "tokio-stream", + "tonic", + "tonic-build", + "tower 0.4.13", + "tower-service", +] + +[[package]] +name = "etcdetcetc" +version = "0.1.0" +dependencies = [ + "anyhow", + "etcd-client", + "futures", + "k8s-openapi", + "kube", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[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 = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +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-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +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", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[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 = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-http-proxy" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ad4b0a1e37510028bc4ba81d0e38d239c39671b0f0ce9e02dfa93a8133f7c08" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls-native-certs 0.7.3", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs 0.8.3", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[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.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" +dependencies = [ + "base64", + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "kube" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4eb20010536b48abe97fec37d23d43069bcbe9686adcf9932202327bc5ca6e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1" +dependencies = [ + "base64", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower 0.5.3", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff0d0793db58e70ca6d689489183816cb3aa481673e7433dc618cf7e8007c675" +dependencies = [ + "chrono", + "form_urlencoded", + "http", + "json-patch", + "k8s-openapi", + "schemars", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "kube-derive" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c562f58dc9f7ca5feac8a6ee5850ca221edd6f04ce0dd2ee873202a88cd494c9" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", +] + +[[package]] +name = "kube-runtime" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f34cfab9b4bd8633062e0e85edb81df23cb09f159f2e31c60b069ae826ffdc" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "async-trait", + "backon", + "educe", + "futures", + "hashbrown 0.15.5", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.13.0", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +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 = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "rustls-pemfile", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "base64", + "bitflags", + "bytes", + "http", + "http-body", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +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", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/apps/etcdetcetc/Cargo.toml b/apps/etcdetcetc/Cargo.toml new file mode 100644 index 00000000..3e4cc0f7 --- /dev/null +++ b/apps/etcdetcetc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "etcdetcetc" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +etcd-client = { version = "0.15", features = ["tls"] } +futures = "0.3" +k8s-openapi = { version = "0.24", features = ["latest"] } +kube = { version = "0.99", features = ["runtime", "derive", "client"] } +schemars = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } diff --git a/apps/etcdetcetc/README.md b/apps/etcdetcetc/README.md new file mode 100644 index 00000000..e189a755 --- /dev/null +++ b/apps/etcdetcetc/README.md @@ -0,0 +1,61 @@ +# etcdetcetc + +**etcd external tenant controller, etc.** + +A Kubernetes controller that manages multi-tenant etcd access. Automates the +lifecycle of etcd users, roles, and prefix-scoped permissions -- the tedious +RBAC plumbing that makes shared etcd clusters actually work. + +## CRDs + +### EtcdCluster + +Declares an etcd cluster the controller can manage tenants on. + +```yaml +apiVersion: etcdetcetc.samcday.com/v1alpha1 +kind: EtcdCluster +metadata: + name: hub-etcd +spec: + endpoints: + - https://etcd1.example.com:2379 + - https://etcd2.example.com:2379 + - https://etcd3.example.com:2379 + authSecretRef: + name: etcd-root-credentials +``` + +The referenced Secret holds root/admin credentials. Supports: +- **TLS cert auth**: keys `tls.crt`, `tls.key`, `ca.crt` +- **Basic auth**: keys `username`, `password`, `ca.crt` + +### EtcdTenant + +Carves out a keyspace on an EtcdCluster for a tenant. + +```yaml +apiVersion: etcdetcetc.samcday.com/v1alpha1 +kind: EtcdTenant +metadata: + name: my-app + namespace: my-app +spec: + clusterRef: + name: hub-etcd + namespace: etcd-system +``` + +The controller creates the etcd user, role, and prefix permissions, then emits +a Secret with connection details. On deletion, the keyspace is purged and RBAC +entities are removed. + +Defaults: +- `prefix`: `//` +- `secretName`: `-etcd` + +## Future + +- Static username/password auth for tenants (not just cert-based identity) +- Multiple EtcdCluster support with pluggable tenant scheduling +- Tenant migration between clusters diff --git a/apps/etcdetcetc/doc/design.md b/apps/etcdetcetc/doc/design.md new file mode 100644 index 00000000..5273acba --- /dev/null +++ b/apps/etcdetcetc/doc/design.md @@ -0,0 +1,185 @@ +# etcdetcetc design + +## Problem + +Multi-tenant etcd clusters need per-tenant RBAC: users, roles, and +prefix-scoped permissions. Today this is done by hand with `etcdctl`, which is +error-prone, not declarative, and has no cleanup path. Decommissioning a tenant +means its keyspace rots in etcd forever. + +## Goals + +1. Declare etcd tenancy as Kubernetes resources (CRDs). +2. Automate user/role/permission lifecycle against a live etcd cluster. +3. Clean up completely on tenant deletion (purge keys, remove RBAC entities). +4. Emit a Secret per tenant with connection details for downstream consumers. +5. Keep the controller decoupled from cert-manager or any specific PKI -- it + just reads and writes Secrets. + +## Non-goals (v1alpha1) + +- Managing etcd cluster deployment or lifecycle. +- Issuing or rotating TLS certificates (that's cert-manager's job). +- Password-based tenant auth (future). +- Multi-cluster scheduling or migration (future -- but the CRD shape + accommodates it). + +## CRDs + +### EtcdCluster + +Represents a live etcd cluster the controller can connect to. + +```yaml +apiVersion: etcdetcetc.samcday.com/v1alpha1 +kind: EtcdCluster +metadata: + name: hub-etcd + namespace: etcd-system +spec: + endpoints: + - https://hub-az1-cp1.hub.internal:2379 + - https://hub-az1-cp2.hub.internal:2379 + - https://hub-az1-cp3.hub.internal:2379 + authSecretRef: + name: hub-etcd-root +status: + connected: false +``` + +**spec.endpoints**: etcd client URLs. + +**spec.authSecretRef**: reference to a Secret (same namespace) holding root +credentials. The controller detects the auth mode by inspecting which keys are +present: + +| Mode | Required keys | +|-------|----------------------------------| +| TLS | `tls.crt`, `tls.key`, `ca.crt` | +| Basic | `username`, `password`, `ca.crt` | + +The `ca.crt` key is always required (TLS to etcd is non-negotiable). + +**status.connected**: set to true when the controller has successfully +authenticated and pinged the cluster. + +### EtcdTenant + +Declares a tenant on an EtcdCluster. Namespace-scoped so it can live alongside +the workloads that consume it. + +```yaml +apiVersion: etcdetcetc.samcday.com/v1alpha1 +kind: EtcdTenant +metadata: + name: cloud + namespace: cloud-cluster +spec: + clusterRef: + name: hub-etcd + namespace: etcd-system + prefix: "/cloud/" + secretName: cloud-etcd +status: + ready: false +``` + +**spec.clusterRef**: cross-namespace reference to an EtcdCluster. + +**spec.prefix**: etcd key prefix for this tenant. Defaults to `//` if +omitted. + +**spec.secretName**: name of the output Secret to create in the tenant's +namespace. Defaults to `-etcd` if omitted. + +**status.ready**: set to true when user, role, and permissions are all +provisioned in etcd. + +## Controller behaviour + +### EtcdCluster reconciler + +1. Read the auth Secret referenced by `spec.authSecretRef`. +2. Build an etcd client with the endpoints and credentials. +3. Ping the cluster (`etcdctl endpoint health` equivalent). +4. Update `status.connected`. +5. Cache the client for use by the EtcdTenant reconciler. + +Watches: EtcdCluster, referenced Secrets (for credential rotation). + +### EtcdTenant reconciler + +**Create / Update:** + +1. Resolve the referenced EtcdCluster. If not connected, requeue. +2. Compute effective prefix (`spec.prefix` or `//`). +3. Ensure etcd user exists (no password -- cert CN auth for v1alpha1). +4. Ensure etcd role exists (named same as the user). +5. Ensure role has `readwrite` permission on the prefix. +6. Ensure role is granted to the user. +7. Create or update the output Secret (see below). +8. Set `status.ready = true`. + +**Delete (finalizer: `etcdetcetc.samcday.com/tenant`):** + +1. Delete all keys under the prefix. +2. Revoke role from user. +3. Delete user. +4. Delete role. +5. Remove finalizer. The output Secret is garbage-collected via ownerRef. + +### Output Secret + +Created in the same namespace as the EtcdTenant, owned by it. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: + namespace: + ownerReferences: + - apiVersion: etcdetcetc.samcday.com/v1alpha1 + kind: EtcdTenant + name: + controller: true +type: Opaque +data: + username: + endpoints: + ca.crt: +``` + +Downstream consumers (e.g. the k8s-control-plane Helm chart) mount this Secret +to configure their etcd connection. Client certs are handled separately by +cert-manager -- this controller doesn't touch PKI. + +## Future roadmap + +### Password-based tenant auth + +Add a `spec.authMode: password` field to EtcdTenant. The controller generates a +random password, creates the etcd user with it, and includes `password` in the +output Secret. This enables tenants that don't use cert-based identity. + +### Multiple EtcdCluster support + +The architecture already supports this -- EtcdTenant references a specific +EtcdCluster. Future work: + +- **Scheduling**: when `clusterRef` is omitted, a scheduler picks the best + cluster based on capacity, locality, or policy. Pluggable via a trait / + interface, similar to kube-scheduler's framework. +- **Capacity tracking**: EtcdCluster status reports tenant count, key count, + and storage usage. + +### Tenant migration + +Move a tenant's keyspace from one EtcdCluster to another: + +1. Snapshot keys under the prefix on the source cluster. +2. Restore them on the destination cluster with a new user/role. +3. Update the output Secret to point to the new cluster. +4. Purge the source keyspace. + +This enables rebalancing, cluster decommissioning, and disaster recovery. diff --git a/apps/etcdetcetc/src/crd.rs b/apps/etcdetcetc/src/crd.rs new file mode 100644 index 00000000..475ec5b8 --- /dev/null +++ b/apps/etcdetcetc/src/crd.rs @@ -0,0 +1,193 @@ +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Standard Kubernetes conditions +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Condition { + pub type_: String, + pub status: ConditionStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + pub last_transition_time: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +pub enum ConditionStatus { + True, + False, + Unknown, +} + +impl std::fmt::Display for ConditionStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::True => write!(f, "True"), + Self::False => write!(f, "False"), + Self::Unknown => write!(f, "Unknown"), + } + } +} + +/// Build a Ready condition. `last_transition_time` uses RFC 3339 UTC. +pub fn ready_condition(ready: bool, reason: &str, message: &str) -> Condition { + let now = rfc3339_now(); + Condition { + type_: "Ready".to_string(), + status: if ready { ConditionStatus::True } else { ConditionStatus::False }, + reason: Some(reason.to_string()), + message: if message.is_empty() { None } else { Some(message.to_string()) }, + last_transition_time: now, + } +} + +fn rfc3339_now() -> String { + let dur = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = dur.as_secs(); + let days = secs / 86400; + let time_secs = secs % 86400; + let hours = time_secs / 3600; + let minutes = (time_secs % 3600) / 60; + let seconds = time_secs % 60; + + // Days since epoch to Y-M-D (civil calendar) + // Algorithm from http://howardhinnant.github.io/date_algorithms.html + let z = days as i64 + 719468; + let era = z / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z") +} + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +/// Reference to a Secret in the same namespace. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct LocalSecretReference { + pub name: String, +} + +/// Cross-namespace reference to an EtcdCluster. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ClusterReference { + pub name: String, + pub namespace: String, +} + +// --------------------------------------------------------------------------- +// EtcdCluster +// --------------------------------------------------------------------------- + +/// An etcd cluster that tenants can be provisioned on. +/// +/// The controller connects using root credentials from the referenced auth +/// Secret, which can hold either TLS certs (tls.crt, tls.key, ca.crt) or +/// basic auth (username, password, ca.crt). +#[derive(CustomResource, Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[kube( + group = "etcdetcetc.samcday.com", + version = "v1alpha1", + kind = "EtcdCluster", + namespaced, + status = "EtcdClusterStatus", + printcolumn = r#"{"name": "Ready", "type": "string", "jsonPath": ".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name": "Version", "type": "string", "jsonPath": ".status.version"}"#, + printcolumn = r#"{"name": "DB Size", "type": "string", "jsonPath": ".status.dbSize"}"#, + printcolumn = r#"{"name": "Leader", "type": "string", "jsonPath": ".status.leader"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct EtcdClusterSpec { + /// etcd endpoint URLs (e.g. https://host:2379). + pub endpoints: Vec, + + /// Reference to a Secret with root/admin credentials. + pub auth_secret_ref: LocalSecretReference, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EtcdClusterStatus { + #[serde(default)] + pub connected: bool, + /// Standard Kubernetes conditions (supports `kubectl wait --for=condition=Ready`). + #[serde(default)] + pub conditions: Vec, + /// etcd server version + pub version: Option, + /// Database size, human-readable (e.g. "24 MiB") + pub db_size: Option, + /// Current leader member name + pub leader: Option, + /// Cluster members + #[serde(default)] + pub members: Vec, + /// Active alarms (e.g. "etcd-1: NOSPACE") + #[serde(default)] + pub alarms: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ClusterMember { + pub name: String, + pub endpoint: String, + #[serde(default)] + pub is_learner: bool, +} + +// --------------------------------------------------------------------------- +// EtcdTenant +// --------------------------------------------------------------------------- + +/// A tenant on an EtcdCluster. The controller provisions the etcd user, role, +/// and prefix-scoped permissions, then emits a Secret with connection details. +/// +/// On deletion a finalizer purges the keyspace and removes RBAC entities. +#[derive(CustomResource, Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[kube( + group = "etcdetcetc.samcday.com", + version = "v1alpha1", + kind = "EtcdTenant", + namespaced, + status = "EtcdTenantStatus", + printcolumn = r#"{"name": "Ready", "type": "string", "jsonPath": ".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name": "Prefix", "type": "string", "jsonPath": ".spec.prefix"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct EtcdTenantSpec { + /// Reference to the EtcdCluster to provision on. + pub cluster_ref: ClusterReference, + + /// etcd key prefix for this tenant. Defaults to `//`. + #[schemars(regex(pattern = r"^/[A-Za-z0-9/_-]*$"))] + #[serde(default)] + pub prefix: Option, + + /// Name of the output Secret. Defaults to `-etcd`. + #[serde(default)] + pub secret_name: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)] +pub struct EtcdTenantStatus { + /// Standard Kubernetes conditions (supports `kubectl wait --for=condition=Ready`). + #[serde(default)] + pub conditions: Vec, +} diff --git a/apps/etcdetcetc/src/main.rs b/apps/etcdetcetc/src/main.rs new file mode 100644 index 00000000..bcc64f28 --- /dev/null +++ b/apps/etcdetcetc/src/main.rs @@ -0,0 +1,24 @@ +mod crd; + +use anyhow::Result; +use tracing_subscriber::{EnvFilter, fmt::format::FmtSpan}; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .json() + .with_span_events(FmtSpan::CLOSE) + .init(); + + tracing::info!("etcdetcetc starting"); + + let client = kube::Client::try_default().await?; + tracing::info!("connected to kubernetes"); + + // TODO: start EtcdCluster and EtcdTenant controllers + + // Park until shutdown signal. + tokio::signal::ctrl_c().await?; + Ok(()) +} From c3e7d8f0fb158f75b9f7b55f22977c139a62cc68 Mon Sep 17 00:00:00 2001 From: Sam Day Date: Mon, 30 Mar 2026 14:50:07 +1100 Subject: [PATCH 02/17] etcdetcetc: implement EtcdCluster and EtcdTenant controllers EtcdCluster controller: persistent cached connections with 15s health check requeue, status vitals (version, db size, leader, members, alarms), config hash change detection, allowedNamespaces filtering. EtcdTenant controller: etcd user/role/permission provisioning with prefix-scoped RBAC, random password generation with GitOps-friendly pre-creation support, output Secret with connection details, finalizer cleanup on deletion. Both controllers use standard Kubernetes conditions for kubectl wait support. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/Cargo.lock | 124 +++++++ apps/etcdetcetc/Cargo.toml | 5 + apps/etcdetcetc/src/cluster.rs | 432 ++++++++++++++++++++++++ apps/etcdetcetc/src/crd.rs | 42 ++- apps/etcdetcetc/src/etcd.rs | 83 +++++ apps/etcdetcetc/src/main.rs | 106 +++++- apps/etcdetcetc/src/tenant.rs | 586 +++++++++++++++++++++++++++++++++ 7 files changed, 1366 insertions(+), 12 deletions(-) create mode 100644 apps/etcdetcetc/src/cluster.rs create mode 100644 apps/etcdetcetc/src/etcd.rs create mode 100644 apps/etcdetcetc/src/tenant.rs diff --git a/apps/etcdetcetc/Cargo.lock b/apps/etcdetcetc/Cargo.lock index 5ccfeb25..0842bf50 100644 --- a/apps/etcdetcetc/Cargo.lock +++ b/apps/etcdetcetc/Cargo.lock @@ -30,6 +30,56 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -210,6 +260,52 @@ dependencies = [ "serde", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -396,10 +492,12 @@ name = "etcdetcetc" version = "0.1.0" dependencies = [ "anyhow", + "clap", "etcd-client", "futures", "k8s-openapi", "kube", + "rand", "schemars", "serde", "serde_json", @@ -880,6 +978,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -1170,6 +1274,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-probe" version = "0.1.6" @@ -2228,6 +2338,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" @@ -2528,6 +2644,14 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", +] + [[package]] name = "zerocopy" version = "0.8.47" diff --git a/apps/etcdetcetc/Cargo.toml b/apps/etcdetcetc/Cargo.toml index 3e4cc0f7..f109c2f3 100644 --- a/apps/etcdetcetc/Cargo.toml +++ b/apps/etcdetcetc/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = [".", "xtask"] + [package] name = "etcdetcetc" version = "0.1.0" @@ -12,6 +15,8 @@ kube = { version = "0.99", features = ["runtime", "derive", "client"] } schemars = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" +clap = { version = "4", features = ["derive"] } +rand = "0.8" thiserror = "2" tokio = { version = "1", features = ["full"] } tracing = "0.1" diff --git a/apps/etcdetcetc/src/cluster.rs b/apps/etcdetcetc/src/cluster.rs new file mode 100644 index 00000000..0f1f26c8 --- /dev/null +++ b/apps/etcdetcetc/src/cluster.rs @@ -0,0 +1,432 @@ +//! EtcdCluster controller. + +use std::{ + collections::{HashMap, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, + sync::{Arc, RwLock as StdRwLock}, + time::Duration, +}; + +use anyhow::anyhow; +use futures::StreamExt; +use k8s_openapi::api::core::v1::Secret; +use kube::{ + Api, Client, Resource, ResourceExt, + api::{Patch, PatchParams}, + runtime::{Controller, controller::Action, reflector::ObjectRef, watcher}, +}; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +use crate::crd::{ClusterMember, EtcdCluster, EtcdClusterSpec, EtcdClusterStatus}; + +/// Shared etcd client cache keyed by `(namespace, name)` of `EtcdCluster`. +pub type ClusterClients = Arc>>; + +/// In-memory index from `(secret namespace, secret name)` to referencing EtcdClusters. +pub type SecretRefIndex = Arc>>>; + +pub type ConfigHashes = Arc>>; + +/// Shared context for the EtcdCluster controller. +#[derive(Clone)] +pub struct ClusterContext { + /// Kubernetes API client. + pub client: Client, + /// Shared etcd client cache. + pub clients: ClusterClients, + /// Index of which EtcdCluster references which auth Secret. + pub secret_refs: SecretRefIndex, + /// Restricts reconciliation to these namespaces when non-empty. + pub allowed_namespaces: Vec, + pub config_hashes: ConfigHashes, +} + +/// Errors produced by EtcdCluster reconciliation. +#[derive(Debug, thiserror::Error)] +pub enum ClusterError { + /// Kubernetes API error. + #[error("kubernetes API error: {0}")] + Kube(#[from] kube::Error), + + /// Etcd client creation or connectivity error. + #[error("etcd error: {0}")] + Etcd(#[from] anyhow::Error), + + /// Invalid or incomplete EtcdCluster object. + #[error("invalid EtcdCluster: {0}")] + Invalid(String), +} + +/// Runs the EtcdCluster controller until the stream ends. +pub async fn run(context: ClusterContext) { + let api = Api::::all(context.client.clone()); + let secret_api = Api::::all(context.client.clone()); + let secret_refs = context.secret_refs.clone(); + let context = Arc::new(context); + + info!("starting EtcdCluster controller"); + + Controller::new(api, watcher::Config::default()) + .watches(secret_api, watcher::Config::default(), move |secret| { + let Some(namespace) = secret.namespace() else { + return Vec::new(); + }; + let secret_name = secret.name_any(); + let index_key = (namespace, secret_name); + + let refs = secret_refs + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + refs.get(&index_key) + .into_iter() + .flat_map(|clusters| clusters.iter()) + .map(|(cluster_namespace, cluster_name)| { + ObjectRef::new(cluster_name).within(cluster_namespace) + }) + .collect::>() + }) + .run(reconcile, error_policy, context) + .for_each(|result| async move { + if let Err(err) = result { + warn!(error = %err, "EtcdCluster reconciliation error"); + } + }) + .await; +} + +async fn reconcile( + cluster: Arc, + context: Arc, +) -> Result { + let namespace = cluster + .namespace() + .ok_or_else(|| ClusterError::Invalid(format!("{} has no namespace", cluster.name_any())))?; + let name = cluster.name_any(); + + if !context.allowed_namespaces.is_empty() + && !context.allowed_namespaces.iter().any(|ns| ns == &namespace) + { + warn!(namespace, name, "cluster namespace not in allowedNamespaces, skipping"); + return Ok(Action::await_change()); + } + + let key = (namespace.clone(), name.clone()); + + if cluster.meta().deletion_timestamp.is_some() { + context.clients.write().await.remove(&key); + context + .config_hashes + .write() + .unwrap_or_else(|p| p.into_inner()) + .remove(&key); + update_status(&cluster, &context.client, &namespace, &name, false).await?; + return Ok(Action::await_change()); + } + + let auth_secret_name = cluster.spec.auth_secret_ref.name.clone(); + index_secret_ref( + &context.secret_refs, + (&namespace, &name), + (&namespace, &auth_secret_name), + ); + + let secrets = Api::::namespaced(context.client.clone(), &namespace); + let secret = match secrets.get(&auth_secret_name).await { + Ok(s) => s, + Err(kube::Error::Api(ae)) if ae.code == 404 => { + warn!(namespace, name, auth_secret_name, "auth secret not found"); + context.clients.write().await.remove(&key); + context + .config_hashes + .write() + .unwrap_or_else(|p| p.into_inner()) + .remove(&key); + update_status(&cluster, &context.client, &namespace, &name, false).await?; + return Ok(Action::requeue(Duration::from_secs(15))); + } + Err(err) => return Err(err.into()), + }; + + let config_hash = compute_config_hash(&cluster.spec, &secret); + let cached_hash = context + .config_hashes + .read() + .unwrap_or_else(|p| p.into_inner()) + .get(&key) + .copied(); + + if cached_hash != Some(config_hash) { + info!(namespace, name, "config changed, rebuilding etcd client"); + match crate::etcd::build_client(&cluster.spec.endpoints, &secret).await { + Ok(client) => { + context.clients.write().await.insert(key.clone(), client); + context + .config_hashes + .write() + .unwrap_or_else(|p| p.into_inner()) + .insert(key.clone(), config_hash); + } + Err(err) => { + warn!(namespace, name, error = %err, "failed to build etcd client"); + context.clients.write().await.remove(&key); + context + .config_hashes + .write() + .unwrap_or_else(|p| p.into_inner()) + .remove(&key); + update_status(&cluster, &context.client, &namespace, &name, false).await?; + return Ok(Action::requeue(Duration::from_secs(15))); + } + } + } + + let client = context.clients.read().await.get(&key).cloned(); + match client { + Some(mut client) => { + if let Err(err) = fetch_and_update_status( + &mut client, + &cluster, + &context.client, + &namespace, + &name, + ) + .await + { + warn!(namespace, name, error = %err, "health check failed, marking disconnected"); + context.clients.write().await.remove(&key); + context + .config_hashes + .write() + .unwrap_or_else(|p| p.into_inner()) + .remove(&key); + update_status(&cluster, &context.client, &namespace, &name, false).await?; + } + } + None => { + update_status(&cluster, &context.client, &namespace, &name, false).await?; + } + } + + Ok(Action::requeue(Duration::from_secs(15))) +} + +fn error_policy(_cluster: Arc, error: &ClusterError, _context: Arc) -> Action { + warn!(error = %error, "applying EtcdCluster error policy"); + Action::requeue(Duration::from_secs(60)) +} + +fn compute_config_hash(spec: &EtcdClusterSpec, secret: &Secret) -> u64 { + let mut hasher = DefaultHasher::new(); + + for endpoint in &spec.endpoints { + endpoint.hash(&mut hasher); + } + + spec.auth_secret_ref.name.hash(&mut hasher); + + if let Some(data) = &secret.data { + let mut entries: Vec<_> = data.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + for (k, v) in entries { + k.hash(&mut hasher); + v.0.hash(&mut hasher); + } + } + + hasher.finish() +} + +async fn fetch_and_update_status( + client: &mut etcd_client::Client, + cluster: &EtcdCluster, + kube_client: &Client, + namespace: &str, + name: &str, +) -> Result<(), ClusterError> { + let status_resp = client + .status() + .await + .map_err(|err| ClusterError::Etcd(anyhow!(err.to_string())))?; + let members_resp = client + .member_list() + .await + .map_err(|err| ClusterError::Etcd(anyhow!(err.to_string())))?; + let alarm_resp = client + .alarm( + etcd_client::AlarmAction::Get, + etcd_client::AlarmType::None, + None, + ) + .await + .map_err(|err| ClusterError::Etcd(anyhow!(err.to_string())))?; + + let desired = to_status( + true, + Some(&status_resp), + Some(&members_resp), + Some(&alarm_resp), + cluster.status.as_ref(), + ); + + patch_status_if_changed(cluster, kube_client, namespace, name, desired).await +} + +fn format_bytes(bytes: i64) -> String { + let bytes_f = bytes as f64; + if bytes_f >= 1_073_741_824.0 { + format!("{:.1} GiB", bytes_f / 1_073_741_824.0) + } else if bytes_f >= 1_048_576.0 { + format!("{:.1} MiB", bytes_f / 1_048_576.0) + } else if bytes_f >= 1024.0 { + format!("{:.1} KiB", bytes_f / 1024.0) + } else { + format!("{} B", bytes) + } +} + +fn to_status( + connected: bool, + status: Option<&etcd_client::StatusResponse>, + members: Option<&etcd_client::MemberListResponse>, + alarms: Option<&etcd_client::AlarmResponse>, + current_status: Option<&EtcdClusterStatus>, +) -> EtcdClusterStatus { + let (ready, reason, message) = if connected { + (true, "Connected", "") + } else { + (false, "Disconnected", "etcd cluster is not reachable") + }; + + let existing_conditions: &[crate::crd::Condition] = current_status + .map(|status| status.conditions.as_slice()) + .unwrap_or(&[]); + + let member_list = members + .map(|resp| { + resp.members() + .iter() + .map(|member| ClusterMember { + name: member.name().to_string(), + endpoint: member.client_urls().first().cloned().unwrap_or_default(), + is_learner: member.is_learner(), + }) + .collect::>() + }) + .unwrap_or_default(); + + let leader = match (status, members) { + (Some(status_resp), Some(members_resp)) => { + let leader_id = status_resp.leader(); + members_resp + .members() + .iter() + .find(|member| member.id() == leader_id) + .map(|member| member.name().to_string()) + .or_else(|| Some(format!("unknown-{leader_id}"))) + } + _ => None, + }; + + let alarm_list = match (alarms, members) { + (Some(alarm_resp), Some(members_resp)) => alarm_resp + .alarms() + .iter() + .map(|alarm| { + let member_name = members_resp + .members() + .iter() + .find(|member| member.id() == alarm.member_id()) + .map(|member| member.name().to_string()) + .unwrap_or_else(|| format!("member-{}", alarm.member_id())); + + let alarm_type = match alarm.alarm() { + etcd_client::AlarmType::Nospace => "NOSPACE", + etcd_client::AlarmType::Corrupt => "CORRUPT", + _ => "UNKNOWN", + }; + + format!("{member_name}: {alarm_type}") + }) + .collect(), + _ => Vec::new(), + }; + + EtcdClusterStatus { + connected, + conditions: vec![crate::crd::ready_condition_with_existing( + ready, + reason, + message, + existing_conditions, + )], + version: if connected { + status.map(|s| s.version().to_owned()) + } else { + None + }, + db_size: if connected { + status.map(|s| format_bytes(s.db_size())) + } else { + None + }, + leader, + members: member_list, + alarms: alarm_list, + } +} + +async fn patch_status_if_changed( + current: &EtcdCluster, + client: &Client, + namespace: &str, + name: &str, + desired: EtcdClusterStatus, +) -> Result<(), ClusterError> { + if current.status.as_ref() == Some(&desired) { + return Ok(()); + } + + let api = Api::::namespaced(client.clone(), namespace); + let patch = serde_json::json!({ + "status": desired, + }); + + api.patch_status(name, &PatchParams::default(), &Patch::Merge(&patch)) + .await?; + + Ok(()) +} + +async fn update_status( + current: &EtcdCluster, + client: &Client, + namespace: &str, + name: &str, + connected: bool, +) -> Result<(), ClusterError> { + let desired = to_status(connected, None, None, None, current.status.as_ref()); + patch_status_if_changed(current, client, namespace, name, desired).await +} + +fn index_secret_ref( + index: &SecretRefIndex, + cluster_key: (&str, &str), + secret_key: (&str, &str), +) { + let cluster_key = (cluster_key.0.to_owned(), cluster_key.1.to_owned()); + let secret_key = (secret_key.0.to_owned(), secret_key.1.to_owned()); + + let mut refs = index + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + for clusters in refs.values_mut() { + clusters.retain(|existing| existing != &cluster_key); + } + + refs.entry(secret_key).or_default().push(cluster_key); + + refs.retain(|_, clusters| !clusters.is_empty()); +} diff --git a/apps/etcdetcetc/src/crd.rs b/apps/etcdetcetc/src/crd.rs index 475ec5b8..578accc5 100644 --- a/apps/etcdetcetc/src/crd.rs +++ b/apps/etcdetcetc/src/crd.rs @@ -35,15 +35,39 @@ impl std::fmt::Display for ConditionStatus { } } -/// Build a Ready condition. `last_transition_time` uses RFC 3339 UTC. -pub fn ready_condition(ready: bool, reason: &str, message: &str) -> Condition { - let now = rfc3339_now(); +/// Build a Ready condition, preserving `last_transition_time` when status is unchanged. +pub fn ready_condition_with_existing( + ready: bool, + reason: &str, + message: &str, + existing_conditions: &[Condition], +) -> Condition { + let desired_status = if ready { + ConditionStatus::True + } else { + ConditionStatus::False + }; + + let last_transition_time = match existing_conditions + .iter() + .find(|condition| condition.type_ == "Ready") + { + Some(existing) if existing.status == desired_status => { + existing.last_transition_time.clone() + } + _ => rfc3339_now(), + }; + Condition { type_: "Ready".to_string(), - status: if ready { ConditionStatus::True } else { ConditionStatus::False }, + status: desired_status, reason: Some(reason.to_string()), - message: if message.is_empty() { None } else { Some(message.to_string()) }, - last_transition_time: now, + message: if message.is_empty() { + None + } else { + Some(message.to_string()) + }, + last_transition_time, } } @@ -88,7 +112,9 @@ pub struct LocalSecretReference { #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct ClusterReference { pub name: String, - pub namespace: String, + /// Namespace of the EtcdCluster. Defaults to the tenant's namespace. + #[serde(default)] + pub namespace: Option, } // --------------------------------------------------------------------------- @@ -176,7 +202,7 @@ pub struct EtcdTenantSpec { pub cluster_ref: ClusterReference, /// etcd key prefix for this tenant. Defaults to `//`. - #[schemars(regex(pattern = r"^/[A-Za-z0-9/_-]*$"))] + #[schemars(regex(pattern = r"^/[A-Za-z0-9/_-]*/$"))] #[serde(default)] pub prefix: Option, diff --git a/apps/etcdetcetc/src/etcd.rs b/apps/etcdetcetc/src/etcd.rs new file mode 100644 index 00000000..3d832c9a --- /dev/null +++ b/apps/etcdetcetc/src/etcd.rs @@ -0,0 +1,83 @@ +//! etcd client construction helpers. + +use anyhow::{Context, Result, anyhow, bail}; +use etcd_client::{Certificate, Client, ConnectOptions, Identity, TlsOptions}; +use k8s_openapi::api::core::v1::Secret; +use kube::ResourceExt; + +/// Builds an authenticated etcd client from endpoint URLs and Secret data. +/// +/// The auth mode is inferred from Secret keys: +/// - TLS client auth: `tls.crt`, `tls.key`, `ca.crt` +/// - Basic auth: `username`, `password`, `ca.crt` +pub async fn build_client(endpoints: &[String], secret: &Secret) -> Result { + if endpoints.is_empty() { + bail!("etcd endpoints are empty"); + } + + let data = secret + .data + .as_ref() + .ok_or_else(|| anyhow!("secret {} has no data", secret.name_any()))?; + + let ca_cert = data + .get("ca.crt") + .ok_or_else(|| anyhow!("secret {} missing required key ca.crt", secret.name_any()))?; + + let tls_options = TlsOptions::new().ca_certificate(Certificate::from_pem(ca_cert.0.clone())); + + let mut connect_options = match (data.get("tls.crt"), data.get("tls.key")) { + (Some(cert), Some(key)) => { + let identity = Identity::from_pem(cert.0.clone(), key.0.clone()); + ConnectOptions::new().with_tls(tls_options.identity(identity)) + } + (Some(_), None) => { + bail!( + "secret {} missing required key tls.key for TLS auth", + secret.name_any() + ); + } + (None, Some(_)) => { + bail!( + "secret {} missing required key tls.crt for TLS auth", + secret.name_any() + ); + } + (None, None) => { + let username = data.get("username").ok_or_else(|| { + anyhow!( + "secret {} missing required keys for auth; expected either [tls.crt, tls.key, ca.crt] or [username, password, ca.crt]", + secret.name_any() + ) + })?; + let password = data.get("password").ok_or_else(|| { + anyhow!( + "secret {} missing required key password for basic auth", + secret.name_any() + ) + })?; + + let username = std::str::from_utf8(&username.0) + .context("username is not valid UTF-8")? + .to_owned(); + let password = std::str::from_utf8(&password.0) + .context("password is not valid UTF-8")? + .to_owned(); + + ConnectOptions::new() + .with_tls(tls_options) + .with_user(username, password) + } + }; + + connect_options = connect_options + .with_keep_alive( + std::time::Duration::from_secs(15), + std::time::Duration::from_secs(5), + ) + .with_keep_alive_while_idle(true); + + Client::connect(endpoints, Some(connect_options)) + .await + .context("failed to connect to etcd") +} diff --git a/apps/etcdetcetc/src/main.rs b/apps/etcdetcetc/src/main.rs index bcc64f28..2f50ae00 100644 --- a/apps/etcdetcetc/src/main.rs +++ b/apps/etcdetcetc/src/main.rs @@ -1,10 +1,58 @@ +mod cluster; mod crd; +mod etcd; +mod tenant; -use anyhow::Result; +use anyhow::{Result, anyhow}; +use clap::{Parser, Subcommand}; +use kube::CustomResourceExt; +use std::{collections::HashMap, sync::{Arc, RwLock as StdRwLock}}; +use tokio::sync::RwLock; +use tokio::{signal::unix::{SignalKind, signal}, task::JoinError}; use tracing_subscriber::{EnvFilter, fmt::format::FmtSpan}; +#[derive(Parser)] +#[command(name = "etcdetcetc")] +struct Cli { + /// Restrict the controller to these namespaces. Can be repeated. + /// When empty, the controller operates cluster-wide. + #[arg(long = "allowed-namespace")] + allowed_namespaces: Vec, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Print CRD manifests as JSON to stdout + Crds, +} + +fn print_crds() { + let list = serde_json::json!({ + "apiVersion": "v1", + "kind": "List", + "items": [ + crd::EtcdCluster::crd(), + crd::EtcdTenant::crd(), + ] + }); + println!("{}", serde_json::to_string_pretty(&list).unwrap()); +} + #[tokio::main] async fn main() -> Result<()> { + let Cli { + command, + allowed_namespaces, + } = Cli::parse(); + + if let Some(Commands::Crds) = command { + print_crds(); + return Ok(()); + } + tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .json() @@ -16,9 +64,59 @@ async fn main() -> Result<()> { let client = kube::Client::try_default().await?; tracing::info!("connected to kubernetes"); - // TODO: start EtcdCluster and EtcdTenant controllers + let cluster_clients: cluster::ClusterClients = Arc::new(RwLock::new(HashMap::new())); + let secret_refs: cluster::SecretRefIndex = Arc::new(StdRwLock::new(HashMap::new())); + let cluster_context = cluster::ClusterContext { + client: client.clone(), + clients: cluster_clients.clone(), + secret_refs, + allowed_namespaces: allowed_namespaces.clone(), + config_hashes: Arc::new(StdRwLock::new(HashMap::new())), + }; + + let tenant_context = tenant::TenantContext { + client: client.clone(), + clients: cluster_clients, + allowed_namespaces, + }; + + let mut cluster_task = tokio::spawn(async move { + cluster::run(cluster_context).await; + }); + + let mut tenant_task = tokio::spawn(async move { + tenant::run(tenant_context).await; + }); + + let mut sigint = signal(SignalKind::interrupt())?; + let mut sigterm = signal(SignalKind::terminate())?; + + tokio::select! { + result = &mut cluster_task => { + return Err(controller_task_ended("EtcdCluster", result)); + } + result = &mut tenant_task => { + return Err(controller_task_ended("EtcdTenant", result)); + } + _ = sigint.recv() => { + tracing::info!("received SIGINT, shutting down"); + } + _ = sigterm.recv() => { + tracing::info!("received SIGTERM, shutting down"); + } + } + + cluster_task.abort(); + tenant_task.abort(); + let _ = cluster_task.await; + let _ = tenant_task.await; - // Park until shutdown signal. - tokio::signal::ctrl_c().await?; Ok(()) } + +fn controller_task_ended(name: &str, result: std::result::Result<(), JoinError>) -> anyhow::Error { + match result { + Ok(()) => anyhow!("{name} controller task exited unexpectedly"), + Err(err) => anyhow!("{name} controller task failed: {err}"), + } +} diff --git a/apps/etcdetcetc/src/tenant.rs b/apps/etcdetcetc/src/tenant.rs new file mode 100644 index 00000000..843fe9b6 --- /dev/null +++ b/apps/etcdetcetc/src/tenant.rs @@ -0,0 +1,586 @@ +//! EtcdTenant controller. + +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +use futures::StreamExt; +use k8s_openapi::{ByteString, api::core::v1::Secret}; +use kube::{ + Api, Client, Resource, ResourceExt, + api::{ObjectMeta, Patch, PatchParams, PostParams}, + runtime::{ + Controller, + controller::Action, + watcher, + }, +}; +use tracing::{error, info, warn}; + +use crate::{ + cluster::ClusterClients, + crd::{EtcdCluster, EtcdTenant}, +}; + +const TENANT_FINALIZER: &str = "etcdetcetc.samcday.com/tenant"; + +/// Shared context for the EtcdTenant controller. +#[derive(Clone)] +pub struct TenantContext { + /// Kubernetes API client. + pub client: Client, + /// Shared EtcdCluster etcd client cache. + pub clients: ClusterClients, + /// Restricts reconciliation to these namespaces when non-empty. + pub allowed_namespaces: Vec, +} + +/// Errors produced by EtcdTenant reconciliation. +#[derive(Debug, thiserror::Error)] +pub enum TenantError { + /// Kubernetes API error. + #[error("kubernetes API error: {0}")] + Kube(#[from] kube::Error), + + /// etcd API error. + #[error("etcd API error: {0}")] + Etcd(#[from] etcd_client::Error), + + /// Serialization error. + #[error("serialization error: {0}")] + Serde(#[from] serde_json::Error), + + /// Invalid or incomplete EtcdTenant/EtcdCluster object. + #[error("invalid object: {0}")] + Invalid(String), +} + +/// Runs the EtcdTenant controller until the stream ends. +pub async fn run(context: TenantContext) { + let api = Api::::all(context.client.clone()); + let context = Arc::new(context); + + info!("starting EtcdTenant controller"); + + Controller::new(api, watcher::Config::default()) + .run(reconcile, error_policy, context) + .for_each(|result| async move { + match result { + Ok((obj_ref, action)) => { + info!( + namespace = obj_ref.namespace.as_deref().unwrap_or_default(), + name = obj_ref.name, + ?action, + "reconciled EtcdTenant" + ); + } + Err(err) => { + error!(error = %err, "EtcdTenant reconciliation failed"); + } + } + }) + .await; +} + +async fn reconcile(tenant: Arc, context: Arc) -> Result { + let namespace = tenant + .namespace() + .ok_or_else(|| TenantError::Invalid(format!("{} has no namespace", tenant.name_any())))?; + let name = tenant.name_any(); + + if !context.allowed_namespaces.is_empty() + && !context.allowed_namespaces.iter().any(|ns| ns == &namespace) + { + warn!(namespace, name, "tenant namespace not in allowedNamespaces, skipping"); + return Ok(Action::await_change()); + } + + let prefix = tenant + .spec + .prefix + .clone() + .unwrap_or_else(|| format!("/{name}/")); + + info!(namespace, name, "reconciling EtcdTenant"); + + if tenant.meta().deletion_timestamp.is_some() { + return reconcile_delete(tenant, context, &namespace, &name, &prefix).await; + } + + ensure_finalizer(&context.client, &tenant, &namespace).await?; + + let cluster_namespace = tenant.spec.cluster_ref.namespace.clone().unwrap_or_else(|| namespace.clone()); + let cluster_name = tenant.spec.cluster_ref.name.clone(); + let clusters = Api::::namespaced(context.client.clone(), &cluster_namespace); + let cluster = match clusters.get(&cluster_name).await { + Ok(cluster) => cluster, + Err(kube::Error::Api(ae)) if ae.code == 404 => { + warn!( + tenant_namespace = namespace, + tenant_name = name, + cluster_namespace, + cluster_name, + "referenced EtcdCluster not found, requeueing" + ); + update_ready_status( + &context.client, + &tenant, + &namespace, + &name, + false, + "ClusterNotFound", + "referenced EtcdCluster not found", + ) + .await?; + return Ok(Action::requeue(Duration::from_secs(30))); + } + Err(err) => return Err(err.into()), + }; + + let mut etcd_client = match get_cluster_client(&context.clients, &cluster_namespace, &cluster_name).await { + Some(client) => client, + None => { + warn!( + tenant_namespace = namespace, + tenant_name = name, + cluster_namespace, + cluster_name, + "referenced EtcdCluster not connected yet, requeueing" + ); + update_ready_status( + &context.client, + &tenant, + &namespace, + &name, + false, + "ClusterNotConnected", + "referenced EtcdCluster not connected yet", + ) + .await?; + return Ok(Action::requeue(Duration::from_secs(30))); + } + }; + + let secret_name = tenant + .spec + .secret_name + .clone() + .unwrap_or_else(|| format!("{name}-etcd")); + + let password = ensure_password_secret(&context.client, &tenant, &namespace, &name).await?; + + ensure_tenant_rbac(&mut etcd_client, &name, &prefix, &password).await?; + ensure_output_secret( + &context.client, + &tenant, + &cluster, + &namespace, + &secret_name, + &name, + &password, + ) + .await?; + update_ready_status( + &context.client, + &tenant, + &namespace, + &name, + true, + "Provisioned", + "", + ) + .await?; + + Ok(Action::requeue(Duration::from_secs(5 * 60))) +} + +fn error_policy(_tenant: Arc, error: &TenantError, _context: Arc) -> Action { + warn!(error = %error, "applying EtcdTenant error policy"); + Action::requeue(Duration::from_secs(60)) +} + +async fn reconcile_delete( + tenant: Arc, + context: Arc, + namespace: &str, + name: &str, + prefix: &str, +) -> Result { + if !has_finalizer(&tenant) { + return Ok(Action::await_change()); + } + + let cluster_namespace = tenant.spec.cluster_ref.namespace.clone().unwrap_or_else(|| namespace.to_owned()); + let cluster_name = tenant.spec.cluster_ref.name.clone(); + + if let Some(mut etcd_client) = get_cluster_client(&context.clients, &cluster_namespace, &cluster_name).await { + info!(namespace, name, prefix, "cleaning up tenant data and RBAC"); + + etcd_client + .delete(prefix, Some(etcd_client::DeleteOptions::new().with_prefix())) + .await?; + + let mut auth = etcd_client.auth_client(); + ignore_not_found(auth.user_revoke_role(name, name).await)?; + ignore_not_found(auth.user_delete(name).await)?; + ignore_not_found(auth.role_delete(name).await)?; + } else { + let clusters = Api::::namespaced(context.client.clone(), &cluster_namespace); + match clusters.get(&cluster_name).await { + Ok(_) => { + warn!( + namespace, + name, + cluster_namespace, + cluster_name, + "cluster client unavailable during cleanup; keeping finalizer and requeueing" + ); + return Ok(Action::requeue(Duration::from_secs(30))); + } + Err(kube::Error::Api(ae)) if ae.code == 404 => { + warn!( + namespace, + name, + cluster_namespace, + cluster_name, + "referenced EtcdCluster is deleted; skipping etcd cleanup and removing finalizer" + ); + } + Err(err) => return Err(err.into()), + } + } + + remove_finalizer(&context.client, &tenant, namespace).await?; + Ok(Action::await_change()) +} + +async fn get_cluster_client( + clients: &ClusterClients, + cluster_namespace: &str, + cluster_name: &str, +) -> Option { + let key = (cluster_namespace.to_owned(), cluster_name.to_owned()); + let clients = clients.read().await; + clients.get(&key).cloned() +} + +async fn ensure_tenant_rbac( + client: &mut etcd_client::Client, + name: &str, + prefix: &str, + password: &str, +) -> Result<(), TenantError> { + let mut auth = client.auth_client(); + + if let Err(err) = auth.user_get(name).await { + if is_not_found_error(&err) { + info!(name, "creating etcd tenant user"); + auth.user_add(name, password, None).await?; + } else { + return Err(TenantError::Etcd(err)); + } + } + + if let Err(err) = auth.user_change_password(name, password).await { + warn!(name, error = %err, "failed to sync etcd user password (may be unchanged)"); + } + + if let Err(err) = auth.role_get(name).await { + if is_not_found_error(&err) { + info!(name, "creating etcd tenant role"); + auth.role_add(name).await?; + } else { + return Err(TenantError::Etcd(err)); + } + } + + let desired_permission = etcd_client::Permission::read_write(prefix).with_prefix(); + let role = auth.role_get(name).await?; + for permission in role.permissions() { + if permission != desired_permission { + info!(name, prefix, "revoking stale prefix permission from tenant role"); + let options = if permission.is_prefix() { + Some(etcd_client::RoleRevokePermissionOptions::new().with_prefix()) + } else if permission.is_from_key() { + Some(etcd_client::RoleRevokePermissionOptions::new().with_from_key()) + } else if permission.range_end().is_empty() { + None + } else { + Some( + etcd_client::RoleRevokePermissionOptions::new() + .with_range_end(permission.range_end().to_vec()), + ) + }; + + auth.role_revoke_permission(name, permission.key().to_vec(), options) + .await?; + } + } + + let role = auth.role_get(name).await?; + if !role.permissions().iter().any(|perm| perm == &desired_permission) { + info!(name, prefix, "granting prefix permission to tenant role"); + auth.role_grant_permission(name, desired_permission).await?; + } + + let user = auth.user_get(name).await?; + if !user.roles().iter().any(|role| role == name) { + info!(name, "granting role to tenant user"); + auth.user_grant_role(name, name).await?; + } + + Ok(()) +} + +async fn ensure_output_secret( + client: &Client, + tenant: &EtcdTenant, + cluster: &EtcdCluster, + tenant_namespace: &str, + secret_name: &str, + tenant_name: &str, + password: &str, +) -> Result<(), TenantError> { + let auth_secret_name = &cluster.spec.auth_secret_ref.name; + let cluster_ns = tenant.spec.cluster_ref.namespace.as_deref() + .unwrap_or(tenant_namespace); + let source_secrets = Api::::namespaced(client.clone(), cluster_ns); + let source_secret = source_secrets.get(auth_secret_name).await?; + let ca_crt = source_secret + .data + .as_ref() + .and_then(|data| data.get("ca.crt")) + .cloned() + .ok_or_else(|| { + TenantError::Invalid(format!( + "auth secret {}/{} missing required key ca.crt", + cluster_ns, cluster.spec.auth_secret_ref.name + )) + })?; + + let owner_reference = tenant.controller_owner_ref(&()).ok_or_else(|| { + TenantError::Invalid(format!( + "{} cannot produce controller owner reference", + tenant.name_any() + )) + })?; + + let mut data = BTreeMap::new(); + data.insert( + "username".to_string(), + ByteString(tenant_name.as_bytes().to_vec()), + ); + data.insert( + "password".to_string(), + ByteString(password.as_bytes().to_vec()), + ); + data.insert( + "endpoints".to_string(), + ByteString(serde_json::to_vec(&cluster.spec.endpoints)?), + ); + data.insert("ca.crt".to_string(), ca_crt); + + let secret = Secret { + metadata: ObjectMeta { + name: Some(secret_name.to_string()), + namespace: Some(tenant_namespace.to_string()), + owner_references: Some(vec![owner_reference]), + ..ObjectMeta::default() + }, + data: Some(data), + type_: Some("Opaque".to_string()), + ..Secret::default() + }; + + let secrets = Api::::namespaced(client.clone(), tenant_namespace); + secrets + .patch( + secret_name, + &PatchParams::apply("etcdetcetc").force(), + &Patch::Apply(&secret), + ) + .await?; + + Ok(()) +} + +async fn update_ready_status( + client: &Client, + tenant: &EtcdTenant, + namespace: &str, + name: &str, + ready: bool, + reason: &str, + message: &str, +) -> Result<(), TenantError> { + let api = Api::::namespaced(client.clone(), namespace); + let existing_conditions: &[crate::crd::Condition] = tenant + .status + .as_ref() + .map(|status| status.conditions.as_slice()) + .unwrap_or(&[]); + + let condition = crate::crd::ready_condition_with_existing( + ready, + reason, + message, + existing_conditions, + ); + let patch = serde_json::json!({ + "status": { + "conditions": [condition], + } + }); + + api.patch_status(name, &PatchParams::default(), &Patch::Merge(&patch)) + .await?; + + Ok(()) +} + +async fn ensure_finalizer(client: &Client, tenant: &EtcdTenant, namespace: &str) -> Result<(), TenantError> { + if has_finalizer(tenant) { + return Ok(()); + } + + let mut finalizers = tenant.meta().finalizers.clone().unwrap_or_default(); + finalizers.push(TENANT_FINALIZER.to_string()); + + let api = Api::::namespaced(client.clone(), namespace); + let patch = serde_json::json!({ + "metadata": { + "finalizers": finalizers, + } + }); + + api.patch( + &tenant.name_any(), + &PatchParams::default(), + &Patch::Merge(&patch), + ) + .await?; + + Ok(()) +} + +async fn remove_finalizer(client: &Client, tenant: &EtcdTenant, namespace: &str) -> Result<(), TenantError> { + let mut finalizers = tenant.meta().finalizers.clone().unwrap_or_default(); + finalizers.retain(|f| f != TENANT_FINALIZER); + + let api = Api::::namespaced(client.clone(), namespace); + let patch = serde_json::json!({ + "metadata": { + "finalizers": finalizers, + } + }); + + api.patch( + &tenant.name_any(), + &PatchParams::default(), + &Patch::Merge(&patch), + ) + .await?; + + Ok(()) +} + +fn has_finalizer(tenant: &EtcdTenant) -> bool { + tenant + .meta() + .finalizers + .as_ref() + .is_some_and(|finalizers| finalizers.iter().any(|f| f == TENANT_FINALIZER)) +} + +fn generate_password() -> String { + use rand::Rng; + + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::thread_rng(); + (0..32) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +async fn ensure_password_secret( + client: &Client, + tenant: &EtcdTenant, + namespace: &str, + name: &str, +) -> Result { + let password_secret_name = format!("{name}-etcd-password"); + let secrets = Api::::namespaced(client.clone(), namespace); + + match secrets.get(&password_secret_name).await { + Ok(secret) => { + let password = secret + .data + .as_ref() + .and_then(|data| data.get("password")) + .ok_or_else(|| { + TenantError::Invalid(format!( + "password secret {namespace}/{password_secret_name} missing required key password" + )) + })?; + + String::from_utf8(password.0.clone()).map_err(|_| { + TenantError::Invalid(format!( + "password secret {namespace}/{password_secret_name} contains non-UTF-8 password" + )) + }) + } + Err(kube::Error::Api(ae)) if ae.code == 404 => { + let owner_reference = tenant.controller_owner_ref(&()).ok_or_else(|| { + TenantError::Invalid(format!( + "{} cannot produce controller owner reference", + tenant.name_any() + )) + })?; + + let password = generate_password(); + let mut data = BTreeMap::new(); + data.insert( + "password".to_string(), + ByteString(password.as_bytes().to_vec()), + ); + + let secret = Secret { + metadata: ObjectMeta { + name: Some(password_secret_name.clone()), + namespace: Some(namespace.to_string()), + owner_references: Some(vec![owner_reference]), + ..ObjectMeta::default() + }, + data: Some(data), + type_: Some("Opaque".to_string()), + ..Secret::default() + }; + + secrets.create(&PostParams::default(), &secret).await?; + Ok(password) + } + Err(err) => Err(err.into()), + } +} + +fn ignore_not_found(result: Result) -> Result<(), TenantError> { + match result { + Ok(_) => Ok(()), + Err(err) if is_not_found_error(&err) => Ok(()), + Err(err) => Err(TenantError::Etcd(err)), + } +} + +fn is_not_found_error(error: &etcd_client::Error) -> bool { + match error { + etcd_client::Error::GRpcStatus(status) => { + // NOT_FOUND (5) is the standard code for missing keys. + // FAILED_PRECONDITION (9) is what etcd returns for missing auth entities + // (users, roles) — or when auth is not enabled. + let code = status.code() as i32; + code == 5 || (code == 9 && status.message().to_ascii_lowercase().contains("not found")) + } + _ => false, + } +} From fd0227eda8ec76497d43b79d78b86c6d315e8fb0 Mon Sep 17 00:00:00 2001 From: Sam Day Date: Mon, 30 Mar 2026 14:50:16 +1100 Subject: [PATCH 03/17] etcdetcetc: add Dockerfile and dev environment Alpine/musl Dockerfile with BuildKit cache mounts for cargo registry and target directory. Dev environment with kind cluster xtask, Tiltfile, and Kubernetes deployment manifests. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/.cargo/config.toml | 2 + apps/etcdetcetc/.dockerignore | 4 + apps/etcdetcetc/.gitignore | 1 + apps/etcdetcetc/Dockerfile | 22 ++ apps/etcdetcetc/Tiltfile | 12 + apps/etcdetcetc/dev/deploy.yaml | 56 +++++ apps/etcdetcetc/xtask/Cargo.toml | 8 + apps/etcdetcetc/xtask/src/main.rs | 391 +++++++++++++++++++++++++++++ 8 files changed, 496 insertions(+) create mode 100644 apps/etcdetcetc/.cargo/config.toml create mode 100644 apps/etcdetcetc/.dockerignore create mode 100644 apps/etcdetcetc/Dockerfile create mode 100644 apps/etcdetcetc/Tiltfile create mode 100644 apps/etcdetcetc/dev/deploy.yaml create mode 100644 apps/etcdetcetc/xtask/Cargo.toml create mode 100644 apps/etcdetcetc/xtask/src/main.rs diff --git a/apps/etcdetcetc/.cargo/config.toml b/apps/etcdetcetc/.cargo/config.toml new file mode 100644 index 00000000..35049cbc --- /dev/null +++ b/apps/etcdetcetc/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/apps/etcdetcetc/.dockerignore b/apps/etcdetcetc/.dockerignore new file mode 100644 index 00000000..91a4fec0 --- /dev/null +++ b/apps/etcdetcetc/.dockerignore @@ -0,0 +1,4 @@ +xtask/ +.cargo/ +.dev/ +target/ diff --git a/apps/etcdetcetc/.gitignore b/apps/etcdetcetc/.gitignore index ea8c4bf7..dd5350a9 100644 --- a/apps/etcdetcetc/.gitignore +++ b/apps/etcdetcetc/.gitignore @@ -1 +1,2 @@ /target +/.dev/ diff --git a/apps/etcdetcetc/Dockerfile b/apps/etcdetcetc/Dockerfile new file mode 100644 index 00000000..729ef98e --- /dev/null +++ b/apps/etcdetcetc/Dockerfile @@ -0,0 +1,22 @@ +FROM rust:1-alpine AS builder +RUN apk add --no-cache build-base protobuf-dev +WORKDIR /build + +COPY Cargo.toml Cargo.lock ./ +# Strip xtask from workspace — it's a dev tool, not shipped in the image. +RUN sed -i '/xtask/d' Cargo.toml +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/build/target \ + mkdir src && echo 'fn main() {}' > src/main.rs && \ + cargo build --release && \ + rm -rf src target/release/.fingerprint/etcdetcetc-* + +COPY src/ src/ +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/build/target \ + cargo build --release && \ + cp target/release/etcdetcetc /etcdetcetc + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=builder /etcdetcetc / +ENTRYPOINT ["/etcdetcetc"] diff --git a/apps/etcdetcetc/Tiltfile b/apps/etcdetcetc/Tiltfile new file mode 100644 index 00000000..525f4033 --- /dev/null +++ b/apps/etcdetcetc/Tiltfile @@ -0,0 +1,12 @@ +docker_build('etcdetcetc', '.', only=['src/', 'Cargo.toml', 'Cargo.lock']) + +k8s_yaml(local('cargo run -p etcdetcetc -- crds 2>/dev/null')) +k8s_yaml('dev/deploy.yaml') + +k8s_resource('etcdetcetc', objects=[ + 'etcdetcetc:serviceaccount', + 'etcdetcetc:clusterrole', + 'etcdetcetc:clusterrolebinding', + 'etcdclusters.etcdetcetc.samcday.com:customresourcedefinition', + 'etcdtenants.etcdetcetc.samcday.com:customresourcedefinition', +]) diff --git a/apps/etcdetcetc/dev/deploy.yaml b/apps/etcdetcetc/dev/deploy.yaml new file mode 100644 index 00000000..6e52531b --- /dev/null +++ b/apps/etcdetcetc/dev/deploy.yaml @@ -0,0 +1,56 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: etcdetcetc + namespace: default +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: etcdetcetc +rules: + - apiGroups: ["etcdetcetc.samcday.com"] + resources: ["etcdclusters", "etcdtenants"] + verbs: ["get", "list", "watch", "patch"] + - apiGroups: ["etcdetcetc.samcday.com"] + resources: ["etcdclusters/status", "etcdtenants/status"] + verbs: ["patch"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: etcdetcetc +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: etcdetcetc +subjects: + - kind: ServiceAccount + name: etcdetcetc + namespace: default +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: etcdetcetc + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: etcdetcetc + template: + metadata: + labels: + app: etcdetcetc + spec: + serviceAccountName: etcdetcetc + containers: + - name: controller + image: etcdetcetc + env: + - name: RUST_LOG + value: info,etcdetcetc=debug diff --git a/apps/etcdetcetc/xtask/Cargo.toml b/apps/etcdetcetc/xtask/Cargo.toml new file mode 100644 index 00000000..f47d0b02 --- /dev/null +++ b/apps/etcdetcetc/xtask/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } diff --git a/apps/etcdetcetc/xtask/src/main.rs b/apps/etcdetcetc/xtask/src/main.rs new file mode 100644 index 00000000..113f3bf6 --- /dev/null +++ b/apps/etcdetcetc/xtask/src/main.rs @@ -0,0 +1,391 @@ +use std::fs; +use std::io::Write; +use std::process::{Command, Stdio}; + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; + +const CLUSTER_NAME: &str = "etcdetcetc"; +const DEV_DIR: &str = ".dev"; + +#[derive(Parser)] +#[command(name = "xtask")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Create a kind cluster with etcd access and emit sourceable env vars + DevUp, + /// Tear down the kind cluster + DevDown, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Commands::DevUp => cmd_dev_up(), + Commands::DevDown => cmd_dev_down(), + } +} + +fn cmd_dev_up() -> Result<()> { + eprintln!("Starting dev-up..."); + + ensure_tool("kind")?; + ensure_tool("docker")?; + ensure_tool("kubectl")?; + + let clusters = + run_stdout("kind", &["get", "clusters"]).context("checking existing kind clusters")?; + let cluster_exists = clusters.lines().any(|line| line.trim() == CLUSTER_NAME); + if cluster_exists { + eprintln!("Kind cluster '{CLUSTER_NAME}' already exists, reusing it"); + } else { + run("kind", &["create", "cluster", "--name", CLUSTER_NAME]) + .context("creating kind cluster")?; + } + + let cwd = std::env::current_dir().context("getting current directory")?; + let dev_dir = cwd.join(DEV_DIR); + fs::create_dir_all(&dev_dir) + .with_context(|| format!("creating {} directory", dev_dir.display()))?; + + let kubeconfig = dev_dir.join("kubeconfig"); + let kubeconfig_str = kubeconfig.to_string_lossy().into_owned(); + run( + "kind", + &[ + "export", + "kubeconfig", + "--name", + CLUSTER_NAME, + "--kubeconfig", + &kubeconfig_str, + ], + ) + .with_context(|| format!("exporting kind kubeconfig to {}", kubeconfig.display()))?; + + // SAFETY: xtask is a single-threaded synchronous CLI. Setting this process env var + // before spawning child commands is safe and ensures kubectl/cargo see KUBECONFIG. + unsafe { + std::env::set_var("KUBECONFIG", &kubeconfig_str); + } + + let container_name = format!("{CLUSTER_NAME}-control-plane"); + let ca_crt = dev_dir.join("ca.crt"); + let tls_crt = dev_dir.join("tls.crt"); + let tls_key = dev_dir.join("tls.key"); + + let cp_ca_src = format!("{container_name}:/etc/kubernetes/pki/etcd/ca.crt"); + let cp_ca_dst = ca_crt.to_string_lossy().into_owned(); + run("docker", &["cp", &cp_ca_src, &cp_ca_dst]) + .context("copying etcd CA cert from container")?; + + let cp_crt_src = format!("{container_name}:/etc/kubernetes/pki/apiserver-etcd-client.crt"); + let cp_crt_dst = tls_crt.to_string_lossy().into_owned(); + run("docker", &["cp", &cp_crt_src, &cp_crt_dst]) + .context("copying apiserver etcd client cert from container")?; + + let cp_key_src = format!("{container_name}:/etc/kubernetes/pki/apiserver-etcd-client.key"); + let cp_key_dst = tls_key.to_string_lossy().into_owned(); + run("docker", &["cp", &cp_key_src, &cp_key_dst]) + .context("copying apiserver etcd client key from container")?; + + let inspect_ip = run_stdout( + "docker", + &[ + "inspect", + "-f", + "{{.NetworkSettings.Networks.kind.IPAddress}}", + &container_name, + ], + ) + .context("inspecting control-plane container IP")?; + if inspect_ip.is_empty() { + bail!("control-plane container IP was empty"); + } + let endpoint = format!("https://{inspect_ip}:2379"); + eprintln!("Using etcd endpoint: {endpoint}"); + + pipe_cmd( + "cargo", + &["run", "-p", "etcdetcetc", "--", "crds"], + "kubectl", + &["apply", "-f", "-"], + ) + .context("installing CRDs")?; + + let ca_crt_str = ca_crt.to_string_lossy().into_owned(); + let tls_crt_str = tls_crt.to_string_lossy().into_owned(); + let tls_key_str = tls_key.to_string_lossy().into_owned(); + let from_ca = format!("ca.crt={ca_crt_str}"); + let from_crt = format!("tls.crt={tls_crt_str}"); + let from_key = format!("tls.key={tls_key_str}"); + pipe_cmd( + "kubectl", + &[ + "create", + "secret", + "generic", + "etcd-root", + "--namespace", + "default", + "--from-file", + &from_ca, + "--from-file", + &from_crt, + "--from-file", + &from_key, + "--dry-run=client", + "-o", + "json", + ], + "kubectl", + &["apply", "-f", "-"], + ) + .context("creating/updating etcd-root secret")?; + + let etcd_cluster_cr = format!( + r#"{{ + "apiVersion": "etcdetcetc.samcday.com/v1alpha1", + "kind": "EtcdCluster", + "metadata": {{"name": "dev", "namespace": "default"}}, + "spec": {{ + "endpoints": ["{endpoint}"], + "authSecretRef": {{"name": "etcd-root"}} + }} +}}"# + ); + run_with_stdin("kubectl", &["apply", "-f", "-"], etcd_cluster_cr.as_bytes()) + .context("creating/updating EtcdCluster dev")?; + + println!("export KUBECONFIG={}", shell_single_quote(&kubeconfig_str)); + eprintln!("dev-up complete"); + Ok(()) +} + +fn cmd_dev_down() -> Result<()> { + eprintln!("Starting dev-down..."); + + ensure_tool("kind")?; + + let clusters = + run_stdout("kind", &["get", "clusters"]).context("checking existing kind clusters")?; + let cluster_exists = clusters.lines().any(|line| line.trim() == CLUSTER_NAME); + if cluster_exists { + run("kind", &["delete", "cluster", "--name", CLUSTER_NAME]) + .context("deleting kind cluster")?; + } else { + eprintln!("Kind cluster '{CLUSTER_NAME}' does not exist, skipping delete"); + } + + let dev_dir = std::env::current_dir() + .context("getting current directory")? + .join(DEV_DIR); + if dev_dir.exists() { + eprintln!("+ rm -rf {}", dev_dir.display()); + fs::remove_dir_all(&dev_dir).with_context(|| format!("removing {}", dev_dir.display()))?; + } else { + eprintln!("{} does not exist, skipping", dev_dir.display()); + } + + eprintln!("dev-down complete"); + Ok(()) +} + +fn ensure_tool(name: &str) -> Result<()> { + let status = Command::new(name) + .arg("--help") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + match status { + Ok(_) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + bail!("required tool '{name}' not found on PATH") + } + Err(err) => Err(err).with_context(|| format!("checking tool '{name}' on PATH")), + } +} + +fn run(program: &str, args: &[&str]) -> Result<()> { + eprintln!("+ {}", format_command(program, args)); + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("spawning command: {}", format_command(program, args)))?; + + if !output.stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stdout)); + } + if !output.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + } + + if !output.status.success() { + bail!( + "command failed ({}): {}", + output + .status + .code() + .map_or_else(|| "signal".to_string(), |c| c.to_string()), + format_command(program, args) + ); + } + + Ok(()) +} + +fn run_stdout(program: &str, args: &[&str]) -> Result { + eprintln!("+ {}", format_command(program, args)); + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("spawning command: {}", format_command(program, args)))?; + + if !output.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + } + + if !output.status.success() { + if !output.stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stdout)); + } + bail!( + "command failed ({}): {}", + output + .status + .code() + .map_or_else(|| "signal".to_string(), |c| c.to_string()), + format_command(program, args) + ); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +fn pipe_cmd( + cmd1_program: &str, + cmd1_args: &[&str], + cmd2_program: &str, + cmd2_args: &[&str], +) -> Result<()> { + eprintln!( + "+ {} | {}", + format_command(cmd1_program, cmd1_args), + format_command(cmd2_program, cmd2_args) + ); + + let mut cmd1 = Command::new(cmd1_program) + .args(cmd1_args) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .with_context(|| { + format!( + "spawning command: {}", + format_command(cmd1_program, cmd1_args) + ) + })?; + + let cmd1_stdout = cmd1 + .stdout + .take() + .context("taking stdout for piped command")?; + + let cmd2_output = Command::new(cmd2_program) + .args(cmd2_args) + .stdin(Stdio::from(cmd1_stdout)) + .output() + .with_context(|| { + format!( + "spawning command: {}", + format_command(cmd2_program, cmd2_args) + ) + })?; + + let cmd1_status = cmd1.wait().context("waiting for first piped command")?; + + if !cmd2_output.stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(&cmd2_output.stdout)); + } + if !cmd2_output.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&cmd2_output.stderr)); + } + + if !cmd1_status.success() { + bail!( + "first piped command failed ({}): {}", + cmd1_status + .code() + .map_or_else(|| "signal".to_string(), |c| c.to_string()), + format_command(cmd1_program, cmd1_args) + ); + } + + if !cmd2_output.status.success() { + bail!( + "second piped command failed ({}): {}", + cmd2_output + .status + .code() + .map_or_else(|| "signal".to_string(), |c| c.to_string()), + format_command(cmd2_program, cmd2_args) + ); + } + + Ok(()) +} + +fn run_with_stdin(program: &str, args: &[&str], stdin_bytes: &[u8]) -> Result<()> { + eprintln!("+ {}", format_command(program, args)); + + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .with_context(|| format!("spawning command: {}", format_command(program, args)))?; + + { + let stdin = child.stdin.as_mut().context("opening command stdin")?; + stdin + .write_all(stdin_bytes) + .context("writing command stdin")?; + } + + let output = child.wait_with_output().context("waiting for command")?; + + if !output.stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stdout)); + } + + if !output.status.success() { + bail!( + "command failed ({}): {}", + output + .status + .code() + .map_or_else(|| "signal".to_string(), |c| c.to_string()), + format_command(program, args) + ); + } + + Ok(()) +} + +fn format_command(program: &str, args: &[&str]) -> String { + if args.is_empty() { + return program.to_owned(); + } + format!("{} {}", program, args.join(" ")) +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} From fee45a84d41bad1df44f4c0402621e95f2b10556 Mon Sep 17 00:00:00 2001 From: Sam Day Date: Mon, 30 Mar 2026 22:30:26 +1100 Subject: [PATCH 04/17] etcdetcetc: add ConfigMap split, watches, RBAC, and config mirroring EtcdCluster now produces a shared ConfigMap with comma-separated endpoints and ca.crt. EtcdTenant output Secret is credentials-only. Tenant controller watches owned Secrets and ConfigMaps for drift. New allowedNamespaces field on EtcdClusterSpec switches between cluster-wide and per-namespace K8s RBAC. Each tenant mirrors the cluster ConfigMap into its own namespace. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/dev/deploy.yaml | 5 +- apps/etcdetcetc/src/cluster.rs | 62 ++++- apps/etcdetcetc/src/crd.rs | 6 + apps/etcdetcetc/src/tenant.rs | 423 ++++++++++++++++++++++++++++++-- 4 files changed, 465 insertions(+), 31 deletions(-) diff --git a/apps/etcdetcetc/dev/deploy.yaml b/apps/etcdetcetc/dev/deploy.yaml index 6e52531b..7b4f522b 100644 --- a/apps/etcdetcetc/dev/deploy.yaml +++ b/apps/etcdetcetc/dev/deploy.yaml @@ -16,8 +16,11 @@ rules: resources: ["etcdclusters/status", "etcdtenants/status"] verbs: ["patch"] - apiGroups: [""] - resources: ["secrets"] + resources: ["secrets", "configmaps"] verbs: ["get", "list", "watch", "create", "patch"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"] + verbs: ["get", "list", "watch", "create", "patch", "delete"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/apps/etcdetcetc/src/cluster.rs b/apps/etcdetcetc/src/cluster.rs index 0f1f26c8..288666f4 100644 --- a/apps/etcdetcetc/src/cluster.rs +++ b/apps/etcdetcetc/src/cluster.rs @@ -1,7 +1,7 @@ //! EtcdCluster controller. use std::{ - collections::{HashMap, hash_map::DefaultHasher}, + collections::{BTreeMap, HashMap, hash_map::DefaultHasher}, hash::{Hash, Hasher}, sync::{Arc, RwLock as StdRwLock}, time::Duration, @@ -9,10 +9,10 @@ use std::{ use anyhow::anyhow; use futures::StreamExt; -use k8s_openapi::api::core::v1::Secret; +use k8s_openapi::api::core::v1::{ConfigMap, Secret}; use kube::{ Api, Client, Resource, ResourceExt, - api::{Patch, PatchParams}, + api::{ObjectMeta, Patch, PatchParams}, runtime::{Controller, controller::Action, reflector::ObjectRef, watcher}, }; use tokio::sync::RwLock; @@ -202,6 +202,8 @@ async fn reconcile( .unwrap_or_else(|p| p.into_inner()) .remove(&key); update_status(&cluster, &context.client, &namespace, &name, false).await?; + } else { + ensure_cluster_configmap(&cluster, &context.client, &namespace, &secret).await?; } } None => { @@ -273,6 +275,60 @@ async fn fetch_and_update_status( patch_status_if_changed(cluster, kube_client, namespace, name, desired).await } +async fn ensure_cluster_configmap( + cluster: &EtcdCluster, + client: &Client, + namespace: &str, + secret: &Secret, +) -> Result<(), ClusterError> { + let ca_crt = secret + .data + .as_ref() + .and_then(|data| data.get("ca.crt")) + .ok_or_else(|| { + ClusterError::Invalid(format!( + "auth secret {}/{} missing required key ca.crt", + namespace, cluster.spec.auth_secret_ref.name + )) + })?; + let ca_crt = String::from_utf8(ca_crt.0.clone()) + .map_err(|_| ClusterError::Invalid("ca.crt is not valid UTF-8".to_string()))?; + + let owner_reference = cluster.controller_owner_ref(&()).ok_or_else(|| { + ClusterError::Invalid(format!( + "{} cannot produce controller owner reference", + cluster.name_any() + )) + })?; + + let mut data = BTreeMap::new(); + data.insert("endpoints".to_string(), cluster.spec.endpoints.join(",")); + data.insert("ca.crt".to_string(), ca_crt); + + let configmap_name = format!("{}-etcd", cluster.name_any()); + let configmap = ConfigMap { + metadata: ObjectMeta { + name: Some(configmap_name.clone()), + namespace: Some(namespace.to_string()), + owner_references: Some(vec![owner_reference]), + ..ObjectMeta::default() + }, + data: Some(data), + ..ConfigMap::default() + }; + + let configmaps = Api::::namespaced(client.clone(), namespace); + configmaps + .patch( + &configmap_name, + &PatchParams::apply("etcdetcetc").force(), + &Patch::Apply(&configmap), + ) + .await?; + + Ok(()) +} + fn format_bytes(bytes: i64) -> String { let bytes_f = bytes as f64; if bytes_f >= 1_073_741_824.0 { diff --git a/apps/etcdetcetc/src/crd.rs b/apps/etcdetcetc/src/crd.rs index 578accc5..a066c671 100644 --- a/apps/etcdetcetc/src/crd.rs +++ b/apps/etcdetcetc/src/crd.rs @@ -145,6 +145,12 @@ pub struct EtcdClusterSpec { /// Reference to a Secret with root/admin credentials. pub auth_secret_ref: LocalSecretReference, + + /// Namespaces allowed to consume this cluster's tenants. + /// When empty, cluster-wide RBAC is used. When set, per-namespace + /// Role/RoleBindings are created for each listed namespace. + #[serde(default)] + pub allowed_namespaces: Vec, } #[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema, PartialEq)] diff --git a/apps/etcdetcetc/src/tenant.rs b/apps/etcdetcetc/src/tenant.rs index 843fe9b6..97e35732 100644 --- a/apps/etcdetcetc/src/tenant.rs +++ b/apps/etcdetcetc/src/tenant.rs @@ -1,12 +1,26 @@ //! EtcdTenant controller. -use std::{collections::BTreeMap, sync::Arc, time::Duration}; +use std::{collections::{BTreeMap, BTreeSet}, sync::Arc, time::Duration}; use futures::StreamExt; -use k8s_openapi::{ByteString, api::core::v1::Secret}; +use k8s_openapi::{ + ByteString, + api::{ + core::v1::{ConfigMap, Secret}, + rbac::v1::{ + ClusterRole, + ClusterRoleBinding, + PolicyRule, + Role, + RoleBinding, + RoleRef, + Subject, + }, + }, +}; use kube::{ Api, Client, Resource, ResourceExt, - api::{ObjectMeta, Patch, PatchParams, PostParams}, + api::{DeleteParams, ListParams, ObjectMeta, Patch, PatchParams, PostParams}, runtime::{ Controller, controller::Action, @@ -56,11 +70,15 @@ pub enum TenantError { /// Runs the EtcdTenant controller until the stream ends. pub async fn run(context: TenantContext) { let api = Api::::all(context.client.clone()); + let secret_api = Api::::all(context.client.clone()); + let configmap_api = Api::::all(context.client.clone()); let context = Arc::new(context); info!("starting EtcdTenant controller"); Controller::new(api, watcher::Config::default()) + .owns(secret_api, watcher::Config::default()) + .owns(configmap_api, watcher::Config::default()) .run(reconcile, error_policy, context) .for_each(|result| async move { match result { @@ -171,13 +189,27 @@ async fn reconcile(tenant: Arc, context: Arc) -> Resu ensure_output_secret( &context.client, &tenant, - &cluster, &namespace, &secret_name, &name, &password, ) .await?; + ensure_config_mirror( + &context.client, + &tenant, + &namespace, + &name, + ) + .await?; + ensure_k8s_rbac( + &context.client, + &cluster, + &namespace, + &name, + &secret_name, + ) + .await?; update_ready_status( &context.client, &tenant, @@ -248,6 +280,7 @@ async fn reconcile_delete( } } + cleanup_k8s_rbac(&context.client, namespace, name).await?; remove_finalizer(&context.client, &tenant, namespace).await?; Ok(Action::await_change()) } @@ -333,29 +366,11 @@ async fn ensure_tenant_rbac( async fn ensure_output_secret( client: &Client, tenant: &EtcdTenant, - cluster: &EtcdCluster, tenant_namespace: &str, secret_name: &str, tenant_name: &str, password: &str, ) -> Result<(), TenantError> { - let auth_secret_name = &cluster.spec.auth_secret_ref.name; - let cluster_ns = tenant.spec.cluster_ref.namespace.as_deref() - .unwrap_or(tenant_namespace); - let source_secrets = Api::::namespaced(client.clone(), cluster_ns); - let source_secret = source_secrets.get(auth_secret_name).await?; - let ca_crt = source_secret - .data - .as_ref() - .and_then(|data| data.get("ca.crt")) - .cloned() - .ok_or_else(|| { - TenantError::Invalid(format!( - "auth secret {}/{} missing required key ca.crt", - cluster_ns, cluster.spec.auth_secret_ref.name - )) - })?; - let owner_reference = tenant.controller_owner_ref(&()).ok_or_else(|| { TenantError::Invalid(format!( "{} cannot produce controller owner reference", @@ -372,11 +387,6 @@ async fn ensure_output_secret( "password".to_string(), ByteString(password.as_bytes().to_vec()), ); - data.insert( - "endpoints".to_string(), - ByteString(serde_json::to_vec(&cluster.spec.endpoints)?), - ); - data.insert("ca.crt".to_string(), ca_crt); let secret = Secret { metadata: ObjectMeta { @@ -402,6 +412,365 @@ async fn ensure_output_secret( Ok(()) } +async fn ensure_config_mirror( + client: &Client, + tenant: &EtcdTenant, + tenant_namespace: &str, + tenant_name: &str, +) -> Result<(), TenantError> { + let cluster_namespace = tenant.spec.cluster_ref.namespace.as_deref() + .unwrap_or(tenant_namespace); + let cluster_name = tenant.spec.cluster_ref.name.as_str(); + let source_name = format!("{cluster_name}-etcd"); + + let source_configmaps = Api::::namespaced(client.clone(), cluster_namespace); + let source = match source_configmaps.get(&source_name).await { + Ok(source) => source, + Err(kube::Error::Api(ae)) if ae.code == 404 => { + warn!( + tenant_namespace, + tenant_name, + cluster_namespace, + cluster_name, + source_configmap = source_name, + "source EtcdCluster ConfigMap not found yet, will retry on next reconcile" + ); + return Ok(()); + } + Err(err) => return Err(err.into()), + }; + + let owner_reference = tenant.controller_owner_ref(&()).ok_or_else(|| { + TenantError::Invalid(format!( + "{} cannot produce controller owner reference", + tenant.name_any() + )) + })?; + let target_name = format!("{tenant_name}-etcd"); + let target = ConfigMap { + metadata: ObjectMeta { + name: Some(target_name.clone()), + namespace: Some(tenant_namespace.to_string()), + owner_references: Some(vec![owner_reference]), + ..ObjectMeta::default() + }, + data: source.data, + ..ConfigMap::default() + }; + + let target_configmaps = Api::::namespaced(client.clone(), tenant_namespace); + target_configmaps + .patch( + &target_name, + &PatchParams::apply("etcdetcetc").force(), + &Patch::Apply(&target), + ) + .await?; + + Ok(()) +} + +async fn ensure_k8s_rbac( + client: &Client, + cluster: &EtcdCluster, + tenant_namespace: &str, + tenant_name: &str, + secret_name: &str, +) -> Result<(), TenantError> { + if cluster.spec.allowed_namespaces.is_empty() { + ensure_clusterwide_k8s_rbac(client, tenant_namespace, tenant_name, secret_name).await?; + cleanup_namespaced_k8s_rbac(client, tenant_namespace, tenant_name, &BTreeSet::new()).await?; + } else { + let desired_namespaces: BTreeSet = cluster + .spec + .allowed_namespaces + .iter() + .filter(|namespace| !namespace.is_empty()) + .cloned() + .collect(); + ensure_namespaced_k8s_rbac( + client, + tenant_namespace, + tenant_name, + secret_name, + &desired_namespaces, + ) + .await?; + cleanup_clusterwide_k8s_rbac(client, tenant_namespace, tenant_name).await?; + cleanup_namespaced_k8s_rbac(client, tenant_namespace, tenant_name, &desired_namespaces).await?; + } + + Ok(()) +} + +async fn ensure_clusterwide_k8s_rbac( + client: &Client, + tenant_namespace: &str, + tenant_name: &str, + secret_name: &str, +) -> Result<(), TenantError> { + let name = format!("etcdetcetc:{tenant_namespace}:{tenant_name}"); + let labels = tenant_rbac_labels(tenant_namespace, tenant_name); + + let cluster_role = ClusterRole { + metadata: ObjectMeta { + name: Some(name.clone()), + labels: Some(labels.clone()), + ..ObjectMeta::default() + }, + rules: Some(vec![PolicyRule { + api_groups: Some(vec!["".to_string()]), + resources: Some(vec!["secrets".to_string()]), + resource_names: Some(vec![secret_name.to_string()]), + verbs: vec!["get".to_string()], + ..PolicyRule::default() + }]), + ..ClusterRole::default() + }; + + let cluster_roles = Api::::all(client.clone()); + cluster_roles + .patch( + &name, + &PatchParams::apply("etcdetcetc").force(), + &Patch::Apply(&cluster_role), + ) + .await?; + + let cluster_role_binding = ClusterRoleBinding { + metadata: ObjectMeta { + name: Some(name.clone()), + labels: Some(labels), + ..ObjectMeta::default() + }, + role_ref: RoleRef { + api_group: "rbac.authorization.k8s.io".to_string(), + kind: "ClusterRole".to_string(), + name: name.clone(), + }, + subjects: Some(vec![Subject { + api_group: Some("rbac.authorization.k8s.io".to_string()), + kind: "Group".to_string(), + name: "system:serviceaccounts".to_string(), + namespace: None, + }]), + ..ClusterRoleBinding::default() + }; + + let cluster_role_bindings = Api::::all(client.clone()); + cluster_role_bindings + .patch( + &name, + &PatchParams::apply("etcdetcetc").force(), + &Patch::Apply(&cluster_role_binding), + ) + .await?; + + Ok(()) +} + +async fn ensure_namespaced_k8s_rbac( + client: &Client, + tenant_namespace: &str, + tenant_name: &str, + secret_name: &str, + desired_namespaces: &BTreeSet, +) -> Result<(), TenantError> { + let name = format!("etcdetcetc:{tenant_name}"); + + for namespace in desired_namespaces { + let mut labels = tenant_rbac_labels(tenant_namespace, tenant_name); + labels.insert("etcdetcetc.samcday.com/consumer-namespace".to_string(), namespace.clone()); + + let role = Role { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(namespace.clone()), + labels: Some(labels.clone()), + ..ObjectMeta::default() + }, + rules: Some(vec![PolicyRule { + api_groups: Some(vec!["".to_string()]), + resources: Some(vec!["secrets".to_string()]), + resource_names: Some(vec![secret_name.to_string()]), + verbs: vec!["get".to_string()], + ..PolicyRule::default() + }]), + ..Role::default() + }; + + let roles = Api::::namespaced(client.clone(), namespace); + roles + .patch( + &name, + &PatchParams::apply("etcdetcetc").force(), + &Patch::Apply(&role), + ) + .await?; + + let role_binding = RoleBinding { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(namespace.clone()), + labels: Some(labels), + ..ObjectMeta::default() + }, + role_ref: RoleRef { + api_group: "rbac.authorization.k8s.io".to_string(), + kind: "Role".to_string(), + name: name.clone(), + }, + subjects: Some(vec![Subject { + api_group: Some("rbac.authorization.k8s.io".to_string()), + kind: "Group".to_string(), + name: format!("system:serviceaccounts:{namespace}"), + namespace: None, + }]), + ..RoleBinding::default() + }; + + let role_bindings = Api::::namespaced(client.clone(), namespace); + role_bindings + .patch( + &name, + &PatchParams::apply("etcdetcetc").force(), + &Patch::Apply(&role_binding), + ) + .await?; + } + + Ok(()) +} + +async fn cleanup_clusterwide_k8s_rbac( + client: &Client, + tenant_namespace: &str, + tenant_name: &str, +) -> Result<(), TenantError> { + let name = format!("etcdetcetc:{tenant_namespace}:{tenant_name}"); + + let cluster_roles = Api::::all(client.clone()); + match cluster_roles.delete(&name, &DeleteParams::default()).await { + Ok(_) => {} + Err(kube::Error::Api(ae)) if ae.code == 404 => {} + Err(err) => return Err(err.into()), + } + + let cluster_role_bindings = Api::::all(client.clone()); + match cluster_role_bindings + .delete(&name, &DeleteParams::default()) + .await + { + Ok(_) => {} + Err(kube::Error::Api(ae)) if ae.code == 404 => {} + Err(err) => return Err(err.into()), + } + + Ok(()) +} + +async fn cleanup_namespaced_k8s_rbac( + client: &Client, + tenant_namespace: &str, + tenant_name: &str, + desired_namespaces: &BTreeSet, +) -> Result<(), TenantError> { + let name = format!("etcdetcetc:{tenant_name}"); + + let roles = Api::::all(client.clone()); + let existing_roles = roles.list(&ListParams::default()).await?; + for role in existing_roles.items { + if role.name_any() != name { + continue; + } + if !is_tenant_rbac_object(&role.metadata.labels, tenant_namespace, tenant_name) { + continue; + } + let Some(namespace) = role.namespace() else { + continue; + }; + if desired_namespaces.contains(&namespace) { + continue; + } + + let namespaced_roles = Api::::namespaced(client.clone(), &namespace); + match namespaced_roles.delete(&name, &DeleteParams::default()).await { + Ok(_) => {} + Err(kube::Error::Api(ae)) if ae.code == 404 => {} + Err(err) => return Err(err.into()), + } + } + + let role_bindings = Api::::all(client.clone()); + let existing_role_bindings = role_bindings.list(&ListParams::default()).await?; + for role_binding in existing_role_bindings.items { + if role_binding.name_any() != name { + continue; + } + if !is_tenant_rbac_object(&role_binding.metadata.labels, tenant_namespace, tenant_name) { + continue; + } + let Some(namespace) = role_binding.namespace() else { + continue; + }; + if desired_namespaces.contains(&namespace) { + continue; + } + + let namespaced_role_bindings = Api::::namespaced(client.clone(), &namespace); + match namespaced_role_bindings + .delete(&name, &DeleteParams::default()) + .await + { + Ok(_) => {} + Err(kube::Error::Api(ae)) if ae.code == 404 => {} + Err(err) => return Err(err.into()), + } + } + + Ok(()) +} + +async fn cleanup_k8s_rbac(client: &Client, tenant_namespace: &str, tenant_name: &str) -> Result<(), TenantError> { + cleanup_clusterwide_k8s_rbac(client, tenant_namespace, tenant_name).await?; + cleanup_namespaced_k8s_rbac(client, tenant_namespace, tenant_name, &BTreeSet::new()).await?; + Ok(()) +} + +fn tenant_rbac_labels(tenant_namespace: &str, tenant_name: &str) -> BTreeMap { + BTreeMap::from([ + ("app.kubernetes.io/managed-by".to_string(), "etcdetcetc".to_string()), + ( + "etcdetcetc.samcday.com/tenant-namespace".to_string(), + tenant_namespace.to_string(), + ), + ( + "etcdetcetc.samcday.com/tenant-name".to_string(), + tenant_name.to_string(), + ), + ]) +} + +fn is_tenant_rbac_object( + labels: &Option>, + tenant_namespace: &str, + tenant_name: &str, +) -> bool { + let Some(labels) = labels else { + return false; + }; + + labels + .get("app.kubernetes.io/managed-by") + .is_some_and(|value| value == "etcdetcetc") + && labels + .get("etcdetcetc.samcday.com/tenant-namespace") + .is_some_and(|value| value == tenant_namespace) + && labels + .get("etcdetcetc.samcday.com/tenant-name") + .is_some_and(|value| value == tenant_name) +} + async fn update_ready_status( client: &Client, tenant: &EtcdTenant, From 589bc7f8829119ed08ce6b138f95f5037f3e777f Mon Sep 17 00:00:00 2001 From: Sam Day Date: Tue, 31 Mar 2026 08:58:55 +1100 Subject: [PATCH 05/17] etcdetcetc: use chrono for timestamps, document password sync caveat Replace hand-rolled Gregorian date arithmetic in rfc3339_now() with chrono::Utc. Add TODO on user_change_password noting the best-effort semantics and intent to improve when non-mTLS support is solidified. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/Cargo.toml | 1 + apps/etcdetcetc/src/crd.rs | 25 +------------------------ apps/etcdetcetc/src/tenant.rs | 6 +++++- 3 files changed, 7 insertions(+), 25 deletions(-) diff --git a/apps/etcdetcetc/Cargo.toml b/apps/etcdetcetc/Cargo.toml index f109c2f3..dae89311 100644 --- a/apps/etcdetcetc/Cargo.toml +++ b/apps/etcdetcetc/Cargo.toml @@ -8,6 +8,7 @@ edition = "2024" [dependencies] anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } etcd-client = { version = "0.15", features = ["tls"] } futures = "0.3" k8s-openapi = { version = "0.24", features = ["latest"] } diff --git a/apps/etcdetcetc/src/crd.rs b/apps/etcdetcetc/src/crd.rs index a066c671..d0c6b71c 100644 --- a/apps/etcdetcetc/src/crd.rs +++ b/apps/etcdetcetc/src/crd.rs @@ -72,30 +72,7 @@ pub fn ready_condition_with_existing( } fn rfc3339_now() -> String { - let dur = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - let secs = dur.as_secs(); - let days = secs / 86400; - let time_secs = secs % 86400; - let hours = time_secs / 3600; - let minutes = (time_secs % 3600) / 60; - let seconds = time_secs % 60; - - // Days since epoch to Y-M-D (civil calendar) - // Algorithm from http://howardhinnant.github.io/date_algorithms.html - let z = days as i64 + 719468; - let era = z / 146097; - let doe = z - era * 146097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - - format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z") + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() } // --------------------------------------------------------------------------- diff --git a/apps/etcdetcetc/src/tenant.rs b/apps/etcdetcetc/src/tenant.rs index 97e35732..e53983bf 100644 --- a/apps/etcdetcetc/src/tenant.rs +++ b/apps/etcdetcetc/src/tenant.rs @@ -312,8 +312,12 @@ async fn ensure_tenant_rbac( } } + // TODO: this is best-effort — for mTLS clusters the password is irrelevant and this + // call may fail harmlessly. For non-mTLS (basic auth) clusters a failure here means + // the output Secret password diverges from etcd. We need proper handling once we + // solidify non-mTLS support (detect auth mode, propagate error for basic-auth clusters). if let Err(err) = auth.user_change_password(name, password).await { - warn!(name, error = %err, "failed to sync etcd user password (may be unchanged)"); + warn!(name, error = %err, "failed to sync etcd user password"); } if let Err(err) = auth.role_get(name).await { From 7c9efeb89823c288482bb8443a7f664a59b42a2a Mon Sep 17 00:00:00 2001 From: Sam Day Date: Tue, 31 Mar 2026 20:36:45 +1100 Subject: [PATCH 06/17] etcdetcetc: fix tenant controller review items - ensure_config_mirror: propagate not-ready when source ConfigMap is missing instead of silently marking tenant ready - update_ready_status: skip no-op status patches when condition is unchanged, matching the cluster controller's behavior - ensure_password_secret: use server-side apply instead of bare create to avoid 409 race on rapid reconciles - Add comment explaining cluster-wide RBAC subject intent Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/Cargo.lock | 117 ++++++++++++++++++++++++++++++---- apps/etcdetcetc/src/tenant.rs | 37 +++++++++-- 2 files changed, 134 insertions(+), 20 deletions(-) diff --git a/apps/etcdetcetc/Cargo.lock b/apps/etcdetcetc/Cargo.lock index 0842bf50..14de4576 100644 --- a/apps/etcdetcetc/Cargo.lock +++ b/apps/etcdetcetc/Cargo.lock @@ -30,6 +30,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -256,8 +265,10 @@ version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ + "iana-time-zone", "num-traits", "serde", + "windows-link", ] [[package]] @@ -492,6 +503,7 @@ name = "etcdetcetc" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "clap", "etcd-client", "futures", @@ -944,6 +956,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1001,9 +1037,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ "once_cell", "wasm-bindgen", @@ -2391,9 +2427,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" dependencies = [ "cfg-if", "once_cell", @@ -2404,9 +2440,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2414,9 +2450,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" dependencies = [ "bumpalo", "proc-macro2", @@ -2427,9 +2463,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" dependencies = [ "unicode-ident", ] @@ -2468,12 +2504,65 @@ dependencies = [ "semver", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2654,18 +2743,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/apps/etcdetcetc/src/tenant.rs b/apps/etcdetcetc/src/tenant.rs index e53983bf..f53575cc 100644 --- a/apps/etcdetcetc/src/tenant.rs +++ b/apps/etcdetcetc/src/tenant.rs @@ -20,7 +20,7 @@ use k8s_openapi::{ }; use kube::{ Api, Client, Resource, ResourceExt, - api::{DeleteParams, ListParams, ObjectMeta, Patch, PatchParams, PostParams}, + api::{DeleteParams, ListParams, ObjectMeta, Patch, PatchParams}, runtime::{ Controller, controller::Action, @@ -195,13 +195,26 @@ async fn reconcile(tenant: Arc, context: Arc) -> Resu &password, ) .await?; - ensure_config_mirror( + let config_mirror_ready = ensure_config_mirror( &context.client, &tenant, &namespace, &name, ) .await?; + if !config_mirror_ready { + update_ready_status( + &context.client, + &tenant, + &namespace, + &name, + false, + "ConfigMapNotFound", + "source EtcdCluster ConfigMap not found yet", + ) + .await?; + return Ok(Action::requeue(Duration::from_secs(30))); + } ensure_k8s_rbac( &context.client, &cluster, @@ -421,7 +434,7 @@ async fn ensure_config_mirror( tenant: &EtcdTenant, tenant_namespace: &str, tenant_name: &str, -) -> Result<(), TenantError> { +) -> Result { let cluster_namespace = tenant.spec.cluster_ref.namespace.as_deref() .unwrap_or(tenant_namespace); let cluster_name = tenant.spec.cluster_ref.name.as_str(); @@ -439,7 +452,7 @@ async fn ensure_config_mirror( source_configmap = source_name, "source EtcdCluster ConfigMap not found yet, will retry on next reconcile" ); - return Ok(()); + return Ok(false); } Err(err) => return Err(err.into()), }; @@ -471,7 +484,7 @@ async fn ensure_config_mirror( ) .await?; - Ok(()) + Ok(true) } async fn ensure_k8s_rbac( @@ -552,6 +565,9 @@ async fn ensure_clusterwide_k8s_rbac( kind: "ClusterRole".to_string(), name: name.clone(), }, + // When allowedNamespaces is empty, grant access to all service accounts + // cluster-wide. Set allowedNamespaces on the EtcdCluster to restrict + // access to specific namespaces instead. subjects: Some(vec![Subject { api_group: Some("rbac.authorization.k8s.io".to_string()), kind: "Group".to_string(), @@ -797,6 +813,9 @@ async fn update_ready_status( message, existing_conditions, ); + if existing_conditions.len() == 1 && existing_conditions[0] == condition { + return Ok(()); + } let patch = serde_json::json!({ "status": { "conditions": [condition], @@ -930,7 +949,13 @@ async fn ensure_password_secret( ..Secret::default() }; - secrets.create(&PostParams::default(), &secret).await?; + secrets + .patch( + &password_secret_name, + &PatchParams::apply("etcdetcetc").force(), + &Patch::Apply(&secret), + ) + .await?; Ok(password) } Err(err) => Err(err.into()), From 457c5e09147c277f353b2f8d4aba0eca2ff80fae Mon Sep 17 00:00:00 2001 From: Sam Day Date: Tue, 31 Mar 2026 22:27:56 +1100 Subject: [PATCH 07/17] etcdetcetc: address Copilot review feedback - Replace cluster-wide ClusterRole+ClusterRoleBinding with namespace- scoped Role+RoleBinding to prevent cross-namespace secret exposure - Fix namespaced RBAC naming to include tenant namespace, preventing collisions between same-named tenants in different namespaces - Update design doc and README to reflect current implementation (password auth, ConfigMap outputs) - Fix is_not_found_error comment accuracy Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/README.md | 3 +- apps/etcdetcetc/doc/design.md | 40 ++++++--- apps/etcdetcetc/src/tenant.rs | 159 ++++++++-------------------------- 3 files changed, 66 insertions(+), 136 deletions(-) diff --git a/apps/etcdetcetc/README.md b/apps/etcdetcetc/README.md index e189a755..69e77c7f 100644 --- a/apps/etcdetcetc/README.md +++ b/apps/etcdetcetc/README.md @@ -47,7 +47,7 @@ spec: ``` The controller creates the etcd user, role, and prefix permissions, then emits -a Secret with connection details. On deletion, the keyspace is purged and RBAC +a Secret with credentials and a ConfigMap with connection info. On deletion, the keyspace is purged and RBAC entities are removed. Defaults: @@ -56,6 +56,5 @@ Defaults: ## Future -- Static username/password auth for tenants (not just cert-based identity) - Multiple EtcdCluster support with pluggable tenant scheduling - Tenant migration between clusters diff --git a/apps/etcdetcetc/doc/design.md b/apps/etcdetcetc/doc/design.md index 5273acba..e7148c18 100644 --- a/apps/etcdetcetc/doc/design.md +++ b/apps/etcdetcetc/doc/design.md @@ -20,7 +20,6 @@ means its keyspace rots in etcd forever. - Managing etcd cluster deployment or lifecycle. - Issuing or rotating TLS certificates (that's cert-manager's job). -- Password-based tenant auth (future). - Multi-cluster scheduling or migration (future -- but the CRD shape accommodates it). @@ -113,7 +112,7 @@ Watches: EtcdCluster, referenced Secrets (for credential rotation). 1. Resolve the referenced EtcdCluster. If not connected, requeue. 2. Compute effective prefix (`spec.prefix` or `//`). -3. Ensure etcd user exists (no password -- cert CN auth for v1alpha1). +3. Ensure etcd user exists with a generated password. 4. Ensure etcd role exists (named same as the user). 5. Ensure role has `readwrite` permission on the prefix. 6. Ensure role is granted to the user. @@ -146,21 +145,38 @@ metadata: type: Opaque data: username: - endpoints: - ca.crt: + password: ``` -Downstream consumers (e.g. the k8s-control-plane Helm chart) mount this Secret -to configure their etcd connection. Client certs are handled separately by -cert-manager -- this controller doesn't touch PKI. +### Output ConfigMap -## Future roadmap +Mirrored from the EtcdCluster's ConfigMap into the tenant's namespace, owned +by the EtcdTenant. + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: -etcd + namespace: + ownerReferences: + - apiVersion: etcdetcetc.samcday.com/v1alpha1 + kind: EtcdTenant + name: + controller: true +data: + endpoints: "https://host1:2379,https://host2:2379" + ca.crt: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- +``` -### Password-based tenant auth +Downstream consumers mount the Secret for credentials and the ConfigMap for +connection info. Client certs are handled separately by cert-manager -- this +controller doesn't touch PKI. -Add a `spec.authMode: password` field to EtcdTenant. The controller generates a -random password, creates the etcd user with it, and includes `password` in the -output Secret. This enables tenants that don't use cert-based identity. +## Future roadmap ### Multiple EtcdCluster support diff --git a/apps/etcdetcetc/src/tenant.rs b/apps/etcdetcetc/src/tenant.rs index f53575cc..949be418 100644 --- a/apps/etcdetcetc/src/tenant.rs +++ b/apps/etcdetcetc/src/tenant.rs @@ -8,8 +8,6 @@ use k8s_openapi::{ api::{ core::v1::{ConfigMap, Secret}, rbac::v1::{ - ClusterRole, - ClusterRoleBinding, PolicyRule, Role, RoleBinding, @@ -494,97 +492,32 @@ async fn ensure_k8s_rbac( tenant_name: &str, secret_name: &str, ) -> Result<(), TenantError> { - if cluster.spec.allowed_namespaces.is_empty() { - ensure_clusterwide_k8s_rbac(client, tenant_namespace, tenant_name, secret_name).await?; - cleanup_namespaced_k8s_rbac(client, tenant_namespace, tenant_name, &BTreeSet::new()).await?; + let (desired_namespaces, all_service_accounts) = if cluster.spec.allowed_namespaces.is_empty() { + // Unrestricted: RBAC in tenant namespace only, granting all service accounts + // cluster-wide. Scoped to the tenant namespace so same-named Secrets in other + // namespaces are not exposed. + (BTreeSet::from([tenant_namespace.to_string()]), true) } else { - let desired_namespaces: BTreeSet = cluster + let ns: BTreeSet = cluster .spec .allowed_namespaces .iter() - .filter(|namespace| !namespace.is_empty()) + .filter(|ns| !ns.is_empty()) .cloned() .collect(); - ensure_namespaced_k8s_rbac( - client, - tenant_namespace, - tenant_name, - secret_name, - &desired_namespaces, - ) - .await?; - cleanup_clusterwide_k8s_rbac(client, tenant_namespace, tenant_name).await?; - cleanup_namespaced_k8s_rbac(client, tenant_namespace, tenant_name, &desired_namespaces).await?; - } - - Ok(()) -} - -async fn ensure_clusterwide_k8s_rbac( - client: &Client, - tenant_namespace: &str, - tenant_name: &str, - secret_name: &str, -) -> Result<(), TenantError> { - let name = format!("etcdetcetc:{tenant_namespace}:{tenant_name}"); - let labels = tenant_rbac_labels(tenant_namespace, tenant_name); - - let cluster_role = ClusterRole { - metadata: ObjectMeta { - name: Some(name.clone()), - labels: Some(labels.clone()), - ..ObjectMeta::default() - }, - rules: Some(vec![PolicyRule { - api_groups: Some(vec!["".to_string()]), - resources: Some(vec!["secrets".to_string()]), - resource_names: Some(vec![secret_name.to_string()]), - verbs: vec!["get".to_string()], - ..PolicyRule::default() - }]), - ..ClusterRole::default() - }; - - let cluster_roles = Api::::all(client.clone()); - cluster_roles - .patch( - &name, - &PatchParams::apply("etcdetcetc").force(), - &Patch::Apply(&cluster_role), - ) - .await?; - - let cluster_role_binding = ClusterRoleBinding { - metadata: ObjectMeta { - name: Some(name.clone()), - labels: Some(labels), - ..ObjectMeta::default() - }, - role_ref: RoleRef { - api_group: "rbac.authorization.k8s.io".to_string(), - kind: "ClusterRole".to_string(), - name: name.clone(), - }, - // When allowedNamespaces is empty, grant access to all service accounts - // cluster-wide. Set allowedNamespaces on the EtcdCluster to restrict - // access to specific namespaces instead. - subjects: Some(vec![Subject { - api_group: Some("rbac.authorization.k8s.io".to_string()), - kind: "Group".to_string(), - name: "system:serviceaccounts".to_string(), - namespace: None, - }]), - ..ClusterRoleBinding::default() + (ns, false) }; - let cluster_role_bindings = Api::::all(client.clone()); - cluster_role_bindings - .patch( - &name, - &PatchParams::apply("etcdetcetc").force(), - &Patch::Apply(&cluster_role_binding), - ) - .await?; + ensure_namespaced_k8s_rbac( + client, + tenant_namespace, + tenant_name, + secret_name, + &desired_namespaces, + all_service_accounts, + ) + .await?; + cleanup_namespaced_k8s_rbac(client, tenant_namespace, tenant_name, &desired_namespaces).await?; Ok(()) } @@ -595,8 +528,9 @@ async fn ensure_namespaced_k8s_rbac( tenant_name: &str, secret_name: &str, desired_namespaces: &BTreeSet, + all_service_accounts: bool, ) -> Result<(), TenantError> { - let name = format!("etcdetcetc:{tenant_name}"); + let name = format!("etcdetcetc:{tenant_namespace}:{tenant_name}"); for namespace in desired_namespaces { let mut labels = tenant_rbac_labels(tenant_namespace, tenant_name); @@ -640,11 +574,20 @@ async fn ensure_namespaced_k8s_rbac( kind: "Role".to_string(), name: name.clone(), }, - subjects: Some(vec![Subject { - api_group: Some("rbac.authorization.k8s.io".to_string()), - kind: "Group".to_string(), - name: format!("system:serviceaccounts:{namespace}"), - namespace: None, + subjects: Some(vec![if all_service_accounts { + Subject { + api_group: Some("rbac.authorization.k8s.io".to_string()), + kind: "Group".to_string(), + name: "system:serviceaccounts".to_string(), + namespace: None, + } + } else { + Subject { + api_group: Some("rbac.authorization.k8s.io".to_string()), + kind: "Group".to_string(), + name: format!("system:serviceaccounts:{namespace}"), + namespace: None, + } }]), ..RoleBinding::default() }; @@ -662,40 +605,13 @@ async fn ensure_namespaced_k8s_rbac( Ok(()) } -async fn cleanup_clusterwide_k8s_rbac( - client: &Client, - tenant_namespace: &str, - tenant_name: &str, -) -> Result<(), TenantError> { - let name = format!("etcdetcetc:{tenant_namespace}:{tenant_name}"); - - let cluster_roles = Api::::all(client.clone()); - match cluster_roles.delete(&name, &DeleteParams::default()).await { - Ok(_) => {} - Err(kube::Error::Api(ae)) if ae.code == 404 => {} - Err(err) => return Err(err.into()), - } - - let cluster_role_bindings = Api::::all(client.clone()); - match cluster_role_bindings - .delete(&name, &DeleteParams::default()) - .await - { - Ok(_) => {} - Err(kube::Error::Api(ae)) if ae.code == 404 => {} - Err(err) => return Err(err.into()), - } - - Ok(()) -} - async fn cleanup_namespaced_k8s_rbac( client: &Client, tenant_namespace: &str, tenant_name: &str, desired_namespaces: &BTreeSet, ) -> Result<(), TenantError> { - let name = format!("etcdetcetc:{tenant_name}"); + let name = format!("etcdetcetc:{tenant_namespace}:{tenant_name}"); let roles = Api::::all(client.clone()); let existing_roles = roles.list(&ListParams::default()).await?; @@ -752,7 +668,6 @@ async fn cleanup_namespaced_k8s_rbac( } async fn cleanup_k8s_rbac(client: &Client, tenant_namespace: &str, tenant_name: &str) -> Result<(), TenantError> { - cleanup_clusterwide_k8s_rbac(client, tenant_namespace, tenant_name).await?; cleanup_namespaced_k8s_rbac(client, tenant_namespace, tenant_name, &BTreeSet::new()).await?; Ok(()) } @@ -974,8 +889,8 @@ fn is_not_found_error(error: &etcd_client::Error) -> bool { match error { etcd_client::Error::GRpcStatus(status) => { // NOT_FOUND (5) is the standard code for missing keys. - // FAILED_PRECONDITION (9) is what etcd returns for missing auth entities - // (users, roles) — or when auth is not enabled. + // etcd may also use FAILED_PRECONDITION (9) for missing auth entities + // (users, roles); we only treat it as "not found" when the message says so. let code = status.code() as i32; code == 5 || (code == 9 && status.message().to_ascii_lowercase().contains("not found")) } From 44a2d6dcfed67b645eb42781f1e7114c8d40d4dc Mon Sep 17 00:00:00 2001 From: Sam Day Date: Tue, 31 Mar 2026 22:56:24 +1100 Subject: [PATCH 08/17] etcdetcetc: remove K8s RBAC reconciliation from tenant controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RBAC approach was fundamentally wrong — creating Roles in consumer namespaces grants access to secrets in those namespaces, not in the tenant namespace where the secret actually lives. For the primary use case (pod volume mounts), no cross-namespace RBAC is needed since pods mount secrets from their own namespace. Consumer Helm charts should handle their own RBAC as cheap YAML rather than controller machinery. Removes ensure_k8s_rbac, ensure_namespaced_k8s_rbac, cleanup_namespaced_k8s_rbac, cleanup_k8s_rbac, tenant_rbac_labels, is_tenant_rbac_object, and the allowedNamespaces CRD field. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/README.md | 4 +- apps/etcdetcetc/src/crd.rs | 6 - apps/etcdetcetc/src/tenant.rs | 266 +--------------------------------- 3 files changed, 4 insertions(+), 272 deletions(-) diff --git a/apps/etcdetcetc/README.md b/apps/etcdetcetc/README.md index 69e77c7f..419b5673 100644 --- a/apps/etcdetcetc/README.md +++ b/apps/etcdetcetc/README.md @@ -4,7 +4,7 @@ A Kubernetes controller that manages multi-tenant etcd access. Automates the lifecycle of etcd users, roles, and prefix-scoped permissions -- the tedious -RBAC plumbing that makes shared etcd clusters actually work. +access-control plumbing that makes shared etcd clusters actually work. ## CRDs @@ -47,7 +47,7 @@ spec: ``` The controller creates the etcd user, role, and prefix permissions, then emits -a Secret with credentials and a ConfigMap with connection info. On deletion, the keyspace is purged and RBAC +a Secret with credentials and a ConfigMap with connection info. On deletion, the keyspace is purged and access-control entities are removed. Defaults: diff --git a/apps/etcdetcetc/src/crd.rs b/apps/etcdetcetc/src/crd.rs index d0c6b71c..22a5793a 100644 --- a/apps/etcdetcetc/src/crd.rs +++ b/apps/etcdetcetc/src/crd.rs @@ -122,12 +122,6 @@ pub struct EtcdClusterSpec { /// Reference to a Secret with root/admin credentials. pub auth_secret_ref: LocalSecretReference, - - /// Namespaces allowed to consume this cluster's tenants. - /// When empty, cluster-wide RBAC is used. When set, per-namespace - /// Role/RoleBindings are created for each listed namespace. - #[serde(default)] - pub allowed_namespaces: Vec, } #[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema, PartialEq)] diff --git a/apps/etcdetcetc/src/tenant.rs b/apps/etcdetcetc/src/tenant.rs index 949be418..c92243d3 100644 --- a/apps/etcdetcetc/src/tenant.rs +++ b/apps/etcdetcetc/src/tenant.rs @@ -1,24 +1,17 @@ //! EtcdTenant controller. -use std::{collections::{BTreeMap, BTreeSet}, sync::Arc, time::Duration}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; use futures::StreamExt; use k8s_openapi::{ ByteString, api::{ core::v1::{ConfigMap, Secret}, - rbac::v1::{ - PolicyRule, - Role, - RoleBinding, - RoleRef, - Subject, - }, }, }; use kube::{ Api, Client, Resource, ResourceExt, - api::{DeleteParams, ListParams, ObjectMeta, Patch, PatchParams}, + api::{ObjectMeta, Patch, PatchParams}, runtime::{ Controller, controller::Action, @@ -125,31 +118,6 @@ async fn reconcile(tenant: Arc, context: Arc) -> Resu let cluster_namespace = tenant.spec.cluster_ref.namespace.clone().unwrap_or_else(|| namespace.clone()); let cluster_name = tenant.spec.cluster_ref.name.clone(); - let clusters = Api::::namespaced(context.client.clone(), &cluster_namespace); - let cluster = match clusters.get(&cluster_name).await { - Ok(cluster) => cluster, - Err(kube::Error::Api(ae)) if ae.code == 404 => { - warn!( - tenant_namespace = namespace, - tenant_name = name, - cluster_namespace, - cluster_name, - "referenced EtcdCluster not found, requeueing" - ); - update_ready_status( - &context.client, - &tenant, - &namespace, - &name, - false, - "ClusterNotFound", - "referenced EtcdCluster not found", - ) - .await?; - return Ok(Action::requeue(Duration::from_secs(30))); - } - Err(err) => return Err(err.into()), - }; let mut etcd_client = match get_cluster_client(&context.clients, &cluster_namespace, &cluster_name).await { Some(client) => client, @@ -213,14 +181,6 @@ async fn reconcile(tenant: Arc, context: Arc) -> Resu .await?; return Ok(Action::requeue(Duration::from_secs(30))); } - ensure_k8s_rbac( - &context.client, - &cluster, - &namespace, - &name, - &secret_name, - ) - .await?; update_ready_status( &context.client, &tenant, @@ -291,7 +251,6 @@ async fn reconcile_delete( } } - cleanup_k8s_rbac(&context.client, namespace, name).await?; remove_finalizer(&context.client, &tenant, namespace).await?; Ok(Action::await_change()) } @@ -485,227 +444,6 @@ async fn ensure_config_mirror( Ok(true) } -async fn ensure_k8s_rbac( - client: &Client, - cluster: &EtcdCluster, - tenant_namespace: &str, - tenant_name: &str, - secret_name: &str, -) -> Result<(), TenantError> { - let (desired_namespaces, all_service_accounts) = if cluster.spec.allowed_namespaces.is_empty() { - // Unrestricted: RBAC in tenant namespace only, granting all service accounts - // cluster-wide. Scoped to the tenant namespace so same-named Secrets in other - // namespaces are not exposed. - (BTreeSet::from([tenant_namespace.to_string()]), true) - } else { - let ns: BTreeSet = cluster - .spec - .allowed_namespaces - .iter() - .filter(|ns| !ns.is_empty()) - .cloned() - .collect(); - (ns, false) - }; - - ensure_namespaced_k8s_rbac( - client, - tenant_namespace, - tenant_name, - secret_name, - &desired_namespaces, - all_service_accounts, - ) - .await?; - cleanup_namespaced_k8s_rbac(client, tenant_namespace, tenant_name, &desired_namespaces).await?; - - Ok(()) -} - -async fn ensure_namespaced_k8s_rbac( - client: &Client, - tenant_namespace: &str, - tenant_name: &str, - secret_name: &str, - desired_namespaces: &BTreeSet, - all_service_accounts: bool, -) -> Result<(), TenantError> { - let name = format!("etcdetcetc:{tenant_namespace}:{tenant_name}"); - - for namespace in desired_namespaces { - let mut labels = tenant_rbac_labels(tenant_namespace, tenant_name); - labels.insert("etcdetcetc.samcday.com/consumer-namespace".to_string(), namespace.clone()); - - let role = Role { - metadata: ObjectMeta { - name: Some(name.clone()), - namespace: Some(namespace.clone()), - labels: Some(labels.clone()), - ..ObjectMeta::default() - }, - rules: Some(vec![PolicyRule { - api_groups: Some(vec!["".to_string()]), - resources: Some(vec!["secrets".to_string()]), - resource_names: Some(vec![secret_name.to_string()]), - verbs: vec!["get".to_string()], - ..PolicyRule::default() - }]), - ..Role::default() - }; - - let roles = Api::::namespaced(client.clone(), namespace); - roles - .patch( - &name, - &PatchParams::apply("etcdetcetc").force(), - &Patch::Apply(&role), - ) - .await?; - - let role_binding = RoleBinding { - metadata: ObjectMeta { - name: Some(name.clone()), - namespace: Some(namespace.clone()), - labels: Some(labels), - ..ObjectMeta::default() - }, - role_ref: RoleRef { - api_group: "rbac.authorization.k8s.io".to_string(), - kind: "Role".to_string(), - name: name.clone(), - }, - subjects: Some(vec![if all_service_accounts { - Subject { - api_group: Some("rbac.authorization.k8s.io".to_string()), - kind: "Group".to_string(), - name: "system:serviceaccounts".to_string(), - namespace: None, - } - } else { - Subject { - api_group: Some("rbac.authorization.k8s.io".to_string()), - kind: "Group".to_string(), - name: format!("system:serviceaccounts:{namespace}"), - namespace: None, - } - }]), - ..RoleBinding::default() - }; - - let role_bindings = Api::::namespaced(client.clone(), namespace); - role_bindings - .patch( - &name, - &PatchParams::apply("etcdetcetc").force(), - &Patch::Apply(&role_binding), - ) - .await?; - } - - Ok(()) -} - -async fn cleanup_namespaced_k8s_rbac( - client: &Client, - tenant_namespace: &str, - tenant_name: &str, - desired_namespaces: &BTreeSet, -) -> Result<(), TenantError> { - let name = format!("etcdetcetc:{tenant_namespace}:{tenant_name}"); - - let roles = Api::::all(client.clone()); - let existing_roles = roles.list(&ListParams::default()).await?; - for role in existing_roles.items { - if role.name_any() != name { - continue; - } - if !is_tenant_rbac_object(&role.metadata.labels, tenant_namespace, tenant_name) { - continue; - } - let Some(namespace) = role.namespace() else { - continue; - }; - if desired_namespaces.contains(&namespace) { - continue; - } - - let namespaced_roles = Api::::namespaced(client.clone(), &namespace); - match namespaced_roles.delete(&name, &DeleteParams::default()).await { - Ok(_) => {} - Err(kube::Error::Api(ae)) if ae.code == 404 => {} - Err(err) => return Err(err.into()), - } - } - - let role_bindings = Api::::all(client.clone()); - let existing_role_bindings = role_bindings.list(&ListParams::default()).await?; - for role_binding in existing_role_bindings.items { - if role_binding.name_any() != name { - continue; - } - if !is_tenant_rbac_object(&role_binding.metadata.labels, tenant_namespace, tenant_name) { - continue; - } - let Some(namespace) = role_binding.namespace() else { - continue; - }; - if desired_namespaces.contains(&namespace) { - continue; - } - - let namespaced_role_bindings = Api::::namespaced(client.clone(), &namespace); - match namespaced_role_bindings - .delete(&name, &DeleteParams::default()) - .await - { - Ok(_) => {} - Err(kube::Error::Api(ae)) if ae.code == 404 => {} - Err(err) => return Err(err.into()), - } - } - - Ok(()) -} - -async fn cleanup_k8s_rbac(client: &Client, tenant_namespace: &str, tenant_name: &str) -> Result<(), TenantError> { - cleanup_namespaced_k8s_rbac(client, tenant_namespace, tenant_name, &BTreeSet::new()).await?; - Ok(()) -} - -fn tenant_rbac_labels(tenant_namespace: &str, tenant_name: &str) -> BTreeMap { - BTreeMap::from([ - ("app.kubernetes.io/managed-by".to_string(), "etcdetcetc".to_string()), - ( - "etcdetcetc.samcday.com/tenant-namespace".to_string(), - tenant_namespace.to_string(), - ), - ( - "etcdetcetc.samcday.com/tenant-name".to_string(), - tenant_name.to_string(), - ), - ]) -} - -fn is_tenant_rbac_object( - labels: &Option>, - tenant_namespace: &str, - tenant_name: &str, -) -> bool { - let Some(labels) = labels else { - return false; - }; - - labels - .get("app.kubernetes.io/managed-by") - .is_some_and(|value| value == "etcdetcetc") - && labels - .get("etcdetcetc.samcday.com/tenant-namespace") - .is_some_and(|value| value == tenant_namespace) - && labels - .get("etcdetcetc.samcday.com/tenant-name") - .is_some_and(|value| value == tenant_name) -} - async fn update_ready_status( client: &Client, tenant: &EtcdTenant, From e5e0a182090375a7db35710518b45027d8567ee0 Mon Sep 17 00:00:00 2001 From: Sam Day Date: Sat, 4 Apr 2026 13:13:37 +1000 Subject: [PATCH 09/17] etcdetcetc: address second round of Copilot review feedback Fix stale secret_refs index entries on EtcdCluster deletion, switch finalizer add/remove to JSON patch to avoid clobbering concurrent finalizers, restrict tls.key permissions in xtask dev-up, remove unused RBAC rules from dev deploy, and update design doc status references. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/Cargo.lock | 1 + apps/etcdetcetc/Cargo.toml | 1 + apps/etcdetcetc/dev/deploy.yaml | 3 --- apps/etcdetcetc/doc/design.md | 12 ++++++--- apps/etcdetcetc/src/cluster.rs | 10 ++++++++ apps/etcdetcetc/src/tenant.rs | 41 ++++++++++++++++++------------- apps/etcdetcetc/xtask/src/main.rs | 7 ++++++ 7 files changed, 51 insertions(+), 24 deletions(-) diff --git a/apps/etcdetcetc/Cargo.lock b/apps/etcdetcetc/Cargo.lock index 14de4576..90aeb285 100644 --- a/apps/etcdetcetc/Cargo.lock +++ b/apps/etcdetcetc/Cargo.lock @@ -507,6 +507,7 @@ dependencies = [ "clap", "etcd-client", "futures", + "json-patch", "k8s-openapi", "kube", "rand", diff --git a/apps/etcdetcetc/Cargo.toml b/apps/etcdetcetc/Cargo.toml index dae89311..fe9bd048 100644 --- a/apps/etcdetcetc/Cargo.toml +++ b/apps/etcdetcetc/Cargo.toml @@ -11,6 +11,7 @@ anyhow = "1" chrono = { version = "0.4", default-features = false, features = ["clock"] } etcd-client = { version = "0.15", features = ["tls"] } futures = "0.3" +json-patch = "4" k8s-openapi = { version = "0.24", features = ["latest"] } kube = { version = "0.99", features = ["runtime", "derive", "client"] } schemars = "0.8" diff --git a/apps/etcdetcetc/dev/deploy.yaml b/apps/etcdetcetc/dev/deploy.yaml index 7b4f522b..dbd5d12d 100644 --- a/apps/etcdetcetc/dev/deploy.yaml +++ b/apps/etcdetcetc/dev/deploy.yaml @@ -18,9 +18,6 @@ rules: - apiGroups: [""] resources: ["secrets", "configmaps"] verbs: ["get", "list", "watch", "create", "patch"] - - apiGroups: ["rbac.authorization.k8s.io"] - resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"] - verbs: ["get", "list", "watch", "create", "patch", "delete"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/apps/etcdetcetc/doc/design.md b/apps/etcdetcetc/doc/design.md index e7148c18..e23e4aa1 100644 --- a/apps/etcdetcetc/doc/design.md +++ b/apps/etcdetcetc/doc/design.md @@ -80,7 +80,10 @@ spec: prefix: "/cloud/" secretName: cloud-etcd status: - ready: false + conditions: + - type: Ready + status: "False" + reason: Pending ``` **spec.clusterRef**: cross-namespace reference to an EtcdCluster. @@ -91,8 +94,9 @@ omitted. **spec.secretName**: name of the output Secret to create in the tenant's namespace. Defaults to `-etcd` if omitted. -**status.ready**: set to true when user, role, and permissions are all -provisioned in etcd. +**status.conditions**: standard Kubernetes conditions array. The `Ready` +condition is `True` when user, role, and permissions are all provisioned in +etcd. Use `kubectl wait --for=condition=Ready etcdtenant/` to wait. ## Controller behaviour @@ -117,7 +121,7 @@ Watches: EtcdCluster, referenced Secrets (for credential rotation). 5. Ensure role has `readwrite` permission on the prefix. 6. Ensure role is granted to the user. 7. Create or update the output Secret (see below). -8. Set `status.ready = true`. +8. Set `Ready` condition to `True`. **Delete (finalizer: `etcdetcetc.samcday.com/tenant`):** diff --git a/apps/etcdetcetc/src/cluster.rs b/apps/etcdetcetc/src/cluster.rs index 288666f4..4801113b 100644 --- a/apps/etcdetcetc/src/cluster.rs +++ b/apps/etcdetcetc/src/cluster.rs @@ -121,6 +121,16 @@ async fn reconcile( .write() .unwrap_or_else(|p| p.into_inner()) .remove(&key); + { + let mut refs = context + .secret_refs + .write() + .unwrap_or_else(|p| p.into_inner()); + for clusters in refs.values_mut() { + clusters.retain(|existing| existing != &key); + } + refs.retain(|_, clusters| !clusters.is_empty()); + } update_status(&cluster, &context.client, &namespace, &name, false).await?; return Ok(Action::await_change()); } diff --git a/apps/etcdetcetc/src/tenant.rs b/apps/etcdetcetc/src/tenant.rs index c92243d3..94228769 100644 --- a/apps/etcdetcetc/src/tenant.rs +++ b/apps/etcdetcetc/src/tenant.rs @@ -486,20 +486,21 @@ async fn ensure_finalizer(client: &Client, tenant: &EtcdTenant, namespace: &str) return Ok(()); } - let mut finalizers = tenant.meta().finalizers.clone().unwrap_or_default(); - finalizers.push(TENANT_FINALIZER.to_string()); - let api = Api::::namespaced(client.clone(), namespace); - let patch = serde_json::json!({ - "metadata": { - "finalizers": finalizers, - } - }); + let patch: json_patch::Patch = if tenant.meta().finalizers.is_some() { + serde_json::from_value(serde_json::json!([ + { "op": "add", "path": "/metadata/finalizers/-", "value": TENANT_FINALIZER } + ])).unwrap() + } else { + serde_json::from_value(serde_json::json!([ + { "op": "add", "path": "/metadata/finalizers", "value": [TENANT_FINALIZER] } + ])).unwrap() + }; api.patch( &tenant.name_any(), &PatchParams::default(), - &Patch::Merge(&patch), + &Patch::Json::<()>(patch), ) .await?; @@ -507,20 +508,26 @@ async fn ensure_finalizer(client: &Client, tenant: &EtcdTenant, namespace: &str) } async fn remove_finalizer(client: &Client, tenant: &EtcdTenant, namespace: &str) -> Result<(), TenantError> { - let mut finalizers = tenant.meta().finalizers.clone().unwrap_or_default(); - finalizers.retain(|f| f != TENANT_FINALIZER); + let Some(index) = tenant + .meta() + .finalizers + .as_ref() + .and_then(|finalizers| finalizers.iter().position(|f| f == TENANT_FINALIZER)) + else { + return Ok(()); + }; let api = Api::::namespaced(client.clone(), namespace); - let patch = serde_json::json!({ - "metadata": { - "finalizers": finalizers, - } - }); + let path = format!("/metadata/finalizers/{index}"); + let patch: json_patch::Patch = serde_json::from_value(serde_json::json!([ + { "op": "test", "path": &path, "value": TENANT_FINALIZER }, + { "op": "remove", "path": &path } + ])).unwrap(); api.patch( &tenant.name_any(), &PatchParams::default(), - &Patch::Merge(&patch), + &Patch::Json::<()>(patch), ) .await?; diff --git a/apps/etcdetcetc/xtask/src/main.rs b/apps/etcdetcetc/xtask/src/main.rs index 113f3bf6..8cae1caf 100644 --- a/apps/etcdetcetc/xtask/src/main.rs +++ b/apps/etcdetcetc/xtask/src/main.rs @@ -94,6 +94,13 @@ fn cmd_dev_up() -> Result<()> { run("docker", &["cp", &cp_key_src, &cp_key_dst]) .context("copying apiserver etcd client key from container")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tls_key, std::fs::Permissions::from_mode(0o600)) + .context("setting permissions on tls.key")?; + } + let inspect_ip = run_stdout( "docker", &[ From 6f23237180abaec34278ab9f483760f36d5cc20a Mon Sep 17 00:00:00 2001 From: Sam Day Date: Sat, 4 Apr 2026 14:27:47 +1000 Subject: [PATCH 10/17] etcdetcetc: namespace-scoped etcd identifiers and cross-namespace authorization Etcd user/role names and key prefixes are now deterministically computed as {namespace}-{name} to prevent collisions across namespaces and hijacking of reserved names like "root". The user-configurable prefix field is removed. Cross-namespace cluster references are now authorized against the EtcdCluster's allowedNamespaces field. Empty means same-namespace only; ["*"] allows all namespaces. The mirrored ConfigMap now includes the computed prefix so consumers know their assigned keyspace. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/README.md | 7 ++++- apps/etcdetcetc/doc/design.md | 33 +++++++++++++++-------- apps/etcdetcetc/src/crd.rs | 13 +++++---- apps/etcdetcetc/src/tenant.rs | 51 ++++++++++++++++++++++++++--------- 4 files changed, 72 insertions(+), 32 deletions(-) diff --git a/apps/etcdetcetc/README.md b/apps/etcdetcetc/README.md index 419b5673..e832f641 100644 --- a/apps/etcdetcetc/README.md +++ b/apps/etcdetcetc/README.md @@ -24,12 +24,17 @@ spec: - https://etcd3.example.com:2379 authSecretRef: name: etcd-root-credentials + allowedNamespaces: + - my-app ``` The referenced Secret holds root/admin credentials. Supports: - **TLS cert auth**: keys `tls.crt`, `tls.key`, `ca.crt` - **Basic auth**: keys `username`, `password`, `ca.crt` +`allowedNamespaces` controls cross-namespace tenant references. Empty means +same-namespace only; use `["*"]` to allow all namespaces. + ### EtcdTenant Carves out a keyspace on an EtcdCluster for a tenant. @@ -51,7 +56,7 @@ a Secret with credentials and a ConfigMap with connection info. On deletion, the entities are removed. Defaults: -- `prefix`: `//` +- `prefix`: auto-assigned as `/{namespace}-{name}/` - `secretName`: `-etcd` ## Future diff --git a/apps/etcdetcetc/doc/design.md b/apps/etcdetcetc/doc/design.md index e23e4aa1..3fff62f1 100644 --- a/apps/etcdetcetc/doc/design.md +++ b/apps/etcdetcetc/doc/design.md @@ -42,6 +42,9 @@ spec: - https://hub-az1-cp3.hub.internal:2379 authSecretRef: name: hub-etcd-root + allowedNamespaces: + - cloud-cluster + - simonet status: connected: false ``` @@ -59,6 +62,10 @@ present: The `ca.crt` key is always required (TLS to etcd is non-negotiable). +**spec.allowedNamespaces**: which tenant namespaces may reference this +EtcdCluster. Empty means same-namespace only. Use `["*"]` to allow all +namespaces. + **status.connected**: set to true when the controller has successfully authenticated and pinged the cluster. @@ -77,7 +84,6 @@ spec: clusterRef: name: hub-etcd namespace: etcd-system - prefix: "/cloud/" secretName: cloud-etcd status: conditions: @@ -88,8 +94,8 @@ status: **spec.clusterRef**: cross-namespace reference to an EtcdCluster. -**spec.prefix**: etcd key prefix for this tenant. Defaults to `//` if -omitted. +The etcd key prefix is computed by the controller as `/{namespace}-{name}/`. +This value is not user-configurable. **spec.secretName**: name of the output Secret to create in the tenant's namespace. Defaults to `-etcd` if omitted. @@ -115,13 +121,17 @@ Watches: EtcdCluster, referenced Secrets (for credential rotation). **Create / Update:** 1. Resolve the referenced EtcdCluster. If not connected, requeue. -2. Compute effective prefix (`spec.prefix` or `//`). -3. Ensure etcd user exists with a generated password. -4. Ensure etcd role exists (named same as the user). -5. Ensure role has `readwrite` permission on the prefix. -6. Ensure role is granted to the user. -7. Create or update the output Secret (see below). -8. Set `Ready` condition to `True`. +2. Authorize cross-namespace references using EtcdCluster + `spec.allowedNamespaces`. +3. Compute scoped etcd identifiers: + - name: `{namespace}-{name}` + - prefix: `/{namespace}-{name}/` +4. Ensure etcd user exists with a generated password. +5. Ensure etcd role exists (named same as the user). +6. Ensure role has `readwrite` permission on the prefix. +7. Ensure role is granted to the user. +8. Create or update the output Secret (see below). +9. Set `Ready` condition to `True`. **Delete (finalizer: `etcdetcetc.samcday.com/tenant`):** @@ -148,7 +158,7 @@ metadata: controller: true type: Opaque data: - username: + username: password: ``` @@ -170,6 +180,7 @@ metadata: controller: true data: endpoints: "https://host1:2379,https://host2:2379" + prefix: "/-/" ca.crt: | -----BEGIN CERTIFICATE----- ... diff --git a/apps/etcdetcetc/src/crd.rs b/apps/etcdetcetc/src/crd.rs index 22a5793a..cb7b2c48 100644 --- a/apps/etcdetcetc/src/crd.rs +++ b/apps/etcdetcetc/src/crd.rs @@ -122,6 +122,11 @@ pub struct EtcdClusterSpec { /// Reference to a Secret with root/admin credentials. pub auth_secret_ref: LocalSecretReference, + + /// Namespaces allowed to reference this cluster. Empty means same-namespace + /// only. Use `["*"]` to allow all namespaces. + #[serde(default)] + pub allowed_namespaces: Vec, } #[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema, PartialEq)] @@ -170,19 +175,13 @@ pub struct ClusterMember { kind = "EtcdTenant", namespaced, status = "EtcdTenantStatus", - printcolumn = r#"{"name": "Ready", "type": "string", "jsonPath": ".status.conditions[?(@.type==\"Ready\")].status"}"#, - printcolumn = r#"{"name": "Prefix", "type": "string", "jsonPath": ".spec.prefix"}"# + printcolumn = r#"{"name": "Ready", "type": "string", "jsonPath": ".status.conditions[?(@.type==\"Ready\")].status"}"# )] #[serde(rename_all = "camelCase")] pub struct EtcdTenantSpec { /// Reference to the EtcdCluster to provision on. pub cluster_ref: ClusterReference, - /// etcd key prefix for this tenant. Defaults to `//`. - #[schemars(regex(pattern = r"^/[A-Za-z0-9/_-]*/$"))] - #[serde(default)] - pub prefix: Option, - /// Name of the output Secret. Defaults to `-etcd`. #[serde(default)] pub secret_name: Option, diff --git a/apps/etcdetcetc/src/tenant.rs b/apps/etcdetcetc/src/tenant.rs index 94228769..a7febd2c 100644 --- a/apps/etcdetcetc/src/tenant.rs +++ b/apps/etcdetcetc/src/tenant.rs @@ -102,16 +102,13 @@ async fn reconcile(tenant: Arc, context: Arc) -> Resu return Ok(Action::await_change()); } - let prefix = tenant - .spec - .prefix - .clone() - .unwrap_or_else(|| format!("/{name}/")); + let etcd_name = format!("{namespace}-{name}"); + let prefix = format!("/{namespace}-{name}/"); info!(namespace, name, "reconciling EtcdTenant"); if tenant.meta().deletion_timestamp.is_some() { - return reconcile_delete(tenant, context, &namespace, &name, &prefix).await; + return reconcile_delete(tenant, context, &namespace, &name).await; } ensure_finalizer(&context.client, &tenant, &namespace).await?; @@ -119,6 +116,29 @@ async fn reconcile(tenant: Arc, context: Arc) -> Resu let cluster_namespace = tenant.spec.cluster_ref.namespace.clone().unwrap_or_else(|| namespace.clone()); let cluster_name = tenant.spec.cluster_ref.name.clone(); + if cluster_namespace != namespace { + let clusters = Api::::namespaced(context.client.clone(), &cluster_namespace); + let cluster = clusters.get(&cluster_name).await?; + let allowed_namespaces = &cluster.spec.allowed_namespaces; + let namespace_allowed = allowed_namespaces.iter().any(|allowed| allowed == "*" || allowed == &namespace); + if !namespace_allowed { + let message = format!( + "tenant namespace {namespace} is not in EtcdCluster allowedNamespaces" + ); + update_ready_status( + &context.client, + &tenant, + &namespace, + &name, + false, + "NamespaceNotAllowed", + &message, + ) + .await?; + return Ok(Action::await_change()); + } + } + let mut etcd_client = match get_cluster_client(&context.clients, &cluster_namespace, &cluster_name).await { Some(client) => client, None => { @@ -151,13 +171,13 @@ async fn reconcile(tenant: Arc, context: Arc) -> Resu let password = ensure_password_secret(&context.client, &tenant, &namespace, &name).await?; - ensure_tenant_rbac(&mut etcd_client, &name, &prefix, &password).await?; + ensure_tenant_rbac(&mut etcd_client, &etcd_name, &prefix, &password).await?; ensure_output_secret( &context.client, &tenant, &namespace, &secret_name, - &name, + &etcd_name, &password, ) .await?; @@ -166,6 +186,7 @@ async fn reconcile(tenant: Arc, context: Arc) -> Resu &tenant, &namespace, &name, + &prefix, ) .await?; if !config_mirror_ready { @@ -205,7 +226,6 @@ async fn reconcile_delete( context: Arc, namespace: &str, name: &str, - prefix: &str, ) -> Result { if !has_finalizer(&tenant) { return Ok(Action::await_change()); @@ -213,6 +233,8 @@ async fn reconcile_delete( let cluster_namespace = tenant.spec.cluster_ref.namespace.clone().unwrap_or_else(|| namespace.to_owned()); let cluster_name = tenant.spec.cluster_ref.name.clone(); + let etcd_name = format!("{namespace}-{name}"); + let prefix = format!("/{namespace}-{name}/"); if let Some(mut etcd_client) = get_cluster_client(&context.clients, &cluster_namespace, &cluster_name).await { info!(namespace, name, prefix, "cleaning up tenant data and RBAC"); @@ -222,9 +244,9 @@ async fn reconcile_delete( .await?; let mut auth = etcd_client.auth_client(); - ignore_not_found(auth.user_revoke_role(name, name).await)?; - ignore_not_found(auth.user_delete(name).await)?; - ignore_not_found(auth.role_delete(name).await)?; + ignore_not_found(auth.user_revoke_role(&etcd_name, &etcd_name).await)?; + ignore_not_found(auth.user_delete(&etcd_name).await)?; + ignore_not_found(auth.role_delete(&etcd_name).await)?; } else { let clusters = Api::::namespaced(context.client.clone(), &cluster_namespace); match clusters.get(&cluster_name).await { @@ -391,6 +413,7 @@ async fn ensure_config_mirror( tenant: &EtcdTenant, tenant_namespace: &str, tenant_name: &str, + prefix: &str, ) -> Result { let cluster_namespace = tenant.spec.cluster_ref.namespace.as_deref() .unwrap_or(tenant_namespace); @@ -421,6 +444,8 @@ async fn ensure_config_mirror( )) })?; let target_name = format!("{tenant_name}-etcd"); + let mut data = source.data.unwrap_or_default(); + data.insert("prefix".to_string(), prefix.to_string()); let target = ConfigMap { metadata: ObjectMeta { name: Some(target_name.clone()), @@ -428,7 +453,7 @@ async fn ensure_config_mirror( owner_references: Some(vec![owner_reference]), ..ObjectMeta::default() }, - data: source.data, + data: Some(data), ..ConfigMap::default() }; From 9eb5ebab3f22114302cab500009ffdfbf03aa780 Mon Sep 17 00:00:00 2001 From: Sam Day Date: Sat, 4 Apr 2026 14:40:45 +1000 Subject: [PATCH 11/17] etcdetcetc: add Codespaces devcontainer Automates the dev environment setup so opening a Codespace drops into a ready-to-iterate environment with kind, tilt, and a warm cargo cache. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- .devcontainer/etcdetcetc/devcontainer.json | 44 ++++++++++++++++++++++ .devcontainer/etcdetcetc/on-create.sh | 25 ++++++++++++ .devcontainer/etcdetcetc/post-start.sh | 19 ++++++++++ 3 files changed, 88 insertions(+) create mode 100644 .devcontainer/etcdetcetc/devcontainer.json create mode 100755 .devcontainer/etcdetcetc/on-create.sh create mode 100755 .devcontainer/etcdetcetc/post-start.sh diff --git a/.devcontainer/etcdetcetc/devcontainer.json b/.devcontainer/etcdetcetc/devcontainer.json new file mode 100644 index 00000000..c91b6aab --- /dev/null +++ b/.devcontainer/etcdetcetc/devcontainer.json @@ -0,0 +1,44 @@ +{ + "name": "etcdetcetc", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/rust:1": {}, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "minikube": "none" + } + }, + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/apps/etcdetcetc", + "onCreateCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/etcdetcetc/on-create.sh", + "postStartCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/etcdetcetc/post-start.sh", + "postAttachCommand": { + "tilt": "tilt up" + }, + "remoteEnv": { + "KUBECONFIG": "${containerWorkspaceFolder}/.dev/kubeconfig" + }, + "forwardPorts": [10350], + "portsAttributes": { + "10350": { + "label": "Tilt UI" + } + }, + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "32gb" + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-kubernetes-tools.vscode-kubernetes-tools", + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml", + "tilt-dev.tiltfile" + ], + "settings": { + "vs-kubernetes.kubeconfig": "${workspaceFolder}/.dev/kubeconfig" + } + } + } +} diff --git a/.devcontainer/etcdetcetc/on-create.sh b/.devcontainer/etcdetcetc/on-create.sh new file mode 100755 index 00000000..bb82ba82 --- /dev/null +++ b/.devcontainer/etcdetcetc/on-create.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +sudo apt-get update && sudo apt-get install -y protobuf-compiler + +arch="$(uname -m)" +case "$arch" in + x86_64) kind_arch="amd64" ;; + aarch64) kind_arch="arm64" ;; + *) + echo "unsupported architecture: $arch" >&2 + exit 1 + ;; +esac + +curl -fsSL "https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-${kind_arch}" -o /tmp/kind +sudo install -m 0755 /tmp/kind /usr/local/bin/kind + +curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh | bash + +cd "$REPO_ROOT/apps/etcdetcetc" +cargo build +cargo build -p xtask diff --git a/.devcontainer/etcdetcetc/post-start.sh b/.devcontainer/etcdetcetc/post-start.sh new file mode 100755 index 00000000..2805feaa --- /dev/null +++ b/.devcontainer/etcdetcetc/post-start.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +timeout_seconds=30 +elapsed=0 + +until docker info >/dev/null 2>&1; do + if [ "$elapsed" -ge "$timeout_seconds" ]; then + echo "docker daemon was not ready within ${timeout_seconds}s" >&2 + exit 1 + fi + sleep 2 + elapsed=$((elapsed + 2)) +done + +cd "$REPO_ROOT/apps/etcdetcetc" +cargo xtask dev-up From 9f7b8e82f08c0a05c3a046c2a555649dbf8000fe Mon Sep 17 00:00:00 2001 From: Sam Day Date: Sat, 4 Apr 2026 14:42:44 +1000 Subject: [PATCH 12/17] etcdetcetc: add VS Code tasks for tilt/xtask Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/.vscode/tasks.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/etcdetcetc/.vscode/tasks.json diff --git a/apps/etcdetcetc/.vscode/tasks.json b/apps/etcdetcetc/.vscode/tasks.json new file mode 100644 index 00000000..1172ede7 --- /dev/null +++ b/apps/etcdetcetc/.vscode/tasks.json @@ -0,0 +1,22 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Tilt Up", + "type": "shell", + "command": "tilt up", + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "Dev Up", + "type": "shell", + "command": "cargo xtask dev-up" + }, + { + "label": "Dev Down", + "type": "shell", + "command": "cargo xtask dev-down" + } + ] +} From b19f44f8830b567f32afd36213587dc5707dda1f Mon Sep 17 00:00:00 2001 From: Sam Day Date: Sat, 4 Apr 2026 18:00:26 +1000 Subject: [PATCH 13/17] etcdetcetc: switch tilt to native cargo build + crane Replace docker_build with custom_build using native cargo compilation and crane for OCI image packaging. This avoids Docker for builds entirely, lets cargo incremental compilation work across rebuilds, and means the Codespaces prebuild cache is directly reused by Tilt. - Add local registry container + kind config for registry mirror - Build debug musl-static binaries on Alpine base for dev - Install crane + musl-tools in devcontainer prebuild Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- .devcontainer/etcdetcetc/on-create.sh | 12 ++++--- apps/etcdetcetc/Tiltfile | 10 +++++- apps/etcdetcetc/dev/deploy.yaml | 2 +- apps/etcdetcetc/dev/kind-config.yaml | 6 ++++ apps/etcdetcetc/xtask/src/main.rs | 47 +++++++++++++++++++++++++-- 5 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 apps/etcdetcetc/dev/kind-config.yaml diff --git a/.devcontainer/etcdetcetc/on-create.sh b/.devcontainer/etcdetcetc/on-create.sh index bb82ba82..b44cac4d 100755 --- a/.devcontainer/etcdetcetc/on-create.sh +++ b/.devcontainer/etcdetcetc/on-create.sh @@ -3,12 +3,12 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -sudo apt-get update && sudo apt-get install -y protobuf-compiler +sudo apt-get update && sudo apt-get install -y protobuf-compiler musl-tools arch="$(uname -m)" case "$arch" in - x86_64) kind_arch="amd64" ;; - aarch64) kind_arch="arm64" ;; + x86_64) kind_arch="amd64"; crane_arch="x86_64" ;; + aarch64) kind_arch="arm64"; crane_arch="arm64" ;; *) echo "unsupported architecture: $arch" >&2 exit 1 @@ -20,6 +20,10 @@ sudo install -m 0755 /tmp/kind /usr/local/bin/kind curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh | bash +curl -fsSL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_${crane_arch}.tar.gz" | sudo tar xz -C /usr/local/bin crane + +rustup target add x86_64-unknown-linux-musl + cd "$REPO_ROOT/apps/etcdetcetc" -cargo build +cargo build --target x86_64-unknown-linux-musl cargo build -p xtask diff --git a/apps/etcdetcetc/Tiltfile b/apps/etcdetcetc/Tiltfile index 525f4033..be68c583 100644 --- a/apps/etcdetcetc/Tiltfile +++ b/apps/etcdetcetc/Tiltfile @@ -1,4 +1,12 @@ -docker_build('etcdetcetc', '.', only=['src/', 'Cargo.toml', 'Cargo.lock']) +custom_build( + 'localhost:5001/etcdetcetc', + 'cargo build --target x86_64-unknown-linux-musl && ' + 'tar cf /tmp/etcdetcetc-layer.tar -C target/x86_64-unknown-linux-musl/debug etcdetcetc && ' + 'crane append -b alpine -f /tmp/etcdetcetc-layer.tar -t $EXPECTED_REF --insecure && ' + 'crane mutate $EXPECTED_REF --entrypoint /etcdetcetc --insecure', + deps=['src/', 'Cargo.toml', 'Cargo.lock'], + skips_local_docker=True, +) k8s_yaml(local('cargo run -p etcdetcetc -- crds 2>/dev/null')) k8s_yaml('dev/deploy.yaml') diff --git a/apps/etcdetcetc/dev/deploy.yaml b/apps/etcdetcetc/dev/deploy.yaml index dbd5d12d..b4794ba3 100644 --- a/apps/etcdetcetc/dev/deploy.yaml +++ b/apps/etcdetcetc/dev/deploy.yaml @@ -50,7 +50,7 @@ spec: serviceAccountName: etcdetcetc containers: - name: controller - image: etcdetcetc + image: localhost:5001/etcdetcetc env: - name: RUST_LOG value: info,etcdetcetc=debug diff --git a/apps/etcdetcetc/dev/kind-config.yaml b/apps/etcdetcetc/dev/kind-config.yaml new file mode 100644 index 00000000..2d91f58b --- /dev/null +++ b/apps/etcdetcetc/dev/kind-config.yaml @@ -0,0 +1,6 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +containerdConfigPatches: +- |- + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:5001"] + endpoint = ["http://kind-registry:5001"] diff --git a/apps/etcdetcetc/xtask/src/main.rs b/apps/etcdetcetc/xtask/src/main.rs index 8cae1caf..13f43068 100644 --- a/apps/etcdetcetc/xtask/src/main.rs +++ b/apps/etcdetcetc/xtask/src/main.rs @@ -37,6 +37,32 @@ fn cmd_dev_up() -> Result<()> { ensure_tool("kind")?; ensure_tool("docker")?; ensure_tool("kubectl")?; + ensure_tool("crane")?; + + // Ensure local registry container is running. + let registry_exists = Command::new("docker") + .args(["inspect", "kind-registry"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !registry_exists { + run( + "docker", + &[ + "run", + "-d", + "--restart=always", + "-p", + "127.0.0.1:5001:5000", + "--name", + "kind-registry", + "registry:2", + ], + ) + .context("starting local registry container")?; + } let clusters = run_stdout("kind", &["get", "clusters"]).context("checking existing kind clusters")?; @@ -44,8 +70,25 @@ fn cmd_dev_up() -> Result<()> { if cluster_exists { eprintln!("Kind cluster '{CLUSTER_NAME}' already exists, reusing it"); } else { - run("kind", &["create", "cluster", "--name", CLUSTER_NAME]) - .context("creating kind cluster")?; + run( + "kind", + &[ + "create", + "cluster", + "--name", + CLUSTER_NAME, + "--config", + "dev/kind-config.yaml", + ], + ) + .context("creating kind cluster")?; + + // Connect registry to kind network (ignore error if already connected). + let _ = Command::new("docker") + .args(["network", "connect", "kind", "kind-registry"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); } let cwd = std::env::current_dir().context("getting current directory")?; From 31130130fe589bd2a50ba348b434a9116383d7fe Mon Sep 17 00:00:00 2001 From: Sam Day Date: Sun, 5 Apr 2026 15:09:54 +1000 Subject: [PATCH 14/17] etcdetcetc: warm native build in prebuild for CRD generation Tilt runs `cargo run -p etcdetcetc -- crds` on the host target to generate CRDs at startup. Build the full workspace natively (not just xtask) so this is a cache hit. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- .devcontainer/etcdetcetc/on-create.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/etcdetcetc/on-create.sh b/.devcontainer/etcdetcetc/on-create.sh index b44cac4d..d07638bd 100755 --- a/.devcontainer/etcdetcetc/on-create.sh +++ b/.devcontainer/etcdetcetc/on-create.sh @@ -26,4 +26,4 @@ rustup target add x86_64-unknown-linux-musl cd "$REPO_ROOT/apps/etcdetcetc" cargo build --target x86_64-unknown-linux-musl -cargo build -p xtask +cargo build From 519e997f3e917a3e5eab15df29f9a2fb9da18ad0 Mon Sep 17 00:00:00 2001 From: Sam Day Date: Sun, 5 Apr 2026 18:00:09 +1000 Subject: [PATCH 15/17] etcdetcetc: warm native build in prebuild for CRD generation Run cargo xtask dev-up + tilt ci during onCreateCommand to validate the full dev pipeline and bake Docker images (kindest/node, registry:2) into the prebuild template. Codespace starts skip image pulls entirely. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- .devcontainer/etcdetcetc/on-create.sh | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.devcontainer/etcdetcetc/on-create.sh b/.devcontainer/etcdetcetc/on-create.sh index d07638bd..757e1080 100755 --- a/.devcontainer/etcdetcetc/on-create.sh +++ b/.devcontainer/etcdetcetc/on-create.sh @@ -25,5 +25,22 @@ curl -fsSL "https://github.com/google/go-containerregistry/releases/latest/downl rustup target add x86_64-unknown-linux-musl cd "$REPO_ROOT/apps/etcdetcetc" +cargo build -p xtask cargo build --target x86_64-unknown-linux-musl -cargo build + +# Wait for Docker-in-Docker, then run full dev-up + tilt ci to validate the +# pipeline and warm the Docker image cache (kindest/node, registry:2). +# Cluster state won't survive prebuild, but the image cache does. +timeout_seconds=30 +elapsed=0 +until docker info >/dev/null 2>&1; do + if [ "$elapsed" -ge "$timeout_seconds" ]; then + echo "docker daemon not ready after ${timeout_seconds}s" >&2 + exit 1 + fi + sleep 2 + elapsed=$((elapsed + 2)) +done + +cargo xtask dev-up +tilt ci From 204cbc714df034419999d428ec6876912e3f839e Mon Sep 17 00:00:00 2001 From: Sam Day Date: Sun, 5 Apr 2026 18:01:00 +1000 Subject: [PATCH 16/17] etcdetcetc: fix tilt dev workflow for containerd v2 Starlark doesn't support implicit string concatenation, so add explicit + operators in Tiltfile. Set CC=gcc for musl target builds outside the devcontainer where musl-tools aren't installed. Update kind-config.yaml and xtask to use containerd v2's certs.d host-based registry config instead of the deprecated v1 mirror format. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Sam Day --- apps/etcdetcetc/Tiltfile | 6 +++--- apps/etcdetcetc/dev/kind-config.yaml | 4 ++-- apps/etcdetcetc/xtask/src/main.rs | 25 +++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/etcdetcetc/Tiltfile b/apps/etcdetcetc/Tiltfile index be68c583..0b3b098d 100644 --- a/apps/etcdetcetc/Tiltfile +++ b/apps/etcdetcetc/Tiltfile @@ -1,8 +1,8 @@ custom_build( 'localhost:5001/etcdetcetc', - 'cargo build --target x86_64-unknown-linux-musl && ' - 'tar cf /tmp/etcdetcetc-layer.tar -C target/x86_64-unknown-linux-musl/debug etcdetcetc && ' - 'crane append -b alpine -f /tmp/etcdetcetc-layer.tar -t $EXPECTED_REF --insecure && ' + 'CC=gcc cargo build --target x86_64-unknown-linux-musl && ' + + 'tar cf /tmp/etcdetcetc-layer.tar -C target/x86_64-unknown-linux-musl/debug etcdetcetc && ' + + 'crane append -b alpine -f /tmp/etcdetcetc-layer.tar -t $EXPECTED_REF --insecure && ' + 'crane mutate $EXPECTED_REF --entrypoint /etcdetcetc --insecure', deps=['src/', 'Cargo.toml', 'Cargo.lock'], skips_local_docker=True, diff --git a/apps/etcdetcetc/dev/kind-config.yaml b/apps/etcdetcetc/dev/kind-config.yaml index 2d91f58b..eec7e0f7 100644 --- a/apps/etcdetcetc/dev/kind-config.yaml +++ b/apps/etcdetcetc/dev/kind-config.yaml @@ -2,5 +2,5 @@ kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 containerdConfigPatches: - |- - [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:5001"] - endpoint = ["http://kind-registry:5001"] + [plugins."io.containerd.grpc.v1.cri".registry] + config_path = "/etc/containerd/certs.d" diff --git a/apps/etcdetcetc/xtask/src/main.rs b/apps/etcdetcetc/xtask/src/main.rs index 13f43068..211840dd 100644 --- a/apps/etcdetcetc/xtask/src/main.rs +++ b/apps/etcdetcetc/xtask/src/main.rs @@ -89,6 +89,31 @@ fn cmd_dev_up() -> Result<()> { .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); + + // Configure containerd to resolve localhost:5001 via the kind-registry container. + let container_name = format!("{CLUSTER_NAME}-control-plane"); + run( + "docker", + &[ + "exec", + &container_name, + "mkdir", + "-p", + "/etc/containerd/certs.d/localhost:5001", + ], + ) + .context("creating containerd certs.d directory")?; + run_with_stdin( + "docker", + &["exec", "-i", &container_name, "tee", "/etc/containerd/certs.d/localhost:5001/hosts.toml"], + b"server = \"http://kind-registry:5000\"\n\n[host.\"http://kind-registry:5000\"]\n capabilities = [\"pull\", \"resolve\"]\n", + ) + .context("writing containerd registry hosts.toml")?; + run( + "docker", + &["exec", &container_name, "systemctl", "restart", "containerd"], + ) + .context("restarting containerd")?; } let cwd = std::env::current_dir().context("getting current directory")?; From 23ebc752f00b76aab43262e365ed22a4361dbfc0 Mon Sep 17 00:00:00 2001 From: Sam Day Date: Mon, 6 Apr 2026 09:13:20 +0800 Subject: [PATCH 17/17] etcdetcetc: fix etcd TLS mismatch, add k9s and sshd to devcontainer After Docker-in-Docker restarts (prebuild -> codespace start), container IPs can shuffle. The etcd server cert was minted for the original IP but dev-up discovers the new one, causing TLS verification to fail. Fix by using a headless Service + Endpoints named after the control-plane container, so the etcd endpoint uses a DNS name that matches the stable DnsName SAN in the etcd server cert. Also pin k9s v0.50.18 in on-create.sh and add the sshd devcontainer feature so gh cs ssh works. Signed-off-by: Sam Day --- .devcontainer/etcdetcetc/devcontainer.json | 3 +- .devcontainer/etcdetcetc/on-create.sh | 2 + apps/etcdetcetc/xtask/src/main.rs | 47 +++++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/.devcontainer/etcdetcetc/devcontainer.json b/.devcontainer/etcdetcetc/devcontainer.json index c91b6aab..1a24b773 100644 --- a/.devcontainer/etcdetcetc/devcontainer.json +++ b/.devcontainer/etcdetcetc/devcontainer.json @@ -6,7 +6,8 @@ "ghcr.io/devcontainers/features/rust:1": {}, "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { "minikube": "none" - } + }, + "ghcr.io/devcontainers/features/sshd:1": {} }, "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/apps/etcdetcetc", "onCreateCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/etcdetcetc/on-create.sh", diff --git a/.devcontainer/etcdetcetc/on-create.sh b/.devcontainer/etcdetcetc/on-create.sh index 757e1080..e0916cd8 100755 --- a/.devcontainer/etcdetcetc/on-create.sh +++ b/.devcontainer/etcdetcetc/on-create.sh @@ -22,6 +22,8 @@ curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/instal curl -fsSL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_${crane_arch}.tar.gz" | sudo tar xz -C /usr/local/bin crane +curl -fsSL "https://github.com/derailed/k9s/releases/download/v0.50.18/k9s_Linux_${crane_arch}.tar.gz" | sudo tar xz -C /usr/local/bin k9s + rustup target add x86_64-unknown-linux-musl cd "$REPO_ROOT/apps/etcdetcetc" diff --git a/apps/etcdetcetc/xtask/src/main.rs b/apps/etcdetcetc/xtask/src/main.rs index 211840dd..1437e356 100644 --- a/apps/etcdetcetc/xtask/src/main.rs +++ b/apps/etcdetcetc/xtask/src/main.rs @@ -111,7 +111,13 @@ fn cmd_dev_up() -> Result<()> { .context("writing containerd registry hosts.toml")?; run( "docker", - &["exec", &container_name, "systemctl", "restart", "containerd"], + &[ + "exec", + &container_name, + "systemctl", + "restart", + "containerd", + ], ) .context("restarting containerd")?; } @@ -182,7 +188,14 @@ fn cmd_dev_up() -> Result<()> { if inspect_ip.is_empty() { bail!("control-plane container IP was empty"); } - let endpoint = format!("https://{inspect_ip}:2379"); + eprintln!("Control-plane IP: {inspect_ip}"); + + // Use the container hostname as the etcd endpoint instead of the raw IP. + // The etcd server cert includes DnsName("-control-plane") as a SAN, + // so TLS verification passes even when Docker reassigns container IPs + // (e.g. across prebuild -> codespace start transitions). + // A headless Service + Endpoints makes this hostname resolvable from pods. + let endpoint = format!("https://{container_name}:2379"); eprintln!("Using etcd endpoint: {endpoint}"); pipe_cmd( @@ -193,6 +206,36 @@ fn cmd_dev_up() -> Result<()> { ) .context("installing CRDs")?; + // Create a headless Service + Endpoints so pods can resolve the + // control-plane container hostname to its current Docker network IP. + let etcd_service = format!( + r#"{{ + "apiVersion": "v1", + "kind": "Service", + "metadata": {{"name": "{container_name}", "namespace": "default"}}, + "spec": {{ + "clusterIP": "None", + "ports": [{{"port": 2379, "targetPort": 2379, "protocol": "TCP"}}] + }} +}}"# + ); + run_with_stdin("kubectl", &["apply", "-f", "-"], etcd_service.as_bytes()) + .context("creating/updating etcd headless service")?; + + let etcd_endpoints = format!( + r#"{{ + "apiVersion": "v1", + "kind": "Endpoints", + "metadata": {{"name": "{container_name}", "namespace": "default"}}, + "subsets": [{{ + "addresses": [{{"ip": "{inspect_ip}"}}], + "ports": [{{"port": 2379, "protocol": "TCP"}}] + }}] +}}"# + ); + run_with_stdin("kubectl", &["apply", "-f", "-"], etcd_endpoints.as_bytes()) + .context("creating/updating etcd endpoints")?; + let ca_crt_str = ca_crt.to_string_lossy().into_owned(); let tls_crt_str = tls_crt.to_string_lossy().into_owned(); let tls_key_str = tls_key.to_string_lossy().into_owned();