From 7a63dddd0711e96ed4de4d6bce3ad154d92bdb86 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 12 May 2026 22:58:32 +0200 Subject: [PATCH 1/2] Define LldapUser/Group/Membership/AttributeSchema CRDs Add lldap-operator-crds with four CustomResource types generated from Rust definitions via a crdgen binary that writes canonical YAML to the Helm chart's crds directory. Each CRD enforces the lldap-instance label via CEL validation; LldapUser additionally requires passwordSecretRef unless passwordPolicy is Ignore. CI verifies the chart YAML stays in sync with the Rust types. Documentation and example manifests updated. --- .../changesets/infallibly-aboveboard-crake.md | 5 + .github/workflows/ci.yml | 17 + Cargo.lock | 722 ++++++++++++++++++ Cargo.toml | 6 + .../crds/lldap-attribute-schema.yaml | 122 +++ charts/lldap-operator/crds/lldap-group.yaml | 159 ++-- .../lldap-operator/crds/lldap-membership.yaml | 100 +++ charts/lldap-operator/crds/lldap-user.yaml | 214 ++++-- crates/lldap-operator-crds/Cargo.toml | 14 + .../src/attribute_schema.rs | 248 ++++++ crates/lldap-operator-crds/src/bin/crdgen.rs | 63 ++ crates/lldap-operator-crds/src/common.rs | 75 ++ crates/lldap-operator-crds/src/group.rs | 142 ++++ crates/lldap-operator-crds/src/lib.rs | 13 + crates/lldap-operator-crds/src/membership.rs | 127 +++ crates/lldap-operator-crds/src/user.rs | 309 ++++++++ .../tests/crd_yaml_snapshot.rs | 40 + docs/examples/lldap-attribute-schema.yaml | 5 +- docs/examples/lldap-group.yaml | 6 + docs/examples/lldap-membership.yaml | 8 +- docs/examples/lldap-user.yaml | 9 +- docs/src/crds/README.md | 8 +- docs/src/crds/lldap-attribute-schema.md | 36 +- docs/src/crds/lldap-group.md | 8 +- docs/src/crds/lldap-membership.md | 30 +- docs/src/crds/lldap-user.md | 21 +- 26 files changed, 2345 insertions(+), 162 deletions(-) create mode 100644 .changeset/changesets/infallibly-aboveboard-crake.md create mode 100644 charts/lldap-operator/crds/lldap-attribute-schema.yaml create mode 100644 charts/lldap-operator/crds/lldap-membership.yaml create mode 100644 crates/lldap-operator-crds/src/attribute_schema.rs create mode 100644 crates/lldap-operator-crds/src/bin/crdgen.rs create mode 100644 crates/lldap-operator-crds/src/common.rs create mode 100644 crates/lldap-operator-crds/src/group.rs create mode 100644 crates/lldap-operator-crds/src/membership.rs create mode 100644 crates/lldap-operator-crds/src/user.rs create mode 100644 crates/lldap-operator-crds/tests/crd_yaml_snapshot.rs diff --git a/.changeset/changesets/infallibly-aboveboard-crake.md b/.changeset/changesets/infallibly-aboveboard-crake.md new file mode 100644 index 0000000..18cffe4 --- /dev/null +++ b/.changeset/changesets/infallibly-aboveboard-crake.md @@ -0,0 +1,5 @@ +--- +lldap-operator-helm-chart: patch +lldap-operator-crds: minor +--- +Define `LldapUser`, `LldapGroup`, `LldapMembership`, and `LldapAttributeSchema` CRDs generated from Rust types, with CEL validation requiring the `lldap-operator.lukidoescode.com/lldap-instance` label, conditional `passwordSecretRef` on `LldapUser`, and email format checking diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1600a4..6a6d262 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,23 @@ jobs: - name: Check MSRV run: cargo test --workspace --all-targets --locked + crd-yaml-up-to-date: + name: CRD YAML Up-To-Date + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Regenerate CRD YAML + run: cargo run -p lldap-operator-crds --bin crdgen + + - name: Verify chart CRD YAML is up to date + run: git diff --exit-code charts/lldap-operator/crds + helm-lint: name: Helm Lint runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 3b29046..7f2b44a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,42 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -85,12 +121,282 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[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", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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 = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[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 = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "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 = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "k8s-openapi" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b326f5219dd55872a72c1b6ddd1b830b8334996c667449c29391d657d78d5e" +dependencies = [ + "base64", + "jiff", + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "kube" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc5a6a69da2975ed9925d56b5dcfc9cc739b66f37add06785b7c9f6d1e88741" +dependencies = [ + "k8s-openapi", + "kube-core", + "kube-derive", +] + +[[package]] +name = "kube-core" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f126d2db7a8b532ec1d839ece2a71e2485dc3bbca6cc3c3f929becaa810e719e" +dependencies = [ + "derive_more", + "form_urlencoded", + "http", + "jiff", + "k8s-openapi", + "schemars", + "serde", + "serde-value", + "serde_json", + "thiserror", +] + +[[package]] +name = "kube-derive" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b9b97e121fce957f9cafc6da534abc4276983ab03190b76c09361e2df849fa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", +] + +[[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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lldap-operator" version = "0.0.0" @@ -106,6 +412,16 @@ version = "0.0.0" [[package]] name = "lldap-operator-crds" version = "0.0.0" +dependencies = [ + "k8s-openapi", + "kube", + "schemars", + "serde", + "serde_json", + "serde_yaml_ng", + "tempfile", + "thiserror", +] [[package]] name = "lldap-operator-reconciler" @@ -118,18 +434,85 @@ dependencies = [ "tokio", ] +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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 = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[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" @@ -148,6 +531,168 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "strsim" version = "0.11.1" @@ -165,6 +710,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -212,12 +770,76 @@ 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 = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen 0.46.0", +] + +[[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 0.51.0", +] + +[[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", + "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", + "semver", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -232,3 +854,103 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[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", + "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", + "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", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index c5da709..42a0b49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,4 +56,10 @@ lldap-operator-client = { path = "crates/lldap-operator-client", version = "0.0. lldap-operator-reconciler = { path = "crates/lldap-operator-reconciler", version = "0.0.0" } # External dependencies +k8s-openapi = { version = "0.27", features = ["v1_33", "schemars"] } +kube = { version = "3.1", default-features = false, features = ["derive"] } +schemars = "1.2" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +serde_yaml_ng = "0.10" thiserror = "2.0.18" diff --git a/charts/lldap-operator/crds/lldap-attribute-schema.yaml b/charts/lldap-operator/crds/lldap-attribute-schema.yaml new file mode 100644 index 0000000..1f04579 --- /dev/null +++ b/charts/lldap-operator/crds/lldap-attribute-schema.yaml @@ -0,0 +1,122 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: lldapattributeschemas.lldap-operator.lukidoescode.com +spec: + group: lldap-operator.lukidoescode.com + names: + categories: [] + kind: LldapAttributeSchema + plural: lldapattributeschemas + shortNames: + - llas + singular: lldapattributeschema + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.attributeName + name: Name + type: string + - jsonPath: .spec.attributeType + name: Type + type: string + - jsonPath: .spec.target + name: Target + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for LldapAttributeSchemaSpec via `CustomResource` + properties: + spec: + properties: + attributeName: + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z][a-zA-Z0-9_]*$ + type: string + attributeType: + enum: + - String + - Integer + - JpegPhoto + - DateTime + type: string + isEditable: + default: false + type: boolean + isList: + default: false + type: boolean + isVisible: + default: false + type: boolean + target: + enum: + - User + - Group + type: string + required: + - attributeName + - attributeType + - target + type: object + status: + nullable: true + properties: + conditions: + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + nullable: true + type: array + observedGeneration: + format: int64 + nullable: true + type: integer + type: object + required: + - spec + title: LldapAttributeSchemaValidated + type: object + x-kubernetes-validations: + - message: metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value + reason: FieldValueRequired + rule: has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0 + served: true + storage: true + subresources: + status: {} diff --git a/charts/lldap-operator/crds/lldap-group.yaml b/charts/lldap-operator/crds/lldap-group.yaml index 86d7d92..4ce665d 100644 --- a/charts/lldap-operator/crds/lldap-group.yaml +++ b/charts/lldap-operator/crds/lldap-group.yaml @@ -5,64 +5,113 @@ metadata: spec: group: lldap-operator.lukidoescode.com names: + categories: [] kind: LldapGroup - listKind: LldapGroupList plural: lldapgroups - singular: lldapgroup shortNames: - - llg + - llg + singular: lldapgroup scope: Namespaced versions: - - name: v1alpha1 - served: true - storage: true - schema: - openAPIV3Schema: - description: An lldap group managed by the operator - type: object - properties: - spec: - type: object - required: - - displayName - properties: - displayName: - type: string - status: - type: object - properties: - groupId: - type: integer - uuid: - type: string - observedGeneration: - type: integer - format: int64 - conditions: - type: array - items: - type: object - properties: - type: - type: string - status: - type: string - lastTransitionTime: - type: string - format: date-time - reason: - type: string - message: + - additionalPrinterColumns: + - jsonPath: .spec.displayName + name: DisplayName + type: string + - jsonPath: .status.groupId + name: GroupId + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for LldapGroupSpec via `CustomResource` + properties: + spec: + properties: + attributes: + items: + properties: + name: + type: string + value: + items: type: string - subresources: - status: {} - additionalPrinterColumns: - - name: DisplayName - type: string - jsonPath: .spec.displayName - - name: Ready - type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status - - name: Age - type: date - jsonPath: .metadata.creationTimestamp + type: array + required: + - name + - value + type: object + nullable: true + type: array + displayName: + maxLength: 128 + minLength: 1 + type: string + required: + - displayName + type: object + status: + nullable: true + properties: + conditions: + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + nullable: true + type: array + groupId: + format: int32 + nullable: true + type: integer + observedGeneration: + format: int64 + nullable: true + type: integer + uuid: + nullable: true + type: string + type: object + required: + - spec + title: LldapGroupValidated + type: object + x-kubernetes-validations: + - message: metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value + reason: FieldValueRequired + rule: has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0 + served: true + storage: true + subresources: + status: {} diff --git a/charts/lldap-operator/crds/lldap-membership.yaml b/charts/lldap-operator/crds/lldap-membership.yaml new file mode 100644 index 0000000..7b16df4 --- /dev/null +++ b/charts/lldap-operator/crds/lldap-membership.yaml @@ -0,0 +1,100 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: lldapmemberships.lldap-operator.lukidoescode.com +spec: + group: lldap-operator.lukidoescode.com + names: + categories: [] + kind: LldapMembership + plural: lldapmemberships + shortNames: + - llm + singular: lldapmembership + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.userName + name: User + type: string + - jsonPath: .spec.groupName + name: Group + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for LldapMembershipSpec via `CustomResource` + properties: + spec: + properties: + groupName: + maxLength: 128 + minLength: 1 + type: string + userName: + maxLength: 64 + minLength: 1 + type: string + required: + - groupName + - userName + type: object + status: + nullable: true + properties: + conditions: + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + nullable: true + type: array + observedGeneration: + format: int64 + nullable: true + type: integer + type: object + required: + - spec + title: LldapMembershipValidated + type: object + x-kubernetes-validations: + - message: metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value + reason: FieldValueRequired + rule: has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0 + served: true + storage: true + subresources: + status: {} diff --git a/charts/lldap-operator/crds/lldap-user.yaml b/charts/lldap-operator/crds/lldap-user.yaml index c535bd3..abcd475 100644 --- a/charts/lldap-operator/crds/lldap-user.yaml +++ b/charts/lldap-operator/crds/lldap-user.yaml @@ -5,89 +5,149 @@ metadata: spec: group: lldap-operator.lukidoescode.com names: + categories: [] kind: LldapUser - listKind: LldapUserList plural: lldapusers - singular: lldapuser shortNames: - - llu + - llu + singular: lldapuser scope: Namespaced versions: - - name: v1alpha1 - served: true - storage: true - schema: - openAPIV3Schema: - description: An lldap user managed by the operator - type: object - properties: - spec: - type: object - required: - - username - - email - - passwordSecretRef - properties: - username: - type: string - email: - type: string - displayName: - type: string - firstName: - type: string - lastName: - type: string - groups: - type: array - items: - type: string - passwordSecretRef: - type: object - required: - - name - - key + - additionalPrinterColumns: + - jsonPath: .spec.username + name: Username + type: string + - jsonPath: .spec.email + name: Email + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for LldapUserSpec via `CustomResource` + properties: + spec: + properties: + attributes: + items: properties: name: type: string - key: - type: string - status: - type: object - properties: - uuid: - type: string - observedGeneration: - type: integer - format: int64 - conditions: - type: array - items: - type: object - properties: - type: + value: + items: type: string - status: - type: string - lastTransitionTime: - type: string - format: date-time - reason: - type: string - message: - type: string - subresources: - status: {} - additionalPrinterColumns: - - name: Username - type: string - jsonPath: .spec.username - - name: Email - type: string - jsonPath: .spec.email - - name: Ready - type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status - - name: Age - type: date - jsonPath: .metadata.creationTimestamp + type: array + required: + - name + - value + type: object + nullable: true + type: array + displayName: + nullable: true + type: string + email: + maxLength: 320 + minLength: 3 + pattern: ^[^@\s]+@[^@\s]+\.[^@\s]+$ + type: string + firstName: + nullable: true + type: string + lastName: + nullable: true + type: string + passwordPolicy: + default: Manage + enum: + - Manage + - InitialOnly + - Ignore + type: string + passwordSecretRef: + nullable: true + properties: + key: + type: string + name: + type: string + required: + - key + - name + type: object + username: + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z0-9._-]+$ + type: string + required: + - email + - username + type: object + status: + nullable: true + properties: + conditions: + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + nullable: true + type: array + observedGeneration: + format: int64 + nullable: true + type: integer + passwordHash: + nullable: true + type: string + uuid: + nullable: true + type: string + type: object + required: + - spec + title: LldapUserValidated + type: object + x-kubernetes-validations: + - message: metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value + reason: FieldValueRequired + rule: has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0 + - message: passwordSecretRef is required unless passwordPolicy is Ignore + reason: FieldValueRequired + rule: self.spec.passwordPolicy == 'Ignore' || has(self.spec.passwordSecretRef) + served: true + storage: true + subresources: + status: {} diff --git a/crates/lldap-operator-crds/Cargo.toml b/crates/lldap-operator-crds/Cargo.toml index f0a398a..c823b79 100644 --- a/crates/lldap-operator-crds/Cargo.toml +++ b/crates/lldap-operator-crds/Cargo.toml @@ -12,4 +12,18 @@ description = "CRD type definitions for the lldap resources operator" [lints] workspace = true +[[bin]] +name = "crdgen" +path = "src/bin/crdgen.rs" + [dependencies] +k8s-openapi.workspace = true +kube.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml_ng.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/lldap-operator-crds/src/attribute_schema.rs b/crates/lldap-operator-crds/src/attribute_schema.rs new file mode 100644 index 0000000..107e956 --- /dev/null +++ b/crates/lldap-operator-crds/src/attribute_schema.rs @@ -0,0 +1,248 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::{CustomResource, KubeSchema}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum AttributeKind { + String, + Integer, + JpegPhoto, + DateTime, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum AttributeTarget { + User, + Group, +} + +#[derive(CustomResource, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, KubeSchema)] +#[kube( + group = "lldap-operator.lukidoescode.com", + version = "v1alpha1", + kind = "LldapAttributeSchema", + plural = "lldapattributeschemas", + shortname = "llas", + namespaced, + status = "LldapAttributeSchemaStatus", + derive = "PartialEq", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.attributeName"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.attributeType"}"#, + printcolumn = r#"{"name":"Target","type":"string","jsonPath":".spec.target"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#, + validation = Rule::new( + "has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0" + ).message("metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value").reason(Reason::FieldValueRequired), +)] +#[serde(rename_all = "camelCase")] +pub struct LldapAttributeSchemaSpec { + #[schemars(regex(pattern = r"^[a-zA-Z][a-zA-Z0-9_]*$"), length(min = 1, max = 64))] + pub attribute_name: String, + pub attribute_type: AttributeKind, + pub target: AttributeTarget, + #[serde(default)] + pub is_list: bool, + #[serde(default)] + pub is_visible: bool, + #[serde(default)] + pub is_editable: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, KubeSchema)] +#[serde(rename_all = "camelCase")] +pub struct LldapAttributeSchemaStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conditions: Option>, +} + +#[cfg(test)] +mod tests { + use kube::CustomResourceExt; + + use super::*; + + fn spec_schema() -> serde_json::Value { + let schema = serde_json::to_value(LldapAttributeSchema::crd()).expect("serialize CRD"); + schema["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"].clone() + } + + fn cel_rules() -> serde_json::Value { + let schema = serde_json::to_value(LldapAttributeSchema::crd()).expect("serialize CRD"); + schema["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["x-kubernetes-validations"] + .clone() + } + + #[test] + fn crd_has_expected_group_version_kind() { + let crd = LldapAttributeSchema::crd(); + assert_eq!(crd.spec.group, "lldap-operator.lukidoescode.com"); + assert_eq!(crd.spec.versions[0].name, "v1alpha1"); + assert_eq!(crd.spec.names.kind, "LldapAttributeSchema"); + assert_eq!(crd.spec.names.plural, "lldapattributeschemas"); + } + + #[test] + fn attribute_kind_serializes_pascal_case() { + assert_eq!( + serde_json::to_string(&AttributeKind::String).expect("serialize"), + "\"String\"" + ); + assert_eq!( + serde_json::to_string(&AttributeKind::JpegPhoto).expect("serialize"), + "\"JpegPhoto\"" + ); + assert_eq!( + serde_json::to_string(&AttributeKind::DateTime).expect("serialize"), + "\"DateTime\"" + ); + } + + #[test] + fn attribute_kind_all_variants_round_trip() { + let cases = [ + (AttributeKind::String, "\"String\""), + (AttributeKind::Integer, "\"Integer\""), + (AttributeKind::JpegPhoto, "\"JpegPhoto\""), + (AttributeKind::DateTime, "\"DateTime\""), + ]; + for (variant, expected_json) in cases { + let json = serde_json::to_string(&variant).expect("serialize"); + assert_eq!(json, expected_json); + let back: AttributeKind = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(variant, back); + } + } + + #[test] + fn attribute_target_serializes_pascal_case() { + assert_eq!( + serde_json::to_string(&AttributeTarget::User).expect("serialize"), + "\"User\"" + ); + assert_eq!( + serde_json::to_string(&AttributeTarget::Group).expect("serialize"), + "\"Group\"" + ); + } + + #[test] + fn attribute_target_all_variants_round_trip() { + let cases = [ + (AttributeTarget::User, "\"User\""), + (AttributeTarget::Group, "\"Group\""), + ]; + for (variant, expected_json) in cases { + let json = serde_json::to_string(&variant).expect("serialize"); + assert_eq!(json, expected_json); + let back: AttributeTarget = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(variant, back); + } + } + + #[test] + fn spec_round_trips_through_yaml() { + let spec = LldapAttributeSchemaSpec { + attribute_name: "department".to_owned(), + attribute_type: AttributeKind::String, + target: AttributeTarget::User, + is_list: false, + is_visible: true, + is_editable: true, + }; + let yaml = serde_yaml_ng::to_string(&spec).expect("serialize"); + let back: LldapAttributeSchemaSpec = serde_yaml_ng::from_str(&yaml).expect("deserialize"); + assert_eq!(spec, back); + } + + #[test] + fn crd_yaml_round_trips() { + let crd = LldapAttributeSchema::crd(); + let yaml = serde_yaml_ng::to_string(&crd).expect("serialize"); + let _: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml).expect("parse"); + } + + #[test] + fn attribute_name_schema_has_correct_pattern() { + let schema = spec_schema(); + assert_eq!( + schema["properties"]["attributeName"]["pattern"], + serde_json::Value::String(r"^[a-zA-Z][a-zA-Z0-9_]*$".to_owned()) + ); + } + + #[test] + fn attribute_name_schema_has_correct_length_constraints() { + let schema = spec_schema(); + assert_eq!(schema["properties"]["attributeName"]["minLength"], 1); + assert_eq!(schema["properties"]["attributeName"]["maxLength"], 64); + } + + #[test] + fn attribute_kind_schema_has_all_enum_variants() { + let schema = spec_schema(); + let variants = schema["properties"]["attributeType"]["enum"] + .as_array() + .expect("attributeType enum is an array"); + let variant_strs: std::collections::HashSet<&str> = + variants.iter().filter_map(|v| v.as_str()).collect(); + assert!(variant_strs.contains("String")); + assert!(variant_strs.contains("Integer")); + assert!(variant_strs.contains("JpegPhoto")); + assert!(variant_strs.contains("DateTime")); + } + + #[test] + fn attribute_target_schema_has_all_enum_variants() { + let schema = spec_schema(); + let variants = schema["properties"]["target"]["enum"] + .as_array() + .expect("target enum is an array"); + let variant_strs: std::collections::HashSet<&str> = + variants.iter().filter_map(|v| v.as_str()).collect(); + assert!(variant_strs.contains("User")); + assert!(variant_strs.contains("Group")); + } + + #[test] + fn boolean_fields_default_to_false() { + let yaml = "attributeName: x\nattributeType: String\ntarget: User\n"; + let spec: LldapAttributeSchemaSpec = serde_yaml_ng::from_str(yaml).expect("deserialize"); + assert!(!spec.is_list); + assert!(!spec.is_visible); + assert!(!spec.is_editable); + } + + #[test] + fn crd_includes_instance_label_validation() { + let yaml = serde_yaml_ng::to_string(&LldapAttributeSchema::crd()).expect("serialize"); + assert!(yaml.contains("x-kubernetes-validations")); + assert!(yaml.contains("lldap-operator.lukidoescode.com/lldap-instance")); + } + + #[test] + fn cel_rule_exact_wording_instance_label() { + let rules = cel_rules(); + let rules = rules + .as_array() + .expect("x-kubernetes-validations is an array"); + let label_rule = rules + .iter() + .find(|r| r["message"].as_str() == Some("metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value")) + .expect("instance label CEL rule present"); + assert_eq!( + label_rule["rule"].as_str().expect("rule string"), + "has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0" + ); + } + + #[test] + fn status_struct_defaults_to_all_none() { + let status = LldapAttributeSchemaStatus::default(); + assert!(status.observed_generation.is_none()); + assert!(status.conditions.is_none()); + } +} diff --git a/crates/lldap-operator-crds/src/bin/crdgen.rs b/crates/lldap-operator-crds/src/bin/crdgen.rs new file mode 100644 index 0000000..ace8765 --- /dev/null +++ b/crates/lldap-operator-crds/src/bin/crdgen.rs @@ -0,0 +1,63 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use kube::CustomResourceExt; +use lldap_operator_crds::{LldapAttributeSchema, LldapGroup, LldapMembership, LldapUser}; + +#[derive(Debug, thiserror::Error)] +enum CrdgenError { + #[error("io error writing CRD YAML")] + Io(#[from] std::io::Error), + #[error("yaml serialization failed")] + Yaml(#[from] serde_yaml_ng::Error), + #[error("CARGO_MANIFEST_DIR not set")] + NoManifestDir, +} + +fn output_dir() -> Result { + let manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR").ok_or(CrdgenError::NoManifestDir)?; + Ok(Path::new(&manifest_dir).join("../../charts/lldap-operator/crds")) +} + +fn clean_yaml(dir: &Path) -> Result<(), CrdgenError> { + for entry in fs::read_dir(dir)? { + let path = entry?.path(); + if path.extension().is_some_and(|ext| ext == "yaml") { + fs::remove_file(path)?; + } + } + Ok(()) +} + +fn write_crd(dir: &Path, filename: &str) -> Result<(), CrdgenError> { + let crd = T::crd(); + let yaml = serde_yaml_ng::to_string(&crd)?; + fs::write(dir.join(filename), yaml)?; + Ok(()) +} + +fn main() -> Result<(), CrdgenError> { + let dir = output_dir()?; + fs::create_dir_all(&dir)?; + clean_yaml(&dir)?; + write_crd::(&dir, "lldap-user.yaml")?; + write_crd::(&dir, "lldap-group.yaml")?; + write_crd::(&dir, "lldap-membership.yaml")?; + write_crd::(&dir, "lldap-attribute-schema.yaml")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clean_yaml_removes_only_yaml_files() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("stale.yaml"), "x").expect("write yaml"); + fs::write(dir.path().join("keep.txt"), "y").expect("write txt"); + clean_yaml(dir.path()).expect("clean"); + assert!(!dir.path().join("stale.yaml").exists()); + assert!(dir.path().join("keep.txt").exists()); + } +} diff --git a/crates/lldap-operator-crds/src/common.rs b/crates/lldap-operator-crds/src/common.rs new file mode 100644 index 0000000..bd3c7e5 --- /dev/null +++ b/crates/lldap-operator-crds/src/common.rs @@ -0,0 +1,75 @@ +use kube::KubeSchema; +use serde::{Deserialize, Serialize}; + +pub const INSTANCE_LABEL: &str = "lldap-operator.lukidoescode.com/lldap-instance"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, KubeSchema)] +#[serde(rename_all = "camelCase")] +pub struct AttributeValue { + pub name: String, + pub value: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, KubeSchema)] +#[serde(rename_all = "camelCase")] +pub struct SecretKeyRef { + pub name: String, + pub key: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attribute_value_round_trips_through_json() { + let value = AttributeValue { + name: "phone".to_owned(), + value: vec!["+1234".to_owned()], + }; + let json = serde_json::to_string(&value).expect("serialize"); + let back: AttributeValue = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(value, back); + } + + #[test] + fn attribute_value_round_trips_with_multiple_values() { + let value = AttributeValue { + name: "phone".to_owned(), + value: vec!["+1234".to_owned(), "+5678".to_owned(), "+9012".to_owned()], + }; + let json = serde_json::to_string(&value).expect("serialize"); + let back: AttributeValue = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(value, back); + } + + #[test] + fn instance_label_constant_value() { + assert_eq!( + INSTANCE_LABEL, + "lldap-operator.lukidoescode.com/lldap-instance" + ); + } + + #[test] + fn secret_key_ref_uses_camel_case() { + let value = SecretKeyRef { + name: "secret".to_owned(), + key: "password".to_owned(), + }; + let json = serde_json::to_string(&value).expect("serialize"); + assert!(json.contains("\"name\"")); + assert!(json.contains("\"key\"")); + } + + #[test] + fn secret_key_ref_round_trips() { + let value = SecretKeyRef { + name: "my-secret".to_owned(), + key: "password".to_owned(), + }; + let json = serde_json::to_string(&value).expect("serialize"); + let back: SecretKeyRef = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(value, back); + } +} diff --git a/crates/lldap-operator-crds/src/group.rs b/crates/lldap-operator-crds/src/group.rs new file mode 100644 index 0000000..8533db8 --- /dev/null +++ b/crates/lldap-operator-crds/src/group.rs @@ -0,0 +1,142 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::{CustomResource, KubeSchema}; +use serde::{Deserialize, Serialize}; + +use crate::common::AttributeValue; + +#[derive(CustomResource, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, KubeSchema)] +#[kube( + group = "lldap-operator.lukidoescode.com", + version = "v1alpha1", + kind = "LldapGroup", + plural = "lldapgroups", + shortname = "llg", + namespaced, + status = "LldapGroupStatus", + derive = "PartialEq", + printcolumn = r#"{"name":"DisplayName","type":"string","jsonPath":".spec.displayName"}"#, + printcolumn = r#"{"name":"GroupId","type":"integer","jsonPath":".status.groupId"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#, + validation = Rule::new( + "has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0" + ).message("metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value").reason(Reason::FieldValueRequired), +)] +#[serde(rename_all = "camelCase")] +pub struct LldapGroupSpec { + #[schemars(length(min = 1, max = 128))] + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attributes: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, KubeSchema)] +#[serde(rename_all = "camelCase")] +pub struct LldapGroupStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uuid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conditions: Option>, +} + +#[cfg(test)] +mod tests { + use kube::CustomResourceExt; + + use super::*; + + fn spec_schema() -> serde_json::Value { + let schema = serde_json::to_value(LldapGroup::crd()).expect("serialize CRD"); + schema["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"].clone() + } + + fn cel_rules() -> serde_json::Value { + let schema = serde_json::to_value(LldapGroup::crd()).expect("serialize CRD"); + schema["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["x-kubernetes-validations"] + .clone() + } + + #[test] + fn crd_has_expected_group_version_kind() { + let crd = LldapGroup::crd(); + assert_eq!(crd.spec.group, "lldap-operator.lukidoescode.com"); + assert_eq!(crd.spec.versions[0].name, "v1alpha1"); + assert_eq!(crd.spec.names.kind, "LldapGroup"); + assert_eq!(crd.spec.names.plural, "lldapgroups"); + } + + #[test] + fn spec_round_trips_through_yaml() { + let spec = LldapGroupSpec { + display_name: "Engineering".to_owned(), + attributes: None, + }; + let yaml = serde_yaml_ng::to_string(&spec).expect("serialize"); + let back: LldapGroupSpec = serde_yaml_ng::from_str(&yaml).expect("deserialize"); + assert_eq!(spec, back); + } + + #[test] + fn spec_round_trips_with_attributes() { + let spec = LldapGroupSpec { + display_name: "Engineering".to_owned(), + attributes: Some(vec![AttributeValue { + name: "cost-center".to_owned(), + value: vec!["CC-42".to_owned(), "CC-99".to_owned()], + }]), + }; + let yaml = serde_yaml_ng::to_string(&spec).expect("serialize"); + let back: LldapGroupSpec = serde_yaml_ng::from_str(&yaml).expect("deserialize"); + assert_eq!(spec, back); + } + + #[test] + fn crd_yaml_round_trips() { + let crd = LldapGroup::crd(); + let yaml = serde_yaml_ng::to_string(&crd).expect("serialize"); + let _: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml).expect("parse"); + } + + #[test] + fn display_name_schema_has_correct_length_constraints() { + let schema = spec_schema(); + assert_eq!(schema["properties"]["displayName"]["minLength"], 1); + assert_eq!(schema["properties"]["displayName"]["maxLength"], 128); + } + + #[test] + fn crd_includes_instance_label_validation() { + let yaml = serde_yaml_ng::to_string(&LldapGroup::crd()).expect("serialize"); + assert!(yaml.contains("x-kubernetes-validations")); + assert!(yaml.contains("lldap-operator.lukidoescode.com/lldap-instance")); + } + + #[test] + fn cel_rule_exact_wording_instance_label() { + let rules = cel_rules(); + let rules = rules + .as_array() + .expect("x-kubernetes-validations is an array"); + let label_rule = rules + .iter() + .find(|r| r["message"].as_str() == Some("metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value")) + .expect("instance label CEL rule present"); + assert_eq!( + label_rule["rule"].as_str().expect("rule string"), + "has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0" + ); + } + + #[test] + fn status_struct_defaults_to_all_none() { + let status = LldapGroupStatus::default(); + assert!(status.group_id.is_none()); + assert!(status.uuid.is_none()); + assert!(status.observed_generation.is_none()); + assert!(status.conditions.is_none()); + } +} diff --git a/crates/lldap-operator-crds/src/lib.rs b/crates/lldap-operator-crds/src/lib.rs index 8b13789..3eee488 100644 --- a/crates/lldap-operator-crds/src/lib.rs +++ b/crates/lldap-operator-crds/src/lib.rs @@ -1 +1,14 @@ +mod attribute_schema; +mod common; +mod group; +mod membership; +mod user; +pub use attribute_schema::{ + AttributeKind, AttributeTarget, LldapAttributeSchema, LldapAttributeSchemaSpec, + LldapAttributeSchemaStatus, +}; +pub use common::{AttributeValue, INSTANCE_LABEL, SecretKeyRef}; +pub use group::{LldapGroup, LldapGroupSpec, LldapGroupStatus}; +pub use membership::{LldapMembership, LldapMembershipSpec, LldapMembershipStatus}; +pub use user::{LldapUser, LldapUserSpec, LldapUserStatus, PasswordPolicy}; diff --git a/crates/lldap-operator-crds/src/membership.rs b/crates/lldap-operator-crds/src/membership.rs new file mode 100644 index 0000000..f89433d --- /dev/null +++ b/crates/lldap-operator-crds/src/membership.rs @@ -0,0 +1,127 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::{CustomResource, KubeSchema}; +use serde::{Deserialize, Serialize}; + +#[derive(CustomResource, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, KubeSchema)] +#[kube( + group = "lldap-operator.lukidoescode.com", + version = "v1alpha1", + kind = "LldapMembership", + plural = "lldapmemberships", + shortname = "llm", + namespaced, + status = "LldapMembershipStatus", + derive = "PartialEq", + printcolumn = r#"{"name":"User","type":"string","jsonPath":".spec.userName"}"#, + printcolumn = r#"{"name":"Group","type":"string","jsonPath":".spec.groupName"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#, + validation = Rule::new( + "has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0" + ).message("metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value").reason(Reason::FieldValueRequired), +)] +#[serde(rename_all = "camelCase")] +pub struct LldapMembershipSpec { + #[schemars(length(min = 1, max = 64))] + pub user_name: String, + #[schemars(length(min = 1, max = 128))] + pub group_name: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, KubeSchema)] +#[serde(rename_all = "camelCase")] +pub struct LldapMembershipStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conditions: Option>, +} + +#[cfg(test)] +mod tests { + use kube::CustomResourceExt; + + use super::*; + + fn spec_schema() -> serde_json::Value { + let schema = serde_json::to_value(LldapMembership::crd()).expect("serialize CRD"); + schema["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"].clone() + } + + fn cel_rules() -> serde_json::Value { + let schema = serde_json::to_value(LldapMembership::crd()).expect("serialize CRD"); + schema["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["x-kubernetes-validations"] + .clone() + } + + #[test] + fn crd_has_expected_group_version_kind() { + let crd = LldapMembership::crd(); + assert_eq!(crd.spec.group, "lldap-operator.lukidoescode.com"); + assert_eq!(crd.spec.versions[0].name, "v1alpha1"); + assert_eq!(crd.spec.names.kind, "LldapMembership"); + assert_eq!(crd.spec.names.plural, "lldapmemberships"); + } + + #[test] + fn spec_round_trips_through_yaml() { + let spec = LldapMembershipSpec { + user_name: "alice".to_owned(), + group_name: "Engineering".to_owned(), + }; + let yaml = serde_yaml_ng::to_string(&spec).expect("serialize"); + let back: LldapMembershipSpec = serde_yaml_ng::from_str(&yaml).expect("deserialize"); + assert_eq!(spec, back); + } + + #[test] + fn crd_yaml_round_trips() { + let crd = LldapMembership::crd(); + let yaml = serde_yaml_ng::to_string(&crd).expect("serialize"); + let _: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml).expect("parse"); + } + + #[test] + fn user_name_schema_has_correct_length_constraints() { + let schema = spec_schema(); + assert_eq!(schema["properties"]["userName"]["minLength"], 1); + assert_eq!(schema["properties"]["userName"]["maxLength"], 64); + } + + #[test] + fn group_name_schema_has_correct_length_constraints() { + let schema = spec_schema(); + assert_eq!(schema["properties"]["groupName"]["minLength"], 1); + assert_eq!(schema["properties"]["groupName"]["maxLength"], 128); + } + + #[test] + fn crd_includes_instance_label_validation() { + let yaml = serde_yaml_ng::to_string(&LldapMembership::crd()).expect("serialize"); + assert!(yaml.contains("x-kubernetes-validations")); + assert!(yaml.contains("lldap-operator.lukidoescode.com/lldap-instance")); + } + + #[test] + fn cel_rule_exact_wording_instance_label() { + let rules = cel_rules(); + let rules = rules + .as_array() + .expect("x-kubernetes-validations is an array"); + let label_rule = rules + .iter() + .find(|r| r["message"].as_str() == Some("metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value")) + .expect("instance label CEL rule present"); + assert_eq!( + label_rule["rule"].as_str().expect("rule string"), + "has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0" + ); + } + + #[test] + fn status_struct_defaults_to_all_none() { + let status = LldapMembershipStatus::default(); + assert!(status.observed_generation.is_none()); + assert!(status.conditions.is_none()); + } +} diff --git a/crates/lldap-operator-crds/src/user.rs b/crates/lldap-operator-crds/src/user.rs new file mode 100644 index 0000000..4407073 --- /dev/null +++ b/crates/lldap-operator-crds/src/user.rs @@ -0,0 +1,309 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::{CustomResource, KubeSchema}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::common::{AttributeValue, SecretKeyRef}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum PasswordPolicy { + #[default] + Manage, + InitialOnly, + Ignore, +} + +#[derive(CustomResource, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, KubeSchema)] +#[kube( + group = "lldap-operator.lukidoescode.com", + version = "v1alpha1", + kind = "LldapUser", + plural = "lldapusers", + shortname = "llu", + namespaced, + status = "LldapUserStatus", + derive = "PartialEq", + printcolumn = r#"{"name":"Username","type":"string","jsonPath":".spec.username"}"#, + printcolumn = r#"{"name":"Email","type":"string","jsonPath":".spec.email"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#, + validation = Rule::new( + "has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0" + ).message("metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value").reason(Reason::FieldValueRequired), + validation = Rule::new( + "self.spec.passwordPolicy == 'Ignore' || has(self.spec.passwordSecretRef)" + ).message("passwordSecretRef is required unless passwordPolicy is Ignore").reason(Reason::FieldValueRequired), +)] +#[serde(rename_all = "camelCase")] +pub struct LldapUserSpec { + #[schemars(regex(pattern = r"^[a-zA-Z0-9._-]+$"), length(min = 1, max = 64))] + pub username: String, + #[schemars( + regex(pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"), + length(min = 3, max = 320) + )] + pub email: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attributes: Option>, + #[serde(default)] + pub password_policy: PasswordPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_secret_ref: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, KubeSchema)] +#[serde(rename_all = "camelCase")] +pub struct LldapUserStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uuid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conditions: Option>, +} + +#[cfg(test)] +mod tests { + use kube::CustomResourceExt; + + use super::*; + + fn spec_schema() -> serde_json::Value { + let schema = serde_json::to_value(LldapUser::crd()).expect("serialize CRD"); + schema["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"].clone() + } + + fn cel_rules() -> serde_json::Value { + let schema = serde_json::to_value(LldapUser::crd()).expect("serialize CRD"); + schema["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["x-kubernetes-validations"] + .clone() + } + + #[test] + fn crd_has_expected_group_version_kind() { + let crd = LldapUser::crd(); + assert_eq!(crd.spec.group, "lldap-operator.lukidoescode.com"); + assert_eq!(crd.spec.versions[0].name, "v1alpha1"); + assert_eq!(crd.spec.names.kind, "LldapUser"); + assert_eq!(crd.spec.names.plural, "lldapusers"); + } + + #[test] + fn spec_round_trips_through_yaml() { + let spec = LldapUserSpec { + username: "alice".to_owned(), + email: "alice@example.com".to_owned(), + display_name: Some("Alice".to_owned()), + first_name: None, + last_name: None, + attributes: None, + password_policy: PasswordPolicy::Manage, + password_secret_ref: Some(SecretKeyRef { + name: "alice-pw".to_owned(), + key: "password".to_owned(), + }), + }; + let yaml = serde_yaml_ng::to_string(&spec).expect("serialize"); + let back: LldapUserSpec = serde_yaml_ng::from_str(&yaml).expect("deserialize"); + assert_eq!(spec, back); + } + + #[test] + fn spec_round_trips_with_attributes() { + let spec = LldapUserSpec { + username: "alice".to_owned(), + email: "alice@example.com".to_owned(), + display_name: None, + first_name: None, + last_name: None, + attributes: Some(vec![AttributeValue { + name: "phone".to_owned(), + value: vec!["555-1234".to_owned(), "555-5678".to_owned()], + }]), + password_policy: PasswordPolicy::Manage, + password_secret_ref: Some(SecretKeyRef { + name: "alice-pw".to_owned(), + key: "password".to_owned(), + }), + }; + let yaml = serde_yaml_ng::to_string(&spec).expect("serialize"); + let back: LldapUserSpec = serde_yaml_ng::from_str(&yaml).expect("deserialize"); + assert_eq!(spec, back); + } + + #[test] + fn spec_round_trips_with_all_optional_fields() { + let spec = LldapUserSpec { + username: "alice".to_owned(), + email: "alice@example.com".to_owned(), + display_name: Some("Alice Smith".to_owned()), + first_name: Some("Alice".to_owned()), + last_name: Some("Smith".to_owned()), + attributes: Some(vec![AttributeValue { + name: "department".to_owned(), + value: vec!["Engineering".to_owned()], + }]), + password_policy: PasswordPolicy::Manage, + password_secret_ref: Some(SecretKeyRef { + name: "alice-pw".to_owned(), + key: "password".to_owned(), + }), + }; + let yaml = serde_yaml_ng::to_string(&spec).expect("serialize"); + let back: LldapUserSpec = serde_yaml_ng::from_str(&yaml).expect("deserialize"); + assert_eq!(spec, back); + } + + #[test] + fn spec_round_trips_with_ignore_policy_no_secret() { + let spec = LldapUserSpec { + username: "alice".to_owned(), + email: "alice@example.com".to_owned(), + display_name: None, + first_name: None, + last_name: None, + attributes: None, + password_policy: PasswordPolicy::Ignore, + password_secret_ref: None, + }; + let yaml = serde_yaml_ng::to_string(&spec).expect("serialize"); + let back: LldapUserSpec = serde_yaml_ng::from_str(&yaml).expect("deserialize"); + assert_eq!(spec, back); + } + + #[test] + fn password_policy_defaults_to_manage() { + assert_eq!(PasswordPolicy::default(), PasswordPolicy::Manage); + } + + #[test] + fn password_policy_all_variants_round_trip() { + for policy in [ + PasswordPolicy::Manage, + PasswordPolicy::InitialOnly, + PasswordPolicy::Ignore, + ] { + let json = serde_json::to_string(&policy).expect("serialize"); + let back: PasswordPolicy = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(policy, back); + } + } + + #[test] + fn crd_yaml_round_trips() { + let crd = LldapUser::crd(); + let yaml = serde_yaml_ng::to_string(&crd).expect("serialize"); + let _: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml).expect("parse"); + } + + #[test] + fn username_schema_has_correct_pattern() { + let schema = spec_schema(); + assert_eq!( + schema["properties"]["username"]["pattern"], + serde_json::Value::String(r"^[a-zA-Z0-9._-]+$".to_owned()) + ); + } + + #[test] + fn username_schema_has_correct_length_constraints() { + let schema = spec_schema(); + assert_eq!(schema["properties"]["username"]["minLength"], 1); + assert_eq!(schema["properties"]["username"]["maxLength"], 64); + } + + #[test] + fn email_schema_has_correct_pattern() { + let schema = spec_schema(); + assert_eq!( + schema["properties"]["email"]["pattern"], + serde_json::Value::String(r"^[^@\s]+@[^@\s]+\.[^@\s]+$".to_owned()) + ); + } + + #[test] + fn email_schema_has_correct_length_constraints() { + let schema = spec_schema(); + assert_eq!(schema["properties"]["email"]["minLength"], 3); + assert_eq!(schema["properties"]["email"]["maxLength"], 320); + } + + #[test] + fn password_policy_schema_has_all_enum_variants() { + let schema = spec_schema(); + let variants = schema["properties"]["passwordPolicy"]["enum"] + .as_array() + .expect("passwordPolicy enum is an array"); + let variant_strs: std::collections::HashSet<&str> = + variants.iter().filter_map(|v| v.as_str()).collect(); + assert!(variant_strs.contains("Manage")); + assert!(variant_strs.contains("InitialOnly")); + assert!(variant_strs.contains("Ignore")); + } + + #[test] + fn crd_includes_instance_label_validation() { + let yaml = serde_yaml_ng::to_string(&LldapUser::crd()).expect("serialize"); + assert!(yaml.contains("x-kubernetes-validations")); + assert!(yaml.contains("lldap-operator.lukidoescode.com/lldap-instance")); + } + + #[test] + fn cel_rule_exact_wording_instance_label() { + let rules = cel_rules(); + let rules = rules + .as_array() + .expect("x-kubernetes-validations is an array"); + let label_rule = rules + .iter() + .find(|r| r["message"].as_str() == Some("metadata.labels must include 'lldap-operator.lukidoescode.com/lldap-instance' with a non-empty value")) + .expect("instance label CEL rule present"); + assert_eq!( + label_rule["rule"].as_str().expect("rule string"), + "has(self.metadata.labels) && 'lldap-operator.lukidoescode.com/lldap-instance' in self.metadata.labels && size(self.metadata.labels['lldap-operator.lukidoescode.com/lldap-instance']) > 0" + ); + } + + #[test] + fn crd_includes_password_secret_ref_validation() { + let yaml = serde_yaml_ng::to_string(&LldapUser::crd()).expect("serialize"); + assert!(yaml.contains("passwordSecretRef is required unless passwordPolicy is Ignore")); + assert!(yaml.contains("self.spec.passwordPolicy == 'Ignore'")); + } + + #[test] + fn cel_rule_exact_wording_password_secret_ref() { + let rules = cel_rules(); + let rules = rules + .as_array() + .expect("x-kubernetes-validations is an array"); + let pw_rule = rules + .iter() + .find(|r| { + r["message"].as_str() + == Some("passwordSecretRef is required unless passwordPolicy is Ignore") + }) + .expect("password secret ref CEL rule present"); + assert_eq!( + pw_rule["rule"].as_str().expect("rule string"), + "self.spec.passwordPolicy == 'Ignore' || has(self.spec.passwordSecretRef)" + ); + } + + #[test] + fn status_struct_defaults_to_all_none() { + let status = LldapUserStatus::default(); + assert!(status.uuid.is_none()); + assert!(status.observed_generation.is_none()); + assert!(status.password_hash.is_none()); + assert!(status.conditions.is_none()); + } +} diff --git a/crates/lldap-operator-crds/tests/crd_yaml_snapshot.rs b/crates/lldap-operator-crds/tests/crd_yaml_snapshot.rs new file mode 100644 index 0000000..831891d --- /dev/null +++ b/crates/lldap-operator-crds/tests/crd_yaml_snapshot.rs @@ -0,0 +1,40 @@ +use std::fs; +use std::path::PathBuf; + +use kube::CustomResourceExt; +use lldap_operator_crds::{LldapAttributeSchema, LldapGroup, LldapMembership, LldapUser}; + +fn snapshot_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../charts/lldap-operator/crds") + .join(name) +} + +fn assert_matches(name: &str) { + let generated = serde_yaml_ng::to_string(&T::crd()).expect("serialize"); + let committed = fs::read_to_string(snapshot_path(name)).expect("read committed CRD"); + assert_eq!( + generated, committed, + "CRD YAML drift for {name} — run `cargo run -p lldap-operator-crds --bin crdgen`", + ); +} + +#[test] +fn lldap_user_yaml_matches_committed() { + assert_matches::("lldap-user.yaml"); +} + +#[test] +fn lldap_group_yaml_matches_committed() { + assert_matches::("lldap-group.yaml"); +} + +#[test] +fn lldap_membership_yaml_matches_committed() { + assert_matches::("lldap-membership.yaml"); +} + +#[test] +fn lldap_attribute_schema_yaml_matches_committed() { + assert_matches::("lldap-attribute-schema.yaml"); +} diff --git a/docs/examples/lldap-attribute-schema.yaml b/docs/examples/lldap-attribute-schema.yaml index 0174aa9..dd6338d 100644 --- a/docs/examples/lldap-attribute-schema.yaml +++ b/docs/examples/lldap-attribute-schema.yaml @@ -1,12 +1,13 @@ -# NOTE: LldapAttributeSchema is a planned CRD and is not yet available. -# This example shows the anticipated resource structure. apiVersion: lldap-operator.lukidoescode.com/v1alpha1 kind: LldapAttributeSchema metadata: name: department + labels: + lldap-operator.lukidoescode.com/lldap-instance: default spec: attributeName: "department" attributeType: "String" + target: "User" isList: false isVisible: true isEditable: true diff --git a/docs/examples/lldap-group.yaml b/docs/examples/lldap-group.yaml index 8e8dd1a..7d5142a 100644 --- a/docs/examples/lldap-group.yaml +++ b/docs/examples/lldap-group.yaml @@ -2,5 +2,11 @@ apiVersion: lldap-operator.lukidoescode.com/v1alpha1 kind: LldapGroup metadata: name: engineering + labels: + lldap-operator.lukidoescode.com/lldap-instance: default spec: displayName: "Engineering" + attributes: + - name: costCenter + value: + - "1234" diff --git a/docs/examples/lldap-membership.yaml b/docs/examples/lldap-membership.yaml index d7879a3..bedb4c8 100644 --- a/docs/examples/lldap-membership.yaml +++ b/docs/examples/lldap-membership.yaml @@ -1,9 +1,9 @@ -# NOTE: LldapMembership is a planned CRD and is not yet available. -# This example shows the anticipated resource structure. apiVersion: lldap-operator.lukidoescode.com/v1alpha1 kind: LldapMembership metadata: name: alice-engineering + labels: + lldap-operator.lukidoescode.com/lldap-instance: default spec: - userRef: "alice" - groupRef: "engineering" + userName: "alice.smith" + groupName: "Engineering" diff --git a/docs/examples/lldap-user.yaml b/docs/examples/lldap-user.yaml index 0e58556..fa75525 100644 --- a/docs/examples/lldap-user.yaml +++ b/docs/examples/lldap-user.yaml @@ -2,17 +2,22 @@ apiVersion: lldap-operator.lukidoescode.com/v1alpha1 kind: LldapUser metadata: name: alice + labels: + lldap-operator.lukidoescode.com/lldap-instance: default spec: username: "alice.smith" email: "alice.smith@example.com" displayName: "Alice Smith" firstName: "Alice" lastName: "Smith" - groups: - - "engineering" + passwordPolicy: Manage passwordSecretRef: name: alice-password key: password + attributes: + - name: department + value: + - "Engineering" --- apiVersion: v1 kind: Secret diff --git a/docs/src/crds/README.md b/docs/src/crds/README.md index 180b440..fc60da6 100644 --- a/docs/src/crds/README.md +++ b/docs/src/crds/README.md @@ -4,10 +4,10 @@ The lldap-operator manages the following Custom Resource Definitions: | Kind | Description | Status | |------|-------------|--------| -| [LldapUser](lldap-user.md) | Manages lldap user accounts | Available | -| [LldapGroup](lldap-group.md) | Manages lldap groups | Available | -| [LldapMembership](lldap-membership.md) | Manages group membership relationships | Planned | -| [LldapAttributeSchema](lldap-attribute-schema.md) | Manages custom attribute schemas | Planned | +| [`LldapUser`](lldap-user.md) | Manages lldap user accounts | Schema only | +| [`LldapGroup`](lldap-group.md) | Manages lldap groups | Schema only | +| [`LldapMembership`](lldap-membership.md) | Manages group membership relationships | Schema only | +| [`LldapAttributeSchema`](lldap-attribute-schema.md) | Manages custom attribute schemas | Schema only | All resources use the API group `lldap-operator.lukidoescode.com/v1alpha1`. diff --git a/docs/src/crds/lldap-attribute-schema.md b/docs/src/crds/lldap-attribute-schema.md index c6eedc8..032b6e4 100644 --- a/docs/src/crds/lldap-attribute-schema.md +++ b/docs/src/crds/lldap-attribute-schema.md @@ -1,8 +1,36 @@ # LldapAttributeSchema -> **Planned:** This CRD is not yet implemented. +An `LldapAttributeSchema` resource declares a custom attribute on either the +user or group entity in lldap. -An `LldapAttributeSchema` resource will manage custom attribute schemas in -lldap, allowing operators to define additional fields for users and groups. +Each resource must carry the `lldap-operator.lukidoescode.com/lldap-instance` +label whose value selects which lldap instance owns the attribute schema. - +lldap does not support mutating an attribute schema after creation: the +attribute can only be added or deleted. Updates to a schema's type or flags +require deleting and recreating the resource. Validation enforcing this +constraint is tracked separately. + +## Example + +```yaml +{{#include ../../examples/lldap-attribute-schema.yaml}} +``` + +## Spec Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `attributeName` | string | yes | Attribute identifier (1–64 chars, `[a-zA-Z][a-zA-Z0-9_]*`) | +| `attributeType` | string | yes | One of `String`, `Integer`, `JpegPhoto`, `DateTime` | +| `target` | string | yes | One of `User`, `Group` | +| `isList` | bool | no | Whether the attribute holds multiple values | +| `isVisible` | bool | no | Whether the attribute is visible to LDAP clients | +| `isEditable` | bool | no | Whether the attribute is editable through the lldap UI | + +## Status + +| Field | Type | Description | +|-------|------|-------------| +| `observedGeneration` | int64 | Last observed resource generation | +| `conditions` | Condition[] | Standard Kubernetes conditions | diff --git a/docs/src/crds/lldap-group.md b/docs/src/crds/lldap-group.md index ebc5b98..c6e5e6f 100644 --- a/docs/src/crds/lldap-group.md +++ b/docs/src/crds/lldap-group.md @@ -2,19 +2,21 @@ An `LldapGroup` resource declares an lldap group managed by the operator. +Each resource must carry the `lldap-operator.lukidoescode.com/lldap-instance` +label whose value selects which lldap instance the group belongs to. + ## Example ```yaml {{#include ../../examples/lldap-group.yaml}} ``` - - ## Spec Fields | Field | Type | Required | Description | |-------|------|----------|-------------| -| `displayName` | string | yes | Group display name | +| `displayName` | string | yes | Group display name (1–128 chars) | +| `attributes` | AttributeValue[] | no | Custom attribute values; each requires a corresponding `LldapAttributeSchema` with `target: Group` | ## Status diff --git a/docs/src/crds/lldap-membership.md b/docs/src/crds/lldap-membership.md index 3cb1f4c..673b094 100644 --- a/docs/src/crds/lldap-membership.md +++ b/docs/src/crds/lldap-membership.md @@ -1,8 +1,30 @@ # LldapMembership -> **Planned:** This CRD is not yet implemented. +An `LldapMembership` resource binds an lldap user to an lldap group. -An `LldapMembership` resource will manage the relationship between lldap users -and groups independently of the `LldapUser` resource. +Each resource must carry the `lldap-operator.lukidoescode.com/lldap-instance` +label whose value selects which lldap instance the membership applies to. - +The `userName` and `groupName` fields reference the lldap-side identifiers +(the `spec.username` of an `LldapUser` and the `spec.displayName` of an +`LldapGroup`), not Kubernetes `metadata.name` values. + +## Example + +```yaml +{{#include ../../examples/lldap-membership.yaml}} +``` + +## Spec Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `userName` | string | yes | lldap username (1–64 chars) | +| `groupName` | string | yes | lldap group display name (1–128 chars) | + +## Status + +| Field | Type | Description | +|-------|------|-------------| +| `observedGeneration` | int64 | Last observed resource generation | +| `conditions` | Condition[] | Standard Kubernetes conditions | diff --git a/docs/src/crds/lldap-user.md b/docs/src/crds/lldap-user.md index 3223897..30749c6 100644 --- a/docs/src/crds/lldap-user.md +++ b/docs/src/crds/lldap-user.md @@ -2,32 +2,39 @@ An `LldapUser` resource declares an lldap user account managed by the operator. +Each resource must carry the `lldap-operator.lukidoescode.com/lldap-instance` +label whose value selects which lldap instance the user belongs to. The operator +ignores resources without this label. + ## Example ```yaml {{#include ../../examples/lldap-user.yaml}} ``` - - ## Spec Fields | Field | Type | Required | Description | |-------|------|----------|-------------| -| `username` | string | yes | LDAP username | +| `username` | string | yes | LDAP username (1–64 chars, `[a-zA-Z0-9._-]`) | | `email` | string | yes | Email address | | `displayName` | string | no | Display name | | `firstName` | string | no | First name | | `lastName` | string | no | Last name | -| `groups` | string[] | no | Group memberships by name | -| `passwordSecretRef` | object | yes | Reference to a Secret containing the password | -| `passwordSecretRef.name` | string | yes | Name of the Secret | -| `passwordSecretRef.key` | string | yes | Key within the Secret containing the password | +| `attributes` | AttributeValue[] | no | Custom attribute values; each requires a corresponding `LldapAttributeSchema` | +| `passwordPolicy` | string | no | One of `Manage` (default), `InitialOnly`, `Ignore` | +| `passwordSecretRef` | object | no | Reference to a Secret containing the password | +| `passwordSecretRef.name` | string | yes (when set) | Name of the Secret | +| `passwordSecretRef.key` | string | yes (when set) | Key within the Secret containing the password | + +Group memberships are not declared on `LldapUser`. Use `LldapMembership` to +attach users to groups. ## Status | Field | Type | Description | |-------|------|-------------| | `uuid` | string | User UUID assigned by lldap | +| `passwordHash` | string | Hash of the last reconciled password, used to detect Secret changes | | `observedGeneration` | int64 | Last observed resource generation | | `conditions` | Condition[] | Standard Kubernetes conditions | From 667ee0eff11a51c42fd064124507b2f51d65f2cd Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 12 May 2026 23:11:19 +0200 Subject: [PATCH 2/2] Bump MSRV to 1.88.0 for kube 3.1 compatibility --- .github/workflows/ci.yml | 2 +- Cargo.toml | 2 +- README.md | 2 +- docs/src/development.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a6d262..8fa28e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@master with: - toolchain: "1.85.0" + toolchain: "1.88.0" - uses: Swatinem/rust-cache@v2 diff --git a/Cargo.toml b/Cargo.toml index 42a0b49..def36e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "3" [workspace.package] edition = "2024" -rust-version = "1.85.0" +rust-version = "1.88.0" license = "MIT" repository = "https://github.com/lukidoescode/lldap-operator" homepage = "https://github.com/lukidoescode/lldap-operator" diff --git a/README.md b/README.md index 7af457d..9aa6b2c 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ and organized as a Cargo workspace: ### Prerequisites -- Rust 1.85+ +- Rust 1.88+ - Docker - [kind](https://kind.sigs.k8s.io/) - [Helm](https://helm.sh/) diff --git a/docs/src/development.md b/docs/src/development.md index 0af76bd..77bd4a2 100644 --- a/docs/src/development.md +++ b/docs/src/development.md @@ -5,7 +5,7 @@ locally. ## Prerequisites -- Rust 1.85+ +- Rust 1.88+ - Docker - [kind](https://kind.sigs.k8s.io/) or [k3d](https://k3d.io/) - [Helm](https://helm.sh/) v3