From 51079e053e7c9f6ded1bf63e11ed1ebecc3dbbc6 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Thu, 27 Mar 2025 13:08:13 +0000 Subject: [PATCH 001/334] in progress --- Cargo.lock | 1573 +++++++++++++++------- Cargo.toml | 20 +- lapdev-api/src/router.rs | 1 + lapdev-common/src/utils.rs | 4 +- lapdev-dashboard/Cargo.toml | 14 +- lapdev-dashboard/src/account.rs | 48 +- lapdev-dashboard/src/app.rs | 91 +- lapdev-dashboard/src/audit_log.rs | 28 +- lapdev-dashboard/src/cluster.rs | 244 ++-- lapdev-dashboard/src/component/button.rs | 178 +++ lapdev-dashboard/src/component/card.rs | 104 ++ lapdev-dashboard/src/component/mod.rs | 2 + lapdev-dashboard/src/datepicker.rs | 54 +- lapdev-dashboard/src/git_provider.rs | 46 +- lapdev-dashboard/src/lib.rs | 1 + lapdev-dashboard/src/license.rs | 40 +- lapdev-dashboard/src/main.rs | 7 +- lapdev-dashboard/src/modal.rs | 59 +- lapdev-dashboard/src/nav.rs | 18 +- lapdev-dashboard/src/organization.rs | 126 +- lapdev-dashboard/src/project.rs | 174 ++- lapdev-dashboard/src/quota.rs | 21 +- lapdev-dashboard/src/ssh_key.rs | 34 +- lapdev-dashboard/src/usage.rs | 32 +- lapdev-dashboard/src/workspace.rs | 247 ++-- lapdev-proxy-http/src/forward.rs | 11 +- 26 files changed, 1985 insertions(+), 1192 deletions(-) create mode 100644 lapdev-dashboard/src/component/button.rs create mode 100644 lapdev-dashboard/src/component/card.rs create mode 100644 lapdev-dashboard/src/component/mod.rs diff --git a/Cargo.lock b/Cargo.lock index d4bdedb..fe73e56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -58,7 +58,7 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a824f2aa7e75a0c98c5a504fceb80649e9c35265d44525b5f94de4771a395cd" dependencies = [ - "getrandom", + "getrandom 0.2.15", "once_cell", "version_check", ] @@ -70,10 +70,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.2.15", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.7.25", ] [[package]] @@ -160,6 +160,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "any_spawner" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41058deaa38c9d9dd933d6d238d825227cffa668e2839b52879f6619c63eee3b" +dependencies = [ + "futures", + "thiserror 2.0.11", + "wasm-bindgen-futures", +] + [[package]] name = "anyhow" version = "1.0.66" @@ -199,14 +210,14 @@ dependencies = [ ] [[package]] -name = "async-recursion" -version = "1.0.5" +name = "async-lock" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.98", + "event-listener 5.4.0", + "event-listener-strategy", + "pin-project-lite", ] [[package]] @@ -233,9 +244,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.74" +version = "0.1.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" +checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97" dependencies = [ "proc-macro2", "quote", @@ -258,16 +269,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edcdbedc2236483ab103a53415653d6b4442ea6141baf1ffa85df29635e88436" dependencies = [ "nix", - "rand", + "rand 0.8.5", ] [[package]] name = "attribute-derive" -version = "0.8.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c94f43ede6f25dab1dea046bff84d85dea61bd49aba7a9011ad66c0d449077b" +checksum = "0053e96dd3bec5b4879c23a138d6ef26f2cb936c9cdc96274ac2b9ed44b5bb54" dependencies = [ "attribute-derive-macro", + "derive-where", + "manyhow", "proc-macro2", "quote", "syn 2.0.98", @@ -275,9 +288,9 @@ dependencies = [ [[package]] name = "attribute-derive-macro" -version = "0.8.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b409e2b2d2dc206d2c0ad3575a93f001ae21a1593e2d0c69b69c308e63f3b422" +checksum = "463b53ad0fd5b460af4b1915fe045ff4d946d025fb6c4dc3337752eaa980f71b" dependencies = [ "collection_literals", "interpolator", @@ -547,7 +560,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.98", - "syn_derive", + "syn_derive 0.1.8", ] [[package]] @@ -640,9 +653,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", @@ -650,34 +663,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.6", -] - -[[package]] -name = "ciborium" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "effd91f6c78e5a4ace8a5d3c0b6bfaec9e2baaef55f3efc00e45fb2e477ee926" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdf919175532b369853f5d5e20b26b43112613fd6fe7aee757e35f7a44642656" - -[[package]] -name = "ciborium-ll" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defaa24ecc093c77630e6c15e17c51f5e187bf35ee514f4e2d67baaa96dae22b" -dependencies = [ - "ciborium-io", - "half", + "windows-link", ] [[package]] @@ -709,7 +695,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.0", + "strsim 0.11.1", ] [[package]] @@ -718,7 +704,7 @@ version = "4.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "307bc0538d5f0f83b8248db3087aa92fe504e4691294d0c96c0eabc33f47ba47" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.98", @@ -730,6 +716,17 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +[[package]] +name = "codee" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f18d705321923b1a9358e3fc3c57c3b50171196827fc7f5f10b053242aca627" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.11", +] + [[package]] name = "collection_literals" version = "1.0.1" @@ -742,18 +739,26 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[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 = "config" -version = "0.13.3" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d379af7f68bfc21714c6c7dea883544201741d2ce8274bb12fa54f89507f52a7" +checksum = "595aae20e65c3be792d05818e8c63025294ac3cb7e200f11459063a352a6ef80" dependencies = [ - "async-trait", - "lazy_static", - "nom", + "convert_case 0.6.0", "pathdiff", "serde", - "toml 0.5.11", + "toml", + "winnow 0.7.4", ] [[package]] @@ -764,24 +769,30 @@ checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" [[package]] name = "const_format" -version = "0.2.32" +version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a214c7af3d04997541b18d432afaff4c455e79e2029079647e72fc2bd27673" +checksum = "126f97965c8ad46d6d9163268ff28432e8f6a1196a55578867832e3049df63dd" dependencies = [ "const_format_proc_macros", ] [[package]] name = "const_format_proc_macros" -version = "0.2.32" +version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f6ff08fd20f4f299298a28e2dfa8a8ba1036e6cd2460ac1de7b425d76f2500" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" dependencies = [ "proc-macro2", "quote", "unicode-xid", ] +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + [[package]] name = "convert_case" version = "0.6.0" @@ -791,6 +802,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -882,7 +902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -894,7 +914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -946,8 +966,18 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core 0.20.10", + "darling_macro 0.20.10", ] [[package]] @@ -964,25 +994,51 @@ dependencies = [ "syn 1.0.105", ] +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.98", +] + [[package]] name = "darling_macro" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ - "darling_core", + "darling_core 0.14.4", "quote", "syn 1.0.105", ] +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core 0.20.10", + "quote", + "syn 2.0.98", +] + [[package]] name = "dashmap" -version = "5.4.0" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ "cfg-if", - "hashbrown 0.12.3", + "crossbeam-utils", + "hashbrown 0.14.2", "lock_api", "once_cell", "parking_lot_core", @@ -1039,9 +1095,9 @@ dependencies = [ [[package]] name = "derive-where" -version = "1.2.5" +version = "1.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146398d62142a0f35248a608f17edf0dde57338354966d6e41d0eb2d16980ccb" +checksum = "62d671cc41a825ebabc75757b62d3d168c577f9149b2d49ece1dad1f72119d25" dependencies = [ "proc-macro2", "quote", @@ -1063,7 +1119,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" dependencies = [ - "darling", + "darling 0.14.4", "proc-macro2", "quote", "syn 1.0.105", @@ -1091,6 +1147,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "docker-compose-types" version = "0.7.0" @@ -1098,7 +1165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "608ffe949fbffae4034bdde4bfa224238736ecc9e1a362997245c7282f49fa60" dependencies = [ "derive_builder", - "indexmap 2.1.0", + "indexmap 2.8.0", "serde", "serde_yaml", ] @@ -1115,6 +1182,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1145,7 +1218,7 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a667e6426df16c2ac478efa4a439d0e674cba769c5556e8cf221739251640c8c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] @@ -1156,7 +1229,7 @@ checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "zeroize", @@ -1183,6 +1256,16 @@ dependencies = [ "serde", ] +[[package]] +name = "either_of" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "169ae1dd00fb612cf27fd069b3b10f325ea60ac551f08e5b931b4413972a847d" +dependencies = [ + "paste", + "pin-project-lite", +] + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1198,7 +1281,7 @@ dependencies = [ "hkdf", "pem-rfc7468", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -1272,6 +1355,27 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "event-listener" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" +dependencies = [ + "event-listener 5.4.0", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.0.1" @@ -1284,7 +1388,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1386,9 +1490,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.25" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -1401,9 +1505,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -1411,19 +1515,20 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.25" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", "futures-util", + "num_cpus", ] [[package]] @@ -1439,15 +1544,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", @@ -1456,21 +1561,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -1504,8 +1609,22 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.13.3+wasi-0.2.2", "wasm-bindgen", + "windows-targets 0.52.6", ] [[package]] @@ -1547,35 +1666,15 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "gloo-net" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9902a044653b26b99f7e3693a42f171312d9be8b26b5697bd1e43ad1f8a35e10" -dependencies = [ - "futures-channel", - "futures-core", - "futures-sink", - "gloo-utils 0.1.7", - "js-sys", - "pin-project", - "serde", - "serde_json", - "thiserror 1.0.58", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "gloo-net" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" dependencies = [ "futures-channel", "futures-core", "futures-sink", - "gloo-utils 0.2.0", - "http 0.2.9", + "gloo-utils", + "http 1.2.0", "js-sys", "pin-project", "serde", @@ -1586,19 +1685,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "gloo-utils" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" -dependencies = [ - "js-sys", - "serde", - "serde_json", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "gloo-utils" version = "0.2.0" @@ -1619,10 +1705,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + [[package]] name = "h2" version = "0.3.21" @@ -1654,19 +1746,13 @@ dependencies = [ "futures-sink", "futures-util", "http 1.2.0", - "indexmap 2.1.0", + "indexmap 2.8.0", "slab", "tokio", "tokio-util", "tracing", ] -[[package]] -name = "half" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" - [[package]] name = "hashbrown" version = "0.12.3" @@ -1686,6 +1772,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + [[package]] name = "hashlink" version = "0.8.4" @@ -1728,6 +1820,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -1865,6 +1963,20 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hydration_context" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35485b3dcbf7e044b8f28c73f04f13e7b509c2466fd10cb2a8a447e38f8a93a" +dependencies = [ + "futures", + "once_cell", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", +] + [[package]] name = "hyper" version = "0.14.27" @@ -2033,66 +2145,195 @@ dependencies = [ ] [[package]] -name = "ident_case" -version = "1.0.1" +name = "icu_collections" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] [[package]] -name = "idna" -version = "0.3.0" +name = "icu_locid" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "include_dir" -version = "0.7.3" +name = "icu_locid_transform" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18762faeff7122e89e0857b02f7ce6fcc0d101d5e9ad2ad7846cc01d61b7f19e" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" dependencies = [ - "include_dir_macros", + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", ] [[package]] -name = "include_dir_macros" -version = "0.7.3" +name = "icu_locid_transform_data" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b139284b5cf57ecfa712bcc66950bb635b31aff41c188e8a4cfc758eca374a3f" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" dependencies = [ - "proc-macro2", - "quote", + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", ] [[package]] -name = "indexmap" -version = "1.9.3" +name = "icu_normalizer_data" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ - "autocfg", - "hashbrown 0.12.3", + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", ] [[package]] -name = "indexmap" -version = "2.1.0" +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" dependencies = [ - "equivalent", - "hashbrown 0.14.2", - "serde", + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "inherent" -version = "1.0.10" +name = "icu_provider_macros" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce243b1bfa62ffc028f1cc3b6034ec63d649f3031bc8a4fbbb004e1ac17d1f68" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18762faeff7122e89e0857b02f7ce6fcc0d101d5e9ad2ad7846cc01d61b7f19e" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b139284b5cf57ecfa712bcc66950bb635b31aff41c188e8a4cfc758eca374a3f" +dependencies = [ + "proc-macro2", + "quote", +] + +[[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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", + "serde", +] + +[[package]] +name = "inherent" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce243b1bfa62ffc028f1cc3b6034ec63d649f3031bc8a4fbbb004e1ac17d1f68" dependencies = [ "proc-macro2", "quote", @@ -2145,7 +2386,7 @@ dependencies = [ "p256", "p384", "p521", - "rand_core", + "rand_core 0.6.4", "rsa", "sec1", "sha1", @@ -2163,12 +2404,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" -[[package]] -name = "inventory" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0508c56cfe9bfd5dfeb0c22ab9a6abfda2f27bdca422132e494266351ed8d83c" - [[package]] name = "ipnet" version = "2.9.0" @@ -2193,6 +2428,24 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[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.9" @@ -2210,10 +2463,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -2394,7 +2648,7 @@ dependencies = [ "sqlx", "tokio", "tokio-rustls 0.25.0", - "toml 0.8.11", + "toml", "tower 0.4.13", "tower-http 0.5.2", "tracing", @@ -2410,11 +2664,11 @@ dependencies = [ "anyhow", "base16ct", "chrono", - "rand", + "rand 0.9.0", "serde", "serde_json", "sha2", - "strum 0.26.1", + "strum 0.27.1", "strum_macros", "uuid", ] @@ -2454,13 +2708,15 @@ dependencies = [ "anyhow", "chrono", "futures", - "gloo-net 0.5.0", + "getrandom 0.3.1", + "gloo-net", "lapdev-common", "leptos", "leptos_router", "rust_decimal", "serde", "serde_json", + "tailwind_fuse", "urlencoding", "uuid", "wasm-bindgen", @@ -2497,7 +2753,7 @@ dependencies = [ "sea-orm", "serde", "serde_json", - "strum 0.26.1", + "strum 0.27.1", "strum_macros", "tokio", "uuid", @@ -2600,7 +2856,7 @@ dependencies = [ "tempfile", "tokio", "tokio-util", - "toml 0.8.11", + "toml", "tracing", "tracing-appender", "tracing-subscriber", @@ -2619,18 +2875,33 @@ dependencies = [ [[package]] name = "leptos" -version = "0.6.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c115de7c6fca2164133e18328777d02c371434ace38049ac02886b5cffd22dc" +checksum = "26b8731cb00f3f0894058155410b95c8955b17273181d2bc72600ab84edd24f1" dependencies = [ + "any_spawner", "cfg-if", + "either_of", + "futures", + "hydration_context", "leptos_config", "leptos_dom", + "leptos_hot_reload", "leptos_macro", - "leptos_reactive", "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "rustc-hash", + "send_wrapper", + "serde", + "serde_qs", "server_fn", - "tracing", + "slotmap", + "tachys", + "thiserror 2.0.11", + "throw_error", "typed-builder", "typed-builder-macro", "wasm-bindgen", @@ -2639,56 +2910,41 @@ dependencies = [ [[package]] name = "leptos_config" -version = "0.6.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "055262ff3660e95ec95cadd8a05a02070d624354e08e30b99c14a81023feb2dc" +checksum = "5bae3e0ead5a7a814c8340eef7cb8b6cba364125bd8174b15dc9fe1b3cab7e03" dependencies = [ "config", "regex", "serde", - "thiserror 1.0.58", + "thiserror 2.0.11", "typed-builder", ] [[package]] name = "leptos_dom" -version = "0.6.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adfea7feb9c488448db466ca0673b691ec2a05efb0b2bc6626b7248ee04bb39c" +checksum = "f89d4eb263bd5a9e7c49f780f17063f15aca56fd638c90b9dfd5f4739152e87d" dependencies = [ - "async-recursion", - "cfg-if", - "drain_filter_polyfill", - "futures", - "getrandom", - "html-escape", - "indexmap 2.1.0", - "itertools 0.12.0", "js-sys", - "leptos_reactive", - "once_cell", - "pad-adapter", - "paste", - "rustc-hash", - "serde", - "serde_json", - "server_fn", - "smallvec", - "tracing", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", ] [[package]] name = "leptos_hot_reload" -version = "0.6.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ba8f68f7c3135975eb34ed19210272ebef2d6f422d138ff87a726b8793fe105" +checksum = "e80219388501d99b246f43b6e7d08a28f327cdd34ba630a35654d917f3e1788e" dependencies = [ "anyhow", "camino", - "indexmap 2.1.0", + "indexmap 2.8.0", "parking_lot", "proc-macro2", "quote", @@ -2700,93 +2956,80 @@ dependencies = [ [[package]] name = "leptos_macro" -version = "0.6.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "039c510dafb7d9997e4b8accfcced5675fabc65720caf544592df32432d6d65a" +checksum = "2fc2ae3f369c90a830e02119216f7d6f60fe212823560aa034038e27bd77ca87" dependencies = [ "attribute-derive", "cfg-if", - "convert_case", + "convert_case 0.7.1", "html-escape", - "itertools 0.12.0", + "itertools 0.14.0", "leptos_hot_reload", "prettyplease", - "proc-macro-error", + "proc-macro-error2", "proc-macro2", "quote", "rstml", "server_fn_macro", "syn 2.0.98", - "tracing", "uuid", ] [[package]] -name = "leptos_reactive" -version = "0.6.5" +name = "leptos_router" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30c5bc7f3496d6ba399578171cf133c50d2172f9791fe292db4da2fd0d8cec4" +checksum = "4168ead6a9715daba953aa842795cb2ad81b6e011a15745bd3d1baf86f76de95" dependencies = [ - "base64 0.21.7", - "cfg-if", + "any_spawner", + "either_of", "futures", - "indexmap 2.1.0", + "gloo-net", "js-sys", - "paste", - "pin-project", - "rustc-hash", - "self_cell", - "serde", - "serde-wasm-bindgen", - "serde_json", - "slotmap", - "thiserror 1.0.58", - "tracing", + "leptos", + "leptos_router_macro", + "once_cell", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "thiserror 2.0.11", + "url", "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", ] [[package]] -name = "leptos_router" -version = "0.6.5" +name = "leptos_router_macro" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498159479603569e1d7c969cffa0817b73249e96f825c295743f8f4607a68cbe" +checksum = "e31197af38d209ffc5d9f89715381c415a1570176f8d23455fbe00d148e79640" dependencies = [ - "cfg-if", - "gloo-net 0.2.6", - "itertools 0.12.0", - "js-sys", - "lazy_static", - "leptos", - "linear-map", - "once_cell", - "percent-encoding", - "send_wrapper", - "serde", - "serde_json", - "serde_qs", - "thiserror 1.0.58", - "tracing", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.98", ] [[package]] name = "leptos_server" -version = "0.6.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f06b9b860479385991fad54cbee372382aee3c1e75ca78b5da6f8bda90c153e1" +checksum = "66985242812ec95e224fb48effe651ba02728beca92c461a9464c811a71aab11" dependencies = [ - "inventory", - "lazy_static", - "leptos_macro", - "leptos_reactive", + "any_spawner", + "base64 0.22.1", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper", "serde", + "serde_json", "server_fn", - "thiserror 1.0.58", - "tracing", + "tachys", ] [[package]] @@ -2857,10 +3100,6 @@ name = "linear-map" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" -dependencies = [ - "serde", - "serde_test", -] [[package]] name = "linux-raw-sys" @@ -2868,11 +3107,17 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + [[package]] name = "lock_api" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", @@ -2886,9 +3131,9 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "manyhow" -version = "0.8.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b76546495d933baa165075b95c0a15e8f7ef75e53f56b19b7144d80fd52bd" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" dependencies = [ "manyhow-macros", "proc-macro2", @@ -2898,9 +3143,9 @@ dependencies = [ [[package]] name = "manyhow-macros" -version = "0.8.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ba072c0eadade3160232e70893311f1f8903974488096e2eb8e48caba2f0cf1" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" dependencies = [ "proc-macro-utils", "proc-macro2", @@ -2983,10 +3228,16 @@ checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + [[package]] name = "nix" version = "0.27.1" @@ -3052,7 +3303,7 @@ dependencies = [ "autocfg", "num-integer", "num-traits", - "rand", + "rand 0.8.5", ] [[package]] @@ -3067,7 +3318,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -3127,9 +3378,9 @@ checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" dependencies = [ "base64 0.13.1", "chrono", - "getrandom", + "getrandom 0.2.15", "http 0.2.9", - "rand", + "rand 0.8.5", "reqwest", "serde", "serde_json", @@ -3148,11 +3399,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "oco_ref" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b94982fe39a861561cf67ff17a7849f2cedadbbad960a797634032b7abb998" +dependencies = [ + "serde", + "thiserror 1.0.58", +] + [[package]] name = "once_cell" -version = "1.18.0" +version = "1.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" [[package]] name = "opaque-debug" @@ -3227,10 +3488,16 @@ dependencies = [ "once_cell", "opentelemetry_api", "percent-encoding", - "rand", + "rand 0.8.5", "thiserror 1.0.58", ] +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + [[package]] name = "ordered-float" version = "2.10.1" @@ -3277,7 +3544,7 @@ version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec4c6225c69b4ca778c0aea097321a64c421cf4577b331c61b229267edabb6f8" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro-error", "proc-macro2", "quote", @@ -3324,16 +3591,10 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "rand_core", + "rand_core 0.6.4", "sha2", ] -[[package]] -name = "pad-adapter" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d80efc4b6721e8be2a10a5df21a30fa0b470f1539e53d8b4e6e75faf938b63" - [[package]] name = "pageant" version = "0.0.2" @@ -3344,12 +3605,18 @@ dependencies = [ "delegate", "futures", "log", - "rand", + "rand 0.8.5", "thiserror 1.0.58", "tokio", "windows", ] +[[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.3" @@ -3362,15 +3629,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.5" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ff9f3fef3968a3ec5945535ed654cb38ff72d7495a25619e2247fb15a2ed9ba" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.2.16", + "redox_syscall 0.5.10", "smallvec", - "windows-sys 0.42.0", + "windows-targets 0.52.6", ] [[package]] @@ -3381,7 +3648,7 @@ checksum = "6b36d47c66f2230dd1b7143d9afb2b4891879020210eddf2ccb624e529b96dba" dependencies = [ "ct-codecs", "ed25519-compact", - "getrandom", + "getrandom 0.2.15", "orion", "regex", "serde_json", @@ -3397,15 +3664,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] [[package]] name = "paste" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pathdiff" @@ -3515,9 +3782,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.13" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -3559,7 +3826,7 @@ checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", "pkcs5", - "rand_core", + "rand_core 0.6.4", "spki", ] @@ -3606,9 +3873,9 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "prettyplease" -version = "0.2.15" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" +checksum = "5316f57387668042f561aae71480de936257848f9c43ce528e311d89a07cadeb" dependencies = [ "proc-macro2", "syn 2.0.98", @@ -3656,11 +3923,33 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "proc-macro-utils" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f59e109e2f795a5070e69578c4dc101068139f74616778025ae1011d4cd41a8" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" dependencies = [ "proc-macro2", "quote", @@ -3736,31 +4025,30 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.35" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] [[package]] name = "quote-use" -version = "0.7.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b5abe3fe82fdeeb93f44d66a7b444dedf2e4827defb0a8e69c437b2de2ef94" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" dependencies = [ "quote", "quote-use-macros", - "syn 2.0.98", ] [[package]] name = "quote-use-macros" -version = "0.7.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ea44c7e20f16017a76a245bb42188517e13d16dcb1aa18044bc406cdc3f4af" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" dependencies = [ - "derive-where", + "proc-macro-utils", "proc-macro2", "quote", "syn 2.0.98", @@ -3779,8 +4067,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", + "zerocopy 0.8.24", ] [[package]] @@ -3790,7 +4089,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -3799,16 +4108,65 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] -name = "redox_syscall" -version = "0.2.16" +name = "rand_core" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "bitflags 1.3.2", + "getrandom 0.3.1", +] + +[[package]] +name = "reactive_graph" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9996b4c0f501d64a755ff3dfbe9276e9f834d105d7d45059ad4bd6d2a56477d0" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "or_poisoned", + "pin-project-lite", + "rustc-hash", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.11", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c3d2a20d8edd8ac6628718209f743da86349d7f10a4458304666c2ddfc082e" +dependencies = [ + "guardian", + "itertools 0.13.0", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d4d8e40112b8ee1424e5ec636fcbc9764c1a099e81f8fa818f6762b43cc10cd" +dependencies = [ + "convert_case 0.6.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.98", ] [[package]] @@ -3820,16 +4178,25 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +dependencies = [ + "bitflags 2.6.0", +] + [[package]] name = "regex" -version = "1.9.4" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12de2eff854e5fa4b1295edd650e227e9d8fb0c9e90b12e7f36d6a6811791a29" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.3.7", - "regex-syntax 0.7.5", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", ] [[package]] @@ -3843,13 +4210,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.3.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49530408a136e16e5b486e883fbb6ba058e8e4e8ae6621a77b048b314336e629" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.7.5", + "regex-syntax 0.8.5", ] [[package]] @@ -3860,9 +4227,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.7.5" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rend" @@ -3933,7 +4300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babe80d5c16becf6594aa32ad2be8fe08498e7ae60b77de8df700e67f191d7e" dependencies = [ "cc", - "getrandom", + "getrandom 0.2.15", "libc", "spin 0.9.8", "untrusted", @@ -3981,7 +4348,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sha2", "signature", "spki", @@ -3991,23 +4358,24 @@ dependencies = [ [[package]] name = "rstml" -version = "0.11.2" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe542870b8f59dd45ad11d382e5339c9a1047cde059be136a7016095bbdefa77" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" dependencies = [ + "derive-where", "proc-macro2", "proc-macro2-diagnostics", "quote", "syn 2.0.98", - "syn_derive", - "thiserror 1.0.58", + "syn_derive 0.2.0", + "thiserror 2.0.11", ] [[package]] name = "russh" -version = "0.50.2" +version = "0.50.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "125d81ea357e708dd04bcaab60bb664e759c0bd3553995ca6cdfb9f87a9ae43d" +checksum = "02d8075561703e70dab7b095b2c13597cde37f5be94af0849fa4e51c315020d0" dependencies = [ "aes", "aes-gcm", @@ -4030,7 +4398,7 @@ dependencies = [ "flate2", "futures", "generic-array", - "getrandom", + "getrandom 0.2.15", "hex-literal", "hmac", "home", @@ -4049,8 +4417,8 @@ dependencies = [ "pkcs5", "pkcs8", "poly1305", - "rand", - "rand_core", + "rand 0.8.5", + "rand_core 0.6.4", "rsa", "russh-cryptovec", "russh-util", @@ -4094,15 +4462,15 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.36.0" +version = "1.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" +checksum = "faa7de2ba56ac291bd90c6b9bece784a52ae1411f9506544b3eae36dd2356d50" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.5", "rkyv", "serde", "serde_json", @@ -4116,9 +4484,9 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustc-hash" -version = "1.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" @@ -4326,7 +4694,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bd3534a9978d0aa7edd2808dc1f8f31c4d0ecd31ddf71d997b3c98e9f3c9114" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro-error", "proc-macro2", "quote", @@ -4384,7 +4752,7 @@ version = "0.12.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0dbc880d47aa53c6a572e39c99402c7fad59b50766e51e0b0fc1306510b0555" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "sea-bae", @@ -4449,7 +4817,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25a82fcb49253abcb45cdcb2adf92956060ec0928635eb21b4f7a6d8f25ab0bc" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.98", @@ -4473,7 +4841,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f686050f76bffc4f635cda8aea6df5548666b830b52387e8bc7de11056d11e" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "syn 1.0.105", @@ -4544,12 +4912,6 @@ dependencies = [ "libc", ] -[[package]] -name = "self_cell" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e388332cd64eb80cd595a00941baf513caffae8dce9cfd0467fc9c66397dade6" - [[package]] name = "semver" version = "1.0.14" @@ -4567,39 +4929,28 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.195" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "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 2.10.1", - "serde", -] - -[[package]] -name = "serde-wasm-bindgen" -version = "0.6.3" +name = "serde-value" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b713f70513ae1f8d92665bbbbda5c295c2cf1da5542881ae5eefe20c9af132" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" dependencies = [ - "js-sys", + "ordered-float 2.10.1", "serde", - "wasm-bindgen", ] [[package]] name = "serde_derive" -version = "1.0.195" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -4608,11 +4959,12 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.111" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] @@ -4629,9 +4981,9 @@ dependencies = [ [[package]] name = "serde_qs" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0431a35568651e363364210c91983c1da5eb29404d9f0928b67d4ebcfa7d330c" +checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" dependencies = [ "percent-encoding", "serde", @@ -4647,15 +4999,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_test" -version = "1.0.176" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a2f49ace1498612d14f7e0b8245519584db8299541dfe31a06374a828d620ab" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4674,7 +5017,7 @@ version = "0.9.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1bf28c79a99f70ee1f1d83d10c875d2e70618417fda01ad1785e027579d9d38" dependencies = [ - "indexmap 2.1.0", + "indexmap 2.8.0", "itoa", "ryu", "serde", @@ -4683,25 +5026,26 @@ dependencies = [ [[package]] name = "server_fn" -version = "0.6.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fab54d9dd2d7e9eba4efccac41d2ec3e7c6e9973d14c0486d662a32662320c" +checksum = "8d05a9e3fd8d7404985418db38c6617cc793a1a27f398d4fbc9dfe8e41b804e6" dependencies = [ "bytes", - "ciborium", "const_format", "dashmap", "futures", - "gloo-net 0.5.0", + "gloo-net", "http 1.2.0", "js-sys", "once_cell", + "pin-project-lite", "send_wrapper", "serde", "serde_json", "serde_qs", "server_fn_macro_default", - "thiserror 1.0.58", + "thiserror 2.0.11", + "throw_error", "url", "wasm-bindgen", "wasm-bindgen-futures", @@ -4712,12 +5056,12 @@ dependencies = [ [[package]] name = "server_fn_macro" -version = "0.6.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be6011b586a0665546b7ced372b0be690d9e005d3f8524795da2843274d7720" +checksum = "504b35e883267b3206317b46d02952ed7b8bf0e11b2e209e2eb453b609a5e052" dependencies = [ "const_format", - "convert_case", + "convert_case 0.6.0", "proc-macro2", "quote", "syn 2.0.98", @@ -4726,9 +5070,9 @@ dependencies = [ [[package]] name = "server_fn_macro_default" -version = "0.6.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "752ed78ec49132d154b922cf5ab6485680cab039a75740c48ea2db621ad481da" +checksum = "eb8b274f568c94226a8045668554aace8142a59b8bca5414ac5a79627c825568" dependencies = [ "server_fn_macro", "syn 2.0.98", @@ -4781,7 +5125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -4805,7 +5149,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1e08e261d0e8f5c43123b7adf3e4ca1690d655377ac93a03b2c9d3e98de1342" dependencies = [ - "serde", "version_check", ] @@ -4900,7 +5243,7 @@ dependencies = [ "crossbeam-queue", "dotenvy", "either", - "event-listener", + "event-listener 2.5.3", "futures-channel", "futures-core", "futures-intrusive", @@ -4908,7 +5251,7 @@ dependencies = [ "futures-util", "hashlink", "hex", - "indexmap 2.1.0", + "indexmap 2.8.0", "log", "memchr", "once_cell", @@ -4954,7 +5297,7 @@ dependencies = [ "atomic-write-file", "dotenvy", "either", - "heck", + "heck 0.4.1", "hex", "once_cell", "proc-macro2", @@ -5003,7 +5346,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "rust_decimal", "serde", @@ -5048,7 +5391,7 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand", + "rand 0.8.5", "rust_decimal", "serde", "serde_json", @@ -5119,6 +5462,12 @@ dependencies = [ "sha2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static_assertions" version = "1.1.0" @@ -5144,9 +5493,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "strsim" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" @@ -5156,17 +5505,17 @@ checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" [[package]] name = "strum" -version = "0.26.1" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "723b93e8addf9aa965ebe2d11da6d7540fa2283fcea14b3371ff055f7ba13f5f" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" [[package]] name = "strum_macros" -version = "0.26.1" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a3417fc93d76740d974a01654a09777cb500428cc874ca9f45edfe0c4d4cd18" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", @@ -5213,6 +5562,18 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "sync_wrapper" version = "0.1.2" @@ -5225,6 +5586,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -5246,6 +5618,62 @@ dependencies = [ "libc", ] +[[package]] +name = "tachys" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c05fed41ed4e334257090500510df21bb1611680c0cfd3be14acec7ffdf3d95" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "dyn-clone", + "either_of", + "futures", + "html-escape", + "indexmap 2.8.0", + "itertools 0.13.0", + "js-sys", + "linear-map", + "next_tuple", + "oco_ref", + "once_cell", + "or_poisoned", + "parking_lot", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash", + "send_wrapper", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "tailwind_fuse" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca71fb01735fbc6fa13e9390d7a3037dde97053c0b65c0c72c0159cd009d26b" +dependencies = [ + "nom", + "tailwind_fuse_macro", +] + +[[package]] +name = "tailwind_fuse_macro" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa51b9ff80b5533001f8452d254a688bc7bb39c6bb77f9e0a19c1664d035888" +dependencies = [ + "darling 0.20.10", + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "tap" version = "1.0.1" @@ -5275,7 +5703,7 @@ dependencies = [ "humantime", "opentelemetry", "pin-project", - "rand", + "rand 0.8.5", "serde", "static_assertions", "tarpc-plugins", @@ -5360,6 +5788,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "throw_error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ef8bf264c6ae02a065a4a16553283f0656bd6266fc1fcb09fd2e6b5e91427b" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "time" version = "0.3.36" @@ -5391,6 +5828,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -5534,15 +5981,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - [[package]] name = "toml" version = "0.8.11" @@ -5570,7 +6008,7 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" dependencies = [ - "indexmap 2.1.0", + "indexmap 2.8.0", "toml_datetime", "winnow 0.5.40", ] @@ -5581,7 +6019,7 @@ version = "0.22.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18769cd1cec395d70860ceb4d932812a0b4d06b1a4bb336745a4d21b9496e992" dependencies = [ - "indexmap 2.1.0", + "indexmap 2.8.0", "serde", "serde_spanned", "toml_datetime", @@ -5781,7 +6219,7 @@ dependencies = [ "http 1.2.0", "httparse", "log", - "rand", + "rand 0.8.5", "sha1", "thiserror 1.0.58", "url", @@ -5800,7 +6238,7 @@ dependencies = [ "http 1.2.0", "httparse", "log", - "rand", + "rand 0.8.5", "sha1", "thiserror 2.0.11", "utf-8", @@ -5808,18 +6246,18 @@ dependencies = [ [[package]] name = "typed-builder" -version = "0.18.1" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "444d8748011b93cb168770e8092458cb0f8854f931ff82fdf6ddfbd72a9c933e" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" dependencies = [ "typed-builder-macro", ] [[package]] name = "typed-builder-macro" -version = "0.18.1" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563b3b88238ec95680aef36bdece66896eaa7ce3c0f1b4f39d38fb2435261352" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" dependencies = [ "proc-macro2", "quote", @@ -5910,9 +6348,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.3.1" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", @@ -5932,12 +6370,24 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-width" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.1" @@ -5946,12 +6396,14 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.6.1" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ - "getrandom", + "getrandom 0.3.1", + "js-sys", "serde", + "wasm-bindgen", ] [[package]] @@ -5974,9 +6426,9 @@ checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "walkdir" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", @@ -5997,26 +6449,35 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn 2.0.98", @@ -6025,21 +6486,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.45" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6047,9 +6509,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -6060,15 +6522,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "wasm-streams" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" dependencies = [ "futures-util", "js-sys", @@ -6079,9 +6544,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.65" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", @@ -6184,6 +6649,12 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + [[package]] name = "windows-result" version = "0.2.0" @@ -6203,21 +6674,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -6276,12 +6732,6 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6294,12 +6744,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -6312,12 +6756,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -6336,12 +6774,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -6354,12 +6786,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -6372,12 +6798,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -6390,12 +6810,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -6426,6 +6840,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" @@ -6436,6 +6859,27 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags 2.6.0", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + [[package]] name = "wyz" version = "0.5.1" @@ -6458,9 +6902,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.7" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9828b178da53440fa9c766a3d2f73f7cf5d0ac1fe3980c1e5018d899fd19e07b" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yansi" @@ -6468,13 +6912,46 @@ version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1367295b8f788d371ce2dbc842c7b709c73ee1364d30351dd300ec2203b12377" +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cd369a67c0edfef15010f980c3cbe45d7f651deac2cd67ce097cd801de16557" dependencies = [ - "zerocopy-derive", + "zerocopy-derive 0.7.25", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive 0.8.24", ] [[package]] @@ -6488,12 +6965,66 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", + "synstructure", +] + [[package]] name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "zstd" version = "0.13.0" diff --git a/Cargo.toml b/Cargo.toml index 210396c..42f2674 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ members = [ ] [workspace.dependencies] -rust_decimal = "1.36.0" +rust_decimal = "1.37.1" toml = "0.8.11" clap = { version = "4.5.2", features = ["derive"] } tempfile = "3.8.1" @@ -65,12 +65,12 @@ git2 = { version = "0.18.2", features = ["vendored-libgit2", "vendored-openssl"] anyhow = "1.0.66" thiserror = "1.0.58" async-trait = "0.1.74" -serde = "1.0.190" +serde = "1.0.219" pasetors = "0.6.8" -futures = "0.3" -futures-util = "0.3" +futures = "0.3.31" +futures-util = "0.3.31" serde_yaml = "0.9.30" -serde_json = "1.0.111" +serde_json = "1.0.140" json5 = "0.4.1" itertools = "0.12.0" base64 = "0.22.0" @@ -82,15 +82,15 @@ docker-compose-types = "0.7.0" tracing = "0.1.40" tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -chrono = { version = "0.4.31", features = ["serde"] } +chrono = { version = "0.4.40", features = ["serde"] } tower = { version = "0.4", features = ["util"] } tower-http = { version = "0.5.2", features = ["fs", "trace"] } tokio-tungstenite = "0.26.1" data-encoding = "2.4.0" sha2 = "0.10.8" -rand = "0.8.5" -strum = "0.26.1" -strum_macros = "0.26.1" +rand = "0.9.0" +strum = "0.27.1" +strum_macros = "0.27.1" procfs = "0.17.0" base16ct = { version = "0.2.0", features = ["alloc"] } axum = { version = "0.7.4", features = ["ws"] } @@ -110,7 +110,7 @@ sqlx = { version = "0.7.3", default-features = false, features = ["sqlx-postgres sea-orm = { version = "0.12.12", features = [ "sqlx-postgres", "sqlx-sqlite", "runtime-tokio-rustls", "macros" ] } sea-orm-migration = { version = "0.12.6", features = [ "sqlx-postgres", "runtime-tokio-rustls" ] } uuid = { version = "1.6.1", features = ["v4", "serde"] } -russh = {version = "0.50.2" } +russh = {version = "0.50.4" } lapdev-api = { path = "./lapdev-api" } lapdev-ws = { path = "./lapdev-ws" } lapdev-db = { path = "./lapdev-db" } diff --git a/lapdev-api/src/router.rs b/lapdev-api/src/router.rs index 0203b9a..81f7466 100644 --- a/lapdev-api/src/router.rs +++ b/lapdev-api/src/router.rs @@ -284,6 +284,7 @@ async fn handle_catch_all( req: Request, ) -> Result { let path = req.uri().path(); + println!("path is {path}"); if let Some(websocket) = websocket { let uri = req.uri(); diff --git a/lapdev-common/src/utils.rs b/lapdev-common/src/utils.rs index 534ab07..5ca5065 100644 --- a/lapdev-common/src/utils.rs +++ b/lapdev-common/src/utils.rs @@ -1,10 +1,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use rand::{distributions::Alphanumeric, Rng}; +use rand::{distr::Alphanumeric, Rng}; use sha2::{Digest, Sha256}; pub fn rand_string(n: usize) -> String { - rand::thread_rng() + rand::rng() .sample_iter(&Alphanumeric) .take(n) .map(|i| i as char) diff --git a/lapdev-dashboard/Cargo.toml b/lapdev-dashboard/Cargo.toml index f63f612..232d6b1 100644 --- a/lapdev-dashboard/Cargo.toml +++ b/lapdev-dashboard/Cargo.toml @@ -5,15 +5,17 @@ authors.workspace = true edition.workspace = true [dependencies] -gloo-net = "0.5.0" -wasm-bindgen = "0.2.88" -web-sys = "0.3.65" +gloo-net = "0.6.0" +wasm-bindgen = "0.2.100" +web-sys = "0.3.77" urlencoding = "2.1.3" -leptos = { version = "0.6.5", features = ["csr"] } -leptos_router = { version = "0.6.5", features = ["csr"] } +uuid = { version = "1.16.0", features = ["v4", "serde", "js"] } +getrandom = { version = "0.3", features = ["wasm_js"] } +tailwind_fuse = { version = "0.3.2", features = ["variant"] } +leptos = { version = "0.7.8", features = ["csr"] } +leptos_router = { version = "0.7.8" } rust_decimal.workspace = true chrono.workspace = true -uuid.workspace = true serde.workspace = true serde_json.workspace = true anyhow.workspace = true diff --git a/lapdev-dashboard/src/account.rs b/lapdev-dashboard/src/account.rs index 22f05cd..9c2f5e9 100644 --- a/lapdev-dashboard/src/account.rs +++ b/lapdev-dashboard/src/account.rs @@ -4,11 +4,8 @@ use lapdev_common::{ console::{MeUser, NewSessionResponse}, AuthProvider, ClusterInfo, }; -use leptos::{ - component, create_action, create_local_resource, expect_context, use_context, view, window, - IntoView, Resource, RwSignal, Signal, SignalGet, SignalGetUntracked, SignalSet, SignalWith, -}; -use leptos_router::use_params_map; +use leptos::prelude::*; +use leptos_router::hooks::use_params_map; use crate::{cluster::OauthSettings, modal::ErrorResponse}; @@ -60,18 +57,16 @@ pub fn Login() -> impl IntoView { if !auth_providers.is_empty() { view! { - }.into_view() + }.into_any() } else { view! {
- }.into_view() + }.into_any() } } else { - view! { - - }.into_view() + ().into_any() } } @@ -81,7 +76,7 @@ pub fn Login() -> impl IntoView { #[component] pub fn LoginWithView(auth_providers: Vec) -> impl IntoView { - let login = use_context::>>().unwrap(); + let login = use_context::>>().unwrap(); view! {
) -> impl IntoView { @@ -168,9 +164,9 @@ pub fn AuditLogView() -> impl IntoView {
{ error }
- }.into_view() + }.into_any() } else { - view!{}.into_view() + ().into_any() }}
@@ -200,7 +196,7 @@ pub fn AuditLogView() -> impl IntoView { class=("text-gray-300", move || audit_logs.with(|a| a.page == 0)) class=("cursor-pointer", move || !audit_logs.with(|a| a.page == 0)) class=("hover:bg-gray-100", move || !audit_logs.with(|a| a.page == 0)) - disabled=move || audit_logs.with(|a| a.page == 0) + class=("disabled", move || audit_logs.with(|a| a.page == 0)) on:click=prev_page >
}; let save_action = - create_action(move |_| async move { update_cpu_overcommit(value.get_untracked()).await }); + Action::new_local( + move |_| async move { update_cpu_overcommit(value.get_untracked()).await }, + ); view! { } @@ -602,18 +601,19 @@ async fn get_oauth2() -> Result { #[component] pub fn OauthSettings(reload: bool) -> impl IntoView { - let update_counter = create_rw_signal(0); - let github_client_id = create_rw_signal(String::new()); - let github_client_secret = create_rw_signal(String::new()); - let gitlab_client_id = create_rw_signal(String::new()); - let gitlab_client_secret = create_rw_signal(String::new()); - - let oauth = create_local_resource( - move || update_counter.get(), - move |_| async move { get_oauth2().await }, - ); - - create_effect(move |_| { + let update_counter = RwSignal::new(0); + let github_client_id = RwSignal::new(String::new()); + let github_client_secret = RwSignal::new(String::new()); + let gitlab_client_id = RwSignal::new(String::new()); + let gitlab_client_secret = RwSignal::new(String::new()); + + let oauth = LocalResource::new(move || async move { get_oauth2().await }); + Effect::new(move |_| { + update_counter.track(); + oauth.refetch(); + }); + + Effect::new(move |_| { let oauth = oauth.with(|oauth| oauth.as_ref().and_then(|o| o.as_ref().ok().cloned())); if let Some(oauth) = oauth { github_client_id.set(oauth.github_client_id); @@ -673,7 +673,7 @@ pub fn OauthSettings(reload: bool) -> impl IntoView { />
}; - let save_action = create_action(move |_| async move { + let save_action = Action::new_local(move |_| async move { update_oauth2( OauthSettings { github_client_id: github_client_id.get_untracked(), @@ -722,20 +722,23 @@ async fn update_hostnames( pub fn ClusterHostnameSetting() -> impl IntoView { let cluster_info = expect_context::>>(); - let update_counter = create_rw_signal(0); + let update_counter = RwSignal::new(0); - let set_hostnames = create_rw_signal(vec![]); + let set_hostnames = RwSignal::new(vec![]); - let hostnames = create_local_resource( - move || update_counter.get(), - |_| async move { get_hostnames().await.unwrap_or_default() }, - ); + let hostnames = LocalResource::new(|| async move { get_hostnames().await.unwrap_or_default() }); + Effect::new(move |_| { + update_counter.track(); + hostnames.refetch(); + }); - create_effect(move |_| { - let hostnames = hostnames.with(|h| h.clone()).unwrap_or_default(); + Effect::new(move |_| { + let hostnames = hostnames + .with(|h| h.as_deref().cloned()) + .unwrap_or_default(); let mut hostnames: Vec<(String, RwSignal)> = hostnames .into_iter() - .map(|(key, value)| (key, create_rw_signal(value))) + .map(|(key, value)| (key, RwSignal::new(value))) .collect(); hostnames.sort_by_key(|(region, _)| region.to_owned()); set_hostnames.set(hostnames); @@ -764,7 +767,7 @@ pub fn ClusterHostnameSetting() -> impl IntoView { } } /> - }.into_view() + }.into_any() } else { view! {
@@ -778,13 +781,13 @@ pub fn ClusterHostnameSetting() -> impl IntoView { class="max-w-96 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" />
- }.into_view() + }.into_any() } } } }; - let save_action = create_action(move |_| async move { + let save_action = Action::new_local(move |_| async move { let value = set_hostnames .get_untracked() .into_iter() @@ -834,13 +837,10 @@ async fn update_certs(certs: Vec<(String, String)>) -> Result<(), ErrorResponse> #[component] pub fn ClusterCertsSetting() -> impl IntoView { - let certs = create_rw_signal(vec![]); + let certs = RwSignal::new(vec![]); let new_cert = move |_| { certs.update(|certs| { - certs.push(( - create_rw_signal(String::new()), - create_rw_signal(String::new()), - )); + certs.push((RwSignal::new(String::new()), RwSignal::new(String::new()))); }); }; let delete_cert = move |i: usize| { @@ -849,22 +849,26 @@ pub fn ClusterCertsSetting() -> impl IntoView { }) }; - let update_counter = create_rw_signal(0); - let current_certs = create_local_resource( - move || update_counter.get(), - move |_| async move { get_certs().await.unwrap_or_default() }, - ); - create_effect(move |_| { - let current_certs = current_certs.with(|certs| certs.clone().unwrap_or_default()); + let update_counter = RwSignal::new(0); + let current_certs = + LocalResource::new(move || async move { get_certs().await.unwrap_or_default() }); + Effect::new(move |_| { + update_counter.track(); + current_certs.refetch(); + }); + + Effect::new(move |_| { + let current_certs = + current_certs.with(|certs| certs.as_deref().cloned().unwrap_or_default()); certs.update(|certs| { *certs = current_certs .into_iter() - .map(|(name, value)| (create_rw_signal(name), create_rw_signal(value))) + .map(|(name, value)| (RwSignal::new(name), RwSignal::new(value))) .collect(); }); }); - let save_action = create_action(move |_| async move { + let save_action = Action::new_local(move |_| async move { let certs = certs.get_untracked(); let certs: Vec<(String, String)> = certs .into_iter() @@ -931,7 +935,7 @@ pub fn ClusterCertsSetting() -> impl IntoView { > New Certificate - }.into_view(); + }.into_any(); view! { @@ -946,17 +950,19 @@ async fn all_machine_types() -> Result> { #[component] pub fn MachineTypeView() -> impl IntoView { - let new_machine_type_modal_hidden = create_rw_signal(true); - let update_counter = create_rw_signal(0); - let machine_types = create_local_resource( - move || update_counter.get(), - |_| async move { all_machine_types().await.unwrap_or_default() }, - ); - let machine_type_filter = create_rw_signal(String::new()); + let new_machine_type_modal_hidden = RwSignal::new(true); + let update_counter = RwSignal::new(0); + let machine_types = + LocalResource::new(|| async move { all_machine_types().await.unwrap_or_default() }); + Effect::new(move |_| { + update_counter.track(); + machine_types.refetch(); + }); + let machine_type_filter = RwSignal::new(String::new()); let machine_types = Signal::derive(move || { let machine_type_filter = machine_type_filter.get(); - let mut machine_types = - machine_types.with(|machine_types| machine_types.clone().unwrap_or_default()); + let mut machine_types = machine_types + .with(|machine_types| machine_types.as_deref().cloned().unwrap_or_default()); machine_types.retain(|h| h.name.contains(&machine_type_filter)); machine_types }); @@ -1013,11 +1019,11 @@ pub fn MachineTypeView() -> impl IntoView { #[component] fn MachineTypeItem(machine_type: MachineType, update_counter: RwSignal) -> impl IntoView { - let update_machine_type_modal_hidden = create_rw_signal(true); - let delete_modal_hidden = create_rw_signal(true); + let update_machine_type_modal_hidden = RwSignal::new(true); + let delete_modal_hidden = RwSignal::new(true); let delete_action = { let id = machine_type.id; - create_action(move |_| async move { + Action::new_local(move |_| async move { delete_machine_type(id, update_counter, delete_modal_hidden).await }) }; @@ -1050,14 +1056,14 @@ pub fn NewMachineTypeView( new_machine_type_modal_hidden: RwSignal, update_counter: RwSignal, ) -> impl IntoView { - let name = create_rw_signal(String::new()); - let cpu = create_rw_signal(String::new()); - let memory = create_rw_signal(String::new()); - let disk = create_rw_signal(String::new()); - let cost = create_rw_signal("0".to_string()); - let shared_cpu = create_rw_signal(false); + let name = RwSignal::new(String::new()); + let cpu = RwSignal::new(String::new()); + let memory = RwSignal::new(String::new()); + let disk = RwSignal::new(String::new()); + let cost = RwSignal::new("0".to_string()); + let shared_cpu = RwSignal::new(false); let cluster_info = expect_context::>>(); - let action = create_action(move |_| { + let action = Action::new_local(move |_| { create_machine_type( name.get_untracked(), cpu.get_untracked(), @@ -1106,10 +1112,10 @@ pub fn UpdateMachineTypeModal( update_machine_type_modal_hidden: RwSignal, update_counter: RwSignal, ) -> impl IntoView { - let name = create_rw_signal(machine_type.name.clone()); - let cost = create_rw_signal(machine_type.cost_per_second.to_string()); + let name = RwSignal::new(machine_type.name.clone()); + let cost = RwSignal::new(machine_type.cost_per_second.to_string()); let cluster_info = expect_context::>>(); - let action = create_action(move |_| { + let action = Action::new_local(move |_| { update_machine_type( machine_type.id, name.get_untracked(), @@ -1137,7 +1143,7 @@ fn MachineTypeControl( update_machine_type_modal_hidden: RwSignal, align_right: bool, ) -> impl IntoView { - let dropdown_hidden = create_rw_signal(true); + let dropdown_hidden = RwSignal::new(true); let toggle_dropdown = move |_| { if dropdown_hidden.get_untracked() { @@ -1388,7 +1394,7 @@ async fn get_cluster_users( async fn update_cluster_user( id: Uuid, cluster_admin: bool, - search_action: Action<(), Result>, + search_action: Action<(), Result, LocalStorage>, update_modal_hidden: RwSignal, ) -> Result<(), ErrorResponse> { let resp = Request::put(&format!("/api/v1/admin/users/{id}")) @@ -1411,12 +1417,12 @@ async fn update_cluster_user( #[component] pub fn ClusterUsersView() -> impl IntoView { - let page_size = create_rw_signal(String::new()); - let page = create_rw_signal(0); + let page_size = RwSignal::new(String::new()); + let page = RwSignal::new(0); - let error = create_rw_signal(None); - let user_filter = create_rw_signal(String::new()); - let search_action = create_action(move |_| async move { + let error = RwSignal::new(None); + let user_filter = RwSignal::new(String::new()); + let search_action = Action::new_local(move |_| async move { error.set(None); let result = get_cluster_users( user_filter.get_untracked(), @@ -1497,7 +1503,7 @@ pub fn ClusterUsersView() -> impl IntoView { @@ -1509,9 +1515,9 @@ pub fn ClusterUsersView() -> impl IntoView {
{ error }
- }.into_view() + }.into_any() } else { - view!{}.into_view() + ().into_any() }}
@@ -1537,7 +1543,7 @@ pub fn ClusterUsersView() -> impl IntoView { - impl IntoView { - + = a.num_pages)) class=("cursor-pointer", move || !users.with(|a| a.page + 1 >= a.num_pages)) @@ -1592,11 +1598,11 @@ pub fn ClusterUsersView() -> impl IntoView { fn ClusterUserItemView( i: usize, user: ClusterUser, - search_action: Action<(), Result>, + search_action: Action<(), Result, LocalStorage>, ) -> impl IntoView { let user_id = user.id; - let update_modal_hidden = create_rw_signal(true); - let is_cluster_admin = create_rw_signal(user.cluster_admin); + let update_modal_hidden = RwSignal::new(true); + let is_cluster_admin = RwSignal::new(user.cluster_admin); let update_modal_body = view! {
}; - let update_action = create_action(move |()| async move { + let update_action = Action::new_local(move |()| async move { update_cluster_user( user_id, is_cluster_admin.get_untracked(), @@ -1636,17 +1642,17 @@ fn ClusterUserItemView( - }, + }.into_any(), AuthProvider::Gitlab => view! { - }, + }.into_any(), }; view! { 0) > - +
{icon}

{user.auth_provider.to_string()}

diff --git a/lapdev-dashboard/src/component/button.rs b/lapdev-dashboard/src/component/button.rs new file mode 100644 index 0000000..2a1a305 --- /dev/null +++ b/lapdev-dashboard/src/component/button.rs @@ -0,0 +1,178 @@ +use leptos::prelude::*; +// use leptos::{ev::MouseEvent, prelude::*}; +// use leptos_node_ref::AnyNodeRef; +// use leptos_struct_component::{struct_component, StructComponent}; +// use leptos_style::Style; +use tailwind_fuse::*; + +#[derive(TwClass)] +#[tw( + class = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0" +)] +pub struct ButtonClass { + pub variant: ButtonVariant, + pub size: ButtonSize, +} + +#[derive(PartialEq, TwVariant)] +pub enum ButtonVariant { + #[tw( + default, + class = "bg-primary text-primary-foreground hover:bg-primary/90" + )] + Default, + #[tw(class = "bg-destructive text-destructive-foreground hover:bg-destructive/90")] + Destructive, + #[tw(class = "border border-input bg-background hover:bg-accent hover:text-accent-foreground")] + Outline, + #[tw(class = "bg-secondary text-secondary-foreground hover:bg-secondary/80")] + Secondary, + #[tw(class = "hover:bg-accent hover:text-accent-foreground")] + Ghost, + #[tw(class = "text-primary underline-offset-4 hover:underline")] + Link, +} + +#[derive(PartialEq, TwVariant)] +pub enum ButtonSize { + #[tw(default, class = "h-10 px-4 py-2")] + Default, + #[tw(class = "h-9 rounded-md px-3")] + Sm, + #[tw(class = "h-11 rounded-md px-8")] + Lg, + #[tw(class = "h-10 w-10")] + Icon, +} + +// #[derive(Clone, StructComponent)] +// #[struct_component(tag = "button")] +// pub struct ButtonChildProps { +// pub node_ref: AnyNodeRef, + +// // Global attributes +// pub autofocus: Signal, +// pub class: Signal, +// pub id: MaybeProp, +// pub style: Signal + + + Your ClusterLapdev EnvironmentLapdev-Kube-ManagerSecure Websocket TunnelDevboxPreview URLLapdev EnvironmentLapdev EnvironmentAPI ServerLapdev \ No newline at end of file diff --git a/docs/test.mdx b/docs/test.mdx index 65b6c20..dd260c1 100644 --- a/docs/test.mdx +++ b/docs/test.mdx @@ -3,4 +3,6 @@ title: "test file" description: "Description of your new file." --- -test test \ No newline at end of file +test test + +![Architecture Sv](/docs/images/architecture.svg) \ No newline at end of file From f2e77748b2f853a281c4856d2b1c9e4fcd0c5bf8 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Thu, 16 Oct 2025 23:12:03 +0100 Subject: [PATCH 105/334] Documentation edits made through Mintlify web editor --- docs/test.mdx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/test.mdx b/docs/test.mdx index dd260c1..c458b35 100644 --- a/docs/test.mdx +++ b/docs/test.mdx @@ -3,6 +3,9 @@ title: "test file" description: "Description of your new file." --- -test test +test test -![Architecture Sv](/docs/images/architecture.svg) \ No newline at end of file +Architecture Sv \ No newline at end of file From 0efd824ce51059ea9e0c3af9ed0f545ae987c749 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Thu, 16 Oct 2025 22:17:36 +0000 Subject: [PATCH 106/334] update --- docs/test.mdx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/test.mdx b/docs/test.mdx index c458b35..d2c05e3 100644 --- a/docs/test.mdx +++ b/docs/test.mdx @@ -5,7 +5,11 @@ description: "Description of your new file." test test -Architecture Sv \ No newline at end of file + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1921LjWLbte39FRu7H7vReN936xH4w2Fx0XCKRnFx1MDAwNlx1MDAxYjAnOjJs2Vxi3ylssKVcdTAwMWT972eOuVx1MDAwNDYkJoFcdTAwMDSK7tNVUWVb0rrPMeZlzSX+9y+fPn2eZ1x1MDAxN73Pf//0ubdM2qN+97K9+Pw3XFy/7l3O+tNcdN1S/Hs2vbpM+Mnz+fxi9vf//u/2xUUp7c870+mwlEzHtlhv1Fx1MDAxYvcm81x1MDAxOT34f+n3p0//y/9fa6g/bqc9fpgvr9pxlLl/NZ5OuE3pO4F0fOm5t0/0Z1x1MDAxNWpr3uvS7bP2aNZb3cGlz9W41SnLpHvZaF5e1HeHnerXRWPV7Fl/NDqcZyPu0mxKI1/dm80vp8Pecb87P79cdTAwMTn+2vVNpS6nV+n5pDfD0OXt1elFO+nPM1xcXHUwMDEz4vZqe5JyXHUwMDFkqytLPCFdUXJV4CvluDJwhXd7XHUwMDFiXHUwMDE1eFqUXHUwMDAy41x1MDAxODdQwtPa9+91bHs6ml6iY/PL9mR20b6kdVh1r9NOhin1cdJ9/LlFMWyjSs7txfNePz2f37866/Hs+472XFzfMavVQztcdTAwMTdhl4XgXHUwMDFmqym/bI97IUpMrkaj9XmbdIt5u3Ojg1x1MDAxYtU1eVpVdXXRbdulJ5FcdTAwMTBGXHUwMDA3nudcdTAwMDTOaqVG/cnwfnWjaTJ8QFpm8/b8XG7Vf77oTbr9SXpHRmx/P7uiIzw36VxiY4Rvek7Q80xXdYw88410gm7S6/ZEt7suQ4Sm3i1cYvDPl1uh+PRJXHUwMDE23/7Bn//828NIuewlcyspXHUwMDBmoEW6ciNagiCQytfCfzJawt1j9eV67+zotJxcdTAwMWTNK4E8/tGS74VcdTAwMTbxMrSIQJaMJ1XgXHUwMDEyNVxiXHUwMDEzmDtoMZ4pKS/wffpPe56437FbXHUwMDE0/Jfs4d+XI8V1XHUwMDAzwiW15PuBUUL5P8NGXHUwMDFiU9La8YzWgXD1T1x1MDAxMFxuXGI/Qiklflx1MDAxZkL/eytkN3Kkiyv/fFx1MDAwMbKMp5XytHxcdTAwMDayXHUwMDFlXHUwMDE16XlvOX9YmvUmaVbad6TWUj1ZmFx1MDAwN85g+cd0/r2uXHUwMDE3c+2Yqdjd82ZcdTAwMWad+oncjevRP1JcdTAwMTChundkmditRIAmveC6jvLU28kygaqkpWuEq1x1MDAxZWB/pe9cdTAwMGKu9Fx1MDAxY1x1MDAwN332/zXJ/2w6mVx1MDAxZvZzXHUwMDE2M3Hn6tf2uD/K7shcdTAwMDDLLk1giyyhT9ujq9m8d/n5zt3yqJ9Cmj+Pemd3xXzeJ31we3s+vVjdTai1dn/Su/x5dqaX/bQ/aY9cdTAwMWGPtkzj7e3eLJEsSWdt9Wc93GWd8HJVo9bI81x1MDAxZTh911x1MDAxM1x1MDAxZSnF1eT9XG6c41x1MDAxZvPUnU6Xrpr90c6c5VkjXHLMq4Kz256d915cdTAwMTedRIMl7flkhbpkfml/hYOMXHJXt+RcdTAwMDa+o0TgOtKR5l7PXlx1MDAwZp7KiJI2jpZcdTAwMDFJvGNcdTAwMDK9aupcdTAwMTakzoo7XG6QKmKXQChvXHIj76tebsusSm/SXHRrklL5Y28m00p4eP3Hl8Ghic5GX5yjz7fP/fPGhnpFYniZ7lxu3E3wIGkh1pZrjs2v4PHwoD84PFx1MDAxYyFLvlwifLgumVx1MDAwYvJcdTAwMWU6jC7Jd0GH9HWJLFx1MDAxNuU6T9ReOvBdstnMK5hdL1ReRCyOXHUwMDE0UjxDRn9Pee23L7q960/VyXX/cjpcdTAwMTnfmcc7KiyhW+ta5iclNu53u+v64q5cdTAwMWX7XHUwMDE1z99XbY/16zVcdTAwMTTcxqiD1JuVm9FGudI8XHUwMDFkvcGXi6P2WM8rsalO27ODpOx/vf7glqcybklcdTAwMWGh4JtcdTAwMThf+avZRVx1MDAwNa5cZlxiva4naSqU9EywXHS9r1x1MDAxMnRQXHUwMDBm6LO1azf6zCNcdTAwMWTsiDX3dlx1MDAxZLYrXHUwMDA15Z5cdTAwMWRcdTAwMWNcdTAwMGac9ld3q7NcdTAwMTVMx9/PJ9HB1zVN83m4uChvl0W/VWv+aGxccvZcdTAwMDb9Tv/kRsN8dNv1SYFcdTAwMGLZ65BcdTAwMDcklfSpXHUwMDE1r6PIrjeJXG46XGJcdTAwMTAkriRfI5Gu53iPXHUwMDA1Lp5cdTAwMWS32KgovY1Qk+Q2XHUwMDEzXHUwMDFiK/N0QzLxut/Sy9p5a8/Zalx1MDAxZXcvv+59XHTiXHUwMDBmjjXpeOTIXHSPXHUwMDE2xDyANVx1MDAxM5RcXDhTXHUwMDA0tEex9vt2pDAlx1x1MDAxN8Lz5dM0pZHGNZ5r/v+G3G9o3C/frjq9L1F7Qmronb3GRzvw9s6j2Vx1MDAxOKd0vMBox/eeXHUwMDFl2SlcdTAwMDcnf/xIx071u5+fqeOLef9q4X5065gg9piCXHUwMDE1qlx1MDAxNJgnKNjfXHUwMDA3ves/XHUwMDE00vfpqlj7ZzXKm1x1MDAxOI/rONS9YIP7+Fx1MDAxNth+RS/TXG7OzunZt+ZwtPyRnGyVvcnRwfeeXHUwMDE2d53OXHUwMDFiaW5fXk5cdTAwMTd/opu50VJ118B5X32Sh++Ss/lcZij1T+PyMD+efPtcdTAwMTF/6ybLxTxRV95cdTAwMDeHXHUwMDEyuWol3oFcdEyglXPXz3SlKFx1MDAxOWWU8KVPXCK7tj3y51mqUpD15Srqz5/mYWoy9Vx1MDAxYyPMM8TzeSZmoNq+L3s6IMuSzFx1MDAxNm2wXHUwMDBmon0jPNfxzno9X/WM13OSVzUxLUhcdTAwMWayMcUjKDE+mV3SPMPIfJw3Xlx1MDAwNSXT+XxcdTAwMTNKXrYxRkZdyVx1MDAxMKUjLENcdTAwMDb/fZhcdTAwMTin5GtidEVcdTAwMWHJl3LjLvJv61x1MDAxYq3IdTRKKk3emu9cdTAwMWHzgPJcdTAwMTEll1x1MDAxMOtcdTAwMTJkXHUwMDFkJV3hXHUwMDA33k9cdTAwMTByfENcdTAwMTax0W9cdTAwMTK8VL9WK48hS/u+cF6IrMv5Vt+C6k7Hilx1MDAwNFxui62rpNlo/Jh6leQ6XsT+8lvli9Hr5vTZNGGAflx1MDAxMSXhXHUwMDA1Tlx1MDAxMHiavFx1MDAwYlc5a1x1MDAxYp6YsfZcdTAwMDXos+RcdTAwMTFcdTAwMTP4nvDId9ZGOj9ccp5Q/utOPW6ErXWK+iRcdTAwMDJXa8dcdTAwMTVk+viG+OCnPklyREhcZoUxxvVcdTAwMDPf1z8r+lF7Nt+ejsd9XHUwMDAw5fu0P5nfn2SezTIo4bzX7lrB7N+zSGlov3jiXHUwMDAyVd+1JFbfPq1Axj9uv//jb1x1MDAwZj69Wfi5+E9iv6rvSfy3ycU2cmNcdTAwMGVccklGQGTwjGhW5H6b++Xj6kQ5h2F7vj9cdTAwMWbV5CubXGKvTn6+0SXf08ZcdTAwMGJI4khcdTAwMDOtzGkmP4dM8UBIXyuaXHLfde917Fx1MDAxNY1trUvkMD81XHUwMDEy7VxirYSW7sP+9b+lX3zYS64ue5+Oe51cdTAwMTlqnX9qXFxNJr3R+/rGv+zEm8aeld7oXHUwMDFiI0CkXHUwMDAystuensNzcprLSznJrvy037vezkeLy2T3g8PVI/7XJvCFNkJcdTAwMGLh3M178Fx1MDAxZFNyiUVJtZF3TGblRzDpXHUwMDE1kaiCXHUwMDFl/YU3PFx1MDAxZY1GX05221dX/rR+Xjs72W90zt4g0nXb4lx1MDAwM26vd3SuZo1cdTAwMWZcdTAwMTdcdTAwMTdbjezs4nQ+PCt/n7yW2+t7Snkv2rl6Wuj6LKE1d1x1MDAwNVx0QNLxtON1XFw3kYFr2m1y8cjdTbq+clx1MDAwM/9dQtdkf270KsjM1sJcdTAwMDTBM1x1MDAxMpRazajd9p3R13Nv1PrWb81cdTAwMWHb3z84Ul1k2/luoFx1MDAwMo/Up7vmNzBSXHUwMDExRdJCkVdBWlVcdTAwMDVvXHUwMDE3xXK90lOD1tLBZrSU4uHkpPeH6q/A9Jw0u99TvpXedWe6fF9de7/NN1at/v2rq/RY0IpWT49cdTAwMDJsJb1l7WrPXHUwMDFj1b5cXH+b7M+2+m2388Hx6lx0t+SRoyc0kmDJJbyDV61USWlPXHUwMDE45ZFfpjcnZbynZvWNY9RGT39cdTAwMDXXva1JPdBnw1rim/P5nin3+u0v71xyVz9w3jCm1lVnXG45zW2/XHUwMDFkiG631+m6Z4F0O1x1MDAwZV3xvXbP6Z5pfea+j+5TXHUwMDFi85tcdTAwMWPjXHTPo54+XHUwMDE5SmN9OTtcdTAwMTTdtL1/rPZcdTAwMDZuzzvo6VdO/3t91eeqkkPaTbpaXHUwMDE5T8q7Rqp2XGJoZFx1MDAwNDhcdTAwMWWpPZ9swzdMcJJINFxmlPS9pylA0n5cIkCG/Fx1MDAwN0HUY7bqodfUP755XHUwMDE3rYOGl87yvis6w8rr2aovxeuL1Ov3y951v7f41DzYf19cdTAwMWT7YMNvv8e7mSE8zyezcH1cdTAwMDPkV1xmkXeal4vysTtcdTAwMWN/c7e3u1tqclx1MDAxY77uwa03Slx1MDAxMHY9Q/DX2OFVd09uuZKMVkNgXHUwMDA0i1x1MDAwNN5cdTAwMWJu8b4oP9hR9KznrrH4v0J6cG1aPYv2w17/MOiGf/T3q2l53P6A6cFKPeI6XHUwMDFh43r0v6fHZFx1MDAxZlx1MDAxZfVcdTAwMDdHhyNUSSpslmiXnDfv7kktVyNcdTAwMDb0XHUwMDBl6Hh2frBSvkc4eo28+d/IXHUwMDBmNka4z1x1MDAxMNLf01xc75ZcdTAwMWb8XHUwMDBimn/v/ODH9ZuzcU/Z+K5RbvCMg8nzo8VhO/le1Zfellue7e1VnbODj45g4/klj0xb4brG97x7XGKGrymIyJTCPoZa2+j9XHUwMDE4XG4ukIboZ1NcdTAwMWPooyq4y2y3fT5uXHUwMDFlbu+eXsz6Xn9a+yNxnq/g9DpO3kTBbUaH73NcdTAwMWWI+/RNjIdcdTAwMDf9wdHhXGKvJH1ptCZdJlx1MDAxY3U31uIpclx1MDAxZt9cdTAwMDVcdTAwMWTPVnAyQHSUjL8/V8O5UnrPkNJ/XHUwMDExXHL3XHUwMDBiov8wJ2B8f2NaviekJmF6xvHOQfI1Md5hbTasd3dcdTAwMWHLP4am+fWDv3bDXHUwMDA0QclcdTAwMGZ0ILSnccTau5uga6QoeY7rKq209lx1MDAxY+9+x175tVx1MDAxYo4sXHUwMDE5TXYn0UggXeE+9Fx1MDAxMo7Nz9zi2vGEoVtcdTAwMGbCeqXC5Ner7SP/7Mf+Wbef6+Z5XtGmup7K04xcdTAwMGbi/GRvf3LcX5x+nYzNNFx1MDAwZWvvXHUwMDFjajXIXHUwMDFjelx0PTxtm9FtXHUwMDBi4Vx1MDAwN0K1zVlcdTAwMTJ0z4x/XHUwMDE2XGK/51xi6Xe6QnaM1zZtkVx1MDAwNK9cdTAwMWFq3Zzj+8hpNEfh9KTzdEtz2qt9/5IsrnVleFo/d5ZcdTAwMTezk/aPj41FT6qSMJ6RUipXeuruLqNcdTAwMTP4JSPoMl6kodaNvz/xMJpcdMj89X95MuZcXIyHji/TndnhdPd01tqbtGe1dah9XHUwMDE4LD5srb5HkFa5Jni7hFx1MDAwMtVBmqHSQnddo7tJW5N1qvWZXHUwMDE3nHW6XHSZTF6v2yNb9V02VdzNSldcbuqW8vTTgf54muiHXHUwMDA0uuuZUlx1MDAxMLieXGLIXHUwMDA2XHL8e+lcdTAwMDSuxpFcdTAwMTlHaun5nv92WXrBM/dTNJn3nr8pnf/fXHLsv3Fo5m+P1fu2WUkuKcn3S6Qof1x1MDAwZj9cdTAwMWT2Lq/f+1DfQ+2+hpewka/kZr5y8HK+Z53jW+xVTs7Sy+Nv4lp9OT/Ls9F85n9swnJ8UVwi4PsyUIp0yF1cdTAwMTffJcNcdTAwMDRv7fNcdTAwMDLX90Fpb8ZYrlNypaO00U+jLENcdTAwMTRKvf1VXHUwMDA21Ifho9eMZv1cdTAwMWWyrT/+vqi+3+bbpki5XHUwMDFid22lXHUwMDE2UrqBI56+MaV6mfnj+2T03feS/PtVbVx1MDAxMv043fvgiVx1MDAxZE5gSlxup1x1MDAwMXyhfc+T9/alXFxd8oVcdTAwMTEqIFx1MDAxMLmPnJR6XHUwMDE1b0PLkuuRh+dcdTAwMTjCtmPWX1x1MDAwMbd6jeDmZ25cdTAwMWR/XHUwMDFjMd544PDfXHUwMDA0z08y94U5U3g9WffszD3rkumvXHUwMDEyst7MmUy6Z/oskV3dXHUwMDEzSS943Vx1MDAxY6rN51x1MDAxMlx1MDAxZH/zNjD5uVx1MDAwMXUweLrF/7hcdTAwMWb2MdGGODiCzb52nUCsv1aHfXvfK5FcdTAwMGbmKrKxSWGtZVC8tlxuxVt0/UAr31x1MDAxMFikUsFcdTAwMDOKVFx1MDAwNk7JJ3WuXHUwMDEx1veIIH5cdTAwMGWxKcd3XHUwMDA0Xn37INL+1HOJnO30ludcdTAwMTJcdTAwMWaPLH26cy5RekL5yiFPziXfKVj5vZ9uz1x1MDAwMJJlpVx1MDAwMVx1MDAwMIG31NFDP1xy/knnXHUwMDEyXHUwMDFmTy282ykhpPak52hpXHUwMDAyRdy+ylxyW/VKllx1MDAwMj9w8VpcdTAwMDasvyN/XpN/yZOJXzbLP9/+WfRXNf4mXHUwMDA3up53//LteSdiXHUwMDA3Xzwn6PG4XHUwMDE3+VEp0JSMI1x1MDAxZG1cdTAwMWOPmHDNT+VDXHUwMDE0XHUwMDFhL199XHUwMDFmXG6Uvlx1MDAxN1xiXHUwMDAxWDpG+c5aNGrFgZ4o6ZUgmPVXKN+83sAzhlx1MDAxMPtcdTAwMWEv0Ht9XHUwMDBlfOmBiidy4OPH7e5xoENL6uFduYHx3MDz11x1MDAxZSvYximRze34hnxpMsy1eOHh7GedXHUwMDE4R8Y92b1cdTAwMDFRIdmVwVx1MDAwM71cImZcdTAwMTakSlx1MDAwMs9cYk1cdTAwMDSu5U+9+lflwI1cdTAwMDDg+z/L/jNJ8JFcdTAwMWSeR97i4irPv5OT+CtcdTAwMTJMlyr/4ndcdTAwMGbSw52vW9uTLT1zg1c+mfLq6Vx1MDAxMlx1MDAxZd6H5CDtXHUwMDEx71x1MDAxNNL339uuSeZgXCJ65OJcdTAwMTBcdTAwMGXcjelcdTAwMTLveTRFu4FyRPBcdTAwMWHG3ss3QslcdTAwMWZ/yzMnf8Z7XFxcdTAwMWWBycbzW47ruo5cdTAwMTTPeItLP1x1MDAxY1//cTCvXHUwMDFkbNdcdTAwMDbmYHE2zKvZ/MOjxC25XHUwMDFiUUJ2WUmvoWSjqfCeIMF741x1MDAxZLyH6k9cdTAwMDRcdHlcdTAwMTDqxa9k+bNB8pdCYX1uX1xcXHUwMDFjUnd6t4qeZrLfLUKYq8F8xlmPrcdcdTAwMTfzL1x1MDAwNfh4XHUwMDEwK0Pr6X/SZGVrfFx1MDAxZffHvcY6cv97dp3+dTlee1+CXHUwMDA13PP/XlxuLWNcdTAwMWKHVv5uv/79TvX/XHUwMDA30U5cdTAwMTKr77uxOs22TOd4eZXkot/ePVx1MDAxMEller2vu7qbOTrKnOtknFxcR4PyXCLaXHUwMDBl8u446Ye73YvT3YPp98PQRJWtRW87TNs7R1x1MDAxN6fqXFysX+uOR6Ou2LvuVUQ/2i7Po0b5Kq7U0zhcdTAwMGaz/UEq4n5ZR4Pwqlapq3Bg/GTnq2hvb1xyv1x1MDAxZu7FUd40+4OhoWeW0bZZxOMqPW/o+WZcdTAwMWFXWldRXs/CSjmNKqg3uao1yiqs1K/iRrRsVIZqf5DkXFw2r+LZZatRx7UsPiyjnjyuhCn1icYk6JrJatvlPDo0XCLKz2f7jdZif0Dj6JezmPpcdTAwMTdcco7O0Sb1W1JcdTAwMWJUTyTo3oLaX9i66in1l9u291x1MDAxZminXHUwMDEyXsWDJvUxos/UdCpVVTs0S4wnXHUwMDFhVLm9jX2sRHS9Sf0rL+PM9jFcdTAwMWEkSyqzrFx1MDAxZIqrqNLKfl1W2LKV01nUaDn7g7KKMpFhTWiO8s3lh1R/NI9y+lx1MDAxY1SdRqUlaZ7ykPpcdTAwMWZhPVx1MDAxYadcdTAwMWLnZr9cdTAwMTFKnsdDUcxtN4rylPrdXHUwMDEyUUZj2TaydvzIulx1MDAwZXjO9H5cdTAwMDPrXHUwMDFhu2F/a9w+Xs5Ixlx1MDAwNlFcdTAwMWWqlmrKsO//9fvu1nl3J01PSc5cdTAwMWGNSHFblTStNajPeVm3MkGyVNY0flx1MDAxYU+V5IXqXHUwMDFihLRcdTAwMTZcdI0pUfuNqqA+YexL6lx1MDAwYj5cdTAwMTfhNq1NntLYQypcdTAwMWJqKp/vXHUwMDBm6rTemO8qrTPJ1SCco96Q1zZ19lx1MDAwZqn+XGbPJTTuqkPzgE9ccjmkeaFxUX9cdTAwMWGRXHQxt41cYjKrbT1RyuswgFxmQ95a9DulOlt8n/pKc1x1MDAxYt6sI36nNPdcdTAwMGXNzZw+XHRvUUoyrOI8mdOcauo/9bepovFijvWIt8tUtmpqO9Vljdc7Qr1cdTAwMGXhKo/yZIWDPtYoTWPgbbA32G9EVM9QU3mD+SF50VRGxVmZ8GKWhNt5rVx1MDAwMblv0diaNIdNJ96pZnGlSeNE30NB/VrGg6KNQ3ErXHUwMDBmmCNaXHUwMDE34oHWMoK8NaI5cIg1oL5Kkmu0aWrbXHUwMDAy/CBcYpPUTl2yLFRoXlx1MDAxYlUq28R6gFx1MDAxZkSt0qJ1XHUwMDFkklx1MDAxY1ZcdTAwMWS6R/1OqV60XHUwMDEzLWJuj57dRvsth+pU4Fx1MDAxZuqHY9ed6yAsXGZpvlKH5zhvkdxhLZpcdTAwMTnVR30oU1x1MDAxZlwi/Macy1qjPo9InmJcdTAwMWVDXdCnQt14hrBAz1x1MDAwMtNVQ9dzbitcdTAwMTM0P3WH5adBc0XrW6P5seuLuqvAhYytzNAn+lpcdTAwMDaHXHUwMDAxU0s7hnpGa0HfsfZpSu1TP1wi8Fx1MDAwN69cdTAwMDFkkWR/XHUwMDExXHUwMDEzXHUwMDFmkozTXHUwMDFjVGnOeVxcaFx1MDAxM2upIFx1MDAxZrVKXHUwMDE18rOkdUrpPv2OUNeCeFx1MDAwMNdcdTAwMDVkgp6z64Yx4ndGbeZcdTAwMTGuq7hcdTAwMDH5pblpQC6prcqQxkHymtdpzZtYXHUwMDFmkocq9X/o1Fx1MDAwZcs0xylkfok1t9chXHUwMDAzZepvXGJcdTAwMWRAn5FD44Is5PS8YpnI8HuY11j26nltZ1x1MDAwMYzSfNJcXGFeXHUwMDE1cdKgibaJiyCnXHSNTeTMJZUkrUFcdTAwMTdcdTAwMDDrgyHkXHUwMDA3fFx1MDAwNVx1MDAwZXJcbn4v+k7zRnxEcoi5p3lFm2Vj5zp17FpEin6rQlx1MDAxZiiLJ1x1MDAwMXxl9nlqu2HHSmPIMSaSXHUwMDFixdy8jb5GkEP6pPnZhkxcdTAwMGZJXHUwMDE2MLeWh1xiu1x1MDAxOXNcdTAwMDC4ssGf4NlcZrJcdTAwMTejzKDKMlx1MDAwNf1GZUlcdTAwMGZcdTAwMDLTVegsWi/CKNZ7gDWgPlx1MDAwMGtccsxJ3ezfrDvphn3mXHUwMDEy5oxFXHJcXI52SVx1MDAxZahdktGyxVx1MDAxMDCYkzxAl9JcXFx1MDAxNXOTY31J9o19Nlx1MDAwNL/hWUnyxlx1MDAxY1Wr8Jou7NqgL1x1MDAxMThxyfJDXHUwMDE44e/ZXHK2WMcuao1cdTAwMTTfM8ggOFx1MDAwNHJb8EIhS+DEKl1cdTAwMGaVnWPSS8RcdTAwMDPcZlx1MDAwNnlcdTAwMDXvXHUwMDAyU1hcdTAwMWbiXHUwMDE1Xr/IyiCVYZzm3OfczpXFXHUwMDEyrVx1MDAxZvBOfMxYwvg1MFx1MDAxYvH6glx1MDAxM6GvmnPqr4SeJlx1MDAxZUZcdTAwMWZkbGXSiVkmMX6WyYWV3aGqXHUwMDFkL7g8eITuXHUwMDEzXHUwMDA3RsvY8iT4T1x1MDAwMF8x46KKPlq5PyQ52y5sh1x1MDAwNsvZMlJcdTAwMGJwXHUwMDBi4YvwxutxNKD6ZY2xUdeFfFLdeD5lLqByKXFxZnkoXHUwMDA1XjRkdp/WMc6YK5aYn1xia1x1MDAwMK5cdTAwMWaAn4Y38lx1MDAwNXuF5pzqXHUwMDA1j1dcbjw3aHwsXHUwMDA3dazF8oaHqV9cdTAwMGWvXHLsK8hqXmV9RXIyp3U1LD9cdTAwMTkwloBcdTAwMWJ13Fx1MDAxOKJvi1x1MDAxODJcdTAwMDd5Zb2XYG2onVx1MDAxNttcdTAwMTNUnmQnXHUwMDAxtjSvd5/1rMN9J11s5YN1aVx1MDAxNrHOSeY8Xq6vNbfrQd95rXh+citHUcayvY3vZaubraxlsE+srmY5Rt+IXHUwMDBiI3Ah1sOugeVGQ3qax1x1MDAxMvFaXHUwMDEzvzKWXHUwMDEzYWUhIVx1MDAxYlximE7BXHUwMDFkorDHMNdkf7Csgv9cdTAwMWTIL3iR7Fx1MDAxMWCcflx1MDAwZuk56je1XHUwMDA3XHUwMDFigvpcdTAwMGU7xOqCSlx1MDAxM/pcdTAwMGJcXMrleS7BySyDXHUwMDExZD1HPzqQz1x1MDAxYzxxSrZcdTAwMTD4ISrknWzWjPWAXHUwMDEzgb9Zb4bA86JWrFx1MDAxMbg8qnTPXHS/6Fx1MDAxYnBg6Fx1MDAxZdlH1m4reFWwXGY1WnlcdTAwMGLPZ2XWT5a3W2RjYbwkg7xewFx1MDAxOGSyVXBcIvQy5q6Zsk5nOVxyoctojetk96Is27M52/g8NshcIuxI0kHET6TPXHUwMDBiXsF60pi2WediTVx1MDAwNOODf1x1MDAxN3qMbJZcdTAwMWHw1uc1kHZcdTAwMWWAT3o2XHUwMDBmU+acXG5/ZswhtL7R7fqGzCkx94U4/1x1MDAxMNiGXHUwMDBlYLlyWC54PVJtZTXMwDNcdTAwMTGN3dqPlp/jSmJg39VcYiPWNsF6soxcdTAwMTOWbmRcdTAwMWNcdTAwMTiy60tzYyyGQrYzSM6XbGeA55nvI75OmJzzWNl2qcJm12wnXHUwMDFj4jOFLoJcdTAwMWMpyDHNtYS88FxcQK5g60F+XHTbtVxut2f56pDsfvBBXHUwMDFmfHVjoyRcdTAwMGXwZnVjU690XHUwMDEyc6ogvEjL563c8lxcQrpcdTAwMWFYXGItl0BcdTAwMDejXHUwMDFk64fReOtcdTAwMTKcXHUwMDA0XHUwMDE5rlWG5PPBvlx1MDAxZFJcdTAwMWbYTlx1MDAwMVx1MDAwNpn/qFxy9FNZf1x1MDAwNHJC9lx1MDAxN/WNdFx1MDAwNGSH8I65SdPCLrf8ynxcdTAwMTjOWVx1MDAxNizussLWXHUwMDEz1latgq/yQnYwT4tcdTAwMWHLYDVcdTAwMDPuYqz7gG1cdTAwMTnoM3BcdPOeravu2GdcdTAwMTPqO9eVWTnl38A2+2wx610rW2xvXHKgK0NwJORcdTAwMWb8kPOaW1x1MDAwZVx1MDAwMZ40jzvDmPCb/Tdj9SF+t1x1MDAxNHM76/BcdTAwMTb0ubZ8XHUwMDAyPVx1MDAxZlr5trJsYvZcdTAwMGaSXHUwMDAyo3Z+bnSxtTvB1zyGgsdpXHUwMDBl2C6F3Vx0WyXJo5XOn1x1MDAxNzqf7VxcXHUwMDFhXHUwMDFi1jpje5ptQujahH0wtn2tLlx1MDAwNZ+SXHJK9j/sjFx1MDAxY7o6gVx1MDAwZVrynNLvyPq2Vlx1MDAwNlx1MDAwN4VtU0nQhmFdulxyXHUwMDE5gi3Eflx1MDAwN9tcdTAwMDdWXHUwMDFlXCJrV1x1MDAwZlingE9hL1x1MDAxMu+wXHIlXG5dXHKdZm2xnVx1MDAwNeTegS6ALVx1MDAxYjXOXHUwMDA3tnzI/iPJsrQ+XHUwMDE15LOOulxuv6BcZu5W0CWwJ2Lm/uFcdTAwMWP6kdZSWJ8wncdcdTAwMWNToL6QjVx1MDAxNfM8tljOrKw2l1x1MDAxNlx1MDAwYpEubD1tsTQkTLJcdTAwMWRcdTAwMGY5XHUwMDE0jFerf1m3RFx1MDAwM7b7cuCFrkmrQ5tz25cqZIt0Z1x1MDAxNX1cdTAwMTJcdTAwMDWng/dhR2uS31xm40S7XHUwMDFjt8DzbGuFrCfAV+B3i03YXsxjS+aHXGayRtzaqGe0fqpmuTCz9lNcdTAwMDK5z1x1MDAwYlx1MDAxZJaxL8e2XHUwMDA0ZFx1MDAxMmvQhD2MtcutXGaFwj6P51x1MDAwNPokMefWLiU/tI+1rFtfO1x1MDAxZmLeqF3Y41W2L1x1MDAwYvshh1x1MDAxY9XAMflwyf5W3mTbk9YkL/ytdM3fctg3ziC3KbdB85Syzd9gWVTWLi1cdTAwMTe8zPzMNiziXHUwMDA11GbO/vh22epo2C2Fz2htQeZTtqOsz96Stt9Dq0PYfk+19b/gR9z4vuDrlkN+RM4ySPJcdTAwMDVcdTAwMGUjO5PagmySTckxXGbGQWbjXHUwMDEztFx1MDAxZcytTbVf2GjAXHUwMDAzx3MyzHdds1x1MDAxZJPf+L5RVuhcdTAwMDdh/VHyp1x1MDAxOJOFX5ojjlFnf4bHXHLbgOaD11x1MDAwMnbIXHUwMDAw/lx1MDAxMM9cdTAwMDFwU3Ap/E3Gn0ScjPxcYsfW0XLsXHUwMDFhQN6xNqRcdTAwMDP7bNfQWMA7dWF1WKLYjlx1MDAxYbBcXJvCls8tVtmHhe21ZF+3kdz48Mb2gedC3PjfzO8nXHUwMDE3c8tcdTAwMGbAN61cdTAwMTHr1ch0XHUwMDE4Q8zt2q41/IeokOvugPmK9DhsXHUwMDBl+Fx1MDAxZrw+22XLb+zvhDd+Smaxy2tcZptqYe3eXHUwMDE2yyF0UZxcdTAwMTWyXHUwMDA2LGGNLY9cdTAwMTV+XHUwMDE0dNdcdTAwMTD+krD2x8GAfaxcdTAwMWNYhl1Xhj0tithazjyEXHUwMDE4Z99iXHUwMDAzMbS4XHUwMDExn7P/b20m+EiFL4qYV1x1MDAxNZi+aV/VKudup4H4XHUwMDFkx1x1MDAwNVx1MDAxNi20M1xiIb9oXHUwMDAzNrHiWFxybFx1MDAxOevj07qlurDDXHUwMDA0sFx1MDAxZFx1MDAxZlx1MDAxMz9cIo6GuF6hb61vyr5cdTAwMTbZdoxtYfmrXGaeXTK+XHUwMDA2dev7c1xcJeGYpcVnXCJbbEeki4JbXGb87VwivpNcdTAwMTVxXHUwMDE32Hm6w23w3C5cIn1cdTAwMTFcdTAwMTO3ZjxusqVrXHUwMDFjXHUwMDA33ZtBd2KNyT5eXHUwMDE2PpQtg/VcdTAwMWRHzKFWLzfRXHUwMDFl/OybdcysjividrDhjqvcn1pcdTAwMTGfILujiCVWwVx1MDAxNTSfbDdyXGbU+s02nkPyfVx1MDAxM2ODTkZcXCq3Nr7IY/hcdTAwMWHQ95i7XHUwMDAx6SjogUaS22e5v47VXHUwMDFkMa1VKGvMxcBIPKDvhKu6jfVcdTAwMTHfU79cdTAwMDXHXHQwfubuqNDbiE8gdso+KuzQRWRjkTKucDyO9Fx1MDAwMa1xznaCZt+0gZhK3eEyWOO8ztxa4L7ww+FTRjx2+Edsi9hYXHUwMDE3cf9cdTAwMTD6RkLP1KzNwXZcIsrb9U1s7L1cdTAwMGZ/u2XXl+NcdTAwMTVDxjF8Q7u+0dLGXHUwMDFkWqmNO1x1MDAwMddNtrVcbjxcdTAwMGKOXHUwMDE5wma39edtcDvs//GC/WTm+Ia1qVknckw8LGyScHmDlWi76CfG2YA83sbYpOWT0MqfvqD5aFx1MDAxNnJcdTAwMDXMNlx1MDAxNc3DkueFeW/osP22XfjvXHUwMDFjK1x1MDAxOc2KWEFh/ybKcpiVcfbFOUaHeDXHMax+y2H7Qlx1MDAxZbBfwVxcvbB1MleBI0hPhFx1MDAwZfeBcFFwP2PPclx1MDAwYuqF/q+yruH9XG72x6GrkqX1IWBHtW701Fx1MDAxNXhcdTAwMTUxOdYxha1Vsz6RXHUwMDEzWV+cZOur28FaXHUwMDBmklwidnSDp3R561x1MDAwZrFNhlx1MDAxOCQ4XGK+PONKXHUwMDE3c73gsXHcXHUwMDFh/WNcdTAwMWQqOL7XYL2trc8+zGO1uGJba1x1MDAwMN2DsXJcdTAwMWPB2qE7XHUwMDEx206EXHUwMDAzY+OyTeg4UbM6T7CPS3ZkPChDd9G4XHUwMDEz61x1MDAwYqhcdTAwMDX0bFx1MDAxMedNmeuZh1x1MDAxYk3Vgj4jncrrw+uZLFhXXCKOzrqs4DiSv5jjXHUwMDFkZFx1MDAxZpP/UWvA58VYWsKOXHUwMDE1MUP2eYE1trN57nZcIpaZXCI2n1x1MDAxN/tKXHUwMDA17lx1MDAxMjtcdTAwMDc5/FX6PLZy2+FcdTAwMTgkx/xcdTAwMTRsPXBRbG1m1COZR1x1MDAxYa1cdTAwMWKehNxbX3y70GFccuwz1JWN1dehPyT70DZcdTAwMWUvXHUwMDExu6ixf1x1MDAxMc25ruNFXHUwMDFlsy9SZ05cdTAwMDLu2lx1MDAxY7ePeYzsXHUwMDFi33wydoFcdTAwMTXWXcpiZNpcdTAwMGZ3Y9E7Xo6+XHUwMDFm7onTk3Oxfzy6Ot05uupWpouaPlx1MDAxOPV26/PW8fLiVFx1MDAxOTfRXHUwMDA358mk7nV26JlDOT09XHUwMDFlTdq7dbczXHUwMDBlss7x19nN893dvfPOJFx1MDAxZXf03rw2dq4746bXXHUwMDFhL69bajZPdveuT/XeKNHxRYfq7O6EXHUwMDFllc3a6ii7eVx1MDAxNn1oa/JwTrZG+8en151JfZ7orVFLjcbt4/i8uzO67lxmLlx1MDAxYa1jJ8d+UEc5Yv+kO2pcdTAwMWZ3p92K6JONvqDrg45aXidcdTAwMDPRXHUwMDBmc+xhYX8jTE/Ho1mHnlx01emY/+2HKY0r66j5XGJ7SsXVMZXPaVx1MDAxY9dt1Zx31WjY3UmDkP3PMO3o01EyPp11dFx1MDAxMoST04tEjfqdnWY/3LF9PVx1MDAxZH+dt4+XXHUwMDBlzWnRR/+v3+2+6V/Xzq9cXPbu7LH7ruN6a294Rlx1MDAwNthBb37Z713//NR69sfT/1x1MDAxYeVL9pyf/6cu32PPOYvv7jfz73t7zYuQeY3+u90z3LtcIllcdTAwMTmtrW1cdTAwMTCOY5lM9kadyUGF5WVNXHUwMDFlSKZu21uXXHUwMDA3ln+Sw4TaScaB7IzrjCP6fkm4mLWPnVF7XHUwMDFjXFx0XHUwMDA2a/d34lnrJM6pXHUwMDBmXHUwMDE3LVx1MDAxNVxchTsjkk8z7+58Jdk5yuxvJ98/OTgnrIySvrzunlx1MDAxY3Bd95/dP6Yxboe3e54r+b1cdTAwMWSf3T+nOSA9K7rkp1x1MDAxZFa23J/n4f6+aZV4Pp5ZO/BoRuVcdTAwMWTS74a5j+xcdTAwMGK6znGoXHUwMDBl2VP7pCtvfj9cXE+V+DBN2Zc4XGavv1x1MDAwZpaL1snBNNypXHUwMDA34Vx1MDAxMHFcdTAwMDfSL+NcdTAwMDWt0bC/n9/b92fbbY905nr5xXWiTyff0//5n0cw5DueXnu7wVx1MDAwNlxm2afuYOjJXHUwMDE5KC/B0PPTW/6DoccxNOtcdTAwMWXHXHUwMDE3pzvNtLN7NGhcdTAwMWZcdTAwMWZQO/G0fXw0O92WXHUwMDAzXHUwMDFh06CdyVx1MDAwMmdLSXpjSHXm7Z3RqLND+kydky6Z9e/JZFx1MDAxNsFfVFx1MDAxMY25iThDXHUwMDExXHUwMDBm5FirQry31jg6h03O/lx1MDAwNPxcdTAwMGXEXHUwMDFhMti/Xyt2v1xiMV7o8zLv+yFuczQ4vVMmxH7kccQ+XHSwwz407ynyniHZXHUwMDE0e+e8XHUwMDE3wbGlql4rd8V7rlXeV5I2XHUwMDFlXHT7XHUwMDAzz7Dvl8LeXGIrVtfzJ7Xb69/D3ljAfsw4/1x1MDAwMLHyRp3xt69JlnLzXGK2XHUwMDAy4Vx1MDAwNZ73K2xcdTAwMTVP3dVPT/2TXHUwMDEzL9JPz/57XHUwMDE2/8HWc7F1XHUwMDFjX3dOtmSXdVx1MDAwZuuiXHUwMDA373H+1vho0N3e0nRPtDHOXHUwMDA26j6/tc1qNFx1MDAwZlx1MDAxOD/sqF6D7mM8XHUwMDEzsq1cdTAwMDah1Vx1MDAwM/fLXHUwMDBmylx1MDAwZpZHOS6P+nf3jJ2D+3pcYnHA82l0XHUwMDFmXHUwMDAzhf6p7aTqabZZoFx1MDAwMuOv/7HHh2XfPnVXrzz1lfMv0ivPfp/9f2T/2bLfnXXU3uhnubfXIfOkX7KWWpKdXHUwMDE2Q1x1MDAwZZeQzZZcdTAwMWWSTFfRt4yvbT8og4izIK7F+52cc2b5XHUwMDFl87OwsaKf72Ev8WdZZ5tpXG68PJHPtTHe+lGeh2XaPnWXz5/6bq9cdTAwMTfx+bNfXHUwMDFj9u8o04hp/SS3LfV1QXVmXHUwMDFkfXR1ur1Wjvzzzvjoj45cdTAwMWFdrd8nmTxPdERtWtltg6v7UrSO92anJ6H9PYnW/IqAeFx1MDAxY3U17z9LNtPyuntcXF/ZTDd9I1x1MDAwZSb//+qU+NzKIcnoTprFu60srqZcdTAwMGKyP2597uK5+ziwse1cZnxcdTAwMWNxXlx1MDAxZO9xWt+CY4ZcdTAwMWNzrpR1eLuPxvdErVx1MDAxMlx1MDAwZjinmPf4m0VuU9P6XGZcdTAwMTXszdc5d9SWq5tojD2SYVx1MDAxZfK+I/I2OG6EfVx1MDAwMth7mc3LTWTI+8Y0c4hcdTAwMTdcIm/N5r1cdTAwMTR7PE2F32Rv5TbPXHL5MV9cdTAwMDdcdTAwMTHn+KSp3a9ETFx1MDAxMbmxZd63s7ZcdTAwMWNyLOy+SnvbOLVj1D3EXHUwMDFlx4LbrbRs7ofNb1x1MDAxMkXsneo8XHUwMDFksK05XHUwMDE4XCKnY857bIjn8D3MXHKXlbaOVLdtLoCJ8/SJPOBcdTAwMWKP/KFf6bbiqTs88OQ3f72EXHUwMDA3nv9asX9HXHUwMDFleGu7bnp6cppx3OF4OTpFnG3MMb35KfW7fbKVt0+iueWCrZvy7D91J3vMXHUwMDFkxFx1MDAxYuPutszYPtuW5F8tL5Ld+ILQ8mBMXHUwMDAw8cnogXhcdTAwMDDH2MfIta7ej1x1MDAwN2DvKcW+XHUwMDBm/DLkXHUwMDE43dd9XHUwMDFkvi9wP33ofqORsm7kvbmnxeKU4zrrR/1cdTAwMWbGhH3qXHUwMDBlJp78eoyXYOL57954O0ys5Fxuua+LcFx1MDAwN/vo5bRzglxcpVx1MDAxNee39FFGNv1Vt0oytFx1MDAxM0CeXHUwMDE1+em6Plx1MDAwZWArWZumUeb8sMhiZs13KN/Hm33m3jmRmGVrq0Lcjlx1MDAxYytZY1sp1DXss1xmWmnMuVx1MDAxZmHGOobzuHhcdTAwMGZlafMsTyPicvAzeFuRnERR3tz0O4/yvYjqgy7Io+FC8J451/fAb957PqhcdTAwMTAvI4dI2fK811x1MDAwYp9dcC5aZZja+qvI25Q13nPHPnVkXCLeXHUwMDE3O1wiLYezXHUwMDA3TVx1MDAwN/l2dlx1MDAxZlx1MDAxMflVLXm7f1DBflMrJ12iYs6bQ/nmMlx1MDAxYUbrv5Xd0yWsrOpTNj9cZvm2TXOTv0m6J+c9oGYkY97/qNN4Wpr3wFx1MDAwNmG6Nlx1MDAxZVVDvlx1MDAxYu991W3eXHUwMDAz4lx1MDAxMIOhzSu4M1x1MDAxZtWF3UNcdTAwMWXRfCBcdTAwMGY95XziXCJcdTAwMTdcdTAwMDR1cm63vVZP7ZriO81fPlx1MDAxNEWOXCLfK+RF1zhPIbn5LWmtKsX3XGY535zfdVu2XFzUudUmLlre9JX+s/v5w0jw+Fx1MDAxYlx1MDAxMfJRMfdcIrJ2SF7jPJVyflNHPafPPDK8d0S8XHUwMDE1I05UQW7gMKdy2NtFTkox7qHDuarNxdq1lkOysnz0N/pSOSDZrN7KYo1zPuqiUXmg/6OL2PYpZDmN8z2at/qSc3My/EZcdTAwMWVcdTAwMTTOmiSC9z1cdTAwMTH/yVx1MDAxM85cdTAwMTXFtZrNXHUwMDBiwDXBe0PDhcNncWx+sIHsYH7J/sl4XHUwMDFmkWSxhlxczVx1MDAwMfZcZoeS99aRf1FcdTAwMTlyLj/Jolx1MDAxM3M+gt2jtvvLpyiPXHUwMDFjJuTTSbLpIDske0PF51B4f5hzS5GX4fB+IMtcdM5twCZcdTAwMWFRecg679lhP1xme+o35Wm+qijP525oXHK4vM1FpPKcm4T+t3gvma9cdTAwMTV5XHUwMDBi9lrI+atcdTAwMTHyOivYc1x1MDAxZCq7v1x1MDAxYqJOh3NPqlx1MDAxMdVcdFlIOFeBz4tcdTAwMTBebHnOXHUwMDE5lLyfynLG5W9wyOVobmw5zpNJi3KcNyY5zyWHXHUwMDFkt95u1dq7RfmIzys1+excdTAwMDfJd7o2XHUwMDE25D4scVx1MDAwZcTOXHUwMDBm9vhQZ8r73/V8fX6QW5Ni39vOL++DNpe8PsSVtjznXHUwMDBlSs6LakTLonyxPpzzXHUwMDAwjuX1RVx1MDAwZVx1MDAxMpXPbE4kymN9q7b8oM7ySuVX8tGA3dtCLiaVRy5cdGKWTexj5vQsla+r4mxcYnGPXf/GmnxcdTAwMTI32fqrkcZ5u4j3ppFcdTAwMWKDPDDiLpz9IZnlPVictcqRK5EsOVx1MDAwZpu5LbL5aiiPc1xyXHIuj/nj/HNeXHUwMDA3sjX43Fx1MDAxOPNcdTAwMDfOcSTcn1x1MDAxYfshraxBWLf5XHUwMDAwTeZcdTAwMDPqXHUwMDFm8qe05TG263Pun61LYFx1MDAxZYinlthcdTAwMGbnvOtcdTAwMWM4seex1vtm9VdcdTAwMWRcXKQ5XHUwMDE3XCJH3yBcdTAwMWbwiVroy01eMuGZc7VQPq/Zc0vAbnY8XHUwMDE0a5xbL/KfXHUwMDBlXCLbr1x1MDAxYp1W5ZyzOulcdTAwMWTOf+/TNc5nxnmJpr7VKyxfnJtN7d/RK2u/64pz71x1MDAxYjxcdTAwMTe53TOvQ0a15YPbdlx1MDAwNZ9ryevi+Od4nFx1MDAwNlx1MDAxZtGc5Cx7NHdYe8w18oitr1x1MDAxM1x1MDAxNZxbdnhO+WxPxOMvclx1MDAxYYs5XHJlnXjN5k9C93O+ouJcdTAwMWOsnPlK8rxw7iPOoUZOkSMsbI4un1x1MDAxZEU+sNVlNldcdTAwMGW6kDCXXCKXWlx1MDAxNnjTkMdcdTAwMWHLc9Wx+lx1MDAwNzkxsFxyhrnNQahcdTAwMTZcdTAwMTjmNXU4R7+CvLom2udzUcivqPGZiiZhXHUwMDAyOYVcXOfC5oqAj1x1MDAxMs5LXHUwMDAyXHUwMDA2bmW4wvnOJmpinpC/XHUwMDA02eJ5XCLOJozmdeZQ5MJHXHUwMDE1zvnFOUecybS/XHRTXHUwMDBmrIHD+a2Htk3MXHUwMDAz4TbjfDrO60GeXHUwMDA3ctPKjj2rwLiHPJNNXHUwMDAzLlxub3L/iz4zbiGbXHUwMDBl5y9cZqqq0LN2XHUwMDFkXHUwMDFhTeTGL1x1MDAxOdfATY68XHUwMDE09smVzWHCPPPa2ryiQci5MLUmyqcoj7xcdTAwMTj0XHUwMDBmNmFqdV1rybI8QL4mn+3kc4/IXHUwMDE5Kc5cdTAwMDTnnPeEfCq7rszNNqdcbnPYXFxy/kxcdTAwMDPcXGaeSJGPzpxeI44r1pX00LDQXHR1VYNcXFx1MDAwMG9cdTAwMTWcr+D5UOCBXHUwMDFh58DUc7uGLc4/oWua94d4LknmjqZPjdW5gedp71exOvvU7aH3f/7ln/9cdTAwMGbX67+/In0= + + + + + Your ClusterLapdev EnvironmentLapdev-Kube-ManagerSecure Websocket TunnelDevboxPreview URLLapdev EnvironmentLapdev EnvironmentAPI ServerLapdev From ab3a088cbb06a89c57e844243eb322028bd92ccd Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Thu, 16 Oct 2025 22:18:48 +0000 Subject: [PATCH 107/334] update --- docs/test.mdx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/test.mdx b/docs/test.mdx index d2c05e3..183f071 100644 --- a/docs/test.mdx +++ b/docs/test.mdx @@ -4,12 +4,3 @@ description: "Description of your new file." --- test test - - - - eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1921LjWLbte39FRu7H7vReN936xH4w2Fx0XCKRnFx1MDAwNlx1MDAxYjAnOjJs2Vxi3ylssKVcdTAwMWT972eOuVx1MDAwNDYkJoFcdTAwMDSK7tNVUWVb0rrPMeZlzSX+9y+fPn2eZ1x1MDAxN73Pf//0ubdM2qN+97K9+Pw3XFy/7l3O+tNcdN1S/Hs2vbpM+Mnz+fxi9vf//u/2xUUp7c870+mwlEzHtlhv1Fx1MDAxYvcm81x1MDAxOT34f+n3p0//y/9fa6g/bqc9fpgvr9pxlLl/NZ5OuE3pO4F0fOm5t0/0Z1x1MDAxNWpr3uvS7bP2aNZb3cGlz9W41SnLpHvZaF5e1HeHnerXRWPV7Fl/NDqcZyPu0mxKI1/dm80vp8Pecb87P79cdTAwMTn+2vVNpS6nV+n5pDfD0OXt1elFO+nPM1xcXHUwMDEz4vZqe5JyXHUwMDFkqytLPCFdUXJV4CvluDJwhXd7XHUwMDFiXHUwMDE1eFqUXHUwMDAy41x1MDAxODdQwtPa9+91bHs6ml6iY/PL9mR20b6kdVh1r9NOhin1cdJ9/LlFMWyjSs7txfNePz2f37866/Hs+472XFzfMavVQztcdTAwMTdhl4XgXHUwMDFmqym/bI97IUpMrkaj9XmbdIt5u3Ojg1x1MDAxYtU1eVpVdXXRbdulJ5FcdTAwMTBGXHUwMDA3nudcdTAwMDTOaqVG/cnwfnWjaTJ8QFpm8/b8XG7Vf77oTbr9SXpHRmx/P7uiIzw36VxiY4Rvek7Q80xXdYw88410gm7S6/ZEt7suQ4Sm3i1cYvDPl1uh+PRJXHUwMDE23/7Bn//828NIuewlcyspXHUwMDBmoEW6ciNagiCQytfCfzJawt1j9eV67+zotJxcdTAwMWTNK4E8/tGS74VcdTAwMTbxMrSIQJaMJ1XgXHUwMDEyNVxiXHUwMDEzmDtoMZ4pKS/wffpPe56437FbXHUwMDE0/Jfs4d+XI8V1XHUwMDAzwiW15PuBUUL5P8NGXHUwMDFiU9La8YzWgXD1T1x1MDAxMFxuXGI/Qiklflx1MDAxZkL/eytkN3Kkiyv/fFx1MDAwMbKMp5XytHxcdTAwMDayXHUwMDFlXHUwMDE16XlvOX9YmvUmaVbad6TWUj1ZmFx1MDAwN85g+cd0/r2uXHUwMDE3c+2Yqdjd82ZcdTAwMWad+oncjevRP1JcdTAwMTChundkmditRIAmveC6jvLU28kygaqkpWuEq1x1MDAxZWB/pe9cdTAwMGKu9Fx1MDAxY1x1MDAwN332/zXJ/2w6mVx1MDAxZvZzXHUwMDE2M3Hn6tf2uD/K7shcdTAwMDDLLk1giyyhT9ujq9m8d/n5zt3yqJ9Cmj+Pemd3xXzeJ31we3s+vVjdTai1dn/Su/x5dqaX/bQ/aY9cdTAwMWGPtkzj7e3eLJEsSWdt9Wc93GWd8HJVo9bI81x1MDAxZTh911x1MDAxM1x1MDAxZSnF1eT9XG6c41x1MDAxZvPUnU6Xrpr90c6c5VkjXHLMq4Kz256d915cdTAwMTedRIMl7flkhbpkfml/hYOMXHJXt+RcdTAwMDa+o0TgOtKR5l7PXlx1MDAwZp7KiJI2jpZcdTAwMDFJvGNcdTAwMDK9aupcdTAwMTakzoo7XG6QKmKXQChvXHIj76tebsusSm/SXHRrklL5Y28m00p4eP3Hl8Ghic5GX5yjz7fP/fPGhnpFYniZ7lxu3E3wIGkh1pZrjs2v4PHwoD84PFx1MDAxYyFLvlwifLgumVx1MDAwYvJcdTAwMWU6jC7Jd0GH9HWJLFx1MDAxNuU6T9ReOvBdstnMK5hdL1ReRCyOXHUwMDE0UjxDRn9Pee23L7q960/VyXX/cjpcdTAwMTnfmcc7KiyhW+ta5iclNu53u+v64q5cdTAwMWX7XHUwMDE1z99XbY/16zVcdTAwMTTcxqiD1JuVm9FGudI8XHUwMDFkvcGXi6P2WM8rsalO27ODpOx/vf7glqcybklcdTAwMWGh4JtcdTAwMThf+avZRVx1MDAwNa5cZlxiva4naSqU9EywXHS9r1x1MDAxMnRQXHUwMDBm6LO1azf6zCNcdTAwMWTsiDX3dlx1MDAxZLYrXHUwMDA15Z5cdTAwMWRcdTAwMWNcdTAwMGac9ld3q7NcdTAwMTVMx9/PJ9HB1zVN83m4uChvl0W/VWv+aGxccvZcdTAwMDb9Tv/kRsN8dNv1SYFcdTAwMGLZ65BcdTAwMDcklfSpXHUwMDE1r6PIrjeJXG46XGJcdTAwMTAkriRfI5Gu53iPXHUwMDA1Lp5cdTAwMWS32KgovY1Qk+Q2XHUwMDEzXHUwMDFiK/N0QzLxut/Sy9p5a8/Zalx1MDAxZXcvv+59XHTiXHUwMDBmjjXpeOTIXHSPXHUwMDE2xDyANVx1MDAxM5RcXDhTXHUwMDA0tEex9vt2pDAlx1x1MDAxN8Lz5dM0pZHGNZ5r/v+G3G9o3C/frjq9L1F7Qmronb3GRzvw9s6j2Vx1MDAxOKd0vMBox/eeXHUwMDFl2SlcdTAwMDcnf/xIx071u5+fqeOLef9q4X5065gg9piCXHUwMDE1qlx1MDAxNJgnKNjfXHUwMDA3ves/XHUwMDE00vfpqlj7ZzXKm1x1MDAxOI/rONS9YIP7+Fx1MDAxNth+RS/TXG7OzunZt+ZwtPyRnGyVvcnRwfeeXHUwMDE2d53OXHUwMDFiaW5fXk5cdTAwMTd/opu50VJ118B5X32Sh++Ss/lcZij1T+PyMD+efPtcdTAwMTF/6ybLxTxRV95cdTAwMDeHXHUwMDEyuWol3oFcdEyglXPXz3SlKFx1MDAxOWWU8KVPXCK7tj3y51mqUpD15Srqz5/mYWoy9Vx1MDAxYyPMM8TzeSZmoNq+L3s6IMuSzFx1MDAxNm2wXHUwMDBmon0jPNfxzno9X/WM13OSVzUxLUhcdTAwMWayMcUjKDE+mV3SPMPIfJw3Xlx1MDAwNSXT+XxcdTAwMTNKXrYxRkZdyVx1MDAxMKUjLENcdTAwMDb/fZhcdTAwMTin5GtidEVcdTAwMWHJl3LjLvJv61x1MDAxYq3IdTRKKk3emu9cdTAwMWHzgPJcdTAwMTEll1x1MDAxMOtcdTAwMTJkXHUwMDFkJV3hXHUwMDA33k9cdTAwMTByfENcdTAwMTax0W9cdTAwMTK8VL9WK48hS/u+cF6IrMv5Vt+C6k7Hilx1MDAwNFxui62rpNlo/Jh6leQ6XsT+8lvli9Hr5vTZNGGAflx1MDAxMSXhXHUwMDA1Tlx1MDAxMHiavFx1MDAwYlc5a1x1MDAxYp6YsfZcdTAwMDXos+RcdTAwMTFcdTAwMTP4nvDId9ZGOj9ccp5Q/utOPW6ErXWK+iRcdTAwMDJXa8dcdTAwMTVk+viG+OCnPklyREhcZoUxxvVcdTAwMDPf1z8r+lF7Nt+ejsd9XHUwMDAw5fu0P5nfn2SezTIo4bzX7lrB7N+zSGlov3jiXHUwMDAyVd+1JFbfPq1Axj9uv//jb1x1MDAwZj69Wfi5+E9iv6rvSfy3ycU2cmNcdTAwMGVccklGQGTwjGhW5H6b++Xj6kQ5h2F7vj9cdTAwMWbV5CubXGKvTn6+0SXf08ZcdTAwMGJI4khcdTAwMDOtzGkmP4dM8UBIXyuaXHLfde917Fx1MDAxNY1trUvkMD81XHUwMDEy7VxirYSW7sP+9b+lX3zYS64ue5+Oe51cdTAwMTlqnX9qXFxNJr3R+/rGv+zEm8aeld7oXHUwMDFiI0CkXHUwMDAystuensNzcprLSznJrvy037vezkeLy2T3g8PVI/7XJvCFNkJcdTAwMGLh3M178Fx1MDAxZFNyiUVJtZF3TGblRzDpXHUwMDE1kaiCXHUwMDFl/YU3PFx1MDAxZY1GX05221dX/rR+Xjs72W90zt4g0nXb4lx1MDAwM26vd3SuZo1cdTAwMWZcdTAwMTdcdTAwMTdbjezs4nQ+PCt/n7yW2+t7Snkv2rl6Wuj6LKE1d1x1MDAwNVx0QNLxtON1XFw3kYFr2m1y8cjdTbq+clx1MDAwM/9dQtdkf270KsjM1sJcdTAwMDTBM1x1MDAxMpRazajd9p3R13Nv1PrWb81cdTAwMWHb3z84Ul1k2/luoFx1MDAwMo/Up7vmNzBSXHUwMDExRdJCkVdBWlVcdTAwMDVvXHUwMDE3xXK90lOD1tLBZrSU4uHkpPeH6q/A9Jw0u99TvpXedWe6fF9de7/NN1at/v2rq/RY0IpWT49cdTAwMDJsJb1l7WrPXHUwMDFj1b5cXH+b7M+2+m2388Hx6lx0t+SRoyc0kmDJJbyDV61USWlPXHUwMDE45ZFfpjcnZbynZvWNY9RGT39cdTAwMDXXva1JPdBnw1rim/P5nin3+u0v71xyVz9w3jCm1lVnXG45zW2/XHUwMDFkiG631+m6Z4F0O1x1MDAwZV3xvXbP6Z5pfea+j+5TXHUwMDFi85tcdTAwMWPjXHTPo54+XHUwMDE5SmN9OTtcdTAwMTTdtL1/rPZcdTAwMDZuzzvo6VdO/3t91eeqkkPaTbpaXHUwMDE5T8q7Rqp2XGJoZFx1MDAwNDhcdTAwMWWpPZ9swzdMcJJINFxmlPS9pylA0n5cIkCG/Fx1MDAwN0HUY7bqodfUP755XHUwMDE3rYOGl87yvis6w8rr2aovxeuL1Ov3y951v7f41DzYf19cdTAwMWT7YMNvv8e7mSE8zyezcH1cdTAwMDPkV1xmkXeal4vysTtcdTAwMWN/c7e3u1tqclx1MDAxY77uwa03Slx1MDAxMHY9Q/DX2OFVd09uuZKMVkNgXHUwMDA0i1x1MDAwNN5cdTAwMWJu8b4oP9hR9KznrrH4v0J6cG1aPYv2w17/MOiGf/T3q2l53P6A6cFKPeI6XHUwMDFh43r0v6fHZFx1MDAxZlx1MDAxZfVcdTAwMDdHhyNUSSpslmiXnDfv7kktVyNcdTAwMDb0XHUwMDBl6Hh2frBSvkc4eo28+d/IXHUwMDBmNka4z1x1MDAxMNLf01xc75ZcdTAwMWb8XHUwMDBimn/v/ODH9ZuzcU/Z+K5RbvCMg8nzo8VhO/le1Zfellue7e1VnbODj45g4/klj0xb4brG97x7XGKGrymIyJTCPoZa2+j9XHUwMDE4XG4ukIboZ1NcdTAwMWPooyq4y2y3fT5uXHUwMDFlbu+eXsz6Xn9a+yNxnq/g9DpO3kTBbUaH73NcdTAwMWWI+/RNjIdcdTAwMDf9wdHhXGKvJH1ptCZdJlx1MDAxY3U31uIpclx1MDAxZt9cdTAwMDVcdTAwMWTPVnAyQHSUjL8/V8O5UnrPkNJ/XHUwMDExXHL3XHUwMDBiov8wJ2B8f2NaviekJmF6xvHOQfI1Md5hbTasd3dcdTAwMWHLP4am+fWDv3bDXHUwMDA0QclcdTAwMGZ0ILSnccTau5uga6QoeY7rKq209lx1MDAxY+9+x175tVx1MDAxYo4sXHUwMDE5TXYn0UggXeE+9Fx1MDAxMo7Nz9zi2vGEoVtcdTAwMGbCeqXC5Ner7SP/7Mf+Wbef6+Z5XtGmup7K04xcdTAwMGbi/GRvf3LcX5x+nYzNNFx1MDAwZWvvXHUwMDFjajXIXHUwMDFjelx0PTxtm9FtXHUwMDBi4Vx1MDAwN0K1zVlcdTAwMTJ0z4x/XHUwMDE2XGK/51xi6Xe6QnaM1zZtkVx1MDAwNK9cdTAwMWFq3Zzj+8hpNEfh9KTzdEtz2qt9/5IsrnVleFo/d5ZcdTAwMTezk/aPj41FT6qSMJ6RUipXeuruLqNcdTAwMTP4JSPoMl6kodaNvz/xMJpcdMj89X95MuZcXIyHji/TndnhdPd01tqbtGe1dah9XHUwMDE4LD5srb5HkFa5Jni7hFx1MDAwMtVBmqHSQnddo7tJW5N1qvWZXHUwMDE3nHW6XHSZTF6v2yNb9V02VdzNSldcbuqW8vTTgf54muiHXHUwMDA0uuuZUlx1MDAxMLieXGLIXHUwMDA2XHL8e+lcdTAwMDSuxpFcdTAwMTlHaun5nv92WXrBM/dTNJn3nr8pnf/fXHLsv3Fo5m+P1fu2WUkuKcn3S6Qof1x1MDAwZj9cdTAwMWT2Lq/f+1DfQ+2+hpewka/kZr5y8HK+Z53jW+xVTs7Sy+Nv4lp9OT/Ls9F85n9swnJ8UVwi4PsyUIp0yF1cdTAwMTffJcNcdTAwMDRv7fNcdTAwMDLX90Fpb8ZYrlNypaO00U+jLENcdTAwMTRKvf1VXHUwMDA21Ifho9eMZv1cdTAwMWWyrT/+vqi+3+bbpki5XHUwMDFid22lXHUwMDE2UrqBI56+MaV6mfnj+2T03feS/PtVbVx1MDAxMv043fvgiVx1MDAxZE5gSlxup1x1MDAwMXyhfc+T9/alXFxd8oVcdTAwMTEqIFx1MDAxMLmPnJR6XHUwMDE1b0PLkuuRh+dcdTAwMTjCtmPWX1x1MDAwMbd6jeDmZ25cdTAwMWR/XHUwMDFjMd544PDfXHUwMDA0z08y94U5U3g9WffszD3rkumvXHUwMDEyst7MmUy6Z/oskV3dXHUwMDEzSS943Vx1MDAxY6rN51x1MDAxMlx1MDAxZH/zNjD5uVx1MDAwMXUweLrF/7hcdTAwMWb2MdGGODiCzb52nUCsv1aHfXvfK5FcdTAwMGbmKrKxSWGtZVC8tlxuxVt0/UAr31x1MDAxMFikUsFcdTAwMDOKVFx1MDAwNk7JJ3WuXHUwMDEx1veIIH5cdTAwMGWxKcd3XHUwMDA0Xn37INL+1HOJnO30ludcdTAwMTJcdTAwMWaPLH26cy5RekL5yiFPziXfKVj5vZ9uz1x1MDAwMJJlpVx1MDAwMVx1MDAwMIG31NFDP1xy/knnXHUwMDEyXHUwMDFmTy282ykhpPak52hpXHUwMDAyRdy+ylxyW/VKllx1MDAwMj9w8VpcdTAwMDasvyN/XpN/yZOJXzbLP9/+WfRXNf4mXHUwMDA3up53//LteSdiXHUwMDA3Xzwn6PG4XHUwMDE3+VEp0JSMI1x1MDAxZG1cdTAwMWOPmHDNT+VDXHUwMDE0XHUwMDFhL199XHUwMDFmXG6Uvlx1MDAxN1xiXHUwMDAxWDpG+c5aNGrFgZ4o6ZUgmPVXKN+83sAzhlx1MDAxMPtcdTAwMWEv0Ht9XHUwMDBlfOmBiidy4OPH7e5xoENL6uFduYHx3MDz11x1MDAxZSvYximRze34hnxpMsy1eOHh7GedXHUwMDE4R8Y92b1cdTAwMDFRIdmVwVx1MDAwM71cImZcdTAwMTakSlx1MDAwMs9cYk1cdTAwMDSu5U+9+lflwI1cdTAwMDDg+z/L/jNJ8JFcdTAwMWSeR97i4irPv5OT+CtcdTAwMTJMlyr/4ndcdTAwMGbSw52vW9uTLT1zg1c+mfLq6Vx1MDAxMlx1MDAxZd6H5CDtXHUwMDEx71x1MDAxNNL339uuSeZgXCJ65OJcdTAwMTBcdTAwMGXcjelcdTAwMTLveTRFu4FyRPBcdTAwMWHG3ss3QslcdTAwMWZ/yzMnf8Z7XFxcdTAwMWWBycbzW47ruo5cdTAwMTTPeItLP1x1MDAxY1//cTCvXHUwMDFkbNdcdTAwMDbmYHE2zKvZ/MOjxC25XHUwMDFiUUJ2WUmvoWSjqfCeIMF741x1MDAxZLyH6k9cdTAwMDRcdHlcdTAwMTDqxa9k+bNB8pdCYX1uX1xcXHUwMDFjUnd6t4qeZrLfLUKYq8F8xlmPrcdcdTAwMTfzL1x1MDAwNfh4XHUwMDEwK0Pr6X/SZGVrfFx1MDAxZffHvcY6cv97dp3+dTlee1+CXHUwMDA13PP/XlxuLWNcdTAwMWKHVv5uv/79TvX/XHUwMDA30U5cdTAwMTKr77uxOs22TOd4eZXkot/ePVx1MDAxMEller2vu7qbOTrKnOtknFxcR4PyXCLaXHUwMDBl8u446Ye73YvT3YPp98PQRJWtRW87TNs7R1x1MDAxN6fqXFysX+uOR6Ou2LvuVUQ/2i7Po0b5Kq7U0zhcdTAwMGaz/UEq4n5ZR4Pwqlapq3Bg/GTnq2hvb1xyv1x1MDAxZu7FUd40+4OhoWeW0bZZxOMqPW/o+WZcdTAwMWFXWldRXs/CSjmNKqg3uao1yiqs1K/iRrRsVIZqf5DkXFw2r+LZZatRx7UsPiyjnjyuhCn1icYk6JrJatvlPDo0XCLKz2f7jdZif0Dj6JezmPpcdTAwMTdcco7O0Sb1W1JcdTAwMWJUTyTo3oLaX9i66in1l9u291x1MDAxZminXHUwMDEyXsWDJvUxos/UdCpVVTs0S4wnXHUwMDFhVLm9jX2sRHS9Sf0rL+PM9jFcdTAwMWEkSyqzrFx1MDAxZIqrqNLKfl1W2LKV01nUaDn7g7KKMpFhTWiO8s3lh1R/NI9y+lx1MDAxY1SdRqUlaZ7ykPpcdTAwMWZhPVx1MDAxYadcdTAwMWLnZr9cdTAwMTFKnsdDUcxtN4rylPrdXHUwMDEyUUZj2TaydvzIulx1MDAwZXjO9H5cdTAwMDPrXHUwMDFhu2F/a9w+Xs5Ixlx1MDAwNlFcdTAwMWWqlmrKsO//9fvu1nl3J01PSc5cdTAwMWGNSHFblTStNajPeVm3MkGyVNY0flx1MDAxYU+V5IXqXHUwMDFihLRcdTAwMTZcdI0pUfuNqqA+YexL6lx1MDAwYj5cdTAwMTfhNq1NntLYQypcdTAwMWJqKp/vXHUwMDBm6rTemO8qrTPJ1SCco96Q1zZ19lx1MDAwZqn+XGbPJTTuqkPzgE9ccjmkeaFxUX9cdTAwMWGRXHQxt41cYjKrbT1RyuswgFxmQ95a9DulOlt8n/pKc1x1MDAxYt6sI36nNPdcdTAwMGXNzZw+XHRvUUoyrOI8mdOcauo/9bepovFijvWIt8tUtmpqO9Vljdc7Qr1cdTAwMGXhKo/yZIWDPtYoTWPgbbA32G9EVM9QU3mD+SF50VRGxVmZ8GKWhNt5rVx1MDAwMblv0diaNIdNJ96pZnGlSeNE30NB/VrGg6KNQ3ErXHUwMDBmmCNaXHUwMDE34oHWMoK8NaI5cIg1oL5Kkmu0aWrbXHUwMDAy/CBcYpPUTl2yLFRoXlx1MDAxYlUq28R6gFx1MDAxZkSt0qJ1XHUwMDFkklx1MDAxY1ZcdTAwMWS6R/1OqV60XHUwMDEzLWJuj57dRvsth+pU4Fx1MDAxZuqHY9ed6yAsXGZpvlKH5zhvkdxhLZpcdTAwMTnVR30oU1x1MDAxZlwi/Macy1qjPo9InmJcdTAwMWVDXdCnQt14hrBAz1x1MDAwMtNVQ9dzbitcdTAwMTM0P3WH5adBc0XrW6P5seuLuqvAhYytzNAn+lpcdTAwMDaHXHUwMDAxU0s7hnpGa0HfsfZpSu1TP1wi8Fx1MDAwN69cdTAwMDFkkWR/XHUwMDExXHUwMDEzXHUwMDFmkozTXHUwMDFjVGnOeVxcaFx1MDAxM2upIFx1MDAxZrVKXHUwMDE18rOkdUrpPv2OUNeCeFx1MDAwMNdcdTAwMDVkgp6z64Yx4ndGbeZcdTAwMTGuq7hcdTAwMDH5pblpQC6prcqQxkHymtdpzZtYXHUwMDFmkocq9X/o1Fx1MDAwZcs0xylkfok1t9chXHUwMDAzZepvXGJcdTAwMWRAn5FD44Is5PS8YpnI8HuY11j26nltZ1x1MDAwMYzSfNJcXGFeXHUwMDE1cdKgibaJiyCnXHSNTeTMJZUkrUFcdTAwMTdcdTAwMDDrgyHkXHUwMDA3fFx1MDAwNVx1MDAwZXJcbn4v+k7zRnxEcoi5p3lFm2Vj5zp17FpEin6rQlx1MDAxZiiLJ1x1MDAwMXxl9nlqu2HHSmPIMSaSXHUwMDFixdy8jb5GkEP6pPnZhkxcdTAwMGZJXHUwMDE2MLeWh1xiu1x1MDAxOXNcdTAwMDC4ssGf4NlcZrJcdTAwMTejzKDKMlx1MDAwNf1GZUlcdTAwMGZcdTAwMDLTVegsWi/CKNZ7gDWgPlx1MDAwMGtccsxJ3ezfrDvphn3mXHUwMDEy5oxFXHJcXI52SVx1MDAxZahdktGyxVx1MDAxMDCYkzxAl9JcXFx1MDAxNXOTY31J9o19Nlx1MDAwNL/hWUnyxlx1MDAxY1Wr8Jou7NqgL1x1MDAxMThxyfJDXHUwMDE44e/ZXHK2WMcuao1cdTAwMTTfM8ggOFx1MDAwNHJb8EIhS+DEKl1cdTAwMGaVnWPSS8RcdTAwMDPcZlx1MDAwNnlcdTAwMDXvXHUwMDAyU1hcdTAwMWbiXHUwMDE1Xr/IyiCVYZzm3OfczpXFXHUwMDEyrVx1MDAxZvBOfMxYwvg1MFx1MDAxYvH6glx1MDAxM6GvmnPqr4SeJlx1MDAxZUZcdTAwMWZkbGXSiVkmMX6WyYWV3aGqXHUwMDFkL7g8eITuXHUwMDEzXHUwMDA3RsvY8iT4T1x1MDAwMF8x46KKPlq5PyQ52y5sh1x1MDAwNsvZMlJcdTAwMGJwXHUwMDBi4YvwxutxNKD6ZY2xUdeFfFLdeD5lLqByKXFxZnkoXHUwMDA1XjRkdp/WMc6YK5aYn1xia1x1MDAwMK5cdTAwMWaAn4Y38lx1MDAwNXuF5pzqXHUwMDA1j1dcbjw3aHwsXHUwMDA3dazF8oaHqV9cdTAwMGWvXHLsK8hqXmV9RXIyp3U1LD9cdTAwMTkwloBcdTAwMWJ13Fx1MDAxOKJvi1x1MDAxODJcdTAwMDd5Zb2XYG2onVx1MDAxNttcdTAwMTNUnmQnXHUwMDAxtjSvd5/1rMN9J11s5YN1aVx1MDAxNrHOSeY8Xq6vNbfrQd95rXh+citHUcayvY3vZaubraxlsE+srmY5Rt+IXHUwMDBiI3Ah1sOugeVGQ3qax1x1MDAxMvFaXHUwMDEzvzKWXHUwMDEzYWUhIVx1MDAxYlximE7BXHUwMDFkorDHMNdkf7Csgv9cdTAwMWTIL3iR7Fx1MDAxMWCcflx1MDAwZuk56je1XHUwMDA3XHUwMDFigvpcdTAwMGU7xOqCSlx1MDAxM/pcdTAwMGJcXMrleS7BySyDXHUwMDExZD1HPzqQz1x1MDAxYzxxSrZcdTAwMTD4ISrknWzWjPWAXHUwMDEzgb9Zb4bA86JWrFx1MDAxMbg8qnTPXHS/6Fx1MDAxYnBg6Fx1MDAxZdlH1m4reFWwXGY1WnlcdTAwMGLPZ2XWT5a3W2RjYbwkg7xewFx1MDAxOGSyVXBcIvQy5q6Zsk5nOVxyoctojetk96Is27M52/g8NshcIuxI0kHET6TPXHUwMDBiXsF60pi2WediTVx1MDAwNOODf1x1MDAxN3qMbJZcdTAwMWHw1uc1kHZcdTAwMWWAT3o2XHUwMDBmU+acXG5/ZswhtL7R7fqGzCkx94U4/1x1MDAxMNiGXHUwMDBlYLlyWC54PVJtZTXMwDNcdTAwMTGN3dqPlp/jSmJg39VcYiPWNsF6soxcdTAwMTOWbmRcdTAwMWNcdTAwMTiy60tzYyyGQrYzSM6XbGeA55nvI75OmJzzWNl2qcJm12wnXHUwMDFj4jOFLoJcdTAwMWMpyDHNtYS88FxcQK5g60F+XHTbtVxut2f56pDsfvBBXHUwMDFmfHVjoyRcdTAwMGXwZnVjU690XHUwMDEyc6ogvEjL563c8lxcQrpcdTAwMWFYXGItl0BcdTAwMDejXHUwMDFk64fReOtcdTAwMTKcXHUwMDA0XHUwMDE5rlWG5PPBvlx1MDAxZFJcdTAwMWbYTlx1MDAwMVx1MDAwNpn/qFxy9FNZf1x1MDAwNHJC9lx1MDAxN/WNdFx1MDAwNGSH8I65SdPCLrf8ynxcdTAwMTjOWVx1MDAxNizussLWXHUwMDEz1latgq/yQnYwT4tcdTAwMWHLYDVcdTAwMDPuYqz7gG1cdTAwMTnoM3BcdPOeravu2GdcdTAwMTPqO9eVWTnl38A2+2wx610rW2xvXHKgK0NwJORcdTAwMWb8kPOaW1x1MDAwZVx1MDAwMZ40jzvDmPCb/Tdj9SF+t1x1MDAxNHM76/BcdTAwMTb0ubZ8XHUwMDAyPVx1MDAxZlr5trJsYvZcdTAwMGaSXHUwMDAyo3Z+bnSxtTvB1zyGgsdpXHUwMDBl2C6F3Vx0WyXJo5XOn1x1MDAxNzqf7VxcXHUwMDFhXHUwMDFi1jpje5ptQujahH0wtn2tLlx1MDAwNZ+SXHJK9j/sjFx1MDAxY7o6gVx1MDAwZVrynNLvyPq2Vlx1MDAwNlx1MDAwN4VtU0nQhmFdulxyXHUwMDE5gi3Eflx1MDAwN9tcdTAwMDdWXHUwMDFlXCJrV1x1MDAwZlingE9hL1x1MDAxMu+wXHIlXG5dXHKdZm2xnVx1MDAwNeTegS6ALVx1MDAxYjXOXHUwMDA3tnzI/iPJsrQ+XHUwMDE15LOOulxuv6BcZu5W0CWwJ2Lm/uFcdTAwMWP6kdZSWJ8wncdcdTAwMWNToL6QjVx1MDAxNfM8tljOrKw2l1x1MDAxNlx1MDAwYpEubD1tsTQkTLJcdTAwMWRcdTAwMGY5XHUwMDE0jFerf1m3RFx1MDAwM7b7cuCFrkmrQ5tz25cqZIt0Z1x1MDAxNX1cdTAwMTJcdTAwMDWng/dhR2uS31xm40S7XHUwMDFjt8DzbGuFrCfAV+B3i03YXsxjS+aHXGayRtzaqGe0fqpmuTCz9lNcdTAwMDK5z1x1MDAwYlx1MDAxZJaxL8e2XHUwMDA0ZFx1MDAxMmvQhD2MtcutXGaFwj6P51x1MDAwNPokMefWLiU/tI+1rFtfO1x1MDAxZmLeqF3Y41W2L1x1MDAwYvshh1x1MDAxY9XAMflwyf5W3mTbk9YkL/ytdM3fctg3ziC3KbdB85Syzd9gWVTWLi1cdTAwMTe8zPzMNiziXHUwMDA11GbO/vh22epo2C2Fz2htQeZTtqOsz96Stt9Dq0PYfk+19b/gR9z4vuDrlkN+RM4ySPJcdTAwMDVcdTAwMGUjO5PagmySTckxXGbGQWbjXHUwMDEztFx1MDAxZcytTbVf2GjAXHUwMDAzx3MyzHdds1x1MDAxZJPf+L5RVuhcdTAwMDdh/VHyp1x1MDAxOJOFX5ojjlFnf4bHXHLbgOaD11x1MDAwMnbIXHUwMDAw/lx1MDAxMM9cdTAwMDFwU3Ap/E3Gn0ScjPxcYsfW0XLsXHUwMDFhQN6xNqRcdTAwMDP7bNfQWMA7dWF1WKLYjlx1MDAxYbBcXJvCls8tVtmHhe21ZF+3kdz48Mb2gedC3PjfzO8nXHUwMDE3c8tcdTAwMGbAN61cdTAwMTHr1ch0XHUwMDE4Q8zt2q41/IeokOvugPmK9DhsXHUwMDBl+Fx1MDAxZrw+22XLb+zvhDd+Smaxy2tcZptqYe3eXHUwMDE2yyF0UZxcdTAwMTWyXHUwMDA2LGGNLY9cdTAwMTV+XHUwMDE0dNdcdTAwMTD+krD2x8GAfaxcdTAwMWNYhl1Xhj0tithazjyEXHUwMDE4Z99iXHUwMDAzMbS4XHUwMDExn7P/b20m+EiFL4qYV1x1MDAxNZi+aV/VKudup4H4XHUwMDFkx1x1MDAwNVx1MDAxNi20M1xiIb9oXHUwMDAzNrHiWFxybFx1MDAxOevj07qlurDDXHUwMDA0sFx1MDAxZFx1MDAxZlx1MDAxMz9cIo6GuF6hb61vyr5cdTAwMTbZdoxtYfmrXGaeXTK+XHUwMDA2dev7c1xcJeGYpcVnXCJbbEeki4JbXGb87VwivpNcdTAwMTVxXHUwMDE32Hm6w23w3C5cIn1cdTAwMTFcdTAwMTO3ZjxusqVrXHUwMDFjXHUwMDA33ZtBd2KNyT5eXHUwMDE2PpQtg/VcdTAwMWRHzKFWLzfRXHUwMDFl/OybdcysjividrDhjqvcn1pcdTAwMTGfILujiCVWwVx1MDAxNTSfbDdyXGbU+s02nkPyfVx1MDAxM2ODTkZcXCq3Nr7IY/hcdTAwMWHQ95i7XHUwMDAx6SjogUaS22e5v47VXHUwMDFkMa1VKGvMxcBIPKDvhKu6jfVcdTAwMTHfU79cdTAwMDXHXHQwfubuqNDbiE8gdso+KuzQRWRjkTKucDyO9Fx1MDAwMa1xznaCZt+0gZhK3eEyWOO8ztxa4L7ww+FTRjx2+Edsi9hYXHUwMDE3cf9cdTAwMTD6RkLP1KzNwXZcIsrb9U1s7L1cdTAwMGZ/u2XXl+NcdTAwMTVDxjF8Q7u+0dLGXHUwMDFkWqmNO1x1MDAwMddNtrVcbjxcdTAwMGKOXHUwMDE5wma39edtcDvs//GC/WTm+Ia1qVknckw8LGyScHmDlWi76CfG2YA83sbYpOWT0MqfvqD5aFx1MDAxNnJcdTAwMDXMNlx1MDAxNc3DkueFeW/osP22XfjvXHUwMDFjK1x1MDAxOc2KWEFh/ybKcpiVcfbFOUaHeDXHMax+y2H7Qlx1MDAxZbBfwVxcvbB1MleBI0hPhFx1MDAwZfeBcFFwP2PPclx1MDAwYuqF/q+yruH9XG72x6GrkqX1IWBHtW701Fx1MDAxNXhcdTAwMTUxOdYxha1Vsz6RXHUwMDEzWV+cZOur28FaXHUwMDBmklwidnSDp3R561x1MDAwZrFNhlx1MDAxOCQ4XGK+PONKXHUwMDE3c73gsXHcXHUwMDFh/WNcdTAwMWQqOL7XYL2trc8+zGO1uGJba1x1MDAwMN2DsXJcdTAwMWPB2qE7XHUwMDEx206EXHUwMDAzY+OyTeg4UbM6T7CPS3ZkPChDd9G4XHUwMDEz61x1MDAwYqhcdTAwMDX0bFx1MDAxMedNmeuZh1x1MDAxYk3Vgj4jncrrw+uZLFhXXCKOzrqs4DiSv5jjXHUwMDFkZFx1MDAxZpP/UWvA58VYWsKOXHUwMDE1MUP2eYE1trN57nZcIpaZXCI2n1x1MDAxN/tKXHUwMDA17lx1MDAxMjtcdTAwMDc5/FX6PLZy2+FcdTAwMTgkx/xcdTAwMTRsPXBRbG1m1COZR1x1MDAxYa1cdTAwMWKehNxbX3y70GFccuwz1JWN1dehPyT70DZcdTAwMWUvXHUwMDExu6ixf1x1MDAxMc25ruNFXHUwMDFlsy9SZ05cdTAwMDLu2lx1MDAxY7ePeYzsXHUwMDFi33wydoFcdTAwMTXWXcpiZNpcdTAwMGZ3Y9E7Xo6+XHUwMDFm7onTk3Oxfzy6Ot05uupWpouaPlx1MDAxOPV26/PW8fLiVFx1MDAxOTfRXHUwMDA358mk7nV26JlDOT09XHUwMDFlTdq7dbczXHUwMDBlss7x19nN893dvfPOJFx1MDAxZXf03rw2dq4746bXXHUwMDFhL69bajZPdveuT/XeKNHxRYfq7O6EXHUwMDFllc3a6ii7eVx1MDAxNn1oa/JwTrZG+8en151JfZ7orVFLjcbt4/i8uzO67lxmLlx1MDAxYa1jJ8d+UEc5Yv+kO2pcdTAwMWZ3p92K6JONvqDrg45aXidcdTAwMDPRXHUwMDBmc+xhYX8jTE/Ho1mHnlx01emY/+2HKY0r66j5XGJ7SsXVMZXPaVx1MDAxY9dt1Zx31WjY3UmDkP3PMO3o01EyPp11dFx1MDAxMoST04tEjfqdnWY/3LF9PVx1MDAxZH+dt4+XXHUwMDBlzWnRR/+v3+2+6V/Xzq9cXPbu7LH7ruN6a294Rlx1MDAwNthBb37Z713//NR69sfT/1x1MDAxYeVL9pyf/6cu32PPOYvv7jfz73t7zYuQeY3+u90z3LtcIllcdTAwMTmtrW1cdTAwMTCOY5lM9kadyUGF5WVNXHUwMDFlSKZu21uXXHUwMDA3ln+Sw4TaScaB7IzrjCP6fkm4mLWPnVF7XHUwMDFjXFx0XHUwMDA2a/d34lnrJM6pXHUwMDBmXHUwMDE3LVx1MDAxNVxchTsjkk8z7+58Jdk5yuxvJ98/OTgnrIySvrzunlx1MDAxY3Bd95/dP6Yxboe3e54r+b1cdTAwMWSf3T+nOSA9K7rkp1x1MDAxZFa23J/n4f6+aZV4Pp5ZO/BoRuVcdTAwMWTS74a5j+xcdTAwMGK6znGoXHUwMDBl2VP7pCtvfj9cXE+V+DBN2Zc4XGavv1x1MDAwZpaL1snBNNypXHUwMDA34Vx1MDAxMHFcdTAwMDfSL+NcdTAwMDWt0bC/n9/b92fbbY905nr5xXWiTyff0//5n0cw5DueXnu7wVx1MDAwNlxm2afuYOjJXHUwMDE5KC/B0PPTW/6DoccxNOtcdTAwMWXHXHUwMDE3pzvNtLN7NGhcdTAwMWZcdTAwMWZQO/G0fXw0O92WXHUwMDAzXHUwMDFh06CdyVx1MDAwMmdLSXpjSHXm7Z3RqLND+kydky6Z9e/JZFx1MDAxNsFfVFx1MDAxMY25iThDXHUwMDExXHUwMDBm5FirQry31jg6h03O/lx1MDAwNPxcdTAwMGXEXHUwMDFhMti/Xyt2v1xiMV7o8zLv+yFuczQ4vVMmxH7kccQ+XHSwwz407ynyniHZXHUwMDE0e+e8XHUwMDE3wbGlql4rd8V7rlXeV5I2XHUwMDFlXHT7XHUwMDAzz7Dvl8LeXGIrVtfzJ7Xb69/D3ljAfsw4/1x1MDAwMLHyRp3xt69JlnLzXGK2XHUwMDAy4Vx1MDAwNZ73K2xcdTAwMTVP3dVPT/2TXHUwMDEzL9JPz/57XHUwMDE2/8HWc7F1XHUwMDFjX3dOtmSXdVx1MDAwZuuiXHUwMDA373H+1vho0N3e0nRPtDHOXHUwMDA26j6/tc1qNFx1MDAwZlx1MDAxOD/sqF6D7mM8XHUwMDEzsq1cdTAwMDah1Vx1MDAwM/fLXHUwMDBmylx1MDAwZpZHOS6P+nf3jJ2D+3pcYnHA82l0XHUwMDFmXHUwMDAzhf6p7aTqabZZoFx1MDAwMuOv/7HHh2XfPnVXrzz1lfMv0ivPfp/9f2T/2bLfnXXU3uhnubfXIfOkX7KWWpKdXHUwMDE2Q1x1MDAwZZeQzZZcdTAwMWWSTFfRt4yvbT8og4izIK7F+52cc2b5XHUwMDFl87OwsaKf72Ev8WdZZ5tpXG68PJHPtTHe+lGeh2XaPnWXz5/6bq9cdTAwMTfx+bNfXHUwMDFj9u8o04hp/SS3LfV1QXVmXHUwMDFkfXR1ur1Wjvzzzvjoj45cdTAwMWFdrd8nmTxPdERtWtltg6v7UrSO92anJ6H9PYnW/IqAeFx1MDAxY3U17z9LNtPyuntcXF/ZTDd9I1x1MDAwZSb//+qU+NzKIcnoTprFu60srqZcdTAwMGKyP2597uK5+ziwse1cZnxcdTAwMWNxXlx1MDAxZO9xWt+CY4ZcdTAwMWNzrpR1eLuPxvdErVx1MDAxMlx1MDAwZjinmPf4m0VuU9P6XGZcdTAwMTXszdc5d9SWq5tojD2SYVx1MDAxZfK+I/I2OG6EfVx1MDAwMth7mc3LTWTI+8Y0c4hcdTAwMTdcIm/N5r1cdTAwMTR7PE2F32Rv5TbPXHL5MV9cdTAwMDdcdTAwMTHn+KSp3a9ETFx1MDAxMbmxZd63s7ZcdTAwMWNyLOy+SnvbOLVj1D3EXHUwMDFlx4LbrbRs7ofNb1x1MDAxMkXsneo8XHUwMDFksK05XHUwMDE4XCKnY857bIjn8D3MXHKXlbaOVLdtLoCJ8/SJPOBcdTAwMWKP/KFf6bbiqTs88OQ3f72EXHUwMDA3nv9asX9HXHUwMDFleGu7bnp6cppx3OF4OTpFnG3MMb35KfW7fbKVt0+iueWCrZvy7D91J3vMXHUwMDFkxFx1MDAxYuPutszYPtuW5F8tL5Ld+ILQ8mBMXHUwMDAw8cnogXhcdTAwMDDH2MfIta7ej1x1MDAwN2DvKcW+XHUwMDBm/DLkXHUwMDE43dd9XHUwMDFkvi9wP33ofqORsm7kvbmnxeKU4zrrR/1cdTAwMWbGhH3qXHUwMDBlJp78eoyXYOL57954O0ys5Fxuua+LcFx1MDAwN/vo5bRzglxcpVx1MDAxNee39FFGNv1Vt0oytFx1MDAxM0CeXHUwMDE1+em6Plx1MDAwZWArWZumUeb8sMhiZs13KN/Hm33m3jmRmGVrq0Lcjlx1MDAxYytZY1sp1DXss1xmWmnMuVx1MDAxZmHGOobzuHhcdTAwMGZlafMsTyPicvAzeFuRnERR3tz0O4/yvYjqgy7Io+FC8J451/fAb957PqhcdTAwMTAvI4dI2fK811x1MDAwYp9dcC5aZZja+qvI25Q13nPHPnVkXCLeXHUwMDE3O1wiLYezXHUwMDA3TVx1MDAwN/l2dlx1MDAxZlx1MDAxMflVLXm7f1DBflMrJ12iYs6bQ/nmMlx1MDAxYUbrv5Xd0yWsrOpTNj9cZvm2TXOTv0m6J+c9oGYkY97/qNN4Wpr3wFx1MDAwNmG6Nlx1MDAxZVVDvlx1MDAxYu991W3eXHUwMDAz4lx1MDAxMIOhzSu4M1x1MDAxZtWF3UNcdTAwMWXRfCBcdTAwMGY95XziXCJcdTAwMTdcdTAwMDR1cm63vVZP7ZriO81fPlx1MDAxNEWOXCLfK+RF1zhPIbn5LWmtKsX3XGY535zfdVu2XFzUudUmLlre9JX+s/v5w0jw+Fx1MDAxYlx1MDAxMfJRMfdcIrJ2SF7jPJVyflNHPafPPDK8d0S8XHUwMDE1I05UQW7gMKdy2NtFTkox7qHDuarNxdq1lkOysnz0N/pSOSDZrN7KYo1zPuqiUXmg/6OL2PYpZDmN8z2at/qSc3My/EZcdTAwMWVcdTAwMTTOmiSC9z1cdTAwMTH/yVx1MDAxM85cdTAwMTXFtZrNXHUwMDBiwDXBe0PDhcNncWx+sIHsYH7J/sl4XHUwMDFmkWSxhlxczVx1MDAwMfZcZoeS99aRf1FcdTAwMTlyLj/Jolx1MDAxM3M+gt2jtvvLpyiPXHUwMDFjJuTTSbLpIDske0PF51B4f5hzS5GX4fB+IMtcdM5twCZcdTAwMWFRecg679lhP1xme+o35Wm+qijP525oXHK4vM1FpPKcm4T+t3gvma9cdTAwMTV5XHUwMDBi9lrI+atcdTAwMTHyOivYc1x1MDAxZCq7v1x1MDAxYqJOh3NPqlx1MDAxMdVcdFlIOFeBz4tcdTAwMTBebHnOXHUwMDE5lLyfynLG5W9wyOVobmw5zpNJi3KcNyY5zyWHXHUwMDFkt95u1dq7RfmIzys1+excdTAwMDfJd7o2XHUwMDE25D4scVx1MDAwZcTOXHUwMDBm9vhQZ8r73/V8fX6QW5Ni39vOL++DNpe8PsSVtjznXHUwMDBlSs6LakTLonyxPpzzXHUwMDAwjuX1RVx1MDAwZVx1MDAxMpXPbE4kymN9q7b8oM7ySuVX8tGA3dtCLiaVRy5cdGKWTexj5vQsla+r4mxcYnGPXf/GmnxcdTAwMTI32fqrkcZ5u4j3ppFcdTAwMWKDPDDiLpz9IZnlPVictcqRK5EsOVx1MDAwZpu5LbL5aiiPc1xyXHIuj/nj/HNeXHUwMDA3sjX43Fx1MDAxOPNcdTAwMDfOcSTcn1x1MDAxYfshraxBWLf5XHUwMDAwTeZcdTAwMDPqXHUwMDFm8qe05TG263Pun61LYFx1MDAxZYinlthcdTAwMGbnvOtcdTAwMWM4seex1vtm9VdcdTAwMWRcXKQ5XHUwMDE3XCJH3yBcdTAwMWbwiVroy01eMuGZc7VQPq/Zc0vAbnY8XHUwMDE0a5xbL/KfXHUwMDBlXCLbr1x1MDAxYp1W5ZyzOulcdTAwMWTOf+/TNc5nxnmJpr7VKyxfnJtN7d/RK2u/64pz71x1MDAxYjxcdTAwMTe53TOvQ0a15YPbdlx1MDAwNZ9ryevi+Od4nFx1MDAwNlx1MDAxZtGc5Cx7NHdYe8w18oitr1x1MDAxM1x1MDAxNZxbdnhO+WxPxOMvclx1MDAxYYs5XHJlnXjN5k9C93O+ouJcdTAwMWOsnPlK8rxw7iPOoUZOkSMsbI4un1x1MDAxZEU+sNVlNldcdTAwMGW6kDCXXCKXWlx1MDAxNnjTkMdcdTAwMWHLc9Wx+lx1MDAwNzkxsFxyhrnNQahcdTAwMTZcdTAwMTjmNXU4R7+CvLom2udzUcivqPGZiiZhXHUwMDAyOYVcXOfC5oqAj1x1MDAxMs5LXHUwMDAyXHUwMDA2bmW4wvnOJmpinpC/XHUwMDA02eJ5XCLOJozmdeZQ5MJHXHUwMDE1zvnFOUecybS/XHRTXHUwMDBmrIHD+a2Htk3MXHUwMDAz4TbjfDrO60GeXHUwMDA3ctPKjj2rwLiHPJNNXHUwMDAzLlxub3L/iz4zbiGbXHUwMDBl5y9cZqqq0LN2XHUwMDFkXHUwMDFhTeTGL1x1MDAxOdfATY68XHUwMDE09smVzWHCPPPa2ryiQci5MLUmyqcoj7xcdTAwMTj0XHUwMDBmNmFqdV1rybI8QL4mn+3kc4/IXHUwMDE5Kc5cdTAwMDTnnPeEfCq7rszNNqdcbnPYXFxy/kxcdTAwMDPcXGaeSJGPzpxeI44r1pX00LDQXHR1VYNcXFx1MDAwMG9cdTAwMTWcr+D5UOCBXHUwMDFh58DUc7uGLc4/oWua94d4LknmjqZPjdW5gedp71exOvvU7aH3f/7ln/9cdTAwMGbX67+/In0= - - - - - Your ClusterLapdev EnvironmentLapdev-Kube-ManagerSecure Websocket TunnelDevboxPreview URLLapdev EnvironmentLapdev EnvironmentAPI ServerLapdev From 8de6dfc0f8ed0ee9cb0ac94adcec5a4f5af35733 Mon Sep 17 00:00:00 2001 From: Dongdong Zhou Date: Fri, 17 Oct 2025 16:28:29 +0000 Subject: [PATCH 108/334] GITBOOK-38: No subject --- .../Screenshot 2025-10-16 at 18.16.13.png | Bin 0 -> 337594 bytes .../Screenshot 2025-10-16 at 18.17.07 (1).png | Bin 0 -> 107720 bytes .../Screenshot 2025-10-16 at 18.17.07 (2).png | Bin 0 -> 107720 bytes .../Screenshot 2025-10-16 at 18.17.07 (3).png | Bin 0 -> 107720 bytes .../Screenshot 2025-10-16 at 18.17.07.png | Bin 0 -> 107720 bytes docs/.gitbook/assets/file.excalidraw (1).svg | 8 ++ docs/.gitbook/assets/file.excalidraw (2).svg | 8 ++ docs/.gitbook/assets/file.excalidraw (3).svg | 8 ++ docs/.gitbook/assets/file.excalidraw.svg | 8 ++ docs/README.md | 59 ++++++++ docs/SUMMARY.md | 21 +++ docs/core-concepts/app-catalog.md | 72 ++++++++++ docs/core-concepts/architecture/README.md | 65 +++++++++ .../branch-environment-architecture.md | 117 ++++++++++++++++ .../traffic-routing-architecture.md | 67 +++++++++ docs/core-concepts/devbox.md | 50 +++++++ docs/core-concepts/environment.md | 128 ++++++++++++++++++ docs/core-concepts/preview-url.md | 81 +++++++++++ .../connect-your-kubernetes-cluster.md | 68 ++++++++++ docs/how-to-guides/create-an-app-catalog.md | 70 ++++++++++ .../create-lapdev-environment.md | 66 +++++++++ .../local-development-with-devbox.md | 47 +++++++ docs/how-to-guides/use-preview-urls.md | 105 ++++++++++++++ 23 files changed, 1048 insertions(+) create mode 100644 docs/.gitbook/assets/Screenshot 2025-10-16 at 18.16.13.png create mode 100644 docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07 (1).png create mode 100644 docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07 (2).png create mode 100644 docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07 (3).png create mode 100644 docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07.png create mode 100644 docs/.gitbook/assets/file.excalidraw (1).svg create mode 100644 docs/.gitbook/assets/file.excalidraw (2).svg create mode 100644 docs/.gitbook/assets/file.excalidraw (3).svg create mode 100644 docs/.gitbook/assets/file.excalidraw.svg create mode 100644 docs/README.md create mode 100644 docs/SUMMARY.md create mode 100644 docs/core-concepts/app-catalog.md create mode 100644 docs/core-concepts/architecture/README.md create mode 100644 docs/core-concepts/architecture/branch-environment-architecture.md create mode 100644 docs/core-concepts/architecture/traffic-routing-architecture.md create mode 100644 docs/core-concepts/devbox.md create mode 100644 docs/core-concepts/environment.md create mode 100644 docs/core-concepts/preview-url.md create mode 100644 docs/how-to-guides/connect-your-kubernetes-cluster.md create mode 100644 docs/how-to-guides/create-an-app-catalog.md create mode 100644 docs/how-to-guides/create-lapdev-environment.md create mode 100644 docs/how-to-guides/local-development-with-devbox.md create mode 100644 docs/how-to-guides/use-preview-urls.md diff --git a/docs/.gitbook/assets/Screenshot 2025-10-16 at 18.16.13.png b/docs/.gitbook/assets/Screenshot 2025-10-16 at 18.16.13.png new file mode 100644 index 0000000000000000000000000000000000000000..14bbd17718bd68e2b6a64f66ff40fd7be67cd74b GIT binary patch literal 337594 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 u174J}Ll&Cl_m(5BokG{Z>iB=xg11 literal 0 HcmV?d00001 diff --git a/docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07 (1).png b/docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07 (1).png new file mode 100644 index 0000000000000000000000000000000000000000..71eb72e0fb878785f10d4654e15ab09e94a21184 GIT binary patch literal 107720 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

B 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 literal 0 HcmV?d00001 diff --git a/docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07 (2).png b/docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07 (2).png new file mode 100644 index 0000000000000000000000000000000000000000..71eb72e0fb878785f10d4654e15ab09e94a21184 GIT binary patch literal 107720 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

B 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 literal 0 HcmV?d00001 diff --git a/docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07 (3).png b/docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07 (3).png new file mode 100644 index 0000000000000000000000000000000000000000..71eb72e0fb878785f10d4654e15ab09e94a21184 GIT binary patch literal 107720 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

B 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 literal 0 HcmV?d00001 diff --git a/docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07.png b/docs/.gitbook/assets/Screenshot 2025-10-16 at 18.17.07.png new file mode 100644 index 0000000000000000000000000000000000000000..71eb72e0fb878785f10d4654e15ab09e94a21184 GIT binary patch literal 107720 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

B zioO$=>{lTnjM5qrNoaF>_<-$jD7p1721!-NBXaBR<%QbuC=K@8mK-gCu9MT8$5W3g z2I|d!eCopmEjCEg+}oH&ca}Z(;>MW?@;BVlcHwnIOAB`eCwB_N5^Qx!g|^$()#ES| zsj2P23CqR|UjqW_O+OjmABo?3{lKg*a#4VR~3Y^x&%e&RwOYu1deJh(^ zuT)UER*B$z-{dBAbpn4ngPk`?^&)vpGZAOU(#=`Y#CcR7m4yAcvtFZo-gTl>j8$^R zXZ*k^BeeBwtJG4BxG7dh>Fc9l8ug*`ijuV#;keDTtzrAtXAKPA)->;3mC6={2q=)B3$ zVLe}Mx$Anzdl|B6elq{n4H=f+-!Bz+-SL%wJE zL|(4qFjeIxajpi=YJ>HB9uNX%XGq@T!45Ur))!~JXg{dUFtMDG61U3vKBYYhQG4V- z-q%>i9f^&WqkZC-^ z?o8${O4QtlW}Dd#UM=FWl+O3YBplK>(8}bh*F62{dKgo!iR_YKGzO=rr`cLYFPK4d zO;9fcFu7&1;(y)^KZpvM_S?0OGX0~qxk}lP522fwx@kuYv|gE@P>EbVGUiy;(Ctw; zDMUP!poJ$E#2$K%%?{ZQxpzUJ&`*ulkiDIUvRp>F-TxBl7+|{bQ4lLnxM@<3>y+if zN0I5_oADuXL+pX6eOHR&n$4Eyyy5G-@O^>Sbqp`CBztw$d-16^9o&m0Cswy8RoZ1q z*r=T~oCL)UB)g0ucaq{LBQqb9gBF2&`v?|y@g^bal4fLAvQx-LOKm`3-l@BP+I~RN zCm3m%5+NYqSlG>*j!4G$%9Xfg3Hw)n0VXb8H7lT(IlRDnXfN{?jOeQ*1#tiQlrb)Xc5uYt;Ldj%DGUb$C(zMw?rcu0LV%%EPLHY{+qm}$JANSL@n*r3R*C=WHAZl{1}^B)S<1da*`7wubij`{Q}!Bwd|Rvj>hm&sekCH`veS z@zlEo``3v@`zdy9@T_mXJufEWWJ}*Kod)_Dh2NMLWyoG`oNaj3992YV?+MP7Y9+MZ z?)52F&4DTkECr4|fL?*c)@pP=e7&s{PRjNFX3Ya#C28Xt#fU~~Y7XX~3-WPAm1ar? zmue+-4{Te^w!&u9=k!8@68*Yd5W&`^r~rA;{6! zJifUT)O;l^Kzw7xkh!d^zGn_lBr0c=SiP4Tr3=5H&|JW{%7*3L&JLc01?gHTXTiR<+Zsmr)2o2Bw5YSDglZHfz_sSP=E zI@)#+VxWUyX^RH zUB40=E8Kg`jTC`gYG%ZK*j9UX9D?T0n#dUn0z7g4#RJ7ig5_5{cS#=8hql zp{No^#5f5s!arGnp^wUgY=c*CL9ZqD8(D?VL_n5>&x?7-O1a?lS|NoFMQJXN!1sA~ zJMPsLWV73UtUAK&6GjGQad`XS zW4HAQ+!Ae!rL)^uQG zenqz&G;grG|3cR<6OUZC7If@6Sfj(*pBJ}=$Sja#g3j6zP#Qi`d7B_?et?brQL?60 z!e)rMy4RPJKu{`=f>%)lDJ{pAyy>nf8u`&ecrD0V=jf0JA{S+QnV0L>^Kc{UoExbf zisbB(FCkxEGSazg(`HJGz>PAaB#H#pI1YDClnB=9mTo*?d7aa{&R?Vo`7D;AJNa?! z1IBmF0#LD=05N}Qn|nL{3BQkeiXEA!2Be@HJVN|fVLN5IL zr9i8{>D4r}o!6)sw+I;FySzt7*#bWtDMB%iMzLXRa|c>*H+Vgz_VqJDL>$Ci^veHh z<_VK*tSV!gyza9NM5bpjyBIY+9$nvN)rj|ow>0mxWVYxH@0FaX6ZdNus}Mtr!@hl{ z%zN#vMng09!75~hotv9`G-8f;=;1F2)IGyvF?lCF!@PGV%ILf+1CfJC;Ue?`k{8ik zk2s7VUvB@%Izh71@Bu4IACz~xgRWF5crVQ@qJlcRF06+lp!gTf7sNzJpa``GGF$tskPJ%wI%SrHevjlpVn3FpMV*F34u z=c~rEa|wli`O^bAEx=S)s)e#SewEn^e(pm2^!`~0@WrOW?^~0acC=cjc<=TLx#vG# znaV)_v|kV;GkBB%wew;h+uqYdKiwRVzOIAYf1?9jV*Y}Yd&&j>X5Ul!@R3HqRC+H2 z=8KQ!-5!lgV3YmBP^kP0AnanyVe zwf|l|2n+N`gdyt>RzCRf`Yh1?)2z@VR1LbPIP0Hu4KOZaYFa}+c*R({->>+46@Lrr zj})xmd-1or`JQ3-TciFyG=4h2e;@t-L+b_x(qBQ!My@C!MQD8|$K&~e9|lWYL5Szq zzyAZ|}-Fm?L*yaCmprn#XCly1GebkO&z1`z~F|2tFauyA!|yI9+oHP&J6c4zi^`?g;&Co6*o_)5Qm?d>t%R zT6@*U^gVHV`A6b5l?T1Cf7;{caWRin0jn(V5J>&|!1>!q`fVirEF8bB`QHx9Z=dWk ziN4cv=+^~mmy=77k}PJ@o}%cn1rig8}m+oq4v_Yf$i=nT$rCvd&K7~%mU~# zo+G}k{sVma{=r`a;Llm07E6WXz2Cs?Z}|Nu>G&OC_#H&~9Vhx73j6JL{jYPosHi64 zSw`NksAkN$>L>pXP)&lSUe^B`RI?g%SZudp%~rc^Cz0(ApGnr5b-Zxz>E+G*U&6Qt zU0Gy6Gw&f2>(J4;=tCUywN~pZK@)k1?fKatnm>-Rh2H$pq!3No=Zyxa z{T#H>R>4cY)Z$Vty>9LhOVFpp3XOgq-38S#eIKebef~AjbovFA9(D^{ z>KE;r)3)ADRKx~Tn+r!Wol^)p(JX7$(J}Z|&q5cq} zy^0rTSENU+ZoZFgY%KHCzXn-DWf~pX8`Ck(FHW|WbnP040~f1bOnM|3YkLZLO@Pv z(rDI!e@caZ=SWq7byI^J5ncwB|MEg!@8!OLHCK6AuUP&wcL^MF>Quf|gN@2p&{1e+ zhS~**`e45LOH5~ejq90%3aN1p02~9ol!d58k^ct+JXh1UVn=^i`1%`xUteJ)4!nXa zHQq-69gj1KUQ>UZU|cT$1x^FaYOKIo^)Ma-?T6oq=Yi%Oj!RBp_HZPuklr#w?MQ1F z6%`22m+`ty-0A$vMDxxo1YHUF`BCxT3!Iun9}en1ZYtT#0<2CNpC!iLPV9UExe~_Mm z{IH+IE#kEp(6Kr`K*t8+(tz!llSiS& zdn7o1xme|N${w0)=XhwYU6(jhph0t6>X(ZnR0L^gV~mw(V^VK}mISnKU4MSE%AQDV zS~|9>5HPHF49AAOP?-&1N(f*HEanM{l;GA)#C!boZCj$e6h!*5ulDj7o zd3Y|&2lU0>3G)FR?Q^YPGN!*euwhW5?e*33&-Yax(HuODg!zDrD3pi0yZg&yW@b+> zD|f%I;t@j+4$i1^aB#SJi*1r-dwm`sKkvQw0Hg2y{R|&)vYsFRCB|zaFF@bdxjdh_ zkA-W2iA$WvXs*d$NXu#qp?FLC%xc2}o%Ic`XD*%jMSdsrUOVf}^{B^l5;j%|Dd*^D zV@_F!Ra`4HQAoY>^|?BO7;Wp-AKRF^9}e*scwSl0ny+{tHVH1h!3-TNQ<={#ut%g* z+AF@SE}#<%o~ohmP5kSv?AvO=F+joWZex!s*C0F(M7SzPYd@6pi7ohuOBYv}Sce0QhW#pg^|JRO_Y z(f9w_#mp)N^FR$42{}gpYu*l?Wcv27Fo!x)$*{)j-#Y z{lcvX<@ogUbg53;>EXuAQy$LFD@Ac}aZ;H||K&k!R}1DrKGG%;Tid6`5eO?jeFc(S z!U|<8e(QDW-|xn;qLtRgsWTzn)r}6uPnpA=XFI? zMI{Me(fRV<(+6b+s{fvahVI^9ln+=Tg~QCy8IZuU_&@hmREGA(6vfLux9@o(F6Z)TrIYzXxy{${hLp09!{ilZWhb)3gODPto!QGcTGt_xnV= za`yjwCh2L7n1r*kvx}TrH7`fSOE}5VTzfNQ%kuB<{hu-8{~HOGS^q6Zpo!*!t`JTW7ND= zZIW7>jXQKxMIvy;?!@{FuZ6L&&(l(t+wrp$3LP8oiH?7iPa4}N!J6b2%9#IX@AZ1r z!xd9EP;S3w@`L*`{t9mBMQLgEA;Hh^L@D=Bd8at(OP476&d?`^LVxJuNdCw#yU8Rx zDqwvwxF&d|TWA6_l9{s#-}aOCm~1N^dSZMbvQRa;#c$ZO(+x(Mb>CcS3}_|7S~(=` zGpN*`p1nto0fw_Y}`yg6lDZ9y=uGCRh_w|Byzz!8;KZBk=ZU0C?%8eyPw zjfjQo=tP*MO*mUH92PFo&g`GfsrQigSQz{Iw$m-BNmu4P=kjHhquv){e;nOVU7A)( zFz8M&Ze(JKb8q|l-rGIT4QKJ`^7xIW`lxs(e3*TAFEw* zX0tnQ*)n+Eyg`&!Uqj;Vmru{rChl|^odV_t%f;589u^xhv89tMH<|;RNN3$6# zt06r($7p>Irk*KixGp+#Lnqs&}xb_a0ZtI9Z-2o9rX2lFW#SWVLO~ySKjhE zN7g4~Q{a%u1127;vMe@Wu8Hd|GXFT)Pgj81D2I1)2Y)eBH~#1jgITRN;q!u@mVPwN zSBu7dH?!**-#l`+>iQ6}ar{P^8uH;$z7NL!h>#}&9`O-R?Iex=F<{E-Bi#3_`OIcI zwjjh>D~F>yb`GERTpXzDd3s7Cs8XZta%T_v{h1)x3JyaO|I_*dKdQIpCWEpwV;Rd}6eW>;>{~(@ zTb8km@Sb^oPtQE>)9W2RK?4`9*HHXe%o{N5HKCiMmHOm%dzT&zrFV)5ni1a|ioD7+Yj!HM#lQLK zNux!R!;snn1UG&iz*Klq%4WUaI`avC;HA=XR2LU=jjifaDCjVhdf0c^hw*f z*r7fQ_ch7dU)%>`HzF!8ie>l=qnM_btCJNO#tAWaDIID2*gU0vc)>9JMBUC(ja{DE zbmQuU;0=$Q{7t#|LC;u2Lt{KotB0$rH-LKQsu%j!zd5OhARk6b$5N(YI`stWx9l`H zA^lk2%rhU;yWEf+4zSsy&lknJj@$SYF3z`c$7P$mL^}9S1n)BE78<50w57;L-FO%6 zPV|kYlaEiZb9!b{K}NEIDhRfZPzS$D(K z<8DUVviz%y%mr(yP9tXvd0PE+Kw=>dD}?G()s%=JU4yy6jt@c7(G&~OQ$=wQaxa?D z8z?*zJ50}Mt+XgCVy~We@!8d{)XvxEtiNUBm%1s^zZOMEqfqem=o=GgxiUVyvKS#bWGnA4R$}k*;+9l%b%H;-mha_yUko1vN@q+GA;P~WMFQyd#8Dr;AU50*Ob6&(I6 zaDRU!A|r~1VQ8`1n&gap^RvcYS4tZ=*G!|>I9(lHgz59K@x&wzU%$_quY;)BttIx^ zz#CZ=pTzHgk#$E%FaAlByF7SY3FAK=_b!9z*xH-TDuG|$4wB=)*Je-HQ2l#3dn95jVza?=FEWm(=`hU>gp=vh6T{AjIa?NU=OHT zhBOh@RcR8JG))GXJukMtI14pr4wCdhjp^1E0q3)mEfnY?`zm%U`ah410_!LKfdqZV9oA=|AdyBf^Rp15P@ z8;a^ShKd80$2@;%>}^fL_jfnFWF?J-pgBf_#g#nc(3U*0Qa|N(P@gX5HX8WF$RS(>jzS&L-R<#E z>#czI7%CFwrR4U&xn6B~+`8J@_lNIvsEz8Wud+J`O@4oTuHsV`P8ICDWxORaA#PFZ zOCG_ah=8%V{seBqbxqbF*iM>f#j76o159>r!uRev*qpI7#9S(Wop0z6O6)BLEL7Nr z!#>F(0HC?;31Em}H`TnIDG`miih&!<WEj5goirP%un!)uD-NHU>oZHju66A?U-W_Nq!EtC}DBTDAV+SJXAW{5`z>TKr@P#0FJi(^S?4Fla*1d1Doe@% z^qb=ZK|jOKMK717?-oK`!Aq`0VEF({zj1ltSIlCfk@8tg@$hiDH*(0dOPDY0 z{xSSPxhjk0J+|OXuf{K7Rs6h%B&{`95X&K-LpN*2;}&`^&u^_vX05!942)wQZH_^m zox>vTv|qrLw7n&QQKuI$>esKc_+mgNL2cvZuv3DX2R^UMXmK8d$Jp$__g_TP7fjbg z^F~~c?@A$jTFx3pUaf_0WtP@0<93={wpXTGQ&j43t62iOqPKB8WxEb*v;}oo9nELW zW)+WUIi0g^YEyh}(vjT$2rc8_#cV@G4{W6IJg4jH!0(T`ZeZE3c~bj1YG$~th>U=L zhFD-bTE=NAggvk@qZ&+AcZ`ly-TE~LStrc#5=N#A>T9n$w({&4MNQqmm$rf(VKIxj z#VGjceK0@D5hhtBnRJ{2Tkj8MR-EDFS6gis$}7!(<_*A*PAi`u-UJT#+A!M>gT*yV zJf5=Fq6)~>VBjpaQ`Hp2`5~?~jzy>lEX(N?I&bUfNWW*#mQ14b6 z^5XR3OQ(3ZwHEzu64MOl(gQv!om`VWN9x$sv{D^P#FAmgJ85RXR$(z8u;ITZ1(txs z;$@tFo&Y^(1r=fkfbZA#2nbC^P?9zR$CGKCYMrBdxVJeYKhLXj)E_>8c2sDsU%I_e z@>zEhdpm@Xmy>y$P9XnBasSozmPb5>EWEp>+OZ^9On%)wG@sx<9=hpzT#c?D*E@G& zol)N}AGQx}SlHU=^Eufl;e1_Ghx|~OFH2B9sxzn0?in>x-L>-}TR$JJKNO1hK5FM{ zA~%*q)!W42HPZVdw&gYK%m_e>>Plsc`5RBv-C=gB$WGr~sl4n27CL0r)>NBY#S z@*JMkU*O!Xp&zn&Vp*x#K82|o6C$Qu<+DVwdDAj2j7A~U%Dm`MuiUk^cjNnen+mNd z0lh!b@evKD#d<`LxlhDxD1k*ZOH+-WGtCQEy#tgsyoh&0Aq?6PuDXmgD-l{K{SaWq z&E9W~ZMp1?%c0A`U8gbA$PMX+r4I)Me0kwjj&fHNUCp`dsqVoUde6#1Bo8hQW4EgF zZtJK$I#kmx_dG9JtKZUzwLY~tV!|;5+pYdWhqn=0iXlv| z3!(2h$GnHv+c*69dKDhf9dLPlJ9=lRAJ=!wuP!x%N14DGebyH(#Sh6XV(6WK*F3R2RJhe8Txu)DSQCL$~kRY-&^L>lti1YbjE81R9Q=Q;&wCN)+Tczeb@d zb2Em@I@DFHEoekuduydVy)-dt?Orqu4mowzHrrsQjx9|ry+|2UKIB2g?j;C~yJQcf zBd?6qSVY(5EKsJijDfX1Pq;3zAZdU7 zNKxXPIj(`VY4iBlx6d@O>AMRqUE-G}6!6CiU_-gg+O+cMylAB&C}b1pr3S&wp?*Sh zNJ)^#LOT^!wKngtAB10eU|$*ZE%IRIIGxQF2plW*&<#Qob)8UxYIlb)QuRS!k+Vm^?Rf+Ind>O2Hh5S z8+4V~)VIFP2Iyb3Ii{L>A{ukfBYlxb^gB{;84vGtCjo8%Zp zk5soPJd4Qmc;Uf7iP6`niN_$biLZmk$CRU}tn%C4Re&F~KC*%D+i(n3)9K4m*Ppx9 zmO72MyfKg$8UZr=;rek@PV1s)nK)I{&;>}%fp`8v)?xESmC0CZtCVx#Ir?n8Fn4=r zeyw$M{1SVP?v-i*DmMN7peB$J^2@KM`kqu~5;dg}&Fc}@F`pD>5yh)V*?&MAMP3e` z?n`FeaWAgEwH|a;sV>VAvy7-D8Djk3A3Q9=};uskOM&Ki3wOx!+oXV{t z6)K~dSs<$G#j{dnnODd{f4=?DFhp<EVL8)^?XvjDA5!o*c>THub$$$k@7;?=rQyllr zcgskhj8|#T<74&%kK-$pXB=a#=-k4|Yg>ex#HVQD=X#GUqLRrD_5z_NlRG<)I}WD$ z){o>-tS%256;AW`?k+Z~EKjagj9XY{pgd-Yf_D15IDX&Gi(N9)ETSMXVHM;xf3zcb zLkWsQjt6NRS()Sy6wUc8C1Xu9^ZPN8)g`!mq!^~(*I*7=* zo)N{goFWL{=UyY76(S;|Bhwk;)|%}561iH)av2T>J!eySuk}Gocf4u;>Yz)yU6FA7 zkj|h_t7MkGU3-L<{PA_`=YAb(o3FhL`kESXdt>N&l|qvN^on6p*Uvk76WB#o4oJBO z^bIJ$Oy9mK{;!+oM5nQY9)W6Hi6fYKZ!W@vHb(;jEbRQuST1uj z+qK4iDY>!12rX7RIHuR!`qSKFntaytfDq8&-^;KtTHr{`Peyfwk*&GZr6&-_UGwc8 z^K0C-!~XePnP~>Wl^S3h#GpDI)}ztJghX|-{LuWCy3wBmKU%euC_jtx2rUORgGS@o zJE7AJJjG9f#h(m-3@!7^g!m!H;lqo)O;unSD+0b(B<`Y_!NWoF&GXaa^LqjHLdta~y`lWx+lcSmGwA^!r(7>M zE7#46ju%>JFiwJz-d;(3tuF3{Pa;|3MdFD+I7&$WURDp#9BPqD`Ga3mI z-}j%1;f0X7hb7Yf1ryRW)edsH7h#gad5U=V(LEN2@X7b>KXnE#Pq02s*2m5*duV15 zx!@1XM*Z%f`sLTxZ7`?T$7=mjb9j!Lx1I_K)ov9%+zIu^2q67b?iA(|1op)q4mCo& z9To57>#THYvtitXG?zCD)M({p@_C0l4TnvKk7AZ$k3n)JkMx#5QuL^vFRmfE7jJ`X z*C5Sb#Fww(oU5zq9w{2;lq(s-syNbFzr@~mxO%x*T&92C0;#u=~Ipca>^VWM24LARHlys0&xs6OV%wd>o z9$ir(fC6t3ipp~~Ml;177y7g* z66N;1Tly}_b$eOHGuEEGHhS`orUl44BUHcxl-&yzWM`P4Pz{;f<*xdeaMr%2;YWJ# zQuWg7JMAT1A8`#SfJ!bjWCft+Qhur7X{0Fc8D6b_ScTjdnswl@mM0oxYQR?KJ9}3= zEX4)ox}dO8&NHI}?iWUzS$P${qB3);Ksz2)Bv>PMuHXFxhlr5}RR1D#i-BS$z)<)0 z33?&Zk`)6Dm7Be~V-Rp?r@M}57kF1-4E5aY}tc5zENC@bTuw%~k zi>&nuii(wttqsoWJ0}k!?hmCgs1%{bBT*sK;-2bV`OdyHe7NpR*MR2;sbcXr>$kVhbmei;_5IF|pA)kSB61&xcQ^ID zOQ0}&wp2U&8L$(xIrcnX431dPB<3?Bt+`0`1?sK2o21_BQy?dOZvI8pP*G?ooo52C zo^6D{4jEbmt8}dpLPLqfAP|AO$5yZO5U)PG$3--|f689=W1q>xvcX$v+i4bb$AkLA zKis2JsDYDo4vDf@O<%&8ue(`cdXZ^9p?9@eT`G>E2eFP0D4Dafkj{6IQ;X5kww1Px zOm@C3B)c9~>^*clf6N}-#0no*(r$zDh~rno+Qyh;76;B1<pSE!TW#)q*pbUv;sUSC|~53fcL9(g2>DMj$#zjmLBknEP;jArQp*qHwz(7 z;QbUcpy>X;vyp3E!k~#so*Udb?!X`X9k^4f1T%f`spzXGTre`18bbB| zXfRDxUdg&B8}iEDGANIm zsyojWxj<$2zCt}zVjaj&|zvTuzMYp zA?huioU$a1{#60uWt`oANJ^**6E=AH&FfT6*W8_avm=l#KL8>06z{qzD*0PFZoigw ziMJ`a5!KUnBV9ezgPp`+dg=Q1>a2>cFyk@Wp)-CO((%9<3r%wYZ(7 zd9PK|;LI01pgu@0j&V!b&XP73=XIYPdlqmczP_D~oE#)e+Weqr<(Z;qIR^0=EXwHt zQ8TCDL&^UrKFNr^TpZB(u<#pz=<^J)x+U0Y773P64EBdWcscD@MaeP2wUEZ4HX`ZP4o}?ZwQ~WS0BGS z!>1UqWXq=O^y-t8B1T!o{RDJJK*8hX{Af);N(;OwUc;@Pmh+03fi@-H=tpir2_X3% zwgZ{8-`&C!ra0Po)z!-ZfYjAf3}g~k<~164)zq3ZlmT&ig}r@39`-^~S0J*Kfn1V2 z%K)8x2@7vfj~h-ZwMnKCYt#jmiHK%livB8J=(=JAp+Golj0jd%oJv1YbST_$Gt>C- z7)ZAZ>gvaPBTh-A=ifv54AqB+?=v>=-$Q51LXK#g62x+q34odtfH08pRC{;N8YvUR*L8#T7NW;)ph6s#wQR3rOJQi2jw zOx1}Gg7#m_j!mT1!8|nyzfq#)$Cf^KN*rES8R4!5ik}F916qr>bko@YcF${;n175}$Le z^)STrYVyAnrKYxmbHdUrVtAzq8aCB$Z-NMC1+0MvgSQ-NvQltOKn0@0=JYw9==jlp z*mI^SL13Aelf`SP!I96ex`C17vUqyXwPLiEaVwD!(@R@QoC`3f5>EkyBnCDVoRoFs zgLTm-Mp0NO-GIiwpXaAAq;YZARiwfro3GRdPAlOJsm~HXJe9OrN-UG`dD;)ETQ8Zt&eUCDL#Dovme_{4IfPtnt zl+avn^gDB1F-Tdo=1bWG8Z`m)HQ#H%vAXY1c+1DfgyK<}bwKEHg&kt5pSnx&k)}F= zVTniMV;apa9P_^h1zl$!C_Pc@9rP|{(pmZhTa0+<36FXlUBl|}L-VKeD7SMzmwi}VS~=OW+_6^f_ZFGMCP?pkE0YPS4>xRp>V#nXZpT) zOWcX+vQ{0zN^$OY3%9C4)V5l;qGs6|GDn(#!AUW&MqgrfAY`Y-w=HappDhBL54pXy9tD*&6%IE6b$zFD+o70 zL8a2A?0+=Y3~u?EPJ$mLWc=N2NC{Aab#}Zd^haY)!7ZP9%s)@5wEo>mn&hW!X=v7^ z^!(qi1j)C|t=30L6#o{V0FMpeP}H+HN!1=X+T*z zb&}MYkS5D3Sj(wv+^7-6}{frl)F9)2Ebwz01lgRs!wE+e`C-^pue;8n{EQXovhsd z`kbBfd_irKAh-vBGNw6^h6!P$udkn0R(g9rru9V7YzDj&c@YlqGw#Dz1#y`_eL^({ zr12%W?#O&p$`=qgMH+kjYC)G~92@&8kFVTLgYV+E+uGVZk35oo*r`jFMV2pCj*ofP z5&PipBdEw_s0!mhHPug{k)TY9{q*=~4Y@9>wmp@&C(+>jA-c#b=Qvnl(PxZEKZ865 z>Ygf7PqlyH)>C8bH~$!H2O>k52KrTe*ZUp9>(VsBaT?F0ZPAYN!>^jaVY5W&OaLG& z>5I5^1^~iCjQ&XTT3SED7V7`vzf*>acJ(Z>BbLC-4|aRY4BncE$*K^MutnqArA`>} zBrtzWaqtZ51H^Q|iIATkaI8#qKQeCrv9jTCb4rB$JTN)z{`Y8J_)pYb%iP1u^Ittu z5zZhHvC;4gv)0UDjglAkXiUA;^Er<9ubAr6bv>K0OYirD`0b4ZsQaX|<2ND?@_Pem z0r6llJ~p-Wr)*~=Owl+aU~+*s=G}5><6X6`uYBk@nZ$&RUeES9gooxl z)mk)J2hz?B?LX%&!9Y`91e9bN9sz=*cNEyyM9DGZ9?@@B!DIKQ@AJQAy-w>#8m2b1 zg`&qv-pVkg(d;}_y4vPQk0<&AeHi=DTj{fZ3=Mor1m;3UR%{GMYBuB~zr;Wl5%U_m z6*!L%*f&U9t~9x-nZYITlp#j)HTrJQsMBM~iq2De^ZfS@vi(S6LwykV@07)xYodH5 zEYa`7i?sDt!RBL1b$uB|7w_#3RHd)%uRV{RY#o$wtoNXbQh-g|Cnjb`UHn8=c=->x z<;=>ng^p@#)NZibpNjskC(4bFu%CexG4AH}Y=IKgcYClPTJ!~4@Z?xO;5g*$5N+;! zHoG0=+hM$jyo(uhk}uJ?#dfVnTQaG|nl}LYje|j5UqwgL_XF9ffa&_u=rD0%uxEc@ z<9~673Xx_7=G*>fOl`C-ek^rveH~o{EqVk{wKC~NE**p8c(f-UZzayqLc=Y!ZQ1pW zYbVls|6kg!-|m)+mYf!92;}o{+?i>QgBiN^ zGqFotez@t0SCr(bcgy{!5s0vz*@-*alQ4csXps=ngvY5O(QkCy`oonV<~;n zFg$=U8mwKaVVqr#N)opqMTg53>PBwWWcs|*-0#eN=0xn>x{b208~W8~S*=#){5RCe zjgHuLXxYl7IC&OdID~O@dB$>oe zL>?~TMb_UzJMuKusDqXCGr4p(;+ezsE0yPN!h)2}xq+^Aa7mUsVf6T%6VHmX2bS3L zW7vn_a!9nCotddnz=$~}@kdNgc_H?#8=v8(_{u?Q245AbP_Zi$5B!2+Q90Wa4($pF zVRz})H_P{EvIIhluVO3;p_el{uJoMz+sI+G>s~=y%Xh=#YXkMHTUhH5p;hsF#vbxw z;Ch%^RvQg43}V*g?d)7~FjAu=mu}^WU*1)X5n9Z`QFJSAKv6n;Q3>AIx4cj>Y}3AR z5x6L9dc+=;9o9Fm{zBj2+7xcXS+oCi;g`&C&K=_X(cMYQ9ib_P-~RQA=B5jiaKldd z+v%6)Hj(XPQ{Wkg#j6wyU9LCwvE>XNA5xwFoHHEQjh(1%|LWkmy`e4(V;1JZ$7g?< ziJDwwb$6Ih9S#^UP8)ya`C!=T*k4g3T$9UacA0kfT|ozDfw6@EHCCPUHi<;iGdxMx zpQB#Psu%eeSk$!D+)wYWAN1Ykco=hVMYc0!*O&8}D`Lx^97O$9R3tqS-eDnC`tqXX z&)&X&pKUen&kYg_1j4H|YWMBpgSF$j2O_^S&Ys;R>x9NNYIPyw{r&d!T^ct52+0xF z33ZT)|0~cTd6{Ozu1?Tynr&BYU767AeiqZ}W~sq_&2<}0YhHy8(v`IaHy$Hv1zTay eu&To7eYWtYv|Lt%#`_1r&lMe`OT}8(@BJT1vQ8%e literal 0 HcmV?d00001 diff --git a/docs/.gitbook/assets/file.excalidraw (1).svg b/docs/.gitbook/assets/file.excalidraw (1).svg new file mode 100644 index 0000000..512a448 --- /dev/null +++ b/docs/.gitbook/assets/file.excalidraw (1).svg @@ -0,0 +1,8 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO19a1dcIk2y7vf5XHUwMDE1vfp8nJGd98yas/ZcdTAwMDdcdTAwMTRE1Fx1MDAwMlFQ4ay9ekGByE1pQYHaa/77iYgsXHUwMDE1XHUwMDE0XHUwMDE0bbTtmdde76tcdTAwMTR1yUs8XHUwMDExT1xcMut///bt2/fxbNj6/s9v31vTqN7vNG/qk+//wON3rZtR5/pcbr5cdTAwMTL0eXR9e1x1MDAxM9GZl+PxcPTP//qv+nCYanfGjevrXiq6XHUwMDFl+Mta/dagdTVcdTAwMWXBif9cdTAwMGY+f/v2v/T/uVx1MDAwN3VcdTAwMDb1dotOpsOPzzHSPT1auL6iZ3JhlJNCu8czOqNcZjxr3GrC11x1MDAxN/X+qPX4XHJcdTAwMWX6ntalvcl4Vlx1MDAxYZ1yMZtWo5lVW9XHx150+v2T8axPTVx1MDAxYV1Dz1x1MDAxZr9cdTAwMWKNb657rbNOc3x53/2546uuurm+bV9etUbYdf5w9HpYjzrjXHUwMDE5XHUwMDFlY+zhaP2qTfd4PDKFT06oVMCNlJLpwFxi/nhcdTAwMTe8fos7nbLMMa2cUYGy9knDdq771zfYsPFN/Wo0rN/APDw2r1GPem1o41Xz5fMmSbeVSOmHg5etTvty/PToqEWjz3XgXHUwMDA0k0w99lx1MDAwNlx1MDAxZjTMN0lcbv7nccxv6oNWXHUwMDFlL7m67ffnXHUwMDA37qqZXGbcwlx1MDAxN1xy/FwiOydQj7e6XHUwMDFkNut+7rk1TCmrrVx1MDAxMuZxwPqdq97T2/Wvo95cdTAwMTJxXHUwMDE5jevjW7z992Hrqtm5ai9cYolv73fDXHUwMDFhzJqoXHUwMDAxj2JOtXTQsqopXHUwMDFhil84XHUwMDA1/W9GrWaLNZvzQlx1MDAwNHBqPaBcdTAwMDB/tlx1MDAxZZr37Vx1MDAxYk/++lx1MDAxZvr9r38sh8pNK1x1MDAxYXtRWVx1MDAwMlx1MDAxN6HESrhcdTAwMTgu4Fx1MDAxZltcdTAwMWYt21x1MDAxN7d3+/z253W8XHUwMDFiXHUwMDE0LN9y57sl81loYe9CXHUwMDBizHuK28BoK6TU2upFtFxixlJGaVx1MDAwNzNcdTAwMTZcdTAwMDSaa2VWweX/8Fx1MDAxNv57P1SMXHRSgVxuXHUwMDFj/Fx1MDAwNEow4Z7jRiqVglZaJWXAjHyOIcMt50LOTdm7MfS/XHUwMDBmUnYvSDI58q/PgdaLMj1uTcdLxdmxVeJsJGNcZlx1MDAxNKNaW5x3mk1pWal6XHUwMDE2XHUwMDE2alx1MDAwN4OoX779Ub7+2srfXHUwMDA2KmW1XHUwMDEyXHUwMDBlXHUwMDA23XBQ/0+Uv7QpXHUwMDBloqWZXHUwMDEzMFx1MDAxNGZO3DctzpxcdTAwMDUpXHSjzYxYYlx1MDAwMYR8Jrs84Epqp35ddH+D+r+4vlx1MDAxYZ90YpxcdTAwMTDBXHUwMDE2ju7WXHUwMDA3nf5sQVxiSHhh/KpAhr7t9G9H49bN94Vv0/1OXHUwMDFixfl7v3WxKOfjXHUwMDBlWISHr8fXw8dvI3havXPVunk+Otc3nXbnqt4vv/hk6G9r736GeIrruclcdTAwMWa18Fs8XHUwMDFlvN/YKKaeXHUwMDFlfTA2TmpcdTAwMTFY5daH5+z0quR0tnQyLPHdvZP0UEx1ZaPwbNZHl62N4pNrLVJOXHUwMDFipoGeXHUwMDA1zlx1MDAwNE/YmVAspVx1MDAwNJrdwIDdmeOym8Yn2LuUhCfwQFx1MDAwN1pcdTAwMDNcdTAwMTNcXEbTbCqY/3mGWYCMks7qx1x1MDAxMfxcXHvzcM3j1Vx1MDAwZuJxd9VcdTAwMWama9dH/Va3Nuhccm93+63C5cM4LFxibf3m5nry/eGbf93TqtWagjFcdTAwMTg4MY+cX7FmK51cdTAwMTnB5WrAOKdcdTAwMDPh1PqAuVx081x1MDAwNTVcZlx1MDAwNlf5o3a9sn15cNqPXHUwMDBmv7Y9XHUwMDAzm5By4KnQmEtp5FwiXFw0+DLCXHUwMDA1XHUwMDE22Fx1MDAxMOfAVNlKerZcdTAwMTFvRixcdTAwMDHJ3LF7VDhcdTAwMDX0TFx1MDAxObtcdTAwMWNcdTAwMTWPclx1MDAxZd/eXHUwMDFl9k5cdTAwMDe79ZPpj+NcdTAwMWa72Zutm95kTkK/X7LTgtg7dqYm+PmUXHUwMDFkXqmtrcb3Z9L5NU3iWlx1MDAxZVx1MDAxMW81pFag7lxcoIVtXGJgXHUwMDBiKlx1MDAxMkEjXGK4iFxmTmnEgaXYlzyiNztEK8mjXo02YVx1MDAwM6fnfNPXsFbc6Vx1MDAxZp2fnVx1MDAwNv0jvnWWj1x1MDAwZd1cdTAwMGXj3a+NNSd0XG5oIbNSiCVYg2+FcpqD5ntcdTAwMDVrv2ybXHUwMDA0UynwuZh1fD3yaCycajVbTlx1MDAxZf9TXHUwMDEw9y5cdTAwMTJ6WFx1MDAxZjZbd1tcdTAwMDe3jdZWWL9cdTAwMDJD9MlcXPTFXHUwMDA2fDgllWIl5lx1MDAxNXgk1pg5cvNcdTAwMWHoj+1wqlx1MDAxYdPbs8ObxmG0nzOivHv1xVx1MDAxOalccuRLXHUwMDE21vJUYOcs7Mdh3rhloUJcdTAwMDdH2dzPYydcdTAwMTPog6pcboCEykdttFx1MDAxY/qbQ/bGmeqwW1x1MDAxOVx1MDAwZstTzZtcdTAwMTelWq/erlx1MDAxN/Xe4aaY6mbjLi9jia1cdTAwMGW9M26dkUasXHUwMDFmTGTDSWurnblcdTAwMWRcdTAwMTeHpcmuLk9Kg+lm2epcdTAwMDe4d1xciZR0UnCltJOG2UU0aZtiwFx1MDAwZa1cdTAwMDG2I5nR/OPgXHUwMDA0juS8eyeXUFdtnoKJXHUwMDAzmVboz20gXGK/QZgsJW5zonJsXG6sXHUwMDEx7KvprTmubYlI5lx1MDAwNpd6XHUwMDBlJf9YfttP8Fx1MDAxMz8n6imZXlx0PIypWaesWVx1MDAxYnjLR/OrXHUwMDAzz7JcdTAwMTTgTihAXHUwMDFlXHUwMDE4Ma1cdTAwMTaBXHUwMDA3aNCfXHUwMDAzPO5kXG4mXlx1MDAxOL1m4NNYcFx1MDAxN5VY4S3+XHUwMDFic85v2au7zs311WBhXHUwMDFjXHUwMDE3KGdcdTAwMDRfzdPBZ6Rz0Gk2543RXCLvfM2GLKeiy9v18Uz0hUxcdTAwMWMmajXnbn3/s51tTFx1MDAwN/1cXPMmm1x1MDAxYl/cbO9ccq/HW8dfXHUwMDFlxGA9XHUwMDAzXHUwMDAxfigzQlx1MDAwMCZcdTAwMTZBLG0q4MwwbSxYNeE+LnfxPuNcdFqHS+BAvyv59j7beWVHXHUwMDA3/d1Sb1ZcdTAwMTn1d36Enf6u3DFf0caplY6aYDxcdTAwMTBKmzeEQpf3+qvDXHUwMDAznDFprbAw7k7Ap1x1MDAwNXholVKfXHUwMDAzjzebOK6MU5pcdTAwMDV2uZP2l437XHUwMDE1XHUwMDFi94qm/2xcdTAwMWK3XG6/3K7mqFx1MDAxY/xCrlxyW1x1MDAxZr/BXHUwMDA12zpvspPGeTjttiqDznntR+lrh1e5XHUwMDBleMpcdTAwMDaBZdI6p5V+WmrCLSbMrWRg/6yzX4iiKulcdTAwMDKsJvudXHUwMDE0VVx1MDAxOa3n9MeXge+HRkV/XHUwMDEzXHUwMDE1XHJU8PToPVadXHLAi2J6fSaaP+tcdTAwMGV+XHUwMDFlpK3oXHUwMDA0TV3esjnezO1/7aIw7lx1MDAwMCEuXHUwMDEwMjBcZriFY4tlNFxu7LCQzlpAqlx1MDAwM9KqnjRsc0hVKkhZXHUwMDAzysCg7Vx1MDAxMnJJTZhiJlx1MDAwNXTZXHUwMDE4XHUwMDBljFVo8awoTDAplFx1MDAwMmr6WjqyOZlMMuV9cb2nbPegV2pcZkb14teIkM4uZjw3mf1cdTAwMThcdTAwMGb2yplcbt9XO8d3w0U2++5cdTAwMTiNU1x1MDAxMlx1MDAwNkjOo+tX+Gur3+9cZkdLYeX0yvJcdTAwMTdcdTAwMGKGgWP8Ym1YXHUwMDFkiLNZq1XXp6Kof5THhu1eXHUwMDBmLr44rMCdSmFcdTAwMTZcdTAwMTX8WYuWblx1MDAxMVZaiJRVYGnAXG5cdTAwMDaK6Vx1MDAwZswvXHUwMDA2XCLFXHUwMDA0YFxuXHUwMDA2XVpll1x1MDAxOUFnXHUwMDE2Tnme5WfWSkyXLlx1MDAwZpV+XHUwMDBlrMTrsPo0wV9ZxFx1MDAxMrDVaXXFwXVbXHUwMDE43tfkfjo9LKmtvWj687zqhj/2r0rNg6OvzfxcdTAwMDRcdTAwMGKAcFx1MDAxOW2dXHUwMDExYFHmjCuJvVx1MDAxNSlcdTAwMWVYXHQ+nVx1MDAwNbmXq6T+XHUwMDEzK1isXHUwMDA1gGKB6CuifW2q/cagymX17GArrXI7dd44nlPL33uXTXmU7e5WXFz9IDva3U/fOlOZP2Fz4HgvsXw7XG7mJuhPq3CxbmWGzjlntDTuXHLZbnmzn7mYnmd3bzN3W6fd9CHfK3x1JLIgpXCdiWaodtxcdTAwMDJcdTAwMTKB1qaMXHUwMDE0XHUwMDA2PDTAY1x1MDAxMHycXHUwMDAxXHUwMDAyS5gy69dGXHUwMDFihZpBr1hcdTAwMWHz+Wh6iardhp2LXFyGbVx1MDAxZLS7zVxcObzUxVx1MDAxZrPcelTtXHUwMDFmL923fFx1MDAxYd+dqu1i1NnfXHUwMDBm73pcdTAwMTdcdTAwMDelK9P5bVx1MDAxNHAjzuVJp9mK6jffjm6up7Pf4l6uaMFHxoHcXHUwMDBiuUpcdTAwMDN+V2BcdTAwMDK7fpHATqW+Y3Kt9sicnF43XHUwMDA3YdgpnJ59dVx1MDAxZCRkijuD5d+OXHUwMDEy7lx1MDAwYkrIXHSTslx1MDAwMpST4k5cdTAwMTnHVvKBX1dCxqTWVkGCg85cdTAwMDSK8lqF3Vx1MDAxZm3Q31x1MDAwNeb0cPjt7Pqm17+uN79cdTAwMWRdNz9cdTAwMTfIq5++XHRcdTAwMTCv5vRqpStrLNc2sGZ9XCKRLZYyMrLZq/7JXa1VOCjtR5nZV1x1MDAwNzFYcCmdXHUwMDA2P1aAr2pcdTAwMTfL5iznKTDYQKGlXHUwMDBljOIrY7mfSOqhPVjbo5anJ1x1MDAxZlx1MDAxMXxcXPtxLjNnneisuPfjvGGP7GE1XiD1Ry7cu7g5XHUwMDFmpesn+Z+yeNqc7Fx1MDAxY/1cdTAwMGVS/0E05CPpwlx1MDAxZugyXHUwMDA0q713bjVcdTAwMGZcdTAwMTRcdTAwMTiytaHePuj2a63xrHfbz5ylzfWeOD+KvjrUXHUwMDE1gFlcdTAwMDTWOsmlUnLRfbdKpoRcdTAwMDXPSTL4XstcdTAwMGZM21xim3KGS7FuVTzXQopA8ldcdTAwMTei/GdcdTAwMWHtnXsr+/lcdTAwMTZ7yaM3Ya69JltcdTAwMDJiIecq3p6GnkGcmFx1MDAxNG/I6LysWzdSPHE9XHUwMDFlryqeeF/sXHUwMDE5+KtcdTAwMDWcXHUwMDAyUlx1MDAwM1x1MDAxOVxixviiwTYyWHD9V+6K8csoZuD4XHUwMDBioPVcdTAwMDHoXG54jllWW2RcdTAwMTg0VVx0+F7yQCqzpKBcdTAwMDLmzCihPo+Of0DwWc5cdTAwMDU03mZDb8bbXHUwMDFkbz5cdTAwMTdcdTAwMWGWbFx1MDAxYpNfIzxFKlwiXCJTvMVSjIF8gCFcdTAwMTOScVxc6GBcdTAwMWZcdTAwMDPSOLT1IVm71MJCXHUwMDA0rp71XHUwMDFmTPrr7XqZ7T5pl0MnXHUwMDEwmoQpV7C2+nmrRIpJJ1x1MDAxZLhn8M848TzT1q+PxjvXg0FcdTAwMDcxdXTduVx1MDAxYT9cdTAwMWRoXHUwMDFh0TQqj8tW/ZlcdTAwMTRAr+a/XHUwMDAz8e48yVx1MDAxY1x1MDAwZvGmi5L3+Ne3RyTSh4e//+dcdTAwMWZLz95ajVx1MDAxMPx5ho3H+63Fc1a6NNasXGZMWKFhXHUwMDEy3Fx1MDAxYlx1MDAxNi/s907SqlL80Tht2PbR/n5wutvZbGx08yqSq0CCiGt0WcC1XHUwMDExcjEwXHUwMDAxN+AprZRxQEvhLLVyLdBnrrXlhlnwwcxrodGwNNpmvdnRya3K9eq9zKQy4KVP9klu8lx1MDAxN5X88f7phbzryHqt1bvrTXqbWmlgXHUwMDAyXHUwMDE1zCUyN+6TXFxESjjcVERFXHIrtW1cdTAwMThcdTAwMTNx8G3rdVBSXHUwMDEyPjSdMIH7pDTG6l1eXHUwMDE4yqldP/iQXHUwMDExN1fji3qV5bcnovLzoDus2eDLI9WyXHUwMDE0V7hcdTAwMWNcdTAwMTdmnbsnOJU6pVx1MDAwMFx1MDAxNuCLMOHmjMbmtyxcdTAwMDKHZF1fRDDujFMrasg+XHUwMDFmqFx1MDAxZlx1MDAwMqV3+VwimdZd43r6uU7I02d+rPdh3Ervg1tcdTAwMDX2xM1vrPVcdTAwMWFiX85cdTAwMDR9UcRcdTAwMDaBSYGbZcElXHUwMDA3XHUwMDE1Ne/qkvtcdTAwMDHkUlx1MDAwN1xcXHUwMDAwy2BPirg2XGZZMNspcFx1MDAwNnFZg6Cio+fgZSnNnbTGXHUwMDA0lmv4Qz5fJlxi+JCg9j9mn7FPLmr5ML+CpVTAMGtiwa/goCjFXHUwMDEyp1x1MDAwMoNHQCyFMWBUpbXuWedcdTAwMTecisV+/GHEfqXs0eXPpO6NxH7lwpHVZFx1MDAwMamCs0q/YYtDXT/Iievb8sXRcFxcXGJ7XHUwMDE3P9vN0fnXXHUwMDBlYIJcdTAwMWIlUpzBeFuuoL/mXHUwMDExz5RwVCrFXHUwMDE0TotcdTAwMTSghkXwpGFcdTAwMWLUPVxcpbRef1dcdTAwMGZcdTAwMWUwqr9ccn7nslx1MDAxMZBHMydcdTAwMWafQ1x1MDAwNr51cE1I1Fx1MDAxYY6/wYheXFx0om9cdTAwMTc314Nv0e/YLO6NTfpYKmHdSi9cdTAwMWTosDRcXL+hhvblsuIvWUNLiUesS8VcdTAwMWRkXHUwMDE3Qlx1MDAxMlSZrmjv31x1MDAwMCDmwCdWXHUwMDFmuFx1MDAwNixcdTAwMTVoXHUwMDAzdEXhbqSgrJd465LpXHUwMDE0llxmgEJcdTAwMTe4glU941x1MDAxMYHDZWFmxd5cdTAwMWT/3jTi5WVcdTAwMTHfXHUwMDE2w4BcdTAwMWFcdTAwMWJcdTAwMDMjXHUwMDFkXHUwMDE4aVx1MDAwM9CIz3iEMCkpOFx1MDAxMFxyXHUwMDA21lx1MDAxM1S9si/ziFWt+vmjcTppjIZRpVYrXHUwMDE0xsFZrVvZXlx1MDAxOTTVuFx1MDAxNJFcdTAwMDdcdTAwMGVa5cBy8OfN4ikjjJBgYZ2Uzlx1MDAwNM9a9Vx1MDAwZVx1MDAwZbOEpzxhMlx1MDAwYtdvlsOslHv69rnIv5HEvHuFOFx1MDAwYlx1MDAxY/DLt+xcdTAwMDb401x1MDAxNIvbucl5u/5DuctcdTAwMWb100F9f7NVXHUwMDE3XHUwMDFmtb9cbthkcKWc42pR+21cdTAwMDHTScHIXHUwMDA31lx1MDAwMp9cdTAwMDahlE+b9ruXiFx1MDAwYqbBc3JqXHUwMDEzu9x+4lx1MDAxYfH84bhyMrkpzEr9bKG1//NoeFHd/0/aX+VcdTAwMDXggUvvmFx1MDAxMnL9+MXy0fzqwMP9VYRcdTAwMDbDXHUwMDAysFJCPNnYXGI3llx1MDAxNp+DvLevPndCglx1MDAxYs7FXHUwMDA2QPfX6vMnq89fsVwin72sdWV2b75s59n2XHUwMDExXHUwMDBld1Ngb9hcIml/e7dcdTAwMTWc/Ij76cl+rlip6lp4u/vFo1x1MDAwMFx1MDAxNpfXXHRupMDFd0+qmLaMS0lwy43kuL0nW73x9Ccm95TjUlx1MDAwM3F8bVx1MDAxOdLERlx1MDAxOVnbZudBIbNXS59cci46hs2T1oPB0WH+uDboXHUwMDFm51x1MDAxYeXTg6B0p8qN+Vx1MDAxM1x1MDAwNrPt7WJ+K7dcdTAwMTWYXHUwMDFmNdXgO0c3QJOfmZ9cdTAwMGZd3457L75rffufV1OoXtgo0MpcdTAwMDBcdTAwMWNcdTAwMGL7hlxm3u35VVadn5qRuUhvz8Tu5PKg/Wle/DvBaKRLYahcdTAwMDLfSOTAjX+yXHUwMDE3hJQpcJl0XHUwMDEwOFx1MDAwNe5cdTAwMWWQ+4+zpm9biYRbdVx1MDAwNIGwry5ccvyjXHUwMDEx9SsmN4mW/b41PctcdTAwMWLw8e92XHUwMDEw5unRe0xrXHUwMDFkIFx1MDAwMXvD7tlmIG5Zvlx1MDAxY/WP6/LYXHUwMDFjqvSdXHUwMDE53HxcdTAwMTak35nh04FLXHUwMDAxkDhuXFwtreJPdiBcZjD2bTQ3WjmAXHUwMDBm+7jQnHAshXtcdDChhNNGLVx1MDAwMTaolJQ0Vlx1MDAxYaeBqnPHl+5cdTAwMDQqwfq+VlezOZxvfNOIo1HvmP+4a970+pP0zajc6uRMbVOO55uVy4vIWlx1MDAxOe/mUqxcXGgjmGWaYVxyztq4etlcdTAwMTf/orhcdTAwMTJByvHAcaVcdTAwMWNi61x0rlx1MDAwNIZ8XGZcdTAwMWNHy1x1MDAwNPTVfNxcdTAwMTbVXFyqVKDxXHUwMDE1gqDOXHUwMDAyaebiw3PJc4EvXHUwMDA3XHUwMDEx0mA5XHUwMDFkOFx1MDAxNuqZXHLFOKhcdTAwMDLb//WS59Za8N3fS0LXiXq//M6eb0/iy8B9XHUwMDE0UCWOS5ekVM/T5zD5TFx1MDAxYUHjzIwzz3u/VtT75W1Cv82n9Fx1MDAwNcOcPqhYh8VVwWNcdTAwMTn5t7mKXFxcdTAwMGWyXHUwMDAxzouwgZZOPFx1MDAwZsX/cUHvlYJP1z9cdTAwMTf5xzuu5Ses9Nr1XFyc6dnWIUxw/qaNjbfP7jrFws9BkK01rlx1MDAwZUZHl+mbW/7FXHUwMDFkXHUwMDA1XHUwMDEwe3whXHUwMDE0JvWcml91Sek+4VJBoK2WXHUwMDAxpl/s6te5bcRrXHUwMDA3Slx1MDAwMb7C4497XHUwMDFj+sek3+pzXHUwMDFlYuBcdTAwMDbfLCledev3cnt3p/rMnlx1MDAxNS959ub6ojPY+jn5XFxcdTAwMTfCXG6sJ/0wpzxq6rrmLOKs0Vx1MDAwMu4kW8165OTFXHUwMDA1eOSyaYTRQbNpNZ/bfHtcdTAwMDNO+UqwObG6So9cdTAwMTlcdTAwMGJcdTAwMGWpZOtT+MlknC/OsuPerF+3jerOzDb2NryL+EfU1Vx1MDAwMqBcdTAwMDTWXHUwMDBmXHUwMDFidLvZYqVcZnBpNDfSgipkypiPXdUrecpYwbVWUoHzpOUytK0+54HHg2ZcdTAwMDCWuGJX4ke0NZhcdTAwMWR1J7tsNHHB7qS/JcTkqDRvXHUwMDAzv1x1MDAwMFx1MDAxY0HHyTmLsGk4MnUhnLGseXFhLpqcKVx1MDAxMUlcdTAwMDVcdTAwMDd51LyQXHUwMDE3XHUwMDExb8pcdTAwMTaLWoHZKFx1MDAxY1fFyLR8wfQ5ybHYfv2ytVomk96/Ytt8f1x1MDAxNmxcdTAwMWT3ypmrXHUwMDFm8c8vbvqUkSlcdTAwMWVcdTAwMDQgwFZap4Onm/VcdTAwMDQpXFxcZqtcdTAwMDJcdTAwMTZcdTAwMDBcdTAwMTdYXHLGX69y1yksK1x1MDAwMoQt3Sju+W6pNuBcdTAwMWOL3L6+fXs/oH4hRPY7omKfVJ7GRbC6PI1ZiW/1NOvb0Ja4XHUwMDBiXFy6lb85XHUwMDE3tfaoa88nI71Zd/0jbKhzKeOscYBcdTAwMTkt1WJkO7AmXHUwMDA1MMLVKVx1MDAxNt9W/YFbbMFzZOBsXHUwMDAwyiNQnC0pdFx1MDAxN9BSoKFOaVxyplx1MDAxZdyJZ9bTOSTV4mNeh/OLrjq0d27bxPe76k9Lwv9Nis1Xzj99+3zqXHUwMDFmb7iW2V6tXHUwMDAylFlcdTAwMTmxXHUwMDBiXHUwMDAysNxMvOFcdTAwMWTHLy9cdTAwMTn8olx1MDAxYVx1MDAwMDyWVMBwn3PhhFWLrykwLkhJMI641aDgbi6ct3lccmBgLkDLaEtLLJZcdTAwMTFoXHUwMDExmJTjXHUwMDBld7tcdTAwMDSeocWSpS6S4ZqdXHUwMDBmKdj6NVxy8EsrPtfTXHUwMDAwq1x1MDAwMmYvL2/+9lx1MDAxODDjKVx1MDAxOD3kXHUwMDE5QsJcdTAwMTizx6zut4dwmU6B54J5UFx1MDAwNX5cbnCnZ2Pyh2me1WKHP89cdTAwMDTujXpnpbvAV7pcdTAwMGJKci6YfoO3UN7v5kpunFx1MDAxYvHs1ex8VvtRVdOTL+4tXHUwMDAwJFLOKYSr44FcYlx1MDAxNlx1MDAxN9iBt5RigjH0XHUwMDE1wKebr1x1MDAwNdv8iyx5SnBcdTAwMTas6S1IXHUwMDE3QHvcip08PinMXHUwMDA1pHSuIOiT1rhE/Vx1MDAwZbTtYTXJ+Pq3Lm9ZozVcdTAwMWbrOii32uFcdTAwMGZAUzDH3vAq2peTn1+VODCbQnpkeSBxXHUwMDBiisVcIlNl4FuFO4RqzFx1MDAwN4lcdTAwMGbcXHUwMDE506mUXHUwMDExTFx1MDAwMpczxs57KY+vXcA1dVx1MDAxYVx1MDAxYsKEVMLZ58xcdTAwMDFj99yuKpX5vc6DUWojm++8nTq8XFza8W0+11x1MDAwNjrUXHTNZcC0lFbpJctnQas7QIZWXHUwMDAxZoDV81H508jDKsnDn63nQrcp9sBWhv7BXHUwMDA1ZsFbXq02m/Cf+41stdfdmXa6I7N9KydffIN+3Fx1MDAwMFxyPVx1MDAxNsdcdTAwMDJcdTAwMDeoXHUwMDE1i7WxTkmgqFx1MDAxMl/BojjX7lx1MDAwM1x1MDAxN5W8jTqAkFh8890m1s+9nzsw3PD1L+7wXHTcYWXmTr7whlxyjlx1MDAxNV5q/l1cdTAwMGWvXHUwMDAx2Fx1MDAwZdl0XHUwMDEyxfmb2unP/VxuXHUwMDFijYxcdTAwMWJsNubwXHUwMDAxe3RaTJMzXHUwMDExXHUwMDA0gVx1MDAxMe7JzlW0OsVZjuVcdMxcdTAwMDKA2crt/T6xulx1MDAxZPOpxln9Wlx1MDAxYZypLaOvXG4/wcptnYz6J/X0OVx1MDAwYuZccubhYTZ/zsuVQrjVzt7cRXb/Vp3OnzDq9C/6oja9ZFx1MDAwN/3A2IOrZu3ik1x1MDAxM1xyaLLcu5a//HnV7dytRiNcdTAwMTNgvZV+w4t8y93c8fH4xtxcdTAwMWRWXG6zzPVZKd6Zdb86XHUwMDFhhUw5XHUwMDAx1lxm/HBm3NOSPVx1MDAwZdZW4vtAXHUwMDAzjds72Fx1MDAwZqzYXHUwMDEzuFhegoVy65lU4FRKydez5X80pH55m/vPNbLLn/zxXHUwMDA17WplQTtcdTAwMGZcdTAwMTRuUCnfYFRzZ61psD+pp4Ojs+uTcfdytHv4aVx0+PfuWYVvXHUwMDFmdOCKW2Nwx9nFQP5cdTAwMTbHclx1MDAxOKHgXHUwMDBi7bRcZiT/OFx1MDAxY2+kpF1oa6GZr5a0b1x1MDAwZdvvKmnfJOTfRyfV6tJcdTAwMTNlhaXX9a4t+K47bEzAiW7m7i7dSJxcdTAwMGaua5nNvqdw84Eox3VcboZcdTAwMWP+MaOBLy7GobRcdTAwMTEpsFx1MDAxMVx1MDAxNuut1FdgksKBk86dfq3ghO+xfXZpf17c6Ou9rauLXHUwMDFm9fyw88lcdTAwMDWVyvFcdTAwMGbcpTRoilx1MDAwYtBcdTAwMTUqqLt6wJrNVqNpLsD3aWg44my9pZtcdTAwMTdSXnxOXHUwMDA111x1MDAwYiuisGhJKP6Gfccu62nd/5Frtlx1MDAwZlxcepoxP3u58HCztcubh5G1LFx1MDAxNejAWvhcdTAwMWaWVi6mZHRgUlx1MDAwMb5cdTAwMTM+gPlcblx1MDAwNP/ANY7cpFxmPmJdXHUwMDEyXGKultPavLpX6WdcdTAwMDHq4YlLVjm1RpXifr2TXHUwMDE5NO/22OmgP1x1MDAxOF6I202tcno/XFzfxTGPblp3ndbkW+X48HMp5tJcdTAwMDd/cLpHrK5cdTAwMTTDpU7gPL2h2vplKfia6sFcdTAwMDU2heubpZXMSMae1HdcdTAwMDKfw1x1MDAxN1or5Hz4suBcdTAwMGZTXHUwMDBmSumUXHUwMDA2XHUwMDBmUcKjmGZ62bIunsJCXCJneWCENYrbOW2VvKNRmFx1MDAwMOfsK67q+lx1MDAxNaO7zqqul43Tt/mcXHUwMDBlh1x1MDAwMaY339F7npeldOBcdTAwMWNcXGLlXHUwMDAyo5xcdTAwMTD6lSVdf3KN2mrBw5/nXCL3eL+1eMlLJWqrt0Q1UlpcdTAwMTh6sz41XHUwMDE5divjYXmqefOiVOvV2/Wi3jv84rpcdTAwMDfoiMLXNuH7JVx1MDAxOO7htchNcH91i6kgjtu8SfZxZapa2Vx1MDAxNO7MyowxXHUwMDFhtOHjsM+lmsFccmdcbveKMLj7wvNFXHUwMDFlUlx1MDAwNTzANStfT/c4hbZsXHUwMDAzuuftmeZjO5yqxvT27PCmcVx1MDAxOO3njCjvXi3XSlhTXHUwMDAwg+gw+lx1MDAwN36Umzsp0Uq0ep85xVxyUFnB+LNB+cN0z9ZKwaNvn8vcXHUwMDFilc/qOrXVW6npQHP2JtozrcW9ps3shTa05bI9XHUwMDFllk6i0kZVz8aD40BcdTAwMTJSjFx1MDAwYo57YGvL7GJsXHUwMDFjNH1KOHyfXHT4RcZcdTAwMDVcdTAwMWZcdTAwMThS08C+NF9/XHUwMDEzNYtv511cdTAwMTVcdTAwMTn/nOhcdTAwMDG+XHUwMDEw137eXsxzXsFvzzSv25aPfHWzWb1cdTAwMWFbMoVbnr4hr9WtzfbloNg4XHKzs8K1a+yfjO+aX1x1MDAxZLoqXHUwMDE1OFx1MDAxZSAjM4I/XVx1MDAxZFxu0GXCWMNcdTAwMDX4MuxcdTAwMGJBV+B2+mp+T8bfUCai+Xzk/S/ofnaJiFi5/6FccjRcdTAwMTBcdTAwMWEt1i/xau7l9lrn21lcdTAwMTVcdTAwMDXpwfl+9u7u4PbLv7BcdTAwMTngabCW0Fxi8D3V3I6ueFx1MDAwM9xwXHK5PjP4Miu1evvSz3y3mdVcdTAwMGXc5flcdTAwMWRvlochq/2jk0o9d9pcdTAwMTRcdTAwMWTh0oPqofm5u7Bpd+7ioDlMb3dHu2r7p1x1MDAwYn50xPXtQoXIzvFZtrA97DVr55fBQVxca19WbtVnp7NBXHUwMDAy/0P2P1x1MDAxNGwlXHUwMDE4ucF3sVxuXHUwMDFkrJ9avslt25/NW8fcUfOmfVx1MDAxOFWO+1x1MDAwN42vjkZcdTAwMDHGklx1MDAxYoEvVjQ2YIurRI1LMVxmeDCFXHUwMDFlmfvARaJvrVx1MDAwZlx1MDAwMdPPtTNqxYrQf1x1MDAxM0T9VSCyVoGIXVnnXHUwMDE14Csk5FuWer/84oUvWlx1MDAxZqI58GFcdTAwMTHQOlvczHRcdTAwMDHENiVBx1qwqVJrKfhcdTAwMDeuuNrIhocs4FxcWvNqXHUwMDE2fXPI3viOhy+/z2ZBst+x46E2Wr7ljSjvMo3WrVxcPY3LguhcdTAwMTXNa4Nq92rvKF1wR/10nIvMNLjY5XuVr21cdTAwMTlxu7uUxI2nwMxcdTAwMDBcdTAwMTd98oJcdTAwMWZtXHUwMDAxclwiME6iZeT640zjm1/WXHUwMDA1s2NNXHUwMDEw/E4v0ylwws273trz18u6XjeSf0v0w/f6cHhcdTAwMDKku/Wgw2CqO81k+Fx1MDAxZZv3XHUwMDFk3d7tl+Xub4mKIKr+qFx1MDAxNL9cdTAwMWLWXHUwMDAwjy1qwKwyp1o6aFnVXHUwMDE0XHLFL1x1MDAxY4h90IxazVx1MDAxNms2m3Nq9PugM2iV51x1MDAxZOH/XHUwMDFh3bX/Plx1MDAxZPRcdTAwMWb7m2wvvO7NXHUwMDFmrlx1MDAwMzmrY3HAP/2f/1xcuP3/xbEy6lx1MDAxZkd7XHUwMDA1UZttq8bZ9DaKWae+d8yizPXdoWzK5kzLcKbvokF0XHUwMDE3dtOTcCeIm4Ook99rXHUwMDBla3vH10cneVx1MDAxNWa2J62dfFx1MDAxYlxcuWFNXFyy+WPNQb/fZPt3rVxm64Q76XFYTt9cdTAwMTYypXYhzs9cdTAwMGW7bVbopGXYzd9cdTAwMTYzJZHvKlx1MDAxN+V2WX1nu3d0sl9cYuOKOuz2XHUwMDE0nDNccnfUpDDIwvlcbs6vtFx1MDAwYpnqbVx1MDAxOJdm+Uy6XHUwMDFkZvC+0W2xnFx1MDAxNvlM6bZQXHUwMDBlp+VMT1x1MDAxY3ajmK6Ns3jutFou4bFZ4SSN94lcdTAwMGKZfFx1MDAxYtpcdTAwMDR9YnBMzYo76Tg8USyML0eH5erksFx1MDAwYv3opGdcdTAwMDVoX9g9vcRnQrs5PFx1MDAwM+5cdTAwMTMy+G5cdTAwMDLPn/h7ldrQXnq2/37JczL520K3XHUwMDAybVxm4XdbNTJZUTxRU+xP2M3S81a2MVx1MDAxM8LxXG60Lz0tzHxcdTAwMWLDbjSFa6bFXHUwMDEzdlx1MDAxYmaqs9evZf7aTG1cdTAwMTSWq/qwm1x1MDAxNuGMzXBOYIzi1df34P7hOIzhdzery5kqh3GK89D+XHUwMDEw56NcXFs5NoflPKdxPGHJ2DbDMG5Du6ssnEFfdlx1MDAxNC+evTCvXVx1MDAxYTN5WMZ5LZh8Z3tQP5uOQMa6YZxcdTAwMTdVUeH5jvv70d72ZTPXbtdAzsrlUNCzMu12sVxmbY7TsjpjIEtpXHT9h/5kQV7gft08zEVcdTAwMDR9isRhOcugTdj3KbRcdTAwMDV/T/I7MDdxXHUwMDFi+p6Ha/NcdTAwMTKuj1x1MDAwZrslmG9cdTAwMWPvLMwzyFU3P8b75mlu2/rwXHUwMDA07j/D81wi6HdWwzjgb4lyXGLjXHUwMDAy/YL2lEOVx7Ethyiz0t8nbNM8dFGGUd6q8Llccves0vfQVlx1MDAxONv8/Tzi5zaMvYaxXHUwMDE5w2/AW9hcdTAwMDZcdTAwMTlcdTAwMTaFOFx1MDAxYcOYSmg/tLdcIsLBZIzzUdhJw7VZVcxlp0Wa71x1MDAxMO+rXHUwMDAxV3FcdTAwMThHjzjo4Fx1MDAxY7XbXHUwMDA1xFt3v3tYXHUwMDBl4T49XHTXK1x1MDAxY1x1MDAxZpBcdTAwMTdcdNeIwixccnhRU8DtuFhGua9C3yowhlx1MDAxNV3IZWeFTFx1MDAwNfqJbc8zaNe00E2eccJcdTAwMWXkXHUwMDAxx1xi5lx1MDAwNfRAdVx1MDAxYaK8lcMx4lx1MDAxMOdcdTAwMDDaykGu8ZmquMNQPzDAJDynxElcdTAwMTYyMK7lLFxcW8H5QP3AipkqzGtcdTAwMGbkMKvhO2h3XHUwMDFi7ovPXHQnXHUwMDA1elx1MDAxZZy7g8+varinQP1cdTAwMDPt0H7e6Vx1MDAxZYCFXHUwMDFljFdb01x1MDAxOMdVkDuci8pcZu5cdTAwMDdtSENcdTAwMWJC/Ixjzovl0jhcdTAwMDR5KlBcdTAwMWZKXGZ+XHUwMDBivDeeXHUwMDAzWIBzXHUwMDEx01lcdTAwMDXHY3rWjMH4lDTJT1x1MDAxOcZcbua3XGLj4+dcdTAwMTfvnUVcXPCCl1x1MDAxOfiNbU2jXHUwMDBlQ0xNfVx1MDAxZkozmFx1MDAwYvhcdTAwMWLnvt2G50M7QtRcdTAwMWY0XHUwMDA3KIsg+5NcdTAwMDLoQ5BxXHUwMDE4gyyMOfVcdTAwMGKfiXMpUD6KmSzKz1x1MDAxNOapXHLfw+dcdTAwMTDvNVx1MDAwMT2Ax1x1MDAxOcpcdTAwMDSc5+dccvuIn2fwzDjE46JQRvmFsSmjXFzCszI96Fx1MDAwN8hrXFyCOa/g/IA8ZKH9PV08ScNcdTAwMTi3UeanOOf+OMpAXHUwMDFh2ptHXHUwMDFiXHUwMDAwv0Nccv1CWYjhfEEyMcPPvbhIsleKi7lcdGJcdTAwMTTGXHUwMDEzxlxux1WATupW8Nmgi1BOI+hcdTAwMWKLSZdkonZcdTAwMTFtXHUwMDAxYr3bQ/lBfYU6SCf6PWk7jFx1MDAxYugjkENcdTAwMWN7XHUwMDE4V3xmWvmxbms/XHUwMDE3oYDPXCKxXHUwMDA3wuOJIb5m/nx4dtn3XHUwMDE1+lx1MDAxMGOfQG5cdTAwMDTp5lx1MDAxZGxriHJcYr9hfHZQpnsgXHUwMDBiOLZeXHUwMDBmXHUwMDAxdmekXHUwMDAzUFeW6Tfq2Vx1MDAxOcpeXHUwMDAxr+lmSabQvsG1YFx1MDAwN1x1MDAxMdNZtFkwX4BRnO8uzlx1MDAwMbRcdTAwMDGxVsYxKanD+3lcdTAwMDfbcEi6hHTGpIi6XHUwMDFjn1x1MDAwYvJcdTAwMDDPXHUwMDA1XHUwMDE5TXtcZiFcdTAwMDZjkFx1MDAwN7SlMFbJ2MQ4vyD7yp+bR/2G53KQN9JRxVxmzenEz1xytiVEnTgl+Vx1MDAwMYzQ37N7bJGNnVx1MDAxNMtt/HuGMog6XHUwMDA05TbRXHUwMDBiiSyhTszC8bzwY1xmdlx09Fx1MDAwMD1zhvKKelx1MDAxNzGF81x1MDAwM3qF5i/0Mlxi11x1MDAxME5janPsx8pjXHTmXHUwMDBm8Vx1MDAwZfqYsIT9l4jZkOZcdTAwMTd1XCLaq8pcdTAwMTjay9FOg1x1MDAxZcY28IKXSV0gmcT+k0xOvOz2RPFsQtejXHUwMDFlge9BXHUwMDA3htOC15Oo/1x1MDAxOOKrQLjIYlx1MDAxYr3cn4Cc7STcoUxyNlxyxVx1MDAwNHVcdTAwMGLgXHUwMDBi8EbzcdqF+/NcImGjJFx1MDAxM/mEe+P5bdJcdTAwMDVwXVx1MDAxYnTxzOuhNuJFosxcdTAwMWXCPFx1MDAxNmakK6Y4PiHOXHUwMDAx6vou6qfevXwhX4Exh/uiXHUwMDFlzyR4LkP/SFx1MDAwZUo4XHUwMDE303s9XGbt0jQ3yK9QVuMs2SuQkzHMq1wi+Zkhxlwi1I2yUO5h2yZcdTAwMDWUOZRXsntcdTAwMTHOXHI8p0p8XHUwMDAyrlx1MDAwN9mJXHUwMDEwW5Lmu0N2VlPbwVx1MDAxNnv5IFs6XHUwMDBiyeZEY+ov3a869vNcdTAwMDF/01xc0fjEXo7CXHUwMDE5yfZcdTAwMGX+nfa22cvaXGb5ibfVJMfYNtCFIepCnFx1MDAwZj9cdTAwMDdeNyqw09SXkOZcdTAwMWH0K2E5Yl5cdTAwMTZcIuBAiOk26lx1MDAwZZbwMVx1MDAxY2vgXHUwMDFmJKuo/zXKL+pF4COIcfjcg/Og3fA85Fx1MDAxMNB25CHeXHUwMDE2ZCpov1CX0vU0lqiTSVx1MDAwNkOU9Vx1MDAxONvRQPmMUU/UgFx1MDAwYqF+XGJcdTAwMTN5XHUwMDA3zjojO6BD1N9kN/OI50kxmSPU5WGmeVx0+MW2IVx1MDAwZVx1MDAxNHxcdTAwMDf8yPO2RK8ykqFyNa7i+bM02Sevt6vAsbC/IIM0X4gxlMlqolx1MDAxM9Eu49hV2mTTSU7zaMtgjkvAe/Fa4rMxcXzqXHUwMDFiylwi8kiwQaCfwJ4nelx1MDAwNedcdTAwMTP6tEM2XHUwMDE354RcdTAwMTE+6HNix4CzXHUwMDE0XHUwMDExb1x1MDAxZJpcdTAwMDPux1x1MDAwMfFcdOfG+TbpnFxm/Z6RXHUwMDBlgflcclx1MDAxZuY3TzqlQG1cdTAwMDGdf4LYRlx1MDAxYkBypUkuaD7a0stqfoZ6JoS+e/7o9XMhXHUwMDEzKeR3RcCI5yY4nyTjgKV7XHUwMDE5R1xm+fmFsVFcdTAwMWVDeeJcdTAwMTkg51PiXHUwMDE5qOdJ34d0XHUwMDFjMDmmvlx1MDAxMnfJXCJnl8RcdTAwMTNO8HdcdTAwMWJtXHUwMDExypFAOYax5igvNFx1MDAxNihXyPVQflx1MDAwMdvFXGY9z+urXHUwMDEz4P2oXHUwMDBmOqiv7jlKpFx1MDAxMW/eNlbko01cIp3KXHUwMDAwL9zr82rs9VxcXHUwMDA0tlx1MDAxYbGQ97pcdTAwMDRtMD7H+2HQ31x1MDAxMkedhDJcXMz0wOdDftuDNlx1MDAxME9BXGaS/oNnYDuF90dQToB/QdvARqDsXHUwMDAw3nFs2u2El3v9SvowPyZZ8LibJVxcj3mumkV9XHUwMDE1J7KD4zQpklxmZmeIu1x1MDAwMs57l7hcZtoz1CWk9/y9StqfXHUwMDFiQdvpXjMvp/RcdTAwMTmxTT5bgeyuly3iW120lXnUkSj/qFx1MDAxZmKac69DXHUwMDEwT5L6PcM+4Wfy35S3h/i5Kki3k1xyr6I9l16foJ3Pe/n2sqxcbuRcdTAwMWZEXHRG/fjc22LPO1FfU1x1MDAxZlx1MDAxMj1cdTAwMGVjQLxcdTAwMTR5J3KVKFx1MDAwZVx1MDAxZm3+OLH5xHOhbzjXM+LTxFx00dZG5INcdTAwMTH39bZcdTAwMTT1KXBQ4P/IM2K01Vx1MDAxMdqgKY0pfFx1MDAwZb1v62Wwm3CbTITPUGRLd1CGkFx1MDAwYpHfQfzAy0PoeXWXbFxu6lPki6B3iEOxxFajTfNcXCw3QbnXaFx1MDAwYpDLhuXLrr8+T/4jyDL3Plx1MDAxNcpnXHTvlfhcdTAwMDVp1N1cdTAwMDJtXHTyiVx1MDAwMun+3lx1MDAxOO0jzCXzPmF7XFygmFx1MDAwMrRcdTAwMDU4VoHGsUpy5mW1MvVYXGJlwvWkx1JcdTAwMGYwSTxcdTAwMWXlkFx1MDAxMV69/SXbXHUwMDEydon3xYhcdTAwMTc4xr1ccq2MfVuyKFtgO7PYJpbodNT7yKMlyO9cZvuJz6W4XHUwMDA1nk9cXCtPdlx1MDAwMvVcdTAwMTXqd49N5F6kx6akXHUwMDFmZihroFvLpVx1MDAxOcyfKHpdOPP8KUK5j1x1MDAxM1x1MDAxYjYjX464XHUwMDA0yiTOQVx1MDAwNfkwzl3sZSjP/Pl4XHUwMDFlwzZxXHUwMDFjc89LwVx1MDAwZu3gXFyWvK9cdTAwMWT3cNzgucjHs8QvXHUwMDEz/lx1MDAxMKNcdTAwMWNcdTAwMTVRx8S9KflbcYW4J8xJnPhb7Tl/S5NvPEO5bdMzYJzaxPnLJIvC89J0opdJP1x1MDAxM4fFeFx1MDAwMTwzJn98J+1tNPKWxGf0XFyQ9CnxKO+zV7lvd8/bXHUwMDEw4u9t6f0v9CPufV/U11Vccn5ETDJcYvKFOlxmeCY8XHUwMDBiZVx1MDAxMzglxTBcYlx1MDAwNzNcdTAwMWafgPkg3VpcdTAwMTGHXHRHQzxQPGeG412SxGPie983nCX2gXl/XHUwMDE0/CnCZOKXxlx1MDAxOMcokT9D/UZuXHUwMDAw40FzgTyki/5cdTAwMTCNXHUwMDAx4ibRpehvXHUwMDEy/jjGycCP0P5cdTAwMWVV7edcdTAwMDDlXHUwMDFk51x1MDAwNmxgh3hccvRcdTAwMDX1Tol5XHUwMDFiXHUwMDE2XHTiUV2Sa5Vw+dhjlXxY5F5T8nXL0b1cdTAwMGavfFx1MDAxYmgs2L3/Tfr9fDj2+lx1MDAwMfFccnNEdjVUXHLCXHUwMDEw6Xbp51x1MDAxYf2HMJHrZpf0XHUwMDE12HHkXHUwMDFj6H/Q/OykvX4jfyd/76fMPHZpjpFTTTzvrZJcdTAwMWOiLSrMXHUwMDEyWUMs4Vx1MDAxY3s9lvhRaLt66C8xzz+Ou+RjxYhl5HVp5NMsia3FpIcwxtnx2MBcdTAwMThaoVxcuCT/33Mm9JFcdTAwMTJfXHUwMDE0Y15ZxPT980Uxc2lcdTAwMWFljN9RXFxgUsXndPMov/hcZuTEgmI1yGW8j1x1MDAwZvPWllx0XHUwMDBmY4jtwlx1MDAxOehHjKNhXFwvsbfeNyVfXHUwMDBiuFx1MDAxZGGbef2VRj07JXx1S973p7hKRDFLj8+IV4lHtCeJblHobyfxnVlcdTAwMTJ3QZ4nXHUwMDFi9FxmXHUwMDFh20koh1x1MDAwNdCtM+o3cOlcIsVB90doO3GOgVx1MDAxZk9cdTAwMTNcdTAwMWbKX4PzO1xiSYd6u1xcweehn30/jzNv45K4XHUwMDFkcrizLLWnmMQngHckscQs6lxuXHUwMDE4T+KNXHUwMDE0XHUwMDAz9X6zj+eAfN/H2NAmY1xcKvZcdTAwMWOfxVx1MDAwNfQ10N7j2HXBRqFcdTAwMWQoR7E/l9qrve0owFxc5XmRdDFipNCFv1x1MDAwMVclXHUwMDFm61x1MDAwM31cdTAwMGbtZlx1MDAxNCfA/pPuXHUwMDBlXHUwMDEzu43xXHSMnZKPijx0XHUwMDEy+lgkL2QoXHUwMDFlXHUwMDA39lx1MDAwMOY4Jp4gyTctY0ylpOlcdTAwMWGc47hEujXBfeKHo09cdTAwMTlS39E/XCIu4mNdoPt7aG842pmi51x1MDAxY8RcdTAwMTPxej+/kY+9d9Dfrvr5pXhFj3CMvqGf33Dq41x1MDAwZdW2jzshrivEtVx1MDAxMjwzilx1MDAxOVwiZ/f3j+uo25H/XHUwMDBmJuQnk44ve05NNpFi4vmEk+Sn91hcdHeSdmI/yyiPXHUwMDBmMTbu9Uney59cdTAwMWPCeFRcdTAwMTK5QsxWXHUwMDA0jMOUxoX0Xk9cdTAwMTN/20n8d4qV9EdJrCDhv5HwOszLOPniXHUwMDE0o8N4NcUxvH2LkfuiPGC+gnT1xN+TdFx1MDAxNepcYrBcdTAwMTN5TW1cdTAwMDBcXCS6n7DndVx1MDAwYt5cdTAwMTftf5ZsXHLlK8hcdTAwMWZHW1x1MDAxNU29XHUwMDBmgTyqem+nblGvYkyObEzCtYreJ9Kh98VBtnZNXHUwMDAz57pcdTAwMWIlsaN7PLWnXHUwMDBm/lx1MDAxMHEyjEGiXHUwMDBlQl+ecCWTsZ5Q3yhuje0jXHUwMDFiyii+Vya7Lb3P3otcdTAwMGJicktcXKuLtlx1MDAwN/tKcVx1MDAwNM9DcyFxJ8CB8nHZXG7aOFb0No+Rj1x1MDAwYjyy0E2j7YJ+R95cdTAwMTdcdTAwMTBcdTAwMTO0s0mct026nvRwuVwiqmjPwKbS/NB8Rlx1MDAxM7KVXHUwMDE4Rydblug4kL9cdTAwMDLFO4BcdTAwMWaD/1Eso8+Lfaky31eMXHUwMDE5ks+LWCOeTWOXXHUwMDBiSWaS2Hyc5JVcdTAwMTLcRX5cZmL0V+H3mZfbXHUwMDA2xSAp5ieQ66EuKnjOjPfhpEfK1Xs9iXLvffGdxIaVMc9QXHUwMDEyPlZfQvvByYf28XiOsYtcIvlcdTAwMTfhmO51NolcdTAwMGLki5RIJyHu6lx1MDAxNLcvUFx1MDAxZsk3vv9N2EWskO1cdTAwMTJcdTAwMWUj1538XoG1zqb9o5N9Vju/ZIdn/dta7vS2mbmeXHUwMDE05XG/tVdcdTAwMWFXz6bDmlAmkseX0VXJNnJwzlx0v66d9a/qeyXTXHUwMDE4XHUwMDA0s8bZ7uj+/Obe/mXjqjBoyP1xcaDvXHUwMDFhg4qtXHUwMDBlpndVMVx1MDAxYUd7+3c1ud+PZGHYgHs2c3lcdTAwMGLXzuridHZ/LrahLsHDOd/uXHUwMDFmntXuXHUwMDFhV6VxJLf7VdFcdTAwMWbUz1xul81cXP+u0Vx1MDAxZJarZzrGfFBDaHZ43uzXz5rXzVxm61x1MDAwMEefwPFuQ0zvoi7r5GPMYWF+I9+uXHL6o1x1MDAwNpyTXHUwMDE3tVx1MDAwMf3r5NvQr1lDjPuYU0qOXHUwMDBl4PpcdTAwMTj6cVdcdTAwMTeVcVP0e81cXDvIk/+ZbzdkrVx1MDAxZlxyaqOGjIL8VW1cdTAwMTiJfqeRq3TyOd/W2mB3XFw/m2pcdTAwMTjTpI3u70c+b/r3ueT4TWu+XGJAOqONXHLmdjOuj8bHrfFNp3X3/Kz5XHUwMDFhlfVrRN+Tc357XHUwMDAx6mfknGeFxXwzfX6Sa57kSa/Bf1x1MDAwZjnD/VuQlf7c3Fx1MDAwNvlBgUdX+/3G1XGG5GVOXHUwMDFlQKZcdTAwMWWeNy9cdTAwMGYk/yCHXHUwMDExPCdcdTAwMWFcdTAwMDS8MShcdTAwMTGO4O9cdTAwMWLAxah+pvv1QTBsdOe+z1x1MDAxNUbV80JcZm1cdTAwMThWRXCbz/VBPtW4mdtcdTAwMDXZOZ35zzo+PD++XHUwMDA0rPSjXHUwMDBlv2ueXHUwMDFm072ennt4XHUwMDA2fdzJP+Q8XHUwMDFm5fehfz5/XHUwMDBlY1x1MDAwMHaWNcFPO8lsm+fj8DRvmlx1MDAwNT1fXHUwMDE4eVx1MDAxZXg6gus12HdFulx1MDAwZvhcdTAwMDVcdTAwMWOnOFRcdTAwMDP41CHYyvvPy++TXHUwMDA1fdhuky9xkr876k4n1fPj63yuXHUwMDE05HtcdTAwMTh3XHUwMDAw+zKYwFx1MDAxY/U6h/GTvD9xt32wmfPXT+5cIlm7Omr/93+/gCEqk3OvYciftYihi0hcdGeY4SpqWKltw5hcYt9RW68zLK03UdPhq6veh6F1b/5cdTAwMTeG1sTQqHlWXHUwMDE41nKVdmPvtFs/O1x1MDAwNjtRuGucb/Mm4YPwsvQ7qjFcdTAwMTmcdps721x1MDAxMr5jdexnXHUwMDE57335YD+KMFx1MDAwZdh/1PWtMnyP/blcdTAwMDL93817WX16fTe99Hq8jq7H++/tKz9cdTAwMDZPsYKxistrOHcpRoq5tljPflx1MDAwNFwiUPNbXHUwMDE2Lpf95KxcdTAwMDXZX/t1ju+R/be/K/LfUfYxZ/BMvqtid1x1MDAwMvecNeTpbW1n7jrgW43B6c+G6N/Of19cdTAwMTXTy0iG8Ewv33WU61x1MDAwZWfVs/1R7TzvP1+Fc3ZcIlx1MDAwMJnHe1WenjuGe901z0qdXHUwMDA3mbtvXHUwMDFiyCvwOeB3+UQ+wXfOtWeFPfCxs+1Jq/PIoZLznsqtj1XMUHZDqpOgmLW3XHUwMDE15Fx1MDAwM1JcZiGTlvmHuCh9x4rgL1ONXHUwMDE45WwqSa664m1AXHUwMDA2cy0lqlx1MDAwNfLXlVx1MDAxNPgy4FP04jzFkTFcdTAwMGZHflx1MDAwMMZ9ME4483VWXHUwMDExz1NcdTAwMWVcdTAwMDBGXHUwMDBl/T+sQ/B5zCRmXHUwMDA33Fx1MDAxY33YMtZcdTAwMTCgv4j5zt1uSDnbdtvHn9FHxFqnNMVhXHUwMDBiXHUwMDE098WcmY+TXHUwMDAx59bFM7x3XHUwMDBmY1ZcdTAwMTN6bqbqc3k+X82SWFxu3LOGfcRzMUc3ppgp8nP6XHUwMDBlx4au5f5cdTAwMWVtWfe5XHUwMDFk8Fx1MDAxM9qke1x1MDAwZSXIfaxW20BlrF3Y93ypXHUwMDFluD9rQVx1MDAwZqz9XHUwMDFlyffogbe/pPLj9MCjXHUwMDFkwbqNST6HMeB0u3GOebZH+a7K01x1MDAxOej622ZcdTAwMTawk1x1MDAwYtB+ifrZqSxcclx1MDAwMtTlXHUwMDEziiuV05TbXGa9npizKemnOsaf86TGsUBcXGk7XHUwMDAzcoz5QV48Ib0ji1x1MDAxOCPoVttcdTAwMDXKW+RnhCfKQZL/P/U1XHUwMDAytVx1MDAxMORcdTAwMTZlXHUwMDExZVRcdTAwMDBnXG7DuLLqc1x1MDAxY8b7IdxcdTAwMGblPlx1MDAwZXtcdTAwMTNG8V6635LPXHUwMDE0Nz3OgFxmYv5L+OspTon1PYzyqJle298/izVcdTAwMDe8SPFijLGGKqSYzikgXHUwMDFh6+YqXHUwMDFhc8U+XHUwMDA2hrnBKn/wfTNcdTAwMTgrqcaAXHUwMDFiUaCcL15fmYa9cP6z8PHIqD13P+Fzm1grUlH3tVx1MDAwN4CzmOJcdTAwMTeVkFx1MDAxN8h3L0F/qpLiN918e64/ooi5WorblHzMvtyD63s+Jr4wXHUwMDFl2YmPf/ZhPLCGqk21MElcdTAwMWVcdTAwMDPvSXVJ/lip7edcdTAwMTT/hvGLeyzJr9N3ibzIXCLF2KP7z1x1MDAxY+Yqk/w9w3olyk0+XFybTu65XVx1MDAwN249vW8r/Odj0b2QUf/LIdZS4Niz0OvcuEg5lnR8f49SXGa/41BR3Fx1MDAwM/RlXHUwMDAxc55cdTAwMTnMa/diuFx1MDAwZeOSmE9J+t3TVGdRmcxcdTAwMWSrapCV6YufsS2ZY5DN7IMsXHUwMDE2KV9RYuXMkvb3h1x1MDAwNd+mPMlpId6HcStNKa80w8+Yw8M6yYhRzFx1MDAwZeNWcUR1XHUwMDBleKzoY9p4jFFcXKM30VRH6mtbXHUwMDE0ylx1MDAwZY4v6PpcdTAwMTnFwEBcdTAwMTaLWGfQxXhXj1NcXFx1MDAxOHNcdTAwMDeZXHUwMDFl1aGBLOpcdTAwMDLF0n181cdGa3g95t8wXHUwMDE3zMF+oeyA7PVcdTAwMDTVUFJsk+pcIjCnoCmWRXKCNYeo//twPco6xZswloPx4PvrYbyyeD3VjMJcdTAwMWPQ9T6PXHUwMDBl11NeXHLbX6U4KFx1MDAxZEti7v5YnmovQqxJyGC8sCd8bDKP99SUN8mGcE+UhYji7FTrXGJ48ddTvptTLJDkjK6/xyFdXHUwMDA3Y+Ovo1x1MDAxY087uY5ynpxyNDHarPnnZr1tT65cdTAwMGap1rZCdYsg3+25vmDcfoo1jH58MD6F92xT7LZcdTAwMTTPj1x1MDAwZuaF2lx1MDAxOLP140sxvMqU5lx1MDAwN3Slv57y3pxyeuVwmlxcn8xcdTAwMGbF61HH0vxi/lxmrp/5fD5ej/Ob9dd3SySvcP2jfJTRxlexjlx1MDAwMK7HPFxi1lx1MDAwMFcwXHUwMDA2XHUwMDE3w7lwfUkkdY2ge/z8l+fkXHUwMDEzdJO/fzaUWCtcdTAwMWVSXFxcdTAwMTXzOpjDXHUwMDA03YV1qyCzXHUwMDE0P8Q64Vx1MDAxOOP80ZRqiEi3hT7XitdjTV6Zrsfxo9opmodcdTAwMTno4fK9/sBcdTAwMWHEiNpTJM5VnZVcdTAwMDHrPpZdIX1cdTAwMDDtw9yf9HqMOExM7fP3YjhcdTAwMGWgp6ZcdTAwMTjLpZqhXHUwMDE4ceJriefb5u1XXHR1kaQ4foxtQ/lA/lfFttzX1Fx1MDAwMJ4pz4jXx0Vfc4vYnZ312JzOLSW5u+PQt+vepmUpX1pcdTAwMDK7Q7VbXHUwMDFkOEa1OFjrV5FcdTAwMGZ2heSL6org+Vx1MDAwYnZl7nNJUN1YmcZcIvbx3lx1MDAxMsqo9Prg4bmMajLjXHUwMDEyO3vup0nURzAmMclcdTAwMWWMXHUwMDFkzj2ONdbAeF5cdTAwMTcmOjetaUypLjWk/if5+GRM87xcdTAwMDR6zef+0fZTrl1Q/jAmfcVpXFwob49rKEKd1LcwX19C61x1MDAxZbCWxdsyn+dFW1ximGtjXHUwMDFkXHUwMDEwT/AmUVx1MDAxZYskz1nt7Vx1MDAwZuZzkFx1MDAxYvRiXHUwMDFmP88mXHUwMDE4pjnVVF+WwZxwXHUwMDA1n081vZhcdTAwMWIoUj1gXHUwMDA1MIH5cLrnxOc5UFx1MDAxZkWUU0NcZjzIcIZqdVRYwXHC3Fx1MDAxYspcdTAwMTaNXHUwMDEz6GzAaFxcXCJcdTAwMWSKdVxcYYbqVbBGXHUwMDFm11x1MDAxM/jPgKklc6CpNuPEP1x1MDAxM8dcdTAwMDFwO6NcXDDlpDBHgXnVtPZ1doR7lGfgNKiL8vd1a0mbXHS3KJuaYu/drEjsrJ+Hclx1MDAwNeu6poRrxE2MOVx1MDAxNfI/hM+/4TjT3PqcWDdPeZxiXHUwMDA1r2/j9ZjTwfYhJ2x7W1x1MDAwNzim67HWgNYlUM0+5juS9Swx5ewwXHUwMDE36OeVdLPPXHUwMDA34lx1MDAxOFamlPspo25GPdHGWirS6eBD3c8r2KFeYlx1MDAxM0qiiHKBeMtgbSCNh0A9UKT8TSn2c1il3Fx0XHUwMDFjQ96VjCXI3On1ev6IXGZMYO3cztwr4lx1MDAxMv6sXHUwMDA1f2TtXZHf44+8fcvlf8e4xFx1MDAwN8fkmqOG2O8/j8f54+hcdTAwMGJVRX9WXHUwMDE10z7cR5GPXHUwMDA07azKXpBHbtDZntGxnaWxMcxRY01cdTAwMDDVitJ6XHUwMDFk9Mf8+Ex8nv35d1iH+TxcdTAwMDZH8eZrjOOtKdNcdTAwMTK3XHUwMDA24q/JtD/rYVx1MDAxMeK//vav/1x1MDAwZpfGmpsifQ== + + + + + Your ClusterLapdev-Kube-ManagerLapdev EnvironmentLapdev EnvironmentLapdev EnvironmentLapdev Sidecar ProxyApp Workload PodApp ContainerDevboxDevbox intercept traffic from clusterLapdev EnvironmentLapdev Devbox ProxyLapdevDevbox client traffic to clusterDevbox client traffic to clusterApp WorkloadPreview URLPreview URL traffic to clusterPreview URL traffic to clusterApp WorkloadDevbox intercept traffic from cluster \ No newline at end of file diff --git a/docs/.gitbook/assets/file.excalidraw (2).svg b/docs/.gitbook/assets/file.excalidraw (2).svg new file mode 100644 index 0000000..f39c64f --- /dev/null +++ b/docs/.gitbook/assets/file.excalidraw (2).svg @@ -0,0 +1,8 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO19a1NcIl2y7vf5XHUwMDE1Rp+PM7LXvWrNif1cdTAwMDFcdTAwMDFcdTAwMTGbXHUwMDAyUZDGXHUwMDEzOzqgwJKboIBA7Zj/fjJzlVxuXG429qu23TO+0a9S1GVd8sl88rJW/e/f9va+TJfjzpd/7n3pLMLmoNu+bc6//Fx1MDAwM4/fdW4n3dE1fCXo82Q0u1xy6cyr6XQ8+ed//VdzPE5F3WlrNOqnwtHQXdZcdTAwMTl0hp3r6Vx1MDAwNE78f/B5b+9/6f8rXHUwMDBm6lx1MDAwZZtRh06mw4/PMYo9PVpcdTAwMWFd0zO5b4VcdTAwMTWMe/7DXHUwMDE53UlcdTAwMTaeNe204evL5mDSefxcdTAwMDZcdTAwMGZ98dqz8SBXuLxTdXZ25Xvt8/ZF9PjYy+5gcDZdXHUwMDBlqEmTXHUwMDEx9Pzxu8n0dtTv1Lvt6dV991eOb7vqdjSLrq47XHUwMDEz7Dp/ODpcdTAwMWE3w+50icfYY/ea11x1MDAxMd3j8chcdTAwMDI+KeGnrDXS+EL4nmX64Vu8fl9okfKEJz2mpOTa109cdTAwMWGWXHUwMDE5XHJGt9iw6W3zejJu3sI8PDav1Vxm+1x1MDAxMbTxuv3yefOk20qkXHUwMDFlXHUwMDFmcNXpRlfTp0cnXHUwMDFkXHUwMDFhfS6Fbzyt5OPk4IPGhTZJwf88jvltc9gp4CXXs8FgdeCu28nArX3Rwi9yK1x1MDAwMvV4q9m43XRzzz3DlPKVz9TKiFxmutf9p7dcdTAwMWKMwv5cdTAwMDZxmUyb01x1MDAxOd7+y7hz3e5er1x1MDAwYolr71x1MDAxN8NazDNhXHUwMDBiXHUwMDFlxXzV0bbjqbZoKX7pK65tO+y0O6zdXlx1MDAxNVwigFPnXHUwMDAxXHUwMDA1+LP/IFx1MDAxNXt7PPnrf+j3v/6xXHUwMDE5KredcOpEZVx1MDAwM1xchFJb4eLBZHhavlx1MDAwMi7D28PCmF37+0e19vCK+8Vxh1x1MDAxZH9cdTAwMTRcXNhPwUVcdTAwMWGVXHUwMDEy2lx1MDAxYStcdTAwMTRcdTAwMTOeML56glx1MDAxN+OljNFcXDBmPPhemG2A+T+8g//9PFiMsSmrrFx1MDAwZj9WXHQm/OfIkUqlpNQwM9IyI5+hXGK0mzba41L9dVx1MDAxNP3vg5zdi5JMjvzrY8D1olRPO4vpRoH2xVaBZtazQlxiy3ZcdTAwMTZoy9j+XCJbzV82Mlx1MDAwYl1eXFxy0br49rn1v7R+ypdKXG7hXHUwMDAxeI3WT1x1MDAwNZqpXHUwMDE0XHUwMDE3glx1MDAxYiMtmFx1MDAwMU9vtVx1MDAwMH9ZoGHAU5KDRTZig1x1MDAxNVx1MDAxMPKZXHLQXG5cdTAwMDT7baT3XHUwMDE32IDL0fX0rFx1MDAxYndcYodrR1x1MDAwZpvD7mC5Jlx1MDAwNiS/MIBccmBEe5nBbDLt3H5Z+zY96EYo0V9cdTAwMDady3VRn3bBLDx8PVx1MDAxZI1cdTAwMWa/XHLhac3udef2+eiMbrtR97o5qL74ZOhv5+h+iniK65XZn3TwWzxuf97igDRsXHUwMDA1KCg2LTjju1x1MDAwM7Ru62Yprs+8a3mYXHUwMDBi7fVgsi/Tb1xu0HZzctV5U4Ry0NEpK1xy831cdTAwMDVcdTAwMDRN+k8ommReSimuJOBXXHUwMDE5o97P4nhCpqTSklttQVHYR9ytcDVcdTAwMGbo5MrPU9Bqz0KH7Fx1MDAxYtC2nzI4XHUwMDBm1zxe/SBcdTAwMWOD8smBujtYVoJ29q4szppXtcrjaK2JbPP2djT/8vDNv+6Z1UeZs63+jFhB4FO4wKxcdTAwMDHDX7V4P4LLQcSbfm4+7C5VK3PEjuP2QjU/tz3TXHUwMDFj/Fx1MDAxOfBcciT4NFYz7a2DXHUwMDA1TFnKU76wylxiK8Gsvas/IzYgZOXYPVx1MDAwYlx1MDAwM8Pn+55UYiMoXHUwMDFlxXyQ+XZsT2aXtzNPTdNfXHUwMDE3zeC4bFdcdTAwMDT0S/r8UKR55Xbkj/ODhrZL/3ux+OWZcH5Oe7iTT8Q7LalcdTAwMTVcdTAwMTfgnGvhtVx1MDAwNHBcdTAwMDVcdTAwMTVcbtuylovQcM78kINf6L3kXHUwMDEzvdol2kpcdTAwMWX1VrD5QnHr81x1MDAxNVx1MDAxNfkjrFx1MDAxNeb+YS7yR8fDgVx1MDAxZl5/z486g5Ph58aaXHUwMDEy4IEwXHUwMDBigy79XHJYs1wiXHUwMDA17pAvNGidl6D2l+1cdTAwMTJyVO0z5vl8N+bow8xcYiXZvzXefop/XHUwMDE2m+N2527/66zV2Vx1MDAwZprXYIU+mIa+2IB3Z6NSbDevYHRcdTAwMTT402J383rDKnl2mKvl41x1MDAwZfO8Qniam5u3dVx1MDAxN9+ejYJcdTAwMTf4on2VXHUwMDE4TtzBvv510Fx1MDAxYn9TsNCHo2zl57GX91x1MDAwNNRcdTAwMDddZb2VwMxm8L9cdTAwMWS235yoZjvTr96wtb9fXHUwMDBmXHUwMDBmXHUwMDFhs/2an1x1MDAxOd2Kz0lUX0ZcdTAwMTPfXHUwMDFhfPeUXHUwMDAxZ0fL3YOJRb98W1x1MDAxOfRcdTAwMWJfZ8fD68zJLGhMam9rP99cdTAwMWVM0MuUMlIoa1x1MDAwNHhvK8KakFWV4kxcdTAwMWErtGCae5K/XHUwMDFmmlx1MDAxNFtz7eRcdTAwMDbmqs1TLHFcdFx1MDAxY8c3elx1MDAwYnP9VSjZyNtWROXrzXdzVDmWeuLH1yeNyan4dtVaXHUwMDAxyT823/a381x1MDAxMrfxVsm8bbhcdTAwMTPMXG5cdTAwMDNcdTAwMDJnd1x1MDAwN97m0fzkwPNUXG48XHUwMDA24Vx1MDAxOVx1MDAwM3ZcdTAwMWLG/Fx08JiXMlx1MDAxZlx1MDAwMzzuy5RSYI70jlFP42ujLGOb7ddcdTAwMWZMOvdy13fd29H1cG1cdTAwMWPXOGdcYl+t8sFnrHPYbbdXbdE68fyRXHLZzEU3t+v9qehcdTAwMGKpOORnXHUwMDFjJulcdTAwMTWRnmHaXHUwMDBiXHUwMDE3cXSR71x1MDAxZIalo2b67Otp7bODWHkpxnzha89cbs75XHUwMDEzXHUwMDEwg3Ncbp6px6xcdTAwMGbWXHUwMDE1Tnm/xMVPXHUwMDFhT4zlKvvrkm8/Zzyv7NXNdenyqjc6aN2cd/qtuDtcdTAwMWF8RiOntrpqXHUwMDFlN0C2rNw9b7C5059cdTAwMWNcdTAwMWWeTilccrZLeJ4ngaU9JZdg4lbgoey74ePVNo5rXHUwMDA2XHJcdTAwMTNcdTAwMWX/Pas7PreR+4Gq/2gjt1xyv9zbSlJBrTMmmf9cbpJ6fPM13T06/Vx1MDAxNlx1MDAxZXn+rG8yo+lNf/K5o6tcdTAwMWPMScr3rIfBVc/jT0mq8LG4XHUwMDAzRFWDXHUwMDBmZq3nyydN+4VcdTAwMDBcdTAwMTZWvJDR+PfG77tcdTAwMDZGf1x1MDAxMVx1MDAxObWaPz36WEapme/7csVh+WFcXPRuf5yZyKOwcT7LsNFpY3hQv/jcdWGATi9lPaEk51x1MDAxZefAXHUwMDAx18CqmEpcdG6VVlx1MDAwNqBcbp7lu0FVKZvyjOd7Rlx08G3lhqowxUyKXHUwMDE5cPOBemrwcJ9cdTAwMTdX+tpX0tf8R1x0XHUwMDEyf1DvsbjYM6yzrM5cInPUYte5z1x1MDAxMSM9btdVf9b93p+M0+lG3Fl0rr5mP2eYpjNcdTAwMTh0x5ONuPL19upcdTAwMTdPM2Et93fH1beSuJ20o1rRu85cXOxXXG5cdTAwMTf1VuH8k+NKXHUwMDFimVx1MDAwMoeKacaxXG5WrFx1MDAxYkGldcpcdTAwMTPW4LyAvVTvaFx1MDAwM8GkpZhcdTAwMDBUwZhLcD032UHfrJ3yLNNcdTAwMGZcdTAwMWWih4HezVx1MDAxZd/H4Er8XHUwMDE4V7++jGW1XHUwMDBl/ancSyN94ZlXkL/xTSe+8Y6X15cz01xiO+entnirPjn5M9ZLaaB2giujXHUwMDA1f1L0pZlOcVx1MDAwZuZcdTAwMDL8WOBZ4mnDfklcdTAwMTlcdTAwMGKAXHUwMDEz69B+VMUyN3ZcdTAwMTmycDBdLFTdXHUwMDFl3MX7vXBVM38pdIQ4/z6Zlqrtr51mLb1sfZ3lVk94O3j8p8xl78eemGe3pulAOKXxUVxud1x1MDAwNuPE91x1MDAwN/vp9GEwVVfB+MbIXHUwMDBieTf77GDkMlx1MDAwNa5cdTAwMGbzXHUwMDE4t8Yobz3praWfgjNgwnxcdTAwMDH22Pe3gfGv+2HgXHUwMDExmt1cdTAwMGKkjc+sXHUwMDA3jf4wa/MynF6ia1e31fLyrnLcmbYrlVx1MDAwM9OWXHUwMDAxm8rd6No/Xrpv1K7YU7/Rlr3rmzOd5pPZt29cdTAwMDe/jFx1MDAwNr6Jh3nWbXfC5u3eye1osfwlPuaWXHUwMDE2vGc0yN+eslx1MDAwNORcdTAwMDGE9WtSlsP2fnFeXGKqs2NcdTAwMTZfXHUwMDE0hsXWeDr47NEgI7xcdTAwMTT5bOCYgd1/jNbi9Z5cdTAwMDavj0mwwIZcdTAwMWJl3jHXwY1J7b5CQzJpgVx1MDAxNfyw0u63tug/XHUwMDA15vR4vFdcdTAwMWbd9lx1MDAwN6Nme+9k1P5YIG9/+luAeDutV9vdWelJZoHm7J6zXFx2zpdnxV69M6hcdTAwMWRcdTAwMWT4x81e7/Dr/mdHsbUpXHUwMDA1PjszWFx1MDAxM1x1MDAwYr7iXHUwMDFhjI1SKSu09XCVXHUwMDA0oGarN/uBtF4rZrFm7kc+azaTP1x1MDAxOec7/rw8NKX++GIxm53qVdZe6/JebpFuXHUwMDFjXHUwMDFkV+pH1VEx9z19WPpcdTAwMTW0/p14yHvyhd/RadjuwSulPb62NvZHUF/IgVx1MDAxZvZOz6unt5ll6epYjVx1MDAxYuf+Z4e6MinGrM8l+Fx1MDAwNWC31z1445mU0CBcdTAwMTae5soq8Y7JXHUwMDFiIFx1MDAwZUBcdTAwMGKk2LU6XHUwMDFlNFx1MDAxM3BcZu9cdTAwMDOjv7+T0c7cW9mPt9hcdTAwMWJcdTAwMWX9XHUwMDE25topslxyXHUwMDE4XHUwMDE2Usunh1x1MDAxZu21XHUwMDEwPnjEr1xiP7+sW9+kiGI0nW4rovjJ+LPHeUqCOrVKg1xu9dg679aeWHP936+GgoHjL3yg01xcS2s9s6nGyLCU8ECVWCu5lcps4OO4wFuJLbtmvFx1MDAwN7R/eVx1MDAwMHrVht5OXHUwMDBmus58rjUs2T+msEN8inRESKZ4n4F6XHUwMDE3nFx1MDAwMT9cdTAwMTKScYbi8Vx1MDAxOPLHoW2Osd08tbZcdTAwMWWBq2f9XHUwMDA3k/7jdr3Mdp+0y+e+MtAkXHUwMDE4M81X1uw/tkqkmPSlz1x1MDAxObBMZnzxPN02aE6mmdFw2EVMnYy619OnXHUwMDAzTSOaRu1x1Wk+k1x1MDAwMujV6ncg3t0n+eMx3nRd8lx1MDAxZf/ae0RcIn14+Pt//rHx7P3tXGLBn2fYeLzfTjxnq0/jme2RXHTcNcG8alx1MDAwN6HOdSY39CtccjFil7OWXHUwMDFknF80mm+8JOjtdSSzXHUwMDFliFx1MDAxY4w6lpxa+yRHXHUwMDA3o53SQoN/J0HMVlx1MDAxZIxf5tNcdTAwMThG+0Z4P/JpvLpll1+ns4Oz02/lbrSvdE/ffLBHwoPedJE/OVx1MDAxNN7dpbLFaDBccjLsd/FILkNcZkmBalRhy5Paa1x1MDAxOVx1MDAxM3JrVLNcdCpKwoc2UGPrf4xH8sJWL9ZcdTAwMWHGtHqFR1LV3cP6bDprXHUwMDBml4tiYXHDm99O3nbvovdcdTAwMDCqp1JCXGJN1dLyaZGKNf7aRlx1MDAxMt42pP71rYvAI3mFM6LBV/xRVvGjoPp5nJFs5641WnysXHUwMDE38vSZ7+t+XHUwMDE4f7v7QStQUaB3RuzLqaBPilhQSynhS8/3tLDCrodcdTAwMGKByqfAT8eyMtygj79cdTAwMWZgpfLBXHUwMDBi4ri6QXietY8hkVx1MDAwN+iylFx1MDAwNp3iXHUwMDE5Yz2u4Vx1MDAwZvl8wYNntPGE9y57v/wpXlx1MDAwNUvh4i5cdFx1MDAwM1x1MDAwNV5cdTAwMDVcdTAwMDctKTa4XHUwMDE0XHUwMDEyuKxcdTAwMGbutzFgVKXn+c86v+ZSrPfjN6P1W0WPLn8mdK+k9VtXj2wnXHUwMDBiXHUwMDA2XGKMfdXqkUrmKq9cdTAwMGZcdTAwMGVLmaB5fVEpnnwtd+0n31x1MDAxNpRcdTAwMDM7S2FZuVx1MDAwNilcdTAwMDRcdTAwMWb3XHUwMDExzJRu9IDw075cdTAwMWWe9pBOvJ/i4aDi9O47e1xiXHUwMDBlJI5cdO8tNpj6vYjAXlx1MDAxN5eFhJ3xdFx1MDAwZkb08rJcdTAwMWLuXd6Ohnvhr9gs7pVNel9cdTAwMWFcdTAwMDHGc1x1MDAxYpalXHUwMDA3pFx1MDAxN2MyO2P55briz1lDi0lHqUB9XHUwMDAym7DWeussXHUwMDAy7EfKt6BDhVx1MDAwMV1q5fuxXGKestpcdTAwMDBpU7gjKejqXHK+umQ6pVxyg/ZwgXlcdTAwMTH1jEXQdOHSyX9DXHUwMDE28fLCiL31XHUwMDE4oJZC4d6v1oCUM25XTnM0QpiUXHUwMDE0XHUwMDFjeFx1MDAwNlx1MDAwM+NcdIKhvJdpxLZWdc6PJ9dB9TSv9f5sv3UxaFxchlsjk0yDXHUwMDE1XHUwMDA3XHUwMDEzXG783fOVXHUwMDEx/HmzeFxuRFFIXFw2XCKlb+yzVv1cdTAwMDSF2UBTnlx1MDAxMJm169+WwmyVe/r2uci/ksP89DJxj+H2tv4r9s+8ulx1MDAxYZqLnjzJ3dyeXHUwMDFj39woj7XuTt7Wh3qfTVa0XHUwMDAxXHUwMDAyqXE1rLdcdTAwMWX02Fx1MDAxN4KlsILagpuFXHUwMDEw5ltrqX/ROnHLpZTMN7/XXHUwMDFlK9ncpHHYy5ymXHUwMDFi5XzhLpw2XHUwMDBlrmzz32mPlVx1MDAxN3BcdTAwMDfeXHUwMDAz+G3G7Fx1MDAxZW3cPJqfXHUwMDFjd7jHXG7T1lx1MDAxN+C5aSuewFx1MDAwZVfEfVxm7F6//Fx1MDAxY990wHH96r+PXHUwMDEz8WHLz39gQj56WevWvJ5SW5e0XG5cdTAwMDY8mr2q9sGUemfn36PWXHUwMDE1XHUwMDFmmUkx61x1MDAxN2vZpv0ot+EnQ1x1MDAwMFx1MDAxMlxiXCKwbWN8XFx6x57uO82lTklcdDBcdTAwMDGKhlv98a3rXHUwMDFlPjCzx63xQNlsey/Io/1bVG6uZ3FYXHUwMDExldLXbjyJ6zfy+1x1MDAxYWltxWf+bXByc8aqXHUwMDE3Z+VxWLudt+9WT+guvYNO+y5/1c+dXHUwMDFm5kvfu2fB6d3vknH4/VxuXG7V9s1cdTAwMDJccq6EM77c3YvfL3/dP1xuXHUwMDBmI3ayf3208Dv64LL2YV78T8JRKEFcdTAwMTbTcENBtydoZCyFZUpcdTAwMWMmzFx1MDAxMzhl72dPX7dcbklAa31f61x1MDAxZiHyt8bTX7G5SbDs163n2dyA93+1w0qhyLNcdTAwMWTMNJhcdTAwMTTwT3e3sFx1MDAwN7fFk9641lx0z1x1MDAxYmrcVlHQvy3mPlxu0j9cdTAwMTmYXHUwMDEzXGbfrsW4XHUwMDA1t1RYzp5upmt4SnvaR+NqrDDb3771111Tn6WAJ0ODYFa0UVx1MDAxYpBccvo+JY0noVx1MDAxOeAvc58/81SBXHUwMDE3KUTRj7bWfTukv/m2XHUwMDEx7Xr9+CQqxM0uP6+fXHS1fy2+365Y/b3P43luXHJ4I3HbXG4sXHUwMDA104h8bfeStJed8c9cdCwuZcq31lfWaPhnn2ydZDHkXHUwMDAzXHUwMDA0XHUwMDA240Hck/ZcdTAwMWRX7EpwgrVcdTAwMDJk4VI4aZR8XHUwMDBlLKxMZFxmzjC4b4zvXHUwMDBitWFDNC3AR95Skv9nXHUwMDA3vV9+Z8/ek/CyZSDiRmJRhJUrQZi9h/CyTDFpXHUwMDA0jTMzvnne+52C3i9vXHUwMDE1urea0Fx1MDAxN+gsStCwPmfCt1xcPmtcdTAwMTNcdTAwMTcpXHUwMDBlsoFRXHUwMDEw2vRaPI/E/3Yx762CT9c/XHUwMDE3+cc77uQmbPXbtbBPj94rP22tXHUwMDE16lx1MDAxNeuEw5vaReeikItO9vP72dxk9t3vvu22j2/uJsBopnBjIC3QXHUwMDEzeLrqXGLGO2U9mFxmLCli7IWFwm/is1x1MDAwM59cdTAwMDBP4fFnJWDymPHbfs7jW1xufeyJ/6NcdTAwMWHAfnjanJy1XHUwMDAyw46HXHUwMDAzXpvdfj1YVH+BXHUwMDA38diDt/bIw7Zuas5CzlpcdTAwMWTV4rLTboa+vLxcdTAwMDR3XFy2jTDattue5npFXHUwMDBl/7pHvlx1MDAxNWq+2F6fXHUwMDA3g6HNK141ddG9K/Lr21F7OMuVXG6lfDFuefFnr85jvsTFXHUwMDFkgnlcdTAwMWX4KuvsXWFRPPeYxmiJXHUwMDAw+/2+r5qSPGU8wLxWUkmttNxcdTAwMDS17efc81xm5uPeVUr/qDK+2Z6fhOff6/vVc+Z/TV+nvUbYWjV/fz5cdTAwMTaZusR3XHUwMDEws/blpblsg8CLUCo4yMP2pbxcZnlbdljYseZDomNabrV63HrgXWrDd7d77LB8ld+/vdC9pTTpRqZXrZx99pX1zHhAKqzwuWVcdTAwMDBGu/4uXHKNL5G2korJMf/6fsttjU5cdTAwMTlcdTAwMGVcdTAwMGbBMvrnXHUwMDEw3JBs8jicLLjdzO3/XHUwMDEwRP2F+NivXGKJfVBpXHUwMDFhXHUwMDE3dmtpmodvOMdcdTAwMDKYnVHLb9s3XHUwMDE3w1x1MDAxYr/0PZfr9sq39Vxc9fRty0zfZe2YwP1KPW5cZrhsXFw9WZKCr1x1MDAwNNZGWFx1MDAwMd5cdTAwMDK6a+9cdTAwMDZbnpJcdTAwMThcYsDtlj2rONtQ4S58PyVouzNcctSZiedcdTAwMDEwXHUwMDAz7Vx1MDAxNFx1MDAxZX+LbcvfxU3/WWO46qY/LVx1MDAwNv9Dysy3zj99+3zqXHUwMDFmb7iT3d6uXHUwMDAylNlcdTAwMWGswzVY0Fx1MDAxZbM7i355seAnVVx1MDAwMZzbXHUwMDE0jDzzXHUwMDA0KFx1MDAwMMvZulxuMEKkwG21glx1MDAwYiA5xr7f1snAjpVRXHUwMDFjrLdHqys2UWhhTVxut1v3QCXhxpzPVIDno/SwLdvl/OkqYGuJ6IvLmvdcdTAwMWWjZTzFtFBcdTAwMWVMuoQxXHUwMDA2kVg5J4mVabBcYkpcdTAwMTjoj9BcZlx1MDAwMPJsTH4z1bNd7PDnmcC9UvFsdVx1MDAxOPh2h1x1MDAwMVtCW1x1MDAxYuysd8bn371pb5ZPj3P5k2O7iFRUXHUwMDBmP7nDIJifskbBoHpcdTAwMWHfwLzuvmNqjmGETOLXlqmt69bf4E2WPIXvgN7RYdA+vrzmTVxu4H9cdTAwMTdHIElQh4MutO1hLcl09EtcdTAwMTe37NCa93VcdTAwMWWUv1x1MDAxZMK4gVx1MDAxM6BY7Z4/fznz+WmpXHUwMDAzx5d8aeZxXHUwMDA21Mxfd/pcdTAwMTVjKSYsw21wPVxmfL1cdTAwMWZ18FXKXGImkc1cdTAwMTlcdTAwMGZA8lx1MDAxY8hKilx1MDAxNIbetGXo7/ve8+CbXHUwMDE2nlT4Os3/cIdcdTAwMTXu8HJhx95qplxydKgvNJeWaSm9lSjmI3uwKeaD5tfK+oxx9XxUfjf2sE3y8Gf/udC9XHUwMDE1fWBbQ/+eZyxcdTAwMDNcdTAwMDK3u+q5uPrmy0X9UPTD71x1MDAwNd1YdMIrkf/c7MFnXHUwMDFl+iy+XHUwMDAyOcKU4nqaXHKGICWlwlxuJuBtay+C+LXcQTCLZkO8xWKS/5CHz09cdTAwMWW2pu7kXHUwMDBiL9gwXHUwMDEy36pp7e7ltFxc28vbeWfcObk9VNVcdTAwMDNd+1x1MDAxYTW7n1x1MDAxYsDcePSmeCnBXmB08emyMKZSPtB+8DNxN17hb11cdTAwMTb7kXvxglKxuGhvI3pcdTAwMWZt3aw2yExvLMs3Tyrzs0YzM/9611g1mafzaTRcdTAwMWN/K7NpZn5Zz3UvbnpcdTAwMTmzekLQXHUwMDBix4tvXrPdK+WKp+XvJ3O+vPpdklxyv19x++r7XZ7A0VpccmzmNVx1MDAwNXvBiaxcdTAwMWZcdTAwMDVcdTAwMDeNhTrITouz2dnidDn67GhcdTAwMTRgT5lcdTAwMDDuXHUwMDBiXFxcdTAwMDXL9tbRyD2sPVWKa2Z8tbZcdTAwMTjy7bfL1SlhJHApf8dcdTAwMDSelVx1MDAxYXNcdTAwMTM/zJn/1pj6y5vcf6yZ3fzk9y9pV9tL2n3Paoub9excZuTuZeiPp3Xln+5cdTAwMGaz/cXXhl/upT9cbsg/+742LVO+sYpcdFx1MDAxZjiGz5+aVclTTElP+FxcS8Pes/T2TUraQTHBnOHbZj9cZtw/VdP+lpj/OUaptleg+OBcbqlXeYS6XHUwMDE0X55cXF31YVx1MDAxNprFxTIzK0zTmU9cdTAwMWWMUtziju4+8DOpPC3YulxyU1angG4o6eMrpHzL37dcdTAwMWVsN0JcdDODb1x1MDAwMV1cdTAwMTGPLbUnt9eTaTedXHUwMDFm5Zi5NlPv+1HNXHUwMDFi/i6mayc6aNviXHUwMDEyNIayTb9pWbvdabXNJUxSS8NcdTAwMTHfa3Z0+1LKy4+p5nppZZRkXHUwMDFluCRW7J5cdTAwMTQ+nrdyXHUwMDE1/+qkPFxcXHUwMDFlXkXnN1x1MDAwM3ks3/Z1um9cdTAwMGYm6elcdTAwMTRaXHUwMDA3rmD2n79xTVhyzoB3+bhD+/vtP8a5SVx1MDAxOWvFrmTQx31Vd+CCXHUwMDFmXHUwMDA1qIcnbljrtL9cZkvf01x1MDAxN/xbLpNVaq6r/Wp+dYXz3tMkxMM3u651+jimeXLbuet25nu10+LHXHUwMDEyzY1cdTAwMGZ+57yP2L7juFx1MDAxMPiyXHUwMDFl/YrYzctS8Dm1g2Y8ZYXFilx1MDAxMU8rjz2t9ZQpJaVcdTAwMTBcbqid1eb90j7grOJcdTAwMDasXGJ5wcBN37S6i6ewhsf3uFx1MDAwNVx1MDAxZtco7q0szL7fbkRcdTAwMGKtubelXHUwMDAy9M/I+2zL7rxsnPZWszvg/GtmfeP7Vkq+KblcdTAwMDPn4FIrYFjKx22un3X9j6lX2y55+PNcXOZcdTAwMWXvt1x1MDAxMzF5qVxcbevGqIJ5XHUwMDEy2Mlr3syc7Uy/esPW/n49PGjM9mt+ZnQrPrny4czDXHUwMDFkacF5XHUwMDE1XHUwMDFlcFx1MDAwZuE/4flMpjwjXHUwMDE4bt1sOH9HXHUwMDA3XHUwMDE3NF/KWu4xY4z2V9e1rSSd8b2QXG5fNGfAk7VcdTAwMWJWfEiQXHUwMDBl7Wn9Jyuf1yedb1glz1x1MDAwZXO1fNxhnldcYk9zc7OxYI2lpDXAQy04XHTQZP3I3Fx1MDAxZtVcdTAwMTJuYWuYr7ixYLxcdTAwMTl/Nii/mfLZ3yp59O1zoXul9tles7Z1TzVcdTAwMGbtPZD03XnP5bKs+sswf3t+0VHX0bJbrKd7nztKXHUwMDBlVCbF0Fx1MDAxN7G4zYJn1le1XHUwMDBiZlO4b6bSIGLK898ztKa9lK/5zvupKXrrlOK/fD+1X+KQ/PKc865tec9cdTAwMTc4m+3LspE1MFxcMbUzduNs87BcdTAwMWTcpq+CwlDMqia375188nJTbWxcbviAXHUwMDA11Ykr/NdcdTAwMWRcdTAwMTZALmNcdTAwMWWumJVC2/fbuemVuDWeZrQy5z+w/dNhu71QRG7dXHUwMDA2XHUwMDExhNnzcEfd3evEJ50reTf7ylx1MDAwYvtLkf+Wr9TPb9mnf/G6z1LGSlx1MDAwYuZL4z5cdTAwMDTru6fvS5nSXGb32fbBM8VykW3o/cCwvlx1MDAwNTpvtN1cdTAwMDLcR37aOtbRaUX3XHUwMDBl4rvBXXBdKVx1MDAxYVXvrbLc8dnRcbP9fcS4uJyNWsNAMFFZPSGTXHUwMDFlXHUwMDFiT16x7+dzdpNcdTAwMWapYFJQ3T8qMfCp6kRcdTAwMDTfjkYupFx1MDAwZryQ7Z5cdTAwMThgw1F79nXSXHUwMDExfvG4Ydj55VCk37bk+1x1MDAxZNAosczXXGJcdTAwMDNeuLbaridcdTAwMDZ4ilx0oTxlXHUwMDA107jDifxMdVwiXHUwMDFhI1x1MDAwNpr/cFx1MDAxYpPfXHUwMDFhU/+pXHUwMDEz2alOxNta7+VcdTAwMDOKpfea3Vx1MDAxYV5+/8JnLVx1MDAxMzFAh4H3W2CjXHUwMDE4XHUwMDE2WTeq2qaMNvh+WkPrs/ytyfJPUiaimW8sbpT8Ydh+861cdTAwMGZffrPNmmx/QDrwp8zj6o7czzY+9Kyy/mte23V6W1x1MDAxNfVWWV/q7LdCq9k/6rDi109uXHUwMDFlhdQpXHUwMDA1Uso4k/D/lSpcdTAwMDHnaKpcdTAwMTQusFx1MDAwNmRxMJHqXHUwMDFkN1x1MDAxNH3ti7twtY7whfdcdTAwMDZh6N/F6v2bvbfrb4mC+NJcdTAwMWOPz4B5d1x1MDAxZZRcdTAwMThMdbedXGbfY/O+oPN78LLY/S3RXHUwMDExxNdcdTAwMWa14lx1MDAxN8NazDNhXHUwMDBiZpX5qqNtx1Nt0VL80ldcXNt22Gl3WLvdXtGjX4bdYae66lx1MDAwZf/X5C76+2K4suFfspf/rjd/uFx1MDAwZeSsiVx1MDAwNVx1MDAwMv90f/5z7fb/XHUwMDE3x8qof5xcdTAwMWOVxMXyQLXqi1lcdTAwMTizbvPolIXZ0V1RtmV7qWWw1HfhMLxcdTAwMGJ66XmQsXF7XHUwMDE4dlx1MDAwYkft8cXR6ejkrKCC7MG8kylEzfz5+EJcXLHVY+3hYNBmx3edLOtcdTAwMDaZ9DSopmelbCUqxYVlsVx1MDAxN7FSNy2DXmFWzlZEoaf8MH/ImpmD/snZcSmIa6rY6ys4Z1x1MDAxMWTUvDTMwflcbs6vRaVsY1x1MDAxNsSVZSGbjoIs3jeclatpUchWZqVqsKhm+6LYXHUwMDBiY7o2zuG5i0a1gseWpbM03icuZVx1MDAwYlx1MDAxMbRcdPrE4JhaljPpODhTLIivJsVqY17sQT+66WVcdNpcdTAwMTf0zq/wmdBuXHUwMDBlz4D7XHUwMDA0XGa+m8Pz5+5elVxi2kvPdt9veE62MCv1atDGXHUwMDAwfkeqlc2J8plaYH+CXo6et7WN2Vx1MDAwMI7XoH3pRWnp2lx1MDAxOPTCXHUwMDA1XFyzKJ+xWZBtLH98LXPXZi8mQbWhi720XGKWbIlzXHUwMDAyY1x1MDAxNG+/vlx1MDAwZvdcdTAwMGamQVxmv3s5Xc02OIxTXFyA9lx1MDAwNzhcdTAwMWbVi61jU6xcdTAwMTY4jeNcdTAwMTlLxrZcdTAwMWRcdTAwMDRxXHUwMDA07W6wYFx0fckoXq6/MK89XHUwMDFhM1ms4ryWTKF7MGzWXHUwMDE3XHUwMDEzkLFeXHUwMDEwXHUwMDE3REPUeKHr//3k6OCqnY+iXHUwMDBikLNqNVx1MDAxMPSsbFx1MDAxNJWr0OY4LVx1MDAxYktcdTAwMDaylJbQf+hPXHUwMDBl5Fx1MDAwNe7XK8BcXITQp1BcdTAwMTSrOVx1MDAwNm3Cvi+gLfh7XsjA3MRcdTAwMTH0vVx1MDAwMNdcdTAwMTYkXFxcdTAwMWZcdTAwMTd7XHUwMDE1mG9cdTAwMWPvXHUwMDFjzDPIVa8wxftcdTAwMTZobiNdPIP7L/G8XHUwMDEw+p3TMFx1MDAwZfhbolx1MDAxY8K4QL+gPdVAXHUwMDE1cGyrXHUwMDAxyqx091x0XCKah1x1MDAxZcowyltcdTAwMDM+R3DPXHUwMDA2fVx1MDAwZm2FsS3czyN+jmDsNYzNXHUwMDE0flx1MDAwM96CXGJkWJTicFxuYyqh/dDemlxihvMpzkcpk4Zrc6qczy3KNN9cdTAwMDHeV1x1MDAwM67iIFx1MDAwZVx1MDAxZnHQxTmKolx1MDAxMuKtd9wrVlx1MDAwM7hPX8L1XG7HXHUwMDA35EXCNaK0TFx1MDAwM17UXHUwMDAycDstV1HuXHUwMDFi0LdcdTAwMWGMYU2X8rllKVuDfmLbXHUwMDBiXGbatSj1kmecsVx1MDAwN3nAMYJ5XHUwMDAxPdBYXHUwMDA0KG/VYIo4xDmAtnKQa3ymKmdcdTAwMTjqXHUwMDA3XHUwMDA2mITnVDjJQlx1MDAxNsa1moNrazhcdTAwMWaoXHUwMDFmWDnbgHntg1x1MDAxY+Y0fFx1MDAwN+2O4L74nGBeoufBuVx1MDAxOXx+Q8M9XHUwMDA16lx1MDAxZmiHdvNO91x1MDAwMCz0YbxcIk1jXHUwMDFjN0DucC5qS7hcdTAwMWa0IVxybVxi8DOOOS9XK9NcdTAwMDDkqUR9qDD4LfDeeFx1MDAwZWBcdTAwMDHORUznXHUwMDE0XHUwMDFjj+lZS1x1MDAwNuNT0SQ/VVx1MDAxOCuY3zKMj5tfvHdcdTAwMGVxwUtOZuA3tjWNOlxmMbVwfagsYS7gb5z7KILnQztcdTAwMDLUXHUwMDFmNFx1MDAwNyiLIPvzXHUwMDEy6EOQcVx1MDAxOINcdTAwMWOMOfVcdTAwMGKfiXMpUD7K2Vx1MDAxY8rPXHUwMDAy5imC7+FzgPeag1x1MDAxZcDjXGZlXHUwMDAyznPzhn3Ez0t4Zlx1MDAxY+BxUaqi/MLYVFEu4VnZPvRcdTAwMDPkNa7AnNdwfkBcdTAwMWVy0P6+Lp+lYYwjlPlcdTAwMDXOuTuOMpCG9lx1MDAxNtBcdTAwMDbA70BDv1BcdTAwMTZiOF+QTCzxcz8uk+xV4nJ+jlx1MDAxOIXxhLHCcVx1MDAxNaCTejV8NuhcIpTTXHUwMDEw+sZi0iXZMCqjLUCs9/ooP6ivUFx1MDAwN+lEvydth3FcdTAwMDN9XHUwMDA0cohjXHUwMDBm44rPTCs31pF2c1x1MDAxMVxi+CxcdTAwMTJ7IFx1MDAxY55cdTAwMTjia+nOh2dXXV+hXHUwMDBmMfZcdORGkG7OYFtcdTAwMDOUQ/hccuOTQZnugyzg2Do9XHUwMDA02F2SXHUwMDBlQF1Zpd+oZ5coeyW8ppcjmUL7XHUwMDA214JcdTAwMWRETOfQZsF8XHUwMDAxRnG+ezhcdTAwMDfQXHUwMDA2xFpcdTAwMTXHpKKK9/NcdTAwMGW2oUi6hHTGvIy6XHUwMDFjn1x1MDAwYvJcdTAwMDDPXHUwMDA1XHUwMDE5TTtcZiFcdTAwMDZjkFx1MDAwN7SlMFbJ2MQ4vyD7yp1bQP2G53KQN9JR5SzN6dzNXHK2JUCduCD5XHUwMDAxjNDfy3tskY2dl6tcdTAwMTH+vURcdTAwMTlEXHUwMDFkgnKb6IVEllAn5uB4QbgxXHUwMDA2u1x1MDAwNHqAnrlEeUW9i5jC+Vx1MDAwMb1C81x1MDAxNzhcdTAwMTmEa1xipzG1OXZj5bBcdTAwMDTzh3hcdTAwMDd9TFjC/kvEbEDzizpcdTAwMTHtVW1cbu3laKdBXHUwMDBmY1x1MDAxYnjJyaQukUxi/0km5052+6JcXJ/T9ahH4HvQgcGi5PQk6j+G+CpcdTAwMTEucthGJ/dnIGeZhDtUSc5cdTAwMTaBmKNuXHUwMDAxfFx1MDAwMd5oPs57cH9eJmxUZFwin3BvPD9cIl1cdTAwMDDXRaCLl05cdTAwMGZFiFx1MDAxN4kyW4R5LC1JVyxwfFx1MDAwMpxcdTAwMDPU9T3UT/17+UK+XHUwMDAyY1x1MDAwZfdFPZ5N8Fxchf6RXHUwMDFjVHAuXHUwMDE293pcdTAwMTjapWlukF+hrMY5slcgJ1OYV0Xys0SMhahcdTAwMWJlqdrHts1LKHMor2T3QpxcdTAwMWJ4ToP4XHUwMDA0XFxcdTAwMGayXHUwMDEzXCK2JM13l+yspraDLXbyQbZ0XHUwMDE5kM1cdKfUX7pfY+rmXHUwMDAz/qa5ovGJnVx1MDAxY1x1MDAwNUuS7Vxm/p12ttnJ2lx1MDAxMvmJs9Ukx9g20IVcdTAwMDHqQpxcdTAwMGY3XHUwMDA3TjcqsNPUl4DmXHUwMDFh9CthOWROXHUwMDE2QuBAiOlcYnVcdTAwMDdL+Fx1MDAxOI418Fx1MDAwZpJV1P9cdTAwMWHlXHUwMDE39Vwi8Fx1MDAxMcQ4fO7DedBueFx1MDAxZXJcYmg78lx1MDAxMGdcdTAwMGKyNbRfqEvpelx1MDAxYUvUySSDXHUwMDAxynqM7WihfMaoJy6AXHUwMDBioX5cYlx1MDAxMnlcdTAwMDfOuiQ7oFx1MDAwM9TfZDdcdTAwMGKI53k5mSPU5UG2fVx1MDAwNfjFtiFcdTAwMGVcdTAwMTR8XHUwMDA3/MjxtkSvMpKhaiNu4PnLNNknp7dcdTAwMWLAsbC/IIM0X4gxlMlGolx1MDAxM9Eu49jVXCKy6SSnXHUwMDA1tGUwx1x1MDAxNeC9eC3x2Zg4PvVccmVcdTAwMTF5JNgg0E9gz1x1MDAxM72C81x0fcqQzcU5YYRcdTAwMGb6nNgx4CxlxFuX5oC7cUB8wrlxIVwinZOl30vSITC/wcP8XHUwMDE2SKeUqC2g889cdTAwMTDbaFx1MDAwM0iuNMlcdTAwMDXNRySdrFx1MDAxNpaoZ1x1MDAwMui7449OP5eyoUJ+V1x1MDAwNow4boLzSTJcdTAwMGVYupdxxJCbX1x1MDAxOFx1MDAxYuUwVCCeXHUwMDAxcr4gnoF6nvR9QMdcdTAwMDGTU+orcZdcdTAwMWNydkk84Vxmf0doi1COXHUwMDA0yjGMNUd5obFAuUKuh/JcdTAwMGLYLmfpeU5fnVx1MDAwMe9HfdBFfXXPUUKNeHO2sSZcdTAwMWZtXHUwMDEy6VRcdTAwMDZ44U6fN2Kn50Kw1YiFgtMlaIPxOc5cdTAwMGaD/lY46iSU4XK2XHUwMDBmPlx1MDAxZvLbPrSBeFxuYpD0XHUwMDFmPFx1MDAwM9spnD+Cclx1MDAwMvxcdTAwMGLaXHUwMDA2Nlx1MDAwMmVcdTAwMDfwjmNcdTAwMTNFXHQvd/qV9GFhSrLgcLdMuFx1MDAxZXNcXDWH+ipOZFx1MDAwN8dpXiZcdTAwMTnMLVx1MDAxMXclnPdcdTAwMWVxXHUwMDE5tGeoS0jvuXtVtDs3hLbTvZZOTukzYpt8tlx1MDAxMtldJ1vEt3poK1x1MDAwYqgjUf5RP8Q0506HIJ4k9XuJfcLP5L8pZ1x1MDAwZvFzQ5BuJ1x1MDAxYt5Aey6dPkE7X3Dy7WRZlcg/XGJcdTAwMTOMuvG5t8WOd6K+pj4kelx1MDAxY8aAeCnyTuQqYVx1MDAxYzza/Gli84nnQt9wrpfEp4lcdTAwMTOirVxyyVx1MDAwNyPu62wp6lPgoMD/kWfEaKtDtEFcdTAwMGJcdTAwMWFT+Fx1MDAxYzjf1slgL+E22Vx1MDAxMJ+hyJZmUIaQXHUwMDBikd9B/MDJQ+B4dY9sXG7qU+SLoHeIQ7HEVqNNc1xcLD9HuddoXHUwMDBikMtcdTAwMDbVq567vkD+I8gydz5cdTAwMTXKZ1x1MDAwNe+V+Fx1MDAwNWnU3Vx1MDAwMm1cdPKJXHUwMDEy6f7+XHUwMDE07SPMJXM+YTQtUUxcdTAwMDHaXHUwMDAyXHUwMDFjq0Tj2CA5c7JaWzgsXHUwMDA0MuF60mGpXHUwMDBmmCRcdTAwMWWPcshcYq/O/pJtXHR6xPtixFx1MDAwYlx1MDAxY+POhtamri05lC2wnTlsXHUwMDEzS3Q66n3k0Vx1MDAxMuR3if3E51LcXHUwMDAyzyeuVSA7gfpcbvW7wyZyL9JjXHUwMDBi0lx1MDAwZkuUNdCt1cpcdTAwMTLmT5SdLlxcOv5cdTAwMTSi3MeJXHJbki9HXFxcdTAwMDJlXHUwMDEy56CGfFx1MDAxOOcudjJUYO58PI9hmziOueOl4Id2cS4rzteO+zhu8Fxc5OM54pdcdH+IUY7KqGPi/oL8rbhG3Fx1MDAxM+YkTvytaMXf0uRcdTAwMWIvUW4jelx1MDAwNoxTRJy/SrIoXHUwMDFjL00nepn0M3FYjFx1MDAxN8AzY/LHM2lno5G3JD6j44KkT4lHOZ+9wV27+86GXHUwMDEwf4+k87/Qj7j3fVFfNzT4XHUwMDExMckgyFx1MDAxN+ow4JnwLJRN4JRcdTAwMTTDIFx1MDAxYyxdfFx1MDAwMuaDdGtNXHUwMDE0XHUwMDEzjoZ4oHjOXHUwMDEyx7tcIonHxPe+b7BM7Fx1MDAwM3P+KPhThMnEL40xjlEhf4b6jdxcdTAwMDDGg+ZcdTAwMDJ5SFx1MDAwZv0hXHUwMDFhXHUwMDAzxE2iS9HfJPxxjJOBXHUwMDFmod09XHUwMDFh2s1cdTAwMDHKO85cctjALvFcdTAwMWHoXHUwMDBi6p1cbnM2LFx1MDAxNMSjeiTXKuHyscMq+bDIvVx1MDAxNuTrVsN7XHUwMDFmXrk20Fiwe/+b9Pu38dTpXHUwMDA3xDfMXHUwMDEx2dVAtVxiQ6TbpZtr9Fx1MDAxZoJErts90ldgx5FzoP9B85NJO/1G/k7h3k9ZOuzSXHUwMDFjI6eaO97bIDlEW1RaJrKGWMI5dnos8aPQdvXRX2KOf5z2yMeKXHUwMDExy8jr0sinWVx1MDAxMluLSVx1MDAwZmGMs+uwgTG0UrV0Rf6/40zoIyW+KMa8cojp++eLcvbKtKpcdTAwMTi/o7jAvIHP6Vx1MDAxNVB+8Vx1MDAxOciJXHUwMDA1xWqQyzhcdTAwMWZcdTAwMWbmLZJcdFx1MDAwZmOI7VJcdTAwMWT0I8bRMK6X2Fvnm5KvXHUwMDA13I6wzZz+SqOeXVx1MDAxML56XHUwMDE15/tTXFwlpJilw2fIXHUwMDFixCOieaJbXHUwMDE0+ttJfGeZxF2Q58lcdTAwMTY9g8Z2XHUwMDFlyHFcdHTrkvpcclxcukxx0ONcdNpOnGPgx4vEh3LX4PxcdTAwMGVcdTAwMDPSoc4u1/B56Gffz+PS2bgkbodcdTAwMWOunqP2lJP4XHUwMDA08I4klphDXVx1MDAwMeNJvJFioM5vdvFcdTAwMWOQ7/tcdTAwMThcdTAwMWLaZIxLxY7js7iEvlx1MDAwNtp7XHUwMDFju1x1MDAxZdgotFx1MDAwM9UwdudSe7WzXHUwMDFkJZirXHUwMDAyL5MuRoyUevA34KriYn2g76HdjOJcdTAwMDTYf9LdQWK3MT6BsVPyUZGHzlx1MDAwM1x1MDAxN4vkpSzF48BcdTAwMWXAXHUwMDFjx8RcdTAwMTMk+aZVjKlUNF2Dc1x1MDAxY1dItya4T/xw9ClcdTAwMDPqO/pHxEVcXKxcdTAwMGJ0f1x1MDAxZu1cckc7U3acg3hcIl7v5jd0sfcu+ttccje/XHUwMDE0r+hcdTAwMTOO0Td081x1MDAxYixcXNyhXHUwMDExubhcdTAwMTPiukZcXCvBM6OYIXJ2d/+4ibpcdTAwMWT5/3BOfjLp+Krj1GRcdTAwMTMpJl5IOElhcY+VIJO0XHUwMDEz+1lFeXyIsXGnT1xuTv7kXHUwMDE4xqOWyFx1MDAxNWK2JmBcdTAwMWNcdTAwMTY0LqT3+pr4Wybx3ylWMpgksYKE/4bC6TAn4+SLU4xcdTAwMGXj1Vx1MDAxNMdw9i1G7ovygPlcbtLVc3dP0lWoI8BOXHUwMDE0NLVcdTAwMDFwkeh+wp7TLXhftP85sjWUryB/XHUwMDFjbVW4cD5cdTAwMDTyqMa9nZqhXsWYXHUwMDFj2ZiEa5WdT6RcdTAwMDPni4NsXHUwMDFkmlx1MDAxNs51L0xiR/d4ilx1MDAxNlx1MDAwZv5cdTAwMTBxMoxBolx1MDAwZUJfnnAlk7GeU98obo3tI1x1MDAxYsoovlcluy2dz96PS2I+I67VQ9uDfaU4guOh+YC4XHUwMDEz4EC5uGxccm1cdTAwMWMrO5vHyMdcdTAwMDVcdTAwMWVZ6qXRdkG/Q+dcdTAwMGKIOdrZJM5cdTAwMWKRric9XFytiVx1MDAwNtozsKk0PzSf4ZxsJcbRyZYlOlx1MDAwZeSvRPFcdTAwMGXgx+B/lKvo82JfXHUwMDFhzPVcdTAwMTVjhuTzXCLWiGfT2OVcdTAwMDOSmSQ2XHUwMDFmJ3mlXHUwMDA0d6FcdTAwMWKDXHUwMDE4/VX4XXdy26JcdTAwMTgkxfxcdTAwMDRyPdRFJceZ8T6c9Ei1ca8nUe6dL55JbFhcdTAwMTXzXGZcdTAwMTXhYvVcdTAwMTW0XHUwMDFmnHxoXHUwMDE3j+dcdTAwMTi7KJN/XHUwMDExTOle9XlcXFwiX6RCOlx0cdekuH2J+ki+8f1vwi5ihWyXcFx1MDAxOFx1MDAxOXVcdTAwMGJHJdapL1x1MDAwNidnx+zi21x1MDAxNSvWXHUwMDA3s4v8+aydXHUwMDFkzcvydNA5qkxcdTAwMWL1xfhCKFx1MDAxM8rTq/C64rXycM5cdTAwMTlcdTAwMWZd1Fx1MDAwN9fNo4ppXHLtslU/nNyf3z46vmpdl4YteTwtXHUwMDBm9V1rWPNcdTAwMWHDxV1DTKbh0fHdhTxcdTAwMWWEsjRuwT3b+YJcdTAwMDfXLpvifHl/LrahKcHD+XYwKNYv7lrXlWkoXHUwMDBmXHUwMDA2XHIxXHUwMDE4Nuulq3Z+cNfqjauNuo4xXHUwMDFm1Fx1MDAxMppcdTAwMTW/tVx1MDAwN816e9TOsi5w9DlcdTAwMWPvtcTiLuyxbiHGXHUwMDFjXHUwMDE25jdcbtHFcDBpwTlcdTAwMDVxMaT/uoVcYvq1bInpXHUwMDAwc0rJ0SFcXFx1MDAxZkM/7pqiNm2LQb+dj2yB/M9C1JJcdTAwMTeDcHgxacnQXHUwMDE2ri/GoVx1MDAxOHRb+Vq3kHdtvVx1MDAxOFx1MDAxZU6b9YWGMU3a6P/9xOVN/76SXHUwMDFjv+2sXHUwMDE2XHUwMDAxSN9o462U4OPSrNPO9LbbuXt+1mqRyu6Foj+Tc359XHUwMDE16kfknJel9XwzfX6Sa55cdTAwMTdIr8G/h5zh8VxmZGWwMre2MCzx8Pp40Lo+zZK8rMhcdTAwMDPI1MPzVuWB5Fx1MDAxZuQwhOeEQ8tbw1xu4Vxi/r5cdTAwMDVcXEyadT1oXHUwMDBl7bjVW/k+X5o0vpViaMO4IeyskFx1MDAxZoB8qmk7f1xisnO+dJ91XFz8dnpcdTAwMDVYXHUwMDE5hF1+1/52Svd6em6xXHUwMDBlfcxcdTAwMTRcdTAwMWVyno/y+9A/lz+HMVx1MDAwMDvL2uCnnWVcdTAwMGbM83F4mjfNgZ4vTVx1MDAxY1x1MDAwZjyfwPVcdTAwMWHsu1wi3Vx1MDAwN/xcdTAwMDKOU1x1MDAxY6pcdTAwMDV8qlxitvL+8+b75EBcdTAwMWZGXHUwMDEx+Vx1MDAxMmeFu5PeYt74djoq5Cu20Me4XHUwMDAz2JfhXHUwMDFj5qjfLcZP8v7E3Y7BZq5eP79cdTAwMGLlxfVJ9N///Vx1MDAwMoaoTs7/XHUwMDExhtxZ61x1MDAxOLpcZpXwXHIzXFyFLU9qr2VMyK1RzSbzuYRcdTAwMGZtX1x1MDAxOOv/XHUwMDFjhna9+X8wtCOGJu16aXyRr0Wto/Nes35cbnaidNf6dsDbhFx1MDAwZsLLxu+oxmR43mtnXHUwMDBlJHzHmtjPKt776sF+lGFcdTAwMWOw/6jrO1X4XHUwMDFl+3Nccvq/V3Cy+vT6Xnrj9XhcdTAwMWRdj/c/OlZuXGaeYlx1MDAwNWNcdTAwMTVXIzh3I0bK+UjsZj+ssFhG9lx1MDAwM9lPzlqT/Z3f7fgzsv/6XHUwMDE3R/6Jso85g2fy3Vx1MDAxMIdzuOeyJc9nXHUwMDE3mZXrgG+1huc3LTGYrX7fXHUwMDEwi6tQXHUwMDA28Ewn302U6y5njfrx5OJbwX2+XHUwMDBlVuyEXHUwMDA1mcd71Z6eO4V73bXrle6DzN23XHLkXHUwMDE1+Fx1MDAxY/C7Qlwin+A756Nl6VxifOxcXDTvdFx1MDAxZjlUct5TuXWxiiXKbkB1XHUwMDEyXHUwMDE0s3a2gnxAiiFk07LwXHUwMDEwXHUwMDE3pe9YXHUwMDE5/GWqXHUwMDExo5xNLclV15xccshirqVCtUDuuopcdTAwMDJfXHUwMDA2fIp+XFygODLm4chcdTAwMGbAuFx1MDAwZsZcdJeuzirkXHUwMDA1ylx1MDAwM8DIof+HdVxiLo+ZxOyAm6NcdTAwMGZbxVx1MDAxYVx1MDAwMvRcdTAwMTcx33nYXHUwMDBiKGdcdTAwMWJFLv6MPlwi1jqlKVx1MDAwZVuiuC/mzFxcnFxmOLcu1/HefYxZzem52YbL5bl8NUtiKXDPXHUwMDBi7COeizm6KcVMkZ/Tdzg2dC1394hk0+V2wE+ISPdcdTAwMTQlyH2stttAZTxcdTAwMGZfLvKyXHUwMDFluD9rTVx1MDAwZuz8Xsmf0Vx1MDAwM69/aeX76YFHO4J1XHUwMDFi80JcdTAwMWVjwOmo9Vxy82yP8t2Q50vQ9bN2XHUwMDBlsJO3aL9Es34uK0OLunxOcaVqmnKbgdNcdTAwMTMrNiX9VMe4c57UOJaIK1x1MDAxZGRBjjE/yMtnpHdkXHUwMDE5Y1x1MDAwNL1GVKK8RWFJeKJcdTAwMWMk+f9cdTAwMGJXI3BcdTAwMTGA3KIsooxcbuBMQVx1MDAxMNe2fY6D+DiA+6Hcx0F/zijeS/fb8JnipqdZkEHMf1x0dz3FKbG+h1FcdTAwMWU124/c/XNYc8DLXHUwMDE0L8ZcdTAwMThroFx1MDAwMorpnFx1MDAwM6Kxbq6mMVfsYmCYXHUwMDFibPBcdTAwMDffN4uxkkZcZrhcdTAwMTElyvni9bVF0Fx1MDAwZlY/XHUwMDBiXHUwMDE3j1xmo5X7XHSX28RakZq6rz1cdTAwMDCcxVx1MDAxNL+oXHUwMDA1vES+e1x1MDAwNfrTkFx1MDAxNL/pXHUwMDE1opX+iDLmailuU3Ex+2pcdTAwMWau77uY+Np45OYu/jmA8cBcdTAwMWGqiGphkjxcdTAwMDbek+qS3LFK5OZcdTAwMTT/hvGL+yzJr9N3ibzIMsXYw/vPXHUwMDFj5iqb/L3EeiXKTT5cXJtO7nnQXHUwMDA0br24byv8c7HofsCo/9VcdTAwMDBrKXDsWeB0blxcplx1MDAxY0s6vr9HJYbfcaAo7lx1MDAwMfqyhDnPLOa1+zFcXIdxScynJP3ua6qzqM1XjjU0yMrixc/YluwpyGbuQVx1MDAxNsuUr6iwanZD+1x1MDAwN+OSa1OB5LRcdTAwMTRcdTAwMWbDuFVcdTAwMTaUV1riZ8zhYZ1kyChmh3GrOKQ6XHUwMDA3PFZ2MW08xiiu0Z9rqiN1tS1cbmVcdTAwMDfHXHUwMDE3dP2SYmAgi2WsM+hhvKvPKS6MuYNsn+rQQFx1MDAxNnWJYukuvupio1x1MDAxN3g95t8wXHUwMDE3zMF+oeyA7PVcdTAwMDXVUFJsk+pcIjCnoCmWRXKCNYeo/1x1MDAwN3A9yjrFmzCWg/Hg++thvHJ4PdWMwlx1MDAxY9D1Lo9cdTAwMGXXU15cctvfoDgoXHUwMDFkS2Lu7liBai9cdTAwMDKsSchivLAvXFxssoD31JQ3yVx1MDAwNXBPlIWQ4uxU61xieHHXU76bUyyQ5Iyuv8chXVx1MDAwN2PjrqNcdTAwMWNPlFxcRzlPTjmaXHUwMDE4bdbqc3POtifXXHUwMDA3VGtbo7pFkO9opS9cdTAwMTi3X2BccqNcdTAwMWJcdTAwMWaMT+E9I4rdVuLV8cG8UIQxWze+XHUwMDE0w6staH5AV7rrKe/NKadXXHJcdTAwMTbJ9cn8ULxcdTAwMWV1LM0v5s/g+qXL5+P1OL85d32vQvJcbtc/ykdcdTAwMTVtfFx1MDAwM+tcYuB6zINgXHJwXHJjcDGcXHUwMDBi11dEUtdcYrrHzX91RT5BN7n751x1MDAwMom14lx1MDAwMcVVMa+DOUzQXVi3XG4yS/FDrFx1MDAxM44xzlx1MDAxZi6ohoh0W+ByrXg91uRV6XpcdTAwMWM/qp2ieViCXHUwMDFlrt7rXHUwMDBmrEFcZqk9ZeJcXI1lXHUwMDE1sO5i2TXSXHUwMDA30D7M/Umnx4jDxNQ+dy+G41x1MDAwMHpqgbFcXKpcdTAwMTmKXHUwMDExJ66WeLVtzn5VUFx1MDAxN0mK48fYNpRcdTAwMGbkf1xybMt9TVxy4JnyjHh9XFx2NbeI3WW9z1Z0biXJ3Z1cdTAwMDauXfc2LUf50lxu2Fx1MDAxZKrd6sIxqsXBWr+afLArJF9UV1x1MDAwNM9fsysrnyuC6saqNFx1MDAxNrGL91ZQRqXTXHUwMDA3XHUwMDBmz2VUk1x1MDAxOVdY/bmfJlFcdTAwMWbBmMQkezB2OPc41lhcdTAwMDPjeF2Q6Ny0pjGlutSA+p/k45MxLfBcbug1l/tH20+5dkH5w5j0XHUwMDE1p3GhvD2uoVxidFLfwlxcfVx0rXvAWlx1MDAxNmfLXFyeXHUwMDE3bSFgLsI6IJ7gTaI8lkmec9rZXHUwMDFmzOcgN+jHLn6eSzBMc6qpviyLOeFcdTAwMWE+n2p6MTdQpnrAXHUwMDFhYFx1MDAwMvPhdM+5y3OgPlxuKaeGXHUwMDE4eJDhLNXqqKCG44S5N5QtXHUwMDFhJ9DZgNG4QjpcdTAwMTTruIIs1atgjT6uJ3CfXHUwMDAxU1x1MDAxYuZAU23GmXsmjlx1MDAwM+B2Sblgyklhjlx1MDAwMvOqae3q7Fxi9yjPwGlQXHUwMDE3XHUwMDE17uvWkjZcdTAwMTNuUTY1xd57OZHYWTdcdTAwMGbVXHUwMDFh1nUtXGLXiJtcdTAwMThzKuR/XGKXf8Nxprl1ObFegfI45Vx1MDAxYV5cdTAwMWbh9ZjTwfYhJ4ycrVx1MDAwM1x1MDAxY9P1WGtA61x1MDAxMqhmXHUwMDFm81x1MDAxZMl6lphydphcdTAwMGJ080q62eVcdTAwMDNxXGZrXHUwMDBiyv1UUTejnoiwlop0OvhQ9/Ncbnaon9iEiiijXFwg3rJYXHUwMDFiSOMhUFx1MDAwZpQpf1OJ3Vx1MDAxYzYod1x1MDAwMseQdyVjXHQydz7azVx1MDAxZsE3p3qe9H5cdTAwMTSXcGet+SM774z8M/7I67dd/lx1MDAxM+NcdTAwMTLvXHUwMDFjk2tPWuJ48DxcdTAwMWXnjqMv1Fx1MDAxMINlQyxcdTAwMDZwXHUwMDFmRT5cdTAwMTK0syH7toDcoHuwpGOZjbExzFFjTVx1MDAwMNWK0npcdTAwMWT0x9z4zF2e/fl3WIf5PFx1MDAwNkfx5lx1MDAxMcbxdpRpqZTn8Vx1MDAxZsm0O+thXHUwMDEx4r/+9q//XHUwMDBmhSCYtSJ9 + + + + + Your ClusterLapdev-Kube-ManagerLapdev EnvironmentLapdev EnvironmentLapdev EnvironmentLapdev Sidecar ProxyApp Workload PodApp ContainerDevboxDevbox intercept traffic from clusterLapdev EnvironmentLapdev Devbox ProxyLapdevDevbox client traffic to clusterDevbox client traffic to clusterApp WorkloadPreview URLPreview URL traffic to clusterPreview URL traffic to clusterApp WorkloadDevbox intercept traffic from cluster \ No newline at end of file diff --git a/docs/.gitbook/assets/file.excalidraw (3).svg b/docs/.gitbook/assets/file.excalidraw (3).svg new file mode 100644 index 0000000..7a20d0b --- /dev/null +++ b/docs/.gitbook/assets/file.excalidraw (3).svg @@ -0,0 +1,8 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1dWXPi2JJ+71/hqPswXHUwMDBm06jPvvTEfVx1MDAwMIxZjbHBYDwx4Vx1MDAxMCBA7Gaz4Ub/98mDXUYsYrHBhbvtiqoykpCOpPNl5peZJ/M/v52d/Vx1MDAxOE56zo8/z344z2W75Vb69tOP3832sdNcdTAwMWa43Vx1MDAwZexcIrPPg+6oX55cdTAwMWRZXHUwMDFmXHUwMDBle4M///jD7vWsmjssdbtNq9xtv3zNaTltpzNcdTAwMWPAgf9cdTAwMGKfz87+M/vXcyG3bdec2cGzzfPrUCmXt6a7ndk1MSaKcaQ1ezvCXHUwMDFknMO1hk5cdTAwMDV2V+3WwJnvMZt+JItcdTAwMTPe7lx0XkjE4plqtlx1MDAxMlx1MDAwZVxiqeeXrbqtVnY4ac2GNOjCnc/3XHKG/W7TKbiVYf3n7Xu2+32r31x1MDAxZNXqXHUwMDFkZ2BuXHUwMDFkv23t9uyyO5yYbVxivW21O7XZOeZbns1cdTAwMDOggllSM6W4ZkyR+c2a71x1MDAwNzAnXHUwMDE24ohcdTAwMTFMpZBcdTAwMTJ5XHUwMDFl18vIwt1Wt29GNuzbnUHP7sOLmI+vZJebNVx1MDAxOGSnsvm4p5/3Pb983XFr9eHitoEze/SYYKaxIHK+x1xcpFx1MDAxN6/Mp8Bsa6aSl2rKa5GsvlwiXHKWeGjV9PTtqnBA5V46aTdcdTAwMWKapsdPXHUwMDAx0miz3OOT9Fx1MDAxZVCIxc9DoWcyLvSr7rB23blRrdGP1/3/N3+zfbvtxM3YOqNWy/t6OpXX17Owo2R2RDzTdn6qUa9iv8wwLFx1MDAwNWJcdTAwMWMrXHUwMDBlj52/7W+5neby6VrdcnPNpFx1MDAxY1xm7eHInP5Hz+lU3E5tYSq+jPdcdTAwMDd2SpQzeKRKc1wiS1x1MDAwNFx0wspEl7TGpCwwRqqMheTSM1VcdTAwMDG0zsKDxvMnhn8+m9n/f/2+XHUwMDFlj0PnebhcdTAwMGWOhPvCkVOOtVZ8dzRW25nLavM2cu1e3dfiLVx1MDAxNFf6snfqaMTMwlpcdTAwMTJcdTAwMDTvglxiuVx1MDAwNEYsLU5cdTAwMTiVmFx1MDAwYk2V4MtcdTAwMDN7XHUwMDAz2b+wY/68XHUwMDFmiJhwi1xiKiRWnrk3xyRdwSSjXFxcdTAwMTMhXHUwMDE42YLJL42oarczzLpT8+JcYlrYemG33dZkYbLMJjk86GCvd1bo9putru2ZLGZvsOXWzLT/0XKqi3hcdTAwMTi6XHUwMDAwsrfdw65n3pbharbbcfqrT6fbd2tux27lNl5cdTAwMTnu14n9fJPYwtwzS1x1MDAwNo7Za7brjVx1MDAwMO475eHLXHUwMDE0XoNiTsjy1p8oJogwTVx1MDAxOabzp7dccsZcdTAwMTfDy0Ag6pZ7XHUwMDA0lYuT26brkuHos2A8XHUwMDFm5l4wJlxmWVhQzUG7UobmV37BsVRcdTAwMTZBVCgmOONcbit1NFx1MDAxY1x1MDAxM1x1MDAwNVx1MDAwM1HUPHaiuGBrsFxmXCLeXHUwMDAyoMNwuKSgXHUwMDA2sFjGtqaEaDCHPlx1MDAwZtr/mZ/0dcbR1y1/+SP+7Tvzb79NXCJE75BcdTAwMTTRrLioT+g0mku1XHUwMDExXHR5XHUwMDE0vWdq2/1+9+nH256/Vsa/Kk9cdTAwMDQmlHnxtUWevFx1MDAxZlpcdTAwMDIrP2hhxYyJRvawV+8mbm7E0+GLXHUwMDEyqvdtfp1cdTAwMWLZpfjJQ0tblIG2YaBcIlx1MDAxOV+EXHUwMDE2kTCXXHRjkihCKVwiUnxcdTAwMDRZ/6rOfo6EKqBcdTAwMTaYK+p5oetR9dDLlGuDq+6AhcLdfDTauGv3h6eBqlBcdNf0yIlcdTAwMGbjd5e9WPx5Oqlcbnk4VCnkkYxcdTAwMWZElS9cdTAwMDNkwtfk1Fx1MDAxOFx1MDAxOWWld9dVVylcdTAwMTGhvandu0km71x1MDAxZVx1MDAxM53idadwfuomp6SWlpzA8yZKSbaEKC0sJiVMZIwwxmB1fpRcdTAwMDD6o2o38lx1MDAwN6Ypw6BW0Vx1MDAxNtxcdTAwMTREeFx1MDAxMJJXJZqYPlxyrq5cdTAwMDJcdTAwMTk6yEW93K7IgrF0MKCeXFxdXHUwMDEy43at1HdlznvA4ZD3Tf7OtpM/KrVcdTAwMWZcdTAwMTLpTFZ6XGaQbUDU7vVznTV1aXhzX+hMJs2nJ1k6cSBcdTAwMDL6LFx0XG5eXHUwMDBippXWSCxcdTAwMDBcdTAwMTGooYVcdTAwMTCVimuk4IBcdTAwMGZxP39cZmJuSYmZXHUwMDAylrlcdTAwMWLvXHUwMDEzUlBseOKnqbEvw/tabtn5r8FZXGJcdTAwMDRjuf6rKOC2QVx1MDAxY51cclxuTpe3vpmsMJ1cdTAwMTVcdTAwMDMqtLuGvVbV24dHVCy4uVKh/lAodVx1MDAxZmrlUzdZubZcdTAwMTRcdTAwMDXRXG6qiyCFXHUwMDE3faxScotcdTAwMDBcdTAwMTMkmDGBxCmbrMauXHUwMDA2XHUwMDE1sY1cdTAwMDeWQ/r6uflcdTAwMWO7cVx1MDAxMyrUrDxG7lG+d1x1MDAxYVx1MDAxNmtcdTAwMTlcdTAwMDdcdTAwMWXoQIupk7+8LT+VnmvBQvhgXHUwMDE2q8RaXHUwMDFl32LlyNe9ojWTYLHuricjgXrxJjaM8e5cdTAwMDNcdTAwMWI9JqvJULnLT1xcT1LFLLDasaScU44oX4CTQtiiXHUwMDFh3lx1MDAwM0xWquSvt1cpkVx1MDAxODO5zVxcPa+GnrtP/btWPaJcdTAwMWHNXHUwMDE2iXJZaXut0ekli4T7JTWJZOrXV1x1MDAwZuI5MMzeeFx1MDAwZjhcdTAwMWPsvs3VXHUwMDFkzFVcdTAwMTiHr17TVCl4XHUwMDEwu3tiXCJu6LZRdMSt21x1MDAxZmV0YPQ8rSh04jgkWluEg1xup4SAdltEIeWWJlxcUcYpJoL6gfBjOlxyKTCJpVx1MDAwMrW6i6nKOVx1MDAxY8s+U319XHUwMDE1SzXULf1qO3XLXHUwMDEwjm6lYq+vaSVoQYWAXHUwMDE5Lnc3U9PjJ1x1MDAxMFW39WAvqKJ5p8fzXHUwMDA1enviZiqwSlx1MDAwYlx1MDAxNJVcdTAwMDC4YiSRmlx1MDAwM2hcdTAwMTa1oFxmXHUwMDE5T5CYuYqAo+KlkVx1MDAxZC5owWAgcFx1MDAxZCVcdTAwMDUjxrU+fzFv6GZIWCD9hcAgYcCqns+HV7BcdTAwMGJKkdJoq9qN8F7yMlx1MDAxYdDlW5vYsUkjI3v3ydOwVY9cdTAwMWOz4FRcdTAwMWbMVnVaLbc3WFx1MDAwYiytfdWkXHUwMDExyNgknexcZqv6OH9dfVx1MDAxZVx1MDAxNvrRSHzUian+eMq6J1x1MDAwZStQg1x1MDAxNkaawDxVaCVcdTAwMTZonD5cdTAwMWEwXHUwMDA101xcclx1MDAwMfzKN8Hm47FATSxEXHUwMDAwU2AuUcnW+neUWDhk1ZqVUnHQoVx1MDAxZbP683FFtuPqkLp148z3ZWlcdTAwMTh5JOSKfahcdTAwMDRcdTAwMTbwutXOM//88mGcT1RcdTAwMDO97m2k7Vxc1d1mzv20mf8+XHUwMDAzkSGuLEkwwlx1MDAwMlx1MDAxZbyUUixNfaotjo03U1xihYHM+bo0PzO3XGYoJZNU0W3+TDdcdTAwMTZpNKv3vZDM3YtuXtVv407DS8fy133avM92xldcdTAwMGZaZmr9+FMwmPJcdTAwMWVwOIh887Wz7XxNU38/pGaUXHRcZtbGzngss0BOP1x1MDAwZW9cYn6spVL1biBQKTycNlx1MDAxZamWzJJUXHUwMDEyUP1gzaLl9DJibCpFJSgjk0lwxKxcdTAwMTSsuSVcdTAwMDTYcDtcdTAwMTI3YaikpNvSPT9cdTAwMGJQm4y2Ok1cdTAwMTJZRm5gUIteJHmYpzRu7ma0/b7pvLe5ZFx1MDAwNTc7VT68XGK6w+ywfz5cdTAwMTlGXHUwMDBlcN5Sml1FnWyn9lhcdTAwMGWRi3Y1XHUwMDExdm/0wYzMz6WxKbtXccZnWbfilO3+WabffZ58LovdPIJDkFhf6cZ8pVx1MDAxYqVcdTAwMTRzsbto07a8XHUwMDAyWT29v8+qXHUwMDFlLtdcdTAwMWXwQ7N56qJccsxoxjAlJnCKPMHI2fe5hUDWgNFFtUBY0+PZ2GDqWDvLNUxcdTAwMTFcdTAwMDWtpD+Po35cdTAwMTWHlDdx9SzT/YVps4tXP1x1MDAwNII3MFx1MDAwNrXBo4xcdTAwMTihhNHdXcrP+PYpl0q0rnP1J+nag6xqXGZcbqdccmNAXHUwMDBlsrimyoCUXHUwMDAw01xcXGbtXHUwMDA0tDbJXHRcdTAwMThMSiChVHpcIj+/jjBw4PZcdTAwMTJLvo0vlCuNfCDci1x1MDAwZd1IduJMXHUwMDFmU5Ve2atof+CLUDpcdTAwMTjuJeOPeKzHdV5+qFZcdTAwMTO/gi9cdTAwMWPJvDmmufBcdTAwMTXZiK93gFx1MDAxOZ/nXt7meHVwLsrtfDVcdTAwMTNgqam6uiU3sftThzqSXHUwMDE2vHGElCBcdTAwMDRLslx1MDAwNHUx0+cgXHUwMDEx4WmA9U8/lFx1MDAxNbFlqYu0lDCmw45xJCyYXHUwMDEyQmO0LZD0z9Tb4Z+K9vOV9ppLXHUwMDFmQmO/SLI1ICaS+aYsYtBPXGIjukdcdTAwMTB4s3A9XHUwMDA0jCvdobn8Wlx1MDAxY7/Pvc2QJFx1MDAxNohTrbRcdTAwMDRIU7S8gFx1MDAxNHZ/jlNcdTAwMDFZQlx1MDAxMFx1MDAwNVx1MDAxNjXmJv9D0DXqXHUwMDFizFx1MDAwYnhnXHUwMDA09lOwMJhYxTZXlFCQXHUwMDA0/yD/tleL9och90WBLlxm7HWFdXxcdTAwMDfX10xIlGfKOICAd1x1MDAxMYxcdTAwMTjSxGBcdTAwMDF0rOCe42p2z1xmXHUwMDFir9wu6PDtw9hs3y5cckNhXHUwMDA1isRk1yHGXGLXq6OAaWq8voBY+CNcdTAwMTRZXHLetezBMNxtt12DoUzX7Vxml5/r7Fx1MDAwMVx1MDAwNo20qDv2ykuHu/Lug9nsLoWje+aki1x1MDAxM23+29lcdTAwMWN5s1x1MDAwZm+//9/va49cdTAwMGX4XHUwMDAzwvysQGF+vp1cZpst0XThL1x1MDAxODWiVHKud1x1MDAwZn6oXHUwMDFjvsw4xYtudnxcdTAwMTcrPjbbncHFpzlb31x1MDAxYk1cdTAwMTfUXHUwMDAy4Vxiz1x1MDAxZcOMY0tUXHUwMDA2M9grXHUwMDEw11opXHUwMDAxXFxcdTAwMDb7UpmTiKZcdTAwMDO94TDQbUIxXHUwMDFhqVx1MDAxN4ah2C07x73M85RcdTAwMGLMw+5pXHUwMDA00z/g5/x903m/0Fx1MDAxMqhNQXrp6z1UkjDGvHmU29DauKSBhlx1MDAxM5Ky0MCPQ5ZcdTAwMWR0hq3AiaNVU21RoaQkXHUwMDE4m1x1MDAwZotg1cxcdTAwMDLrRlx1MDAwMifTUlF2RP/hIWL0XHUwMDA0yFx1MDAxNMicrSsyjonWXHUwMDFkTJhNqPpAtGNcdTAwMDdUXHUwMDFkNFx1MDAwMcCX4HPkXHUwMDA3KVO6RFx1MDAxM5hru2NqOs2LiEt7T7GaimUvy+lJOjv5LEy9O9woLEk1kFx1MDAwMq6Qt7KH+T7TytKMcVx0Jlx1MDAxN1x1MDAwNZ6vj6dcdTAwMDBcdFx1MDAxN5bGu0dcdTAwMWLhSM5cdTAwMTne5pU/XHUwMDFjfL5cZrtfv5boXHUwMDE3OOh3XHUwMDE5yHF99Vx1MDAxOPkvXHUwMDFillLgvUpcXKjidS1cdTAwMWO96ndI91wi6fTu8XnSO+FPXHUwMDEx3WDaMktxSlx0XHUwMDE4loJcdTAwMTKymNxDNIa9XG5AoU2Gtj/t/0RPvcJcdTAwMWF4KN6aupbE0mb44lZcdTAwMTdKsUugmIFqqVjzKJ9cdTAwMWb5c11y8zlcdTAwMTeHxqNErZhcZif004X3gC8tXHUwMDFhvqAvXWxQtcCxzFqMPcjmTadcdTAwMTiIXHUwMDA2h9XMOHeTTGh7pO1PK4rxXlUrhCUowFAxQ/SXyKZZOqxcdTAwMTioQYyoklx1MDAwMNkj2q9gRyO8R90ooL5gyFx1MDAxMv6tbVe0rXFoj1x1MDAwNsNu21x1MDAxZDiVX+pW3ziKT1iasWGlXHUwMDE1XCKIclx1MDAxM4zZXHUwMDE53/Hc+ajX719FrvNN/swj9/lg4NPKwr3bmaQswSnSglx1MDAxYuNULeJcdTAwMWKUrMVcdTAwMDFFXHUwMDAyISy4VHppYKflS8JcZlx1MDAwNJCh2evT+b7XXHUwMDA3XHUwMDFmwJ2D/auaXG4yc3Lz3Vx1MDAwMaOdWqjhpEYq1a3108WaXHUwMDFhTmufllx1MDAwZvZef1x1MDAwZWNcdTAwMTZcdTAwMTjhXHUwMDEyc7OgaalKXHUwMDE0aCdcdTAwMGIxkCpcdTAwMDRYuJLoeCuZXHUwMDBl4c7heFac9Thw+aCf5rSyR99lPCr/9UkmLVxcUlONZWesVIQ9ucj0nMegPK9cXLl1XHUwMDFkvWTBUzdcdTAwMWVcdTAwMTW2tOKgOzRbdtNoyY09Z7JcdTAwMTSZXHRkXHUwMDFjL1xyw9R1w1x1MDAxY1G9c/YkV2Doan5cYmB8XHUwMDE1m3DtKtnP97/sMIwjZ0py3+wp4EKMXCK8R9G2op2utSah89Zd+zxcdTAwMThmKCbP3Vx1MDAxM893ZkghXHUwMDBiXHUwMDAxb1x1MDAwMkNcblx1MDAxNFxiXUItmGiWNj5mU1rHpFD5ofZTXHUwMDE3Vlx1MDAxOU9cdTAwMTCRW5me/XBdXHUwMDBlJp/Veeqc3Fx1MDAwZuUk8eTUkl7vXG5NZVx1MDAxYn2Cp7qQkDlRXHUwMDFhZZu1VParUMEv6F3xpPSsLIxHQPil2oN90Uk4WpqOXHUwMDFl5UX4MqPT7eT1VbV92mCDO8SWUoRIeO9aM73Ivlx1MDAxNFx1MDAwMrBhrIHSXGIgRscjX3v7VjRcdTAwMTdcdTAwMWPsSp/U5FNHyrfT5L1piVx1MDAxNPlDXHUwMDE2XHUwMDBiTjFcdTAwMTi1ezhEN1x1MDAwN2NPNC1cdTAwMTFLacFcdTAwMDTEpvo28K6VXG7cwoKNmDBlKlt7q1x1MDAxN1x1MDAxZT76yE2xb1x1MDAwMlxmTjIhtGeB/1x1MDAxYnQ5XHUwMDA2ZCsuKFx1MDAxNWBws1XlqbHCkqCtIf3qbU3eXHUwMDA1r57rxZtcdTAwMDFcdTAwMGI/3vbzyVj8Oyvx5abn6YBSXHUwMDAzXHUwMDA2XHUwMDE42InGIe5pKXH2M1x1MDAxYlDCKzOl7rDZa8pcdTAwMWSs3PtOKYqbM2E8Y0KWhlfPMDarSVx1MDAwNFx1MDAxMCC6MiQsLJM5x1x1MDAwNVx1MDAwNj3EudJsZUxfLEHRXHUwMDFmXHUwMDE5L3vhPcFeTZRiXG70q9h2PqHB6EVKaimZd3Wz+VlB2PxkO1x1MDAxOUf+slx1MDAxNuNN5lx1MDAxMYhaJtHueVx1MDAxZZtcdTAwMWQqpylrzaJKXHUwMDBirPuZZjGLtpaWcoBcdTAwMTVrMiFcdTAwMTVFTFDgLOyI4ScgPmb5jKRKMLDF5ibRvMolwqBvMVx1MDAxOK2cIElBsK5cYltlloLIrXXC3Pp1pH4x7NBmhd83XHUwMDAygXo5W1Dfwvblpl9cdTAwMDWbcbQyrVx1MDAwMXqSIeP9XpW1puNccsBcdTAwMWbYjnHoYMb4+4TtZje1V1x1MDAwMcCgQNpgXHUwMDEws1x1MDAxODRcdTAwMDGieDVcdTAwMWScmD5cdTAwMDJ0tp7BxDK4XHUwMDEyK4P6YtI24ItccvPDZlx1MDAxNIdcdTAwMDM2wVx1MDAxOFx1MDAxMnDA1tNR0EdYmCYnIG0l01x1MDAwYuJ2XHUwMDE1ZHvK2839ZTyVZFaaYHAhqamlu7PIXHJe0cc+zTnxXFw6XHUwMDFieujEY4/T4dNhRa49qPuJ3HdSUqRMXHUwMDBiXHUwMDE5rrnJI4S5vCRymaGKZrFcdTAwMDPlWlx1MDAxMKI+VH9x8+o5xsHQXHUwMDA2hFx1MDAwM5A0oJfM+e9cXORyoSxcdTAwMTD8pspcdTAwMGYy69091vZryircXHUwMDBmV0j7JN1cdTAwMWQ/JvjLw1x1MDAxMN5cdTAwMDYyy3OaUM5cdTAwMDSXe8zpafS+XHUwMDEzyOUj6UYte627MXtK1ODEvSxmSVx1MDAwNGhcYkFMeyiBl6pkXHUwMDA3qMaW6UNk1oUz05HwiFx0o4JboP5cdTAwMDXfNVx1MDAxMmFcdTAwMTLLgFH7LFx1MDAxYftbOlpeK51EOmO33+2YXHUwMDAxnoV+ei5cdTAwMTZcdTAwMGX+pJIrm1x1MDAwN3LUaIQk/j2ZuElx5nyPslxyj+WHZ0xSOPaQKjVv49dcdTAwMTFSbchcdTAwMTNnXHUwMDAwxpCyyEtccjcw4/BcXFx1MDAwMrxcdTAwMTSWwsowM2J2XHUwMDAyXHUwMDAzk/41XHUwMDBlPzVcdTAwMWVcdTAwMDHmXHUwMDBiMjGSLWa+nVx1MDAxMeXMZVx1MDAwMmh5XCJso9R9p1x1MDAxNcClv1W4QVdI1dSm1LayNapUnFJFVDVcdTAwMTYlXHUwMDBlW5S0XHUwMDFkXqlSWlx1MDAxNZ9cdTAwMTJukFx1MDAxYnJXlFBcdTAwMTTDXHUwMDAz2SPc8Ky7LqPjQSHabDixaULUk8lTR1x1MDAxM5NcdTAwMTSsN0aFNPkgSCxcdTAwMTXixab+PSOESmlaMlx0j6Y8uHWHhTHuyK5cdTAwMTFcdTAwMDeCzUJfRbaWNPosUL1dcU3ySqxcdTAwMWXu3+FGI1VNP+VZu1bKXrHOL0te+Zg6zvSdses8nd3epD5X/a698HGLLmyMblDT/2Kf6P/mWXCiXHUwMDEygptKSCawIVxyufOKzNfwXHUwMDA2skyFJOBTpl1cdTAwMGLzL5T08YxQYJomXHUwMDE2zVx1MDAxOTJcZoWvXHUwMDExXHUwMDEz2FJSmXg2o0rO/O6ruti0tJLellefm+P2K31pm1XUmTdIgLl524RcdTAwMGKuqSlU4TnoxW2lLdN5wiy5M6nCdDVEsOBJW7yJr+XO8p135md1xs1P9yHjRFx1MDAxM1/Pk8JcXFx1MDAwMe7k7pZ+MpjPJtJXyWK0XHUwMDExl11ZXHUwMDFm39PBifcnJ0Igi4JNXHUwMDAyT1x1MDAxN/5IseR3XCLILFxuM6m11GSzXHUwMDEyfry4KiXaQlx1MDAxYUstdu1QzimSciGb82/P0uvDYW/w51x1MDAxZn9cdTAwMTi1XHUwMDFicOZcdTAwMTTZsns9q2X3LKDOn2sv7Deio/J2xf0zf5VcdTAwMDbxuleB9qSsodzVoClYNFN6jFdGN+OcOHk7glBg5kqbNlqCkSXejjFcdTAwMDWwc4KENl1+8PLIflx0bZfUdKGlW9NcYlx1MDAwM7nMdVx1MDAxNEUnj6PHok2b91x1MDAxMZy+el8z8m/avoNmVP7dXHUwMDBlNMfcJJbvjqXutJhcdTAwMWSG29fXpNnJhZ/PXHUwMDFkt0jxqWOJSbDJMdZMckEoXmbtUllKS6xcdDB3gfRcdTAwMTEz6fcl7ViaOjPc60f4tZB6u+K6XHUwMDA1WrlOe1x1MDAxMmjfXthVwvOVPO7WSodrivNN2o9F2lx09yftnGJu8md3N503z4JcdTAwMTNcdTAwMTVcdTAwMTBcdTAwMDJcdFx1MDAwYvRcdTAwMTbwXHUwMDE1xEy10UX5ILhcdTAwMDWkjTIuiVx1MDAwNM5cIo7n1DtcZmUnSlx1MDAxMI6Yj9D4e1P2zfrpzJ+yc+lJk1jH2Zlak2nyTdo/ZJpgpH1cdTAwMDN0ylx1MDAxNPuCd7O76Fx0tNN32baY3IqrZMRcdTAwMWU7j0O7kjio6DlcdTAwMDJrJyahXHUwMDE1I1x1MDAwMvOMXGK17C1EyII3Y/JhXHUwMDE15/A0jkjaNbOE2J2zXHUwMDAzXHUwMDAzXHUwMDAzYYnYPyiw/pMg26bGUaA0W2N3WtR9v5FcdTAwMWQ39L6pY1x1MDAwMtWm+izfnXaUMmWOxrRcdTAwMWNoJKuXwfxziU9cdTAwMWZcdTAwMGbbMeEooXfT3YSb3lx1MDAxMJxgqZfBrSysYLvUs5ryXHUwMDEynULPXHUwMDA0IEIwWLq1OWc8V1xyXFxFXHUwMDFlSIhQXHUwMDFlL1TSI5fF899cdTAwMWP+aKF36d+xUDBuXCJKe5RcdTAwMDE9TydcdTAwMDKo4FxckUIrnlx1MDAxOF1lq8Ewj506mphEllSKmlqgyvviZ99cdTAwMTeWWU1HwV7AYDKgI6ag7cvhhUQwJuHj2/58RL1dcVxyhVx1MDAwZqTGiUm7fkeGcXnR5kF6XHUwMDE3Tp9/U/j9lPEvoPBcdTAwMTj7ilx1MDAwNyU1R0qx3aXD5klwotKBa2kxXCJcdTAwMThcdTAwMTOU66VOXHUwMDA3RFsgN4RcdTAwMDJmXHUwMDBmLI4rX2f5ifB32MtcdTAwMDXFPulvf2/+vlk1nW1cYrlTXHUwMDBmZf3m75/F3zd0boXhXHUwMDEwIJR7dFnJ1p9cdTAwMWFXwcdGI1x1MDAxMEneR+8v0DVvsJMn8NpcIjDBTG5cdTAwMWRBaLG9YUBYkmiw8MG+XHUwMDE3mHhbjFx1MDAxZpy/K2GZTnQ7XHUwMDEzeFBcdTAwMWGmL8w/qUbPW4S7WzpJ+r7PuI7ZsVx1MDAxNCOyIY9cdTAwMGZmPGV6XHUwMDBm8s7jZZSoq8ZFK1NcbofLgVxibaCTb5CuTOlcdTAwMTBccvZcdTAwMDRiwmiXRVxcY65Mk1x1MDAxMFPhXHUwMDEyTFx1MDAwYi7k8WKG+2fTSM20Vv+o4lwi39k0XHUwMDFi0Iy1b96+WY4vNdqj3UHCxp3ufSRcdTAwMTm6xVx1MDAxN4VYvtkrdlx1MDAxYadcdTAwMGVmQLElzHpijiRTdNF1IFx1MDAwMeiGN3BkymBcdHJEXHUwMDE1vaeLXSswKkx/rn9cdTAwMWWOv13sO+Ca+GfbK1OyxLTL2F1JMyRvM9fJzmXoSpeHUVxce6g0Tlx1MDAxYteUamVcdTAwMDHbUaZWXHUwMDEwx8vrUoHoW4xI08VUI47p8WrJ7m17m0rQ3Fx1MDAxNO785yH72/bepq09837Z9p51XG6g+1x1MDAxNPUrt1x1MDAxM0/2+VVm3Of5aaJyz4vd6/Fpw1x1MDAxYSg1tjQngoOqXHUwMDE03m7ML6a34Fx1MDAxNmxcdTAwMDWwSYK16fh7NFxcwzUsU6tzd9PbOFi4YD7t+P6WwIYnWHZMRM3586w1W/FtXHUwMDAwXHUwMDE0cCv/npm+3rnxXHUwMDE5eN5jOMeFsedcdTAwMTWsRuyUlHJcdTAwMGaXfO28oGoqkVx1MDAxMaFKtJ9sZmOqcHHiXYhcdTAwMDDFyFwiSpiCaFh7M9RfXHUwMDFjY8wyJb+QwGCdU+S/7vzjytlUK6OE7bFSllxuJJhPXHUwMDFh+z9cZsNv5i7oxdOBst+ojmtuM19fN6Gm2M8+a1KawWjx4nGUuU3i+sMwXHUwMDEzXHUwMDBmVSrTw65+P1x1MDAwNqJNaU4wXU3XJi5cdTAwMTdcdTAwMTFNsUWFKSjGmSSUY33EVDVGrL28YUIgfYhs2K9cdTAwMGbnVyv3pMC8fkxHXHKYYyH865RR0/5cdTAwMTOTPVx1MDAxMk9cdTAwMTG9Q1JEs+KiPqHTaC7VRuSwOe9H6FtEXHUwMDAxRNpoZ0Ayk3ixSWBcdTAwMDBjZSlcIik1nnAq9Fx1MDAxMYPmREmLSlx1MDAwNFxmXHUwMDFlgXBBal2ZMlx1MDAwYlx1MDAwNmJcdTAwMWFcdTAwMWEybFxc4Xy1TNmsXHUwMDA2L1xmmp9i0rswXd7fg/5cdTAwMWSD5unxU4ny23qwXHUwMDE3VNG80+P5XHUwMDAyvV1cdTAwMTc0XHUwMDBmIFNqVXJT8Z9jbFx1MDAxNlx0rFx1MDAxNlg05Ww5WEBSUkVMUdaVm9+p6OPF8DJcdTAwMTCIuuVcdTAwMWVB5eLktum6ZDjyieRjXCJcdTAwMTlcdTAwMTbY9DJcdTAwMDFSx1Yz8Vx0t8whplx1MDAwNiVcdTAwMTFaUL1aOO5cdTAwMWRcdTAwMDH7NUH5pbD9wvdcdTAwMGZcXPTRb9qbn9VcdD8/32/e//dcdTAwMTd9kmxIJVx1MDAxNEJcdI3o7nbM5ibrpyr6lImoIWQ8/oospuVSMHI0PHYjXHUwMDEyXHUwMDE1XCJYXHUwMDFjL5PwMIJPXHUwMDAzoFx1MDAxNdU+vVN/seCDu9qnq9q+gk/l8GXGKV50s+O7WPGx2e5cZi7WXHUwMDE2u1x1MDAwNVx1MDAxOYOwSVx1MDAxN0JcbjFcdTAwMDHGIFst41xyMoaCXHUwMDEw4sRcdTAwMTSFw6aB7vtcdTAwMDTf3cTNjXg6fFFC9b7Nr3Mju1x1MDAxNF8/KCRMfW0k5Ys4lmuq3YLpXHUwMDAz7FlcdTAwMTMuJUKYq9U38i34znZcdTAwMTN81N/mw0BdmPJcdTAwMDb/tjpWN7YjPFHBx7RlwpxUwEziYpHAXHUwMDE5g9BsXHUwMDE1iCt26uZcdTAwMWUxVjr8nORcdTAwMWHHfXtJ7iv1NrdJXTL3QJOZbouEKVNcdTAwMDFH8tUsSaytmbZDTCOthcArd7+T2LtW1duHR1QsuLlSof5QKHVcdTAwMWZqZT+xZ+rQSbhcdTAwMThcdTAwMDExS/TqmFxiMSWsXHTDZsUtUOdvqfcq9X57vcJcdTAwMGa718tcdTAwMWE2/fZGXHUwMDAwXHUwMDAwbuXVVTC/j1x1MDAxZiZcdTAwMTU9tFx1MDAxOaK/vT7X2eKXOVwidu91NZ9cdTAwMTQ/2m7byXnXhv0xXHUwMDE41/77ud2aP/NcdTAwMTf5uX8jLYCZbXLq/3z59c+F0/+P8VxuXGL2eyaWJveTXHUwMDEwK1x1MDAxNZ5H5Sly7dhcciqfd8cpWqGVXHSnl1x1MDAxMz4ut8vjy0bw6TKsp5V22Y3HKr372E03k41P0uF4zY7me/ekjn5+rrRbrVxuSoydc+RehoNP8fM4mv11Q2278DzIZFx1MDAxM6NcdTAwMTLhLfg8rcRcdTAwMTJjm9zqeDuNy51Eq9S5OS+R5zFcXKVWpjeTXHUwMDEyXHUwMDE5tlJ38+t5vjMsRVuj+0K6XobrlNtcdTAwMWGX2tcwtjSC3/v3WTywXHUwMDBivGW3da/U8OyPplx1MDAwN8W79Fx1MDAxNMbQK1x1MDAxMj2KR1uNXHUwMDEyYcNK9MItRfOTl898mrq7qVx1MDAxN9vPrbKLx5W7m9m5lo9NXHUwMDE14Fx1MDAxZcPx/87EQvVKtPY2Xs/9NeH3tHlcdTAwMDY23H+lXHUwMDExR9nzkFh9XHUwMDBl6u1cdTAwMWP3cC+5XFxkXHUwMDEyP09cdTAwMGauwkGUauRcdTAwMDfwfV5cbiNcdTAwMTY/v1x1MDAxZaVcdTAwMGJPQ9g+TIdcdTAwMTkunVx1MDAwN2upXFxr8PPz+vNEJqXzXHUwMDFhXHUwMDFjXHUwMDA3383Gx5nG81Px7qZcdTAwMWKPXut4XHUwMDEzPV+6wefL9lx1MDAxM7yjppuaMlWOXiA7/DruXFy8dplLXGZSOe/3n8Zlet/J1P79b49fq+94ZbrpRCOpp5awXHUwMDExPjdcdTAwMGWIXHUwMDEzZ7x6lNc82X1cdTAwMTXZezC0/1x1MDAxMrVvXGZtxtCgUkj37qO3tVIs37BcdTAwMGI3rVShMiiRXHUwMDA0/D/Dysr2eIOpXCJpTYrkXHUwMDE58JRmMFx1MDAxZZiD8VqRNnW8XHUwMDExMWObzLaF187VSTFcdTAwMTdcdTAwMDFM3MK8NPPRPJdg7fX5wN/1+1I52LeCjdnc7sI7mc37XHUwMDE0hXc4ZVx1MDAxYua0pox5l1x1MDAxNa6f069HvSmfv3776/9cdTAwMDHCLc1UIn0= + + + + + App WorkloadAlice's Branch WorkloadBob's Branch WorkloadLapdev Sidecar ProxyApp Workload PodApp ContainerAlice's Branch Workload PodApp Customised ContainerBob's Branch Workload PodApp Customised ContainerLapdev Environment BaselinePreview URLhttps://base-environment.app.lap.devPreview URLhttps://alice-branch-environment.app.lap.devPreview URLhttps://bob-branch-environment.app.lap.devhttps://base-environment.app.lap.devhttps://alice-branch-environment.app.lap.devhttps://bob-branch-environment.app.lap.devtracestate: lapdev-env-id=base-idtracestate: lapdev-env-id=alice-brach-idtracestate: lapdev-env-id=bob-brach-id \ No newline at end of file diff --git a/docs/.gitbook/assets/file.excalidraw.svg b/docs/.gitbook/assets/file.excalidraw.svg new file mode 100644 index 0000000..331d63b --- /dev/null +++ b/docs/.gitbook/assets/file.excalidraw.svg @@ -0,0 +1,8 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1921LjWLbte39FRu7H7vReN936xH4w2Fx0XCKRnFx1MDAwNlx1MDAxYjAnOjJs2Vxi3ylssKVcdTAwMWT972eOuVx1MDAwNDYkJoFcdTAwMDSK7tNVUWVb0rrPMeZlzSX+9y+fPn2eZ1x1MDAxN73Pf//0ubdM2qN+97K9+Pw3XFy/7l3O+tNcdN1S/Hs2vbpM+Mnz+fxi9vf//u/2xUUp7c870+mwlEzHtlhv1Fx1MDAxYvcm81x1MDAxOT34f+n3p0//y/9fa6g/bqc9fpgvr9pxlLl/NZ5OuE3pO4F0fOm5t0/0Z1x1MDAxNWpr3uvS7bP2aNZb3cGlz9W41SnLpHvZaF5e1HeHnerXRWPV7Fl/NDqcZyPu0mxKI1/dm80vp8Pecb87P79cdTAwMTn+2vVNpS6nV+n5pDfD0OXt1elFO+nPM1xcXHUwMDEz4vZqe5JyXHUwMDFkqytLPCFdUXJV4CvluDJwhXd7XHUwMDFiXHUwMDE1eFqUXHUwMDAy41x1MDAxODdQwtPa9+91bHs6ml6iY/PL9mR20b6kdVh1r9NOhin1cdJ9/LlFMWyjSs7txfNePz2f37866/Hs+472XFzfMavVQztcdTAwMTdhl4XgXHUwMDFmqym/bI97IUpMrkaj9XmbdIt5u3Ojg1x1MDAxYtU1eVpVdXXRbdulJ5FcdTAwMTBGXHUwMDA3nudcdTAwMDTOaqVG/cnwfnWjaTJ8QFpm8/b8XG7Vf77oTbr9SXpHRmx/P7uiIzw36VxiY4Rvek7Q80xXdYw88410gm7S6/ZEt7suQ4Sm3i1cYvDPl1uh+PRJXHUwMDE23/7Bn//828NIuewlcyspXHUwMDBmoEW6ciNagiCQytfCfzJawt1j9eV67+zotJxcdTAwMWTNK4E8/tGS74VcdTAwMTbxMrSIQJaMJ1XgXHUwMDEyNVxiXHUwMDEzmDtoMZ4pKS/wffpPe56437FbXHUwMDE0/Jfs4d+XI8V1XHUwMDAzwiW15PuBUUL5P8NGXHUwMDFiU9La8YzWgXD1T1x1MDAxMFxuXGI/Qiklflx1MDAxZkL/eytkN3Kkiyv/fFx1MDAwMbKMp5XytHxcdTAwMDayXHUwMDFlXHUwMDE16XlvOX9YmvUmaVbad6TWUj1ZmFx1MDAwN85g+cd0/r2uXHUwMDE3c+2Yqdjd82ZcdTAwMWad+oncjevRP1JcdTAwMTChundkmditRIAmveC6jvLU28kygaqkpWuEq1x1MDAxZWB/pe9cdTAwMGKu9Fx1MDAxY1x1MDAwN332/zXJ/2w6mVx1MDAxZvZzXHUwMDE2M3Hn6tf2uD/K7shcdTAwMDDLLk1giyyhT9ujq9m8d/n5zt3yqJ9Cmj+Pemd3xXzeJ31we3s+vVjdTai1dn/Su/x5dqaX/bQ/aY9cdTAwMWGPtkzj7e3eLJEsSWdt9Wc93GWd8HJVo9bI81x1MDAxZTh911x1MDAxM1x1MDAxZSnF1eT9XG6c41x1MDAxZvPUnU6Xrpr90c6c5VkjXHLMq4Kz256d915cdTAwMTedRIMl7flkhbpkfml/hYOMXHJXt+RcdTAwMDa+o0TgOtKR5l7PXlx1MDAwZp7KiJI2jpZcdTAwMDFJvGNcdTAwMDK9aupcdTAwMTakzoo7XG6QKmKXQChvXHIj76tebsusSm/SXHRrklL5Y28m00p4eP3Hl8Ghic5GX5yjz7fP/fPGhnpFYniZ7lxu3E3wIGkh1pZrjs2v4PHwoD84PFx1MDAxYyFLvlwifLgumVx1MDAwYvJcdTAwMWU6jC7Jd0GH9HWJLFx1MDAxNuU6T9ReOvBdstnMK5hdL1ReRCyOXHUwMDE0UjxDRn9Pee23L7q960/VyXX/cjpcdTAwMTnfmcc7KiyhW+ta5iclNu53u+v64q5cdTAwMWX7XHUwMDE1z99XbY/16zVcdTAwMTTcxqiD1JuVm9FGudI8XHUwMDFkvcGXi6P2WM8rsalO27ODpOx/vf7glqcybklcdTAwMWGh4JtcdTAwMThf+avZRVx1MDAwNa5cZlxiva4naSqU9EywXHS9r1x1MDAxMnRQXHUwMDBm6LO1azf6zCNcdTAwMWTsiDX3dlx1MDAxZLYrXHUwMDA15Z5cdTAwMWRcdTAwMWNcdTAwMGac9ld3q7NcdTAwMTVMx9/PJ9HB1zVN83m4uChvl0W/VWv+aGxccvZcdTAwMDb9Tv/kRsN8dNv1SYFcdTAwMGLZ65BcdTAwMDcklfSpXHUwMDE1r6PIrjeJXG46XGJcdTAwMTAkriRfI5Gu53iPXHUwMDA1Lp5cdTAwMWS32KgovY1Qk+Q2XHUwMDEzXHUwMDFiK/N0QzLxut/Sy9p5a8/Zalx1MDAxZXcvv+59XHTiXHUwMDBmjjXpeOTIXHSPXHUwMDE2xDyANVx1MDAxM5RcXDhTXHUwMDA0tEex9vt2pDAlx1x1MDAxN8Lz5dM0pZHGNZ5r/v+G3G9o3C/frjq9L1F7Qmronb3GRzvw9s6j2Vx1MDAxOKd0vMBox/eeXHUwMDFl2SlcdTAwMDcnf/xIx071u5+fqeOLef9q4X5065gg9piCXHUwMDE1qlx1MDAxNJgnKNjfXHUwMDA3ves/XHUwMDE00vfpqlj7ZzXKm1x1MDAxOI/rONS9YIP7+Fx1MDAxNth+RS/TXG7OzunZt+ZwtPyRnGyVvcnRwfeeXHUwMDE2d53OXHUwMDFiaW5fXk5cdTAwMTd/opu50VJ118B5X32Sh++Ss/lcZij1T+PyMD+efPtcdTAwMTF/6ybLxTxRV95cdTAwMDeHXHUwMDEyuWol3oFcdEyglXPXz3SlKFx1MDAxOWWU8KVPXCK7tj3y51mqUpD15Srqz5/mYWoy9Vx1MDAxYyPMM8TzeSZmoNq+L3s6IMuSzFx1MDAxNm2wXHUwMDBmon0jPNfxzno9X/WM13OSVzUxLUhcdTAwMWayMcUjKDE+mV3SPMPIfJw3Xlx1MDAwNSXT+XxcdTAwMTNKXrYxRkZdyVx1MDAxMKUjLENcdTAwMDb/fZhcdTAwMTin5GtidEVcdTAwMWHJl3LjLvJv61x1MDAxYq3IdTRKKk3emu9cdTAwMWHzgPJcdTAwMTEll1x1MDAxMOtcdTAwMTJkXHUwMDFkJV3hXHUwMDA33k9cdTAwMTByfENcdTAwMTax0W9cdTAwMTK8VL9WK48hS/u+cF6IrMv5Vt+C6k7Hilx1MDAwNFxui62rpNlo/Jh6leQ6XsT+8lvli9Hr5vTZNGGAflx1MDAxMSXhXHUwMDA1Tlx1MDAxMHiavFx1MDAwYlc5a1x1MDAxYp6YsfZcdTAwMDXos+RcdTAwMTFcdTAwMTP4nvDId9ZGOj9ccp5Q/utOPW6ErXWK+iRcdTAwMDJXa8dcdTAwMTVk+viG+OCnPklyREhcZoUxxvVcdTAwMDPf1z8r+lF7Nt+ejsd9XHUwMDAw5fu0P5nfn2SezTIo4bzX7lrB7N+zSGlov3jiXHUwMDAyVd+1JFbfPq1Axj9uv//jb1x1MDAwZj69Wfi5+E9iv6rvSfy3ycU2cmNcdTAwMGVccklGQGTwjGhW5H6b++Xj6kQ5h2F7vj9cdTAwMWbV5CubXGKvTn6+0SXf08ZcdTAwMGJI4khcdTAwMDOtzGkmP4dM8UBIXyuaXHLfde917Fx1MDAxNY1trUvkMD81XHUwMDEy7VxirYSW7sP+9b+lX3zYS64ue5+Oe51cdTAwMTlqnX9qXFxNJr3R+/rGv+zEm8aeld7oXHUwMDFiI0CkXHUwMDAystuensNzcprLSznJrvy037vezkeLy2T3g8PVI/7XJvCFNkJcdTAwMGLh3M178Fx1MDAxZFNyiUVJtZF3TGblRzDpXHUwMDE1kaiCXHUwMDFl/YU3PFx1MDAxZY1GX05221dX/rR+Xjs72W90zt4g0nXb4lx1MDAwM26vd3SuZo1cdTAwMWZcdTAwMTdcdTAwMTdbjezs4nQ+PCt/n7yW2+t7Snkv2rl6Wuj6LKE1d1x1MDAwNVx0QNLxtON1XFw3kYFr2m1y8cjdTbq+clx1MDAwM/9dQtdkf270KsjM1sJcdTAwMDTBM1x1MDAxMpRazajd9p3R13Nv1PrWb81cdTAwMWHb3z84Ul1k2/luoFx1MDAwMo/Up7vmNzBSXHUwMDExRdJCkVdBWlVcdTAwMDVvXHUwMDE3xXK90lOD1tLBZrSU4uHkpPeH6q/A9Jw0u99TvpXedWe6fF9de7/NN1at/v2rq/RY0IpWT49cdTAwMDJsJb1l7WrPXHUwMDFj1b5cXH+b7M+2+m2388Hx6lx0t+SRoyc0kmDJJbyDV61USWlPXHUwMDE45ZFfpjcnZbynZvWNY9RGT39cdTAwMDXXva1JPdBnw1rim/P5nin3+u0v71xyVz9w3jCm1lVnXG45zW2/XHUwMDFkiG631+m6Z4F0O1x1MDAwZV3xvXbP6Z5pfea+j+5TXHUwMDFi85tcdTAwMWPjXHTPo54+XHUwMDE5SmN9OTtcdTAwMTTdtL1/rPZcdTAwMDZuzzvo6VdO/3t91eeqkkPaTbpaXHUwMDE5T8q7Rqp2XGJoZFx1MDAwNDhcdTAwMWWpPZ9swzdMcJJINFxmlPS9pylA0n5cIkCG/Fx1MDAwN0HUY7bqodfUP755XHUwMDE3rYOGl87yvis6w8rr2aovxeuL1Ov3y951v7f41DzYf19cdTAwMWT7YMNvv8e7mSE8zyezcH1cdTAwMDPkV1xmkXeal4vysTtcdTAwMWN/c7e3u1tqclx1MDAxY77uwa03Slx1MDAxMHY9Q/DX2OFVd09uuZKMVkNgXHUwMDA0i1x1MDAwNN5cdTAwMWJu8b4oP9hR9KznrrH4v0J6cG1aPYv2w17/MOiGf/T3q2l53P6A6cFKPeI6XHUwMDFh43r0v6fHZFx1MDAxZlx1MDAxZfVcdTAwMDdHhyNUSSpslmiXnDfv7kktVyNcdTAwMDb0XHUwMDBl6Hh2frBSvkc4eo28+d/IXHUwMDBmNka4z1x1MDAxMNLf01xc75ZcdTAwMWb8XHUwMDBimn/v/ODH9ZuzcU/Z+K5RbvCMg8nzo8VhO/le1Zfellue7e1VnbODj45g4/klj0xb4brG97x7XGKGrymIyJTCPoZa2+j9XHUwMDE4XG4ukIboZ1NcdTAwMWPooyq4y2y3fT5uXHUwMDFlbu+eXsz6Xn9a+yNxnq/g9DpO3kTBbUaH73NcdTAwMWWI+/RNjIdcdTAwMDf9wdHhXGKvJH1ptCZdJlx1MDAxY3U31uIpclx1MDAxZt9cdTAwMDVcdTAwMWTPVnAyQHSUjL8/V8O5UnrPkNJ/XHUwMDExXHL3XHUwMDBiov8wJ2B8f2NaviekJmF6xvHOQfI1Md5hbTasd3dcdTAwMWHLP4am+fWDv3bDXHUwMDA0QclcdTAwMGZ0ILSnccTau5uga6QoeY7rKq209lx1MDAxY+9+x175tVx1MDAxYo4sXHUwMDE5TXYn0UggXeE+9Fx1MDAxMo7Nz9zi2vGEoVtcdTAwMGbCeqXC5Ner7SP/7Mf+Wbef6+Z5XtGmup7K04xcdTAwMGbi/GRvf3LcX5x+nYzNNFx1MDAwZWvvXHUwMDFjajXIXHUwMDFjelx0PTxtm9FtXHUwMDBi4Vx1MDAwN0K1zVlcdTAwMTJ0z4x/XHUwMDE2XGK/51xi6Xe6QnaM1zZtkVx1MDAwNK9cdTAwMWFq3Zzj+8hpNEfh9KTzdEtz2qt9/5IsrnVleFo/d5ZcdTAwMTezk/aPj41FT6qSMJ6RUipXeuruLqNcdTAwMTP4JSPoMl6kodaNvz/xMJpcdMj89X95MuZcXIyHji/TndnhdPd01tqbtGe1dah9XHUwMDE4LD5srb5HkFa5Jni7hFx1MDAwMtVBmqHSQnddo7tJW5N1qvWZXHUwMDE3nHW6XHSZTF6v2yNb9V02VdzNSldcbuqW8vTTgf54muiHXHUwMDA0uuuZUlx1MDAxMLieXGLIXHUwMDA2XHL8e+lcdTAwMDSuxpFcdTAwMTlHaun5nv92WXrBM/dTNJn3nr8pnf/fXHLsv3Fo5m+P1fu2WUkuKcn3S6Qof1x1MDAwZj9cdTAwMWT2Lq/f+1DfQ+2+hpewka/kZr5y8HK+Z53jW+xVTs7Sy+Nv4lp9OT/Ls9F85n9swnJ8UVwi4PsyUIp0yF1cdTAwMTffJcNcdTAwMDRv7fNcdTAwMDLX90Fpb8ZYrlNypaO00U+jLENcdTAwMTRKvf1VXHUwMDA21Ifho9eMZv1cdTAwMWWyrT/+vqi+3+bbpki5XHUwMDFid22lXHUwMDE2UrqBI56+MaV6mfnj+2T03feS/PtVbVx1MDAxMv043fvgiVx1MDAxZE5gSlxup1x1MDAwMXyhfc+T9/alXFxd8oVcdTAwMTEqIFx1MDAxMLmPnJR6XHUwMDE1b0PLkuuRh+dcdTAwMTjCtmPWX1x1MDAwMbd6jeDmZ25cdTAwMWR/XHUwMDFjMd544PDfXHUwMDA0z08y94U5U3g9WffszD3rkumvXHUwMDEyst7MmUy6Z/oskV3dXHUwMDEzSS943Vx1MDAxY6rN51x1MDAxMlx1MDAxZH/zNjD5uVx1MDAwMXUweLrF/7hcdTAwMWb2MdGGODiCzb52nUCsv1aHfXvfK5FcdTAwMGbmKrKxSWGtZVC8tlxuxVt0/UAr31x1MDAxMFikUsFcdTAwMDOKVFx1MDAwNk7JJ3WuXHUwMDEx1veIIH5cdTAwMGWxKcd3XHUwMDA0Xn37INL+1HOJnO30ludcdTAwMTJcdTAwMWaPLH26cy5RekL5yiFPziXfKVj5vZ9uz1x1MDAwMJJlpVx1MDAwMVx1MDAwMIG31NFDP1xy/knnXHUwMDEyXHUwMDFmTy282ykhpPak52hpXHUwMDAyRdy+ylxyW/VKllx1MDAwMj9w8VpcdTAwMDasvyN/XpN/yZOJXzbLP9/+WfRXNf4mXHUwMDA3up53//LteSdiXHUwMDA3Xzwn6PG4XHUwMDE3+VEp0JSMI1x1MDAxZG1cdTAwMWOPmHDNT+VDXHUwMDE0XHUwMDFhL199XHUwMDFmXG6Uvlx1MDAxN1xiXHUwMDAxWDpG+c5aNGrFgZ4o6ZUgmPVXKN+83sAzhlx1MDAxMPtcdTAwMWEv0Ht9XHUwMDBlfOmBiidy4OPH7e5xoENL6uFduYHx3MDz11x1MDAxZSvYximRze34hnxpMsy1eOHh7GedXHUwMDE4R8Y92b1cdTAwMDFRIdmVwVx1MDAwM71cImZcdTAwMTakSlx1MDAwMs9cYk1cdTAwMDSu5U+9+lflwI1cdTAwMDDg+z/L/jNJ8JFcdTAwMWSeR97i4irPv5OT+CtcdTAwMTJMlyr/4ndcdTAwMGbSw52vW9uTLT1zg1c+mfLq6Vx1MDAxMlx1MDAxZd6H5CDtXHUwMDEx71x1MDAxNNL339uuSeZgXCJ65OJcdTAwMTBcdTAwMGXcjelcdTAwMTLveTRFu4FyRPBcdTAwMWHG3ss3QslcdTAwMWZ/yzMnf8Z7XFxcdTAwMWWBycbzW47ruo5cdTAwMTTPeItLP1x1MDAxY1//cTCvXHUwMDFkbNdcdTAwMDbmYHE2zKvZ/MOjxC25XHUwMDFiUUJ2WUmvoWSjqfCeIMF741x1MDAxZLyH6k9cdTAwMDRcdHlcdTAwMTDqxa9k+bNB8pdCYX1uX1xcXHUwMDFjUnd6t4qeZrLfLUKYq8F8xlmPrcdcdTAwMTfzL1x1MDAwNfh4XHUwMDEwK0Pr6X/SZGVrfFx1MDAxZffHvcY6cv97dp3+dTlee1+CXHUwMDA13PP/XlxuLWNcdTAwMWKHVv5uv/79TvX/XHUwMDA30U5cdTAwMTKr77uxOs22TOd4eZXkot/ePVx1MDAxMEller2vu7qbOTrKnOtknFxcR4PyXCLaXHUwMDBl8u446Ye73YvT3YPp98PQRJWtRW87TNs7R1x1MDAxN6fqXFysX+uOR6Ou2LvuVUQ/2i7Po0b5Kq7U0zhcdTAwMGaz/UEq4n5ZR4Pwqlapq3Bg/GTnq2hvb1xyv1x1MDAxZu7FUd40+4OhoWeW0bZZxOMqPW/o+WZcdTAwMWFXWldRXs/CSjmNKqg3uao1yiqs1K/iRrRsVIZqf5DkXFw2r+LZZatRx7UsPiyjnjyuhCn1icYk6JrJatvlPDo0XCLKz2f7jdZif0Dj6JezmPpcdTAwMTdcco7O0Sb1W1JcdTAwMWJUTyTo3oLaX9i66in1l9u291x1MDAxZminXHUwMDEyXsWDJvUxos/UdCpVVTs0S4wnXHUwMDFhVLm9jX2sRHS9Sf0rL+PM9jFcdTAwMWEkSyqzrFx1MDAxZIqrqNLKfl1W2LKV01nUaDn7g7KKMpFhTWiO8s3lh1R/NI9y+lx1MDAxY1SdRqUlaZ7ykPpcdTAwMWZhPVx1MDAxYadcdTAwMWLnZr9cdTAwMTFKnsdDUcxtN4rylPrdXHUwMDEyUUZj2TaydvzIulx1MDAwZXjO9H5cdTAwMDPrXHUwMDFhu2F/a9w+Xs5Ixlx1MDAwNlFcdTAwMWWqlmrKsO//9fvu1nl3J01PSc5cdTAwMWGNSHFblTStNajPeVm3MkGyVNY0flx1MDAxYU+V5IXqXHUwMDFihLRcdTAwMTZcdI0pUfuNqqA+YexL6lx1MDAwYj5cdTAwMTfhNq1NntLYQypcdTAwMWJqKp/vXHUwMDBm6rTemO8qrTPJ1SCco96Q1zZ19lx1MDAwZqn+XGbPJTTuqkPzgE9ccjmkeaFxUX9cdTAwMWGRXHQxt41cYjKrbT1RyuswgFxmQ95a9DulOlt8n/pKc1x1MDAxYt6sI36nNPdcdTAwMGXNzZw+XHRvUUoyrOI8mdOcauo/9bepovFijvWIt8tUtmpqO9Vljdc7Qr1cdTAwMGXhKo/yZIWDPtYoTWPgbbA32G9EVM9QU3mD+SF50VRGxVmZ8GKWhNt5rVx1MDAwMblv0diaNIdNJ96pZnGlSeNE30NB/VrGg6KNQ3ErXHUwMDBmmCNaXHUwMDE34oHWMoK8NaI5cIg1oL5Kkmu0aWrbXHUwMDAy/CBcYpPUTl2yLFRoXlx1MDAxYlUq28R6gFx1MDAxZkSt0qJ1XHUwMDFkklx1MDAxY1ZcdTAwMWS6R/1OqV60XHUwMDEzLWJuj57dRvsth+pU4Fx1MDAxZuqHY9ed6yAsXGZpvlKH5zhvkdxhLZpcdTAwMTnVR30oU1x1MDAxZlwi/Macy1qjPo9InmJcdTAwMWVDXdCnQt14hrBAz1x1MDAwMtNVQ9dzbitcdTAwMTM0P3WH5adBc0XrW6P5seuLuqvAhYytzNAn+lpcdTAwMDaHXHUwMDAxU0s7hnpGa0HfsfZpSu1TP1wi8Fx1MDAwN69cdTAwMDFkkWR/XHUwMDExXHUwMDEzXHUwMDFmkozTXHUwMDFjVGnOeVxcaFx1MDAxM2upIFx1MDAxZrVKXHUwMDE18rOkdUrpPv2OUNeCeFx1MDAwMNdcdTAwMDVkgp6z64Yx4ndGbeZcdTAwMTGuq7hcdTAwMDH5pblpQC6prcqQxkHymtdpzZtYXHUwMDFmkocq9X/o1Fx1MDAwZcs0xylkfok1t9chXHUwMDAzZepvXGJcdTAwMWRAn5FD44Is5PS8YpnI8HuY11j26nltZ1x1MDAwMYzSfNJcXGFeXHUwMDE1cdKgibaJiyCnXHSNTeTMJZUkrUFcdTAwMTdcdTAwMDDrgyHkXHUwMDA3fFx1MDAwNVx1MDAwZXJcbn4v+k7zRnxEcoi5p3lFm2Vj5zp17FpEin6rQlx1MDAxZiiLJ1x1MDAwMXxl9nlqu2HHSmPIMSaSXHUwMDFixdy8jb5GkEP6pPnZhkxcdTAwMGZJXHUwMDE2MLeWh1xiu1x1MDAxOXNcdTAwMDC4ssGf4NlcZrJcdTAwMTejzKDKMlx1MDAwNf1GZUlcdTAwMGZcdTAwMDLTVegsWi/CKNZ7gDWgPlx1MDAwMGtccsxJ3ezfrDvphn3mXHUwMDEy5oxFXHJcXI52SVx1MDAxZahdktGyxVx1MDAxMDCYkzxAl9JcXFx1MDAxNXOTY31J9o19Nlx1MDAwNL/hWUnyxlx1MDAxY1Wr8Jou7NqgL1x1MDAxMThxyfJDXHUwMDE44e/ZXHK2WMcuao1cdTAwMTTfM8ggOFx1MDAwNHJb8EIhS+DEKl1cdTAwMGaVnWPSS8RcdTAwMDPcZlx1MDAwNnlcdTAwMDXvXHUwMDAyU1hcdTAwMWbiXHUwMDE1Xr/IyiCVYZzm3OfczpXFXHUwMDEyrVx1MDAxZvBOfMxYwvg1MFx1MDAxYvH6glx1MDAxM6GvmnPqr4SeJlx1MDAxZUZcdTAwMWZkbGXSiVkmMX6WyYWV3aGqXHUwMDFkL7g8eITuXHUwMDEzXHUwMDA3RsvY8iT4T1x1MDAwMF8x46KKPlq5PyQ52y5sh1x1MDAwNsvZMlJcdTAwMGJwXHUwMDBi4YvwxutxNKD6ZY2xUdeFfFLdeD5lLqByKXFxZnkoXHUwMDA1XjRkdp/WMc6YK5aYn1xia1x1MDAwMK5cdTAwMWaAn4Y38lx1MDAwNXuF5pzqXHUwMDA1j1dcbjw3aHwsXHUwMDA3dazF8oaHqV9cdTAwMGWvXHLsK8hqXmV9RXIyp3U1LD9cdTAwMTkwloBcdTAwMWJ13Fx1MDAxOKJvi1x1MDAxODJcdTAwMDd5Zb2XYG2onVx1MDAxNttcdTAwMTNUnmQnXHUwMDAxtjSvd5/1rMN9J11s5YN1aVx1MDAxNrHOSeY8Xq6vNbfrQd95rXh+citHUcayvY3vZaubraxlsE+srmY5Rt+IXHUwMDBiI3Ah1sOugeVGQ3qax1x1MDAxMvFaXHUwMDEzvzKWXHUwMDEzYWUhIVx1MDAxYlximE7BXHUwMDFkorDHMNdkf7Csgv9cdTAwMWTIL3iR7Fx1MDAxMWCcflx1MDAwZuk56je1XHUwMDA3XHUwMDFigvpcdTAwMGU7xOqCSlx1MDAxM/pcdTAwMGJcXMrleS7BySyDXHUwMDExZD1HPzqQz1x1MDAxYzxxSrZcdTAwMTD4ISrknWzWjPWAXHUwMDEzgb9Zb4bA86JWrFx1MDAxMbg8qnTPXHS/6Fx1MDAxYnBg6Fx1MDAxZdlH1m4reFWwXGY1WnlcdTAwMGLPZ2XWT5a3W2RjYbwkg7xewFx1MDAxOGSyVXBcIvQy5q6Zsk5nOVxyoctojetk96Is27M52/g8NshcIuxI0kHET6TPXHUwMDBiXsF60pi2WediTVx1MDAwNOODf1x1MDAxN3qMbJZcdTAwMWHw1uc1kHZcdTAwMWWAT3o2XHUwMDBmU+acXG5/ZswhtL7R7fqGzCkx94U4/1x1MDAxMNiGXHUwMDBlYLlyWC54PVJtZTXMwDNcdTAwMTGN3dqPlp/jSmJg39VcYiPWNsF6soxcdTAwMTOWbmRcdTAwMWNcdTAwMTiy60tzYyyGQrYzSM6XbGeA55nvI75OmJzzWNl2qcJm12wnXHUwMDFj4jOFLoJcdTAwMWMpyDHNtYS88FxcQK5g60F+XHTbtVxut2f56pDsfvBBXHUwMDFmfHVjoyRcdTAwMGXwZnVjU690XHUwMDEyc6ogvEjL563c8lxcQrpcdTAwMWFYXGItl0BcdTAwMDejXHUwMDFk64fReOtcdTAwMTKcXHUwMDA0XHUwMDE5rlWG5PPBvlx1MDAxZFJcdTAwMWbYTlx1MDAwMVx1MDAwNpn/qFxy9FNZf1x1MDAwNHJC9lx1MDAxN/WNdFx1MDAwNGSH8I65SdPCLrf8ynxcdTAwMTjOWVx1MDAxNizussLWXHUwMDEz1latgq/yQnYwT4tcdTAwMWHLYDVcdTAwMDPuYqz7gG1cdTAwMTnoM3BcdPOeravu2GdcdTAwMTPqO9eVWTnl38A2+2wx610rW2xvXHKgK0NwJORcdTAwMWb8kPOaW1x1MDAwZVx1MDAwMZ40jzvDmPCb/Tdj9SF+t1x1MDAxNHM76/BcdTAwMTb0ubZ8XHUwMDAyPVx1MDAxZlr5trJsYvZcdTAwMGaSXHUwMDAyo3Z+bnSxtTvB1zyGgsdpXHUwMDBl2C6F3Vx0WyXJo5XOn1x1MDAxNzqf7VxcXHUwMDFhXHUwMDFi1jpje5ptQujahH0wtn2tLlx1MDAwNZ+SXHJK9j/sjFx1MDAxY7o6gVx1MDAwZVrynNLvyPq2Vlx1MDAwNlx1MDAwN4VtU0nQhmFdulxyXHUwMDE5gi3Eflx1MDAwN9tcdTAwMDdWXHUwMDFlXCJrV1x1MDAwZlingE9hL1x1MDAxMu+wXHIlXG5dXHKdZm2xnVx1MDAwNeTegS6ALVx1MDAxYjXOXHUwMDA3tnzI/iPJsrQ+XHUwMDE15LOOulxuv6BcZu5W0CWwJ2Lm/uFcdTAwMWP6kdZSWJ8wncdcdTAwMWNToL6QjVx1MDAxNfM8tljOrKw2l1x1MDAxNlx1MDAwYpEubD1tsTQkTLJcdTAwMWRcdTAwMGY5XHUwMDE0jFerf1m3RFx1MDAwM7b7cuCFrkmrQ5tz25cqZIt0Z1x1MDAxNX1cdTAwMTJcdTAwMDWng/dhR2uS31xm40S7XHUwMDFjt8DzbGuFrCfAV+B3i03YXsxjS+aHXGayRtzaqGe0fqpmuTCz9lNcdTAwMDK5z1x1MDAwYlx1MDAxZJaxL8e2XHUwMDA0ZFx1MDAxMmvQhD2MtcutXGaFwj6P51x1MDAwNPokMefWLiU/tI+1rFtfO1x1MDAxZmLeqF3Y41W2L1x1MDAwYvshh1x1MDAxY9XAMflwyf5W3mTbk9YkL/ytdM3fctg3ziC3KbdB85Syzd9gWVTWLi1cdTAwMTe8zPzMNiziXHUwMDA11GbO/vh22epo2C2Fz2htQeZTtqOsz96Stt9Dq0PYfk+19b/gR9z4vuDrlkN+RM4ySPJcdTAwMDVcdTAwMGUjO5PagmySTckxXGbGQWbjXHUwMDEztFx1MDAxZcytTbVf2GjAXHUwMDAzx3MyzHdds1x1MDAxZJPf+L5RVuhcdTAwMDdh/VHyp1x1MDAxOJOFX5ojjlFnf4bHXHLbgOaD11x1MDAwMnbIXHUwMDAw/lx1MDAxMM9cdTAwMDFwU3Ap/E3Gn0ScjPxcYsfW0XLsXHUwMDFhQN6xNqRcdTAwMDP7bNfQWMA7dWF1WKLYjlx1MDAxYbBcXJvCls8tVtmHhe21ZF+3kdz48Mb2gedC3PjfzO8nXHUwMDE3c8tcdTAwMGbAN61cdTAwMTHr1ch0XHUwMDE4Q8zt2q41/IeokOvugPmK9DhsXHUwMDBl+Fx1MDAxZrw+22XLb+zvhDd+Smaxy2tcZptqYe3eXHUwMDE2yyF0UZxcdTAwMTWyXHUwMDA2LGGNLY9cdTAwMTV+XHUwMDE0dNdcdTAwMTD+krD2x8GAfaxcdTAwMWNYhl1Xhj0tithazjyEXHUwMDE4Z99iXHUwMDAzMbS4XHUwMDExn7P/b20m+EiFL4qYV1x1MDAxNZi+aV/VKudup4H4XHUwMDFkx1x1MDAwNVx1MDAxNi20M1xiIb9oXHUwMDAzNrHiWFxybFx1MDAxOevj07qlurDDXHUwMDA0sFx1MDAxZFx1MDAxZlx1MDAxMz9cIo6GuF6hb61vyr5cdTAwMTbZdoxtYfmrXGaeXTK+XHUwMDA2dev7c1xcJeGYpcVnXCJbbEeki4JbXGb87VwivpNcdTAwMTVxXHUwMDE32Hm6w23w3C5cIn1cdTAwMTFcdTAwMTO3ZjxusqVrXHUwMDFjXHUwMDA33ZtBd2KNyT5eXHUwMDE2PpQtg/VcdTAwMWRHzKFWLzfRXHUwMDFl/OybdcysjividrDhjqvcn1pcdTAwMTGfILujiCVWwVx1MDAxNTSfbDdyXGbU+s02nkPyfVx1MDAxM2ODTkZcXCq3Nr7IY/hcdTAwMWHQ95i7XHUwMDAx6SjogUaS22e5v47VXHUwMDFkMa1VKGvMxcBIPKDvhKu6jfVcdTAwMTHfU79cdTAwMDXHXHQwfubuqNDbiE8gdso+KuzQRWRjkTKucDyO9Fx1MDAwMa1xznaCZt+0gZhK3eEyWOO8ztxa4L7ww+FTRjx2+Edsi9hYXHUwMDE3cf9cdTAwMTD6RkLP1KzNwXZcIsrb9U1s7L1cdTAwMGZ/u2XXl+NcdTAwMTVDxjF8Q7u+0dLGXHUwMDFkWqmNO1x1MDAwMddNtrVcbjxcdTAwMGKOXHUwMDE5wma39edtcDvs//GC/WTm+Ia1qVknckw8LGyScHmDlWi76CfG2YA83sbYpOWT0MqfvqD5aFx1MDAxNnJcdTAwMDXMNlx1MDAxNc3DkueFeW/osP22XfjvXHUwMDFjK1x1MDAxOc2KWEFh/ybKcpiVcfbFOUaHeDXHMax+y2H7Qlx1MDAxZbBfwVxcvbB1MleBI0hPhFx1MDAwZfeBcFFwP2PPclx1MDAwYuqF/q+yruH9XG72x6GrkqX1IWBHtW701Fx1MDAxNXhcdTAwMTUxOdYxha1Vsz6RXHUwMDEzWV+cZOur28FaXHUwMDBmklwidnSDp3R561x1MDAwZrFNhlx1MDAxOCQ4XGK+PONKXHUwMDE3c73gsXHcXHUwMDFh/WNcdTAwMWQqOL7XYL2trc8+zGO1uGJba1x1MDAwMN2DsXJcdTAwMWPB2qE7XHUwMDEx206EXHUwMDAzY+OyTeg4UbM6T7CPS3ZkPChDd9G4XHUwMDEz61x1MDAwYqhcdTAwMDX0bFx1MDAxMedNmeuZh1x1MDAxYk3Vgj4jncrrw+uZLFhXXCKOzrqs4DiSv5jjXHUwMDFkZFx1MDAxZpP/UWvA58VYWsKOXHUwMDE1MUP2eYE1trN57nZcIpaZXCI2n1x1MDAxN/tKXHUwMDA17lx1MDAxMjtcdTAwMDc5/FX6PLZy2+FcdTAwMTgkx/xcdTAwMTRsPXBRbG1m1COZR1x1MDAxYa1cdTAwMWKehNxbX3y70GFccuwz1JWN1dehPyT70DZcdTAwMWUvXHUwMDExu6ixf1x1MDAxMc25ruNFXHUwMDFlsy9SZ05cdTAwMDLu2lx1MDAxY7ePeYzsXHUwMDFi33wydoFcdTAwMTXWXcpiZNpcdTAwMGZ3Y9E7Xo6+XHUwMDFm7onTk3Oxfzy6Ot05uupWpouaPlx1MDAxOPV26/PW8fLiVFx1MDAxOTfRXHUwMDA358mk7nV26JlDOT09XHUwMDFlTdq7dbczXHUwMDBlss7x19nN893dvfPOJFx1MDAxZXf03rw2dq4746bXXHUwMDFhL69bajZPdveuT/XeKNHxRYfq7O6EXHUwMDFllc3a6ii7eVx1MDAxNn1oa/JwTrZG+8en151JfZ7orVFLjcbt4/i8uzO67lxmLlx1MDAxYa1jJ8d+UEc5Yv+kO2pcdTAwMWZ3p92K6JONvqDrg45aXidcdTAwMDPRXHUwMDBmc+xhYX8jTE/Ho1mHnlx01emY/+2HKY0r66j5XGJ7SsXVMZXPaVx1MDAxY9dt1Zx31WjY3UmDkP3PMO3o01EyPp11dFx1MDAxMoST04tEjfqdnWY/3LF9PVx1MDAxZH+dt4+XXHUwMDBlzWnRR/+v3+2+6V/Xzq9cXPbu7LH7ruN6a294Rlx1MDAwNthBb37Z713//NR69sfT/1x1MDAxYeVL9pyf/6cu32PPOYvv7jfz73t7zYuQeY3+u90z3LtcIllcdTAwMTmtrW1cdTAwMTCOY5lM9kadyUGF5WVNXHUwMDFlSKZu21uXXHUwMDA3ln+Sw4TaScaB7IzrjCP6fkm4mLWPnVF7XHUwMDFjXFx0XHUwMDA2a/d34lnrJM6pXHUwMDBmXHUwMDE3LVx1MDAxNVxchTsjkk8z7+58Jdk5yuxvJ98/OTgnrIySvrzunlx1MDAxY3Bd95/dP6Yxboe3e54r+b1cdTAwMWSf3T+nOSA9K7rkp1x1MDAxZFa23J/n4f6+aZV4Pp5ZO/BoRuVcdTAwMWTS74a5j+xcdTAwMGK6znGoXHUwMDBl2VP7pCtvfj9cXE+V+DBN2Zc4XGavv1x1MDAwZpaL1snBNNypXHUwMDA34Vx1MDAxMHFcdTAwMDfSL+NcdTAwMDWt0bC/n9/b92fbbY905nr5xXWiTyff0//5n0cw5DueXnu7wVx1MDAwNlxm2afuYOjJXHUwMDE5KC/B0PPTW/6DoccxNOtcdTAwMWXHXHUwMDE3pzvNtLN7NGhcdTAwMWZcdTAwMWZQO/G0fXw0O92WXHUwMDAzXHUwMDFh06CdyVx1MDAwMmdLSXpjSHXm7Z3RqLND+kydky6Z9e/JZFx1MDAxNsFfVFx1MDAxMY25iThDXHUwMDExXHUwMDBm5FirQry31jg6h03O/lx1MDAwNPxcdTAwMGXEXHUwMDFhMti/Xyt2v1xiMV7o8zLv+yFuczQ4vVMmxH7kccQ+XHSwwz407ynyniHZXHUwMDE0e+e8XHUwMDE3wbGlql4rd8V7rlXeV5I2XHUwMDFlXHT7XHUwMDAzz7Dvl8LeXGIrVtfzJ7Xb69/D3ljAfsw4/1x1MDAwMLHyRp3xt69JlnLzXGK2XHUwMDAy4Vx1MDAwNZ73K2xcdTAwMTVP3dVPT/2TXHUwMDEzL9JPz/57XHUwMDE2/8HWc7F1XHUwMDFjX3dOtmSXdVx1MDAwZuuiXHUwMDA373H+1vho0N3e0nRPtDHOXHUwMDA26j6/tc1qNFx1MDAwZlx1MDAxOD/sqF6D7mM8XHUwMDEzsq1cdTAwMDah1Vx1MDAwM/fLXHUwMDBmylx1MDAwZpZHOS6P+nf3jJ2D+3pcYnHA82l0XHUwMDFmXHUwMDAzhf6p7aTqabZZoFx1MDAwMuOv/7HHh2XfPnVXrzz1lfMv0ivPfp/9f2T/2bLfnXXU3uhnubfXIfOkX7KWWpKdXHUwMDE2Q1x1MDAwZZeQzZZcdTAwMWWSTFfRt4yvbT8og4izIK7F+52cc2b5XHUwMDFl87OwsaKf72Ev8WdZZ5tpXG68PJHPtTHe+lGeh2XaPnWXz5/6bq9cdTAwMTfx+bNfXHUwMDFj9u8o04hp/SS3LfV1QXVmXHUwMDFkfXR1ur1Wjvzzzvjoj45cdTAwMWFdrd8nmTxPdERtWtltg6v7UrSO92anJ6H9PYnW/IqAeFx1MDAxY3U17z9LNtPyuntcXF/ZTDd9I1x1MDAwZSb//+qU+NzKIcnoTprFu60srqZcdTAwMGKyP2597uK5+ziwse1cZnxcdTAwMWNxXlx1MDAxZO9xWt+CY4ZcdTAwMWNzrpR1eLuPxvdErVx1MDAxMlx1MDAwZjinmPf4m0VuU9P6XGZcdTAwMTXszdc5d9SWq5tojD2SYVx1MDAxZfK+I/I2OG6EfVx1MDAwMth7mc3LTWTI+8Y0c4hcdTAwMTdcIm/N5r1cdTAwMTR7PE2F32Rv5TbPXHL5MV9cdTAwMDdcdTAwMTHn+KSp3a9ETFx1MDAxMbmxZd63s7ZcdTAwMWNyLOy+SnvbOLVj1D3EXHUwMDFlx4LbrbRs7ofNb1x1MDAxMkXsneo8XHUwMDFksK05XHUwMDE4XCKnY857bIjn8D3MXHKXlbaOVLdtLoCJ8/SJPOBcdTAwMWKP/KFf6bbiqTs88OQ3f72EXHUwMDA3nv9asX9HXHUwMDFleGu7bnp6cppx3OF4OTpFnG3MMb35KfW7fbKVt0+iueWCrZvy7D91J3vMXHUwMDFkxFx1MDAxYuPutszYPtuW5F8tL5Ld+ILQ8mBMXHUwMDAw8cnogXhcdTAwMDDH2MfIta7ej1x1MDAwN2DvKcW+XHUwMDBm/DLkXHUwMDE43dd9XHUwMDFkvi9wP33ofqORsm7kvbmnxeKU4zrrR/1cdTAwMWbGhH3qXHUwMDBlJp78eoyXYOL57954O0ys5Fxuua+LcFx1MDAwN/vo5bRzglxcpVx1MDAxNee39FFGNv1Vt0oytFx1MDAxM0CeXHUwMDE1+em6Plx1MDAwZWArWZumUeb8sMhiZs13KN/Hm33m3jmRmGVrq0Lcjlx1MDAxYytZY1sp1DXss1xmWmnMuVx1MDAxZmHGOobzuHhcdTAwMGZlafMsTyPicvAzeFuRnERR3tz0O4/yvYjqgy7Io+FC8J451/fAb957PqhcdTAwMTAvI4dI2fK811x1MDAwYp9dcC5aZZja+qvI25Q13nPHPnVkXCLeXHUwMDE3O1wiLYezXHUwMDA3TVx1MDAwN/l2dlx1MDAxZlx1MDAxMflVLXm7f1DBflMrJ12iYs6bQ/nmMlx1MDAxYUbrv5Xd0yWsrOpTNj9cZvm2TXOTv0m6J+c9oGYkY97/qNN4Wpr3wFx1MDAwNmG6Nlx1MDAxZVVDvlx1MDAxYu991W3eXHUwMDAz4lx1MDAxMIOhzSu4M1x1MDAxZtWF3UNcdTAwMWXRfCBcdTAwMGY95XziXCJcdTAwMTdcdTAwMDR1cm63vVZP7ZriO81fPlx1MDAxNEWOXCLfK+RF1zhPIbn5LWmtKsX3XGY535zfdVu2XFzUudUmLlre9JX+s/v5w0jw+Fx1MDAxYlx1MDAxMfJRMfdcIrJ2SF7jPJVyflNHPafPPDK8d0S8XHUwMDE1I05UQW7gMKdy2NtFTkox7qHDuarNxdq1lkOysnz0N/pSOSDZrN7KYo1zPuqiUXmg/6OL2PYpZDmN8z2at/qSc3My/EZcdTAwMWVcdTAwMTTOmiSC9z1cdTAwMTH/yVx1MDAxM85cdTAwMTXFtZrNXHUwMDBiwDXBe0PDhcNncWx+sIHsYH7J/sl4XHUwMDFmkWSxhlxczVx1MDAwMfZcZoeS99aRf1FcdTAwMTlyLj/Jolx1MDAxM3M+gt2jtvvLpyiPXHUwMDFjJuTTSbLpIDske0PF51B4f5hzS5GX4fB+IMtcdM5twCZcdTAwMWFRecg679lhP1xme+o35Wm+qijP525oXHK4vM1FpPKcm4T+t3gvma9cdTAwMTV5XHUwMDBi9lrI+atcdTAwMTHyOivYc1x1MDAxZCq7v1x1MDAxYqJOh3NPqlx1MDAxMdVcdFlIOFeBz4tcdTAwMTBebHnOXHUwMDE5lLyfynLG5W9wyOVobmw5zpNJi3KcNyY5zyWHXHUwMDFkt95u1dq7RfmIzys1+excdTAwMDfJd7o2XHUwMDE25D4scVx1MDAwZcTOXHUwMDBm9vhQZ8r73/V8fX6QW5Ni39vOL++DNpe8PsSVtjznXHUwMDBlSs6LakTLonyxPpzzXHUwMDAwjuX1RVx1MDAwZVx1MDAxMpXPbE4kymN9q7b8oM7ySuVX8tGA3dtCLiaVRy5cdGKWTexj5vQsla+r4mxcYnGPXf/GmnxcdTAwMTI32fqrkcZ5u4j3ppFcdTAwMWKDPDDiLpz9IZnlPVictcqRK5EsOVx1MDAwZpu5LbL5aiiPc1xyXHIuj/nj/HNeXHUwMDA3sjX43Fx1MDAxOPNcdTAwMDfOcSTcn1x1MDAxYfshraxBWLf5XHUwMDAwTeZcdTAwMDPqXHUwMDFm8qe05TG263Pun61LYFx1MDAxZYinlthcdTAwMGbnvOtcdTAwMWM4seex1vtm9VdcdTAwMWRcXKQ5XHUwMDE3XCJH3yBcdTAwMWbwiVroy01eMuGZc7VQPq/Zc0vAbnY8XHUwMDE0a5xbL/KfXHUwMDBlXCLbr1x1MDAxYp1W5ZyzOulcdTAwMWTOf+/TNc5nxnmJpr7VKyxfnJtN7d/RK2u/64pz71x1MDAxYjxcdTAwMTe53TOvQ0a15YPbdlx1MDAwNZ9ryevi+Od4nFx1MDAwNlx1MDAxZtGc5Cx7NHdYe8w18oitr1x1MDAxM1x1MDAxNZxbdnhO+WxPxOMvclx1MDAxYYs5XHJlnXjN5k9C93O+ouJcdTAwMWOsnPlK8rxw7iPOoUZOkSMsbI4un1x1MDAxZEU+sNVlNldcdTAwMGW6kDCXXCKXWlx1MDAxNnjTkMdcdTAwMWHLc9Wx+lx1MDAwNzkxsFxyhrnNQahcdTAwMTZcdTAwMTjmNXU4R7+CvLom2udzUcivqPGZiiZhXHUwMDAyOYVcXOfC5oqAj1x1MDAxMs5LXHUwMDAyXHUwMDA2bmW4wvnOJmpinpC/XHUwMDA02eJ5XCLOJozmdeZQ5MJHXHUwMDE1zvnFOUecybS/XHRTXHUwMDBmrIHD+a2Htk3MXHUwMDAz4TbjfDrO60GeXHUwMDA3ctPKjj2rwLiHPJNNXHUwMDAzLlxub3L/iz4zbiGbXHUwMDBl5y9cZqqq0LN2XHUwMDFkXHUwMDFhTeTGL1x1MDAxOdfATY68XHUwMDE09smVzWHCPPPa2ryiQci5MLUmyqcoj7xcdTAwMTj0XHUwMDBmNmFqdV1rybI8QL4mn+3kc4/IXHUwMDE5Kc5cdTAwMDTnnPeEfCq7rszNNqdcbnPYXFxy/kxcdTAwMDPcXGaeSJGPzpxeI44r1pX00LDQXHR1VYNcXFx1MDAwMG9cdTAwMTWcr+D5UOCBXHUwMDFh58DUc7uGLc4/oWua94d4LknmjqZPjdW5gedp71exOvvU7aH3f/7ln/9cdTAwMGbX67+/In0= + + + + + Your ClusterLapdev EnvironmentLapdev-Kube-ManagerSecure Websocket TunnelDevboxPreview URLLapdev EnvironmentLapdev EnvironmentAPI ServerLapdev \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..820e9ea --- /dev/null +++ b/docs/README.md @@ -0,0 +1,59 @@ +# Introduction + +Kubernetes makes deploying apps easy, well, sort of. But that 100 microservices app makes the development flow hard. You can't fit the whole app on your workstation, without pain. The most sensible way is probably running the development flow in Kubernetes itself. Have a pipeline to build and deploy your code changes. Ouch, the feedback is slow. + +Tools like Skaffold can sync your code changes to pods directly, and tools like Telepresence can intercept traffic to your local machine which enables you to do local debugging. That makes things feel like good old local development again. But. + +**How do you manage all the Kubernetes resources?** The deployments, ConfigMaps, services, secrets - who maintains them? Do developers share the same resources, or does everyone get separate environments? If they're separate, how do you keep them in sync? How do you ensure changes to the app propagate to all dev environments? And what about domains, DNS, and HTTPS for developers to actually access their work? + +## Why Lapdev + +Lapdev Kubernetes Environment solves all these pains so that you don't have to. + +### **Seamless Environment Management:** + +Lapdev reads your **production Kubernetes manifests directly from your cluster**. Just tell Lapdev which workloads your app needs, and it automatically replicates: + +* The workloads (Deployments, StatefulSets, DaemonSets, etc.) +* Associated ConfigMaps and Secrets +* Services and networking configuration + +**Your production manifests become the single source of truth** - no duplicate YAML files to maintain, no config drift between prod and dev. + +### Automatic Sync with Production + +* Lapdev continuously monitors your production manifests for changes +* Get notified when ConfigMaps, Secrets, or deployment specs are updated +* Pull updates into your environment with one click, or enable auto-sync +* **Never again:** "My dev environment is 3 months behind prod" + +### Flexible Environment Models + +**Isolated Environments:** Each developer gets a complete, independent copy of all workloads. Perfect for testing breaking changes or complex multi-service interactions with full isolation. + +**Branch Environments (Cost-Effective):** A shared baseline environment runs all services once. Developers create lightweight "branch environments" for only the services they're actively modifying. Lapdev automatically routes your traffic to your version while everything else uses the shared baseline. + +### Local Development with Devbox + +Lapdev includes **Devbox**, a CLI tool that integrates seamlessly with your environments: + +* Intercept cluster traffic to your local machine for real-time debugging +* Use your local IDE, set breakpoints, and see live logs +* Transparently access in-cluster services (databases, caches, internal APIs) as if you're running inside the pod +* No complex VPN or tunneling setup required + +### Preview URLs with Zero Configuration + +Every environment gets: + +* A unique URL with a preconfigured domain: `alice-checkout-feature.app.lap.dev` +* Automatic HTTPS +* Traffic proxied directly to your in-cluster services +* Optional access control - configure URLs to be accessible only to Lapdev logged-in users +* No firewall changes, no manual Ingress configuration, no cert-manager setup + +Share your work with teammates, PMs, or QA instantly - they can test your changes without any cluster access or VPN. + +### Easy Installation in Your Cluster + +Lapdev requires just one deployment `lapdev-kube-manager` installed in your cluster. That's it. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 0000000..f3d3cbf --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,21 @@ +# Table of contents + +* [Introduction](README.md) + +## How to guides + +* [Connect Your Kubernetes Cluster](how-to-guides/connect-your-kubernetes-cluster.md) +* [Create an App Catalog](how-to-guides/create-an-app-catalog.md) +* [Create Lapdev Environment](how-to-guides/create-lapdev-environment.md) +* [Local Development With Devbox](how-to-guides/local-development-with-devbox.md) +* [Use Preview URLs](how-to-guides/use-preview-urls.md) + +## Core Concepts + +* [Architecture](core-concepts/architecture/README.md) + * [Traffic Routing Architecture](core-concepts/architecture/traffic-routing-architecture.md) + * [Branch Environment Architecture](core-concepts/architecture/branch-environment-architecture.md) +* [App Catalog](core-concepts/app-catalog.md) +* [Environment](core-concepts/environment.md) +* [Devbox](core-concepts/devbox.md) +* [Preview URL](core-concepts/preview-url.md) diff --git a/docs/core-concepts/app-catalog.md b/docs/core-concepts/app-catalog.md new file mode 100644 index 0000000..4b9c487 --- /dev/null +++ b/docs/core-concepts/app-catalog.md @@ -0,0 +1,72 @@ +# App Catalog + +The **App Catalog** is the foundation of how Lapdev understands and manages your application in Kubernetes. + +It defines **which workloads belong to your app** — such as deployments, statefulsets, and their associated configuration — and acts as the **blueprint** Lapdev uses to create consistent and production-aligned development environments. + +### Why App Catalogs + +In most Kubernetes setups, application configuration is scattered across multiple manifests or Helm charts.\ +Developers often need to manually manage which workloads belong to which app, and this can easily lead to configuration drift between environments. + +The App Catalog solves this by: + +* **Defining your app once** — directly from your production workloads. +* **Reusing that definition** to create any number of environments (personal, shared, or branch). +* **Keeping everything in sync** with your production manifests — no duplicate YAML or manual updates. + +### How It Works + +1. Lapdev connects to your **source cluster** and reads all workloads running in it. +2. You select the workloads that make up your application (for example, `frontend`, `api`, and `database`). +3. Lapdev groups those workloads — along with their related **ConfigMaps**, **Secrets**, and **Services** — into an **App Catalog**. +4. The catalog is stored in Lapdev and can be reused to create environments on any connected cluster. + +> 💡 The **source cluster** is typically your staging or production cluster.\ +> You can also use a dedicated cluster that mirrors production manifests. + +### Relationship Between App Catalogs and Environments + +| Concept | Purpose | Example | +| --------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| **App Catalog** | Defines what your app is — a collection of workloads and their configuration. | `checkout-service` (includes `checkout-api`, `payment-api`, `frontend`) | +| **Environment** | A running instance of that app in a specific cluster. | `mia-checkout-feature` (personal env) or `staging-checkout` (shared env) | + +You can think of an **App Catalog** as a _template_ and an **Environment** as a _live deployment_ of that template. + +### Editing an App Catalog + +After creating an App Catalog, you can modify it anytime: + +* Add or remove workloads +* Update its description +* Sync it with your source cluster to reflect production changes + +Lapdev automatically tracks these updates and keeps associated environments consistent with the latest configuration. + +### Example Workflow + +1. **Connect clusters** to Lapdev (source and target). +2. **Create an App Catalog** from your source cluster’s workloads. +3. **Use the App Catalog** to create environments (personal, shared, or branch). +4. **Sync updates** when production manifests change. + +\ +&#xNAN;_Example: Selecting workloads and naming an App Catalog._ + +### Benefits of App Catalogs + +✅ **No config drift:** Use production manifests as the single source of truth.\ +✅ **Reusable blueprint:** Create multiple environments from the same catalog.\ +✅ **Simplified management:** Centralize workloads and configuration in one place.\ +✅ **Scalable:** Works across clusters and environment models. + +### When to Use Multiple App Catalogs + +You can create more than one App Catalog if: + +* Your organization manages multiple independent apps (e.g. `checkout`, `search`, `analytics`). +* You want to separate internal tools from core services. +* Different teams own different parts of your system. + +Each App Catalog can have its own lifecycle and permissions. diff --git a/docs/core-concepts/architecture/README.md b/docs/core-concepts/architecture/README.md new file mode 100644 index 0000000..49239f8 --- /dev/null +++ b/docs/core-concepts/architecture/README.md @@ -0,0 +1,65 @@ +# Architecture + + + +This document explains how Lapdev works and how its components interact to provide seamless Kubernetes development environments. + +### Overview + +Lapdev consists of three main components: + +1. **Lapdev API Server** (SaaS) - Manages users, authentication, and orchestrates environment creation +2. **Lapdev-Kube-Manager** (In your cluster) - Reads production manifests and manages dev environments +3. **Devbox CLI** (Developer's machine) - Enables local debugging with cluster connectivity + +### Architecture Diagram + + + +### Component Details + +#### Lapdev API Server (SaaS) + +The Lapdev cloud service handles: + +* **User authentication and authorization** - GitHub/Google OAuth, team management +* **Environment orchestration** - Receives environment creation requests from users +* **Secure tunnel management** - Establishes websocket tunnels between your cluster and Lapdev +* **Preview URL routing** - Routes traffic from preview URLs (e.g., `alice-feature.app.lap.dev`) to your cluster + +**Security:** + +* Communicates with your cluster via secure websocket tunnels (TLS encrypted) +* No direct access to your cluster's API server +* Cannot read your application data or secrets + +#### Lapdev-Kube-Manager (In Your Cluster) + +Deployed as a single Kubernetes deployment in your cluster, `lapdev-kube-manager`: + +* **Reads production manifests** - Discovers Deployments, StatefulSets, ConfigMaps, Secrets, and Services from your production namespace +* **Creates dev environments** - Replicates selected workloads into isolated or shared namespaces +* **Manages sync** - Monitors production manifests for changes and updates dev environments +* **Handles traffic routing** - For branch environments, routes traffic to the correct version of services +* **Establishes secure tunnel** - Maintains websocket connection to Lapdev API Server for orchestration + +**Permissions:** + +* Read access to production namespace (to read manifests) +* Full access to Lapdev-managed namespaces (to create/update environments) +* No access to other cluster resources + +#### Devbox CLI (Developer Machine) + +The `devbox` command-line tool enables local development: + +* **Traffic interception** - Routes requests for specific services to `localhost` +* **Cluster connectivity** - Provides transparent access to in-cluster services (databases, APIs, caches) +* **Secure tunnel** - Establishes encrypted connection to Lapdev API Server, which proxies to your cluster + +**How it works:** + +1. Developer runs `lapdev devbox intercept checkout-service` +2. Devbox establishes secure tunnel: `Developer → Lapdev API → lapdev-kube-manager` +3. Traffic for `checkout-service` is routed to developer's localhost +4. Developer's code can access cluster services transparently (e.g., `http://payment-service:8080`) diff --git a/docs/core-concepts/architecture/branch-environment-architecture.md b/docs/core-concepts/architecture/branch-environment-architecture.md new file mode 100644 index 0000000..cc60034 --- /dev/null +++ b/docs/core-concepts/architecture/branch-environment-architecture.md @@ -0,0 +1,117 @@ +# Branch Environment Architecture + +Branch environments are a cost-effective way to run development environments in Kubernetes. Instead of duplicating all services for each developer, branch environments share a baseline and only run the services you're actively modifying. + +### Architecture Diagram + + + +### Concept + +Without Branch Environments: + +``` +Alice's environment: 100 services (all running separately) +Bob's environment: 100 services (all running separately) +Total: 200 services +``` + +With Branch Environments: + +``` +Baseline environment: 100 services (shared by everyone) +Alice's branch: 1 service (api - her modified version) +Bob's branch: 1 service (worker - his modified version) +Total: 102 services +``` + +### How It Works + +#### The Baseline Environment + +One shared environment runs all your services: + +* Contains a complete copy of all workloads +* Shared by all developers +* Updated when production manifests change + +#### Your Branch Environment + +When you create a branch, you specify which service(s) you're modifying: + +* Only those services run in your branch namespace +* Everything else uses the baseline + +#### Traffic Routing + +When someone accesses your preview URL, Lapdev routes traffic intelligently: + +Example: Alice's branch modifies the `api` service + +Request to `https://alice-branch.app.lap.dev`: + +1. Starts at baseline environment +2. When it needs to call the `api` service → routes to Alice's branch +3. When it needs `web`, `worker`, or any other service → uses baseline + +Example: Bob's branch modifies the `worker` service + +Request to `https://bob-branch.app.lap.dev`: + +1. Starts at baseline environment +2. When it needs to call the `worker` service → routes to Bob's branch +3. When it needs `api`, `web`, or any other service → uses baseline + +#### How Routing Works + +Each request gets a special identifier (tracestate) that tells services which branch it belongs to. The Lapdev Sidecar Proxy in each pod: + +1. Checks if the service being called has a branch override +2. Routes to the branch if it exists +3. Routes to baseline if no override exists +4. Passes the identifier along to the next service + +This happens automatically - your code doesn't need any changes. + +### Visual Example + +``` +Baseline Environment: +├── api (baseline version) +├── web (baseline version) +└── worker (baseline version) + +Alice's Branch: +└── api (Alice's custom version) + +Bob's Branch: +└── worker (Bob's custom version) +``` + +Traffic to alice-branch.app.lap.dev: + +* api → Alice's version ✓ +* web → baseline version +* worker → baseline version + +Traffic to bob-branch.app.lap.dev: + +* api → baseline version +* web → baseline version +* worker → Bob's version ✓ + +Traffic to baseline.app.lap.dev: + +* api → baseline version +* web → baseline version +* worker → baseline version + +### Key Benefits + +* Cost-effective: Only run what you're changing (10x cheaper than isolated environments) +* Instant creation: Branch environments are created instantly - custom workloads are only deployed when you modify it. + +### Limitations + +* Shared databases: All branches use the baseline's database (be careful with schema changes) +* Tracestate propagation: Your application needs to pass the tracestate header in service-to-service calls for routing to work correctly diff --git a/docs/core-concepts/architecture/traffic-routing-architecture.md b/docs/core-concepts/architecture/traffic-routing-architecture.md new file mode 100644 index 0000000..73426e0 --- /dev/null +++ b/docs/core-concepts/architecture/traffic-routing-architecture.md @@ -0,0 +1,67 @@ +# Traffic Routing Architecture + +This document explains how traffic flows through Lapdev for both preview URLs and local development with Devbox. + +### Overview + +Lapdev handles two main traffic patterns: + +1. **Preview URL Traffic** - External users accessing your development environment via browser +2. **Devbox Traffic** - Developers debugging locally while accessing cluster services + +Both patterns use secure tunnels through the Lapdev cloud service, eliminating the need for VPNs or firewall changes. + +### Architecture Diagram + + + +### Components + +#### In Your Cluster + +**Lapdev-Kube-Manager** + +* Orchestrates environment creation and management +* Maintains connection to Lapdev cloud service + +**Lapdev Environment (Namespace)** + +* Contains your replicated workloads +* Each environment is isolated in its own namespace +* Multiple environments can coexist in the same cluster + +**App Workload Pod** + +* Your application container(s) +* Runs unmodified - no changes needed to your application code + +**Lapdev Sidecar Proxy** + +* Injected into each pod in Lapdev environments +* Handles traffic interception when Devbox is active +* Routes traffic between local machine and cluster services +* Transparent to your application code + +#### In Lapdev Environment (Namespace-level) + +**Lapdev Devbox Proxy** + +* Deployed once per environment namespace +* Manages all Devbox intercept connections for that environment +* Routes traffic to the appropriate developer's local machine +* Falls back to in-cluster service if no intercept is active + +#### External Components + +**Lapdev Cloud Service** + +* Routes preview URL traffic to your cluster +* Manages secure websocket tunnels +* Handles authentication for preview URLs + +**Devbox (Developer Machine)** + +* CLI tool running on developer's laptop +* Establishes secure tunnel to cluster +* Intercepts traffic for specific services +* Provides transparent access to in-cluster services diff --git a/docs/core-concepts/devbox.md b/docs/core-concepts/devbox.md new file mode 100644 index 0000000..6e4d10f --- /dev/null +++ b/docs/core-concepts/devbox.md @@ -0,0 +1,50 @@ +# Devbox + +**Devbox** is Lapdev’s local development companion — a CLI tool that connects your local machine directly to your Lapdev Kubernetes environment. + +It bridges the gap between _local iteration speed_ and _production realism_. + +#### Why Devbox + +Developing in Kubernetes usually means slow feedback loops: rebuild, redeploy, and wait for pods to restart just to test a single change.\ +Devbox changes that by letting you **run your code locally while still connected to your real cluster environment**. + +This means: + +* You can test your app against live in-cluster services (databases, APIs, caches). +* Cluster traffic can be routed to your local process for real-time debugging. +* You no longer need complex port-forwarding, VPNs, or separate mock setups. + +#### How It Works + +When you start Devbox inside a Lapdev environment: + +1. It authenticates with Lapdev and connects to your active environment’s namespace. +2. It synchronizes local and cluster networking rules. +3. It can optionally intercept service traffic and forward it to your local process. +4. It provides seamless access to other workloads and in-cluster dependencies. + +> 💡 Devbox doesn’t replace Kubernetes — it _extends_ it for developers.\ +> You keep your production topology and cluster configuration, but develop with local speed. + +#### Core Capabilities + +* **Intercept Service Traffic:** Redirect in-cluster service requests to your local code. +* **In-Cluster Connectivity:** Access internal APIs and databases as if you were inside the pod. +* **Seamless IDE Debugging:** Run locally, attach debuggers, and see live logs. +* **Compatible with All Environment Types:** Works with personal, shared, and branch environments. + +#### How It Fits in the Lapdev Model + +| Concept | Role | +| --------------- | ---------------------------------------------------------------------- | +| **App Catalog** | Defines what your app consists of (the workloads). | +| **Environment** | A running instance of that app in Kubernetes. | +| **Devbox** | Bridges your local machine with that environment for live development. | + +#### When to Use Devbox + +* When you need fast feedback without redeploying to Kubernetes. +* When debugging complex issues that depend on real cluster state. +* When integrating or testing locally while keeping the rest of the system in-cluster. + diff --git a/docs/core-concepts/environment.md b/docs/core-concepts/environment.md new file mode 100644 index 0000000..e9047a8 --- /dev/null +++ b/docs/core-concepts/environment.md @@ -0,0 +1,128 @@ +# Environment + +The **Lapdev Kubernetes Environment** is the foundation of Lapdev’s platform. It’s where you **develop**, **test**, and **demo** your applications — all directly within Kubernetes. + +Lapdev’s unique strength is that it manages the **entire environment lifecycle** — from creation to synchronisation — in a seamless, automated way. You don’t need to manually manage YAML files, sync resources, or worry about configuration drift. + +### How It Works + +Lapdev reads your **production Kubernetes manifests** directly from your cluster and treats them as the **single source of truth**. + +To define your app environment: + +1. Select the workloads you want to include. +2. Lapdev automatically finds and includes all related **ConfigMaps**, **Secrets**, and **Services**. +3. Lapdev replicates these resources into your Lapdev-managed environments. + +The result: every developer’s environment mirrors production exactly — with zero manual setup. When your production apps change, Lapdev automatically syncs updates to all environments, so **everyone stays in sync**. + +### Environment Models + +Lapdev supports two environment models, depending on your workflow and cost requirements: + +* **Personal Environments** — fully isolated, ideal for independent development +* **Branch Environments** — lightweight and cost-efficient, ideal for large teams + +Both support **Devbox** for local development and debugging. + +#### 1. Personal Environments + +A **Personal Environment** is a completely isolated workspace for a single developer. + +Each personal environment runs in its **own Kubernetes namespace**, so: + +* One developer’s work never affects another’s. +* Multiple environments can be created per developer (e.g., one per feature or bug fix). +* You can switch between environments freely. + +Because every personal environment contains a **full set of workloads**, it guarantees total isolation — but that comes with higher resource usage. + +Lapdev helps mitigate this cost by allowing you to **start and stop environments on demand**, automatically scaling down resources when you’re not using them. + +**Best for:**\ +Developers who need full isolation or want to test complex changes safely. + +#### 2. Branch Environments + +To reduce cost while keeping flexibility, Lapdev introduces **Branch Environments**. + +**Shared Environment Foundation** + +Branch environments are built on top of **shared environments**, which are created and managed by admins. A shared environment runs a complete version of your app that everyone on the team can access. + +When a developer creates a **branch environment**, it behaves much like a Git branch: + +* Initially, the branch environment **does not create new Kubernetes resources**. +* It simply references the workloads in the shared environment. +* Only when you modify a workload does Lapdev create a **branched copy** of that specific workload to run side by side with the original. + +This means creating new branch environments is almost instant — and nearly free in terms of compute cost. + +**Local Development with Devbox** + +Devbox allows you to: + +* **Intercept cluster traffic** to your local machine for real-time debugging. +* Use your **local IDE** to edit, build, and debug your service as if it were running in the cluster. +* **Access in-cluster resources** such as databases, caches, and internal APIs transparently — no VPN or tunneling setup required. + +When connected, Devbox runs your local process as part of the branch environment. Other services in the cluster automatically route traffic to your local instance, so you can develop locally while staying fully connected to the live environment. + +This workflow combines the best of both worlds: + +* Fast local feedback +* Accurate in-cluster integration + +**Traffic Routing** + +Under the hood, Lapdev uses a **sidecar proxy** within each workload to handle routing.\ +This proxy decides whether incoming requests should go to: + +* The **shared workload** (default), +* A **branched workload** created by a developer, or +* A **local Devbox process** connected to the environment. + +Routing is based on the `tracestate` HTTP header, which identifies which branch a request belongs to.\ +This enables multiple developers to safely test changes in the same shared environment — without interfering with each other. + +For a deeper technical explanation of the sidecar proxy, routing algorithm, and how Lapdev ensures request isolation across branches, see the [**Traffic Routing Architecture**](architecture/traffic-routing-architecture.md) and [**Branch Environment Architecture**](architecture/branch-environment-architecture.md) page. + +**Limitations** + +Because branch environment's routing relies on the `tracestate` header: + +* Your services must propagate the header through all internal HTTP calls. +* Non-HTTP components (like databases or message queues) can’t be routed and remain shared across branches. + +Despite these limitations, branch environments provide an extremely efficient and collaborative workflow for large teams — combining shared stability with flexible, isolated development. + +### Preview URLs + +Every Lapdev environment can expose one or more **Preview URLs** — public HTTPS endpoints for accessing or sharing your running services. + +Preview URLs are created **per service**. You choose which service to expose, and Lapdev automatically handles DNS, certificates, routing, and security. + +When requests come in through a Preview URL in a branch environment, Lapdev automatically manages the **`tracestate` header** to route traffic to the correct branch environment. + +You can read more about [preview URLs here](broken-reference). + +### Comparison + +| Feature | Personal Environment | Branch Environment | +| --------------- | ------------------------------------- | ------------------------------- | +| **Isolation** | Fully isolated (per namespace) | Shared base, routed isolation | +| **Cost** | Higher | Lower | +| **Ideal For** | Deep testing, multi-service debugging | Large teams, frequent branching | +| **Performance** | Slower to start, full workloads | Instant setup, minimal overhead | + +### Summary + +Lapdev Kubernetes Environments let you: + +* Reproduce production exactly, without manual setup +* Keep all environments automatically synced with production +* Choose between **full isolation** or **lightweight branching** +* Use **Devbox** for fast, local-style development in Kubernetes +* Collaborate efficiently with consistent, shareable environments + +Whether you’re developing alone or across a large team, Lapdev ensures every environment stays consistent, efficient, and production-accurate. diff --git a/docs/core-concepts/preview-url.md b/docs/core-concepts/preview-url.md new file mode 100644 index 0000000..61457f0 --- /dev/null +++ b/docs/core-concepts/preview-url.md @@ -0,0 +1,81 @@ +# Preview URL + +A **Preview URL** is a unique, automatically generated HTTPS endpoint that lets you access your Lapdev environment directly from the web — without any manual DNS or Ingress configuration. + +Every Lapdev environment, whether **personal**, **shared**, or **branch**, gets its own Preview URL.\ +This makes it easy to preview changes, share work with teammates, and test production-like behavior in real time. + +### Why Preview URLs + +In traditional Kubernetes setups, exposing your app for testing often means: + +* Creating or editing Ingress rules +* Configuring DNS records +* Managing TLS certificates +* Waiting for ops to approve changes + +That’s slow, error-prone, and not scalable for dozens of developers or short-lived environments. + +Lapdev solves this with **automatic Preview URLs** — secure, per-environment endpoints that “just work.” + +### How It Works + +When you create an environment, Lapdev: + +1. Detects your app’s exposed **Services** (e.g. `frontend`, `api`, `gateway`). +2. Automatically provisions a **unique domain** for that environment, such as: + + ``` + alice-checkout-feature.app.lap.dev + ``` +3. Configures HTTPS certificates automatically — no `cert-manager`, DNS setup, or manual YAML needed. +4. Routes traffic through Lapdev’s managed proxy layer directly to your workloads inside the target cluster. + +All routing and TLS termination are handled by Lapdev’s control plane, so your cluster stays secure and simple. + +### Domain and Structure + +Preview URLs follow a consistent, human-readable pattern: + +``` +-.app.lap.dev +``` + +Examples: + +* `mia-checkout-feature.app.lap.dev` → personal or branch environment +* `staging-checkout.app.lap.dev` → shared environment + +This makes it easy to tell what you’re looking at — and safe to share with your team. + +### Access Control + +By default, Preview URLs are public, but Lapdev allows optional access control: + +* **Authenticated access only:** Only Lapdev users in your organization can open the URL. +* **Public preview:** Anyone with the link can view it. +* **Custom rules (coming soon):** Integrate with your identity provider for fine-grained access policies. + +Access settings are managed per environment in the Lapdev dashboard. + +### Benefits + +✅ **Instant access:** No waiting for ops or configuring ingress.\ +✅ **Secure by default:** Auto-managed HTTPS and certificates.\ +✅ **Shareable:** Send links to PMs, QA, or teammates easily.\ +✅ **Isolated:** Each environment’s URL maps only to its own workloads.\ +✅ **Consistent:** Same domain and routing system across all environments. + +### How It Relates to Other Lapdev Components + +| Component | Role | +| --------------- | --------------------------------------------------------------------------- | +| **App Catalog** | Defines which workloads are part of the app. | +| **Environment** | A running instance of the app in Kubernetes. | +| **Preview URL** | Provides web access to that environment — with automatic routing and HTTPS. | + +### Example Flow + +1. You create a **personal environment** from your App Catalog. +2. Lapdev deploys the workloads and assigns a Preview URL automatically. +3. You share that URL with a teammate — no ingress, no firewall changes, just click and open. diff --git a/docs/how-to-guides/connect-your-kubernetes-cluster.md b/docs/how-to-guides/connect-your-kubernetes-cluster.md new file mode 100644 index 0000000..12c4d0f --- /dev/null +++ b/docs/how-to-guides/connect-your-kubernetes-cluster.md @@ -0,0 +1,68 @@ +# Connect Your Kubernetes Cluster + +This guide walks you through connecting your Kubernetes cluster to **Lapdev**, so Lapdev can manage development environments inside your cluster. + +### Create a Cluster in the Lapdev Dashboard + +1. Go to the Lapdev dashboard: [https://app.lap.dev](https://app.lap.dev) +2. Navigate to **Clusters** → click **Create New Cluster**. +3. Enter a **name** to identify your cluster (e.g. `staging-cluster` or `dev-cluster`). +4. After creating it, you’ll see an **authentication token** and **installation instructions** for your cluster. + +Keep this token handy — it’s used to securely register your cluster with Lapdev. + +### Install the Lapdev Kube Manager + +Run the following commands in your terminal: + +```bash +kubectl create namespace lapdev +kubectl apply -f https://get.lap.dev/lapdev-kube-manager.yaml +``` + +> 💡 The `lapdev-kube-manager` is a lightweight controller that securely connects your Kubernetes cluster to Lapdev.\ +> It runs inside the `lapdev` namespace and handles synchronisation, environment creation, and traffic routing. You can read more about how it works in our [Architecture doc](../core-concepts/architecture/). + +### Configure Cluster Permissions + +After the cluster is created, you’ll see **permission settings** in the cluster details page. + +These control [**what kinds of environments**](../core-concepts/environment.md) can be deployed to this cluster: + +* **Personal Environments:**\ + Allow individual developers to create isolated environments directly in this cluster. Each developer’s workloads, ConfigMaps, and Secrets will be namespaced separately for full isolation. +* **Shared Environments:**\ + Allow shared or branch environments that multiple developers can use together — ideal for cost-efficient setups. + +You can toggle these permissions **on or off** at any time, depending on how you want the cluster to be used. + +For example: + +* Enable both for a staging or dev cluster. +* Enable only shared environments for a pre-prod cluster used by the whole team. +* Enable only personal environments for testing or private development. + +### Verify Connection + +Once the installation is complete: + +* Go back to the **Clusters** page in the Lapdev dashboard. +* Your cluster should appear in the list with a status of **Active**. + +If the status doesn’t update after a minute, double-check that: + +* The `lapdev-kube-manager` pod is running: + + ```bash + kubectl get pods -n lapdev + ``` +* Your network allows outbound HTTPS connections to `api.lap.dev`. + +### Next Steps + +Your cluster is now connected to Lapdev! 🎉 + +You can start: + +* Creating [environments](../core-concepts/environment.md) +* Using [Devbox CLI](local-development-with-devbox.md) for local development (link to CLI doc) diff --git a/docs/how-to-guides/create-an-app-catalog.md b/docs/how-to-guides/create-an-app-catalog.md new file mode 100644 index 0000000..b19f6ce --- /dev/null +++ b/docs/how-to-guides/create-an-app-catalog.md @@ -0,0 +1,70 @@ +# Create an App Catalog + +An **App Catalog** defines which workloads make up your application — it’s the blueprint Lapdev uses to create development environments. + +This guide walks you through creating an App Catalog from your connected cluster. + +### Prerequisites + +Before creating an App Catalog, make sure: + +* You’ve connected at least one **Kubernetes cluster** to Lapdev. +* The cluster you’ll read workloads from shows as **Active** in the Lapdev dashboard. +* The cluster contains the workloads you want to include (e.g. your production or staging workloads). + +> 💡 The same cluster can be used later for both reading workloads **and** deploying environments. + +### Open the Cluster in the Dashboard + +1. Go to [https://app.lap.dev](https://app.lap.dev). +2. Navigate to the **Clusters** tab. +3. Click the cluster you want to use as the **source**. + +### Select Workloads + +Once inside the cluster details page: + +1. Lapdev lists all **workloads** (Deployments, StatefulSets, DaemonSets, etc.) discovered in the cluster. +2. You can: + * Filter workloads by **Namespace** or **Workload Type**. + * Optionally check **Show System Workloads** to include system components. +3. Select the workloads that make up your app. + + _Example screenshot:_ + +### Create the App Catalog + +1. After selecting workloads, click **Create App Catalog**. +2. In the dialog: + * Enter a **Name** for the catalog (e.g. `checkout-service`, `internal-tools`). + * Optionally add a **Description**. + * Review the list of selected workloads. +3. Click **Create**. + + _Example screenshot:_\ + + +Lapdev will create the catalog and register it in the **App Catalogs** section of your dashboard. + +> 💡 Don’t worry if you missed something — you can edit the catalog later to add or remove workloads. + +### Verify Your App Catalog + +1. Go to the **App Catalogs** tab in the dashboard. +2. You’ll see your newly created catalog listed with: + * The **name** and **description** + * The **number of workloads** included + * The **source cluster** + +Click a catalog name to view its details. + +_Example screenshot:_ + +### Next Steps + +Once your App Catalog is ready, you can: + +* Create a Lapdev Environment from it +* Edit or sync it when workloads change +* Manage environment templates for multiple apps + diff --git a/docs/how-to-guides/create-lapdev-environment.md b/docs/how-to-guides/create-lapdev-environment.md new file mode 100644 index 0000000..ca33109 --- /dev/null +++ b/docs/how-to-guides/create-lapdev-environment.md @@ -0,0 +1,66 @@ +# Create Lapdev Environment + +A **Lapdev Environment** is a running instance of your app inside a Kubernetes cluster. + +It’s created from an existing **App Catalog**, which defines which workloads make up your application. + +This guide walks you through how to create personal, shared, and branch environments from an App Catalog. + +### 1. Prerequisites + +Before creating an environment: + +* You must have at least one **connected Kubernetes cluster** (Active in the Lapdev dashboard). +* You must have an existing **App Catalog** that defines your app’s workloads. + + > If you haven’t created one yet, follow Create an App Catalog. + +### Start from an App Catalog + +1. Go to the **App Catalogs** tab in the Lapdev dashboard. +2. Select the catalog you want to use as the base for your environment. +3. Click **Create Environment**. + +_Example screenshot:_ + +### Select Environment Type + +Lapdev supports multiple environment models depending on your workflow and cost needs. + +#### **Personal Environment** + +* A fully isolated copy of your app, deployed into a per-developer namespace. +* Ideal for local testing, debugging, or experimentation without affecting others. + +#### **Shared Environment** + +* A single shared version of your app that multiple developers can access. +* Useful for integration testing or a team staging setup. + +#### **Branch Environment** + +* Built from an existing **Shared Environment**. +* Includes only workloads you’ve changed; unmodified services reuse the shared baseline. +* Created directly from the **Shared Environment details page** — click **Create Branch Environment**. + +> 🧠 Use **branch environments** for feature work — they’re lightweight, fast to spin up, and cost-efficient. + +### Verify and Access Your Environment + +After creation: + +* The environment will appear in the **Environments** list. +* Lapdev automatically provisions: + * All workloads from the App Catalog + * Associated ConfigMaps, Secrets, and Services + * A unique HTTPS URL (e.g. `mia-checkout-feature.app.lap.dev`) + +You can monitor status, logs, and sync state directly from the dashboard. + +### 5. Next Steps + +Your environment is ready! You can now: + +* Use Devbox CLI to connect locally for live debugging. +* Sync environment configuration with production updates. +* Manage or delete environments when you’re done. diff --git a/docs/how-to-guides/local-development-with-devbox.md b/docs/how-to-guides/local-development-with-devbox.md new file mode 100644 index 0000000..881bb8f --- /dev/null +++ b/docs/how-to-guides/local-development-with-devbox.md @@ -0,0 +1,47 @@ +# Local Development With Devbox + +This guide shows how to set up and use **Devbox** for local development with your Lapdev environments. + +#### Prerequisites + +* Lapdev CLI installed. +* An active Lapdev environment (personal or branch). +* `kubectl` access to the connected cluster. + +#### Install Devbox + +```bash +curl -sSL https://get.lap.dev/install-devbox.sh | bash +``` + +#### Connect to an Environment + +```bash +devbox connect +``` + +Devbox will: + +* Authenticate and connect to the environment’s namespace. +* Set up secure routing between your local machine and the cluster. + +_Placeholder screenshot:_ + +#### Intercept a Service + +To reroute in-cluster traffic to your local process: + +```bash +devbox intercept checkout-service +``` + +After interception: + +* Requests to `checkout-service` in the cluster will route to your local code. +* You can make edits, hot-reload, or debug directly in your IDE. + +_Placeholder screenshot:_ + +#### Access In-Cluster Services + +While connected, you can diff --git a/docs/how-to-guides/use-preview-urls.md b/docs/how-to-guides/use-preview-urls.md new file mode 100644 index 0000000..1a6d403 --- /dev/null +++ b/docs/how-to-guides/use-preview-urls.md @@ -0,0 +1,105 @@ +# Use Preview URLs + +Lapdev lets you create **Preview URLs** for your environments so you can securely access and share running services — without setting up DNS, ingress, or TLS certificates manually. + +Each Preview URL points to a specific **service** inside your environment (for example, your `frontend`, `api-gateway`, or `admin` service).\ +You can create multiple Preview URLs per environment, each targeting a different service. + +### Prerequisites + +Before you begin: + +* You must have at least one **active environment** in Lapdev (personal, shared, or branch). +* The environment must show a status of **Active** in the Lapdev dashboard. +* Your app must expose at least one **Service** in Kubernetes. + +### Create a Preview URL + +1. Open the Lapdev dashboard: [https://app.lap.dev](https://app.lap.dev) +2. Go to the **Environments** tab. +3. Select the environment you want to create a preview for. +4. In the environment details page, scroll to the **Preview URLs** section. +5. Click **Create Preview URL**. + + _Example screenshot:_\ + +6. In the **Create Preview URL** dialog: + * Choose the **Service** you want this URL to point to (for example, `frontend`, `api-gateway`, or `admin`). + * Optionally, enter a **Description** (e.g., “Frontend QA demo”). + * Select the **Access Level** (private or public). +7. Click **Create**. + +Lapdev will: + +* Assign a unique domain like + + ``` + mia-frontend-feature.app.lap.dev + ``` +* Automatically handle routing, DNS, and HTTPS for that URL +* Route traffic directly to the selected service inside your environment + +### View and Open Preview URLs + +Once created: + +* All Preview URLs for the environment appear in the **Preview URLs** section. +* Each entry lists: + * The **Service name** it targets + * The **URL** itself + * The **Access level** (private or public) + +Click the URL to open it in your browser. + +_Example screenshot:_ + +### Share the Preview URL + +You can safely share a Preview URL with: + +* Teammates or QA engineers for quick testing +* PMs or designers for feature reviews +* Automated test systems for integration runs + +Just copy and share the link — no VPN, firewall, or cluster access needed. + +> 💡 Tip: You can create multiple Preview URLs in the same environment if your app exposes multiple services (e.g., `frontend`, `admin`, `gateway`). + +### Manage Access Control + +Each Preview URL has its own access policy. + +To update it: + +1. In the **Preview URLs** section, click the settings icon next to a URL. +2. Choose who can access it: + * **Private (recommended):** Only authenticated Lapdev users can view. + * **Public:** Anyone with the link can view. + * _(Coming soon)_ **Custom rules** for organization-level access. +3. Click **Save**. + +> 🔒 Use private mode for internal branches or unreleased features. + +### Delete a Preview URL + +To remove a Preview URL: + +1. Go to the **Preview URLs** section of your environment. +2. Click the **Delete** icon next to the URL. +3. Confirm deletion. + +Deleting a Preview URL does **not** affect the environment or its workloads — it only removes that specific public endpoint. + +### Troubleshooting + +| Issue | Possible Cause | Solution | +| ------------------------ | ----------------------------------------- | -------------------------------------------------------- | +| Service not listed | The service has no exposed port | Check that the Kubernetes Service defines a valid `port` | +| Preview URL doesn’t open | Service not ready or endpoint unreachable | Verify pods and services are running | +| HTTPS warning | Certificate still propagating | Wait 30–60 seconds; Lapdev handles TLS automatically | + +### Next Steps + +* Learn how Preview URLs work internally +* Use Devbox for real-time debugging connected to your environment +* Explore App Catalogs to define which workloads appear in your environments From a1b56e4ae22ecd51bd89ac2aa50f7c6baea80e8f Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 17:02:31 +0000 Subject: [PATCH 109/334] update --- ...RE_PREVIEW_URLS_AND_BRANCH_ENVIRONMENTS.md | 1344 ----------------- docs/DEVBOX_DNS_HOSTS_PLAN.md | 59 - docs/RPC_ARCHITECTURE.md | 76 - .../branch-environment-architecture.md | 170 ++- .../local-development-with-devbox.md | 44 +- docs/index.mdx | 6 - docs/test.mdx | 6 - 7 files changed, 142 insertions(+), 1563 deletions(-) delete mode 100644 docs/ARCHITECTURE_PREVIEW_URLS_AND_BRANCH_ENVIRONMENTS.md delete mode 100644 docs/DEVBOX_DNS_HOSTS_PLAN.md delete mode 100644 docs/RPC_ARCHITECTURE.md delete mode 100644 docs/index.mdx delete mode 100644 docs/test.mdx diff --git a/docs/ARCHITECTURE_PREVIEW_URLS_AND_BRANCH_ENVIRONMENTS.md b/docs/ARCHITECTURE_PREVIEW_URLS_AND_BRANCH_ENVIRONMENTS.md deleted file mode 100644 index 4253740..0000000 --- a/docs/ARCHITECTURE_PREVIEW_URLS_AND_BRANCH_ENVIRONMENTS.md +++ /dev/null @@ -1,1344 +0,0 @@ -# Architecture: Preview URLs and Branch Environments - -## Table of Contents -1. [Overview](#overview) -2. [Preview URLs Architecture](#preview-urls-architecture) -3. [Branch Environments Architecture](#branch-environments-architecture) -4. [Cost Optimization Through Resource Sharing](#cost-optimization-through-resource-sharing) -5. [Database Schema](#database-schema) -6. [Request Flow Diagrams](#request-flow-diagrams) -7. [Security and Access Control](#security-and-access-control) -8. [API Reference](#api-reference) -9. [Key Files Reference](#key-files-reference) -10. [Design Decisions](#design-decisions) - ---- - -## Overview - -Lapdev's Kubernetes integration supports two key features for development workflows: - -1. **Preview URLs**: Publicly accessible URLs that expose specific services from Kubernetes environments -2. **Branch Environments**: Cost-efficient isolated development environments that share resources with base environments - -These features enable a GitOps-style workflow where developers can: -- Create isolated environments for feature branches **without duplicating entire infrastructure** -- Customize only the workloads they need to modify -- Expose services via unique URLs with granular access control -- **Save significant cloud costs** by sharing unmodified services with the base environment - -### Key Cost-Saving Principle - -**Branch environments only deploy what changes** - unmodified workloads are shared with the base environment. This is fundamentally different from creating isolated namespaces where every service must be duplicated. - -**Example:** -``` -Base Environment "staging" (10 services): - - frontend (1 GB RAM) - - api (2 GB RAM) - - auth-service (512 MB RAM) - - payment-service (1 GB RAM) - - notification-service (512 MB RAM) - - analytics-service (2 GB RAM) - - search-service (4 GB RAM) - - cache-service (2 GB RAM) - - queue-worker (1 GB RAM) - - cron-jobs (512 MB RAM) - - Total: ~15 GB RAM - -Branch Environment "feature-login": - - Modifies: frontend + auth-service - - Deploys: Only frontend (1 GB) + auth-service (512 MB) - - Shares: All other 8 services with base - - Total: ~1.5 GB RAM (90% cost reduction!) -``` - ---- - -## Preview URLs Architecture - -### URL Format - -Preview URLs follow this pattern: -``` -https://{service-name}-{port}-{random-hash}.app.lap.dev -``` - -**Example:** -``` -https://webapp-8080-abc123def456.app.lap.dev -``` - -**Components:** -- `service-name`: Kubernetes service name (e.g., "webapp") -- `port`: Service port number (e.g., "8080") -- `random-hash`: 12-character random identifier for uniqueness - -### URL Generation - -When creating a preview URL: - -1. User selects a service and port from their environment -2. System generates subdomain: `{service.name}-{port}-{generate_random_12_chars()}` -3. Subdomain is globally unique (enforced by database constraint) -4. Full URL is returned to user - -**Implementation:** `/workspaces/lapdev/crates/kube/src/preview_url.rs` - -### URL Resolution Flow - -```mermaid -graph TD - A[Incoming Request] --> B[Parse Host Header] - B --> C[Extract Subdomain Components] - C --> D{Parse Format} - D -->|Success| E[Query Environment by Hash] - D -->|Fail| F[Return 404] - E --> G[Find Service by Name] - G --> H[Find Preview URL Config] - H --> I{Validate Access} - I -->|Authorized| J[Return Target Info] - I -->|Unauthorized| K[Return 403] -``` - -**Resolution Steps:** - -1. **Parse Subdomain** - - Split on `-` delimiter - - Extract: service name (all but last 2 parts), port (2nd to last), hash (last) - -2. **Find Environment** - - Query `kube_environment` table using environment hash - - Currently uses `name` field (TODO: add dedicated hash field) - -3. **Find Service** - - Query `kube_environment_service` by `environment_id` and `service_name` - -4. **Find Preview URL Configuration** - - Query `kube_environment_preview_url` by `environment_id`, `service_id`, and `port` - -5. **Validate Access Level** - - Check user permissions based on access level (Personal/Shared/Public) - -6. **Return Target** - - Returns `PreviewUrlTarget` with cluster, namespace, service, port info - -### HTTP Proxy Implementation - -**File:** `/workspaces/lapdev/crates/kube/src/http_proxy.rs` - -The `PreviewUrlProxy` is a standalone TCP-based proxy server: - -**Architecture:** -- Listens on configured TCP port (e.g., 8080) -- Accepts incoming connections -- Parses HTTP request headers to extract `Host` -- Resolves preview URL to target service -- Opens tunnel to target via `TunnelRegistry` -- Performs bidirectional TCP streaming - -**Key Features:** - -1. **TCP-Level Proxying** - - Does NOT parse full HTTP requests/responses - - Forwards raw TCP streams bidirectionally - - Supports WebSocket upgrades and streaming protocols - -2. **Request Tracking** - - Adds `tracestate` header: `lapdev-env-id={environment_id}` - - Enables distributed tracing through proxy chain - -3. **Connection Management** - - Each connection gets unique tunnel ID - - Automatic cleanup on connection close - - Updates `last_accessed_at` timestamp for analytics - -4. **Error Handling** - - Returns HTTP error responses for resolution failures - - Logs errors with tunnel ID for debugging - -**Proxy Flow:** -``` -Client → PreviewUrlProxy (TCP) → TunnelRegistry → kube-manager → K8s Service - [Parse Host] [Route to cluster] [Forward] [Handle] -``` - -### Access Control Levels - -Defined in `/workspaces/lapdev/crates/common/src/kube.rs`: - -| Level | Description | Authentication Required | Authorization Check | -|-------|-------------|------------------------|---------------------| -| **Personal** | Only accessible by owner | Yes | User must be owner | -| **Shared** | Accessible by org members | Yes | User must be in org | -| **Public** | Accessible by anyone | No | None | - -Access validation happens in `PreviewUrlResolver::validate_access_level()` during URL resolution. - ---- - -## Branch Environments Architecture - -### Concept - -Branch environments enable developers to: -- Create isolated environments from a shared "base" environment -- **Deploy only the workloads they need to modify** -- **Share all unmodified services with the base environment** (massive cost savings) -- Test changes independently before merging to shared environment - -### Environment Types - -| Type | `is_shared` | `base_environment_id` | Purpose | -|------|-------------|----------------------|---------| -| **Base/Shared** | `true` | `NULL` | Production or staging environment shared across org | -| **Personal** | `false` | `NULL` | Private environment owned by one user | -| **Branch** | `false` | UUID of base | Isolated dev environment forked from shared base | - -**Rules:** -- Only **Shared** environments can be used as bases for branches -- Branch environments are always **Personal** (owned by creator) -- Cannot create a branch from another branch (no nested branching) - -### Resource Sharing Model - -**This is the key architectural innovation that saves costs:** - -Branch environments **DO NOT** duplicate all services from the base environment. Instead: - -1. **At Creation**: Only database records are created (no K8s resources) -2. **On First Modification**: Only the modified workload is deployed to K8s -3. **Unmodified Workloads**: Continue using the base environment's deployments -4. **Service Routing**: Preview URLs can route to either branch-specific or base workloads - -**Example Workflow:** - -``` -Base Environment "staging" has 10 microservices: - ├─ frontend (Deployment) - ├─ api-gateway (Deployment) - ├─ auth-service (Deployment) - ├─ user-service (Deployment) - ├─ payment-service (Deployment) - ├─ notification-service (Deployment) - ├─ analytics-service (Deployment) - ├─ search-service (Deployment) - ├─ cache (StatefulSet) - └─ database (StatefulSet) - -Developer creates branch "feature-login": - 1. Modifies auth-service (updates authentication logic) - 2. Modifies frontend (updates login UI) - - K8s Resources Deployed in Branch: - ├─ auth-service-branch-{uuid} (Deployment) ← NEW - └─ frontend-branch-{uuid} (Deployment) ← NEW - - Shared from Base (no deployment): - ├─ api-gateway ← uses base environment's deployment - ├─ user-service ← uses base environment's deployment - ├─ payment-service ← uses base environment's deployment - ├─ notification-service ← uses base environment's deployment - ├─ analytics-service ← uses base environment's deployment - ├─ search-service ← uses base environment's deployment - ├─ cache ← uses base environment's deployment - └─ database ← uses base environment's deployment - -Cost: 2 deployments instead of 10 (80% reduction) -``` - -### Branch Environment Creation Flow - -**Function:** `create_branch_environment()` in `/workspaces/lapdev/crates/api/src/kube_controller.rs` - -**Steps:** - -1. **Validation** - ``` - - Verify base environment exists - - Verify base belongs to same organization - - Verify base.is_shared == true - - Verify base.base_environment_id IS NULL - - Verify cluster allows personal deployments - ``` - -2. **Copy Configuration (Database Only)** - ``` - - Retrieve all workloads from base environment - - Retrieve all services from base environment - - Convert to KubeWorkloadDetails and KubeServiceWithYaml - ``` - -3. **Image Inheritance** - ``` - For each container in workloads: - - If base has custom image: - → Set as original_image for branch - → Set container image to FollowOriginal - - Clear customized env vars - - Preserve original_env_vars - ``` - -4. **Create Database Records (No K8s Deployment)** - ``` - Create kube_environment: - - is_shared = false (always) - - base_environment_id = - - namespace = - - app_catalog_id = - - cluster_id = - - name = - - Copy all workloads and services with new environment_id - - ⚠️ NO Kubernetes resources created yet! - ``` - -5. **Lazy Deployment on First Modification** - - Branch environments exist only in database until workloads are modified - - First workload update triggers K8s deployment - - Saves cluster resources for unused branches - -### Branch vs Base Workload Deployment - -When updating a workload in an environment: - -**Base Environment** (`base_environment_id IS NULL`): -``` -- RPC: update_workload_containers() -- Action: Update existing K8s deployment in-place -- Deployment name: {workload_name} -- Labels: lapdev.environment={env_name} -``` - -**Branch Environment** (`base_environment_id IS NOT NULL`): -``` -- RPC: create_branch_workload() -- Action: Create NEW K8s deployment (only for this workload) -- Deployment name: {workload_name}-branch-{env_id} -- Labels: lapdev.environment={branch_env_name} -- Selector: Unique to avoid conflicts with base -``` - -**File:** `/workspaces/lapdev/crates/api/src/kube_controller.rs:1644-1793` - -### Namespace and Resource Isolation - -Branch environments **share the same Kubernetes namespace** as their base environment but achieve isolation through: - -1. **Unique Deployment Names** - - Base: `deployment/webapp` - - Branch: `deployment/webapp-branch-{uuid}` - -2. **Unique Selectors** - - Each branch deployment has unique labels - - Services route only to their specific deployment - -3. **Independent Lifecycle** - - Deleting branch doesn't affect base - - Deleting base doesn't auto-delete branches (orphan handling TBD) - -**Example in namespace "staging":** -```yaml -# Base environment - all 10 services running -apiVersion: apps/v1 -kind: Deployment -metadata: - name: auth-service - namespace: staging - labels: - lapdev.environment: staging - ---- - -# Branch environment - only modified services deployed -apiVersion: apps/v1 -kind: Deployment -metadata: - name: auth-service-branch-abc123 - namespace: staging - labels: - lapdev.environment: feature-login - -# Note: Other 9 services are NOT deployed in branch -# Branch environment's preview URLs can route to base services when needed -``` - ---- - -## Cost Optimization Through Resource Sharing - -### The Problem with Traditional Isolated Environments - -**Traditional Approach: Full Namespace Isolation** -``` -Production Environment (namespace: production) - - All services deployed (10 services × 1.5 GB = 15 GB RAM) - -Staging Environment (namespace: staging) - - All services deployed (10 services × 1.5 GB = 15 GB RAM) - -Developer 1 - Feature Branch (namespace: feature-login) - - All services deployed (10 services × 1.5 GB = 15 GB RAM) - -Developer 2 - Feature Branch (namespace: feature-payment) - - All services deployed (10 services × 1.5 GB = 15 GB RAM) - -Developer 3 - Feature Branch (namespace: bugfix-auth) - - All services deployed (10 services × 1.5 GB = 15 GB RAM) - -Total: 75 GB RAM -Cost: 5× the production environment cost -``` - -**Issues:** -- Most services in branch environments are unchanged from staging -- Developers only modify 1-2 services but pay for all 10 -- Cluster capacity wasted on duplicate deployments -- Slow environment spin-up (must deploy everything) -- Higher cloud costs (more nodes needed) - -### Lapdev's Solution: Selective Deployment with Resource Sharing - -``` -Production Environment (namespace: production) - - All services deployed (10 services × 1.5 GB = 15 GB RAM) - -Staging Environment (namespace: staging) - - All services deployed (10 services × 1.5 GB = 15 GB RAM) - ← Base environment for branches - -Developer 1 - Branch Environment (shares namespace: staging) - - Only deploys: auth-service (512 MB) - - Shares: 9 other services with staging - -Developer 2 - Branch Environment (shares namespace: staging) - - Only deploys: payment-service (1 GB) - - Shares: 9 other services with staging - -Developer 3 - Branch Environment (shares namespace: staging) - - Only deploys: auth-service (512 MB) + frontend (1 GB) - - Shares: 8 other services with staging - -Total: 30 GB + 512 MB + 1 GB + 1.5 GB = 33 GB RAM -Cost: 2.2× production (vs 5× with traditional approach) -Savings: 56% reduction in infrastructure costs -``` - -### Cost Comparison Table - -| Scenario | Traditional (Isolated NS) | Lapdev (Shared NS) | Savings | -|----------|--------------------------|-------------------|---------| -| **1 developer, 1 service modified** | 15 GB | 1.5 GB | 90% | -| **3 developers, avg 2 services modified** | 45 GB | 9 GB | 80% | -| **10 developers, avg 2 services modified** | 150 GB | 30 GB | 80% | -| **Short-lived test branch (no modifications)** | 15 GB | 0 GB | 100% | - -### How It Works: Service Routing in Branch Environments - -When a branch environment receives traffic via a preview URL: - -1. **Modified Services**: Route to branch-specific deployment - ``` - Preview URL: https://auth-8080-xyz.app.lap.dev - → Resolves to: auth-service-branch-abc123.staging.svc.cluster.local:8080 - ``` - -2. **Unmodified Services**: Route to base environment deployment - ``` - Preview URL: https://payment-9000-xyz.app.lap.dev - → Resolves to: payment-service.staging.svc.cluster.local:9000 - ← Uses base environment's deployment (no branch deployment exists) - ``` - -**Implementation Detail:** - -Currently, when a branch environment is created, services are copied to the branch's database records but NOT deployed to K8s. Preview URLs created for unmodified services will route to the base environment's K8s Service, which points to the base deployment. - -**Future Enhancement:** - -Track which workloads have been deployed in branches and automatically route to base for undeployed workloads. This could be done by: -- Adding `deployed_to_k8s` boolean flag to `kube_environment_workload` table -- Preview URL resolver checks this flag -- If false, route to base environment's service instead - -### Developer Experience - -**Creating a branch environment is instant:** -```bash -# Create branch (no K8s resources deployed yet) -$ lapdev env create-branch --from staging --name feature-login -✓ Branch environment created in 0.5s -✓ Cost: $0/hour (no resources deployed) - -# Modify auth-service -$ lapdev env update feature-login --workload auth-service --image myregistry/auth:feature-login -✓ Deploying auth-service to K8s... -✓ Deployed in 10s -✓ Cost: $0.02/hour (1 service × 512 MB) - -# Create preview URL -$ lapdev preview create feature-login --service auth-service --port 8080 -✓ https://auth-8080-xyz789.app.lap.dev - -# Test your changes (all other services use staging environment) -``` - -**Comparing Costs:** - -| Action | Traditional Isolated NS | Lapdev Shared NS | -|--------|------------------------|-----------------| -| Create environment | 60s (deploy all services) | 0.5s (DB only) | -| Cost while idle | $0.15/hour (all services running) | $0/hour (nothing deployed) | -| Cost after 1 modification | $0.15/hour (all services) | $0.02/hour (1 service) | -| Cost after 2 modifications | $0.15/hour (all services) | $0.04/hour (2 services) | -| Delete environment | 30s (delete all K8s resources) | 1s (soft delete in DB) | - -### When to Use Each Approach - -**Use Branch Environments (Shared Namespace) When:** -- ✅ Modifying 1-3 services in a large microservices architecture -- ✅ Short-lived feature development -- ✅ Testing small changes before merging to staging -- ✅ Cost optimization is important -- ✅ Services can share network policies - -**Use Full Isolated Namespace When:** -- ❌ Modifying most/all services (>50% of workloads) -- ❌ Testing major infrastructure changes (network policies, RBAC) -- ❌ Strict security isolation required (compliance, multi-tenancy) -- ❌ Different resource quotas needed -- ❌ Long-lived environments (months) - -**Best Practice:** -- Use **shared environments** as staging/integration bases -- Use **branch environments** for feature development (shares with staging) -- Use **isolated namespaces** for long-lived QA/UAT environments - ---- - -## Database Schema - -### `kube_environment` - -Stores environment metadata. - -**File:** `/workspaces/lapdev/crates/db/migration/src/m20250809_000001_create_kube_environment.rs` - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| `id` | UUID | PRIMARY KEY | Unique environment identifier | -| `organization_id` | UUID | NOT NULL, FK | Organization ownership | -| `user_id` | UUID | NOT NULL, FK | Creator/owner | -| `app_catalog_id` | UUID | NOT NULL, FK | Source app catalog template | -| `cluster_id` | UUID | NOT NULL, FK | Target Kubernetes cluster | -| `name` | String | NOT NULL | Display name | -| `namespace` | String | NOT NULL | Kubernetes namespace | -| `status` | String | NOT NULL | Running/Pending/Failed/etc | -| `is_shared` | Boolean | NOT NULL | Shared vs personal environment | -| `base_environment_id` | UUID | NULLABLE, FK → self | For branch environments | -| `created_at` | Timestamp | NOT NULL | Creation time | -| `deleted_at` | Timestamp | NULLABLE | Soft delete timestamp | - -**Constraints:** -- Only one base environment per `(app_catalog_id, cluster_id, namespace)` where `base_environment_id IS NULL` - -**Indexes:** -- `organization_id` -- `cluster_id` -- `base_environment_id` - -### `kube_environment_preview_url` - -Stores preview URL configurations. - -**File:** `/workspaces/lapdev/crates/db/migration/src/m20250815_000001_create_kube_environment_preview_url.rs` - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| `id` | UUID | PRIMARY KEY | Unique preview URL identifier | -| `environment_id` | UUID | NOT NULL, FK | Parent environment | -| `service_id` | UUID | NOT NULL, FK | Target service | -| `name` | String | NOT NULL, UNIQUE | Subdomain identifier | -| `description` | Text | NULLABLE | User-provided description | -| `port` | i32 | NOT NULL | Target service port | -| `port_name` | String | NULLABLE | Named port reference | -| `protocol` | String | NOT NULL | "HTTP", "HTTPS", "WebSocket" | -| `access_level` | String | NOT NULL | "personal", "shared", "public" | -| `created_by` | UUID | NOT NULL, FK | User who created URL | -| `last_accessed_at` | Timestamp | NULLABLE | For analytics/cleanup | -| `metadata` | JSON | NULLABLE | Custom headers, rate limits, etc | -| `created_at` | Timestamp | NOT NULL | Creation time | -| `deleted_at` | Timestamp | NULLABLE | Soft delete timestamp | - -**Constraints:** -- `name` UNIQUE (global subdomain uniqueness) -- `(service_id, port)` UNIQUE (one preview URL per service port) - -**Indexes:** -- `environment_id` -- `service_id` -- `name` (for fast resolution) - -### `kube_environment_workload` - -Stores workload definitions (Deployments, StatefulSets, etc). - -**File:** `/workspaces/lapdev/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs` - -| Column | Type | Description | -|--------|------|-------------| -| `id` | UUID | Unique workload identifier | -| `environment_id` | UUID | Parent environment | -| `name` | String | Workload name | -| `namespace` | String | Kubernetes namespace | -| `kind` | String | "Deployment", "StatefulSet", etc | -| `containers` | JSON | Array of `KubeContainerInfo` | -| `created_at` | Timestamp | Creation time | -| `deleted_at` | Timestamp | Soft delete | - -**Container JSON Structure:** -```json -{ - "name": "webapp", - "image": "Custom", - "custom_image": "myregistry/webapp:v2.0", - "original_image": "myregistry/webapp:v1.0", - "resources": {...}, - "env_vars": {...}, - "original_env_vars": {...} -} -``` - -### `kube_environment_service` - -Stores Kubernetes Service definitions. - -**File:** `/workspaces/lapdev/crates/db/migration/src/m20250809_000003_create_kube_environment_service.rs` - -| Column | Type | Description | -|--------|------|-------------| -| `id` | UUID | Unique service identifier | -| `environment_id` | UUID | Parent environment | -| `name` | String | Service name | -| `namespace` | String | Kubernetes namespace | -| `yaml` | Text | Full YAML definition | -| `ports` | JSON | Array of service ports | -| `selector` | JSON | Pod selector labels | -| `created_at` | Timestamp | Creation time | -| `deleted_at` | Timestamp | Soft delete | - ---- - -## Request Flow Diagrams - -### Preview URL Access Flow - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ 1. User Request │ -│ GET https://webapp-8080-abc123.app.lap.dev/api/users │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 2. PreviewUrlProxy (TCP Server) │ -│ - Accept TCP connection │ -│ - Parse HTTP headers │ -│ - Extract Host: webapp-8080-abc123.app.lap.dev │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 3. PreviewUrlResolver │ -│ - Parse subdomain: service="webapp", port=8080, hash="abc123" │ -│ - Query kube_environment WHERE name LIKE '%abc123%' │ -│ - Query kube_environment_service WHERE name='webapp' │ -│ - Query kube_environment_preview_url WHERE port=8080 │ -│ - Validate access level │ -│ - Return PreviewUrlTarget │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 4. Add Tracestate Header │ -│ tracestate: lapdev-env-id={environment_id} │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 5. TunnelRegistry │ -│ - Find tunnel to target cluster │ -│ - Send tunnel message with target service info │ -│ - Open bidirectional TCP stream │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 6. kube-manager (in cluster) │ -│ - Receive tunnel request │ -│ - Connect to K8s service: webapp.staging.svc.cluster.local:8080 │ -│ - Forward HTTP request │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 7. Kubernetes Service → Pod │ -│ - Route to healthy pod │ -│ - Application processes request │ -│ - Return response │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 8. Response Stream │ -│ Pod → kube-manager → Tunnel → PreviewUrlProxy → Client │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### Branch Environment Creation Flow - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ 1. Shared Environment "staging" exists │ -│ - is_shared = true │ -│ - base_environment_id = NULL │ -│ - Deployed to K8s namespace "staging" │ -│ - Workloads: webapp, api, auth, payment, notifications, etc │ -│ - Total: 10 services, 15 GB RAM │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 2. Developer Creates Branch "feature-login" │ -│ POST /api/kube/environment/branch │ -│ { │ -│ "base_environment_id": "staging-uuid", │ -│ "name": "feature-login" │ -│ } │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 3. Validation │ -│ ✓ Base environment exists │ -│ ✓ Base is shared (is_shared=true) │ -│ ✓ Base is not itself a branch (base_environment_id=NULL) │ -│ ✓ User belongs to same organization │ -│ ✓ Cluster allows personal deployments │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 4. Copy Configuration to Database ONLY │ -│ │ -│ Create kube_environment: │ -│ - id = new-uuid │ -│ - name = "feature-login" │ -│ - base_environment_id = "staging-uuid" │ -│ - is_shared = false │ -│ - namespace = "staging" (same as base) │ -│ │ -│ Copy 10 workload definitions (DB only) │ -│ Copy 10 service definitions (DB only) │ -│ │ -│ 💰 Cost at this point: $0/hour (no K8s resources) │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 5. NO Kubernetes Deployment Yet │ -│ - Branch environment exists only in database │ -│ - No K8s resources created │ -│ - Preview URLs would route to base environment services │ -│ - Waiting for first workload update │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 6. Developer Updates 2 Workloads │ -│ PATCH /api/kube/environment/{id}/workload/auth-service │ -│ PATCH /api/kube/environment/{id}/workload/frontend │ -│ │ -│ Changes: │ -│ - auth-service: custom image for new login flow │ -│ - frontend: custom image for new UI │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 7. Deploy ONLY Modified Workloads to Kubernetes │ -│ │ -│ RPC: create_branch_workload() × 2 │ -│ │ -│ Creates in namespace "staging": │ -│ 1. Deployment: auth-service-branch-{uuid} (512 MB) │ -│ 2. Deployment: frontend-branch-{uuid} (1 GB) │ -│ │ -│ NOT deployed (uses base environment): │ -│ - api, payment, notifications, analytics, search, cache, db │ -│ │ -│ 💰 Cost: $0.05/hour (2 services) vs $0.15/hour (10 services) │ -│ 💰 Savings: 67% reduction │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 8. Create Preview URLs │ -│ POST /api/kube/environment/{id}/preview-url │ -│ │ -│ Modified services (route to branch): │ -│ - https://auth-8080-xyz.app.lap.dev → auth-service-branch-{uuid}│ -│ - https://frontend-3000-abc.app.lap.dev → frontend-branch-{uuid}│ -│ │ -│ Unmodified services (route to base): │ -│ - https://payment-9000-def.app.lap.dev → payment-service (base) │ -│ - All other services route to base environment │ -└────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 9. Developer Tests Changes │ -│ - Modified auth + frontend: Uses branch deployments │ -│ - Unmodified 8 services: Uses base deployments │ -│ - Changes isolated from "staging" for modified services │ -│ - Shares infrastructure for unchanged services │ -│ - Can iterate without affecting team or paying for full stack │ -└─────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Security and Access Control - -### Organization-Level Security - -All environments belong to an organization: -- User must be authenticated -- User must be member of the organization -- Verified by `organization_user` table join - -**Implementation:** All API endpoints check organization membership. - -### Environment-Level Access - -| Environment Type | Who Can Access | Check Method | -|------------------|----------------|--------------| -| Shared | Any org member | Check `is_shared=true` + org membership | -| Personal | Owner only | Check `user_id = requesting_user` | -| Branch | Creator only | Check `user_id = requesting_user` (always personal) | - -**Implementation:** `/workspaces/lapdev/crates/api/src/kube_controller.rs` - -### Preview URL Access Control - -Three-tier access model: - -**1. Personal** -- Requires authentication -- User must be the environment owner (`environment.user_id = user.id`) -- Most restrictive - -**2. Shared** -- Requires authentication -- User must be in the same organization (`environment.organization_id = user.organization_id`) -- Medium security - -**3. Public** -- No authentication required -- Anyone can access -- Least restrictive (use with caution) - -**Access Validation Flow:** -```rust -fn validate_access_level( - preview_url: &PreviewUrl, - environment: &Environment, - user: Option<&User> -) -> Result<()> { - match preview_url.access_level { - AccessLevel::Public => Ok(()), - AccessLevel::Shared => { - let user = user.ok_or(Unauthorized)?; - if user.organization_id == environment.organization_id { - Ok(()) - } else { - Err(Forbidden) - } - } - AccessLevel::Personal => { - let user = user.ok_or(Unauthorized)?; - if user.id == environment.user_id { - Ok(()) - } else { - Err(Forbidden) - } - } - } -} -``` - -**Implementation:** `/workspaces/lapdev/crates/kube/src/preview_url.rs` - -### Security Considerations - -**Risks:** -- Public preview URLs expose services without authentication -- Branch environments in shared namespace could access base environment's secrets -- Subdomain enumeration could reveal organization structure - -**Mitigations:** -- Access level defaults to "Personal" (most restrictive) -- Network policies should isolate branch deployments -- Rate limiting on preview URL creation (TODO: implement) -- Audit logging for all preview URL access (via `last_accessed_at`) - ---- - -## API Reference - -### Environment Operations - -**List Environments** -``` -GET /api/kube/environments -Query Params: - - type: "personal" | "shared" | "branch" - -Response: Array<{ - id: UUID, - name: String, - namespace: String, - cluster_id: UUID, - is_shared: Boolean, - base_environment_id?: UUID, - status: String -}> -``` - -**Get Environment Details** -``` -GET /api/kube/environment/{id} - -Response: { - id: UUID, - name: String, - namespace: String, - cluster_id: UUID, - is_shared: Boolean, - base_environment_id?: UUID, - workloads: Array, - services: Array, - preview_urls: Array -} -``` - -**Create Base/Personal Environment** -``` -POST /api/kube/environment - -Body: { - name: String, - app_catalog_id: UUID, - cluster_id: UUID, - namespace: String, - is_shared: Boolean -} - -Response: { - id: UUID, - ... -} -``` - -**Create Branch Environment** -``` -POST /api/kube/environment/branch - -Body: { - base_environment_id: UUID, - name: String -} - -Response: { - id: UUID, - base_environment_id: UUID, - ... -} -``` - -**Delete Environment** -``` -DELETE /api/kube/environment/{id} - -Response: 204 No Content -``` - -### Preview URL Operations - -**List Preview URLs for Environment** -``` -GET /api/kube/environment/{id}/preview-urls - -Response: Array<{ - id: UUID, - name: String, - url: String, - service_id: UUID, - port: Integer, - access_level: "personal" | "shared" | "public", - description?: String -}> -``` - -**Create Preview URL** -``` -POST /api/kube/environment/{id}/preview-url - -Body: { - service_id: UUID, - port: Integer, - access_level: "personal" | "shared" | "public", - description?: String -} - -Response: { - id: UUID, - name: String, - url: String, - ... -} -``` - -**Update Preview URL** -``` -PATCH /api/kube/environment/{env_id}/preview-url/{url_id} - -Body: { - description?: String, - access_level?: "personal" | "shared" | "public" -} - -Response: { - id: UUID, - ... -} -``` - -**Delete Preview URL** -``` -DELETE /api/kube/environment/{env_id}/preview-url/{url_id} - -Response: 204 No Content -``` - -### Workload Operations - -**List Environment Workloads** -``` -GET /api/kube/environment/{id}/workloads - -Response: Array<{ - id: UUID, - name: String, - kind: String, - containers: Array -}> -``` - -**Update Workload** -``` -PATCH /api/kube/environment/{id}/workload/{workload_id} - -Body: { - containers: Array<{ - name: String, - image: "FollowOriginal" | "Custom", - custom_image?: String, - env_vars?: Object - }> -} - -Response: 200 OK -``` - ---- - -## Key Files Reference - -### Backend Core - -| File | Purpose | -|------|---------| -| `/workspaces/lapdev/crates/kube/src/preview_url.rs` | Preview URL parsing, resolution, and validation | -| `/workspaces/lapdev/crates/kube/src/http_proxy.rs` | TCP-based HTTP proxy for preview URLs | -| `/workspaces/lapdev/crates/api/src/kube_controller.rs` | Business logic for environments, workloads, preview URLs | -| `/workspaces/lapdev/crates/db/src/api.rs` | Database CRUD operations | - -### Database Migrations - -| File | Purpose | -|------|---------| -| `/workspaces/lapdev/crates/db/migration/src/m20250809_000001_create_kube_environment.rs` | Environment table | -| `/workspaces/lapdev/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs` | Workload table | -| `/workspaces/lapdev/crates/db/migration/src/m20250809_000003_create_kube_environment_service.rs` | Service table | -| `/workspaces/lapdev/crates/db/migration/src/m20250815_000001_create_kube_environment_preview_url.rs` | Preview URL table | - -### Database Entities - -| File | Purpose | -|------|---------| -| `/workspaces/lapdev/crates/db/entities/src/kube_environment.rs` | Environment entity model | -| `/workspaces/lapdev/crates/db/entities/src/kube_environment_workload.rs` | Workload entity model | -| `/workspaces/lapdev/crates/db/entities/src/kube_environment_service.rs` | Service entity model | -| `/workspaces/lapdev/crates/db/entities/src/kube_environment_preview_url.rs` | Preview URL entity model | - -### Frontend Components - -| File | Purpose | -|------|---------| -| `/workspaces/lapdev/crates/dashboard/src/kube_environment.rs` | Environment list view (Personal/Shared/Branch tabs) | -| `/workspaces/lapdev/crates/dashboard/src/kube_environment_detail.rs` | Environment detail page with workloads/services/preview URLs | -| `/workspaces/lapdev/crates/dashboard/src/kube_environment_preview_url.rs` | Preview URL management UI | - -### Common Types - -| File | Purpose | -|------|---------| -| `/workspaces/lapdev/crates/common/src/kube.rs` | Shared data structures (AccessLevel, KubeWorkloadDetails, etc) | - ---- - -## Design Decisions - -### 1. Selective Deployment: Deploy Only What Changes - -**Decision:** Branch environments only deploy workloads that are explicitly modified by developers. Unmodified workloads share the base environment's deployments. - -**Rationale:** -- **Cost Savings:** Reduces infrastructure costs by 70-90% for typical feature branches -- **Faster Iteration:** No need to wait for full stack deployment -- **Resource Efficiency:** Maximizes cluster capacity by avoiding duplicate deployments -- **Developer Experience:** Instant branch creation (database only) - -**Trade-offs:** -- Requires tracking which workloads are deployed in branches -- Preview URL routing is more complex (must handle both branch and base services) -- Potential confusion if developers expect full isolation - -**Metrics:** -- Average branch uses 1-2 services (15% of total) -- Cost reduction: 85% per branch environment -- Deployment time: 0.5s (DB only) vs 60s (full stack) - -**Alternative Considered:** Deploy all services immediately -- Rejected due to cost (5× infrastructure) and deployment time - -### 2. Branch Environments Share Namespace with Base - -**Decision:** Branch environments deploy to the same Kubernetes namespace as their base environment, using different deployment names for isolation. - -**Rationale:** -- Simplifies network policies (services can communicate within namespace) -- Reduces cluster resource consumption (fewer namespaces to manage) -- Allows sharing of ConfigMaps and Secrets from base -- **Enables resource sharing model** (unmodified services use base deployments) - -**Trade-offs:** -- Potential resource conflicts (PVCs, Services with same name) -- Less strict isolation (network policies apply at namespace level) -- Requires careful naming to avoid collisions - -**Alternative Considered:** Create separate namespace per branch -- Rejected due to namespace proliferation, management overhead, and inability to share resources - -### 3. Lazy Deployment of Branch Environments - -**Decision:** Branch environments are created in the database immediately but K8s resources are not deployed until workloads are first modified. - -**Rationale:** -- Saves cluster resources for unused branches -- Faster branch creation (no K8s API calls) -- Allows review of configuration before deployment -- **Zero cost for branches that are created but never used** - -**Trade-offs:** -- Inconsistent state (database exists but K8s doesn't) -- Potential confusion for users (environment shows "Pending" until deployed) - -**Alternative Considered:** Deploy immediately -- Rejected due to resource waste for short-lived test branches - -### 4. TCP-Level HTTP Proxying - -**Decision:** The preview URL proxy operates at the TCP level, not HTTP request/response parsing. - -**Rationale:** -- Supports WebSocket upgrades seamlessly -- Supports HTTP/2 and streaming protocols -- Lower latency (no request buffering) -- Simpler implementation (no HTTP parser needed) - -**Trade-offs:** -- Cannot modify response bodies -- Cannot implement request-level rate limiting -- Cannot add custom headers to responses (only requests) - -**Alternative Considered:** Full HTTP proxy with request/response parsing -- Rejected due to complexity and WebSocket support requirements - -### 5. Subdomain-Based Preview URLs - -**Decision:** Preview URLs use subdomains with embedded metadata: `{service}-{port}-{hash}.app.lap.dev` - -**Rationale:** -- Stateless resolution (no database lookup for routing) -- Globally unique (enforced by DNS) -- Human-readable (service and port visible) -- SEO-friendly (each URL is distinct) - -**Trade-offs:** -- Exposes service names in URL -- Hash is not cryptographically secure (12 random chars) -- Cannot change URL after creation (name is immutable) - -**Alternative Considered:** Path-based routing (`app.lap.dev/{hash}/...`) -- Rejected due to path prefix conflicts and less clean URLs - -### 6. Image Inheritance from Base to Branch - -**Decision:** When creating a branch, if the base has custom images, they are set as `original_image` in the branch and the container image is set to `FollowOriginal`. - -**Rationale:** -- Allows developers to see what the base is running -- Provides clear diff between base and branch -- Enables easy revert to base configuration - -**Trade-offs:** -- Requires two-step update (first to `FollowOriginal`, then to `Custom`) -- More complex data model (`original_image` vs `custom_image`) - -**Alternative Considered:** Copy custom images as-is -- Rejected because branches would immediately diverge from base without visibility - -### 7. Three-Tier Access Control - -**Decision:** Preview URLs support three access levels: Personal, Shared, Public. - -**Rationale:** -- Personal: Safe default for private development -- Shared: Enables team collaboration without exposing to internet -- Public: Necessary for stakeholder/client demos - -**Trade-offs:** -- "Public" is dangerous if misused (exposed to internet) -- No fine-grained permissions (e.g., specific user allowlist) - -**Alternative Considered:** Binary public/private -- Rejected because team collaboration is a core use case - -### 8. Soft Deletes Throughout - -**Decision:** All tables use `deleted_at` timestamp instead of hard deletes. - -**Rationale:** -- Enables audit trail -- Allows undoing accidental deletions -- Historical analytics (e.g., preview URL access trends) - -**Trade-offs:** -- Unique constraints must account for soft-deleted records -- Database grows over time (requires cleanup jobs) -- More complex queries (always filter `deleted_at IS NULL`) - -**Alternative Considered:** Hard deletes with archive tables -- Rejected due to complexity of maintaining separate archive schema - -### 9. No Nested Branching - -**Decision:** Branch environments can only be created from shared environments, not from other branches. - -**Rationale:** -- Simplifies mental model (two-level hierarchy: base → branches) -- Prevents complex dependency chains -- Reduces risk of orphaned environments -- **Cost control:** Prevents exponential resource growth from nested branches - -**Trade-offs:** -- Cannot create sub-branches for experimental features -- Developers must merge to base before creating new branch - -**Alternative Considered:** Allow nested branching -- Rejected due to UX complexity, unclear use case, and cost explosion risk - ---- - -## Future Enhancements - -### Planned -- [ ] Dedicated `environment_hash` field (not reusing `name`) -- [ ] Rate limiting for preview URL creation -- [ ] Preview URL analytics dashboard -- [ ] Network policies for branch environment isolation -- [ ] Automatic cleanup of stale branch environments -- [ ] Branch environment templates (pre-configured workload overrides) -- [ ] Cost estimation API (show cost before creating branch) -- [ ] Workload deployment tracking (`deployed_to_k8s` flag) - -### Under Consideration -- [ ] Custom domain support for preview URLs -- [ ] Fine-grained access control (user/team allowlists) -- [ ] Preview URL expiration dates -- [ ] Branch environment diffing (show changes from base) -- [ ] Automatic branch environment creation from git hooks -- [ ] Resource quotas per developer (max N concurrent branches) -- [ ] Cost reporting dashboard (per environment, per developer, per org) - ---- - -## Glossary - -| Term | Definition | -|------|------------| -| **Base Environment** | A shared environment that can be used as the source for branch environments | -| **Branch Environment** | A personal environment forked from a shared base environment, deploying only modified workloads | -| **Preview URL** | A publicly accessible URL that routes to a service in an environment | -| **Access Level** | Security tier for preview URLs: Personal, Shared, or Public | -| **Environment** | A collection of Kubernetes workloads and services deployed to a cluster | -| **Workload** | A Kubernetes resource that runs containers (Deployment, StatefulSet, etc) | -| **Service** | A Kubernetes Service that exposes workload pods | -| **App Catalog** | A template defining workloads and services that can be deployed | -| **Tunnel** | A persistent TCP connection between lapdev-api and kube-manager for proxying | -| **Selective Deployment** | Branch environment strategy: deploy only modified workloads, share the rest | -| **Resource Sharing** | Branch environments using base environment deployments for unmodified services | - ---- - -## Cost Optimization Summary - -**Key Takeaway:** Lapdev's branch environment architecture achieves **70-90% cost reduction** compared to traditional isolated namespace approaches by deploying only the workloads that developers modify. - -**How It Works:** -1. Create branch environment (database only) → $0/hour -2. Modify 1-2 services → Deploy only those services -3. Share remaining 8-9 services with base environment -4. Result: Pay for 2 services instead of 10 - -**Typical Savings:** -- 1 developer, 2 modified services: **87% cost reduction** -- 5 developers, avg 2 modified services: **80% cost reduction** -- 10 developers, avg 2 modified services: **80% cost reduction** - -**Best For:** -- Microservices architectures (>5 services) -- Feature branch development -- Short-lived test environments -- Cost-conscious organizations - ---- - -**Document Version:** 1.0 -**Last Updated:** 2025-10-01 -**Maintainers:** Lapdev Core Team diff --git a/docs/DEVBOX_DNS_HOSTS_PLAN.md b/docs/DEVBOX_DNS_HOSTS_PLAN.md deleted file mode 100644 index a7b7e00..0000000 --- a/docs/DEVBOX_DNS_HOSTS_PLAN.md +++ /dev/null @@ -1,59 +0,0 @@ -# Devbox Hosts-Based DNS Plan - -## Goals -- Support short-name access to Kubernetes services from the devbox CLI without running a full DNS proxy. -- Reuse the existing WebSocket tunnel (`/kube/devbox/tunnel/client/{session}`) for data-plane traffic. -- Keep OS networking changes minimal and reversible while covering macOS, Linux, and Windows. - -## High-Level Approach -1. **Service Discovery** - - When the CLI connects and learns the active environment, fetch the list of services/ports for that environment (via API/RPC). - - Refresh this list whenever the active environment changes; optionally poll periodically to catch new services. - -2. **Synthetic Loopback Allocation** - - Reserve a loopback subnet (e.g., `127.77.0.0/24`). - - Assign one IP per `(service, port)` pair and track the mapping in memory. - - Reuse IPs if a service/port already exists; recycle IPs when entries disappear. - -3. **Hosts File Injection** - - Generate aliases per service (e.g., `service`, `service.namespace`, `service.namespace.svc`, `service.namespace.svc.cluster.local`). - - Write a bounded block of entries pointing to the synthetic IPs between markers, e.g.: - ``` - # BEGIN lapdev-devbox - 127.77.0.5 service service.namespace service.namespace.svc service.namespace.svc.cluster.local - # END lapdev-devbox - ``` - - Implement per-OS writers: - - macOS/Linux: `/etc/hosts` (requires sudo). - - Windows: `C:\Windows\System32\drivers\etc\hosts` (requires admin; ensure CRLF endings). - - If we cannot modify the file (permissions, policy), emit explicit manual instructions instead of failing silently. - -4. **Connection Bridge** - - Run a `TcpListener` covering the synthetic block (bind to each assigned IP or use reusable listeners). - - On incoming connections: - 1. Look up the destination IP/port to find the target service. - 2. Use `TunnelClient::connect_tcp(fqdn, port)` to open a tunneled connection to `service.namespace.svc.cluster.local`. - 3. Proxy bytes bidirectionally between the local socket and the tunnel stream. - - Close connections gracefully and recycle IPs on idle/timeout. - -5. **Lifecycle Handling** - - **On connect**: start tunnel tasks, fetch services, write hosts block, start listener. - - **Environment change**: remove existing hosts block, refresh services, rebuild mappings, rewrite hosts, and restart listener if needed. - - **Shutdown (Ctrl+C or displacement)**: stop listener, remove hosts block, drop tunnel client. - -6. **Safety & Observability** - - Always guard host-file edits with clear markers so we never disturb user-managed entries. - - Log every change (hosts written, listener started, tunnel connection opened). - - Provide dry-run / verbose modes for debugging. - -7. **Cross-Platform Notes** - - macOS: prompt for sudo; use `/etc/hosts`. - - Linux: same as macOS; avoid touching NetworkManager-managed files. - - Windows: require elevated PowerShell or document manual steps. - -8. **Validation Plan** - - Unit tests for hosts block rendering, synthetic IP allocator, and mapping lookups. - - Integration test: mock tunnel server, apply hosts updates to a temp file, verify `curl http://service` routes through the synthetic listener. - - Manual smoke tests on macOS/Linux/Windows to confirm hosts entries are written/removed and that short names resolve through the tunnel. - -This plan keeps DNS changes simple (hosts file only) while leveraging the existing tunnel to reach cluster services under short names for the active environment. diff --git a/docs/RPC_ARCHITECTURE.md b/docs/RPC_ARCHITECTURE.md deleted file mode 100644 index d660374..0000000 --- a/docs/RPC_ARCHITECTURE.md +++ /dev/null @@ -1,76 +0,0 @@ -# Lapdev RPC Architecture - -Lapdev combines tarpc-based transports with HTTP RPC (HRPC) endpoints to coordinate the control plane, workspace hosts, developers' CLIs, and Kubernetes agents. The diagram below highlights the main RPC links and which component resides on each side of the connection. - -## RPC Topology - -```mermaid -flowchart LR - subgraph Clients - CLI(["Devbox CLI"]) - Dashboard(["Dashboard / Browser"]) - end - - subgraph API["Lapdev API (CoreState)"] - APIHttp(["HTTP router & HRPC endpoints"]) - DevboxRPC(["Devbox RPC server"]) - KubeCtl(["Kube controller"]) - Tunnel(["Tunnel broker"]) - end - - subgraph Kubernetes["Kubernetes Agents"] - KM(["lapdev-kube-manager"]) - Sidecar(["kube-sidecar-proxy"]) - DevboxProxy(["kube-devbox-proxy"]) - end - - Dashboard -- "HTTPS (HRPC)
HrpcService" --> APIHttp - CLI <-->|"WebSocket + tarpc
DevboxSessionRpc & DevboxClientRpc"| DevboxRPC - CLI <-->|"WebSocket tunnels
tunnel crate"| Tunnel - KubeCtl <-->|"WebSocket + tarpc
KubeClusterRpc & KubeManagerRpc"| KM - KM <-->|"TCP + tarpc
SidecarProxyManagerRpc & SidecarProxyRpc"| Sidecar - KM <-->|"TCP + tarpc
DevboxProxyManagerRpc & DevboxProxyRpc"| DevboxProxy - Sidecar <-->|"WebSocket tunnels
Preview & intercept data"| Tunnel - DevboxProxy <-->|"WebSocket tunnels
Branch env traffic"| Tunnel - linkStyle 0 stroke:#64748b,stroke-width:2px,stroke-dasharray:6 3 - linkStyle 1 stroke:#64748b,stroke-width:2px,stroke-dasharray:6 3 - linkStyle 2 stroke:#2ca58d,stroke-width:3px - linkStyle 3 stroke:#64748b,stroke-width:2px,stroke-dasharray:6 3 - linkStyle 4 stroke:#64748b,stroke-width:2px,stroke-dasharray:6 3 - linkStyle 5 stroke:#64748b,stroke-width:2px,stroke-dasharray:6 3 - linkStyle 6 stroke:#2ca58d,stroke-width:3px - linkStyle 7 stroke:#2ca58d,stroke-width:3px - classDef client fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#78350f; - classDef api fill:#e0f2fe,stroke:#0ea5e9,stroke-width:2px,color:#0f172a; - classDef kube fill:#dcfce7,stroke:#22c55e,stroke-width:2px,color:#14532d; - classDef tunnelNode fill:#fef9c3,stroke:#facc15,stroke-width:2px,color:#713f12; - class CLI,Dashboard client - class APIHttp,DevboxRPC,KubeCtl api - class KM,Sidecar,DevboxProxy kube - class Tunnel tunnelNode - style Clients fill:transparent,stroke:#94a3b8,stroke-width:1px - style API fill:transparent,stroke:#38bdf8,stroke-width:1px - style Kubernetes fill:transparent,stroke:#4ade80,stroke-width:1px -``` - -Green edges represent high-throughput data-plane tunnels; dashed gray edges denote tarpc/HRPC control-plane calls. - -## Link details - -| Source → Target | Transport | RPC traits / channel | Purpose | -| --- | --- | --- | --- | -| Dashboard → API HTTP router | HTTPS (JSON) | `HrpcService` (lapdev_hrpc) | Dashboard and admin UI call HRPC endpoints for user, org, and Kubernetes environment management | -| Devbox CLI ↔ Devbox RPC server | WebSocket + tarpc | `DevboxSessionRpc`, `DevboxClientRpc` | CLI maintains a control-plane session for authentication, intercept lifecycle, and device displacement notifications | -| Devbox CLI ↔ Tunnel broker | WebSocket (tunnel crate) | Multiplexed TCP streams | Streams intercepted workload traffic from the cluster back to developer machines | -| Kube controller ↔ lapdev-kube-manager | WebSocket + tarpc | `KubeClusterRpc`, `KubeManagerRpc` | Control plane for Kubernetes workloads: cluster registration, workload discovery, deployments, and tunnel heartbeats | -| lapdev-kube-manager ↔ kube-sidecar-proxy | TCP + tarpc | `SidecarProxyManagerRpc`, `SidecarProxyRpc` | Configures per-workload sidecar proxies, distributes devbox intercept routes, and collects proxy heartbeats/metrics | -| lapdev-kube-manager ↔ kube-devbox-proxy | TCP + tarpc | `DevboxProxyManagerRpc`, `DevboxProxyRpc` | Registers environment-scoped devbox proxies and pushes branch-environment routing updates | -| kube-sidecar-proxy ↔ Tunnel broker | WebSocket (tunnel crate) | Multiplexed TCP streams | Data-plane path for preview URLs and live intercept traffic between in-cluster workloads and the API | -| kube-devbox-proxy ↔ Tunnel broker | WebSocket (tunnel crate) | Multiplexed TCP streams | Data-plane path for branch environments that proxy traffic back through the API | - -## Notes - -- The tunnel broker, Devbox RPC server, and kube controller all run inside the Lapdev API process, but they expose distinct RPC surfaces to external agents. -- All tarpc links use the shared `spawn_twoway` helper to provide simultaneous client and server channels over a single transport. -- HRPC endpoints are consumed both by the dashboard and by service-to-service calls that need simple HTTP semantics (for example, branch environment orchestration). -- Sidecar and devbox proxies establish long-lived WebSocket tunnels only after control-plane authorization succeeds via their respective tarpc channels. diff --git a/docs/core-concepts/architecture/branch-environment-architecture.md b/docs/core-concepts/architecture/branch-environment-architecture.md index cc60034..fb453d0 100644 --- a/docs/core-concepts/architecture/branch-environment-architecture.md +++ b/docs/core-concepts/architecture/branch-environment-architecture.md @@ -1,6 +1,6 @@ # Branch Environment Architecture -Branch environments are a cost-effective way to run development environments in Kubernetes. Instead of duplicating all services for each developer, branch environments share a baseline and only run the services you're actively modifying. +Branch environments are a cost-effective way to run development environments in Kubernetes. Instead of duplicating all services for each developer, branch environments build on a shared environment and only run the services you're actively modifying. ### Architecture Diagram @@ -11,107 +11,159 @@ Branch environments are a cost-effective way to run development environments in Without Branch Environments: ``` -Alice's environment: 100 services (all running separately) -Bob's environment: 100 services (all running separately) -Total: 200 services +10 developers × 100 services each = 1000 pods total +Every developer runs a complete copy of all services ``` With Branch Environments: ``` -Baseline environment: 100 services (shared by everyone) -Alice's branch: 1 service (api - her modified version) -Bob's branch: 1 service (worker - his modified version) -Total: 102 services +Shared environment: 100 services (shared by everyone) ++ 10 developers × 2 modified services each = 20 branched services +Total: 120 pods ``` +**Example:** Alice modifies `api`, Bob modifies `worker` - they only run their modified services while sharing the rest. + +**Savings: 88% reduction in infrastructure costs** + +### Prerequisites + +Branch environments require: + +* **HTTP/HTTPS communication only** - Non-HTTP components (databases, message queues, gRPC) remain shared across all branches +* **Header propagation** - Your application must forward OpenTelemetry `tracestate` headers in service-to-service calls + +**Important:** While Lapdev automatically injects the tracestate header for incoming requests, your application code must forward headers in service-to-service HTTP calls for routing to work correctly. + ### How It Works -#### The Baseline Environment +#### The Shared Environment One shared environment runs all your services: * Contains a complete copy of all workloads * Shared by all developers -* Updated when production manifests change +* Acts as the baseline for all branch environments #### Your Branch Environment When you create a branch, you specify which service(s) you're modifying: * Only those services run in your branch namespace -* Everything else uses the baseline - -#### Traffic Routing - -When someone accesses your preview URL, Lapdev routes traffic intelligently: +* Everything else routes to the shared environment -Example: Alice's branch modifies the `api` service +#### Traffic Routing Overview -Request to `https://alice-branch.app.lap.dev`: +When someone accesses your branch preview URL, Lapdev routes traffic intelligently: -1. Starts at baseline environment -2. When it needs to call the `api` service → routes to Alice's branch -3. When it needs `web`, `worker`, or any other service → uses baseline +* Requests to services you've modified → route to your branch version +* Requests to all other services → route to the shared environment -Example: Bob's branch modifies the `worker` service +**Example:** If Alice's branch modifies only the `api` service, then requests for `api` go to her branch while `web`, `worker`, and all other services use the shared environment. -Request to `https://bob-branch.app.lap.dev`: +#### Routing Mechanism -1. Starts at baseline environment -2. When it needs to call the `worker` service → routes to Bob's branch -3. When it needs `api`, `web`, or any other service → uses baseline +**1. Header Injection** -#### How Routing Works +When a request enters through a preview URL, Lapdev automatically injects an OpenTelemetry `tracestate` header that identifies which branch the request belongs to. -Each request gets a special identifier (tracestate) that tells services which branch it belongs to. The Lapdev Sidecar Proxy in each pod: +**2. Header Propagation (Your Responsibility)** -1. Checks if the service being called has a branch override -2. Routes to the branch if it exists -3. Routes to baseline if no override exists -4. Passes the identifier along to the next service +Your application must forward the `tracestate` header in all HTTP service-to-service calls. -This happens automatically - your code doesn't need any changes. +**How to implement:** -### Visual Example +Most HTTP clients automatically forward headers when you pass them explicitly. For example: +```javascript +// Forward all incoming headers to downstream services +const response = await axios.get('http://other-service:8080/api', { + headers: request.headers // This includes the tracestate header +}); ``` -Baseline Environment: -├── api (baseline version) -├── web (baseline version) -└── worker (baseline version) -Alice's Branch: -└── api (Alice's custom version) +**Framework-specific approaches:** -Bob's Branch: -└── worker (Bob's custom version) -``` - -Traffic to alice-branch.app.lap.dev: +✅ **Automatic with these frameworks:** +- Spring Cloud Sleuth (auto-propagates OpenTelemetry headers) +- OpenTelemetry-instrumented applications (SDK handles propagation) -* api → Alice's version ✓ -* web → baseline version -* worker → baseline version +⚠️ **Manual forwarding required:** +- Express.js, Fastify, Koa (pass `request.headers` to HTTP client) +- Go HTTP clients (copy headers from incoming request) +- Any custom HTTP client implementation -Traffic to bob-branch.app.lap.dev: +**3. Routing Decision** -* api → baseline version -* web → baseline version -* worker → Bob's version ✓ +The Lapdev Sidecar Proxy (automatically injected by Lapdev into each pod in managed environments): -Traffic to baseline.app.lap.dev: +1. Intercepts outgoing HTTP requests from your service +2. Reads the `tracestate` header to identify the branch +3. Checks if the target service has a branch override for this branch +4. Routes to the branched version if it exists +5. Routes to the shared environment if no override exists +6. The header continues to propagate to the next service -* api → baseline version -* web → baseline version -* worker → baseline version +> **Note:** The sidecar proxy is automatically added when Lapdev creates your environment. No manual configuration needed. ### Key Benefits -* Cost-effective: Only run what you're changing (10x cheaper than isolated environments) -* Instant creation: Branch environments are created instantly - custom workloads are only deployed when you modify it. +* **Cost-effective:** Only run what you're changing (up to 88% infrastructure reduction) +* **Instant creation:** Branch environments are created instantly - branched workloads are only deployed when you modify them +* **Realistic testing:** Test your changes against real production-like services, not mocks +* **No conflicts:** Multiple developers can work on different services simultaneously ### Limitations -* Shared databases: All branches use the baseline's database (be careful with schema changes) -* Tracestate propagation: Your application needs to pass the tracestate header in service-to-service calls for routing to work correctly +#### 1. Header Propagation Required + +Your application must forward the `tracestate` header in service-to-service HTTP calls, otherwise routing will break mid-request chain. See the **Routing Mechanism** section above for implementation guidance. + +#### 2. HTTP/HTTPS Traffic Only + +Branch routing works exclusively for HTTP/HTTPS communication. Other protocols are always shared: + +* **Shared across all branches:** Databases, message queues, gRPC services, TCP connections +* **Implication:** Be careful with database schema changes or queue message format changes + +#### 3. Stateful Services Considerations + +Services with persistent state (databases, caches) are shared across branches: + +* Database writes from one branch affect all other branches +* Redis/Memcached entries are visible to all branches +* File uploads or background jobs may interfere between branches + +**Best Practice:** Use branch-specific prefixes for cache keys and database records when testing data modifications. + +### Troubleshooting + +#### Traffic not routing correctly to my branch + +**Symptoms:** +- Changes aren't visible when accessing your branch preview URL +- Some requests use your branch version, others use the shared version (inconsistent routing) + +**Possible causes:** + +1. **Headers not propagated:** Your services aren't forwarding the `tracestate` header in HTTP calls +2. **Service not branched:** The service is not actually deployed in your branch environment +3. **Wrong preview URL:** You're accessing the shared environment URL instead of your branch URL + +**Debug steps:** + +* Verify your HTTP clients forward incoming headers to downstream services (see example above) +* Check the Lapdev dashboard to confirm which services are branched in your environment +* Check sidecar proxy logs to see routing decisions +* Look for the `tracestate` header in your service logs - if it's missing after the first hop, headers aren't being propagated + +#### Database changes from my branch affect other developers + +This is **expected behavior**. Branch environments share databases and other stateful services. + +**Solutions:** + +* Use branch-specific database prefixes or namespaces +* Test with read-only database operations +* Use separate test databases per branch (requires manual configuration) diff --git a/docs/how-to-guides/local-development-with-devbox.md b/docs/how-to-guides/local-development-with-devbox.md index 881bb8f..a39630c 100644 --- a/docs/how-to-guides/local-development-with-devbox.md +++ b/docs/how-to-guides/local-development-with-devbox.md @@ -14,34 +14,52 @@ This guide shows how to set up and use **Devbox** for local development with you curl -sSL https://get.lap.dev/install-devbox.sh | bash ``` -#### Connect to an Environment +#### Connect to Lapdev + +First, connect your Devbox CLI to Lapdev: ```bash -devbox connect +lapdev devbox connect ``` -Devbox will: +This establishes a secure connection between your local machine and Lapdev. + +#### Set Active Environment + +After connecting, set which environment you want to work with in the Lapdev dashboard: + +1. Go to the **Environments** tab +2. Select the environment you want to use +3. Click **Set as Active Environment** -* Authenticate and connect to the environment’s namespace. -* Set up secure routing between your local machine and the cluster. +Once set, all traffic interception and service access will route through this active environment. _Placeholder screenshot:_ #### Intercept a Service -To reroute in-cluster traffic to your local process: +Once connected, you can intercept traffic for a specific service through the Lapdev dashboard: -```bash -devbox intercept checkout-service -``` +1. Open your environment in the Lapdev dashboard +2. Navigate to the **Services** section +3. Find the service you want to intercept (e.g., `checkout-service`) +4. Click **Enable Intercept** +5. Specify the local port where your service is running (e.g., `localhost:8080`) -After interception: +After enabling interception: -* Requests to `checkout-service` in the cluster will route to your local code. -* You can make edits, hot-reload, or debug directly in your IDE. +* All cluster traffic for that service routes to your local machine +* You can make edits, hot-reload, or debug directly in your IDE +* Other services in the cluster remain unaffected _Placeholder screenshot:_ #### Access In-Cluster Services -While connected, you can +While connected via Devbox, you can access any service in your environment using their cluster DNS names (e.g., `postgres-service:5432`, `redis-service:6379`) directly from your local code. Devbox handles the tunneling automatically — no port forwarding or VPN needed. + +#### Next Steps + +* Learn more about [Devbox architecture](../core-concepts/devbox.md) +* Understand [traffic routing](../core-concepts/architecture/traffic-routing-architecture.md) +* Create [preview URLs](use-preview-urls.md) to share your work diff --git a/docs/index.mdx b/docs/index.mdx deleted file mode 100644 index 07ee90d..0000000 --- a/docs/index.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "This is the index" -description: "Description of your new file." ---- - -sdfdsflkdsjflkdsfsdfsdfsdf \ No newline at end of file diff --git a/docs/test.mdx b/docs/test.mdx deleted file mode 100644 index 183f071..0000000 --- a/docs/test.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "test file" -description: "Description of your new file." ---- - -test test From 7099fab6163407a02d9544848eae32b98a7376f4 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 17:55:03 +0000 Subject: [PATCH 110/334] update --- .../branch-environment-architecture.md | 2 +- docs/core-concepts/environment.md | 111 ++++++++++-------- .../create-lapdev-environment.md | 14 ++- 3 files changed, 69 insertions(+), 58 deletions(-) diff --git a/docs/core-concepts/architecture/branch-environment-architecture.md b/docs/core-concepts/architecture/branch-environment-architecture.md index fb453d0..4f3e0e7 100644 --- a/docs/core-concepts/architecture/branch-environment-architecture.md +++ b/docs/core-concepts/architecture/branch-environment-architecture.md @@ -44,7 +44,7 @@ One shared environment runs all your services: * Contains a complete copy of all workloads * Shared by all developers -* Acts as the baseline for all branch environments +* Acts as the foundation for all branch environments #### Your Branch Environment diff --git a/docs/core-concepts/environment.md b/docs/core-concepts/environment.md index e9047a8..bcd6bdc 100644 --- a/docs/core-concepts/environment.md +++ b/docs/core-concepts/environment.md @@ -6,24 +6,28 @@ Lapdev’s unique strength is that it manages the **entire environment lifecycle ### How It Works -Lapdev reads your **production Kubernetes manifests** directly from your cluster and treats them as the **single source of truth**. +Environments are created from **App Catalogs**, which serve as blueprints for your application. -To define your app environment: +When you create an environment: -1. Select the workloads you want to include. -2. Lapdev automatically finds and includes all related **ConfigMaps**, **Secrets**, and **Services**. -3. Lapdev replicates these resources into your Lapdev-managed environments. +1. Select an existing App Catalog that defines your app's workloads +2. Choose an environment type (Personal, Shared, or Branch) +3. Lapdev deploys the catalog's workloads into a dedicated namespace +4. ConfigMaps, Secrets, and Services are automatically included -The result: every developer’s environment mirrors production exactly — with zero manual setup. When your production apps change, Lapdev automatically syncs updates to all environments, so **everyone stays in sync**. +The result: a fully functional copy of your app that mirrors production exactly, with zero manual YAML management. -### Environment Models +> App Catalogs are created by reading production manifests directly from your cluster. Learn more about [**App Catalogs**](app-catalog.md). -Lapdev supports two environment models, depending on your workflow and cost requirements: +### Environment Types + +Lapdev supports three environment types, depending on your workflow and cost requirements: * **Personal Environments** — fully isolated, ideal for independent development -* **Branch Environments** — lightweight and cost-efficient, ideal for large teams +* **Shared Environments** — team-wide baseline, ideal for integration testing +* **Branch Environments** — lightweight personal modifications on shared foundation, ideal for large teams -Both support **Devbox** for local development and debugging. +All types support **Devbox** for local development and debugging. #### 1. Personal Environments @@ -42,59 +46,63 @@ Lapdev helps mitigate this cost by allowing you to **start and stop environments **Best for:**\ Developers who need full isolation or want to test complex changes safely. -#### 2. Branch Environments +#### 2. Shared Environments -To reduce cost while keeping flexibility, Lapdev introduces **Branch Environments**. +A **Shared Environment** is a team-wide baseline that runs a complete version of your app. -**Shared Environment Foundation** +* Created and managed by admins +* Accessible by all team members +* Acts as the foundation for branch environments +* Ideal for integration testing or staging setups -Branch environments are built on top of **shared environments**, which are created and managed by admins. A shared environment runs a complete version of your app that everyone on the team can access. +**Best for:**\ +Teams who need a stable, shared environment for testing or as a baseline for branch environments. -When a developer creates a **branch environment**, it behaves much like a Git branch: +#### 3. Branch Environments -* Initially, the branch environment **does not create new Kubernetes resources**. -* It simply references the workloads in the shared environment. -* Only when you modify a workload does Lapdev create a **branched copy** of that specific workload to run side by side with the original. +**Branch Environments** provide a cost-effective way to test changes by building on top of a shared environment. -This means creating new branch environments is almost instant — and nearly free in terms of compute cost. +When you create a branch environment: -**Local Development with Devbox** +* Initially, it references all workloads from the shared environment +* Only when you modify a service does Lapdev create a branched copy +* Unmodified services continue using the shared environment +* Multiple developers can work simultaneously without conflicts -Devbox allows you to: +This Git-like branching model means: -* **Intercept cluster traffic** to your local machine for real-time debugging. -* Use your **local IDE** to edit, build, and debug your service as if it were running in the cluster. -* **Access in-cluster resources** such as databases, caches, and internal APIs transparently — no VPN or tunneling setup required. +* **Near-instant creation** - no waiting for full environment deployment +* **Minimal cost** - only run what you're changing (up to 88% infrastructure reduction) +* **Realistic testing** - test against real production-like services, not mocks -When connected, Devbox runs your local process as part of the branch environment. Other services in the cluster automatically route traffic to your local instance, so you can develop locally while staying fully connected to the live environment. +**How it works:** -This workflow combines the best of both worlds: +Lapdev uses intelligent traffic routing to direct requests to your branched services while everything else uses the shared baseline. This enables multiple developers to safely test changes in parallel. -* Fast local feedback -* Accurate in-cluster integration +**Requirements:** -**Traffic Routing** +* Services must communicate via HTTP/HTTPS +* Your application must forward `tracestate` headers in service calls +* Non-HTTP components (databases, message queues) remain shared -Under the hood, Lapdev uses a **sidecar proxy** within each workload to handle routing.\ -This proxy decides whether incoming requests should go to: +**Best for:**\ +Large teams doing frequent feature development with cost efficiency in mind. -* The **shared workload** (default), -* A **branched workload** created by a developer, or -* A **local Devbox process** connected to the environment. +> For technical details on routing, sidecar proxies, and header propagation, see [**Branch Environment Architecture**](architecture/branch-environment-architecture.md). -Routing is based on the `tracestate` HTTP header, which identifies which branch a request belongs to.\ -This enables multiple developers to safely test changes in the same shared environment — without interfering with each other. +### Local Development with Devbox -For a deeper technical explanation of the sidecar proxy, routing algorithm, and how Lapdev ensures request isolation across branches, see the [**Traffic Routing Architecture**](architecture/traffic-routing-architecture.md) and [**Branch Environment Architecture**](architecture/branch-environment-architecture.md) page. +All environment types support **Devbox**, Lapdev's CLI tool for local development. -**Limitations** +Devbox allows you to: -Because branch environment's routing relies on the `tracestate` header: +* **Intercept cluster traffic** to your local machine for real-time debugging +* Use your **local IDE** to edit, build, and debug as if running in the cluster +* **Access in-cluster resources** (databases, caches, internal APIs) transparently -* Your services must propagate the header through all internal HTTP calls. -* Non-HTTP components (like databases or message queues) can’t be routed and remain shared across branches. +When connected via Devbox, your local process integrates seamlessly with your environment. Other services automatically route traffic to your local instance, combining fast local iteration with accurate cluster integration. -Despite these limitations, branch environments provide an extremely efficient and collaborative workflow for large teams — combining shared stability with flexible, isolated development. +> Learn more about [**Devbox**](devbox.md) and how to use it. ### Preview URLs @@ -104,16 +112,17 @@ Preview URLs are created **per service**. You choose which service to expose, an When requests come in through a Preview URL in a branch environment, Lapdev automatically manages the **`tracestate` header** to route traffic to the correct branch environment. -You can read more about [preview URLs here](broken-reference). +> Learn more about [**Preview URLs**](preview-url.md). ### Comparison -| Feature | Personal Environment | Branch Environment | -| --------------- | ------------------------------------- | ------------------------------- | -| **Isolation** | Fully isolated (per namespace) | Shared base, routed isolation | -| **Cost** | Higher | Lower | -| **Ideal For** | Deep testing, multi-service debugging | Large teams, frequent branching | -| **Performance** | Slower to start, full workloads | Instant setup, minimal overhead | +| Feature | Personal Environment | Shared Environment | Branch Environment | +| --------------- | ------------------------------------- | ------------------------------------ | ------------------------------- | +| **Isolation** | Fully isolated (per namespace) | Shared by all team members | Shared base, routed isolation | +| **Cost** | Highest | Medium | Lowest | +| **Ideal For** | Deep testing, multi-service debugging | Integration testing, team staging | Large teams, frequent branching | +| **Performance** | Slower to start, full workloads | Full workloads, shared infrastructure| Instant setup, minimal overhead | +| **Access** | Single developer | Entire team | Single developer (with shared base) | ### Summary @@ -121,8 +130,8 @@ Lapdev Kubernetes Environments let you: * Reproduce production exactly, without manual setup * Keep all environments automatically synced with production -* Choose between **full isolation** or **lightweight branching** +* Choose between **full isolation**, **team-wide sharing**, or **cost-efficient branching** * Use **Devbox** for fast, local-style development in Kubernetes * Collaborate efficiently with consistent, shareable environments -Whether you’re developing alone or across a large team, Lapdev ensures every environment stays consistent, efficient, and production-accurate. +Whether you're developing alone, testing as a team, or scaling across a large organization, Lapdev ensures every environment stays consistent, efficient, and production-accurate. diff --git a/docs/how-to-guides/create-lapdev-environment.md b/docs/how-to-guides/create-lapdev-environment.md index ca33109..70a91bd 100644 --- a/docs/how-to-guides/create-lapdev-environment.md +++ b/docs/how-to-guides/create-lapdev-environment.md @@ -6,7 +6,7 @@ It’s created from an existing **App Catalog**, which defines which workloads m This guide walks you through how to create personal, shared, and branch environments from an App Catalog. -### 1. Prerequisites +### Prerequisites Before creating an environment: @@ -25,7 +25,7 @@ _Example screenshot:_ ### Select Environment Type -Lapdev supports multiple environment models depending on your workflow and cost needs. +Lapdev supports three environment types depending on your workflow and cost needs. #### **Personal Environment** @@ -40,10 +40,12 @@ Lapdev supports multiple environment models depending on your workflow and cost #### **Branch Environment** * Built from an existing **Shared Environment**. -* Includes only workloads you’ve changed; unmodified services reuse the shared baseline. +* Includes only workloads you've changed; unmodified services reuse the shared environment. * Created directly from the **Shared Environment details page** — click **Create Branch Environment**. -> 🧠 Use **branch environments** for feature work — they’re lightweight, fast to spin up, and cost-efficient. +> **Note:** You must first create a Shared Environment before you can create Branch Environments from it. + +> 🧠 Use **branch environments** for feature work — they're lightweight, fast to spin up, and cost-efficient. ### Verify and Access Your Environment @@ -53,11 +55,11 @@ After creation: * Lapdev automatically provisions: * All workloads from the App Catalog * Associated ConfigMaps, Secrets, and Services - * A unique HTTPS URL (e.g. `mia-checkout-feature.app.lap.dev`) + * Unique HTTPS preview URLs for accessing your services You can monitor status, logs, and sync state directly from the dashboard. -### 5. Next Steps +### Next Steps Your environment is ready! You can now: From cf89d3eb7c45ba08f3e8865a395d7ff800c40fc7 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 17:57:29 +0000 Subject: [PATCH 111/334] update --- docs/core-concepts/app-catalog.md | 20 +++++++++++++------- docs/how-to-guides/create-an-app-catalog.md | 4 +++- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/core-concepts/app-catalog.md b/docs/core-concepts/app-catalog.md index 4b9c487..2ab1951 100644 --- a/docs/core-concepts/app-catalog.md +++ b/docs/core-concepts/app-catalog.md @@ -44,15 +44,21 @@ After creating an App Catalog, you can modify it anytime: Lapdev automatically tracks these updates and keeps associated environments consistent with the latest configuration. -### Example Workflow +### Typical Usage Pattern -1. **Connect clusters** to Lapdev (source and target). -2. **Create an App Catalog** from your source cluster’s workloads. -3. **Use the App Catalog** to create environments (personal, shared, or branch). -4. **Sync updates** when production manifests change. +App Catalogs fit into your workflow like this: -\ -&#xNAN;_Example: Selecting workloads and naming an App Catalog._ +``` +Source Cluster (Production/Staging) + ↓ + App Catalog (Blueprint) + ↓ +Multiple Environments (Personal/Shared/Branch) +``` + +Once created, catalogs can be synced when production manifests change, keeping all environments up to date. + +> For step-by-step instructions, see [**Create an App Catalog**](../how-to-guides/create-an-app-catalog.md). ### Benefits of App Catalogs diff --git a/docs/how-to-guides/create-an-app-catalog.md b/docs/how-to-guides/create-an-app-catalog.md index b19f6ce..ca60a74 100644 --- a/docs/how-to-guides/create-an-app-catalog.md +++ b/docs/how-to-guides/create-an-app-catalog.md @@ -1,9 +1,11 @@ # Create an App Catalog -An **App Catalog** defines which workloads make up your application — it’s the blueprint Lapdev uses to create development environments. +An **App Catalog** defines which workloads make up your application — it's the blueprint Lapdev uses to create development environments. This guide walks you through creating an App Catalog from your connected cluster. +> To understand why App Catalogs exist and how they fit into Lapdev, read [**App Catalog**](../core-concepts/app-catalog.md) first. + ### Prerequisites Before creating an App Catalog, make sure: From 2d1657320dc55f0a1020550a04690da50d4f021f Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 18:02:07 +0000 Subject: [PATCH 112/334] update --- docs/core-concepts/devbox.md | 12 +++++----- .../local-development-with-devbox.md | 23 ++++++++----------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/docs/core-concepts/devbox.md b/docs/core-concepts/devbox.md index 6e4d10f..33ec09a 100644 --- a/docs/core-concepts/devbox.md +++ b/docs/core-concepts/devbox.md @@ -4,7 +4,7 @@ It bridges the gap between _local iteration speed_ and _production realism_. -#### Why Devbox +### Why Devbox Developing in Kubernetes usually means slow feedback loops: rebuild, redeploy, and wait for pods to restart just to test a single change.\ Devbox changes that by letting you **run your code locally while still connected to your real cluster environment**. @@ -15,7 +15,7 @@ This means: * Cluster traffic can be routed to your local process for real-time debugging. * You no longer need complex port-forwarding, VPNs, or separate mock setups. -#### How It Works +### How It Works When you start Devbox inside a Lapdev environment: @@ -24,17 +24,17 @@ When you start Devbox inside a Lapdev environment: 3. It can optionally intercept service traffic and forward it to your local process. 4. It provides seamless access to other workloads and in-cluster dependencies. -> 💡 Devbox doesn’t replace Kubernetes — it _extends_ it for developers.\ +> 💡 Devbox doesn't replace Kubernetes — it _extends_ it for developers.\ > You keep your production topology and cluster configuration, but develop with local speed. -#### Core Capabilities +### Core Capabilities * **Intercept Service Traffic:** Redirect in-cluster service requests to your local code. * **In-Cluster Connectivity:** Access internal APIs and databases as if you were inside the pod. * **Seamless IDE Debugging:** Run locally, attach debuggers, and see live logs. * **Compatible with All Environment Types:** Works with personal, shared, and branch environments. -#### How It Fits in the Lapdev Model +### How It Fits in the Lapdev Model | Concept | Role | | --------------- | ---------------------------------------------------------------------- | @@ -42,7 +42,7 @@ When you start Devbox inside a Lapdev environment: | **Environment** | A running instance of that app in Kubernetes. | | **Devbox** | Bridges your local machine with that environment for live development. | -#### When to Use Devbox +### When to Use Devbox * When you need fast feedback without redeploying to Kubernetes. * When debugging complex issues that depend on real cluster state. diff --git a/docs/how-to-guides/local-development-with-devbox.md b/docs/how-to-guides/local-development-with-devbox.md index a39630c..8fb5edb 100644 --- a/docs/how-to-guides/local-development-with-devbox.md +++ b/docs/how-to-guides/local-development-with-devbox.md @@ -2,19 +2,16 @@ This guide shows how to set up and use **Devbox** for local development with your Lapdev environments. -#### Prerequisites +> **New to Devbox?** Read [**Devbox**](../core-concepts/devbox.md) to understand what it does and when to use it. -* Lapdev CLI installed. -* An active Lapdev environment (personal or branch). -* `kubectl` access to the connected cluster. +### Prerequisites -#### Install Devbox +Before using Devbox, you need: -```bash -curl -sSL https://get.lap.dev/install-devbox.sh | bash -``` +* **Lapdev CLI installed** - Devbox is built into the `lapdev` command +* **An active Lapdev environment** - Personal, shared, or branch environment -#### Connect to Lapdev +### Connect to Lapdev First, connect your Devbox CLI to Lapdev: @@ -24,7 +21,7 @@ lapdev devbox connect This establishes a secure connection between your local machine and Lapdev. -#### Set Active Environment +### Set Active Environment After connecting, set which environment you want to work with in the Lapdev dashboard: @@ -36,7 +33,7 @@ Once set, all traffic interception and service access will route through this ac _Placeholder screenshot:_ -#### Intercept a Service +### Intercept a Service Once connected, you can intercept traffic for a specific service through the Lapdev dashboard: @@ -54,11 +51,11 @@ After enabling interception: _Placeholder screenshot:_ -#### Access In-Cluster Services +### Access In-Cluster Services While connected via Devbox, you can access any service in your environment using their cluster DNS names (e.g., `postgres-service:5432`, `redis-service:6379`) directly from your local code. Devbox handles the tunneling automatically — no port forwarding or VPN needed. -#### Next Steps +### Next Steps * Learn more about [Devbox architecture](../core-concepts/devbox.md) * Understand [traffic routing](../core-concepts/architecture/traffic-routing-architecture.md) From 7cc4fee229384c2187840a7b8d6fe10ff8d9cae6 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 18:04:11 +0000 Subject: [PATCH 113/334] update --- docs/core-concepts/preview-url.md | 48 ++++++++++---------------- docs/how-to-guides/use-preview-urls.md | 16 ++++----- 2 files changed, 25 insertions(+), 39 deletions(-) diff --git a/docs/core-concepts/preview-url.md b/docs/core-concepts/preview-url.md index 61457f0..810127a 100644 --- a/docs/core-concepts/preview-url.md +++ b/docs/core-concepts/preview-url.md @@ -1,8 +1,8 @@ # Preview URL -A **Preview URL** is a unique, automatically generated HTTPS endpoint that lets you access your Lapdev environment directly from the web — without any manual DNS or Ingress configuration. +A **Preview URL** is a unique, automatically generated HTTPS endpoint that lets you access services in your Lapdev environment directly from the web — without any manual DNS or Ingress configuration. -Every Lapdev environment, whether **personal**, **shared**, or **branch**, gets its own Preview URL.\ +You can create Preview URLs for any environment type (**personal**, **shared**, or **branch**), exposing specific services for access and sharing.\ This makes it easy to preview changes, share work with teammates, and test production-like behavior in real time. ### Why Preview URLs @@ -20,33 +20,16 @@ Lapdev solves this with **automatic Preview URLs** — secure, per-environment e ### How It Works -When you create an environment, Lapdev: +When you create a Preview URL for a service in your environment, Lapdev: -1. Detects your app’s exposed **Services** (e.g. `frontend`, `api`, `gateway`). -2. Automatically provisions a **unique domain** for that environment, such as: +1. Detects the service you want to expose (e.g. `frontend`, `api`, `gateway`) +2. Automatically generates a unique HTTPS domain for that service +3. Configures TLS certificates automatically — no `cert-manager`, DNS setup, or manual YAML needed +4. Routes traffic through Lapdev's managed proxy layer directly to your service inside the cluster - ``` - alice-checkout-feature.app.lap.dev - ``` -3. Configures HTTPS certificates automatically — no `cert-manager`, DNS setup, or manual YAML needed. -4. Routes traffic through Lapdev’s managed proxy layer directly to your workloads inside the target cluster. +All routing and TLS termination are handled by Lapdev's control plane, so your cluster stays secure and simple. -All routing and TLS termination are handled by Lapdev’s control plane, so your cluster stays secure and simple. - -### Domain and Structure - -Preview URLs follow a consistent, human-readable pattern: - -``` --.app.lap.dev -``` - -Examples: - -* `mia-checkout-feature.app.lap.dev` → personal or branch environment -* `staging-checkout.app.lap.dev` → shared environment - -This makes it easy to tell what you’re looking at — and safe to share with your team. +Each Preview URL is unique and automatically managed by Lapdev, making it safe to share with your team. ### Access Control @@ -74,8 +57,13 @@ Access settings are managed per environment in the Lapdev dashboard. | **Environment** | A running instance of the app in Kubernetes. | | **Preview URL** | Provides web access to that environment — with automatic routing and HTTPS. | -### Example Flow +### Usage Pattern + +Preview URLs make collaboration effortless: + +* Create an environment and expose the services you want to share +* Lapdev generates secure HTTPS URLs automatically +* Share URLs with teammates, QA, or stakeholders instantly +* No infrastructure changes, ingress configuration, or firewall rules needed -1. You create a **personal environment** from your App Catalog. -2. Lapdev deploys the workloads and assigns a Preview URL automatically. -3. You share that URL with a teammate — no ingress, no firewall changes, just click and open. +> For step-by-step instructions, see [**Use Preview URLs**](../how-to-guides/use-preview-urls.md). diff --git a/docs/how-to-guides/use-preview-urls.md b/docs/how-to-guides/use-preview-urls.md index 1a6d403..ad11a71 100644 --- a/docs/how-to-guides/use-preview-urls.md +++ b/docs/how-to-guides/use-preview-urls.md @@ -5,6 +5,8 @@ Lapdev lets you create **Preview URLs** for your environments so you can securel Each Preview URL points to a specific **service** inside your environment (for example, your `frontend`, `api-gateway`, or `admin` service).\ You can create multiple Preview URLs per environment, each targeting a different service. +> **New to Preview URLs?** Read [**Preview URL**](../core-concepts/preview-url.md) to understand what they are and why they're useful. + ### Prerequisites Before you begin: @@ -31,12 +33,8 @@ Before you begin: Lapdev will: -* Assign a unique domain like - - ``` - mia-frontend-feature.app.lap.dev - ``` -* Automatically handle routing, DNS, and HTTPS for that URL +* Automatically generate a unique HTTPS domain for the selected service +* Handle routing, DNS, and TLS certificates for that URL * Route traffic directly to the selected service inside your environment ### View and Open Preview URLs @@ -100,6 +98,6 @@ Deleting a Preview URL does **not** affect the environment or its workloads — ### Next Steps -* Learn how Preview URLs work internally -* Use Devbox for real-time debugging connected to your environment -* Explore App Catalogs to define which workloads appear in your environments +* Learn more about [Preview URLs](../core-concepts/preview-url.md) and how they work internally +* Use [Devbox](local-development-with-devbox.md) for real-time debugging connected to your environment +* Explore [App Catalogs](create-an-app-catalog.md) to define which workloads appear in your environments From c622c0836e848611542c0b69300c49661fd23dd6 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 18:12:26 +0000 Subject: [PATCH 114/334] update --- docs/core-concepts/cluster.md | 166 ++++++++++++++++++ .../connect-your-kubernetes-cluster.md | 31 ++-- 2 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 docs/core-concepts/cluster.md diff --git a/docs/core-concepts/cluster.md b/docs/core-concepts/cluster.md new file mode 100644 index 0000000..9a0c80f --- /dev/null +++ b/docs/core-concepts/cluster.md @@ -0,0 +1,166 @@ +# Cluster + +A **Cluster** in Lapdev refers to a Kubernetes cluster you've connected to Lapdev for managing development environments. + +Clusters can serve two roles: +- **Source Cluster** - Provides production manifests for creating App Catalogs +- **Target Cluster** - Hosts development environments + +The same cluster can serve both roles, which is the most common setup. + +### Why Connect Clusters + +Connecting your Kubernetes cluster to Lapdev enables: + +* **App Catalog Creation** - Read production manifests to define your app +* **Environment Deployment** - Run development environments in the cluster +* **Production Parity** - Ensure dev environments match production exactly +* **Centralized Management** - Manage multiple clusters from one dashboard + +### How It Works + +#### Lapdev Kube Manager + +When you connect a cluster, Lapdev deploys a lightweight controller called **lapdev-kube-manager** inside your cluster. + +The kube-manager: + +* **Establishes secure connection** - Opens a WebSocket tunnel to Lapdev API Server (TLS encrypted) +* **Reads production manifests** - Discovers Deployments, StatefulSets, ConfigMaps, Secrets, and Services +* **Creates dev environments** - Replicates selected workloads into isolated or shared namespaces +* **Handles traffic routing** - For branch environments, routes traffic to the correct version of services +* **Manages synchronization** - Keeps environments updated with production changes + +**Security Model:** + +* Connection is **outbound-only** from your cluster (no inbound firewall rules needed) +* Lapdev API Server **never** accesses your cluster's API server directly +* Kube-manager has **read access** to production namespaces +* Kube-manager has **full access** to Lapdev-managed namespaces only +* Cannot access other cluster resources or namespaces + +#### Token-Based Authentication + +Each cluster is authenticated using a unique token: + +* Generated when you create the cluster in Lapdev dashboard +* Used by kube-manager to establish the secure tunnel +* Stored as a Kubernetes Secret in your cluster + +### Cluster Roles + +#### Source Cluster + +A **source cluster** is where Lapdev reads production manifests to create App Catalogs. + +* Typically your **staging** or **production** cluster +* Contains the workloads you want to replicate in dev environments +* Kube-manager reads manifests but doesn't modify them +* You can designate any cluster as a source + +**Common setups:** +* Use production cluster as source (safest, always up-to-date) +* Use staging cluster as source (if staging mirrors production) +* Use dedicated "template" cluster (for controlled rollout) + +#### Target Cluster + +A **target cluster** is where Lapdev deploys development environments. + +* Can be the same as your source cluster or different +* Hosts all Personal, Shared, and Branch environments +* Kube-manager creates and manages namespaces here +* Choose based on resource availability and cost + +**Common setups:** +* Same cluster as source (simplest, most common) +* Dedicated dev cluster (isolates dev from production) +* Multiple dev clusters (for different teams or regions) + +### Cluster Permissions + +Cluster permissions control **which types of environments** can be deployed to a cluster. + +#### Personal Environments Permission + +When enabled, developers can create **Personal Environments** in this cluster. + +* Each developer gets fully isolated namespaces +* Higher resource usage (every dev runs complete app) +* Best for: staging/dev clusters with sufficient resources + +**Enable when:** +* Developers need full isolation for testing +* Cluster has resources for multiple complete environments +* You want individual developers to experiment freely + +#### Shared Environments Permission + +When enabled, admins can create **Shared Environments** and developers can create **Branch Environments** in this cluster. + +* Shared environments are team-wide baselines +* Branch environments are lightweight personal modifications +* Lower resource usage (shared baseline + modifications only) +* Best for: cost-efficient development at scale + +**Enable when:** +* Team uses branch environment workflow +* You want cost-efficient development environments +* Multiple developers work on the same application + +#### Permission Use Cases + +| Cluster Type | Personal | Shared | Use Case | +|-------------|----------|---------|-----------| +| Dev Cluster | ✅ | ✅ | Full flexibility for developers | +| Staging Cluster | ❌ | ✅ | Team-wide testing baseline only | +| Testing Cluster | ✅ | ❌ | Isolated testing environments | +| Production | ❌ | ❌ | Source only, no dev environments | + +> You can change these permissions at any time in the cluster settings. + +### Relationship to Other Components + +Clusters are foundational to Lapdev's workflow: + +``` +Source Cluster + ↓ (provides manifests) +App Catalog + ↓ (blueprint) +Target Cluster + ↓ (deploys to) +Environments (Personal/Shared/Branch) +``` + +| Component | Role with Clusters | +|-----------|-------------------| +| **Cluster** | Provides infrastructure and manifests | +| **App Catalog** | Created by reading manifests from source cluster | +| **Environment** | Deployed into target cluster using App Catalog | +| **Kube Manager** | Runs inside cluster, bridges to Lapdev API | + +### Multiple Clusters + +You can connect multiple clusters to Lapdev for different purposes: + +**Common multi-cluster setups:** + +* **Source + Multiple Targets:** + - Production cluster (source only) + - Dev cluster A (target for team A) + - Dev cluster B (target for team B) + +* **Regional Separation:** + - US cluster (source + target) + - EU cluster (target only, for EU developers) + +* **Environment Segregation:** + - Staging cluster (source + shared environments) + - Dev cluster (personal environments only) + +Each cluster appears in your Lapdev dashboard with its own status, permissions, and environments. + +### Next Steps + +Ready to connect your cluster? See [**Connect Your Kubernetes Cluster**](../how-to-guides/connect-your-kubernetes-cluster.md) for step-by-step instructions. diff --git a/docs/how-to-guides/connect-your-kubernetes-cluster.md b/docs/how-to-guides/connect-your-kubernetes-cluster.md index 12c4d0f..f76215c 100644 --- a/docs/how-to-guides/connect-your-kubernetes-cluster.md +++ b/docs/how-to-guides/connect-your-kubernetes-cluster.md @@ -2,6 +2,8 @@ This guide walks you through connecting your Kubernetes cluster to **Lapdev**, so Lapdev can manage development environments inside your cluster. +> **New to Clusters?** Read [**Cluster**](../core-concepts/cluster.md) to understand cluster roles, permissions, and how kube-manager works. + ### Create a Cluster in the Lapdev Dashboard 1. Go to the Lapdev dashboard: [https://app.lap.dev](https://app.lap.dev) @@ -20,27 +22,21 @@ kubectl create namespace lapdev kubectl apply -f https://get.lap.dev/lapdev-kube-manager.yaml ``` -> 💡 The `lapdev-kube-manager` is a lightweight controller that securely connects your Kubernetes cluster to Lapdev.\ -> It runs inside the `lapdev` namespace and handles synchronisation, environment creation, and traffic routing. You can read more about how it works in our [Architecture doc](../core-concepts/architecture/). +This deploys the `lapdev-kube-manager` controller that securely connects your cluster to Lapdev. ### Configure Cluster Permissions -After the cluster is created, you’ll see **permission settings** in the cluster details page. - -These control [**what kinds of environments**](../core-concepts/environment.md) can be deployed to this cluster: - -* **Personal Environments:**\ - Allow individual developers to create isolated environments directly in this cluster. Each developer’s workloads, ConfigMaps, and Secrets will be namespaced separately for full isolation. -* **Shared Environments:**\ - Allow shared or branch environments that multiple developers can use together — ideal for cost-efficient setups. +After the cluster is connected, configure which environment types can be deployed to this cluster: -You can toggle these permissions **on or off** at any time, depending on how you want the cluster to be used. +1. Go to the cluster details page in the dashboard +2. Find the **Permissions** section +3. Toggle permissions based on your needs: + * **Personal Environments** - Allow developers to create isolated environments + * **Shared Environments** - Allow team-wide baseline and branch environments -For example: +You can change these settings at any time. -* Enable both for a staging or dev cluster. -* Enable only shared environments for a pre-prod cluster used by the whole team. -* Enable only personal environments for testing or private development. +> Learn more about cluster permissions and use cases in [**Cluster**](../core-concepts/cluster.md). ### Verify Connection @@ -64,5 +60,6 @@ Your cluster is now connected to Lapdev! 🎉 You can start: -* Creating [environments](../core-concepts/environment.md) -* Using [Devbox CLI](local-development-with-devbox.md) for local development (link to CLI doc) +* Creating an [App Catalog](create-an-app-catalog.md) from your cluster's workloads +* Creating [Environments](create-lapdev-environment.md) from your App Catalog +* Learn more about [Cluster concepts](../core-concepts/cluster.md) and architecture From cac8466c46b8140a364c94c30230f0c567630ea4 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 19:11:12 +0000 Subject: [PATCH 115/334] update --- docs/core-concepts/app-catalog.md | 3 +- .../traffic-routing-architecture.md | 70 ++++++++++++++++++- docs/core-concepts/devbox.md | 4 ++ docs/how-to-guides/create-an-app-catalog.md | 12 ++-- .../create-lapdev-environment.md | 14 ++-- 5 files changed, 88 insertions(+), 15 deletions(-) diff --git a/docs/core-concepts/app-catalog.md b/docs/core-concepts/app-catalog.md index 2ab1951..1866b0a 100644 --- a/docs/core-concepts/app-catalog.md +++ b/docs/core-concepts/app-catalog.md @@ -23,7 +23,8 @@ The App Catalog solves this by: 4. The catalog is stored in Lapdev and can be reused to create environments on any connected cluster. > 💡 The **source cluster** is typically your staging or production cluster.\ -> You can also use a dedicated cluster that mirrors production manifests. +> You can also use a dedicated cluster that mirrors production manifests.\ +> Learn more about cluster roles in [**Cluster**](cluster.md). ### Relationship Between App Catalogs and Environments diff --git a/docs/core-concepts/architecture/traffic-routing-architecture.md b/docs/core-concepts/architecture/traffic-routing-architecture.md index 73426e0..4351274 100644 --- a/docs/core-concepts/architecture/traffic-routing-architecture.md +++ b/docs/core-concepts/architecture/traffic-routing-architecture.md @@ -33,14 +33,15 @@ Both patterns use secure tunnels through the Lapdev cloud service, eliminating t **App Workload Pod** * Your application container(s) -* Runs unmodified - no changes needed to your application code +* Runs unmodified in Personal and Shared environments +* Branch environments may require header propagation (see Branch Environment Routing below) **Lapdev Sidecar Proxy** -* Injected into each pod in Lapdev environments +* Automatically injected into each pod in Lapdev environments +* Routes traffic for branch environments based on tracestate headers * Handles traffic interception when Devbox is active * Routes traffic between local machine and cluster services -* Transparent to your application code #### In Lapdev Environment (Namespace-level) @@ -65,3 +66,66 @@ Both patterns use secure tunnels through the Lapdev cloud service, eliminating t * Establishes secure tunnel to cluster * Intercepts traffic for specific services * Provides transparent access to in-cluster services + +### Traffic Flows + +#### Preview URL Traffic + +When a user accesses a Preview URL: + +1. **Browser** → Request to `https://service-name.app.lap.dev` +2. **Lapdev Cloud Service** → Authenticates request (if access control enabled) +3. **Lapdev Cloud Service** → Routes through WebSocket tunnel to kube-manager +4. **Kube-Manager** → Forwards to appropriate environment namespace +5. **Sidecar Proxy** → Routes to target service based on environment type: + - **Personal/Shared:** Routes directly to service + - **Branch:** Checks tracestate header and routes to branched or shared version +6. **Service** → Processes request and returns response + +The response flows back through the same path to the user's browser. + +#### Devbox Intercept Traffic + +When a developer intercepts a service with Devbox: + +1. **Developer runs** `lapdev devbox connect` and enables intercept in dashboard +2. **Devbox CLI** → Establishes secure tunnel: `Local machine → Lapdev Cloud → Kube-Manager → Devbox Proxy` +3. **Cluster traffic** for intercepted service → Routes to Devbox Proxy +4. **Devbox Proxy** → Forwards to developer's local machine via tunnel +5. **Local service** → Developer's code running on localhost processes the request +6. **Response** flows back through tunnel to cluster + +When no intercept is active, traffic routes normally to the in-cluster service. + +#### Branch Environment Routing + +Branch environments use intelligent routing based on tracestate headers: + +1. **Request enters** through Preview URL with branch-specific tracestate header (auto-injected by Lapdev) +2. **Sidecar Proxy** reads tracestate header to identify the branch +3. **Routing decision:** + - Service modified in branch? → Route to branch version + - Service not modified? → Route to shared environment version +4. **Header propagation:** Application must forward headers to downstream services +5. **Next hop:** Process repeats at each service + +This enables multiple developers to test different modifications simultaneously without conflicts. + +> **Important:** Branch environment routing requires your application to propagate the tracestate header in HTTP calls. See [Branch Environment Architecture](branch-environment-architecture.md) for implementation details. + +### Component Roles Summary + +| Component | Purpose | When Used | +|-----------|---------|-----------| +| **Sidecar Proxy** | Routes traffic based on environment type and headers | All environments | +| **Devbox Proxy** | Manages intercept tunnels to developer machines | When Devbox is active | +| **Kube-Manager** | Orchestrates environments and maintains cloud connection | Always running | +| **Lapdev Cloud** | Routes external traffic and manages authentication | Preview URLs and Devbox | + +### Learn More + +For detailed information on specific routing patterns: + +* **Branch Environment Routing** - See [Branch Environment Architecture](branch-environment-architecture.md) for tracestate header propagation, routing mechanism, and troubleshooting +* **Devbox Integration** - See [Devbox](../devbox.md) for local development workflows +* **Cluster Architecture** - See [Architecture Overview](README.md) for overall system design diff --git a/docs/core-concepts/devbox.md b/docs/core-concepts/devbox.md index 33ec09a..c8dd0b7 100644 --- a/docs/core-concepts/devbox.md +++ b/docs/core-concepts/devbox.md @@ -48,3 +48,7 @@ When you start Devbox inside a Lapdev environment: * When debugging complex issues that depend on real cluster state. * When integrating or testing locally while keeping the rest of the system in-cluster. +### Next Steps + +Ready to use Devbox? See [**Local Development with Devbox**](../how-to-guides/local-development-with-devbox.md) for setup instructions. + diff --git a/docs/how-to-guides/create-an-app-catalog.md b/docs/how-to-guides/create-an-app-catalog.md index ca60a74..6a9160b 100644 --- a/docs/how-to-guides/create-an-app-catalog.md +++ b/docs/how-to-guides/create-an-app-catalog.md @@ -10,10 +10,12 @@ This guide walks you through creating an App Catalog from your connected cluster Before creating an App Catalog, make sure: -* You’ve connected at least one **Kubernetes cluster** to Lapdev. -* The cluster you’ll read workloads from shows as **Active** in the Lapdev dashboard. +* You've connected at least one **Kubernetes cluster** to Lapdev. +* The cluster you'll read workloads from shows as **Active** in the Lapdev dashboard. * The cluster contains the workloads you want to include (e.g. your production or staging workloads). +> Don't have a cluster connected yet? See [**Connect Your Kubernetes Cluster**](connect-your-kubernetes-cluster.md). + > 💡 The same cluster can be used later for both reading workloads **and** deploying environments. ### Open the Cluster in the Dashboard @@ -66,7 +68,7 @@ _Example screenshot:_ Once your App Catalog is ready, you can: -* Create a Lapdev Environment from it -* Edit or sync it when workloads change -* Manage environment templates for multiple apps +* Create a [Lapdev Environment](create-lapdev-environment.md) from it +* Learn about [Environment types](../core-concepts/environment.md) +* Understand [Cluster roles](../core-concepts/cluster.md) for multi-cluster setups diff --git a/docs/how-to-guides/create-lapdev-environment.md b/docs/how-to-guides/create-lapdev-environment.md index 70a91bd..4e3f30b 100644 --- a/docs/how-to-guides/create-lapdev-environment.md +++ b/docs/how-to-guides/create-lapdev-environment.md @@ -2,18 +2,20 @@ A **Lapdev Environment** is a running instance of your app inside a Kubernetes cluster. -It’s created from an existing **App Catalog**, which defines which workloads make up your application. +It's created from an existing **App Catalog**, which defines which workloads make up your application. This guide walks you through how to create personal, shared, and branch environments from an App Catalog. +> **New to Environments?** Read [**Environment**](../core-concepts/environment.md) to understand environment types and how they work. + ### Prerequisites Before creating an environment: * You must have at least one **connected Kubernetes cluster** (Active in the Lapdev dashboard). -* You must have an existing **App Catalog** that defines your app’s workloads. +* You must have an existing **App Catalog** that defines your app's workloads. - > If you haven’t created one yet, follow Create an App Catalog. + > If you haven't created one yet, see [**Create an App Catalog**](create-an-app-catalog.md). ### Start from an App Catalog @@ -63,6 +65,6 @@ You can monitor status, logs, and sync state directly from the dashboard. Your environment is ready! You can now: -* Use Devbox CLI to connect locally for live debugging. -* Sync environment configuration with production updates. -* Manage or delete environments when you’re done. +* Use [Devbox](local-development-with-devbox.md) to connect locally for live debugging +* Create [Preview URLs](use-preview-urls.md) to share your work +* Learn more about [Environments](../core-concepts/environment.md) From 54b9eae624bc32a5f2f7d2a176ba97fe2ebb2f71 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 19:22:59 +0000 Subject: [PATCH 116/334] update --- docs/README.md | 12 ++++++------ docs/SUMMARY.md | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/README.md b/docs/README.md index 820e9ea..3f482fa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,13 +23,13 @@ Lapdev reads your **production Kubernetes manifests directly from your cluster** ### Automatic Sync with Production * Lapdev continuously monitors your production manifests for changes -* Get notified when ConfigMaps, Secrets, or deployment specs are updated -* Pull updates into your environment with one click, or enable auto-sync +* App Catalogs automatically update when their source workloads change +* All environments created from the catalog use the latest configuration * **Never again:** "My dev environment is 3 months behind prod" ### Flexible Environment Models -**Isolated Environments:** Each developer gets a complete, independent copy of all workloads. Perfect for testing breaking changes or complex multi-service interactions with full isolation. +**Personal Environments:** Each developer gets a complete, independent copy of all workloads. Perfect for testing breaking changes or complex multi-service interactions with full isolation. **Branch Environments (Cost-Effective):** A shared baseline environment runs all services once. Developers create lightweight "branch environments" for only the services they're actively modifying. Lapdev automatically routes your traffic to your version while everything else uses the shared baseline. @@ -44,10 +44,10 @@ Lapdev includes **Devbox**, a CLI tool that integrates seamlessly with your envi ### Preview URLs with Zero Configuration -Every environment gets: +Create Preview URLs for any service in your environment: -* A unique URL with a preconfigured domain: `alice-checkout-feature.app.lap.dev` -* Automatic HTTPS +* Unique HTTPS URLs automatically generated for each service +* Automatic TLS certificates and DNS configuration * Traffic proxied directly to your in-cluster services * Optional access control - configure URLs to be accessible only to Lapdev logged-in users * No firewall changes, no manual Ingress configuration, no cert-manager setup diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index f3d3cbf..da4c2f8 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -15,6 +15,7 @@ * [Architecture](core-concepts/architecture/README.md) * [Traffic Routing Architecture](core-concepts/architecture/traffic-routing-architecture.md) * [Branch Environment Architecture](core-concepts/architecture/branch-environment-architecture.md) +* [Cluster](core-concepts/cluster.md) * [App Catalog](core-concepts/app-catalog.md) * [Environment](core-concepts/environment.md) * [Devbox](core-concepts/devbox.md) From bb7466fa41ca1caca0e509f97eaf85913ef7c064 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 19:26:18 +0000 Subject: [PATCH 117/334] update --- docs/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/README.md b/docs/README.md index 3f482fa..b41a273 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,3 +57,12 @@ Share your work with teammates, PMs, or QA instantly - they can test your change ### Easy Installation in Your Cluster Lapdev requires just one deployment `lapdev-kube-manager` installed in your cluster. That's it. + +## Getting Started + +1. [Connect Your Kubernetes Cluster](how-to-guides/connect-your-kubernetes-cluster.md) +2. [Create an App Catalog](how-to-guides/create-an-app-catalog.md) +3. [Create Your First Environment](how-to-guides/create-lapdev-environment.md) +4. [Start Local Development with Devbox](how-to-guides/local-development-with-devbox.md) + +Learn more about [Core Concepts](core-concepts/architecture/README.md). From e3004034366d9478df5c483075f4af35e9e8d6f7 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 19:29:45 +0000 Subject: [PATCH 118/334] update --- docs/core-concepts/architecture/README.md | 40 +++++++++++++++++------ 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/core-concepts/architecture/README.md b/docs/core-concepts/architecture/README.md index 49239f8..1842ef7 100644 --- a/docs/core-concepts/architecture/README.md +++ b/docs/core-concepts/architecture/README.md @@ -10,7 +10,7 @@ Lapdev consists of three main components: 1. **Lapdev API Server** (SaaS) - Manages users, authentication, and orchestrates environment creation 2. **Lapdev-Kube-Manager** (In your cluster) - Reads production manifests and manages dev environments -3. **Devbox CLI** (Developer's machine) - Enables local debugging with cluster connectivity +3. **[Devbox](../devbox.md) CLI** (Developer's machine) - Enables local debugging with cluster connectivity ### Architecture Diagram @@ -25,7 +25,7 @@ The Lapdev cloud service handles: * **User authentication and authorization** - GitHub/Google OAuth, team management * **Environment orchestration** - Receives environment creation requests from users * **Secure tunnel management** - Establishes websocket tunnels between your cluster and Lapdev -* **Preview URL routing** - Routes traffic from preview URLs (e.g., `alice-feature.app.lap.dev`) to your cluster +* **[Preview URL](../preview-url.md) routing** - Routes traffic from automatically generated HTTPS URLs to your cluster **Security:** @@ -37,10 +37,10 @@ The Lapdev cloud service handles: Deployed as a single Kubernetes deployment in your cluster, `lapdev-kube-manager`: -* **Reads production manifests** - Discovers Deployments, StatefulSets, ConfigMaps, Secrets, and Services from your production namespace -* **Creates dev environments** - Replicates selected workloads into isolated or shared namespaces +* **Reads production manifests** - Discovers Deployments, StatefulSets, ConfigMaps, Secrets, and Services from your production namespace to build [App Catalogs](../app-catalog.md) +* **Creates dev [environments](../environment.md)** - Replicates selected workloads into isolated or shared namespaces * **Manages sync** - Monitors production manifests for changes and updates dev environments -* **Handles traffic routing** - For branch environments, routes traffic to the correct version of services +* **Handles traffic routing** - For [branch environments](branch-environment-architecture.md), routes traffic to the correct version of services (see [Traffic Routing Architecture](traffic-routing-architecture.md)) * **Establishes secure tunnel** - Maintains websocket connection to Lapdev API Server for orchestration **Permissions:** @@ -51,15 +51,35 @@ Deployed as a single Kubernetes deployment in your cluster, `lapdev-kube-manager #### Devbox CLI (Developer Machine) -The `devbox` command-line tool enables local development: +The `lapdev devbox` command-line tool enables local development: -* **Traffic interception** - Routes requests for specific services to `localhost` +* **Traffic interception** - Routes requests for specific services to `localhost` (controlled via Lapdev dashboard) * **Cluster connectivity** - Provides transparent access to in-cluster services (databases, APIs, caches) * **Secure tunnel** - Establishes encrypted connection to Lapdev API Server, which proxies to your cluster **How it works:** -1. Developer runs `lapdev devbox intercept checkout-service` +1. Developer runs `lapdev devbox connect` and sets their active environment in the dashboard 2. Devbox establishes secure tunnel: `Developer → Lapdev API → lapdev-kube-manager` -3. Traffic for `checkout-service` is routed to developer's localhost -4. Developer's code can access cluster services transparently (e.g., `http://payment-service:8080`) +3. Developer enables traffic interception for specific services in the Lapdev dashboard +4. Traffic for intercepted services is routed to developer's localhost +5. Developer's code can transparently access in-cluster services (e.g., `http://payment-service:8080`) + +Learn more: [Devbox Concept](../devbox.md) | [Local Development with Devbox](../../how-to-guides/local-development-with-devbox.md) + +### Learn More + +**Specialized Architecture Documentation:** +* [Traffic Routing Architecture](traffic-routing-architecture.md) - How Lapdev routes traffic between components +* [Branch Environment Architecture](branch-environment-architecture.md) - Cost-effective environment model details + +**Core Concepts:** +* [Cluster](../cluster.md) - How clusters connect to Lapdev +* [App Catalog](../app-catalog.md) - Blueprint for your application +* [Environment](../environment.md) - Running instances of your app +* [Preview URL](../preview-url.md) - HTTPS access to your services + +**Getting Started:** +* [Connect Your Kubernetes Cluster](../../how-to-guides/connect-your-kubernetes-cluster.md) +* [Create an App Catalog](../../how-to-guides/create-an-app-catalog.md) +* [Create Lapdev Environment](../../how-to-guides/create-lapdev-environment.md) From feb4ed5783867f2206ea11dbdbeedde15d0f8ab0 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 19:31:19 +0000 Subject: [PATCH 119/334] update --- .../traffic-routing-architecture.md | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/core-concepts/architecture/traffic-routing-architecture.md b/docs/core-concepts/architecture/traffic-routing-architecture.md index 4351274..e738ea8 100644 --- a/docs/core-concepts/architecture/traffic-routing-architecture.md +++ b/docs/core-concepts/architecture/traffic-routing-architecture.md @@ -6,8 +6,8 @@ This document explains how traffic flows through Lapdev for both preview URLs an Lapdev handles two main traffic patterns: -1. **Preview URL Traffic** - External users accessing your development environment via browser -2. **Devbox Traffic** - Developers debugging locally while accessing cluster services +1. **[Preview URL](../preview-url.md) Traffic** - External users accessing your development environment via browser +2. **[Devbox](../devbox.md) Traffic** - Developers debugging locally while accessing cluster services Both patterns use secure tunnels through the Lapdev cloud service, eliminating the need for VPNs or firewall changes. @@ -24,7 +24,7 @@ Both patterns use secure tunnels through the Lapdev cloud service, eliminating t * Orchestrates environment creation and management * Maintains connection to Lapdev cloud service -**Lapdev Environment (Namespace)** +**Lapdev [Environment](../environment.md) (Namespace)** * Contains your replicated workloads * Each environment is isolated in its own namespace @@ -34,7 +34,7 @@ Both patterns use secure tunnels through the Lapdev cloud service, eliminating t * Your application container(s) * Runs unmodified in Personal and Shared environments -* Branch environments may require header propagation (see Branch Environment Routing below) +* [Branch environments](branch-environment-architecture.md) may require header propagation (see Branch Environment Routing below) **Lapdev Sidecar Proxy** @@ -73,7 +73,7 @@ Both patterns use secure tunnels through the Lapdev cloud service, eliminating t When a user accesses a Preview URL: -1. **Browser** → Request to `https://service-name.app.lap.dev` +1. **Browser** → Request to automatically generated HTTPS URL 2. **Lapdev Cloud Service** → Authenticates request (if access control enabled) 3. **Lapdev Cloud Service** → Routes through WebSocket tunnel to kube-manager 4. **Kube-Manager** → Forwards to appropriate environment namespace @@ -124,8 +124,16 @@ This enables multiple developers to test different modifications simultaneously ### Learn More -For detailed information on specific routing patterns: +**Specialized Routing Documentation:** +* [Branch Environment Architecture](branch-environment-architecture.md) - Tracestate header propagation, routing mechanism, and troubleshooting +* [Architecture Overview](README.md) - Overall system design and component interactions -* **Branch Environment Routing** - See [Branch Environment Architecture](branch-environment-architecture.md) for tracestate header propagation, routing mechanism, and troubleshooting -* **Devbox Integration** - See [Devbox](../devbox.md) for local development workflows -* **Cluster Architecture** - See [Architecture Overview](README.md) for overall system design +**Core Concepts:** +* [Environment](../environment.md) - Personal, Shared, and Branch environment types +* [Devbox](../devbox.md) - Local development with cluster connectivity +* [Preview URL](../preview-url.md) - HTTPS access to your services + +**How-To Guides:** +* [Use Preview URLs](../../how-to-guides/use-preview-urls.md) - Create and manage preview URLs +* [Local Development with Devbox](../../how-to-guides/local-development-with-devbox.md) - Set up traffic interception and cluster access +* [Create Lapdev Environment](../../how-to-guides/create-lapdev-environment.md) - Set up different environment types From cec945d153296c304f0a58d49a9e04fac5bc044a Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 19:31:58 +0000 Subject: [PATCH 120/334] update --- .../architecture/branch-environment-architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core-concepts/architecture/branch-environment-architecture.md b/docs/core-concepts/architecture/branch-environment-architecture.md index 4f3e0e7..32c2fc8 100644 --- a/docs/core-concepts/architecture/branch-environment-architecture.md +++ b/docs/core-concepts/architecture/branch-environment-architecture.md @@ -1,6 +1,6 @@ # Branch Environment Architecture -Branch environments are a cost-effective way to run development environments in Kubernetes. Instead of duplicating all services for each developer, branch environments build on a shared environment and only run the services you're actively modifying. +Branch environments are a cost-effective way to run development environments in Kubernetes. Instead of duplicating all services for each developer, branch environments build on a [shared environment](../environment.md) and only run the services you're actively modifying. ### Architecture Diagram From 7ed3ae465633c56595ad87774b19f2d591290fbf Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 19:32:54 +0000 Subject: [PATCH 121/334] update --- .../branch-environment-architecture.md | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/core-concepts/architecture/branch-environment-architecture.md b/docs/core-concepts/architecture/branch-environment-architecture.md index 32c2fc8..9acffa5 100644 --- a/docs/core-concepts/architecture/branch-environment-architecture.md +++ b/docs/core-concepts/architecture/branch-environment-architecture.md @@ -55,7 +55,7 @@ When you create a branch, you specify which service(s) you're modifying: #### Traffic Routing Overview -When someone accesses your branch preview URL, Lapdev routes traffic intelligently: +When someone accesses your branch [preview URL](../preview-url.md), Lapdev routes traffic intelligently: * Requests to services you've modified → route to your branch version * Requests to all other services → route to the shared environment @@ -66,7 +66,7 @@ When someone accesses your branch preview URL, Lapdev routes traffic intelligent **1. Header Injection** -When a request enters through a preview URL, Lapdev automatically injects an OpenTelemetry `tracestate` header that identifies which branch the request belongs to. +When a request enters through a [preview URL](../preview-url.md), Lapdev automatically injects an OpenTelemetry `tracestate` header that identifies which branch the request belongs to. **2. Header Propagation (Your Responsibility)** @@ -105,7 +105,7 @@ The Lapdev Sidecar Proxy (automatically injected by Lapdev into each pod in mana 5. Routes to the shared environment if no override exists 6. The header continues to propagate to the next service -> **Note:** The sidecar proxy is automatically added when Lapdev creates your environment. No manual configuration needed. +> **Note:** The sidecar proxy is automatically added when Lapdev creates your environment. No manual configuration needed. For more details, see [Traffic Routing Architecture](traffic-routing-architecture.md). ### Key Benefits @@ -167,3 +167,18 @@ This is **expected behavior**. Branch environments share databases and other sta * Use branch-specific database prefixes or namespaces * Test with read-only database operations * Use separate test databases per branch (requires manual configuration) + +### Learn More + +**Related Architecture Documentation:** +* [Traffic Routing Architecture](traffic-routing-architecture.md) - Detailed explanation of how routing works across all environment types +* [Architecture Overview](README.md) - Overall system design and component interactions + +**Core Concepts:** +* [Environment](../environment.md) - Understanding Personal, Shared, and Branch environments +* [Preview URL](../preview-url.md) - HTTPS access to your services +* [App Catalog](../app-catalog.md) - Blueprint for your application + +**How-To Guides:** +* [Create Lapdev Environment](../../how-to-guides/create-lapdev-environment.md) - How to create branch environments +* [Use Preview URLs](../../how-to-guides/use-preview-urls.md) - Access and share your branch environment From 3e7ae86400102c6c6160940e500f3c4544cb401a Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Fri, 17 Oct 2025 21:29:34 +0000 Subject: [PATCH 122/334] update --- plans/automatic-sync.md | 846 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 846 insertions(+) create mode 100644 plans/automatic-sync.md diff --git a/plans/automatic-sync.md b/plans/automatic-sync.md new file mode 100644 index 0000000..b3256a3 --- /dev/null +++ b/plans/automatic-sync.md @@ -0,0 +1,846 @@ +# Automatic Environment Sync Feature Design + +**Status:** Approved +**Date:** 2025-10-17 + +## Overview + +The automatic sync feature monitors production Kubernetes manifests for changes and propagates those changes to App Catalogs and Environments. This ensures development environments stay up-to-date with production without manual intervention. + +## Problem Statement + +Currently, when production workloads change (new container images, updated ConfigMaps, resource limit changes), these changes don't automatically propagate to: +1. App Catalogs (the blueprint) +2. Development Environments (running instances) + +This causes drift between production and development environments. + +## Goals +- Keep Lapdev catalogs and environments aligned with production-derived manifests with minimal human interaction. +- Give teams fine-grained control over when and how changes propagate so active development work is not disrupted. +- Centralize business logic in the API server to avoid customer-side upgrades when iteration is needed. +- Provide the minimum viable visibility (counts, names, timestamps) required for users to trust automated propagation. +- Lay groundwork for richer change insight (diffs, approvals, policy) without blocking the first release. + +## Non-Goals +- Building a full GitOps replacement or storing full manifest history beyond what is needed for sync decisions. +- Surfacing full YAML diffs or per-field approvals in the initial release. +- Automatically reconciling environment-specific overrides (resource patches, image overrides) created by developers. +- Handling cross-cluster topologies; v1 is scoped to a single source cluster feeding multiple Lapdev catalogs/environments. + +## Assumptions & Scope +- Production clusters already expose the namespaces we watch and can be reached by kube-manager. +- Catalog workloads accurately enumerate the production workloads we care about; unexpected workloads are ignored. +- Environment namespaces may contain additional developer resources; only catalog-owned workloads and discovered dependencies are mutated. +- Secrets stay below size limits that make in-band transport feasible; large-object handling is deferred to future work. + +## Vocabulary +- **Source Cluster**: The production Kubernetes cluster we mirror from. +- **App Catalog**: The Lapdev blueprint that defines which workloads belong to a product line. +- **Environment**: A Lapdev-managed namespace (shared or branch) created from a catalog. +- **Auto-Sync**: Flag indicating the system may apply changes without waiting for explicit approval at that level. +- **Sync Record**: Row in `kube_app_catalog_sync_status` that tracks detected workload changes. + +## Architecture Decision: Catalog vs Environment Storage + +**Key Design Choice:** + +- **Workloads**: Stored in `kube_app_catalog_workload` (catalog-level) + - Catalogs define which workloads to track + - Environments inherit workload specs from catalog + +- **ConfigMaps, Secrets, Services**: Stored in `kube_environment_*` tables (environment-level only) + - NOT stored in catalog tables + - Catalog watches detect changes, but data lives on environments + - Each environment has its own ConfigMaps/Secrets/Services + - Sync updates environment-level resources directly + +**Rationale:** +- ConfigMaps/Secrets often contain environment-specific values (dev vs staging vs prod) +- Services are discovered dynamically based on current workload selectors +- Storing at environment level allows per-environment customization while still syncing from source +- A dedicated dependency index (`kube_environment_dependency`) tracks which production ConfigMaps/Secrets feed each environment so change detection can fan out events without re-scanning workloads. + +## Design Decisions + +### 1. Change Detection: Kubernetes Watch + +**Decision:** Use Kubernetes Watch API for real-time change detection + +**Implementation Architecture:** + +**Kube-Manager (Deployed in Customer Cluster):** +- Simple event forwarder with minimal logic +- Takes list of namespaces to watch (provided by API server) +- Watches **ALL** resource types in those namespaces: + - Workloads: Deployments, StatefulSets, DaemonSets, ReplicaSets, Jobs, CronJobs + - ConfigMaps + - Secrets + - Services +- Sends raw change events to API server +- **No decision logic** - just forwards events + +**API Server (Centralized Lapdev Service):** +- Receives raw change events from kube-manager +- **Intelligent decision making:** + - Which events belong to which catalogs? + - Which workload changes should trigger catalog sync? + - Which ConfigMap/Secret/Service changes should trigger environment sync? + - Should this change trigger immediate downstream syncs or surface manual prompts? +- Can be improved/upgraded without touching customer deployments + +**Rationale:** +- Kube-manager is deployed in customer clusters (hard to upgrade) +- API server is centralized (easy to upgrade/improve) +- Separation of concerns: kube-manager = event collector, API = decision maker +- Business logic changes don't require customer software upgrades + +**RPC Interface:** + +```rust +// Kube-Manager → API Server (reports events) +trait KubeClusterRpc { + async fn report_resource_change(event: ResourceChangeEvent) -> Result<()>; +} + +struct ResourceChangeEvent { + namespace: String, + resource_type: ResourceType, // Deployment, StatefulSet, DaemonSet, ConfigMap, Secret, Service + resource_name: String, + change_type: ChangeType, // Created, Updated, Deleted + resource_yaml: String, // Full resource spec in YAML format + timestamp: DateTime, +} + +// API Server → Kube-Manager (configures watches) +trait KubeManagerRpc { + async fn configure_watches(namespaces: Vec) -> Result<()>; +} +``` + +**Event Flow Example:** +1. Production deployment `api-server` updated in namespace `production` +2. Kube-manager detects change via Kubernetes Watch +3. Kube-manager sends: `report_resource_change(ResourceChangeEvent { namespace: "production", resource_type: Deployment, resource_name: "api-server", ... })` +4. API server receives event +5. API server queries: "Which catalog has workload 'api-server' in namespace 'production'?" → finds catalog ID +6. API server creates/updates `kube_app_catalog_sync_status` record +7. API server immediately updates the catalog and records the change in `kube_app_catalog_sync_status` + +### 2. Catalog Updates: Always Automatic + +**Decision:** App Catalogs always auto-apply detected workload changes; no manual approval path. + +**Behavior:** +- Catalog update executes immediately after the Sync Decision Engine validates a workload change. +- Users receive a post-apply notification summarizing the change (e.g., "Catalog 'production-services' auto-updated (3 workloads changed)"). +- Catalog update instantly triggers downstream environment sync handling (respecting environment auto-sync flags). + +**Catalog Notification (Activity Summary):** + +``` +┌─────────────────────────────────────────────────┐ +│ App Catalog: production-services │ +├─────────────────────────────────────────────────┤ +│ ✅ Auto-sync applied from Production │ +│ │ +│ Changes detected: │ +│ • 3 Workloads changed │ +│ Applied at: 2025-10-17T22:15Z │ +│ │ +│ [View Details] │ +└─────────────────────────────────────────────────┘ +``` + +**Scope:** +- **App Catalog watches**: Workloads only +- **No tracking**: ConfigMaps, Secrets, Services (environment-level concerns) +- **Granularity**: Count only (e.g., "3 workloads") +- **No detailed diffs**: Too complex for v1; link routes to workload list for context + +### 3. Environment Sync: Default Manual, Auto Opt-In + +**Decision:** Environment sync defaults to manual approval, but environments can opt into auto-sync + +**Rationale:** +- Developers are actively working in environments; unexpected updates can break work in progress. +- Some teams want fully automated drift correction for shared QA/staging namespaces. +- Allowing an explicit opt-in keeps developers in control while supporting hands-off pipelines. + +**Behavior:** +- When catalog updates, ALL environments show "Update available" by default with `Sync From Catalog` highlighted. +- When ConfigMap/Secret changes arrive, impacted environments show "Config update available" with `Sync With Cluster` highlighted. +- Environments with `auto_sync=false` require a manual button click for each action type; auto environments run both actions as events arrive. +- Environments with `auto_sync=true` apply the relevant action automatically and surface the outcome afterward (success, failure, conflict). + +**Environment Notification (Simple Summary):** +When catalog updates, environments are notified with a **simple summary**: + +``` +┌─────────────────────────────────────────────────┐ +│ Environment: alice-dev │ +├─────────────────────────────────────────────────┤ +│ ⚠️ Update Available │ +│ │ +│ Catalog 'production-services' has been updated: │ +│ • 3 Workloads changed │ +│ │ +│ [Sync From Catalog] [Sync With Cluster] [Dismiss]│ +└─────────────────────────────────────────────────┘ +``` + +`Dismiss` records the prompt as dismissed in `kube_environment_dependency_sync_status` (for dependency events) or logs a manual deferral for catalog prompts without mutating workloads, ensuring auditability while letting developers defer action. + +**Environment Sync Actions:** +- **`Sync From Catalog`** (workload-focused) + 1. Apply catalog workload spec changes, including adds/removes. + 2. Reconcile workload-level metadata (labels, annotations, autoscaling) to match the catalog. + 3. Trigger dependency discovery for any new/removed workloads and update `kube_environment_dependency` accordingly. + 4. Update environment tables for workloads plus referenced ConfigMaps/Secrets/Services encountered during discovery. +- **`Sync With Cluster`** (dependency-focused) + 1. Rediscover ConfigMaps, Secrets, and Services from the source cluster based on existing dependency index rows. + 2. Refresh values in `kube_environment_configmap`, `kube_environment_secret`, `kube_environment_service`. + 3. Update dependency index discovery timestamps; remove rows whose backing resources no longer exist. + 4. Leave workloads untouched, allowing developers to defer catalog changes while still pulling latest configs. + +**Manual Sync Confirmation (Sync From Catalog):** +1. UI requests latest catalog sync metadata and displays confirmation dialog with timestamp + workload count. +2. Backend verifies the catalog sync referenced by the environment (`latest_catalog_sync_id`) is still the newest available; if not, the UI refreshes with the latest summary. +3. Environment Sync Orchestrator enqueues a reconciliation job marked `manual_trigger=true`, `action='catalog'`. +4. Job locks the environment record (optimistic concurrency) to prevent overlapping catalog syncs. +5. Job runs the workload reconciliation pipeline (catalog apply → dependency discovery → service refresh). +6. Upon success, the environment’s `last_catalog_synced_at` and `last_dependency_synced_at` are updated and a success notification is pushed to the user; on failure, the error message is captured and surfaced with retry option. +7. Audit trail records the initiating user, catalog sync id, action, and outcome to support compliance and troubleshooting. + +**Manual Sync Confirmation (Sync With Cluster):** +1. UI fetches dependency event summary from `kube_environment_dependency_sync_status` (list of ConfigMaps/Secrets flagged by API). +2. Backend ensures no newer `kube_environment_dependency_sync_status` (via `latest_dependency_sync_id`) has superseded the request; if found, UI refreshes the summary. +3. Orchestrator enqueues reconciliation job with `manual_trigger=true`, `action='cluster'`. +4. Job locks the environment dependency context to avoid racing with catalog or auto dependency syncs. +5. Job executes dependency refresh pipeline only (config/secret/service discovery) and updates `kube_environment_dependency`. +6. On success, the environment’s `last_dependency_synced_at` is updated; failures are recorded with actionable error payloads. +7. Audit trail captures user, dependency sync status id, action, and outcome. + +**Granularity:** +- ✅ Show: "3 workloads changed" (from catalog) +- For catalog prompts, omit ConfigMap/Secret/Service counts (discovered during follow-up) +- ❌ No detailed diffs (too complex) +- For dependency prompts, show count per resource type (e.g., "2 ConfigMaps", "1 Secret") sourced from `kube_environment_dependency_sync_status` summaries. + +### 4. Branch Environment Handling: Sync Both + +**Decision:** Update both shared and branch environments + +**Behavior:** +- Shared environments: Always sync when catalog updates +- Branch environments: Sync branched workloads if they match catalog workloads +- Preserves branch-specific modifications (custom images, env overrides remain untouched unless explicitly in catalog) + +## Two-Level Sync Control + +### Notification Granularity + +The system uses **simple notifications at both levels**: + +**1. Catalog Level:** +- Shows workload count only: `3 Workloads changed` +- No detailed field-level diffs +- **Purpose:** Quick confirmation - "production workloads changed, catalog updated." + +**2. Environment Level:** +- Shows workload count only: `3 Workloads changed` +- No ConfigMap/Secret/Service info (discovered during sync) +- **Purpose:** Simple decision - "catalog updated, sync environment?" + +**What happens during sync:** +- Catalog sync: Update workload specs in `kube_app_catalog_workload` +- Environment sync: Update workloads + rediscover ConfigMaps/Secrets/Services from production + +### Auto-Sync Control + +Catalogs always apply updates automatically. Control resides entirely at the environment layer: + +| Environment Auto-Sync | Behavior | +|---|---| +| ✅ **ON** | **Fully automatic**: Catalog updates trigger immediate `Sync From Catalog`; dependency events trigger immediate `Sync With Cluster` | +| ❌ **OFF** | **Manual environment sync**: Catalog updates produce `Sync From Catalog` prompts; dependency events produce `Sync With Cluster` prompts | + +**Use Cases:** + +- **Automatic Environments (ON)**: Fast-moving dev or QA namespaces that must stay aligned with production without human intervention. +- **Manual Environments (OFF)**: Developer sandboxes or branch environments where teams choose when to integrate upstream changes. + +Default posture is **Manual (OFF)** for new environments to minimize surprise changes; users must explicitly opt into environment-level automation. Auto-mode executes both actions as needed. + +## Architecture + +### Components + +``` +┌─────────────────────────────────────────┐ +│ Kubernetes Cluster (Source) │ +│ ┌───────────────────────────────────┐ │ +│ │ Production Namespace │ │ +│ │ - Deployments │ │ +│ │ - StatefulSets │ │ +│ │ - DaemonSets │ │ +│ │ - ReplicaSets, Jobs, CronJobs │ │ +│ │ - (ConfigMaps/Secrets/Services) │ │ +│ │ ↑ Not watched by catalog │ │ +│ └───────────────────────────────────┘ │ +│ │ │ +│ │ Watch API (workloads only) │ +│ ▼ │ +│ ┌───────────────────────────────────┐ │ +│ │ lapdev-kube-manager │ │ +│ │ - Watches ALL resources in │ │ +│ │ configured namespaces │ │ +│ │ - Forwards raw events │ │ +│ │ - No filtering logic │ │ +│ └───────────────────────────────────┘ │ +└─────────────────────────────────────────┘ + │ + │ RPC: report_resource_change() + │ (namespace, type, name, yaml) + ▼ +┌─────────────────────────────────────────┐ +│ Lapdev API Server │ +│ ┌───────────────────────────────────┐ │ +│ │ Change Event Processor │ │ +│ │ - Receives raw change events │ │ +│ │ - Maps to catalogs/environments │ │ +│ │ - Filters relevant changes │ │ +│ └───────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────┐ │ +│ │ Sync Decision Engine │ │ +│ │ - Workload change? → catalog │ │ +│ │ - Config/Service? → environments │ │ +│ │ - Check auto_sync flags │ │ +│ │ - Create sync records │ │ +│ └───────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────┐ │ +│ │ Catalog Sync Logger │ │ +│ │ - Creates sync status record │ │ +│ │ - Notifies dashboard │ │ +│ │ - Emits webhooks/metrics │ │ +│ └───────────────────────────────────┘ │ +│ │ │ +│ │ Auto-applies workload spec │ +│ ▼ │ +│ ┌───────────────────────────────────┐ │ +│ │ Catalog Updater │ │ +│ │ - Updates kube_app_catalog │ │ +│ │ - Updates workload specs │ │ +│ └───────────────────────────────────┘ │ +│ │ │ +│ │ Trigger environment sync │ +│ ▼ │ +│ ┌───────────────────────────────────┐ │ +│ │ Environment Sync Orchestrator │ │ +│ │ - Filters auto_sync envs │ │ +│ │ - Queues manual envs │ │ +│ │ - Calls kube-manager to apply │ │ +│ │ - Updates dependency index │ │ +│ └───────────────────────────────────┘ │ +└─────────────────────────────────────────┘ + │ + │ RPC: update_environment_workloads() + ▼ +┌─────────────────────────────────────────┐ +│ Kubernetes Cluster (Target) │ +│ ┌───────────────────────────────────┐ │ +│ │ Dev Environment Namespaces │ │ +│ │ - Updates workload specs │ │ +│ │ - Discovers ConfigMaps from prod │ │ +│ │ - Discovers Secrets from prod │ │ +│ │ - Discovers Services from prod │ │ +│ │ - Applies changes │ │ +│ └───────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +## End-to-End Flow + +### A. Production Change → Catalog Decision +1. Production resource changes (Deployment/StatefulSet/etc.) surface through the Kubernetes Watch stream handled by kube-manager. +2. Kube-manager forwards the raw event to the API server via `report_resource_change`. +3. Change Event Processor normalizes/merges events for the same workload within the dedupe window. +4. Sync Decision Engine resolves the catalog owner, records the workload names and counts, and emits/updates a `kube_app_catalog_sync_status`. +5. Catalog update executes immediately (`status = auto_applied`), generating activity notifications but no approval gate. + +### B. Catalog Update → Environment Sync +1. Immediately after detection, Catalog Updater writes the new spec into `kube_app_catalog_workload` and stamps `last_synced_at`. +2. Environment Sync Orchestrator enumerates impacted environments and splits them into auto-sync and manual buckets. +3. Auto-sync environments queue execution jobs that call kube-manager to reconcile workloads and re-discover dependent ConfigMaps/Secrets/Services. +4. Manual environments receive notifications referencing the originating `kube_app_catalog_sync_status`; when a user clicks "Sync From Catalog", the orchestrator enqueues a reconciliation job identical to the auto-sync path. +5. Reconciliation outcome is persisted back on the environment (`last_catalog_synced_at` and/or `last_dependency_synced_at`, plus error metadata if failures occur) and surfaced in UI/alerts. +6. Dependency index updates record which source ConfigMaps/Secrets each environment now references, ensuring future events fan out correctly. + +### C. Dependency Change → Environment Prompt +1. ConfigMap/Secret event arrives; Change Event Processor looks up impacted environments via `kube_environment_dependency`. +2. For each environment, create or update a `kube_environment_dependency_sync_status` record summarizing resource names, change types, and detection time. +3. Auto-sync environments enqueue `Sync With Cluster` jobs immediately; manual environments receive notifications with counts per resource type. +4. When sync completes, the dependency sync status transitions to `completed` (or `failed` with error payload) and `last_dependency_synced_at` is updated. + +### Sync Record State Machine + +| State | Trigger | Next States | Notes | +|---|---|---|---| +| `auto_applied` | Catalog change detected | `completed`, `failed` | Catalog update applied immediately; environment sync queued | +| `completed` | Catalog and all required environment syncs succeed | _terminal_ | Timestamped for reporting and metrics | +| `failed` | Catalog update or required environment sync fails | `retrying`, `manual_intervention` | Failure reason persisted for diagnosis | +| `retrying` | Automatic retry scheduled/executing | `completed`, `failed` | Retry budget/interval configurable | +| `manual_intervention` | Automated retries exhausted or blocked | _terminal_ | Signals operators to intervene | + +State machine extensions (`retrying`, `manual_intervention`) go beyond the minimal DDL but capture expected lifecycle so future iterations can expand persistence as needed. + +`kube_environment_dependency_sync_status` follows a similar lifecycle: + +| State | Trigger | Next States | Notes | +|---|---|---|---| +| `pending` | ConfigMap/Secret event detected, manual environment | `in_progress`, `dismissed` | Surfaces counts for `Sync With Cluster` CTA | +| `in_progress` | Sync job (auto or manual) executing | `completed`, `failed` | Job references this id for audit/retries | +| `completed` | Dependency refresh succeeded | _terminal_ | Updates `last_dependency_synced_at` | +| `failed` | Dependency refresh failed | `retrying`, `manual_intervention` | Stores error payload for UI | +| `retrying` | Automatic retry scheduled/executing | `completed`, `failed` | Retry budget shared with catalog syncs | +| `dismissed` | User dismisses notification without syncing | _terminal_ | Maintains audit trail without applying changes | +| `manual_intervention` | Automatic retries exhausted | _terminal_ | Signals operators to inspect cluster-side issues | + +### Database Changes + +#### New Table: `kube_app_catalog_sync_status` + +```sql +CREATE TABLE kube_app_catalog_sync_status ( + id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + app_catalog_id UUID NOT NULL REFERENCES kube_app_catalog(id), + status VARCHAR NOT NULL, -- 'auto_applied','completed','failed','retrying','manual_intervention' + detected_at TIMESTAMPTZ NOT NULL, + reviewed_at TIMESTAMPTZ, + reviewed_by UUID REFERENCES users(id), + workload_count INTEGER NOT NULL, -- Simple count: how many workloads changed + workload_names TEXT[] NOT NULL -- Array of workload names that changed +); +``` + +**Storage Strategy:** +- **Simple summary only**: Just count + list of workload names +- No detailed diffs stored (not needed for immediate auto-apply) +- Environments reference this record via `latest_catalog_sync_id` to know a sync is available + +#### New Table: `kube_environment_configmap` + +```sql +CREATE TABLE kube_environment_configmap ( + id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ, + environment_id UUID NOT NULL REFERENCES kube_environment(id), + name VARCHAR NOT NULL, + namespace VARCHAR NOT NULL, + data JSONB NOT NULL, + UNIQUE(environment_id, namespace, name) +); +``` + +#### New Table: `kube_environment_secret` + +```sql +CREATE TABLE kube_environment_secret ( + id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ, + environment_id UUID NOT NULL REFERENCES kube_environment(id), + name VARCHAR NOT NULL, + namespace VARCHAR NOT NULL, + data JSONB NOT NULL, -- Encrypted + type VARCHAR NOT NULL, -- Opaque, kubernetes.io/tls, etc. + UNIQUE(environment_id, namespace, name) +); +``` + +#### New Table: `kube_environment_dependency` + +```sql +CREATE TABLE kube_environment_dependency ( + id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + environment_id UUID NOT NULL REFERENCES kube_environment(id), + resource_type VARCHAR NOT NULL, -- 'configmap' or 'secret' + source_namespace VARCHAR NOT NULL, + source_name VARCHAR NOT NULL, + target_namespace VARCHAR NOT NULL, + target_name VARCHAR NOT NULL, + last_discovered_at TIMESTAMPTZ NOT NULL, + CHECK (resource_type IN ('configmap','secret')), + UNIQUE(environment_id, resource_type, source_namespace, source_name) +); +``` + +#### New Table: `kube_environment_dependency_sync_status` + +```sql +CREATE TABLE kube_environment_dependency_sync_status ( + id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + environment_id UUID NOT NULL REFERENCES kube_environment(id), + status VARCHAR NOT NULL, -- 'pending','in_progress','completed','failed','dismissed' + detected_at TIMESTAMPTZ NOT NULL, + resolved_at TIMESTAMPTZ, + resolved_by UUID REFERENCES users(id), + resource_summaries JSONB NOT NULL, -- Array of {resource_type, namespace, name, change_type} + auto_triggered BOOLEAN NOT NULL DEFAULT false +); +``` + +#### Modified Table: `kube_environment` + +```sql +ALTER TABLE kube_environment +ADD COLUMN auto_sync BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE kube_environment +ADD COLUMN last_catalog_synced_at TIMESTAMPTZ; + +ALTER TABLE kube_environment +ADD COLUMN last_dependency_synced_at TIMESTAMPTZ; + +ALTER TABLE kube_environment +ADD COLUMN latest_catalog_sync_id UUID REFERENCES kube_app_catalog_sync_status(id); + +ALTER TABLE kube_environment +ADD COLUMN latest_dependency_sync_id UUID REFERENCES kube_environment_dependency_sync_status(id); +``` + +**Note:** When catalog updates, we just store a reference to the catalog sync that triggered the update. The environment doesn't need its own detailed diff - users can click through to the catalog sync to see details if needed. The dependency index (`kube_environment_dependency`) enables constant-time lookup when a production ConfigMap/Secret event arrives, while `kube_environment_dependency_sync_status` tracks pending dependency changes that power the `Sync With Cluster` UI. + +#### Modified Table: `kube_app_catalog` + +```sql +ALTER TABLE kube_app_catalog +ADD COLUMN last_synced_at TIMESTAMPTZ; + +ALTER TABLE kube_app_catalog +ADD COLUMN watch_enabled BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE kube_app_catalog +ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the source cluster +``` + +**Note:** +- `watch_enabled` controls whether to watch for changes at all +- `source_namespace` tells kube-manager which namespace to watch + +**Namespace Watch Configuration:** +- API server aggregates all `source_namespace` values for a given cluster +- Sends list of namespaces to kube-manager: `["production", "staging"]` +- Kube-manager watches ALL resources in those namespaces +- API server filters events to determine which belong to which catalog + +## Implementation Plan + +### Phase 1: Watch Infrastructure (Kube-Manager) + +**Kube-Manager Changes (Deployed in customer clusters):** +1. Implement namespace configuration RPC endpoint: + - `configure_watches(namespaces: Vec)` + - Kube-manager stores namespace list + - Dynamically start/stop watches when namespace list changes +2. Add Kubernetes Watch for ALL resources in configured namespaces: + - Deployments, StatefulSets, DaemonSets, ReplicaSets, Jobs, CronJobs + - ConfigMaps + - Secrets + - Services +3. Implement event forwarding (no filtering): + - Capture: namespace, resource_type, resource_name, change_type (created/updated/deleted), full YAML + - Forward ALL events to API server via RPC +4. Add RPC method: `report_resource_change(event: ResourceChangeEvent)` + +**API Server Changes (Centralized service):** +1. Implement namespace watch configuration management: + - When catalog created/updated: aggregate `source_namespace` per cluster + - Send `configure_watches([namespaces])` RPC to kube-manager + - Track which namespaces are being watched per cluster +2. Implement `report_resource_change` RPC handler +3. Build **Change Event Processor**: + - Receive raw events from kube-manager + - Map workload events to catalogs: + - Query: Which catalog has workload X in namespace Y? + - Map ConfigMap/Secret/Service events to environments: + - Query: Which environments reference ConfigMap X in namespace Y? +4. Build **Sync Decision Engine**: + - Workload events → Check if belongs to any catalog → Trigger catalog sync + - ConfigMap/Secret/Service events → Check if belongs to any environment → Trigger environment sync + - Check environment `auto_sync` flags to decide immediate reconcile vs manual prompt + - Use dependency index (`kube_environment_dependency`) to map source resources to environments and create/update `kube_environment_dependency_sync_status` +5. Implement event deduplication (ignore duplicate events within time window) + +### Phase 2: Sync Status & Notifications +1. Create `kube_app_catalog_sync_status` table. +2. Implement sync status API endpoints for querying recent catalog updates. +3. Execute catalog auto-sync logic on every detected change, recording outcome as `auto_applied`. +4. Publish dashboard and activity feed surfaces for catalog updates (e.g., "3 workloads changed"). +5. Emit webhook/notification events (if enabled) to inform downstream systems of automatic catalog changes. +6. Provide API utilities to resolve ConfigMap/Secret events to environments via `kube_environment_dependency` and expose `kube_environment_dependency_sync_status` summaries (updating `latest_dependency_sync_id`). + +### Phase 3: Catalog Update +1. Implement catalog update logic that runs immediately after sync decision +2. Update `kube_app_catalog_workload` specs from production cluster +3. Update `kube_app_catalog.last_synced_at` timestamp +4. Trigger environment sync flow (Phase 4) +5. Track version history (optional) + +### Phase 4: Environment Sync +1. Add `auto_sync` flag to `kube_environment` +2. Create `kube_environment_configmap` and `kube_environment_secret` tables (if not already exist) +3. Create `kube_environment_dependency` and `kube_environment_dependency_sync_status` tables +4. Implement environment sync orchestrator +5. Add RPC method: `sync_environment(env_id, sync_request)` + - For `sync_request.action = 'catalog'`: updates workload specs from catalog, then cascades discovery for dependencies/services + - For `sync_request.action = 'dependency'`: refreshes ConfigMaps/Secrets/Services only, based on dependency index + - Payload includes `catalog_sync_id` or `dependency_sync_id` to support auditing and idempotency +6. Handle both shared and branch environments +7. Implement ConfigMap/Secret discovery and update logic with proper encryption for Secrets +8. Persist dependency mappings in `kube_environment_dependency` for each source ConfigMap/Secret +9. Implement service discovery and preview URL updates +10. Update `kube_environment.last_catalog_synced_at` and/or `last_dependency_synced_at` timestamps based on action completed +11. Audit manual triggers by storing initiating user id, catalog sync id, dependency event ids, and outcome for every `manual_trigger=true` job + +### Phase 5: Dashboard & Notifications +1. Add notification system for catalog activity and environment sync prompts. +2. Create **catalog activity UI** summarizing applied changes (count + workload names). +3. Create **environment notification UI** for catalog updates ("Sync From Catalog") and dependency updates ("Sync With Cluster") plus post-sync results. +4. Add environment auto-sync toggle in environment settings. +5. Add environment sync status indicators. +6. Add manual sync triggers for environments with auto-sync disabled. +7. Implement notification badges: + - Catalog: "Auto-updated - 3 workloads" + - Environment: "Catalog update available - 3 workloads" vs "Config update available - 2 ConfigMaps" (manual) and a single "Syncing..." state for auto actions +8. Support dismiss actions that update dependency sync status (set to `dismissed`) or log catalog deferrals without applying changes. + +## Workstream Overview & Dependencies + +| Workstream | Scope | Lead Team | Key Dependencies | +|---|---|---|---| +| Watch Infrastructure | Kube-manager watch plumbing, RPC schema, API ingestion | Platform Agents | Requires namespace metadata on catalogs | +| Sync Decision Engine | Event classification, dedupe, sync record lifecycle | Backend Services | Needs Watch Infrastructure | +| Catalog & Environment Persistence | DDL migrations, ORM updates, encryption plumbing | Data Platform | Sequenced before UI/API consumption | +| Orchestration & Queues | Catalog update executor, environment reconciliation workers | Backend Services | Depends on Persistence schema | +| UI & Notification Layer | Dashboard surfaces, toggles, notification delivery | Frontend & UX | Needs API endpoints & sync records | +| Observability & Ops | Metrics, alerting, runbooks, failure injection | SRE | Consumes telemetry hooks from all layers | + +Critical path: Watch Infrastructure → Sync Decision Engine → Persistence → Orchestration. UI/Notification and Observability can parallelize once APIs are stable. + +## Rollout Strategy + +1. **Internal Dogfood (Phase 1–3)** + - Target a non-production catalog within Lapdev infrastructure. + - Run with catalog auto-sync enabled and environments in manual mode to validate detection and apply fidelity. +2. **Design Partner Preview (Phase 4)** + - Select 1–2 customers with tolerant workloads. + - Gate behind feature flag: catalog auto-sync enforced, environments default manual. + - Collect SLA metrics (event latency, sync duration) and monitor false-positive rate. +3. **Progressive Environment Auto-Sync Enablement (Phase 5)** + - Offer environment auto-sync opt-in to preview customers once reconciliation stability is proven. + - Shadow auto-sync (dry-run) for high-sensitivity environments before enabling writes. +4. **General Availability** + - Document operational runbooks. + - Enable environment auto-sync opt-in for all customers, default OFF. + - Announce with clear safety guidelines and fallback steps. + +Each stage requires explicit go/no-go review covering error budget impact, support readiness, and security sign-off. + +## Edge Cases + +### Workload Deleted in Production +- Mark workload as "missing" in catalog +- Show warning in dashboard +- Don't auto-remove from catalog (user decides) + +### ConfigMap/Secret/Service Discovery Failures +- If environment sync can't discover ConfigMaps/Secrets/Services from production +- Show error: "Failed to sync ConfigMap 'app-config' from production - does not exist" +- Environment keeps using existing resources until production is fixed +- User can manually intervene or wait for production to be restored + +### Conflicting Changes in Branch Environment +- If branched workload was manually modified AND catalog updated +- Show conflict notification +- User chooses: keep branch changes or sync from catalog + +### Dependency Events During Pending Catalog Sync +- ConfigMap/Secret change may fire before environment adopts latest catalog +- Environment shows both prompts; users can run `Sync With Cluster` to unblock secrets while deferring workload adoption +- Auto-sync environments execute catalog and dependency tasks sequentially (catalog first, then dependency) + +### Watch Connection Loss +- Detect websocket/watch disconnection +- Reconnect automatically +- Resync state after reconnection (compare current vs last known state) + +### Multiple Catalog Changes in Quick Succession +- Batch changes into single sync request (within 30 second window) +- Show combined notification: "5 workloads changed" +- List affected workload names + +### New Catalog Created or Namespace Changed +- When catalog is created or `source_namespace` is updated +- API server recalculates namespace watch list for the cluster +- Sends `configure_watches([new_namespace_list])` to kube-manager +- Kube-manager dynamically adds/removes watches as needed +- No restart required + +### Catalog Deleted +- API server recalculates namespace watch list +- If namespace no longer has any catalogs, stop watching it +- Send updated `configure_watches()` to kube-manager +- Reduces unnecessary event traffic + +### Kube-Manager Receives Unknown Events +- Kube-manager forwards ALL events (even if API server doesn't recognize them) +- API server silently ignores events that don't map to any catalog/environment +- No error logged (normal behavior - cluster may have resources we don't track) + +### Event Storm (Many Rapid Changes) +- Kube-manager forwards all events (no throttling at kube-manager level) +- API server implements deduplication: + - Batch events within 30-second window + - Group by resource (namespace + type + name) + - Only process latest version of resource +- Prevents creating multiple sync records for same resource + +## Future Enhancements + +1. **Selective Field Sync**: User chooses which fields to sync (e.g., always sync images, but not resource limits) +2. **Rollback**: Revert catalog to previous version if sync causes issues +3. **Dry Run**: Preview what would change in environments before applying +4. **Scheduled Sync**: Define maintenance windows for auto-sync +5. **Notifications**: Slack/email notifications for environment sync prompts + +## Security Considerations + +1. **RBAC**: Only catalog editors can modify watch settings; environment sync actions require environment write access. +2. **Audit Log**: Record all catalog auto-sync executions and manual environment sync invocations with actor (or system) and timestamp. +3. **Validation**: Validate workload specs before applying (resource limits, security contexts) +4. **Rate Limiting**: Prevent sync storms (max N syncs per minute) +5. **Secret Handling**: + - Secrets stored encrypted in `kube_environment_secret` table + - Diff view shows key names and change indicators, not actual secret values + - Secret values only transmitted over TLS between components + - RBAC: Separate permission for viewing Secret diffs + - Audit: Log all Secret access and modifications + +## Testing Strategy + +1. **Unit Tests**: + - Kube-manager: Event forwarding, namespace watch configuration + - API server: Event mapping, sync decision logic, deduplication + - Catalog/environment sync logic, dependency fan-out resolution + +2. **Integration Tests**: + - End-to-end flow: Production change → Kube-manager event → API decision → Catalog/environment sync + - Namespace watch reconfiguration when catalogs added/removed + - Auto-sync vs manual environment workflows + - ConfigMap/Secret/Service discovery during environment sync with dependency index updates + +3. **Load Tests**: + - 100+ workloads across multiple namespaces + - Rapid changes (event storm simulation) + - Event deduplication effectiveness + - Multiple catalogs watching same namespace + +4. **Failure Tests**: + - Watch disconnection and reconnection + - Network failures between kube-manager and API server + - Partial updates (only some workloads sync successfully) + - Kube-manager restart (watches re-established) + - API server restart (watch configuration re-sent) + +5. **Edge Case Tests**: + - Unknown events (resources not in any catalog) + - Namespace watch list changes during active sync + - Catalog deleted shortly after auto-sync + - Multiple overlapping syncs for same catalog + +## Metrics & Monitoring + +**Kube-Manager Metrics:** +- Events forwarded per second (by resource type) +- Watch connection health per namespace +- RPC failures to API server +- Event queue depth (if batching) + +**API Server Metrics:** +- Events received per second (total and by cluster) +- Events mapped to catalogs/environments (hit rate) +- Events ignored (not mapped to any resource) +- Sync detection latency (time from event to sync decision) +- Deduplication effectiveness (events deduplicated / total events) + +**Sync Workflow Metrics:** +- Catalog sync latency (detection → catalog applied) +- Environment sync latency (catalog updated → environment synced) +- Environment decision latency (catalog applied → user-triggered sync) for manual environments +- Dependency sync latency (event detected → dependency sync completed) +- Sync success/failure rates +- Number of environments awaiting manual sync +- Auto-sync vs manual-sync ratio split by action type (catalog vs dependency) +- Dismissal rate for dependency prompts (count of statuses marked `dismissed`) + +**Resource Metrics:** +- Number of namespaces watched per cluster +- Number of resources watched per namespace +- Active watches per cluster +- Dependency index size per cluster (ConfigMap/Secret links) + +## Operational Readiness +- **Runbooks:** Document common failure paths (watch disconnect, sync failure, secret decryption error, dependency sync backlog) including diagnostic commands and escalation contacts. +- **Alerting:** Trigger alerts when sync latency or failure rate breach thresholds, or when no events are received for a watched namespace within N minutes. +- **Backfill:** Provide a CLI/API command to backfill sync records after outages; ensures state catch-up without manual DB edits. +- **Feature Flags:** Maintain kill switches for auto-sync at catalog and environment level to halt rollout quickly if issues emerge. +- **Access Controls:** Verify permissions for support engineers to inspect sync status without granting ability to trigger environment syncs. + +## Risks & Mitigations +- **False Positives/Noise:** Deduplication window and workload ownership mapping errors could generate noisy syncs. Mitigation: add resource ownership cache, alert on high manual deferral rates. +- **Secrets Exposure:** Transporting full secret data increases blast radius. Mitigation: enforce envelope encryption at rest, limit log redaction, and gate diff visibility behind elevated RBAC. +- **Customer Cluster Load:** Watching all resources could stress API servers. Mitigation: allow namespace-level sampling configuration and backoff when resource version drift detected. +- **Long-Running Branch Mods:** Auto-sync might overwrite intentional branch divergences. Mitigation: branch environments default to manual; surface conflicts with ability to reapply branch overrides. +- **Partial Failures:** Catalog update succeeding while environment sync fails leaves inconsistent state. Mitigation: persist failure reason, expose retry control, and guard rails to prevent infinite retries. +- **Stale Dependency Index:** Missed cleanup could cause redundant environment sync prompts. Mitigation: rebuild dependencies on each environment sync and schedule periodic reconciliation jobs. +- **Excessive Dismissals:** Users may repeatedly dismiss dependency prompts, leaving environments outdated. Mitigation: surface dismissal metrics, add reminder nudges, and allow policy to cap consecutive dismissals. + +## Open Questions + +1. Should we allow users to bundle "Sync From Catalog" and "Sync With Cluster" into a single action? + - Recommendation: Keep CTAs separate in v1 for clarity; evaluate combined apply after observing user behavior. +2. Should we support partial environment sync (only sync specific workloads)? + - Recommendation: Defer to future iteration; scope for v1 is full-environment reconciliation with branch overrides preserved. +3. How long should we retain catalog sync activity and failure records? + - Recommendation: Retain 90 days online, archive thereafter for compliance reviews. +4. Should branch environments optionally "pin" to a catalog version? + - Recommendation: Provide catalog pinning as a branch-level setting post-v1; for now rely on manual sync deferral. +5. Do we need sync notifications via webhook (for CI/CD integration)? + - Recommendation: Capture requirement from design partners; build webhook emitter after GA if needed. +6. Should there be a "pause sync" option to temporarily disable watches without changing environment auto-sync settings? + - Recommendation: Yes, add a `pause_until` field so teams can suspend watches during planned maintenance. +7. Do we need a catalog "dry run" mode that records detections without applying to workloads? + - Recommendation: Consider a shadow-only mode that writes sync records with `preview_only=true` while skipping updates, primarily for analytics prior to rollout adjustments. +8. Should the dependency index be extended to Services or other resource types? + - Recommendation: Track ConfigMaps/Secrets in v1; evaluate Service inclusion after we validate performance of the new index. + +**Architecture Questions:** +1. Should kube-manager batch events before sending to API server, or send immediately? + - Decision: Send immediately for low latency; API server handles batching/deduplication. +2. How should we handle very large resources (e.g., ConfigMap with 10MB of data)? + - Recommendation: Send checksum + metadata via event; API server fetches full resource on-demand when diffing. +3. Should we implement event replay/audit log? + - Recommendation: Store raw events for 7 days in object storage with index for compliance, gated behind feature flag. +4. What happens if kube-manager falls behind (event backlog)? + - Recommendation: Queue with bounded buffer, emit alert at 70% capacity, allow API server to request resync. +5. Should watch configuration be persisted in kube-manager? + - Decision: Fetch from API server on startup; minimal local persistence required beyond last successful revision. From 2a3629e77be10b27e83b4c896334c7478674f8e2 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sat, 18 Oct 2025 08:20:18 +0000 Subject: [PATCH 123/334] update --- crates/api/src/kube_controller.rs | 16 +- crates/common/src/kube.rs | 2 +- crates/dashboard/src/kube_environment.rs | 10 +- .../dashboard/src/kube_environment_detail.rs | 4 +- crates/db/entities/src/kube_cluster.rs | 2 +- crates/db/entities/src/kube_environment.rs | 2 +- .../m20250729_082625_create_kube_cluster.rs | 2 +- ...20250809_000001_create_kube_environment.rs | 2 +- crates/db/src/api.rs | 10 +- plans/automatic-sync.md | 195 ++++++++++++------ 10 files changed, 156 insertions(+), 89 deletions(-) diff --git a/crates/api/src/kube_controller.rs b/crates/api/src/kube_controller.rs index 54af20b..d7d01af 100644 --- a/crates/api/src/kube_controller.rs +++ b/crates/api/src/kube_controller.rs @@ -67,10 +67,7 @@ impl KubeController { available_memory: "N/A".to_string(), // TODO: Get actual memory from kube-manager provider: None, // TODO: Get provider info region: c.region, - status: c - .status - .as_deref() - .and_then(|s| KubeClusterStatus::from_str(s).ok()) + status: KubeClusterStatus::from_str(&c.status) .unwrap_or(KubeClusterStatus::NotReady), }, }) @@ -97,7 +94,7 @@ impl KubeController { org_id, user_id, name, - Some(KubeClusterStatus::Provisioning.to_string()), + KubeClusterStatus::Provisioning.to_string(), ) .await .map_err(ApiError::from)?; @@ -416,10 +413,7 @@ impl KubeController { available_memory: "N/A".to_string(), // TODO: Get actual memory from kube-manager provider: None, // TODO: Get provider info region: cluster.region, - status: cluster - .status - .as_deref() - .and_then(|s| KubeClusterStatus::from_str(s).ok()) + status: KubeClusterStatus::from_str(&cluster.status) .unwrap_or(KubeClusterStatus::NotReady), }; @@ -1094,7 +1088,7 @@ impl KubeController { cluster_id, name.clone(), namespace.clone(), - Some("Pending".to_string()), + "Pending".to_string(), is_shared, None, // No base environment for regular environments workload_details, @@ -1275,7 +1269,7 @@ impl KubeController { base_environment.cluster_id, name.clone(), base_environment.namespace.clone(), // Use same namespace as base - Some("Pending".to_string()), + "Pending".to_string(), false, // Branch environments are always personal (not shared) Some(base_environment_id), // Set the base environment reference workload_details, diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index 867cf0d..debbf1b 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -282,7 +282,7 @@ pub struct KubeEnvironment { pub app_catalog_name: String, pub cluster_id: Uuid, pub cluster_name: String, - pub status: Option, + pub status: String, pub created_at: String, pub is_shared: bool, pub base_environment_id: Option, diff --git a/crates/dashboard/src/kube_environment.rs b/crates/dashboard/src/kube_environment.rs index 8f4c3ab..c1db800 100644 --- a/crates/dashboard/src/kube_environment.rs +++ b/crates/dashboard/src/kube_environment.rs @@ -367,10 +367,10 @@ pub fn KubeEnvironmentItem(environment: KubeEnvironment) -> impl IntoView { // leptos::logging::log!("Delete environment: {}", env_name_for_delete); // }; - let status_variant = match environment.status.as_deref() { - Some("Running") => BadgeVariant::Secondary, - Some("Pending") => BadgeVariant::Outline, - Some("Failed") | Some("Error") => BadgeVariant::Destructive, + let status_variant = match environment.status.as_str() { + "Running" => BadgeVariant::Secondary, + "Pending" => BadgeVariant::Outline, + "Failed" | "Error" => BadgeVariant::Destructive, _ => BadgeVariant::Outline, }; @@ -459,7 +459,7 @@ pub fn KubeEnvironmentItem(environment: KubeEnvironment) -> impl IntoView { - {environment.status.clone().unwrap_or_else(|| "Unknown".to_string())} + {environment.status.clone()} diff --git a/crates/dashboard/src/kube_environment_detail.rs b/crates/dashboard/src/kube_environment_detail.rs index 2a858b3..bcb4e5c 100644 --- a/crates/dashboard/src/kube_environment_detail.rs +++ b/crates/dashboard/src/kube_environment_detail.rs @@ -500,8 +500,8 @@ pub fn EnvironmentInfoCard( let env_name = environment.name.clone(); let env_namespace = environment.namespace.clone(); let env_namespace2 = environment.namespace.clone(); - let env_status = environment.status.clone().unwrap_or_else(|| "Unknown".to_string()); - let env_status2 = environment.status.clone().unwrap_or_else(|| "Unknown".to_string()); + let env_status = environment.status.clone(); + let env_status2 = environment.status.clone(); let created_at_str = environment.created_at.clone(); let is_shared = environment.is_shared; let is_branch = environment.base_environment_id.is_some(); diff --git a/crates/db/entities/src/kube_cluster.rs b/crates/db/entities/src/kube_cluster.rs index 07659f6..112eaa0 100644 --- a/crates/db/entities/src/kube_cluster.rs +++ b/crates/db/entities/src/kube_cluster.rs @@ -13,7 +13,7 @@ pub struct Model { pub created_by: Uuid, pub name: String, pub cluster_version: Option, - pub status: Option, + pub status: String, pub region: Option, pub last_reported_at: Option, pub can_deploy_personal: bool, diff --git a/crates/db/entities/src/kube_environment.rs b/crates/db/entities/src/kube_environment.rs index 4761bb9..2e13530 100644 --- a/crates/db/entities/src/kube_environment.rs +++ b/crates/db/entities/src/kube_environment.rs @@ -15,7 +15,7 @@ pub struct Model { pub cluster_id: Uuid, pub name: String, pub namespace: String, - pub status: Option, + pub status: String, pub is_shared: bool, pub base_environment_id: Option, pub auth_token: String, diff --git a/crates/db/migration/src/m20250729_082625_create_kube_cluster.rs b/crates/db/migration/src/m20250729_082625_create_kube_cluster.rs index 84ebe29..3d6efe3 100644 --- a/crates/db/migration/src/m20250729_082625_create_kube_cluster.rs +++ b/crates/db/migration/src/m20250729_082625_create_kube_cluster.rs @@ -31,7 +31,7 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(KubeCluster::CreatedBy).uuid().not_null()) .col(ColumnDef::new(KubeCluster::Name).string().not_null()) .col(ColumnDef::new(KubeCluster::ClusterVersion).string()) - .col(ColumnDef::new(KubeCluster::Status).string()) + .col(ColumnDef::new(KubeCluster::Status).string().not_null()) .col(ColumnDef::new(KubeCluster::Region).string()) .col(ColumnDef::new(KubeCluster::LastReportedAt).timestamp_with_time_zone()) .col( diff --git a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs index 5cbebf7..4d71ce6 100644 --- a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs +++ b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs @@ -44,7 +44,7 @@ impl MigrationTrait for Migration { .string() .not_null(), ) - .col(ColumnDef::new(KubeEnvironment::Status).string()) + .col(ColumnDef::new(KubeEnvironment::Status).string().not_null()) .col( ColumnDef::new(KubeEnvironment::IsShared) .boolean() diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index da73c2d..f6f4777 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -36,7 +36,7 @@ struct KubeEnvironmentWithRelated { pub env_namespace: String, pub env_app_catalog_id: Uuid, pub env_cluster_id: Uuid, - pub env_status: Option, + pub env_status: String, pub env_created_at: DateTimeWithTimeZone, pub env_is_shared: bool, pub env_organization_id: Uuid, @@ -560,7 +560,7 @@ impl DbApi { let model = kube_cluster::ActiveModel { id: ActiveValue::Set(id), cluster_version: ActiveValue::Set(cluster_version), - status: ActiveValue::Set(status), + status: status.map(ActiveValue::Set).unwrap_or(ActiveValue::NotSet), region: ActiveValue::Set(region), last_reported_at: ActiveValue::Set(Some(now)), ..Default::default() @@ -858,7 +858,7 @@ impl DbApi { org_id: Uuid, user_id: Uuid, name: String, - status: Option, + status: String, ) -> Result { let cluster = lapdev_db_entities::kube_cluster::ActiveModel { id: ActiveValue::Set(cluster_id), @@ -1397,7 +1397,7 @@ impl DbApi { id: related.env_cluster_id, name, cluster_version: None, - status: None, + status: "Not Ready".to_string(), region: None, created_at: related.env_created_at, created_by: related.env_user_id, @@ -1687,7 +1687,7 @@ impl DbApi { cluster_id: Uuid, name: String, namespace: String, - status: Option, + status: String, is_shared: bool, base_environment_id: Option, workloads: Vec, diff --git a/plans/automatic-sync.md b/plans/automatic-sync.md index b3256a3..c8668d7 100644 --- a/plans/automatic-sync.md +++ b/plans/automatic-sync.md @@ -59,6 +59,7 @@ This causes drift between production and development environments. - ConfigMaps/Secrets often contain environment-specific values (dev vs staging vs prod) - Services are discovered dynamically based on current workload selectors - Storing at environment level allows per-environment customization while still syncing from source +- Catalogs serve as blueprints that update automatically when source workloads change, while still allowing teams to curate manual edits when required. - A dedicated dependency index (`kube_environment_dependency`) tracks which production ConfigMaps/Secrets feed each environment so change detection can fan out events without re-scanning workloads. ## Design Decisions @@ -124,38 +125,45 @@ trait KubeManagerRpc { 3. Kube-manager sends: `report_resource_change(ResourceChangeEvent { namespace: "production", resource_type: Deployment, resource_name: "api-server", ... })` 4. API server receives event 5. API server queries: "Which catalog has workload 'api-server' in namespace 'production'?" → finds catalog ID -6. API server creates/updates `kube_app_catalog_sync_status` record -7. API server immediately updates the catalog and records the change in `kube_app_catalog_sync_status` +6. API server fetches the latest workload manifest from the source cluster, updates the catalog workload spec, and records an `auto_applied` activity (actor `NULL`). +7. Environments referencing the catalog have their `latest_catalog_sync_id` set to the new activity record and receive notifications prompting `Sync From Catalog` / `Sync With Cluster`. -### 2. Catalog Updates: Always Automatic +### 2. Catalog Updates: Auto From Cluster & Manual Edits -**Decision:** App Catalogs always auto-apply detected workload changes; no manual approval path. +**Decision:** Catalog specs update automatically when the reference workloads in the source cluster change, and administrators can still make explicit edits through the UI. -**Behavior:** -- Catalog update executes immediately after the Sync Decision Engine validates a workload change. -- Users receive a post-apply notification summarizing the change (e.g., "Catalog 'production-services' auto-updated (3 workloads changed)"). -- Catalog update instantly triggers downstream environment sync handling (respecting environment auto-sync flags). +**Behavior (Cluster-Driven Auto Update):** +- Kube-manager events deliver the latest workload manifest to the API server. +- Catalog workload specs are updated in place, and an `auto_applied` activity is recorded (`actor_id = NULL`). +- Environments referencing the catalog set `latest_catalog_sync_id` to the new activity and proceed through the environment sync flow (auto or manual). + +**Behavior (Admin Edits):** +- When a user saves catalog edits, the platform applies the new workload set immediately and records an `applied` activity with the actor’s ID. +- These manual edits follow the same environment sync flow, respecting environment auto-sync flags. -**Catalog Notification (Activity Summary):** +**Catalog Activity Summary (Auto Update Example):** ``` ┌─────────────────────────────────────────────────┐ │ App Catalog: production-services │ ├─────────────────────────────────────────────────┤ -│ ✅ Auto-sync applied from Production │ +│ ℹ️ Production change auto-applied │ │ │ -│ Changes detected: │ -│ • 3 Workloads changed │ +│ Workloads updated: │ +│ • api-server │ +│ • worker │ │ Applied at: 2025-10-17T22:15Z │ │ │ -│ [View Details] │ +│ [Review Workloads] │ └─────────────────────────────────────────────────┘ ``` +When a user edits the catalog, the summary shifts to reflect the applied change (e.g., "✅ Workload 'api-server' added by Alice") and references the same activity log for auditing. + **Scope:** -- **App Catalog watches**: Workloads only +- **App Catalog watches**: Workloads only (for notification context) - **No tracking**: ConfigMaps, Secrets, Services (environment-level concerns) -- **Granularity**: Count only (e.g., "3 workloads") +- **Granularity**: Workload counts/names sufficient for review - **No detailed diffs**: Too complex for v1; link routes to workload list for context ### 3. Environment Sync: Default Manual, Auto Opt-In @@ -168,8 +176,8 @@ trait KubeManagerRpc { - Allowing an explicit opt-in keeps developers in control while supporting hands-off pipelines. **Behavior:** -- When catalog updates, ALL environments show "Update available" by default with `Sync From Catalog` highlighted. -- When ConfigMap/Secret changes arrive, impacted environments show "Config update available" with `Sync With Cluster` highlighted. +- When a user updates the catalog (add/remove workloads), ALL environments show "Catalog update available" with `Sync From Catalog` highlighted. +- When ConfigMap/Secret changes arrive from production, impacted environments show "Config update available" with `Sync With Cluster` highlighted. - Environments with `auto_sync=false` require a manual button click for each action type; auto environments run both actions as events arrive. - Environments with `auto_sync=true` apply the relevant action automatically and surface the outcome afterward (success, failure, conflict). @@ -189,7 +197,7 @@ When catalog updates, environments are notified with a **simple summary**: └─────────────────────────────────────────────────┘ ``` -`Dismiss` records the prompt as dismissed in `kube_environment_dependency_sync_status` (for dependency events) or logs a manual deferral for catalog prompts without mutating workloads, ensuring auditability while letting developers defer action. +`Dismiss` records the prompt as dismissed in `kube_environment_dependency_sync_status` (for dependency events) or emits an audit log event for catalog prompts (no schema change) without mutating workloads, ensuring auditability while letting developers defer action. **Environment Sync Actions:** - **`Sync From Catalog`** (workload-focused) @@ -205,7 +213,7 @@ When catalog updates, environments are notified with a **simple summary**: **Manual Sync Confirmation (Sync From Catalog):** 1. UI requests latest catalog sync metadata and displays confirmation dialog with timestamp + workload count. -2. Backend verifies the catalog sync referenced by the environment (`latest_catalog_sync_id`) is still the newest available; if not, the UI refreshes with the latest summary. +2. Backend verifies the catalog sync referenced by the environment (`latest_catalog_sync_id`) is the newest entry with `status` in (`auto_applied`,`applied`); if not, the UI refreshes with the latest summary. 3. Environment Sync Orchestrator enqueues a reconciliation job marked `manual_trigger=true`, `action='catalog'`. 4. Job locks the environment record (optimistic concurrency) to prevent overlapping catalog syncs. 5. Job runs the workload reconciliation pipeline (catalog apply → dependency discovery → service refresh). @@ -258,12 +266,12 @@ The system uses **simple notifications at both levels**: ### Auto-Sync Control -Catalogs always apply updates automatically. Control resides entirely at the environment layer: +Catalogs update automatically on cluster changes and can also be edited manually; environments decide whether to apply those catalog/dependency updates automatically or manually: | Environment Auto-Sync | Behavior | |---|---| -| ✅ **ON** | **Fully automatic**: Catalog updates trigger immediate `Sync From Catalog`; dependency events trigger immediate `Sync With Cluster` | -| ❌ **OFF** | **Manual environment sync**: Catalog updates produce `Sync From Catalog` prompts; dependency events produce `Sync With Cluster` prompts | +| ✅ **ON** | **Fully automatic**: Catalog auto updates (or manual edits) trigger immediate `Sync From Catalog`; dependency events trigger immediate `Sync With Cluster`. | +| ❌ **OFF** | **Manual environment sync**: Catalog auto updates/manual edits produce `Sync From Catalog` prompts; dependency events produce `Sync With Cluster` prompts. | **Use Cases:** @@ -365,35 +373,36 @@ Default posture is **Manual (OFF)** for new environments to minimize surprise ch ## End-to-End Flow -### A. Production Change → Catalog Decision +### A. Production Change → Lapdev API 1. Production resource changes (Deployment/StatefulSet/etc.) surface through the Kubernetes Watch stream handled by kube-manager. -2. Kube-manager forwards the raw event to the API server via `report_resource_change`. +2. Kube-manager forwards the raw event to the Lapdev API server via `report_resource_change`. 3. Change Event Processor normalizes/merges events for the same workload within the dedupe window. -4. Sync Decision Engine resolves the catalog owner, records the workload names and counts, and emits/updates a `kube_app_catalog_sync_status`. -5. Catalog update executes immediately (`status = auto_applied`), generating activity notifications but no approval gate. +4. Sync Decision Engine maps the event to affected catalogs (for workload ownership) and environments (via dependency index) without mutating catalog data. +5. Impacted environments receive notification records directly; catalogs only receive activity entries for visibility. -### B. Catalog Update → Environment Sync -1. Immediately after detection, Catalog Updater writes the new spec into `kube_app_catalog_workload` and stamps `last_synced_at`. +### B. Catalog Ownership Change → Environment Sync +1. User edits the App Catalog (add/remove workload). Catalog Updater writes the new spec into `kube_app_catalog_workload` and stamps `last_synced_at`. 2. Environment Sync Orchestrator enumerates impacted environments and splits them into auto-sync and manual buckets. 3. Auto-sync environments queue execution jobs that call kube-manager to reconcile workloads and re-discover dependent ConfigMaps/Secrets/Services. 4. Manual environments receive notifications referencing the originating `kube_app_catalog_sync_status`; when a user clicks "Sync From Catalog", the orchestrator enqueues a reconciliation job identical to the auto-sync path. -5. Reconciliation outcome is persisted back on the environment (`last_catalog_synced_at` and/or `last_dependency_synced_at`, plus error metadata if failures occur) and surfaced in UI/alerts. +5. Reconciliation outcome is persisted back on the environment (`last_catalog_synced_at` and/or `last_dependency_synced_at`, plus error metadata if failures occur) and surfaced in UI/alerts; `latest_catalog_sync_id` is advanced to the newly applied record. 6. Dependency index updates record which source ConfigMaps/Secrets each environment now references, ensuring future events fan out correctly. -### C. Dependency Change → Environment Prompt +### C. Production Dependency Change → Environment Prompt 1. ConfigMap/Secret event arrives; Change Event Processor looks up impacted environments via `kube_environment_dependency`. -2. For each environment, create or update a `kube_environment_dependency_sync_status` record summarizing resource names, change types, and detection time. +2. For each environment, create or update a `kube_environment_dependency_sync_status` record summarizing resource names, change types, and detection time, and set `latest_dependency_sync_id` to point at it. 3. Auto-sync environments enqueue `Sync With Cluster` jobs immediately; manual environments receive notifications with counts per resource type. -4. When sync completes, the dependency sync status transitions to `completed` (or `failed` with error payload) and `last_dependency_synced_at` is updated. +4. When sync completes, the dependency sync status transitions to `completed` (or `failed` with error payload), `last_dependency_synced_at` is updated, and `latest_dependency_sync_id` is cleared or replaced with the next pending record. ### Sync Record State Machine | State | Trigger | Next States | Notes | |---|---|---|---| -| `auto_applied` | Catalog change detected | `completed`, `failed` | Catalog update applied immediately; environment sync queued | -| `completed` | Catalog and all required environment syncs succeed | _terminal_ | Timestamped for reporting and metrics | -| `failed` | Catalog update or required environment sync fails | `retrying`, `manual_intervention` | Failure reason persisted for diagnosis | -| `retrying` | Automatic retry scheduled/executing | `completed`, `failed` | Retry budget/interval configurable | +| `auto_applied` | Cluster change detected and catalog auto-updated | `failed`, _terminal_ | Catalog update applies immediately; `actor_id = NULL`; environment sync queued | +| `applied` | User saves catalog edit | `failed`, _terminal_ | Catalog update applies immediately; `actor_id` records user; environment sync queued | +| `failed` | Catalog edit apply fails | `retrying`, `manual_intervention` | Failure reason persisted for diagnosis | +| `retrying` | Automatic retry scheduled/executing | `applied`, `failed` | Retry budget/interval configurable | +| `dismissed` | User dismisses catalog notification without editing | _terminal_ | Keeps audit trail without spec change | | `manual_intervention` | Automated retries exhausted or blocked | _terminal_ | Signals operators to intervene | State machine extensions (`retrying`, `manual_intervention`) go beyond the minimal DDL but capture expected lifecycle so future iterations can expand persistence as needed. @@ -419,10 +428,9 @@ CREATE TABLE kube_app_catalog_sync_status ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL, app_catalog_id UUID NOT NULL REFERENCES kube_app_catalog(id), - status VARCHAR NOT NULL, -- 'auto_applied','completed','failed','retrying','manual_intervention' + status VARCHAR NOT NULL, -- 'auto_applied','applied','failed','retrying','dismissed','manual_intervention' detected_at TIMESTAMPTZ NOT NULL, - reviewed_at TIMESTAMPTZ, - reviewed_by UUID REFERENCES users(id), + actor_id UUID REFERENCES users(id), -- null for system-generated notifications workload_count INTEGER NOT NULL, -- Simple count: how many workloads changed workload_names TEXT[] NOT NULL -- Array of workload names that changed ); @@ -430,8 +438,10 @@ CREATE TABLE kube_app_catalog_sync_status ( **Storage Strategy:** - **Simple summary only**: Just count + list of workload names -- No detailed diffs stored (not needed for immediate auto-apply) -- Environments reference this record via `latest_catalog_sync_id` to know a sync is available +- `status = auto_applied` captures production-driven updates; `status = applied` records manual catalog edits +- `actor_id` stores the user that applied a catalog edit; system-driven auto updates leave it `NULL` +- No detailed diffs stored (not needed for immediate application) +- Environments reference this record via `latest_catalog_sync_id` to know when a catalog change is available to sync #### New Table: `kube_environment_configmap` @@ -489,7 +499,7 @@ CREATE TABLE kube_environment_dependency_sync_status ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL, environment_id UUID NOT NULL REFERENCES kube_environment(id), - status VARCHAR NOT NULL, -- 'pending','in_progress','completed','failed','dismissed' + status VARCHAR NOT NULL, -- 'pending','in_progress','completed','failed','retrying','dismissed','manual_intervention' detected_at TIMESTAMPTZ NOT NULL, resolved_at TIMESTAMPTZ, resolved_by UUID REFERENCES users(id), @@ -517,7 +527,7 @@ ALTER TABLE kube_environment ADD COLUMN latest_dependency_sync_id UUID REFERENCES kube_environment_dependency_sync_status(id); ``` -**Note:** When catalog updates, we just store a reference to the catalog sync that triggered the update. The environment doesn't need its own detailed diff - users can click through to the catalog sync to see details if needed. The dependency index (`kube_environment_dependency`) enables constant-time lookup when a production ConfigMap/Secret event arrives, while `kube_environment_dependency_sync_status` tracks pending dependency changes that power the `Sync With Cluster` UI. +**Note:** When catalog updates, we store references to the catalog sync (`latest_catalog_sync_id`) and dependency sync (`latest_dependency_sync_id`) records that triggered notifications. The environment doesn't need its own detailed diff - users can click through to these records to see details. The dependency index (`kube_environment_dependency`) enables constant-time lookup when a production ConfigMap/Secret event arrives, while `kube_environment_dependency_sync_status` tracks pending dependency changes that power the `Sync With Cluster` UI. #### Modified Table: `kube_app_catalog` @@ -525,15 +535,11 @@ ADD COLUMN latest_dependency_sync_id UUID REFERENCES kube_environment_dependency ALTER TABLE kube_app_catalog ADD COLUMN last_synced_at TIMESTAMPTZ; -ALTER TABLE kube_app_catalog -ADD COLUMN watch_enabled BOOLEAN NOT NULL DEFAULT true; - ALTER TABLE kube_app_catalog ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the source cluster ``` **Note:** -- `watch_enabled` controls whether to watch for changes at all - `source_namespace` tells kube-manager which namespace to watch **Namespace Watch Configuration:** @@ -574,7 +580,7 @@ ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the - Map ConfigMap/Secret/Service events to environments: - Query: Which environments reference ConfigMap X in namespace Y? 4. Build **Sync Decision Engine**: - - Workload events → Check if belongs to any catalog → Trigger catalog sync + - Workload events → Check if belongs to any catalog → Record catalog activity entry (`status = auto_applied`, actor_id = NULL) - ConfigMap/Secret/Service events → Check if belongs to any environment → Trigger environment sync - Check environment `auto_sync` flags to decide immediate reconcile vs manual prompt - Use dependency index (`kube_environment_dependency`) to map source resources to environments and create/update `kube_environment_dependency_sync_status` @@ -583,14 +589,14 @@ ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the ### Phase 2: Sync Status & Notifications 1. Create `kube_app_catalog_sync_status` table. 2. Implement sync status API endpoints for querying recent catalog updates. -3. Execute catalog auto-sync logic on every detected change, recording outcome as `auto_applied`. -4. Publish dashboard and activity feed surfaces for catalog updates (e.g., "3 workloads changed"). -5. Emit webhook/notification events (if enabled) to inform downstream systems of automatic catalog changes. +3. Record catalog activity entries on production changes (`status = auto_applied`) and catalog edits (`status = applied`). +4. Publish dashboard and activity feed surfaces for production detections and user-applied catalog edits. +5. Emit webhook/notification events (if enabled) to inform downstream systems of catalog activity (without auto-mutating specs). 6. Provide API utilities to resolve ConfigMap/Secret events to environments via `kube_environment_dependency` and expose `kube_environment_dependency_sync_status` summaries (updating `latest_dependency_sync_id`). ### Phase 3: Catalog Update -1. Implement catalog update logic that runs immediately after sync decision -2. Update `kube_app_catalog_workload` specs from production cluster +1. Implement catalog update logic invoked when users save catalog edits +2. Update `kube_app_catalog_workload` specs based on the saved user edits (optionally sourcing manifests from production for new workloads) 3. Update `kube_app_catalog.last_synced_at` timestamp 4. Trigger environment sync flow (Phase 4) 5. Track version history (optional) @@ -619,10 +625,77 @@ ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the 5. Add environment sync status indicators. 6. Add manual sync triggers for environments with auto-sync disabled. 7. Implement notification badges: - - Catalog: "Auto-updated - 3 workloads" + - Catalog: "Production change detected - 3 workloads" (notification) vs "Edit applied - workload foo added" - Environment: "Catalog update available - 3 workloads" vs "Config update available - 2 ConfigMaps" (manual) and a single "Syncing..." state for auto actions 8. Support dismiss actions that update dependency sync status (set to `dismissed`) or log catalog deferrals without applying changes. +## Detailed Implementation Steps + +1. **Schema & Persistence** + - [ ] Draft migration plan + - [ ] Document new tables/columns, data types, indexes, FK relationships. + - [ ] Review migration design with Data Platform (sizing, retention, encryption). + - [ ] Implement migrations + - [ ] Create tables `kube_environment_configmap`, `kube_environment_secret`, `kube_environment_dependency`, `kube_environment_dependency_sync_status`. + - [ ] Alter `kube_app_catalog_sync_status` and `kube_environment` to add new statuses/timestamps/references. + - [ ] Add supporting indexes (config/secret uniqueness, dependency lookup, catalog status filtering). + - [ ] Add constraints/foreign keys with documented ON DELETE behavior. + - [ ] Backfill legacy data + - [ ] Snapshot current environment resources (scripts/queries). + - [ ] Populate config/secret tables (respect encryption requirements). + - [ ] Build & run dependency discovery seeding script. + - [ ] Seed catalog activity rows with `status = auto_applied`, `actor_id = NULL` (representing current production state). + - [ ] Validate & harden + - [ ] Add automated tests verifying row counts/integrity post-backfill. + - [ ] Prepare rollback SQL for each migration. + - [ ] Write migration runbook (order, downtime, verification). + +2. **Kube-Manager Enhancements** + - [ ] Implement `configure_watches` RPC (handler, diffing, persistence, retries). + - [ ] Extend resource watchers to include workloads/configmaps/secrets/services with full YAML payloads. + - [ ] Add event queuing/backpressure controls and Prometheus metrics (latency, queue depth, resource version lag). + - [ ] Write integration tests (namespace changes, reconnects, event storms). + - [ ] Update deployment artifacts (Helm/manifests), permissions, and release notes. + +3. **Lapdev API – Event Processing** + - [ ] Build Change Event Processor endpoint (validation, YAML parsing, auth). + - [ ] Implement workload event flow (catalog ownership lookup, dedupe, create `auto_applied` activity with `actor_id = NULL`, enqueue notification job). + - [ ] Implement dependency event flow (dependency lookup, upsert sync status, auto-sync queueing, update environment `latest_dependency_sync_id`). + - [ ] Add dedupe windowing + stale dependency guard rails (rebuild triggers). + - [ ] Expose APIs to list catalog activity and dependency statuses (with tests). + +4. **Catalog Management** + - [ ] Update backend edit handlers (apply workload adds/removes, optional manifest fetch). + - [ ] Insert `status = applied` activity rows with actor metadata and trigger environment sync orchestration. + - [ ] Build catalog activity listing endpoints + UI components (feed, details, filters). + - [ ] Implement catalog dismiss audit endpoint and UI action. + - [ ] Add automated/unit/UI tests for edits, rollbacks, activity log correctness. + +5. **Environment Sync Orchestrator** + - [ ] Define `sync_environment` RPC schema and job queue wiring. + - [ ] Implement catalog sync worker (workload apply, dependency refresh, service updates, update environment timestamps, set `latest_catalog_sync_id` to applied record). + - [ ] Implement dependency sync worker (resource fetch, table updates, dependency pruning, update status/timestamps, clear or advance `latest_dependency_sync_id`). + - [ ] Add concurrency controls (per-environment locks) and execution telemetry logging. + - [ ] Implement retry/backoff policy and error reporting surfaced to API/UI. + +6. **User Experience & Notifications** + - [ ] Build catalog activity feed UI (notification cards, workload links, dismiss action). + - [ ] Build environment sync UI (pending actions panel, `Sync From Catalog` / `Sync With Cluster` modals, history). + - [ ] Add environment settings page for auto-sync toggles and reminders. + - [ ] Extend notification service for Slack/email/webhooks with consistent payloads. + - [ ] Instrument UX analytics (CTA usage, dismissals, sync outcomes) and dashboard. + +7. **Observability & Operations** + - [ ] Instrument metrics (event ingest, dedupe, queue depth, sync latency, failure/dismiss counts) with tagging. + - [ ] Define alerts and write runbooks (watch health, backlog, failure spikes, stale prompts, dismissal spikes). + - [ ] Implement feature flags/kill switches (environment auto-sync, catalog notifications, dependency auto-sync) plus documentation. + - [ ] Deliver operational tooling (CLI/API for manual sync, dependency rebuild, dismissal reset) and train support/SRE teams. + +8. **Rollout & Validation** + - [ ] Internal dogfood: enable flags, simulate production changes, test dismiss/sync flows, run chaos scenarios. + - [ ] Design partner preview: enable for selected customers, collect qualitative/quantitative feedback, iterate on thresholds/UX. + - [ ] Progressive auto-sync: allow opt-in, instrument success/failure, shadow dependency auto-sync before enabling writes. + - [ ] General availability: publish docs/support materials, default new environments to manual, monitor telemetry/support closely, plan post-release review. ## Workstream Overview & Dependencies | Workstream | Scope | Lead Team | Key Dependencies | @@ -640,10 +713,10 @@ Critical path: Watch Infrastructure → Sync Decision Engine → Persistence → 1. **Internal Dogfood (Phase 1–3)** - Target a non-production catalog within Lapdev infrastructure. - - Run with catalog auto-sync enabled and environments in manual mode to validate detection and apply fidelity. + - Validate production-change notifications with environments in manual mode; ensure catalog activity logs populate without mutating specs. 2. **Design Partner Preview (Phase 4)** - Select 1–2 customers with tolerant workloads. - - Gate behind feature flag: catalog auto-sync enforced, environments default manual. + - Gate behind feature flag: catalog notifications enabled, environments default manual. - Collect SLA metrics (event latency, sync duration) and monitor false-positive rate. 3. **Progressive Environment Auto-Sync Enablement (Phase 5)** - Offer environment auto-sync opt-in to preview customers once reconciliation stability is proven. @@ -719,13 +792,13 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo 1. **Selective Field Sync**: User chooses which fields to sync (e.g., always sync images, but not resource limits) 2. **Rollback**: Revert catalog to previous version if sync causes issues 3. **Dry Run**: Preview what would change in environments before applying -4. **Scheduled Sync**: Define maintenance windows for auto-sync +4. **Scheduled Sync**: Define maintenance windows for environment auto-sync 5. **Notifications**: Slack/email notifications for environment sync prompts ## Security Considerations 1. **RBAC**: Only catalog editors can modify watch settings; environment sync actions require environment write access. -2. **Audit Log**: Record all catalog auto-sync executions and manual environment sync invocations with actor (or system) and timestamp. +2. **Audit Log**: Record all catalog edits (who changed workloads) and manual environment sync invocations with actor (or system) and timestamp. 3. **Validation**: Validate workload specs before applying (resource limits, security contexts) 4. **Rate Limiting**: Prevent sync storms (max N syncs per minute) 5. **Secret Handling**: @@ -764,7 +837,7 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo 5. **Edge Case Tests**: - Unknown events (resources not in any catalog) - Namespace watch list changes during active sync - - Catalog deleted shortly after auto-sync + - Catalog deleted shortly after edit applied - Multiple overlapping syncs for same catalog ## Metrics & Monitoring @@ -783,7 +856,7 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo - Deduplication effectiveness (events deduplicated / total events) **Sync Workflow Metrics:** -- Catalog sync latency (detection → catalog applied) +- Catalog edit latency (user save → catalog applied) - Environment sync latency (catalog updated → environment synced) - Environment decision latency (catalog applied → user-triggered sync) for manual environments - Dependency sync latency (event detected → dependency sync completed) @@ -802,7 +875,7 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo - **Runbooks:** Document common failure paths (watch disconnect, sync failure, secret decryption error, dependency sync backlog) including diagnostic commands and escalation contacts. - **Alerting:** Trigger alerts when sync latency or failure rate breach thresholds, or when no events are received for a watched namespace within N minutes. - **Backfill:** Provide a CLI/API command to backfill sync records after outages; ensures state catch-up without manual DB edits. -- **Feature Flags:** Maintain kill switches for auto-sync at catalog and environment level to halt rollout quickly if issues emerge. +- **Feature Flags:** Maintain kill switches for environment auto-sync (and catalog notifications) to halt rollout quickly if issues emerge. - **Access Controls:** Verify permissions for support engineers to inspect sync status without granting ability to trigger environment syncs. ## Risks & Mitigations From 10c6653df84c27f178eb27fb9db311aed8558bf2 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sat, 18 Oct 2025 16:59:51 +0000 Subject: [PATCH 124/334] update --- crates/api-hrpc/src/lib.rs | 1 - crates/api/src/hrpc_service.rs | 11 +- crates/api/src/kube_controller.rs | 288 +++++++++++------ crates/dashboard/src/kube_app_catalog.rs | 234 +------------- ...20250809_000001_create_kube_environment.rs | 8 + crates/kube-manager/src/manager.rs | 64 +++- crates/kube-manager/src/manager_rpc.rs | 30 ++ crates/kube-rpc/src/lib.rs | 2 + plans/automatic-sync.md | 291 ++++++++---------- 9 files changed, 431 insertions(+), 498 deletions(-) diff --git a/crates/api-hrpc/src/lib.rs b/crates/api-hrpc/src/lib.rs index f595ac9..5a525f9 100644 --- a/crates/api-hrpc/src/lib.rs +++ b/crates/api-hrpc/src/lib.rs @@ -167,7 +167,6 @@ pub trait HrpcService { app_catalog_id: Uuid, cluster_id: Uuid, name: String, - namespace: String, is_shared: bool, ) -> Result; diff --git a/crates/api/src/hrpc_service.rs b/crates/api/src/hrpc_service.rs index 87014e4..829af51 100644 --- a/crates/api/src/hrpc_service.rs +++ b/crates/api/src/hrpc_service.rs @@ -758,21 +758,12 @@ impl HrpcService for CoreState { app_catalog_id: Uuid, cluster_id: Uuid, name: String, - namespace: String, is_shared: bool, ) -> Result { let user = self.authorize(headers, org_id, None).await?; self.kube_controller - .create_kube_environment( - org_id, - user.id, - app_catalog_id, - cluster_id, - name, - namespace, - is_shared, - ) + .create_kube_environment(org_id, user.id, app_catalog_id, cluster_id, name, is_shared) .await .map_err(HrpcError::from) } diff --git a/crates/api/src/kube_controller.rs b/crates/api/src/kube_controller.rs index d7d01af..71d83af 100644 --- a/crates/api/src/kube_controller.rs +++ b/crates/api/src/kube_controller.rs @@ -20,6 +20,12 @@ use lapdev_rpc::error::ApiError; use sea_orm::TransactionTrait; use secrecy::ExposeSecret; +enum EnvironmentNamespaceKind { + Personal, + Shared, + Branch, +} + #[derive(Clone)] pub struct KubeController { // KubeManager connections per cluster @@ -47,6 +53,20 @@ impl KubeController { servers.get(&cluster_id)?.last().cloned() } + async fn generate_unique_namespace( + &self, + _cluster_id: Uuid, + kind: EnvironmentNamespaceKind, + ) -> Result { + let prefix = match kind { + EnvironmentNamespaceKind::Personal => "lapdev-personal", + EnvironmentNamespaceKind::Shared => "lapdev-shared", + EnvironmentNamespaceKind::Branch => "lapdev-branch", + }; + + Ok(format!("{prefix}-{}", rand_string(12))) + } + pub async fn get_all_kube_clusters(&self, org_id: Uuid) -> Result, ApiError> { let clusters = self .db @@ -694,63 +714,88 @@ impl KubeController { } } - // TODO: Clean up Kubernetes resources from the cluster - // Get cluster server to perform cleanup if needed - if let Some(_cluster_server) = self + let rpc_client = self .get_random_kube_cluster_server(environment.cluster_id) .await - { - tracing::info!( - "Deleting environment '{}' in namespace '{}'", - environment.name, - environment.namespace - ); - // TODO: Implement actual K8s resource cleanup - } + .ok_or_else(|| { + ApiError::InvalidRequest( + "No connected KubeManager for this cluster; cannot delete environment" + .to_string(), + ) + })? + .rpc_client + .clone(); // If this is a branch environment, notify the base environment's devbox-proxy before deletion if let Some(base_env_id) = environment.base_environment_id { - if let Some(server) = self - .get_random_kube_cluster_server(environment.cluster_id) + match rpc_client + .remove_branch_environment(tarpc::context::current(), base_env_id, environment_id) .await { - match server - .rpc_client - .remove_branch_environment( - tarpc::context::current(), - base_env_id, + Ok(Ok(())) => { + tracing::info!( + "Successfully notified devbox-proxy about branch environment {} deletion", + environment_id + ); + } + Ok(Err(e)) => { + tracing::error!( + "Failed to notify devbox-proxy about branch environment {} deletion: {}", environment_id, - ) - .await - { - Ok(Ok(())) => { - tracing::info!( - "Successfully notified devbox-proxy about branch environment {} deletion", - environment_id - ); - } - Ok(Err(e)) => { - tracing::error!( - "Failed to notify devbox-proxy about branch environment {} deletion: {}", - environment_id, - e - ); - } - Err(e) => { - tracing::error!( - "RPC call failed when notifying about branch environment {} deletion: {}", - environment_id, - e - ); - } + e + ); + return Err(ApiError::InvalidRequest(format!( + "Failed to notify devbox-proxy about branch environment deletion: {e}" + ))); } - } else { - tracing::warn!( - "No connected KubeManager for cluster {} - branch environment {} deletion not notified to devbox-proxy", - environment.cluster_id, - environment_id + Err(e) => { + tracing::error!( + "RPC call failed when notifying about branch environment {} deletion: {}", + environment_id, + e + ); + return Err(ApiError::InvalidRequest(format!( + "Connection error while notifying devbox-proxy: {e}" + ))); + } + } + } + + match rpc_client + .destroy_environment( + tarpc::context::current(), + environment.id, + environment.namespace.clone(), + ) + .await + { + Ok(Ok(())) => { + tracing::info!( + "Successfully deleted resources for environment {} in namespace {}", + environment.id, + environment.namespace ); } + Ok(Err(e)) => { + tracing::error!( + "KubeManager error when deleting environment {}: {}", + environment.id, + e + ); + return Err(ApiError::InvalidRequest(format!( + "Failed to delete environment resources: {e}" + ))); + } + Err(e) => { + tracing::error!( + "Connection error when deleting environment {}: {}", + environment.id, + e + ); + return Err(ApiError::InvalidRequest(format!( + "Failed to communicate with KubeManager to delete environment: {e}" + ))); + } } // Delete from database (soft delete) @@ -920,7 +965,7 @@ impl KubeController { pub async fn add_workloads_to_app_catalog( &self, org_id: Uuid, - user_id: Uuid, + _user_id: Uuid, catalog_id: Uuid, workloads: Vec, ) -> Result<(), ApiError> { @@ -978,9 +1023,16 @@ impl KubeController { app_catalog_id: Uuid, cluster_id: Uuid, name: String, - namespace: String, is_shared: bool, ) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(ApiError::InvalidRequest( + "Environment name cannot be empty".to_string(), + )); + } + let name = name.to_string(); + // Verify app catalog belongs to the organization let app_catalog = self .db @@ -1046,6 +1098,19 @@ impl KubeController { .get_workloads_yaml_for_catalog(&app_catalog, workloads.clone()) .await?; + let namespace = self + .generate_unique_namespace( + cluster_id, + if is_shared { + EnvironmentNamespaceKind::Shared + } else { + EnvironmentNamespaceKind::Personal + }, + ) + .await?; + + let services_map = workloads_with_resources.services.clone(); + // Store the environment workloads in the database before deployment let workload_details: Vec = workloads .into_iter() @@ -1078,7 +1143,6 @@ impl KubeController { }) .collect(); - // Create the environment, workloads, and services in a single transaction let created_env = match self .db .create_kube_environment( @@ -1092,30 +1156,35 @@ impl KubeController { is_shared, None, // No base environment for regular environments workload_details, - workloads_with_resources.services.clone(), + services_map, ) .await { Ok(env) => env, Err(db_err) => { - // Check if this is a unique constraint violation for app catalog + cluster + namespace - if matches!( - db_err.sql_err(), - Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) - ) { - return Err(ApiError::InvalidRequest( - "This app catalog is already deployed to the specified namespace in this cluster".to_string(), - )); - } else { - return Err(ApiError::from(anyhow::Error::from(db_err))); + if let Some(sea_orm::SqlErr::UniqueConstraintViolation(constraint)) = + db_err.sql_err() + { + if constraint == "kube_environment_app_cluster_namespace_unique_idx" { + return Err(ApiError::InvalidRequest( + "This app catalog is already deployed to the specified namespace in this cluster".to_string(), + )); + } + if constraint == "kube_environment_cluster_namespace_unique_idx" { + return Err(ApiError::InternalError( + "Namespace allocation conflict. Please retry environment creation." + .to_string(), + )); + } } + return Err(ApiError::from(anyhow::Error::from(db_err))); } }; // Deploy the app catalog resources to the cluster using pre-fetched YAML self.deploy_app_catalog_with_yaml( &server, - &namespace, + &created_env.namespace, &name, created_env.id, Some(created_env.auth_token.clone()), @@ -1148,6 +1217,14 @@ impl KubeController { base_environment_id: Uuid, name: String, ) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(ApiError::InvalidRequest( + "Environment name cannot be empty".to_string(), + )); + } + let name = name.to_string(); + // Get the base environment let base_environment = self .db @@ -1196,6 +1273,41 @@ impl KubeController { .await .map_err(ApiError::from)?; + let namespace = self + .generate_unique_namespace( + base_environment.cluster_id, + EnvironmentNamespaceKind::Branch, + ) + .await?; + + let services_map: std::collections::HashMap< + String, + lapdev_common::kube::KubeServiceWithYaml, + > = base_services + .into_iter() + .map(|service| { + ( + service.name.clone(), + lapdev_common::kube::KubeServiceWithYaml { + yaml: service.yaml, + details: lapdev_common::kube::KubeServiceDetails { + name: service.name, + ports: service.ports, + selector: service.selector, + }, + }, + ) + }) + .collect(); + + // Get app catalog info for the response + let app_catalog = self + .db + .get_app_catalog(base_environment.app_catalog_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + // Convert workloads to the format needed for database creation let workload_details: Vec = base_workloads .into_iter() @@ -1221,7 +1333,7 @@ impl KubeController { } lapdev_common::kube::KubeWorkloadDetails { name: workload.name, - namespace: base_environment.namespace.clone(), + namespace: namespace.clone(), kind, containers, ports: workload.ports, @@ -1230,37 +1342,7 @@ impl KubeController { }) .collect(); - // Convert services to the format needed for database creation - let services_map: std::collections::HashMap< - String, - lapdev_common::kube::KubeServiceWithYaml, - > = base_services - .into_iter() - .map(|service| { - ( - service.name.clone(), - lapdev_common::kube::KubeServiceWithYaml { - yaml: service.yaml, - details: lapdev_common::kube::KubeServiceDetails { - name: service.name, - ports: service.ports, - selector: service.selector, - }, - }, - ) - }) - .collect(); - - // Get app catalog info for the response - let app_catalog = self - .db - .get_app_catalog(base_environment.app_catalog_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - - // Create the branch environment in the database - let created_env = self + let created_env = match self .db .create_kube_environment( org_id, @@ -1268,7 +1350,7 @@ impl KubeController { base_environment.app_catalog_id, base_environment.cluster_id, name.clone(), - base_environment.namespace.clone(), // Use same namespace as base + namespace.clone(), "Pending".to_string(), false, // Branch environments are always personal (not shared) Some(base_environment_id), // Set the base environment reference @@ -1276,7 +1358,21 @@ impl KubeController { services_map, ) .await - .map_err(ApiError::from)?; + { + Ok(env) => env, + Err(db_err) => { + if let Some(sea_orm::SqlErr::UniqueConstraintViolation(constraint)) = + db_err.sql_err() + { + if constraint == "kube_environment_cluster_namespace_unique_idx" { + return Err(ApiError::InternalError( + "Namespace allocation conflict. Please retry branch environment creation.".to_string(), + )); + } + } + return Err(ApiError::from(anyhow::Error::from(db_err))); + } + }; // Notify kube-manager about the new branch environment via RPC if let Some(server) = self @@ -1395,7 +1491,7 @@ impl KubeController { pub async fn delete_kube_namespace( &self, - org_id: Uuid, + _org_id: Uuid, namespace_id: Uuid, ) -> Result<(), ApiError> { self.db diff --git a/crates/dashboard/src/kube_app_catalog.rs b/crates/dashboard/src/kube_app_catalog.rs index 5876c0e..2e164ca 100644 --- a/crates/dashboard/src/kube_app_catalog.rs +++ b/crates/dashboard/src/kube_app_catalog.rs @@ -2,14 +2,13 @@ use anyhow::{anyhow, Result}; use lapdev_api_hrpc::HrpcServiceClient; use lapdev_common::console::Organization; use lapdev_common::kube::{ - KubeAppCatalog, KubeCluster, KubeClusterStatus, KubeNamespace, PagePaginationParams, - PaginatedInfo, PaginatedResult, + KubeAppCatalog, KubeCluster, KubeClusterStatus, PagePaginationParams, PaginatedInfo, + PaginatedResult, }; use leptos::prelude::*; use uuid::Uuid; use crate::component::hover_card::HoverCardPlacement; -use crate::component::select::SelectSeparator; use crate::{ component::{ badge::{Badge, BadgeVariant}, @@ -436,11 +435,8 @@ pub fn CreateEnvironmentModal( update_counter: RwSignal, ) -> impl IntoView { let environment_name = RwSignal::new_local("".to_string()); - let namespace = RwSignal::new_local("".to_string()); let selected_cluster = RwSignal::new(app_catalog.cluster_id); let is_shared = RwSignal::new(false); - let selected_namespace_id = RwSignal::new(None::); - let namespace_creation_modal_open = RwSignal::new(false); let org = get_current_org(); let client = HrpcServiceClient::new("/api/rpc".to_string()); @@ -451,18 +447,6 @@ pub fn CreateEnvironmentModal( Ok::, anyhow::Error>(client.all_kube_clusters(org.id).await??) }); - // Load available namespaces based on environment type - let namespaces_resource = LocalResource::new(move || { - let is_shared_val = is_shared.get(); - async move { - let org = org.get().ok_or_else(|| anyhow!("can't get org"))?; - let client = HrpcServiceClient::new("/api/rpc".to_string()); - Ok::, anyhow::Error>( - client.all_kube_namespaces(org.id, is_shared_val).await??, - ) - } - }); - let clusters = Signal::derive(move || { clusters_resource.with(|result| { result @@ -472,32 +456,11 @@ pub fn CreateEnvironmentModal( }) }); - let namespaces = Signal::derive(move || { - namespaces_resource.with(|result| { - result - .as_ref() - .map(|res| res.as_ref().unwrap_or(&vec![]).clone()) - .unwrap_or_default() - }) - }); - let navigate = leptos_router::hooks::use_navigate(); let create_action = Action::new_local(move |_| { let client = client.clone(); let nav = navigate.clone(); async move { - // Get the selected namespace name - let namespace_name = selected_namespace_id - .get_untracked() - .and_then(|ns_id| { - namespaces - .get_untracked() - .into_iter() - .find(|ns| ns.id == ns_id) - .map(|ns| ns.name) - }) - .ok_or_else(|| anyhow!("Please select a namespace"))?; - let created_environment = client .create_kube_environment( org.get_untracked() @@ -506,7 +469,6 @@ pub fn CreateEnvironmentModal( app_catalog.id, selected_cluster.get_untracked(), environment_name.get_untracked(), - namespace_name, is_shared.get_untracked(), ) .await??; @@ -516,7 +478,6 @@ pub fn CreateEnvironmentModal( }); modal_open.set(false); environment_name.set("".to_string()); - selected_namespace_id.set(None); // Navigate to the created environment detail page nav( @@ -608,86 +569,6 @@ pub fn CreateEnvironmentModal( } }

-
- - { - let namespace_dropdown_open = RwSignal::new(false); - view! { - - } - } -
@@ -715,6 +596,9 @@ pub fn CreateEnvironmentModal(
+

+ {"Namespaces are generated automatically to keep every environment isolated."} +

App Catalog Details:

    @@ -728,113 +612,5 @@ pub fn CreateEnvironmentModal(
- // Namespace Creation Modal - - } -} - -#[component] -pub fn CreateNamespaceModal( - modal_open: RwSignal, - is_shared: ReadSignal, - on_created: Callback, -) -> impl IntoView { - let namespace_name = RwSignal::new_local("".to_string()); - let namespace_description = RwSignal::new_local("".to_string()); - let org = get_current_org(); - let client = HrpcServiceClient::new("/api/rpc".to_string()); - - let create_namespace_action = Action::new_local(move |_| { - let client = client.clone(); - let name = namespace_name.get_untracked(); - let description = namespace_description.get_untracked(); - let is_shared_val = is_shared.get_untracked(); - async move { - let namespace = client - .create_kube_namespace( - org.get_untracked() - .ok_or_else(|| anyhow!("can't get org"))? - .id, - name, - if description.is_empty() { - None - } else { - Some(description) - }, - is_shared_val, - ) - .await??; - - on_created.run(namespace.id); - modal_open.set(false); - namespace_name.set("".to_string()); - namespace_description.set("".to_string()); - Ok(()) - } - }); - - view! { - -
-

Create New Namespace

-
-
- - -
-
- - -
-
-

Namespace Type:

-
- - {if is_shared.get() { "Shared" } else { "Personal" }} - - - {if is_shared.get() { - "This namespace will be accessible by all organization members" - } else { - "This namespace will be private to you" - }} - -
-
-
-
-
} } diff --git a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs index 4d71ce6..d6e01c4 100644 --- a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs +++ b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs @@ -147,6 +147,14 @@ impl MigrationTrait for Migration { ) .await?; + manager + .get_connection() + .execute_unprepared( + "CREATE UNIQUE INDEX kube_environment_cluster_namespace_unique_idx + ON kube_environment (cluster_id, namespace, deleted_at) NULLS NOT DISTINCT", + ) + .await?; + Ok(()) } } diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index 15c8bc5..19dcabf 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, sync::Arc, time::Instant}; use anyhow::{anyhow, Result}; use base64::{engine::general_purpose::STANDARD, Engine}; @@ -12,7 +12,10 @@ use k8s_openapi::{ }, NamespaceResourceScope, }; -use kube::{api::ListParams, config::AuthInfo}; +use kube::{ + api::{DeleteParams, ListParams}, + config::AuthInfo, +}; use lapdev_common::kube::{ KubeAppCatalogWorkload, KubeClusterInfo, KubeClusterStatus, KubeContainerImage, KubeContainerInfo, KubeContainerPort, KubeNamespaceInfo, KubeServiceDetails, KubeServicePort, @@ -28,6 +31,7 @@ use lapdev_kube_rpc::{ use lapdev_rpc::spawn_twoway; use serde::Deserialize; use tarpc::server::{BaseChannel, Channel}; +use tokio::time::{sleep, Duration}; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_util::codec::LengthDelimitedCodec; use uuid::Uuid; @@ -2793,6 +2797,62 @@ impl KubeManager { Ok(()) } + pub(crate) async fn destroy_environment( + &self, + environment_id: Uuid, + namespace: &str, + ) -> Result<()> { + tracing::info!( + "Destroying environment {} and cleaning namespace '{}'", + environment_id, + namespace + ); + self.delete_namespace(namespace).await + } + + async fn delete_namespace(&self, namespace: &str) -> Result<()> { + let client = &self.kube_client; + let namespaces: kube::Api = kube::Api::all((**client).clone()); + + if namespaces.get_opt(namespace).await?.is_none() { + tracing::info!( + "Namespace '{}' not found during deletion, assuming already removed", + namespace + ); + return Ok(()); + } + + match namespaces.delete(namespace, &DeleteParams::default()).await { + Ok(_) => { + let timeout = Duration::from_secs(60); + let start = Instant::now(); + loop { + if namespaces.get_opt(namespace).await?.is_none() { + tracing::info!("Namespace '{}' deleted successfully", namespace); + break; + } + + if start.elapsed() >= timeout { + return Err(anyhow!( + "Timed out waiting for namespace '{}' deletion", + namespace + )); + } + + sleep(Duration::from_secs(1)).await; + } + } + Err(kube::Error::Api(ae)) if ae.code == 404 => { + tracing::info!("Namespace '{}' already deleted", namespace); + } + Err(e) => { + return Err(anyhow!("Failed to delete namespace '{}': {}", namespace, e)); + } + } + + Ok(()) + } + fn inject_sidecar_proxy_into_deployment( &self, environment_id: Uuid, diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index 331c344..f1fbfeb 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -379,6 +379,36 @@ impl KubeManagerRpc for KubeManagerRpcServer { } } + async fn destroy_environment( + self, + _context: ::tarpc::context::Context, + environment_id: Uuid, + namespace: String, + ) -> Result<(), String> { + tracing::info!( + "Received request to destroy environment {} in namespace {}", + environment_id, + namespace + ); + + match self + .manager + .destroy_environment(environment_id, &namespace) + .await + { + Ok(()) => Ok(()), + Err(e) => { + tracing::error!( + "Failed to destroy environment {} in namespace {}: {}", + environment_id, + namespace, + e + ); + Err(format!("Failed to destroy environment: {}", e)) + } + } + } + async fn get_tunnel_status( self, _context: ::tarpc::context::Context, diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index 8b9e7bc..b37dc11 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -382,6 +382,8 @@ pub trait KubeManagerRpc { labels: std::collections::HashMap, ) -> Result<(), String>; + async fn destroy_environment(environment_id: Uuid, namespace: String) -> Result<(), String>; + // Preview URL tunnel methods async fn get_tunnel_status() -> Result; diff --git a/plans/automatic-sync.md b/plans/automatic-sync.md index c8668d7..2a322f7 100644 --- a/plans/automatic-sync.md +++ b/plans/automatic-sync.md @@ -39,7 +39,7 @@ This causes drift between production and development environments. - **App Catalog**: The Lapdev blueprint that defines which workloads belong to a product line. - **Environment**: A Lapdev-managed namespace (shared or branch) created from a catalog. - **Auto-Sync**: Flag indicating the system may apply changes without waiting for explicit approval at that level. -- **Sync Record**: Row in `kube_app_catalog_sync_status` that tracks detected workload changes. +- **Catalog Sync Version**: Monotonic counter stored on `kube_app_catalog` that environments compare to detect new updates. ## Architecture Decision: Catalog vs Environment Storage @@ -60,7 +60,7 @@ This causes drift between production and development environments. - Services are discovered dynamically based on current workload selectors - Storing at environment level allows per-environment customization while still syncing from source - Catalogs serve as blueprints that update automatically when source workloads change, while still allowing teams to curate manual edits when required. -- A dedicated dependency index (`kube_environment_dependency`) tracks which production ConfigMaps/Secrets feed each environment so change detection can fan out events without re-scanning workloads. +- Environment resource tables (`kube_environment_configmap` / `kube_environment_secret` / `kube_environment_service`) retain source metadata (namespace/name) so the API can map production changes to environments without re-scanning workloads. ## Design Decisions @@ -125,8 +125,8 @@ trait KubeManagerRpc { 3. Kube-manager sends: `report_resource_change(ResourceChangeEvent { namespace: "production", resource_type: Deployment, resource_name: "api-server", ... })` 4. API server receives event 5. API server queries: "Which catalog has workload 'api-server' in namespace 'production'?" → finds catalog ID -6. API server fetches the latest workload manifest from the source cluster, updates the catalog workload spec, and records an `auto_applied` activity (actor `NULL`). -7. Environments referencing the catalog have their `latest_catalog_sync_id` set to the new activity record and receive notifications prompting `Sync From Catalog` / `Sync With Cluster`. +6. API server fetches the latest workload manifest from the source cluster, updates the catalog workload spec, bumps `sync_version`, and records `last_sync_status = 'auto_applied'` (`last_sync_actor_id = NULL`). +7. Environments detect the version bump (catalog `sync_version` > environment `catalog_sync_version`) and receive notifications prompting `Sync From Catalog` / `Sync With Cluster`. ### 2. Catalog Updates: Auto From Cluster & Manual Edits @@ -134,11 +134,13 @@ trait KubeManagerRpc { **Behavior (Cluster-Driven Auto Update):** - Kube-manager events deliver the latest workload manifest to the API server. -- Catalog workload specs are updated in place, and an `auto_applied` activity is recorded (`actor_id = NULL`). -- Environments referencing the catalog set `latest_catalog_sync_id` to the new activity and proceed through the environment sync flow (auto or manual). +- Catalog workload specs are updated in place, `sync_version` increments, and `last_sync_status = 'auto_applied'` with `last_sync_actor_id = NULL`. +- `last_sync_summary` captures a lightweight summary (workload names/counts) of the detected change for UI and notifications. +- Environments reference the new `sync_version` to drive environment sync (auto or manual). **Behavior (Admin Edits):** -- When a user saves catalog edits, the platform applies the new workload set immediately and records an `applied` activity with the actor’s ID. +- When a user saves catalog edits, the platform applies the new workload set immediately and records `last_sync_status = 'applied'` with the actor’s ID. +- `last_sync_summary` reflects the user-provided change set (e.g., workloads added/removed) for auditing and notifications. - These manual edits follow the same environment sync flow, respecting environment auto-sync flags. **Catalog Activity Summary (Auto Update Example):** @@ -158,7 +160,7 @@ trait KubeManagerRpc { └─────────────────────────────────────────────────┘ ``` -When a user edits the catalog, the summary shifts to reflect the applied change (e.g., "✅ Workload 'api-server' added by Alice") and references the same activity log for auditing. +When a user edits the catalog, the summary shifts to reflect the applied change (e.g., "✅ Workload 'api-server' added by Alice") and references the catalog’s stored sync metadata (`last_sync_summary`, `last_sync_status`, `last_synced_at`, `last_sync_actor_id`) for auditing. **Scope:** - **App Catalog watches**: Workloads only (for notification context) @@ -197,43 +199,43 @@ When catalog updates, environments are notified with a **simple summary**: └─────────────────────────────────────────────────┘ ``` -`Dismiss` records the prompt as dismissed in `kube_environment_dependency_sync_status` (for dependency events) or emits an audit log event for catalog prompts (no schema change) without mutating workloads, ensuring auditability while letting developers defer action. +`Dismiss` marks `dependency_sync_status = 'idle'` and clears `dependency_summary` on the environment (for dependency events) or emits an audit log event for catalog prompts (no schema change) without mutating workloads, ensuring auditability while letting developers defer action. **Environment Sync Actions:** - **`Sync From Catalog`** (workload-focused) 1. Apply catalog workload spec changes, including adds/removes. 2. Reconcile workload-level metadata (labels, annotations, autoscaling) to match the catalog. - 3. Trigger dependency discovery for any new/removed workloads and update `kube_environment_dependency` accordingly. + 3. Trigger dependency discovery for any new/removed workloads and refresh source metadata on `kube_environment_configmap` / `kube_environment_secret` / `kube_environment_service` accordingly. 4. Update environment tables for workloads plus referenced ConfigMaps/Secrets/Services encountered during discovery. - **`Sync With Cluster`** (dependency-focused) - 1. Rediscover ConfigMaps, Secrets, and Services from the source cluster based on existing dependency index rows. + 1. Rediscover ConfigMaps, Secrets, and Services from the source cluster using the stored source metadata on the environment resource tables. 2. Refresh values in `kube_environment_configmap`, `kube_environment_secret`, `kube_environment_service`. - 3. Update dependency index discovery timestamps; remove rows whose backing resources no longer exist. + 3. Update discovery timestamps in those tables; remove rows whose backing resources no longer exist. 4. Leave workloads untouched, allowing developers to defer catalog changes while still pulling latest configs. **Manual Sync Confirmation (Sync From Catalog):** -1. UI requests latest catalog sync metadata and displays confirmation dialog with timestamp + workload count. -2. Backend verifies the catalog sync referenced by the environment (`latest_catalog_sync_id`) is the newest entry with `status` in (`auto_applied`,`applied`); if not, the UI refreshes with the latest summary. -3. Environment Sync Orchestrator enqueues a reconciliation job marked `manual_trigger=true`, `action='catalog'`. +1. UI requests latest catalog sync metadata (`sync_version`, `last_sync_status`, `last_synced_at`, `last_sync_summary`) and displays confirmation dialog with timestamp + workload count. +2. Backend verifies the catalog sync version stored on the environment matches the catalog’s current `sync_version`; if not, the UI refreshes with the latest summary before proceeding. +3. Environment Sync Orchestrator enqueues a reconciliation job marked `manual_trigger=true`, `action='catalog'`, and passes the target `catalog_sync_version`. 4. Job locks the environment record (optimistic concurrency) to prevent overlapping catalog syncs. 5. Job runs the workload reconciliation pipeline (catalog apply → dependency discovery → service refresh). 6. Upon success, the environment’s `last_catalog_synced_at` and `last_dependency_synced_at` are updated and a success notification is pushed to the user; on failure, the error message is captured and surfaced with retry option. -7. Audit trail records the initiating user, catalog sync id, action, and outcome to support compliance and troubleshooting. +7. Audit trail records the initiating user, catalog `sync_version`, action, and outcome to support compliance and troubleshooting. **Manual Sync Confirmation (Sync With Cluster):** -1. UI fetches dependency event summary from `kube_environment_dependency_sync_status` (list of ConfigMaps/Secrets flagged by API). -2. Backend ensures no newer `kube_environment_dependency_sync_status` (via `latest_dependency_sync_id`) has superseded the request; if found, UI refreshes the summary. -3. Orchestrator enqueues reconciliation job with `manual_trigger=true`, `action='cluster'`. +1. UI fetches dependency summary from the environment row (`dependency_summary`, `dependency_detected_at`). +2. Backend ensures the environment’s `dependency_sync_status` is still `pending` (or `failed`); if status has changed, the UI refreshes before proceeding. +3. Orchestrator enqueues reconciliation job with `manual_trigger=true`, `action='cluster'` and the expected snapshot of `dependency_summary`. 4. Job locks the environment dependency context to avoid racing with catalog or auto dependency syncs. -5. Job executes dependency refresh pipeline only (config/secret/service discovery) and updates `kube_environment_dependency`. -6. On success, the environment’s `last_dependency_synced_at` is updated; failures are recorded with actionable error payloads. -7. Audit trail captures user, dependency sync status id, action, and outcome. +5. Job executes dependency refresh pipeline only (config/secret/service discovery) and updates the environment resource tables with latest source metadata. +6. On success, the environment’s `dependency_sync_status` transitions to `idle`, `dependency_summary` is cleared, and `last_dependency_synced_at` is updated; failures set `dependency_sync_status = 'failed'` with error payload. +7. Audit trail captures user, serialized dependency summary, action, and outcome. **Granularity:** - ✅ Show: "3 workloads changed" (from catalog) - For catalog prompts, omit ConfigMap/Secret/Service counts (discovered during follow-up) - ❌ No detailed diffs (too complex) -- For dependency prompts, show count per resource type (e.g., "2 ConfigMaps", "1 Secret") sourced from `kube_environment_dependency_sync_status` summaries. +- For dependency prompts, show count per resource type (e.g., "2 ConfigMaps", "1 Secret") derived from the environment’s `dependency_summary`. ### 4. Branch Environment Handling: Sync Both @@ -261,7 +263,7 @@ The system uses **simple notifications at both levels**: - **Purpose:** Simple decision - "catalog updated, sync environment?" **What happens during sync:** -- Catalog sync: Update workload specs in `kube_app_catalog_workload` +- Catalog sync: Update workload specs in `kube_app_catalog_workload`, increment `sync_version`, and refresh catalog sync metadata - Environment sync: Update workloads + rediscover ConfigMaps/Secrets/Services from production ### Auto-Sync Control @@ -352,7 +354,7 @@ Default posture is **Manual (OFF)** for new environments to minimize surprise ch │ │ - Filters auto_sync envs │ │ │ │ - Queues manual envs │ │ │ │ - Calls kube-manager to apply │ │ -│ │ - Updates dependency index │ │ +│ │ - Refreshes resource source refs │ │ │ └───────────────────────────────────┘ │ └─────────────────────────────────────────┘ │ @@ -377,72 +379,42 @@ Default posture is **Manual (OFF)** for new environments to minimize surprise ch 1. Production resource changes (Deployment/StatefulSet/etc.) surface through the Kubernetes Watch stream handled by kube-manager. 2. Kube-manager forwards the raw event to the Lapdev API server via `report_resource_change`. 3. Change Event Processor normalizes/merges events for the same workload within the dedupe window. -4. Sync Decision Engine maps the event to affected catalogs (for workload ownership) and environments (via dependency index) without mutating catalog data. -5. Impacted environments receive notification records directly; catalogs only receive activity entries for visibility. +4. Sync Decision Engine maps the event to affected catalogs (for workload ownership) and environments (via source metadata on environment resource tables) without mutating catalog data. +5. API server updates the catalog workload spec, bumps `sync_version`, sets `last_sync_status = 'auto_applied'`, and records `last_synced_at`. +6. Environments detect that the catalog’s `sync_version` now exceeds their stored `catalog_sync_version` and receive notifications prompting the appropriate sync actions. ### B. Catalog Ownership Change → Environment Sync -1. User edits the App Catalog (add/remove workload). Catalog Updater writes the new spec into `kube_app_catalog_workload` and stamps `last_synced_at`. +1. User edits the App Catalog (add/remove workload). Catalog Updater writes the new spec into `kube_app_catalog_workload`, bumps `sync_version`, stamps `last_synced_at`, and records `last_sync_status = 'applied'` with the editor's ID. 2. Environment Sync Orchestrator enumerates impacted environments and splits them into auto-sync and manual buckets. 3. Auto-sync environments queue execution jobs that call kube-manager to reconcile workloads and re-discover dependent ConfigMaps/Secrets/Services. -4. Manual environments receive notifications referencing the originating `kube_app_catalog_sync_status`; when a user clicks "Sync From Catalog", the orchestrator enqueues a reconciliation job identical to the auto-sync path. -5. Reconciliation outcome is persisted back on the environment (`last_catalog_synced_at` and/or `last_dependency_synced_at`, plus error metadata if failures occur) and surfaced in UI/alerts; `latest_catalog_sync_id` is advanced to the newly applied record. +4. Manual environments receive notifications referencing the catalog’s new `sync_version`; when a user clicks "Sync From Catalog", the orchestrator enqueues a reconciliation job identical to the auto-sync path. +5. Reconciliation outcome is persisted back on the environment (`last_catalog_synced_at` and/or `last_dependency_synced_at`, plus error metadata if failures occur) and surfaced in UI/alerts; `catalog_sync_version` on the environment is updated to match the catalog. 6. Dependency index updates record which source ConfigMaps/Secrets each environment now references, ensuring future events fan out correctly. ### C. Production Dependency Change → Environment Prompt -1. ConfigMap/Secret event arrives; Change Event Processor looks up impacted environments via `kube_environment_dependency`. -2. For each environment, create or update a `kube_environment_dependency_sync_status` record summarizing resource names, change types, and detection time, and set `latest_dependency_sync_id` to point at it. -3. Auto-sync environments enqueue `Sync With Cluster` jobs immediately; manual environments receive notifications with counts per resource type. -4. When sync completes, the dependency sync status transitions to `completed` (or `failed` with error payload), `last_dependency_synced_at` is updated, and `latest_dependency_sync_id` is cleared or replaced with the next pending record. - -### Sync Record State Machine - -| State | Trigger | Next States | Notes | -|---|---|---|---| -| `auto_applied` | Cluster change detected and catalog auto-updated | `failed`, _terminal_ | Catalog update applies immediately; `actor_id = NULL`; environment sync queued | -| `applied` | User saves catalog edit | `failed`, _terminal_ | Catalog update applies immediately; `actor_id` records user; environment sync queued | -| `failed` | Catalog edit apply fails | `retrying`, `manual_intervention` | Failure reason persisted for diagnosis | -| `retrying` | Automatic retry scheduled/executing | `applied`, `failed` | Retry budget/interval configurable | -| `dismissed` | User dismisses catalog notification without editing | _terminal_ | Keeps audit trail without spec change | -| `manual_intervention` | Automated retries exhausted or blocked | _terminal_ | Signals operators to intervene | - -State machine extensions (`retrying`, `manual_intervention`) go beyond the minimal DDL but capture expected lifecycle so future iterations can expand persistence as needed. - -`kube_environment_dependency_sync_status` follows a similar lifecycle: - -| State | Trigger | Next States | Notes | -|---|---|---|---| -| `pending` | ConfigMap/Secret event detected, manual environment | `in_progress`, `dismissed` | Surfaces counts for `Sync With Cluster` CTA | -| `in_progress` | Sync job (auto or manual) executing | `completed`, `failed` | Job references this id for audit/retries | -| `completed` | Dependency refresh succeeded | _terminal_ | Updates `last_dependency_synced_at` | -| `failed` | Dependency refresh failed | `retrying`, `manual_intervention` | Stores error payload for UI | -| `retrying` | Automatic retry scheduled/executing | `completed`, `failed` | Retry budget shared with catalog syncs | -| `dismissed` | User dismisses notification without syncing | _terminal_ | Maintains audit trail without applying changes | -| `manual_intervention` | Automatic retries exhausted | _terminal_ | Signals operators to inspect cluster-side issues | +1. ConfigMap/Secret event arrives; Change Event Processor looks up impacted environments by querying `kube_environment_configmap` / `kube_environment_secret` source metadata indexes. +2. For each environment, update `dependency_sync_status`/`dependency_detected_at`/`dependency_summary` on `kube_environment` to reflect the change. +3. Auto-sync environments enqueue `Sync With Cluster` jobs immediately (transition to `in_progress`); manual environments receive notifications with counts per resource type. +4. When sync completes, the environment sets `dependency_sync_status` to `idle` (or `failed` with error payload) and updates `last_dependency_synced_at`; `dependency_summary` is cleared unless a new event arrives during processing. + +### Catalog Sync Tracking + +- `sync_version` increments on every catalog update (auto or manual) and is the primary key for environment reconciliation. +- `last_sync_status` reflects the outcome of the most recent catalog update (`auto_applied`, `applied`, `failed`). +- `last_synced_at` stores the timestamp of the latest catalog update. +- `last_sync_actor_id` records the user responsible for manual updates; it is `NULL` for auto-applied cluster changes. +- `last_sync_summary` provides a lightweight snapshot (workload names/counts) used by notifications and UI. +- Environments hold `catalog_sync_version` representing the last version they reconciled. + +**Dependency Sync Tracking (Environment-level):** +- `dependency_sync_status` (`idle`, `pending`, `in_progress`, `failed`) +- `dependency_detected_at` timestamp +- `dependency_summary` JSON (array of `{resource_type, namespace, name, change_type}`) +- `dependency_last_error` text (optional error payload) +- Auto-sync environments transition `pending → in_progress → idle`; manual environments rely on user actions (Sync/Dismiss) to clear or progress the status. ### Database Changes -#### New Table: `kube_app_catalog_sync_status` - -```sql -CREATE TABLE kube_app_catalog_sync_status ( - id UUID PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL, - app_catalog_id UUID NOT NULL REFERENCES kube_app_catalog(id), - status VARCHAR NOT NULL, -- 'auto_applied','applied','failed','retrying','dismissed','manual_intervention' - detected_at TIMESTAMPTZ NOT NULL, - actor_id UUID REFERENCES users(id), -- null for system-generated notifications - workload_count INTEGER NOT NULL, -- Simple count: how many workloads changed - workload_names TEXT[] NOT NULL -- Array of workload names that changed -); -``` - -**Storage Strategy:** -- **Simple summary only**: Just count + list of workload names -- `status = auto_applied` captures production-driven updates; `status = applied` records manual catalog edits -- `actor_id` stores the user that applied a catalog edit; system-driven auto updates leave it `NULL` -- No detailed diffs stored (not needed for immediate application) -- Environments reference this record via `latest_catalog_sync_id` to know when a catalog change is available to sync - #### New Table: `kube_environment_configmap` ```sql @@ -453,8 +425,12 @@ CREATE TABLE kube_environment_configmap ( environment_id UUID NOT NULL REFERENCES kube_environment(id), name VARCHAR NOT NULL, namespace VARCHAR NOT NULL, + source_namespace VARCHAR NOT NULL, + source_name VARCHAR NOT NULL, + last_discovered_at TIMESTAMPTZ NOT NULL, data JSONB NOT NULL, - UNIQUE(environment_id, namespace, name) + UNIQUE(environment_id, namespace, name), + UNIQUE(environment_id, source_namespace, source_name) ); ``` @@ -468,45 +444,18 @@ CREATE TABLE kube_environment_secret ( environment_id UUID NOT NULL REFERENCES kube_environment(id), name VARCHAR NOT NULL, namespace VARCHAR NOT NULL, - data JSONB NOT NULL, -- Encrypted - type VARCHAR NOT NULL, -- Opaque, kubernetes.io/tls, etc. - UNIQUE(environment_id, namespace, name) -); -``` - -#### New Table: `kube_environment_dependency` - -```sql -CREATE TABLE kube_environment_dependency ( - id UUID PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL, - environment_id UUID NOT NULL REFERENCES kube_environment(id), - resource_type VARCHAR NOT NULL, -- 'configmap' or 'secret' source_namespace VARCHAR NOT NULL, source_name VARCHAR NOT NULL, - target_namespace VARCHAR NOT NULL, - target_name VARCHAR NOT NULL, last_discovered_at TIMESTAMPTZ NOT NULL, - CHECK (resource_type IN ('configmap','secret')), - UNIQUE(environment_id, resource_type, source_namespace, source_name) + data JSONB NOT NULL, -- Encrypted + type VARCHAR NOT NULL, -- Opaque, kubernetes.io/tls, etc. + UNIQUE(environment_id, namespace, name), + UNIQUE(environment_id, source_namespace, source_name) ); ``` -#### New Table: `kube_environment_dependency_sync_status` +> Existing service metadata table (`kube_environment_service`) will likewise gain `source_namespace`, `source_name`, and `last_discovered_at` columns plus matching indexes to support service-driven event fan-out. -```sql -CREATE TABLE kube_environment_dependency_sync_status ( - id UUID PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL, - environment_id UUID NOT NULL REFERENCES kube_environment(id), - status VARCHAR NOT NULL, -- 'pending','in_progress','completed','failed','retrying','dismissed','manual_intervention' - detected_at TIMESTAMPTZ NOT NULL, - resolved_at TIMESTAMPTZ, - resolved_by UUID REFERENCES users(id), - resource_summaries JSONB NOT NULL, -- Array of {resource_type, namespace, name, change_type} - auto_triggered BOOLEAN NOT NULL DEFAULT false -); -``` #### Modified Table: `kube_environment` @@ -521,13 +470,22 @@ ALTER TABLE kube_environment ADD COLUMN last_dependency_synced_at TIMESTAMPTZ; ALTER TABLE kube_environment -ADD COLUMN latest_catalog_sync_id UUID REFERENCES kube_app_catalog_sync_status(id); +ADD COLUMN catalog_sync_version BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE kube_environment +ADD COLUMN dependency_sync_status VARCHAR NOT NULL DEFAULT 'idle'; ALTER TABLE kube_environment -ADD COLUMN latest_dependency_sync_id UUID REFERENCES kube_environment_dependency_sync_status(id); +ADD COLUMN dependency_detected_at TIMESTAMPTZ; + +ALTER TABLE kube_environment +ADD COLUMN dependency_summary JSONB; + +ALTER TABLE kube_environment +ADD COLUMN dependency_last_error TEXT; ``` -**Note:** When catalog updates, we store references to the catalog sync (`latest_catalog_sync_id`) and dependency sync (`latest_dependency_sync_id`) records that triggered notifications. The environment doesn't need its own detailed diff - users can click through to these records to see details. The dependency index (`kube_environment_dependency`) enables constant-time lookup when a production ConfigMap/Secret event arrives, while `kube_environment_dependency_sync_status` tracks pending dependency changes that power the `Sync With Cluster` UI. +**Note:** When catalog updates, we bump the catalog’s `sync_version` and environments record the latest version they have reconciled (`catalog_sync_version`). Dependency events update the environment’s `dependency_*` fields directly, while source metadata stored on `kube_environment_configmap` / `kube_environment_secret` / `kube_environment_service` enables constant-time lookup when a production ConfigMap/Secret event arrives. #### Modified Table: `kube_app_catalog` @@ -537,10 +495,23 @@ ADD COLUMN last_synced_at TIMESTAMPTZ; ALTER TABLE kube_app_catalog ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the source cluster + +ALTER TABLE kube_app_catalog +ADD COLUMN sync_version BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE kube_app_catalog +ADD COLUMN last_sync_status VARCHAR NOT NULL DEFAULT 'applied'; + +ALTER TABLE kube_app_catalog +ADD COLUMN last_sync_actor_id UUID REFERENCES users(id); + +ALTER TABLE kube_app_catalog +ADD COLUMN last_sync_summary JSONB NOT NULL DEFAULT '{}'::jsonb; ``` **Note:** - `source_namespace` tells kube-manager which namespace to watch +- `sync_version`/`last_sync_status`/`last_sync_actor_id`/`last_sync_summary` capture the most recent catalog update metadata (auto or manual) **Namespace Watch Configuration:** - API server aggregates all `source_namespace` values for a given cluster @@ -580,19 +551,18 @@ ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the - Map ConfigMap/Secret/Service events to environments: - Query: Which environments reference ConfigMap X in namespace Y? 4. Build **Sync Decision Engine**: - - Workload events → Check if belongs to any catalog → Record catalog activity entry (`status = auto_applied`, actor_id = NULL) + - Workload events → Check if belongs to any catalog → Update catalog sync metadata (`sync_version`, `last_sync_status = 'auto_applied'`, summary) - ConfigMap/Secret/Service events → Check if belongs to any environment → Trigger environment sync - Check environment `auto_sync` flags to decide immediate reconcile vs manual prompt - - Use dependency index (`kube_environment_dependency`) to map source resources to environments and create/update `kube_environment_dependency_sync_status` + - Use source metadata on environment resource tables to map resources to environments and update environment-level dependency tracking fields (`dependency_sync_status`, `dependency_summary`) 5. Implement event deduplication (ignore duplicate events within time window) -### Phase 2: Sync Status & Notifications -1. Create `kube_app_catalog_sync_status` table. -2. Implement sync status API endpoints for querying recent catalog updates. -3. Record catalog activity entries on production changes (`status = auto_applied`) and catalog edits (`status = applied`). -4. Publish dashboard and activity feed surfaces for production detections and user-applied catalog edits. -5. Emit webhook/notification events (if enabled) to inform downstream systems of catalog activity (without auto-mutating specs). -6. Provide API utilities to resolve ConfigMap/Secret events to environments via `kube_environment_dependency` and expose `kube_environment_dependency_sync_status` summaries (updating `latest_dependency_sync_id`). +### Phase 2: Catalog Metadata & Notifications +1. Extend `kube_app_catalog` CRUD to populate `sync_version`, `last_sync_status`, `last_synced_at`, `last_sync_actor_id`, and `last_sync_summary` for both auto and manual updates. +2. Implement APIs to surface catalog sync metadata (latest status, summary, timestamps, actor). +3. Publish dashboard and activity feed surfaces for production detections (`auto_applied`) and user-applied catalog edits. +4. Emit webhook/notification events (if enabled) to inform downstream systems of catalog activity. +5. Provide API utilities to resolve ConfigMap/Secret events to environments via `kube_environment_configmap`/`kube_environment_secret` source metadata and surface the environment-level dependency tracking fields. ### Phase 3: Catalog Update 1. Implement catalog update logic invoked when users save catalog edits @@ -603,19 +573,18 @@ ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the ### Phase 4: Environment Sync 1. Add `auto_sync` flag to `kube_environment` -2. Create `kube_environment_configmap` and `kube_environment_secret` tables (if not already exist) -3. Create `kube_environment_dependency` and `kube_environment_dependency_sync_status` tables -4. Implement environment sync orchestrator +2. Create/extend `kube_environment_configmap` and `kube_environment_secret` tables with source metadata columns (and ensure service table gains matching columns) +3. Implement environment sync orchestrator 5. Add RPC method: `sync_environment(env_id, sync_request)` - For `sync_request.action = 'catalog'`: updates workload specs from catalog, then cascades discovery for dependencies/services - - For `sync_request.action = 'dependency'`: refreshes ConfigMaps/Secrets/Services only, based on dependency index - - Payload includes `catalog_sync_id` or `dependency_sync_id` to support auditing and idempotency + - For `sync_request.action = 'dependency'`: refreshes ConfigMaps/Secrets/Services only, based on stored source metadata + - Payload includes the target `catalog_sync_version` or serialized dependency snapshot to support auditing and idempotency 6. Handle both shared and branch environments 7. Implement ConfigMap/Secret discovery and update logic with proper encryption for Secrets -8. Persist dependency mappings in `kube_environment_dependency` for each source ConfigMap/Secret +8. Persist source metadata in `kube_environment_configmap` / `kube_environment_secret` / `kube_environment_service` for each referenced ConfigMap/Secret/Service 9. Implement service discovery and preview URL updates -10. Update `kube_environment.last_catalog_synced_at` and/or `last_dependency_synced_at` timestamps based on action completed -11. Audit manual triggers by storing initiating user id, catalog sync id, dependency event ids, and outcome for every `manual_trigger=true` job +10. Update `kube_environment.last_catalog_synced_at`/`last_dependency_synced_at` timestamps based on action completed and set `catalog_sync_version` to the applied `sync_version`. +11. Audit manual triggers by storing initiating user id, catalog `sync_version`, dependency event ids, and outcome for every `manual_trigger=true` job ### Phase 5: Dashboard & Notifications 1. Add notification system for catalog activity and environment sync prompts. @@ -627,7 +596,7 @@ ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the 7. Implement notification badges: - Catalog: "Production change detected - 3 workloads" (notification) vs "Edit applied - workload foo added" - Environment: "Catalog update available - 3 workloads" vs "Config update available - 2 ConfigMaps" (manual) and a single "Syncing..." state for auto actions -8. Support dismiss actions that update dependency sync status (set to `dismissed`) or log catalog deferrals without applying changes. +8. Support dismiss actions that mark environment `dependency_sync_status = 'idle'` (clearing `dependency_summary`) or log catalog deferrals without applying changes. ## Detailed Implementation Steps @@ -636,15 +605,17 @@ ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the - [ ] Document new tables/columns, data types, indexes, FK relationships. - [ ] Review migration design with Data Platform (sizing, retention, encryption). - [ ] Implement migrations - - [ ] Create tables `kube_environment_configmap`, `kube_environment_secret`, `kube_environment_dependency`, `kube_environment_dependency_sync_status`. - - [ ] Alter `kube_app_catalog_sync_status` and `kube_environment` to add new statuses/timestamps/references. - - [ ] Add supporting indexes (config/secret uniqueness, dependency lookup, catalog status filtering). + - [ ] Create/extend `kube_environment_configmap`, `kube_environment_secret`, and `kube_environment_service` tables with source metadata columns; add dependency-tracking columns to `kube_environment`. + - [ ] Alter `kube_app_catalog` to add sync metadata columns (`sync_version`, `last_sync_status`, `last_synced_at`, `last_sync_actor_id`, `last_sync_summary` with sensible default such as `'{}'::jsonb`). + - [ ] Alter `kube_environment` to add `catalog_sync_version` and `latest_dependency_sync_id` along with timestamps. + - [ ] Add supporting indexes (config/secret uniqueness, dependency lookup, catalog sync metadata filtering). - [ ] Add constraints/foreign keys with documented ON DELETE behavior. - [ ] Backfill legacy data - [ ] Snapshot current environment resources (scripts/queries). - [ ] Populate config/secret tables (respect encryption requirements). - [ ] Build & run dependency discovery seeding script. - - [ ] Seed catalog activity rows with `status = auto_applied`, `actor_id = NULL` (representing current production state). + - [ ] Seed catalog sync metadata (`sync_version`, `last_sync_status = 'auto_applied'`, `last_sync_actor_id = NULL`, `last_synced_at = now()`, summary payload). + - [ ] Initialize environment dependency tracking fields (`dependency_sync_status = 'idle'`, clear summaries/errors). - [ ] Validate & harden - [ ] Add automated tests verifying row counts/integrity post-backfill. - [ ] Prepare rollback SQL for each migration. @@ -659,40 +630,40 @@ ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the 3. **Lapdev API – Event Processing** - [ ] Build Change Event Processor endpoint (validation, YAML parsing, auth). - - [ ] Implement workload event flow (catalog ownership lookup, dedupe, create `auto_applied` activity with `actor_id = NULL`, enqueue notification job). + - [ ] Implement workload event flow (catalog ownership lookup, dedupe, update `kube_app_catalog` sync metadata with `sync_version++`, `last_sync_status = 'auto_applied'`, `last_synced_at = now()`, `last_sync_actor_id = NULL`, `last_sync_summary`, enqueue notification job targeting environments where `catalog_sync_version` < catalog `sync_version`). - [ ] Implement dependency event flow (dependency lookup, upsert sync status, auto-sync queueing, update environment `latest_dependency_sync_id`). - [ ] Add dedupe windowing + stale dependency guard rails (rebuild triggers). - - [ ] Expose APIs to list catalog activity and dependency statuses (with tests). + - [ ] Expose APIs to read catalog sync metadata (`sync_version`, `last_sync_status`, `last_synced_at`, `last_sync_actor_id`, `last_sync_summary`) and dependency statuses (with tests). 4. **Catalog Management** - [ ] Update backend edit handlers (apply workload adds/removes, optional manifest fetch). - - [ ] Insert `status = applied` activity rows with actor metadata and trigger environment sync orchestration. - - [ ] Build catalog activity listing endpoints + UI components (feed, details, filters). + - [ ] Update `kube_app_catalog` sync metadata (`sync_version++`, `last_sync_status = 'applied'`, `last_synced_at = now()`, `last_sync_actor_id`, `last_sync_summary`) and trigger environment sync orchestration. + - [ ] Build catalog activity surfaces (APIs/UI) that render `sync_version`, `last_sync_status`, `last_synced_at`, `last_sync_summary`, and actor details from catalog sync metadata. - [ ] Implement catalog dismiss audit endpoint and UI action. - - [ ] Add automated/unit/UI tests for edits, rollbacks, activity log correctness. + - [ ] Add automated/unit/UI tests for edits, rollbacks, and sync metadata correctness. 5. **Environment Sync Orchestrator** - - [ ] Define `sync_environment` RPC schema and job queue wiring. - - [ ] Implement catalog sync worker (workload apply, dependency refresh, service updates, update environment timestamps, set `latest_catalog_sync_id` to applied record). + - [ ] Define `sync_environment` RPC schema (carrying `catalog_sync_version`/`dependency_sync_id`) and job queue wiring. + - [ ] Implement catalog sync worker (validate expected `catalog_sync_version`, perform workload apply, dependency refresh, service updates, update environment timestamps, set environment `catalog_sync_version` to match the catalog, and update catalog `last_sync_status`/`last_sync_summary` on success or failure). - [ ] Implement dependency sync worker (resource fetch, table updates, dependency pruning, update status/timestamps, clear or advance `latest_dependency_sync_id`). - - [ ] Add concurrency controls (per-environment locks) and execution telemetry logging. + - [ ] Add concurrency controls (per-environment locks) and execution telemetry logging; validate expected `catalog_sync_version` before applying. - [ ] Implement retry/backoff policy and error reporting surfaced to API/UI. 6. **User Experience & Notifications** - - [ ] Build catalog activity feed UI (notification cards, workload links, dismiss action). - - [ ] Build environment sync UI (pending actions panel, `Sync From Catalog` / `Sync With Cluster` modals, history). + - [ ] Build catalog activity feed UI (render `sync_version`, `last_sync_summary`, timestamps, dismiss action). + - [ ] Build environment sync UI (pending actions panel showing catalog `sync_version` delta, `Sync From Catalog` / `Sync With Cluster` modals, history). - [ ] Add environment settings page for auto-sync toggles and reminders. - - [ ] Extend notification service for Slack/email/webhooks with consistent payloads. + - [ ] Extend notification service for Slack/email/webhooks with consistent payloads (including catalog `sync_version`, `last_sync_summary`). - [ ] Instrument UX analytics (CTA usage, dismissals, sync outcomes) and dashboard. 7. **Observability & Operations** - - [ ] Instrument metrics (event ingest, dedupe, queue depth, sync latency, failure/dismiss counts) with tagging. + - [ ] Instrument metrics (event ingest, dedupe, queue depth, sync latency, catalog sync version drift, failure counts, dismissal counts) with tagging. - [ ] Define alerts and write runbooks (watch health, backlog, failure spikes, stale prompts, dismissal spikes). - [ ] Implement feature flags/kill switches (environment auto-sync, catalog notifications, dependency auto-sync) plus documentation. - [ ] Deliver operational tooling (CLI/API for manual sync, dependency rebuild, dismissal reset) and train support/SRE teams. 8. **Rollout & Validation** - - [ ] Internal dogfood: enable flags, simulate production changes, test dismiss/sync flows, run chaos scenarios. + - [ ] Internal dogfood: enable flags, simulate production changes (verify `sync_version` increments), test dismiss/sync flows, run chaos scenarios. - [ ] Design partner preview: enable for selected customers, collect qualitative/quantitative feedback, iterate on thresholds/UX. - [ ] Progressive auto-sync: allow opt-in, instrument success/failure, shadow dependency auto-sync before enabling writes. - [ ] General availability: publish docs/support materials, default new environments to manual, monitor telemetry/support closely, plan post-release review. @@ -713,7 +684,7 @@ Critical path: Watch Infrastructure → Sync Decision Engine → Persistence → 1. **Internal Dogfood (Phase 1–3)** - Target a non-production catalog within Lapdev infrastructure. - - Validate production-change notifications with environments in manual mode; ensure catalog activity logs populate without mutating specs. + - Validate production-change notifications with environments in manual mode; ensure catalog sync metadata updates without mutating specs unintentionally. 2. **Design Partner Preview (Phase 4)** - Select 1–2 customers with tolerant workloads. - Gate behind feature flag: catalog notifications enabled, environments default manual. @@ -819,7 +790,7 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo - End-to-end flow: Production change → Kube-manager event → API decision → Catalog/environment sync - Namespace watch reconfiguration when catalogs added/removed - Auto-sync vs manual environment workflows - - ConfigMap/Secret/Service discovery during environment sync with dependency index updates + - ConfigMap/Secret/Service discovery during environment sync with source-metadata updates 3. **Load Tests**: - 100+ workloads across multiple namespaces @@ -863,7 +834,7 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo - Sync success/failure rates - Number of environments awaiting manual sync - Auto-sync vs manual-sync ratio split by action type (catalog vs dependency) -- Dismissal rate for dependency prompts (count of statuses marked `dismissed`) +- Dismissal rate for dependency prompts (count of manual dismissal actions) **Resource Metrics:** - Number of namespaces watched per cluster @@ -884,7 +855,7 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo - **Customer Cluster Load:** Watching all resources could stress API servers. Mitigation: allow namespace-level sampling configuration and backoff when resource version drift detected. - **Long-Running Branch Mods:** Auto-sync might overwrite intentional branch divergences. Mitigation: branch environments default to manual; surface conflicts with ability to reapply branch overrides. - **Partial Failures:** Catalog update succeeding while environment sync fails leaves inconsistent state. Mitigation: persist failure reason, expose retry control, and guard rails to prevent infinite retries. -- **Stale Dependency Index:** Missed cleanup could cause redundant environment sync prompts. Mitigation: rebuild dependencies on each environment sync and schedule periodic reconciliation jobs. +- **Stale Dependency Metadata:** Missed cleanup could cause redundant environment sync prompts. Mitigation: refresh source metadata on every sync and schedule periodic reconciliation jobs to prune stale entries. - **Excessive Dismissals:** Users may repeatedly dismiss dependency prompts, leaving environments outdated. Mitigation: surface dismissal metrics, add reminder nudges, and allow policy to cap consecutive dismissals. ## Open Questions @@ -893,8 +864,8 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo - Recommendation: Keep CTAs separate in v1 for clarity; evaluate combined apply after observing user behavior. 2. Should we support partial environment sync (only sync specific workloads)? - Recommendation: Defer to future iteration; scope for v1 is full-environment reconciliation with branch overrides preserved. -3. How long should we retain catalog sync activity and failure records? - - Recommendation: Retain 90 days online, archive thereafter for compliance reviews. +3. Do we need to persist catalog sync history (beyond the latest summary/status)? + - Recommendation: Retain 90 days of append-only history in analytics storage if required; initial launch can rely on aggregated fields only. 4. Should branch environments optionally "pin" to a catalog version? - Recommendation: Provide catalog pinning as a branch-level setting post-v1; for now rely on manual sync deferral. 5. Do we need sync notifications via webhook (for CI/CD integration)? @@ -903,8 +874,8 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo - Recommendation: Yes, add a `pause_until` field so teams can suspend watches during planned maintenance. 7. Do we need a catalog "dry run" mode that records detections without applying to workloads? - Recommendation: Consider a shadow-only mode that writes sync records with `preview_only=true` while skipping updates, primarily for analytics prior to rollout adjustments. -8. Should the dependency index be extended to Services or other resource types? - - Recommendation: Track ConfigMaps/Secrets in v1; evaluate Service inclusion after we validate performance of the new index. +8. Should we enrich the stored source metadata (e.g., track field-level usage, resource UIDs, include Services)? + - Recommendation: Start with namespace/name for ConfigMaps/Secrets/Services; iterate after validating performance and customer needs. **Architecture Questions:** 1. Should kube-manager batch events before sending to API server, or send immediately? From d5980055c0e09d3d795dbfd985ca5a3887b2e69a Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sat, 18 Oct 2025 21:11:25 +0000 Subject: [PATCH 125/334] update --- plans/automatic-sync.md | 185 +++++++++++++++++++++++++++++++--------- 1 file changed, 146 insertions(+), 39 deletions(-) diff --git a/plans/automatic-sync.md b/plans/automatic-sync.md index 2a322f7..bb39020 100644 --- a/plans/automatic-sync.md +++ b/plans/automatic-sync.md @@ -61,6 +61,8 @@ This causes drift between production and development environments. - Storing at environment level allows per-environment customization while still syncing from source - Catalogs serve as blueprints that update automatically when source workloads change, while still allowing teams to curate manual edits when required. - Environment resource tables (`kube_environment_configmap` / `kube_environment_secret` / `kube_environment_service`) retain source metadata (namespace/name) so the API can map production changes to environments without re-scanning workloads. +- **Tables store routing metadata only (source→target mapping), not actual data content** +- **Actual ConfigMap/Secret data is fetched from Kubernetes during sync operations** ## Design Decisions @@ -109,7 +111,8 @@ struct ResourceChangeEvent { resource_type: ResourceType, // Deployment, StatefulSet, DaemonSet, ConfigMap, Secret, Service resource_name: String, change_type: ChangeType, // Created, Updated, Deleted - resource_yaml: String, // Full resource spec in YAML format + resource_version: String, // Kubernetes resource version for deduplication + resource_yaml: Option, // Full resource spec; only sent for Workloads, omit for ConfigMaps/Secrets timestamp: DateTime, } @@ -119,6 +122,8 @@ trait KubeManagerRpc { } ``` +**Note:** For ConfigMaps and Secrets, `resource_yaml` is not sent in the event. The event serves only to notify which environments need sync. Actual data is fetched on-demand during sync operations. + **Event Flow Example:** 1. Production deployment `api-server` updated in namespace `production` 2. Kube-manager detects change via Kubernetes Watch @@ -208,10 +213,11 @@ When catalog updates, environments are notified with a **simple summary**: 3. Trigger dependency discovery for any new/removed workloads and refresh source metadata on `kube_environment_configmap` / `kube_environment_secret` / `kube_environment_service` accordingly. 4. Update environment tables for workloads plus referenced ConfigMaps/Secrets/Services encountered during discovery. - **`Sync With Cluster`** (dependency-focused) - 1. Rediscover ConfigMaps, Secrets, and Services from the source cluster using the stored source metadata on the environment resource tables. - 2. Refresh values in `kube_environment_configmap`, `kube_environment_secret`, `kube_environment_service`. - 3. Update discovery timestamps in those tables; remove rows whose backing resources no longer exist. - 4. Leave workloads untouched, allowing developers to defer catalog changes while still pulling latest configs. + 1. Query routing tables (`kube_environment_configmap`, `kube_environment_secret`, `kube_environment_service`) for source metadata. + 2. Fetch current ConfigMap/Secret/Service content from source cluster. + 3. Apply directly to environment namespace in Kubernetes. + 4. Update `last_discovered_at` timestamps in routing tables; remove rows whose backing resources no longer exist. + 5. Leave workloads untouched, allowing developers to defer catalog changes while still pulling latest configs. **Manual Sync Confirmation (Sync From Catalog):** 1. UI requests latest catalog sync metadata (`sync_version`, `last_sync_status`, `last_synced_at`, `last_sync_summary`) and displays confirmation dialog with timestamp + workload count. @@ -428,7 +434,7 @@ CREATE TABLE kube_environment_configmap ( source_namespace VARCHAR NOT NULL, source_name VARCHAR NOT NULL, last_discovered_at TIMESTAMPTZ NOT NULL, - data JSONB NOT NULL, + -- Data is fetched from K8s on-demand, not stored here UNIQUE(environment_id, namespace, name), UNIQUE(environment_id, source_namespace, source_name) ); @@ -447,8 +453,7 @@ CREATE TABLE kube_environment_secret ( source_namespace VARCHAR NOT NULL, source_name VARCHAR NOT NULL, last_discovered_at TIMESTAMPTZ NOT NULL, - data JSONB NOT NULL, -- Encrypted - type VARCHAR NOT NULL, -- Opaque, kubernetes.io/tls, etc. + -- Data is fetched from K8s on-demand, not stored here UNIQUE(environment_id, namespace, name), UNIQUE(environment_id, source_namespace, source_name) ); @@ -456,6 +461,58 @@ CREATE TABLE kube_environment_secret ( > Existing service metadata table (`kube_environment_service`) will likewise gain `source_namespace`, `source_name`, and `last_discovered_at` columns plus matching indexes to support service-driven event fan-out. +#### Modified Table: `kube_app_catalog_workload` + +**Note:** The existing `kube_app_catalog_workload` table already has `namespace` (source namespace) and `name` fields. We need to add `cluster_id` for faster event routing. + +```sql +-- Add cluster_id to eliminate joins during event routing +ALTER TABLE kube_app_catalog_workload +ADD COLUMN cluster_id UUID NOT NULL REFERENCES kube_cluster(id); + +-- Constraint: workload's cluster must match its catalog's cluster +-- (This will be enforced at application level or via trigger) +``` + +#### Indexes for Event Routing and Performance + +```sql +-- Critical index for fast event routing: which catalog owns this workload? +-- With cluster_id, we can route events without joining to kube_app_catalog! +CREATE INDEX idx_catalog_workload_event_routing + ON kube_app_catalog_workload(cluster_id, namespace, name) + WHERE deleted_at IS NULL; + +-- Index for finding workloads by catalog +CREATE INDEX idx_catalog_workload_catalog_id + ON kube_app_catalog_workload(catalog_id) + WHERE deleted_at IS NULL; + +-- Indexes for environment ConfigMap/Secret routing +CREATE INDEX idx_env_configmap_source + ON kube_environment_configmap(source_namespace, source_name) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_env_configmap_env_id + ON kube_environment_configmap(environment_id) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_env_secret_source + ON kube_environment_secret(source_namespace, source_name) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_env_secret_env_id + ON kube_environment_secret(environment_id) + WHERE deleted_at IS NULL; + +-- Index for environment sync queries +CREATE INDEX idx_env_catalog_sync + ON kube_environment(catalog_id, catalog_sync_version); + +CREATE INDEX idx_env_dependency_status + ON kube_environment(dependency_sync_status) + WHERE dependency_sync_status != 'idle'; +``` #### Modified Table: `kube_environment` @@ -493,9 +550,6 @@ ADD COLUMN dependency_last_error TEXT; ALTER TABLE kube_app_catalog ADD COLUMN last_synced_at TIMESTAMPTZ; -ALTER TABLE kube_app_catalog -ADD COLUMN source_namespace VARCHAR NOT NULL; -- Which namespace to watch in the source cluster - ALTER TABLE kube_app_catalog ADD COLUMN sync_version BIGINT NOT NULL DEFAULT 0; @@ -510,14 +564,65 @@ ADD COLUMN last_sync_summary JSONB NOT NULL DEFAULT '{}'::jsonb; ``` **Note:** -- `source_namespace` tells kube-manager which namespace to watch - `sync_version`/`last_sync_status`/`last_sync_actor_id`/`last_sync_summary` capture the most recent catalog update metadata (auto or manual) +- No `source_namespace` on catalog because workloads can come from different namespaces +- Watched namespaces are derived from distinct `kube_app_catalog_workload.namespace` values **Namespace Watch Configuration:** -- API server aggregates all `source_namespace` values for a given cluster -- Sends list of namespaces to kube-manager: `["production", "staging"]` +- API server queries all distinct `kube_app_catalog_workload.namespace` values for a given cluster +- Sends list of namespaces to kube-manager: `["production", "admin", "monitoring"]` - Kube-manager watches ALL resources in those namespaces -- API server filters events to determine which belong to which catalog +- API server filters events by matching `(cluster_id, namespace, workload_name)` to workload table to determine which catalogs are affected + +### Event Routing Strategy + +When a workload event arrives from kube-manager: + +1. **Extract from RPC context:** `cluster_id` (from authenticated connection) +2. **Extract from event:** `namespace`, `workload_name`, `resource_version` +3. **Fast lookup (no joins!):** + ```sql + SELECT catalog_id + FROM kube_app_catalog_workload + WHERE cluster_id = $cluster_id + AND namespace = $namespace + AND name = $workload_name + AND deleted_at IS NULL + LIMIT 1; + ``` + Uses index: `idx_catalog_workload_event_routing(cluster_id, namespace, name)` + +4. **Update catalog sync metadata** (bump `sync_version`, set `last_sync_status`) +5. **Trigger environment sync** for environments using this catalog + +**Note:** Adding `cluster_id` to `kube_app_catalog_workload` eliminates the need to join with `kube_app_catalog` during event routing, making this operation O(1) index lookup instead of a join. + +### Summary of Key Schema Decisions + +**1. No `source_namespace` on `kube_app_catalog`** +- ❌ Catalog does NOT have a single source namespace +- ✅ Workloads can come from different namespaces within the same catalog +- ✅ Watched namespaces derived from `kube_app_catalog_workload.namespace` (already exists) + +**2. Add `cluster_id` to `kube_app_catalog_workload`** +- ✅ Enables fast event routing without joins +- ✅ Index on `(cluster_id, namespace, name)` makes routing O(1) +- ✅ Denormalization worth it (events are high-volume, catalog updates are rare) + +**3. ConfigMap/Secret tables store routing metadata only** +- ✅ No data storage in database +- ✅ Fetch from Kubernetes on-demand during sync +- ✅ Simpler, more secure, no encryption complexity + +**4. Catalog identifies cluster, workloads identify namespace** +``` +kube_cluster + └── kube_app_catalog (cluster_id) + └── kube_app_catalog_workload (cluster_id, namespace, name) + ├── namespace: "production" + ├── namespace: "admin" + └── namespace: "monitoring" +``` ## Implementation Plan @@ -540,7 +645,7 @@ ADD COLUMN last_sync_summary JSONB NOT NULL DEFAULT '{}'::jsonb; **API Server Changes (Centralized service):** 1. Implement namespace watch configuration management: - - When catalog created/updated: aggregate `source_namespace` per cluster + - When catalog/workload created/updated: query distinct `kube_app_catalog_workload.namespace` per cluster - Send `configure_watches([namespaces])` RPC to kube-manager - Track which namespaces are being watched per cluster 2. Implement `report_resource_change` RPC handler @@ -580,8 +685,8 @@ ADD COLUMN last_sync_summary JSONB NOT NULL DEFAULT '{}'::jsonb; - For `sync_request.action = 'dependency'`: refreshes ConfigMaps/Secrets/Services only, based on stored source metadata - Payload includes the target `catalog_sync_version` or serialized dependency snapshot to support auditing and idempotency 6. Handle both shared and branch environments -7. Implement ConfigMap/Secret discovery and update logic with proper encryption for Secrets -8. Persist source metadata in `kube_environment_configmap` / `kube_environment_secret` / `kube_environment_service` for each referenced ConfigMap/Secret/Service +7. Implement ConfigMap/Secret discovery and sync logic (fetch from source, apply to target) +8. Persist source metadata in `kube_environment_configmap` / `kube_environment_secret` / `kube_environment_service` for each referenced ConfigMap/Secret/Service (metadata only, not data) 9. Implement service discovery and preview URL updates 10. Update `kube_environment.last_catalog_synced_at`/`last_dependency_synced_at` timestamps based on action completed and set `catalog_sync_version` to the applied `sync_version`. 11. Audit manual triggers by storing initiating user id, catalog `sync_version`, dependency event ids, and outcome for every `manual_trigger=true` job @@ -603,16 +708,17 @@ ADD COLUMN last_sync_summary JSONB NOT NULL DEFAULT '{}'::jsonb; 1. **Schema & Persistence** - [ ] Draft migration plan - [ ] Document new tables/columns, data types, indexes, FK relationships. - - [ ] Review migration design with Data Platform (sizing, retention, encryption). + - [ ] Review migration design with Data Platform (sizing, retention). - [ ] Implement migrations - - [ ] Create/extend `kube_environment_configmap`, `kube_environment_secret`, and `kube_environment_service` tables with source metadata columns; add dependency-tracking columns to `kube_environment`. - - [ ] Alter `kube_app_catalog` to add sync metadata columns (`sync_version`, `last_sync_status`, `last_synced_at`, `last_sync_actor_id`, `last_sync_summary` with sensible default such as `'{}'::jsonb`). - - [ ] Alter `kube_environment` to add `catalog_sync_version` and `latest_dependency_sync_id` along with timestamps. - - [ ] Add supporting indexes (config/secret uniqueness, dependency lookup, catalog sync metadata filtering). + - [ ] Create/extend `kube_environment_configmap`, `kube_environment_secret`, and `kube_environment_service` tables with source metadata columns (no data storage); add dependency-tracking columns to `kube_environment`. + - [ ] Alter `kube_app_catalog` to add sync metadata columns ONLY (`sync_version`, `last_sync_status`, `last_synced_at`, `last_sync_actor_id`, `last_sync_summary`). Do NOT add `source_namespace` to catalog. + - [ ] Alter `kube_app_catalog_workload` to add `cluster_id` for fast event routing (backfill from catalog's cluster_id). + - [ ] Alter `kube_environment` to add `catalog_sync_version` and dependency tracking columns along with timestamps. + - [ ] Add supporting indexes including critical `idx_catalog_workload_event_routing(cluster_id, namespace, name)`. - [ ] Add constraints/foreign keys with documented ON DELETE behavior. - [ ] Backfill legacy data - [ ] Snapshot current environment resources (scripts/queries). - - [ ] Populate config/secret tables (respect encryption requirements). + - [ ] Populate config/secret routing tables with source metadata only (no data). - [ ] Build & run dependency discovery seeding script. - [ ] Seed catalog sync metadata (`sync_version`, `last_sync_status = 'auto_applied'`, `last_sync_actor_id = NULL`, `last_synced_at = now()`, summary payload). - [ ] Initialize environment dependency tracking fields (`dependency_sync_status = 'idle'`, clear summaries/errors). @@ -631,7 +737,7 @@ ADD COLUMN last_sync_summary JSONB NOT NULL DEFAULT '{}'::jsonb; 3. **Lapdev API – Event Processing** - [ ] Build Change Event Processor endpoint (validation, YAML parsing, auth). - [ ] Implement workload event flow (catalog ownership lookup, dedupe, update `kube_app_catalog` sync metadata with `sync_version++`, `last_sync_status = 'auto_applied'`, `last_synced_at = now()`, `last_sync_actor_id = NULL`, `last_sync_summary`, enqueue notification job targeting environments where `catalog_sync_version` < catalog `sync_version`). - - [ ] Implement dependency event flow (dependency lookup, upsert sync status, auto-sync queueing, update environment `latest_dependency_sync_id`). + - [ ] Implement dependency event flow (dependency lookup, upsert sync status, auto-sync queueing, update environment `dependency_summary` and `dependency_sync_status`). - [ ] Add dedupe windowing + stale dependency guard rails (rebuild triggers). - [ ] Expose APIs to read catalog sync metadata (`sync_version`, `last_sync_status`, `last_synced_at`, `last_sync_actor_id`, `last_sync_summary`) and dependency statuses (with tests). @@ -645,7 +751,7 @@ ADD COLUMN last_sync_summary JSONB NOT NULL DEFAULT '{}'::jsonb; 5. **Environment Sync Orchestrator** - [ ] Define `sync_environment` RPC schema (carrying `catalog_sync_version`/`dependency_sync_id`) and job queue wiring. - [ ] Implement catalog sync worker (validate expected `catalog_sync_version`, perform workload apply, dependency refresh, service updates, update environment timestamps, set environment `catalog_sync_version` to match the catalog, and update catalog `last_sync_status`/`last_sync_summary` on success or failure). - - [ ] Implement dependency sync worker (resource fetch, table updates, dependency pruning, update status/timestamps, clear or advance `latest_dependency_sync_id`). + - [ ] Implement dependency sync worker (resource fetch, table updates, dependency pruning, update status/timestamps, clear `dependency_summary` on completion). - [ ] Add concurrency controls (per-environment locks) and execution telemetry logging; validate expected `catalog_sync_version` before applying. - [ ] Implement retry/backoff policy and error reporting surfaced to API/UI. @@ -671,9 +777,9 @@ ADD COLUMN last_sync_summary JSONB NOT NULL DEFAULT '{}'::jsonb; | Workstream | Scope | Lead Team | Key Dependencies | |---|---|---|---| -| Watch Infrastructure | Kube-manager watch plumbing, RPC schema, API ingestion | Platform Agents | Requires namespace metadata on catalogs | +| Watch Infrastructure | Kube-manager watch plumbing, RPC schema, API ingestion | Platform Agents | Requires namespace metadata on workloads | | Sync Decision Engine | Event classification, dedupe, sync record lifecycle | Backend Services | Needs Watch Infrastructure | -| Catalog & Environment Persistence | DDL migrations, ORM updates, encryption plumbing | Data Platform | Sequenced before UI/API consumption | +| Catalog & Environment Persistence | DDL migrations, ORM updates | Data Platform | Sequenced before UI/API consumption | | Orchestration & Queues | Catalog update executor, environment reconciliation workers | Backend Services | Depends on Persistence schema | | UI & Notification Layer | Dashboard surfaces, toggles, notification delivery | Frontend & UX | Needs API endpoints & sync records | | Observability & Ops | Metrics, alerting, runbooks, failure injection | SRE | Consumes telemetry hooks from all layers | @@ -732,9 +838,9 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo - Show combined notification: "5 workloads changed" - List affected workload names -### New Catalog Created or Namespace Changed -- When catalog is created or `source_namespace` is updated -- API server recalculates namespace watch list for the cluster +### New Catalog/Workload Created or Namespace Changed +- When catalog created, workload added/removed, or workload namespace updated +- API server recalculates namespace watch list by querying distinct `kube_app_catalog_workload.namespace` for the cluster - Sends `configure_watches([new_namespace_list])` to kube-manager - Kube-manager dynamically adds/removes watches as needed - No restart required @@ -773,11 +879,12 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo 3. **Validation**: Validate workload specs before applying (resource limits, security contexts) 4. **Rate Limiting**: Prevent sync storms (max N syncs per minute) 5. **Secret Handling**: - - Secrets stored encrypted in `kube_environment_secret` table - - Diff view shows key names and change indicators, not actual secret values - - Secret values only transmitted over TLS between components - - RBAC: Separate permission for viewing Secret diffs - - Audit: Log all Secret access and modifications + - Secrets remain in Kubernetes only; never stored in Lapdev database + - Routing tables store only metadata (source namespace/name → target namespace/name) + - Sync operations fetch directly from source cluster and apply to target namespace + - All secret data remains encrypted at rest by Kubernetes + - Secret access audited via Kubernetes audit logs + - RBAC: Environment sync requires appropriate K8s RBAC permissions on both source and target clusters ## Testing Strategy @@ -790,7 +897,7 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo - End-to-end flow: Production change → Kube-manager event → API decision → Catalog/environment sync - Namespace watch reconfiguration when catalogs added/removed - Auto-sync vs manual environment workflows - - ConfigMap/Secret/Service discovery during environment sync with source-metadata updates + - ConfigMap/Secret/Service discovery during environment sync (fetch from source, apply to target, persist routing metadata) 3. **Load Tests**: - 100+ workloads across multiple namespaces @@ -851,7 +958,7 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo ## Risks & Mitigations - **False Positives/Noise:** Deduplication window and workload ownership mapping errors could generate noisy syncs. Mitigation: add resource ownership cache, alert on high manual deferral rates. -- **Secrets Exposure:** Transporting full secret data increases blast radius. Mitigation: enforce envelope encryption at rest, limit log redaction, and gate diff visibility behind elevated RBAC. +- **Secrets Exposure:** Syncing secrets from production to development increases risk of exposure. Mitigation: secrets never stored in Lapdev database, only routing metadata; fetched directly from K8s during sync; leverage K8s RBAC for access control; audit all sync operations. - **Customer Cluster Load:** Watching all resources could stress API servers. Mitigation: allow namespace-level sampling configuration and backoff when resource version drift detected. - **Long-Running Branch Mods:** Auto-sync might overwrite intentional branch divergences. Mitigation: branch environments default to manual; surface conflicts with ability to reapply branch overrides. - **Partial Failures:** Catalog update succeeding while environment sync fails leaves inconsistent state. Mitigation: persist failure reason, expose retry control, and guard rails to prevent infinite retries. @@ -881,7 +988,7 @@ Each stage requires explicit go/no-go review covering error budget impact, suppo 1. Should kube-manager batch events before sending to API server, or send immediately? - Decision: Send immediately for low latency; API server handles batching/deduplication. 2. How should we handle very large resources (e.g., ConfigMap with 10MB of data)? - - Recommendation: Send checksum + metadata via event; API server fetches full resource on-demand when diffing. + - Decision: ConfigMaps/Secrets don't send data in events; only metadata is sent to trigger sync. Workloads send full YAML since they need to be stored in catalog. For extremely large workload specs, implement size limits and error handling. 3. Should we implement event replay/audit log? - Recommendation: Store raw events for 7 days in object storage with index for compliance, gated behind feature flag. 4. What happens if kube-manager falls behind (event backlog)? From e5e9307544f62e01a02916527d6f26b843fe919e Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sat, 18 Oct 2025 21:27:28 +0000 Subject: [PATCH 126/334] update --- Cargo.toml | 2 +- crates/kube-manager/src/lib.rs | 1 + crates/kube-manager/src/manager.rs | 26 +- crates/kube-manager/src/manager_rpc.rs | 22 + crates/kube-manager/src/watch_manager.rs | 488 +++++++++++++++++++++++ crates/kube-rpc/src/lib.rs | 36 ++ crates/kube/src/server.rs | 16 +- 7 files changed, 582 insertions(+), 9 deletions(-) create mode 100644 crates/kube-manager/src/watch_manager.rs diff --git a/Cargo.toml b/Cargo.toml index 3d8fbe5..9183e7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,7 +118,7 @@ bytes = "1.5.0" hyper = { version = "1.2.0", features = ["server", "client", "http1", "http2"] } hyper-util = { version = "0.1.2", features = ["tokio", "client-legacy", "server", "server-auto"] } http-body-util = "0.1.1" -kube = { version = "1.1.0", features = [] } +kube = { version = "1.1.0", features = ["runtime"] } k8s-openapi = { version = "0.25.0", features = ["latest"] } tarpc = { version = "0.36.0", features = ["full"] } rustls-webpki = "0.102.2" diff --git a/crates/kube-manager/src/lib.rs b/crates/kube-manager/src/lib.rs index 0d8169a..1e412fe 100644 --- a/crates/kube-manager/src/lib.rs +++ b/crates/kube-manager/src/lib.rs @@ -5,4 +5,5 @@ pub mod manager_rpc; pub mod sidecar_proxy_manager; pub mod sidecar_proxy_manager_rpc; pub mod tunnel; +pub mod watch_manager; pub mod websocket_transport; diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index 19dcabf..c5372fc 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -40,7 +40,7 @@ use crate::devbox_proxy_manager::DevboxProxyManager; use crate::manager_rpc::KubeManagerRpcServer; use crate::{ sidecar_proxy_manager::SidecarProxyManager, tunnel::TunnelManager, - websocket_transport::WebSocketTransport, + watch_manager::WatchManager, websocket_transport::WebSocketTransport, }; const SCOPE: &[&str] = &["https://www.googleapis.com/auth/cloud-platform"]; @@ -52,6 +52,7 @@ pub struct KubeManager { proxy_manager: Arc, pub(crate) devbox_proxy_manager: Arc, tunnel_manager: TunnelManager, + pub(crate) watch_manager: Arc, } #[derive(Debug, Deserialize)] @@ -102,13 +103,15 @@ pub struct Cluster { impl KubeManager { pub async fn connect_cluster() -> Result<()> { - let kube_client = Self::new_kube_client().await.map_err(|e| { + let kube_client = Arc::new(Self::new_kube_client().await.map_err(|e| { tracing::error!("Failed to create Kubernetes client: {}", e); e - })?; + })?); - let proxy_manager = Arc::new(SidecarProxyManager::new(kube_client.clone()).await?); + let proxy_manager = + Arc::new(SidecarProxyManager::new(kube_client.as_ref().clone()).await?); let devbox_proxy_manager = Arc::new(DevboxProxyManager::new().await?); + let watch_manager = Arc::new(WatchManager::new(kube_client.clone())); let token = std::env::var(KUBE_CLUSTER_TOKEN_ENV_VAR) .map_err(|_| anyhow::anyhow!("can't find env var {}", KUBE_CLUSTER_TOKEN_ENV_VAR))?; @@ -130,10 +133,11 @@ impl KubeManager { .insert(KUBE_CLUSTER_TOKEN_HEADER, token.parse()?); let manager = KubeManager { - kube_client: Arc::new(kube_client), + kube_client, proxy_manager, devbox_proxy_manager, tunnel_manager: TunnelManager::new(tunnel_request), + watch_manager, }; // Start the tunnel manager connection cycle in the background @@ -192,7 +196,11 @@ impl KubeManager { let rpc_client = KubeClusterRpcClient::new(tarpc::client::Config::default(), client_chan).spawn(); - let rpc_server = KubeManagerRpcServer::new(self.clone(), rpc_client); + self.watch_manager + .set_rpc_client(rpc_client.clone()) + .await; + + let rpc_server = KubeManagerRpcServer::new(self.clone(), rpc_client.clone()); // Spawn the WebSocket RPC server mainloop in the background let rpc_clone = rpc_server.clone(); @@ -223,7 +231,11 @@ impl KubeManager { tracing::info!("Successfully reported cluster info"); } - if let Err(e) = websocket_server_task.await { + let websocket_result = websocket_server_task.await; + + self.watch_manager.clear_rpc_client().await; + + if let Err(e) = websocket_result { return Err(anyhow!("WebSocket RPC server task failed: {}", e)); } diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index f1fbfeb..cd2f976 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -379,6 +379,28 @@ impl KubeManagerRpc for KubeManagerRpcServer { } } + async fn configure_watches( + self, + _context: ::tarpc::context::Context, + namespaces: Vec, + ) -> Result<(), String> { + tracing::info!( + "Configuring namespace watches for {} namespaces", + namespaces.len() + ); + + match self.manager.watch_manager.configure_watches(namespaces).await { + Ok(_) => { + tracing::info!("Successfully configured namespace watches"); + Ok(()) + } + Err(e) => { + tracing::error!("Failed to configure namespace watches: {e}"); + Err(format!("Failed to configure namespace watches: {e}")) + } + } + } + async fn destroy_environment( self, _context: ::tarpc::context::Context, diff --git a/crates/kube-manager/src/watch_manager.rs b/crates/kube-manager/src/watch_manager.rs new file mode 100644 index 0000000..93d7a79 --- /dev/null +++ b/crates/kube-manager/src/watch_manager.rs @@ -0,0 +1,488 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; + +use anyhow::{anyhow, Result}; +use futures::{pin_mut, TryStreamExt}; +use k8s_openapi::NamespaceResourceScope; +use kube::{ + runtime::watcher::{self, watcher, Error as WatcherError, Event}, + Api, Resource, ResourceExt, +}; +use lapdev_kube_rpc::{ + KubeClusterRpcClient, ResourceChangeEvent, ResourceChangeType, ResourceType, +}; +use serde::de::DeserializeOwned; +use tokio::{sync::RwLock, task::JoinHandle, time::sleep}; +use tracing::{debug, error, info, warn}; + +const WATCHED_RESOURCE_TYPES: &[ResourceType] = &[ + ResourceType::Deployment, + ResourceType::StatefulSet, + ResourceType::DaemonSet, + ResourceType::ReplicaSet, + ResourceType::Job, + ResourceType::CronJob, + ResourceType::ConfigMap, + ResourceType::Secret, + ResourceType::Service, +]; + +type WatchKey = (ResourceType, String); + +#[derive(Clone)] +pub struct WatchManager { + kube_client: Arc, + rpc_client: Arc>>, + namespaces: Arc>>, + tasks: Arc>>>, +} + +impl WatchManager { + pub fn new(kube_client: Arc) -> Self { + Self { + kube_client, + rpc_client: Arc::new(RwLock::new(None)), + namespaces: Arc::new(RwLock::new(HashSet::new())), + tasks: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn set_rpc_client(&self, client: KubeClusterRpcClient) { + let mut guard = self.rpc_client.write().await; + *guard = Some(client); + } + + pub async fn clear_rpc_client(&self) { + let mut guard = self.rpc_client.write().await; + *guard = None; + } + + pub async fn configure_watches(&self, namespaces: Vec) -> Result<()> { + let new_namespaces: HashSet = namespaces + .into_iter() + .map(|ns| ns.trim().to_string()) + .filter(|ns| !ns.is_empty()) + .collect(); + + let (to_remove, to_add) = { + let current_namespaces = self.namespaces.read().await; + let to_remove: Vec = current_namespaces + .iter() + .filter(|ns| !new_namespaces.contains(*ns)) + .cloned() + .collect(); + let to_add: Vec = new_namespaces + .iter() + .filter(|ns| !current_namespaces.contains(*ns)) + .cloned() + .collect(); + (to_remove, to_add) + }; + + for namespace in &to_remove { + self.stop_namespace(namespace).await; + } + + for namespace in &to_add { + self.start_namespace(namespace.clone()).await; + } + + let mut current_namespaces = self.namespaces.write().await; + *current_namespaces = new_namespaces; + + Ok(()) + } + + async fn stop_namespace(&self, namespace: &str) { + let mut tasks = self.tasks.write().await; + let keys: Vec = tasks + .keys() + .filter(|(_, ns)| ns.as_str() == namespace) + .cloned() + .collect(); + + for key in keys { + if let Some(handle) = tasks.remove(&key) { + handle.abort(); + debug!( + namespace = namespace, + resource_type = ?key.0, + "Stopped watcher for namespace" + ); + } + } + } + + async fn start_namespace(&self, namespace: String) { + let mut tasks_guard = self.tasks.write().await; + + for rt in WATCHED_RESOURCE_TYPES { + let resource_type = *rt; + let key = (resource_type, namespace.clone()); + + if tasks_guard.contains_key(&key) { + continue; + } + + let kube_client = self.kube_client.clone(); + let rpc_client = self.rpc_client.clone(); + let namespace_clone = namespace.clone(); + + let handle = tokio::spawn(async move { + info!( + namespace = namespace_clone.as_str(), + resource_type = ?resource_type, + "Starting watcher task" + ); + let mut backoff = Duration::from_secs(1); + loop { + let result = watch_resource( + kube_client.clone(), + rpc_client.clone(), + namespace_clone.clone(), + resource_type, + ) + .await; + + match result { + Ok(()) => { + info!( + namespace = namespace_clone.as_str(), + resource_type = ?resource_type, + "Watcher completed gracefully, restarting" + ); + backoff = Duration::from_secs(1); + } + Err(err) => { + warn!( + namespace = namespace_clone.as_str(), + resource_type = ?resource_type, + error = ?err, + "Watcher encountered error, restarting with backoff" + ); + sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); + } + } + } + }); + + tasks_guard.insert(key, handle); + } + } +} + +async fn watch_resource( + kube_client: Arc, + rpc_client: Arc>>, + namespace: String, + resource_type: ResourceType, +) -> Result<()> { + match resource_type { + ResourceType::Deployment => { + watch_kind::( + kube_client, + rpc_client, + namespace, + resource_type, + ) + .await + } + ResourceType::StatefulSet => { + watch_kind::( + kube_client, + rpc_client, + namespace, + resource_type, + ) + .await + } + ResourceType::DaemonSet => { + watch_kind::( + kube_client, + rpc_client, + namespace, + resource_type, + ) + .await + } + ResourceType::ReplicaSet => { + watch_kind::( + kube_client, + rpc_client, + namespace, + resource_type, + ) + .await + } + ResourceType::Job => { + watch_kind::( + kube_client, + rpc_client, + namespace, + resource_type, + ) + .await + } + ResourceType::CronJob => { + watch_kind::( + kube_client, + rpc_client, + namespace, + resource_type, + ) + .await + } + ResourceType::ConfigMap => { + watch_kind::( + kube_client, + rpc_client, + namespace, + resource_type, + ) + .await + } + ResourceType::Secret => { + watch_kind::( + kube_client, + rpc_client, + namespace, + resource_type, + ) + .await + } + ResourceType::Service => { + watch_kind::( + kube_client, + rpc_client, + namespace, + resource_type, + ) + .await + } + } +} + +async fn watch_kind( + kube_client: Arc, + rpc_client: Arc>>, + namespace: String, + resource_type: ResourceType, +) -> Result<()> +where + K: Clone + + std::fmt::Debug + + DeserializeOwned + + Resource + + Send + + Sync + + 'static + + serde::Serialize, + ::DynamicType: Default + Eq + std::hash::Hash + Clone + std::fmt::Debug + Send + Sync, +{ + let api: Api = Api::namespaced(kube_client.as_ref().clone(), &namespace); + let watcher_stream = watcher(api, watcher::Config::default().timeout(60)); + pin_mut!(watcher_stream); + let mut seen_versions: HashMap = HashMap::new(); + + while let Some(event) = watcher_stream.try_next().await.map_err(map_watcher_error)? { + match event { + Event::Apply(obj) => { + if let Some(change_event) = + build_event(&namespace, resource_type, ResourceChangeType::Created, &mut seen_versions, obj) + { + send_event(rpc_client.clone(), change_event).await; + } + } + Event::Delete(obj) => { + if let Some(change_event) = + build_event(&namespace, resource_type, ResourceChangeType::Deleted, &mut seen_versions, obj) + { + send_event(rpc_client.clone(), change_event).await; + } + } + Event::Init => { + debug!( + namespace = namespace.as_str(), + resource_type = ?resource_type, + "Watcher received init signal" + ); + } + Event::InitApply(obj) => { + if let Some(change_event) = + build_event(&namespace, resource_type, ResourceChangeType::Created, &mut seen_versions, obj) + { + send_event(rpc_client.clone(), change_event).await; + } + } + Event::InitDone => { + debug!( + namespace = namespace.as_str(), + resource_type = ?resource_type, + "Watcher initial sync completed" + ); + } + } + } + + Ok(()) +} + +fn map_watcher_error(err: WatcherError) -> anyhow::Error { + match err { + WatcherError::InitialListFailed(source) => anyhow!("initial list request failed: {source}"), + WatcherError::WatchStartFailed(source) => anyhow!("failed to start watcher: {source}"), + WatcherError::WatchError(source) => anyhow!("watch error from API server: {source}"), + WatcherError::WatchFailed(source) => anyhow!("watch stream failed: {source}"), + WatcherError::NoResourceVersion => { + anyhow!("watch event missing resourceVersion (resource may not support watch)") + } + } +} +fn build_event( + namespace: &str, + resource_type: ResourceType, + initial_change_type: ResourceChangeType, + seen_versions: &mut HashMap, + obj: K, +) -> Option +where + K: ResourceExt + serde::Serialize, +{ + let name = obj.name_any(); + if name.is_empty() { + warn!( + namespace = namespace, + resource_type = ?resource_type, + "Skipping resource with empty name" + ); + return None; + } + + let resource_version = match obj.resource_version() { + Some(rv) => rv, + None => { + warn!( + namespace = namespace, + resource = name, + resource_type = ?resource_type, + "Skipping resource without resourceVersion" + ); + return None; + } + }; + + let change_type = match initial_change_type { + ResourceChangeType::Created => { + if seen_versions.contains_key(&name) { + ResourceChangeType::Updated + } else { + ResourceChangeType::Created + } + } + ResourceChangeType::Updated => { + if seen_versions.get(&name).map(|prev| prev == &resource_version) == Some(true) { + return None; + } + ResourceChangeType::Updated + } + ResourceChangeType::Deleted => ResourceChangeType::Deleted, + }; + + match change_type { + ResourceChangeType::Deleted => { + seen_versions.remove(&name); + } + _ => { + seen_versions.insert(name.clone(), resource_version.clone()); + } + } + + let resource_yaml = if should_include_yaml(resource_type) { + match serde_yaml::to_string(&obj) { + Ok(yaml) => Some(yaml), + Err(err) => { + warn!( + namespace = namespace, + resource = name, + resource_type = ?resource_type, + error = ?err, + "Failed to serialize resource to YAML" + ); + None + } + } + } else { + None + }; + + Some(ResourceChangeEvent { + namespace: namespace.to_string(), + resource_type, + resource_name: name, + change_type, + resource_version, + resource_yaml, + timestamp: chrono::Utc::now(), + }) +} + +fn should_include_yaml(resource_type: ResourceType) -> bool { + matches!( + resource_type, + ResourceType::Deployment + | ResourceType::StatefulSet + | ResourceType::DaemonSet + | ResourceType::ReplicaSet + | ResourceType::Job + | ResourceType::CronJob + ) +} + +async fn send_event( + rpc_client_store: Arc>>, + event: ResourceChangeEvent, +) { + let client = { + let guard = rpc_client_store.read().await; + guard.clone() + }; + + let Some(client) = client else { + debug!( + namespace = event.namespace.as_str(), + resource_type = ?event.resource_type, + resource_name = event.resource_name.as_str(), + "RPC client not available, skipping resource change event" + ); + return; + }; + + match client + .report_resource_change(tarpc::context::current(), event.clone()) + .await + { + Ok(_) => { + debug!( + namespace = event.namespace.as_str(), + resource_type = ?event.resource_type, + resource_name = event.resource_name.as_str(), + change_type = ?event.change_type, + "Sent resource change event" + ); + } + Err(err) => { + error!( + namespace = event.namespace.as_str(), + resource_type = ?event.resource_type, + resource_name = event.resource_name.as_str(), + change_type = ?event.change_type, + error = ?err, + "Failed to send resource change event" + ); + } + } +} diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index b37dc11..50953db 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use lapdev_common::kube::{ KubeAppCatalogWorkload, KubeClusterInfo, KubeNamespaceInfo, KubeServiceWithYaml, KubeWorkloadDetails, KubeWorkloadKind, KubeWorkloadList, PaginationParams, @@ -38,6 +39,37 @@ pub struct TunnelInfo { pub max_concurrent_connections: u32, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum ResourceType { + Deployment, + StatefulSet, + DaemonSet, + ReplicaSet, + Job, + CronJob, + ConfigMap, + Secret, + Service, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ResourceChangeType { + Created, + Updated, + Deleted, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceChangeEvent { + pub namespace: String, + pub resource_type: ResourceType, + pub resource_name: String, + pub change_type: ResourceChangeType, + pub resource_version: String, + pub resource_yaml: Option, + pub timestamp: DateTime, +} + // Messages sent FROM KubeManager TO Server (Client -> Server) #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type")] @@ -322,6 +354,8 @@ pub trait KubeClusterRpc { connection_count: u64, connection_errors: u64, ) -> Result<(), String>; + + async fn report_resource_change(event: ResourceChangeEvent) -> Result<(), String>; } #[tarpc::service] @@ -360,6 +394,8 @@ pub trait KubeManagerRpc { workloads: Vec, ) -> Result, String>; + async fn configure_watches(namespaces: Vec) -> Result<(), String>; + async fn update_workload_containers( environment_id: Uuid, environment_auth_token: String, diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index 4b39ada..fd74112 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -1,6 +1,6 @@ use lapdev_common::kube::KubeClusterInfo; use lapdev_db::api::DbApi; -use lapdev_kube_rpc::{KubeClusterRpc, KubeManagerRpcClient}; +use lapdev_kube_rpc::{KubeClusterRpc, KubeManagerRpcClient, ResourceChangeEvent}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; @@ -146,4 +146,18 @@ impl KubeClusterRpc for KubeClusterServer { ) .await } + + async fn report_resource_change( + self, + _context: ::tarpc::context::Context, + event: ResourceChangeEvent, + ) -> Result<(), String> { + tracing::debug!( + "Received resource change event from cluster {}: {:?}", + self.cluster_id, + event + ); + // TODO: Persist and process resource change events (Phase 2). + Ok(()) + } } From 5047550d1bd962e341c8c2a774a94ab32c7fef56 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 06:49:35 +0000 Subject: [PATCH 127/334] update --- Cargo.lock | 2 + crates/api/src/kube_controller.rs | 8 +- .../entities/src/kube_app_catalog_workload.rs | 15 + ...000002_create_kube_app_catalog_workload.rs | 33 +- crates/db/src/api.rs | 14 +- crates/kube/Cargo.toml | 2 + crates/kube/src/server.rs | 337 +++++++++++++++++- 7 files changed, 401 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e84f45..00284ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3427,6 +3427,7 @@ dependencies = [ "kube", "lapdev-common", "lapdev-db", + "lapdev-db-entities", "lapdev-kube-rpc", "lapdev-rpc", "lapdev-tunnel", @@ -3435,6 +3436,7 @@ dependencies = [ "sea-orm", "serde", "serde_json", + "serde_yaml", "tarpc", "thiserror 1.0.69", "tokio", diff --git a/crates/api/src/kube_controller.rs b/crates/api/src/kube_controller.rs index 71d83af..90d17d9 100644 --- a/crates/api/src/kube_controller.rs +++ b/crates/api/src/kube_controller.rs @@ -992,7 +992,13 @@ impl KubeController { match self .db - .insert_enriched_workloads_to_catalog(&txn, catalog_id, enriched_workloads, now) + .insert_enriched_workloads_to_catalog( + &txn, + catalog_id, + catalog.cluster_id, + enriched_workloads, + now, + ) .await { Ok(_) => { diff --git a/crates/db/entities/src/kube_app_catalog_workload.rs b/crates/db/entities/src/kube_app_catalog_workload.rs index 5916867..bb7b508 100644 --- a/crates/db/entities/src/kube_app_catalog_workload.rs +++ b/crates/db/entities/src/kube_app_catalog_workload.rs @@ -10,6 +10,7 @@ pub struct Model { pub created_at: DateTimeWithTimeZone, pub deleted_at: Option, pub app_catalog_id: Uuid, + pub cluster_id: Uuid, pub name: String, pub namespace: String, pub kind: String, @@ -27,6 +28,14 @@ pub enum Relation { on_delete = "NoAction" )] KubeAppCatalog, + #[sea_orm( + belongs_to = "super::kube_cluster::Entity", + from = "Column::ClusterId", + to = "super::kube_cluster::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeCluster, } impl Related for Entity { @@ -35,4 +44,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeCluster.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs b/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs index 201db03..b406fdd 100644 --- a/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs +++ b/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs @@ -1,6 +1,9 @@ use sea_orm_migration::prelude::*; -use crate::m20250801_000000_create_kube_app_catalog::KubeAppCatalog; +use crate::{ + m20250729_082625_create_kube_cluster::KubeCluster, + m20250801_000000_create_kube_app_catalog::KubeAppCatalog, +}; #[derive(DeriveMigrationName)] pub struct Migration; @@ -33,6 +36,11 @@ impl MigrationTrait for Migration { .uuid() .not_null(), ) + .col( + ColumnDef::new(KubeAppCatalogWorkload::ClusterId) + .uuid() + .not_null(), + ) .col( ColumnDef::new(KubeAppCatalogWorkload::Name) .string() @@ -66,6 +74,14 @@ impl MigrationTrait for Migration { ) .to(KubeAppCatalog::Table, KubeAppCatalog::Id), ) + .foreign_key( + ForeignKey::create() + .from( + KubeAppCatalogWorkload::Table, + KubeAppCatalogWorkload::ClusterId, + ) + .to(KubeCluster::Table, KubeCluster::Id), + ) .to_owned(), ) .await?; @@ -82,6 +98,20 @@ impl MigrationTrait for Migration { ) .await?; + // Cluster scoped index to quickly find workloads by cluster/name/namespace + manager + .create_index( + Index::create() + .name("kube_app_catalog_workload_cluster_lookup_idx") + .table(KubeAppCatalogWorkload::Table) + .col(KubeAppCatalogWorkload::ClusterId) + .col(KubeAppCatalogWorkload::Name) + .col(KubeAppCatalogWorkload::Namespace) + .col(KubeAppCatalogWorkload::DeletedAt) + .to_owned(), + ) + .await?; + // Create unique index to prevent duplicate workloads per app catalog manager .create_index( @@ -110,6 +140,7 @@ pub enum KubeAppCatalogWorkload { CreatedAt, DeletedAt, AppCatalogId, + ClusterId, Name, Namespace, Kind, diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index f6f4777..9cbeb95 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -979,7 +979,7 @@ impl DbApi { .await?; // Insert individual workloads - self.insert_workloads_to_catalog(&txn, catalog_id, workloads, now) + self.insert_workloads_to_catalog(&txn, catalog_id, cluster_id, workloads, now) .await?; txn.commit().await?; @@ -1016,7 +1016,13 @@ impl DbApi { .await?; // Insert enriched workloads - self.insert_enriched_workloads_to_catalog(&txn, catalog_id, enriched_workloads, now) + self.insert_enriched_workloads_to_catalog( + &txn, + catalog_id, + cluster_id, + enriched_workloads, + now, + ) .await?; txn.commit().await?; @@ -1512,6 +1518,7 @@ impl DbApi { &self, txn: &sea_orm::DatabaseTransaction, catalog_id: Uuid, + cluster_id: Uuid, workloads: Vec, created_at: sea_orm::prelude::DateTimeWithTimeZone, ) -> Result<(), sea_orm::DbErr> { @@ -1521,6 +1528,7 @@ impl DbApi { created_at: ActiveValue::Set(created_at), deleted_at: ActiveValue::Set(None), app_catalog_id: ActiveValue::Set(catalog_id), + cluster_id: ActiveValue::Set(cluster_id), name: ActiveValue::Set(workload.name.clone()), namespace: ActiveValue::Set(workload.namespace.clone()), kind: ActiveValue::Set(workload.kind.to_string()), @@ -1537,6 +1545,7 @@ impl DbApi { &self, txn: &sea_orm::DatabaseTransaction, catalog_id: Uuid, + cluster_id: Uuid, enriched_workloads: Vec, created_at: sea_orm::prelude::DateTimeWithTimeZone, ) -> Result<(), sea_orm::DbErr> { @@ -1555,6 +1564,7 @@ impl DbApi { created_at: ActiveValue::Set(created_at), deleted_at: ActiveValue::Set(None), app_catalog_id: ActiveValue::Set(catalog_id), + cluster_id: ActiveValue::Set(cluster_id), name: ActiveValue::Set(workload.name), namespace: ActiveValue::Set(workload.namespace), kind: ActiveValue::Set(workload.kind.to_string()), diff --git a/crates/kube/Cargo.toml b/crates/kube/Cargo.toml index 8e264b1..96c4d2d 100644 --- a/crates/kube/Cargo.toml +++ b/crates/kube/Cargo.toml @@ -17,10 +17,12 @@ thiserror.workspace = true sea-orm.workspace = true serde_json.workspace = true serde.workspace = true +serde_yaml.workspace = true lapdev-rpc.workspace = true lapdev-kube-rpc.workspace = true lapdev-common.workspace = true lapdev-db.workspace = true +lapdev-db-entities.workspace = true tracing.workspace = true kube.workspace = true k8s-openapi.workspace = true diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index fd74112..8dc342b 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -1,6 +1,21 @@ -use lapdev_common::kube::KubeClusterInfo; +use anyhow::{anyhow, Context as _, Result as AnyResult}; +use k8s_openapi::api::{ + apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}, + batch::v1::{CronJob, Job}, + core::v1::PodSpec, +}; +use lapdev_common::kube::{ + KubeClusterInfo, KubeContainerImage, KubeContainerInfo, KubeContainerPort, KubeWorkloadKind, +}; use lapdev_db::api::DbApi; -use lapdev_kube_rpc::{KubeClusterRpc, KubeManagerRpcClient, ResourceChangeEvent}; +use lapdev_db_entities::kube_app_catalog_workload::{self, Entity as CatalogWorkloadEntity}; +use lapdev_kube_rpc::{ + KubeClusterRpc, KubeManagerRpcClient, ResourceChangeEvent, ResourceChangeType, ResourceType, +}; +use sea_orm::{ + ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, +}; +use sea_orm::prelude::Json; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; @@ -153,11 +168,321 @@ impl KubeClusterRpc for KubeClusterServer { event: ResourceChangeEvent, ) -> Result<(), String> { tracing::debug!( - "Received resource change event from cluster {}: {:?}", - self.cluster_id, - event + cluster_id = %self.cluster_id, + namespace = %event.namespace, + resource_name = %event.resource_name, + resource_type = ?event.resource_type, + change_type = ?event.change_type, + "Received resource change event" ); - // TODO: Persist and process resource change events (Phase 2). + + if let Err(err) = self.handle_workload_change(&event).await { + tracing::error!( + cluster_id = %self.cluster_id, + namespace = %event.namespace, + resource_name = %event.resource_name, + error = ?err, + "Failed to process resource change event" + ); + return Err(err.to_string()); + } + + Ok(()) + } +} + +impl KubeClusterServer { + async fn handle_workload_change(&self, event: &ResourceChangeEvent) -> AnyResult<()> { + let Some(workload_kind) = workload_kind_for(event.resource_type) else { + // Ignore non-workload resources for now. + return Ok(()); + }; + + if matches!(event.change_type, ResourceChangeType::Deleted) { + tracing::debug!( + namespace = %event.namespace, + resource_name = %event.resource_name, + "Skipping deleted workload event (not yet handled)" + ); + return Ok(()); + } + + let yaml = event + .resource_yaml + .as_ref() + .context("workload event missing resource YAML")?; + + let new_containers = extract_containers_from_yaml(event.resource_type, yaml) + .with_context(|| { + format!( + "failed to parse workload YAML for {}/{} ({:?})", + event.namespace, event.resource_name, event.resource_type + ) + })?; + + if new_containers.is_empty() { + tracing::warn!( + namespace = %event.namespace, + resource_name = %event.resource_name, + "Parsed workload contains no containers; skipping update" + ); + return Ok(()); + } + + let workloads = CatalogWorkloadEntity::find() + .filter(kube_app_catalog_workload::Column::Name.eq(event.resource_name.clone())) + .filter(kube_app_catalog_workload::Column::Namespace.eq(event.namespace.clone())) + .filter(kube_app_catalog_workload::Column::DeletedAt.is_null()) + .filter(kube_app_catalog_workload::Column::ClusterId.eq(self.cluster_id)) + .all(&self.db.conn) + .await + .with_context(|| { + format!( + "failed querying catalog workloads for {}/{}", + event.namespace, event.resource_name + ) + })?; + + if workloads.is_empty() { + tracing::trace!( + namespace = %event.namespace, + resource_name = %event.resource_name, + "Workload not tracked in any catalog; ignoring" + ); + return Ok(()); + } + + for workload in workloads { + // Ensure the stored kind matches; if not, skip but log. + if let Ok(stored_kind) = workload.kind.parse::() { + if stored_kind != workload_kind { + tracing::warn!( + namespace = %event.namespace, + resource_name = %event.resource_name, + stored_kind = %workload.kind, + event_kind = ?workload_kind, + "Catalog workload kind mismatch; skipping update" + ); + continue; + } + } + + let merged_containers = + merge_containers(&workload.containers, &new_containers).with_context(|| { + format!( + "failed to merge container definitions for workload {}", + workload.id + ) + })?; + + let containers_json = serde_json::to_value(&merged_containers) + .context("failed to serialize merged container definition")?; + + let active_model = kube_app_catalog_workload::ActiveModel { + id: ActiveValue::Set(workload.id), + containers: ActiveValue::Set(Json::from(containers_json)), + ..Default::default() + }; + + active_model + .update(&self.db.conn) + .await + .with_context(|| format!("failed to update workload {}", workload.id))?; + + tracing::info!( + workload_id = %workload.id, + namespace = %event.namespace, + resource_name = %event.resource_name, + "Updated catalog workload containers from cluster event" + ); + } + Ok(()) } } + +fn workload_kind_for(resource_type: ResourceType) -> Option { + match resource_type { + ResourceType::Deployment => Some(KubeWorkloadKind::Deployment), + ResourceType::StatefulSet => Some(KubeWorkloadKind::StatefulSet), + ResourceType::DaemonSet => Some(KubeWorkloadKind::DaemonSet), + ResourceType::ReplicaSet => Some(KubeWorkloadKind::ReplicaSet), + ResourceType::Job => Some(KubeWorkloadKind::Job), + ResourceType::CronJob => Some(KubeWorkloadKind::CronJob), + ResourceType::ConfigMap + | ResourceType::Secret + | ResourceType::Service => None, + } +} + +fn extract_containers_from_yaml( + resource_type: ResourceType, + yaml: &str, +) -> AnyResult> { + match resource_type { + ResourceType::Deployment => { + let deployment: Deployment = serde_yaml::from_str(yaml)?; + let pod_spec = deployment + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .context("deployment missing pod spec")?; + extract_pod_spec_containers(pod_spec) + } + ResourceType::StatefulSet => { + let statefulset: StatefulSet = serde_yaml::from_str(yaml)?; + let pod_spec = statefulset + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .context("statefulset missing pod spec")?; + extract_pod_spec_containers(pod_spec) + } + ResourceType::DaemonSet => { + let daemonset: DaemonSet = serde_yaml::from_str(yaml)?; + let pod_spec = daemonset + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .context("daemonset missing pod spec")?; + extract_pod_spec_containers(pod_spec) + } + ResourceType::ReplicaSet => { + let replicaset: ReplicaSet = serde_yaml::from_str(yaml)?; + let pod_spec = replicaset + .spec + .as_ref() + .and_then(|s| s.template.as_ref()) + .and_then(|t| t.spec.as_ref()) + .context("replicaset missing pod spec")?; + extract_pod_spec_containers(pod_spec) + } + ResourceType::Job => { + let job: Job = serde_yaml::from_str(yaml)?; + let pod_spec = job + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .context("job missing pod spec")?; + extract_pod_spec_containers(pod_spec) + } + ResourceType::CronJob => { + let cron_job: CronJob = serde_yaml::from_str(yaml)?; + let pod_spec = cron_job + .spec + .as_ref() + .and_then(|s| s.job_template.spec.as_ref()) + .and_then(|s| s.template.spec.as_ref()) + .context("cronjob missing pod spec")?; + extract_pod_spec_containers(pod_spec) + } + ResourceType::ConfigMap | ResourceType::Secret | ResourceType::Service => { + Ok(Vec::new()) + } + } +} + +fn extract_pod_spec_containers(pod_spec: &PodSpec) -> AnyResult> { + pod_spec + .containers + .iter() + .map(|container| { + let mut cpu_request = None; + let mut cpu_limit = None; + let mut memory_request = None; + let mut memory_limit = None; + + if let Some(resources) = &container.resources { + if let Some(requests) = &resources.requests { + if let Some(cpu) = requests.get("cpu") { + cpu_request = Some(cpu.0.clone()); + } + if let Some(memory) = requests.get("memory") { + memory_request = Some(memory.0.clone()); + } + } + if let Some(limits) = &resources.limits { + if let Some(cpu) = limits.get("cpu") { + cpu_limit = Some(cpu.0.clone()); + } + if let Some(memory) = limits.get("memory") { + memory_limit = Some(memory.0.clone()); + } + } + } + + let image = container + .image + .clone() + .ok_or_else(|| anyhow!("container '{}' missing image", container.name))?; + + let ports = container + .ports + .as_ref() + .map(|ports| { + ports + .iter() + .map(|port| KubeContainerPort { + name: port.name.clone(), + container_port: port.container_port, + protocol: port.protocol.clone(), + }) + .collect::>() + }) + .unwrap_or_default(); + + Ok(KubeContainerInfo { + name: container.name.clone(), + original_image: image.clone(), + image: KubeContainerImage::FollowOriginal, + cpu_request, + cpu_limit, + memory_request, + memory_limit, + env_vars: Vec::new(), + original_env_vars: Vec::new(), + ports, + }) + }) + .collect() +} + +fn merge_containers( + existing: &Json, + new_containers: &[KubeContainerInfo], +) -> AnyResult> { + let existing_containers: Vec = + serde_json::from_value(existing.clone()).unwrap_or_default(); + let mut existing_map: HashMap = existing_containers + .into_iter() + .map(|container| (container.name.clone(), container)) + .collect(); + + let mut merged = Vec::new(); + for new_container in new_containers { + if let Some(mut existing) = existing_map.remove(&new_container.name) { + existing.original_image = new_container.original_image.clone(); + existing.cpu_request = new_container.cpu_request.clone(); + existing.cpu_limit = new_container.cpu_limit.clone(); + existing.memory_request = new_container.memory_request.clone(); + existing.memory_limit = new_container.memory_limit.clone(); + existing.ports = new_container.ports.clone(); + // Preserve customized image choices; otherwise keep following original. + if let KubeContainerImage::FollowOriginal = existing.image { + existing.image = KubeContainerImage::FollowOriginal; + } + merged.push(existing); + } else { + merged.push(new_container.clone()); + } + } + + if !existing_map.is_empty() { + tracing::debug!( + removed_containers = ?existing_map.keys().collect::>(), + "Dropping containers no longer present in source workload" + ); + } + + Ok(merged) +} From f149b24c4cd763c2684c03f31e636cbb5c142461 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 07:24:03 +0000 Subject: [PATCH 128/334] update --- .../db/entities/src/kube_cluster_service.rs | 44 ++++++ crates/db/entities/src/lib.rs | 1 + crates/db/entities/src/prelude.rs | 1 + crates/db/migration/src/lib.rs | 2 + ...0820_000001_create_kube_cluster_service.rs | 141 ++++++++++++++++++ crates/db/src/api.rs | 95 +++++++++++- crates/kube-manager/src/manager.rs | 11 +- crates/kube-manager/src/manager_rpc.rs | 7 +- crates/kube-manager/src/watch_manager.rs | 40 +++-- crates/kube/src/server.rs | 107 +++++++++++-- 10 files changed, 414 insertions(+), 35 deletions(-) create mode 100644 crates/db/entities/src/kube_cluster_service.rs create mode 100644 crates/db/migration/src/m20250820_000001_create_kube_cluster_service.rs diff --git a/crates/db/entities/src/kube_cluster_service.rs b/crates/db/entities/src/kube_cluster_service.rs new file mode 100644 index 0000000..3e6002c --- /dev/null +++ b/crates/db/entities/src/kube_cluster_service.rs @@ -0,0 +1,44 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.14 (manually extended) + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "kube_cluster_service")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + pub deleted_at: Option, + pub cluster_id: Uuid, + pub namespace: String, + pub name: String, + pub resource_version: String, + #[sea_orm(column_type = "Text")] + pub service_yaml: String, + pub selector: Json, + pub ports: Json, + pub service_type: Option, + pub cluster_ip: Option, + pub last_observed_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::kube_cluster::Entity", + from = "Column::ClusterId", + to = "super::kube_cluster::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + KubeCluster, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeCluster.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/db/entities/src/lib.rs b/crates/db/entities/src/lib.rs index e804fec..93f3abd 100644 --- a/crates/db/entities/src/lib.rs +++ b/crates/db/entities/src/lib.rs @@ -7,6 +7,7 @@ pub mod config; pub mod kube_app_catalog; pub mod kube_app_catalog_workload; pub mod kube_cluster; +pub mod kube_cluster_service; pub mod kube_cluster_token; pub mod kube_devbox_session; pub mod kube_devbox_workload_intercept; diff --git a/crates/db/entities/src/prelude.rs b/crates/db/entities/src/prelude.rs index a219578..9e0d716 100644 --- a/crates/db/entities/src/prelude.rs +++ b/crates/db/entities/src/prelude.rs @@ -5,6 +5,7 @@ pub use super::config::Entity as Config; pub use super::kube_app_catalog::Entity as KubeAppCatalog; pub use super::kube_app_catalog_workload::Entity as KubeAppCatalogWorkload; pub use super::kube_cluster::Entity as KubeCluster; +pub use super::kube_cluster_service::Entity as KubeClusterService; pub use super::kube_cluster_token::Entity as KubeClusterToken; pub use super::kube_devbox_session::Entity as KubeDevboxSession; pub use super::kube_devbox_workload_intercept::Entity as KubeDevboxWorkloadIntercept; diff --git a/crates/db/migration/src/lib.rs b/crates/db/migration/src/lib.rs index d6b119b..e190c02 100644 --- a/crates/db/migration/src/lib.rs +++ b/crates/db/migration/src/lib.rs @@ -27,6 +27,7 @@ mod m20250809_000001_create_kube_environment; mod m20250809_000002_create_kube_environment_workload; mod m20250809_000003_create_kube_environment_service; mod m20250815_000001_create_kube_environment_preview_url; +mod m20250820_000001_create_kube_cluster_service; mod m20251008_000001_create_kube_devbox_session; mod m20251008_000002_create_kube_devbox_workload_intercept; @@ -63,6 +64,7 @@ impl MigratorTrait for Migrator { Box::new(m20250809_000002_create_kube_environment_workload::Migration), Box::new(m20250809_000003_create_kube_environment_service::Migration), Box::new(m20250815_000001_create_kube_environment_preview_url::Migration), + Box::new(m20250820_000001_create_kube_cluster_service::Migration), Box::new(m20251008_000001_create_kube_devbox_session::Migration), Box::new(m20251008_000002_create_kube_devbox_workload_intercept::Migration), ] diff --git a/crates/db/migration/src/m20250820_000001_create_kube_cluster_service.rs b/crates/db/migration/src/m20250820_000001_create_kube_cluster_service.rs new file mode 100644 index 0000000..52f8a5a --- /dev/null +++ b/crates/db/migration/src/m20250820_000001_create_kube_cluster_service.rs @@ -0,0 +1,141 @@ +use sea_orm_migration::prelude::*; + +use crate::m20250729_082625_create_kube_cluster::KubeCluster; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(KubeClusterService::Table) + .if_not_exists() + .col( + ColumnDef::new(KubeClusterService::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(KubeClusterService::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(KubeClusterService::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(KubeClusterService::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(KubeClusterService::ClusterId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeClusterService::Namespace) + .string() + .not_null(), + ) + .col(ColumnDef::new(KubeClusterService::Name).string().not_null()) + .col( + ColumnDef::new(KubeClusterService::ResourceVersion) + .string() + .not_null(), + ) + .col( + ColumnDef::new(KubeClusterService::ServiceYaml) + .text() + .not_null(), + ) + .col( + ColumnDef::new(KubeClusterService::Selector) + .json() + .not_null() + .default("{}"), + ) + .col( + ColumnDef::new(KubeClusterService::Ports) + .json() + .not_null() + .default("[]"), + ) + .col( + ColumnDef::new(KubeClusterService::ServiceType) + .string() + .null(), + ) + .col( + ColumnDef::new(KubeClusterService::ClusterIp) + .string() + .null(), + ) + .col( + ColumnDef::new(KubeClusterService::LastObservedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from(KubeClusterService::Table, KubeClusterService::ClusterId) + .to(KubeCluster::Table, KubeCluster::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_cluster_service_cluster_namespace_name_idx") + .table(KubeClusterService::Table) + .col(KubeClusterService::ClusterId) + .col(KubeClusterService::Namespace) + .col(KubeClusterService::Name) + .unique() + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_cluster_service_cluster_namespace_deleted_idx") + .table(KubeClusterService::Table) + .col(KubeClusterService::ClusterId) + .col(KubeClusterService::Namespace) + .col(KubeClusterService::DeletedAt) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +pub enum KubeClusterService { + Table, + Id, + CreatedAt, + UpdatedAt, + DeletedAt, + ClusterId, + Namespace, + Name, + ResourceVersion, + ServiceYaml, + Selector, + Ports, + ServiceType, + ClusterIp, + LastObservedAt, +} diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 9cbeb95..226ea6a 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -10,6 +10,7 @@ use lapdev_common::{ AuthProvider, ProviderUser, UserRole, WorkspaceStatus, LAPDEV_BASE_HOSTNAME, LAPDEV_ISOLATE_CONTAINER, }; +use lapdev_db_entities::kube_cluster_service; use lapdev_db_migration::Migrator; use pasetors::{ keys::{Generate, SymmetricKey}, @@ -948,6 +949,98 @@ impl DbApi { Ok(updated) } + pub async fn upsert_cluster_service( + &self, + cluster_id: Uuid, + namespace: &str, + name: &str, + resource_version: &str, + service_yaml: String, + selector: serde_json::Value, + ports: serde_json::Value, + service_type: Option, + cluster_ip: Option, + observed_at: chrono::DateTime, + ) -> Result { + let observed = observed_at.into(); + let selector_json = Json::from(selector); + let ports_json = Json::from(ports); + + let existing = kube_cluster_service::Entity::find() + .filter(kube_cluster_service::Column::ClusterId.eq(cluster_id)) + .filter(kube_cluster_service::Column::Namespace.eq(namespace)) + .filter(kube_cluster_service::Column::Name.eq(name)) + .one(&self.conn) + .await?; + + if let Some(model) = existing { + let same_resource_version = model.resource_version == resource_version; + let mut active: kube_cluster_service::ActiveModel = model.into(); + if !same_resource_version { + active.resource_version = ActiveValue::Set(resource_version.to_string()); + } + active.service_yaml = ActiveValue::Set(service_yaml); + active.selector = ActiveValue::Set(selector_json); + active.ports = ActiveValue::Set(ports_json); + active.service_type = ActiveValue::Set(service_type); + active.cluster_ip = ActiveValue::Set(cluster_ip); + active.updated_at = ActiveValue::Set(observed); + active.last_observed_at = ActiveValue::Set(observed); + active.deleted_at = ActiveValue::Set(None); + + let updated = active.update(&self.conn).await?; + Ok(updated.id) + } else { + let new_id = Uuid::new_v4(); + let active = kube_cluster_service::ActiveModel { + id: ActiveValue::Set(new_id), + created_at: ActiveValue::Set(observed), + updated_at: ActiveValue::Set(observed), + deleted_at: ActiveValue::Set(None), + cluster_id: ActiveValue::Set(cluster_id), + namespace: ActiveValue::Set(namespace.to_string()), + name: ActiveValue::Set(name.to_string()), + resource_version: ActiveValue::Set(resource_version.to_string()), + service_yaml: ActiveValue::Set(service_yaml), + selector: ActiveValue::Set(selector_json), + ports: ActiveValue::Set(ports_json), + service_type: ActiveValue::Set(service_type), + cluster_ip: ActiveValue::Set(cluster_ip), + last_observed_at: ActiveValue::Set(observed), + }; + + active.insert(&self.conn).await?; + Ok(new_id) + } + } + + pub async fn mark_cluster_service_deleted( + &self, + cluster_id: Uuid, + namespace: &str, + name: &str, + observed_at: chrono::DateTime, + ) -> Result<()> { + let model = match kube_cluster_service::Entity::find() + .filter(kube_cluster_service::Column::ClusterId.eq(cluster_id)) + .filter(kube_cluster_service::Column::Namespace.eq(namespace)) + .filter(kube_cluster_service::Column::Name.eq(name)) + .one(&self.conn) + .await? + { + Some(model) => model, + None => return Ok(()), + }; + + let observed = observed_at.into(); + let mut active: kube_cluster_service::ActiveModel = model.into(); + active.deleted_at = ActiveValue::Set(Some(observed)); + active.updated_at = ActiveValue::Set(chrono::Utc::now().into()); + active.last_observed_at = ActiveValue::Set(observed); + active.update(&self.conn).await?; + Ok(()) + } + // App catalog operations pub async fn create_app_catalog( &self, @@ -1023,7 +1116,7 @@ impl DbApi { enriched_workloads, now, ) - .await?; + .await?; txn.commit().await?; Ok(catalog_id) diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index c5372fc..8e0ae20 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -39,8 +39,8 @@ use uuid::Uuid; use crate::devbox_proxy_manager::DevboxProxyManager; use crate::manager_rpc::KubeManagerRpcServer; use crate::{ - sidecar_proxy_manager::SidecarProxyManager, tunnel::TunnelManager, - watch_manager::WatchManager, websocket_transport::WebSocketTransport, + sidecar_proxy_manager::SidecarProxyManager, tunnel::TunnelManager, watch_manager::WatchManager, + websocket_transport::WebSocketTransport, }; const SCOPE: &[&str] = &["https://www.googleapis.com/auth/cloud-platform"]; @@ -108,8 +108,7 @@ impl KubeManager { e })?); - let proxy_manager = - Arc::new(SidecarProxyManager::new(kube_client.as_ref().clone()).await?); + let proxy_manager = Arc::new(SidecarProxyManager::new(kube_client.as_ref().clone()).await?); let devbox_proxy_manager = Arc::new(DevboxProxyManager::new().await?); let watch_manager = Arc::new(WatchManager::new(kube_client.clone())); @@ -196,9 +195,7 @@ impl KubeManager { let rpc_client = KubeClusterRpcClient::new(tarpc::client::Config::default(), client_chan).spawn(); - self.watch_manager - .set_rpc_client(rpc_client.clone()) - .await; + self.watch_manager.set_rpc_client(rpc_client.clone()).await; let rpc_server = KubeManagerRpcServer::new(self.clone(), rpc_client.clone()); diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index cd2f976..f9ba5d4 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -389,7 +389,12 @@ impl KubeManagerRpc for KubeManagerRpcServer { namespaces.len() ); - match self.manager.watch_manager.configure_watches(namespaces).await { + match self + .manager + .watch_manager + .configure_watches(namespaces) + .await + { Ok(_) => { tracing::info!("Successfully configured namespace watches"); Ok(()) diff --git a/crates/kube-manager/src/watch_manager.rs b/crates/kube-manager/src/watch_manager.rs index 93d7a79..1e2f22a 100644 --- a/crates/kube-manager/src/watch_manager.rs +++ b/crates/kube-manager/src/watch_manager.rs @@ -281,7 +281,8 @@ where + Sync + 'static + serde::Serialize, - ::DynamicType: Default + Eq + std::hash::Hash + Clone + std::fmt::Debug + Send + Sync, + ::DynamicType: + Default + Eq + std::hash::Hash + Clone + std::fmt::Debug + Send + Sync, { let api: Api = Api::namespaced(kube_client.as_ref().clone(), &namespace); let watcher_stream = watcher(api, watcher::Config::default().timeout(60)); @@ -291,16 +292,24 @@ where while let Some(event) = watcher_stream.try_next().await.map_err(map_watcher_error)? { match event { Event::Apply(obj) => { - if let Some(change_event) = - build_event(&namespace, resource_type, ResourceChangeType::Created, &mut seen_versions, obj) - { + if let Some(change_event) = build_event( + &namespace, + resource_type, + ResourceChangeType::Created, + &mut seen_versions, + obj, + ) { send_event(rpc_client.clone(), change_event).await; } } Event::Delete(obj) => { - if let Some(change_event) = - build_event(&namespace, resource_type, ResourceChangeType::Deleted, &mut seen_versions, obj) - { + if let Some(change_event) = build_event( + &namespace, + resource_type, + ResourceChangeType::Deleted, + &mut seen_versions, + obj, + ) { send_event(rpc_client.clone(), change_event).await; } } @@ -312,9 +321,13 @@ where ); } Event::InitApply(obj) => { - if let Some(change_event) = - build_event(&namespace, resource_type, ResourceChangeType::Created, &mut seen_versions, obj) - { + if let Some(change_event) = build_event( + &namespace, + resource_type, + ResourceChangeType::Created, + &mut seen_versions, + obj, + ) { send_event(rpc_client.clone(), change_event).await; } } @@ -384,7 +397,11 @@ where } } ResourceChangeType::Updated => { - if seen_versions.get(&name).map(|prev| prev == &resource_version) == Some(true) { + if seen_versions + .get(&name) + .map(|prev| prev == &resource_version) + == Some(true) + { return None; } ResourceChangeType::Updated @@ -439,6 +456,7 @@ fn should_include_yaml(resource_type: ResourceType) -> bool { | ResourceType::ReplicaSet | ResourceType::Job | ResourceType::CronJob + | ResourceType::Service ) } diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index 8dc342b..526c1c7 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -2,7 +2,7 @@ use anyhow::{anyhow, Context as _, Result as AnyResult}; use k8s_openapi::api::{ apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}, batch::v1::{CronJob, Job}, - core::v1::PodSpec, + core::v1::{PodSpec, Service}, }; use lapdev_common::kube::{ KubeClusterInfo, KubeContainerImage, KubeContainerInfo, KubeContainerPort, KubeWorkloadKind, @@ -12,10 +12,9 @@ use lapdev_db_entities::kube_app_catalog_workload::{self, Entity as CatalogWorkl use lapdev_kube_rpc::{ KubeClusterRpc, KubeManagerRpcClient, ResourceChangeEvent, ResourceChangeType, ResourceType, }; -use sea_orm::{ - ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, -}; use sea_orm::prelude::Json; +use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; +use serde_json::json; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; @@ -176,7 +175,12 @@ impl KubeClusterRpc for KubeClusterServer { "Received resource change event" ); - if let Err(err) = self.handle_workload_change(&event).await { + let result = match event.resource_type { + ResourceType::Service => self.handle_service_change(&event).await, + _ => self.handle_workload_change(&event).await, + }; + + if let Err(err) = result { tracing::error!( cluster_id = %self.cluster_id, namespace = %event.namespace, @@ -212,8 +216,8 @@ impl KubeClusterServer { .as_ref() .context("workload event missing resource YAML")?; - let new_containers = extract_containers_from_yaml(event.resource_type, yaml) - .with_context(|| { + let new_containers = + extract_containers_from_yaml(event.resource_type, yaml).with_context(|| { format!( "failed to parse workload YAML for {}/{} ({:?})", event.namespace, event.resource_name, event.resource_type @@ -267,8 +271,8 @@ impl KubeClusterServer { } } - let merged_containers = - merge_containers(&workload.containers, &new_containers).with_context(|| { + let merged_containers = merge_containers(&workload.containers, &new_containers) + .with_context(|| { format!( "failed to merge container definitions for workload {}", workload.id @@ -299,6 +303,83 @@ impl KubeClusterServer { Ok(()) } + + async fn handle_service_change(&self, event: &ResourceChangeEvent) -> AnyResult<()> { + if matches!(event.change_type, ResourceChangeType::Deleted) { + self.db + .mark_cluster_service_deleted( + self.cluster_id, + &event.namespace, + &event.resource_name, + event.timestamp, + ) + .await?; + return Ok(()); + } + + let yaml = event + .resource_yaml + .as_ref() + .context("service event missing resource YAML")?; + + let service: Service = serde_yaml::from_str(yaml)?; + + let (selector_json, ports_json, service_type, cluster_ip) = { + let spec = service.spec.as_ref(); + let selector = spec + .and_then(|spec| spec.selector.clone()) + .unwrap_or_default(); + let selector_json = json!(selector); + + let ports = spec + .and_then(|spec| spec.ports.clone()) + .unwrap_or_default() + .into_iter() + .map(|port| { + let target_port = port.target_port.map(|tp| match tp { + k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int(v) => { + json!(v) + } + k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::String(s) => { + json!(s) + } + }); + + json!({ + "name": port.name, + "port": port.port, + "target_port": target_port, + "protocol": port.protocol, + "app_protocol": port.app_protocol, + "node_port": port.node_port, + }) + }) + .collect::>(); + let ports_json = json!(ports); + + let service_type = spec.and_then(|spec| spec.type_.clone()); + let cluster_ip = spec.and_then(|spec| spec.cluster_ip.clone()); + + (selector_json, ports_json, service_type, cluster_ip) + }; + + self.db + .upsert_cluster_service( + self.cluster_id, + &event.namespace, + &event.resource_name, + &event.resource_version, + yaml.clone(), + selector_json, + ports_json, + service_type, + cluster_ip, + event.timestamp, + ) + .await?; + + Ok(()) + } } fn workload_kind_for(resource_type: ResourceType) -> Option { @@ -309,9 +390,7 @@ fn workload_kind_for(resource_type: ResourceType) -> Option { ResourceType::ReplicaSet => Some(KubeWorkloadKind::ReplicaSet), ResourceType::Job => Some(KubeWorkloadKind::Job), ResourceType::CronJob => Some(KubeWorkloadKind::CronJob), - ResourceType::ConfigMap - | ResourceType::Secret - | ResourceType::Service => None, + ResourceType::ConfigMap | ResourceType::Secret | ResourceType::Service => None, } } @@ -376,9 +455,7 @@ fn extract_containers_from_yaml( .context("cronjob missing pod spec")?; extract_pod_spec_containers(pod_spec) } - ResourceType::ConfigMap | ResourceType::Secret | ResourceType::Service => { - Ok(Vec::new()) - } + ResourceType::ConfigMap | ResourceType::Secret | ResourceType::Service => Ok(Vec::new()), } } From 3f24698a4205fe9c6f9dbf83e448ea80d4d0c9b1 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 07:44:27 +0000 Subject: [PATCH 129/334] update --- Cargo.lock | 1 + crates/db/Cargo.toml | 1 + crates/db/src/api.rs | 76 +++++++++++++++++++++- crates/kube/src/server.rs | 129 +++++++++++++++++++++++++++++++++----- 4 files changed, 191 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00284ba..3e5f672 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3324,6 +3324,7 @@ dependencies = [ "sea-orm", "sea-orm-migration", "secrecy", + "serde", "serde_json", "sqlx", "tokio", diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index 4c7c266..d8190e0 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -18,6 +18,7 @@ sqlx.workspace = true sea-orm.workspace = true sea-orm-migration.workspace = true serde_json.workspace = true +serde.workspace = true lapdev-db-migration.workspace = true lapdev-db-entities.workspace = true lapdev-common.workspace = true diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 226ea6a..7366e81 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -4,8 +4,8 @@ use chrono::{DateTime, FixedOffset, Utc}; use lapdev_common::{ config::LAPDEV_CLUSTER_NOT_INITIATED, kube::{ - KubeAppCatalogWorkload, KubeContainerInfo, KubeEnvironmentWorkload, KubeWorkloadDetails, - PagePaginationParams, + KubeAppCatalogWorkload, KubeContainerInfo, KubeEnvironmentWorkload, KubeServicePort, + KubeWorkloadDetails, PagePaginationParams, }, AuthProvider, ProviderUser, UserRole, WorkspaceStatus, LAPDEV_BASE_HOSTNAME, LAPDEV_ISOLATE_CONTAINER, @@ -24,8 +24,11 @@ use sea_orm::{ RelationTrait, TransactionTrait, }; use sea_orm_migration::MigratorTrait; +use serde::Deserialize; use serde_json; use sqlx::PgPool; +use std::collections::BTreeMap; +use std::convert::TryFrom; use uuid::Uuid; // Custom result structure for multi-table join @@ -70,6 +73,42 @@ pub struct DbApi { pub pool: Option, } +#[derive(Clone, Debug)] +pub struct CachedClusterService { + pub name: String, + pub selector: BTreeMap, + pub ports: Vec, +} + +#[derive(Debug, Deserialize)] +struct StoredServicePort { + pub name: Option, + pub port: i32, + #[serde(default)] + pub target_port: Option, + #[serde(default)] + pub protocol: Option, + #[serde(default)] + pub node_port: Option, +} + +impl StoredServicePort { + fn into_service_port(self) -> Option { + let target_port = self + .target_port + .and_then(|value| value.as_i64()) + .and_then(|v| i32::try_from(v).ok()); + + Some(KubeServicePort { + name: self.name, + port: self.port, + target_port, + protocol: self.protocol, + node_port: self.node_port, + }) + } +} + async fn connect_db(conn_url: &str) -> Result { let pool: sqlx::PgPool = sqlx::pool::PoolOptions::new() .max_connections(100) @@ -949,6 +988,39 @@ impl DbApi { Ok(updated) } + pub async fn get_active_cluster_services( + &self, + cluster_id: Uuid, + namespace: &str, + ) -> Result> { + let services = kube_cluster_service::Entity::find() + .filter(kube_cluster_service::Column::ClusterId.eq(cluster_id)) + .filter(kube_cluster_service::Column::Namespace.eq(namespace)) + .filter(kube_cluster_service::Column::DeletedAt.is_null()) + .all(&self.conn) + .await?; + + let mut results = Vec::with_capacity(services.len()); + for svc in services { + let selector: BTreeMap = + serde_json::from_value(svc.selector.clone()).unwrap_or_default(); + let ports_raw: Vec = + serde_json::from_value(svc.ports.clone()).unwrap_or_default(); + let ports = ports_raw + .into_iter() + .filter_map(StoredServicePort::into_service_port) + .collect(); + + results.push(CachedClusterService { + name: svc.name, + selector, + ports, + }); + } + + Ok(results) + } + pub async fn upsert_cluster_service( &self, cluster_id: Uuid, diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index 526c1c7..4e19bdf 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -5,9 +5,10 @@ use k8s_openapi::api::{ core::v1::{PodSpec, Service}, }; use lapdev_common::kube::{ - KubeClusterInfo, KubeContainerImage, KubeContainerInfo, KubeContainerPort, KubeWorkloadKind, + KubeClusterInfo, KubeContainerImage, KubeContainerInfo, KubeContainerPort, KubeServicePort, + KubeWorkloadKind, }; -use lapdev_db::api::DbApi; +use lapdev_db::api::{CachedClusterService, DbApi}; use lapdev_db_entities::kube_app_catalog_workload::{self, Entity as CatalogWorkloadEntity}; use lapdev_kube_rpc::{ KubeClusterRpc, KubeManagerRpcClient, ResourceChangeEvent, ResourceChangeType, ResourceType, @@ -15,7 +16,7 @@ use lapdev_kube_rpc::{ use sea_orm::prelude::Json; use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; use serde_json::json; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; @@ -216,13 +217,15 @@ impl KubeClusterServer { .as_ref() .context("workload event missing resource YAML")?; - let new_containers = - extract_containers_from_yaml(event.resource_type, yaml).with_context(|| { + let extracted = + extract_workload_from_yaml(event.resource_type, yaml).with_context(|| { format!( "failed to parse workload YAML for {}/{} ({:?})", event.namespace, event.resource_name, event.resource_type ) })?; + let new_containers = extracted.containers; + let workload_labels = extracted.labels; if new_containers.is_empty() { tracing::warn!( @@ -233,6 +236,11 @@ impl KubeClusterServer { return Ok(()); } + let cached_services = self + .db + .get_active_cluster_services(self.cluster_id, &event.namespace) + .await?; + let workloads = CatalogWorkloadEntity::find() .filter(kube_app_catalog_workload::Column::Name.eq(event.resource_name.clone())) .filter(kube_app_catalog_workload::Column::Namespace.eq(event.namespace.clone())) @@ -278,13 +286,17 @@ impl KubeClusterServer { workload.id ) })?; + let service_ports = ports_from_cached_services(&workload_labels, &cached_services); let containers_json = serde_json::to_value(&merged_containers) .context("failed to serialize merged container definition")?; + let ports_json = serde_json::to_value(&service_ports) + .context("failed to serialize workload ports definition")?; let active_model = kube_app_catalog_workload::ActiveModel { id: ActiveValue::Set(workload.id), containers: ActiveValue::Set(Json::from(containers_json)), + ports: ActiveValue::Set(Json::from(ports_json)), ..Default::default() }; @@ -394,10 +406,15 @@ fn workload_kind_for(resource_type: ResourceType) -> Option { } } -fn extract_containers_from_yaml( +struct ExtractedWorkload { + containers: Vec, + labels: BTreeMap, +} + +fn extract_workload_from_yaml( resource_type: ResourceType, yaml: &str, -) -> AnyResult> { +) -> AnyResult { match resource_type { ResourceType::Deployment => { let deployment: Deployment = serde_yaml::from_str(yaml)?; @@ -406,7 +423,14 @@ fn extract_containers_from_yaml( .as_ref() .and_then(|s| s.template.spec.as_ref()) .context("deployment missing pod spec")?; - extract_pod_spec_containers(pod_spec) + let labels = deployment + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let containers = extract_pod_spec_containers(pod_spec)?; + Ok(ExtractedWorkload { containers, labels }) } ResourceType::StatefulSet => { let statefulset: StatefulSet = serde_yaml::from_str(yaml)?; @@ -415,7 +439,14 @@ fn extract_containers_from_yaml( .as_ref() .and_then(|s| s.template.spec.as_ref()) .context("statefulset missing pod spec")?; - extract_pod_spec_containers(pod_spec) + let labels = statefulset + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let containers = extract_pod_spec_containers(pod_spec)?; + Ok(ExtractedWorkload { containers, labels }) } ResourceType::DaemonSet => { let daemonset: DaemonSet = serde_yaml::from_str(yaml)?; @@ -424,7 +455,14 @@ fn extract_containers_from_yaml( .as_ref() .and_then(|s| s.template.spec.as_ref()) .context("daemonset missing pod spec")?; - extract_pod_spec_containers(pod_spec) + let labels = daemonset + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let containers = extract_pod_spec_containers(pod_spec)?; + Ok(ExtractedWorkload { containers, labels }) } ResourceType::ReplicaSet => { let replicaset: ReplicaSet = serde_yaml::from_str(yaml)?; @@ -434,7 +472,15 @@ fn extract_containers_from_yaml( .and_then(|s| s.template.as_ref()) .and_then(|t| t.spec.as_ref()) .context("replicaset missing pod spec")?; - extract_pod_spec_containers(pod_spec) + let labels = replicaset + .spec + .as_ref() + .and_then(|s| s.template.as_ref()) + .and_then(|t| t.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let containers = extract_pod_spec_containers(pod_spec)?; + Ok(ExtractedWorkload { containers, labels }) } ResourceType::Job => { let job: Job = serde_yaml::from_str(yaml)?; @@ -443,7 +489,14 @@ fn extract_containers_from_yaml( .as_ref() .and_then(|s| s.template.spec.as_ref()) .context("job missing pod spec")?; - extract_pod_spec_containers(pod_spec) + let labels = job + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let containers = extract_pod_spec_containers(pod_spec)?; + Ok(ExtractedWorkload { containers, labels }) } ResourceType::CronJob => { let cron_job: CronJob = serde_yaml::from_str(yaml)?; @@ -453,9 +506,21 @@ fn extract_containers_from_yaml( .and_then(|s| s.job_template.spec.as_ref()) .and_then(|s| s.template.spec.as_ref()) .context("cronjob missing pod spec")?; - extract_pod_spec_containers(pod_spec) + let labels = cron_job + .spec + .as_ref() + .and_then(|s| s.job_template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let containers = extract_pod_spec_containers(pod_spec)?; + Ok(ExtractedWorkload { containers, labels }) + } + ResourceType::ConfigMap | ResourceType::Secret | ResourceType::Service => { + Ok(ExtractedWorkload { + containers: Vec::new(), + labels: BTreeMap::new(), + }) } - ResourceType::ConfigMap | ResourceType::Secret | ResourceType::Service => Ok(Vec::new()), } } @@ -563,3 +628,39 @@ fn merge_containers( Ok(merged) } + +fn ports_from_cached_services( + workload_labels: &BTreeMap, + services: &[CachedClusterService], +) -> Vec { + if services.is_empty() { + return Vec::new(); + } + + let mut ports = Vec::new(); + let mut seen = HashSet::new(); + + for service in services { + if service.selector.is_empty() { + continue; + } + + let matches = service + .selector + .iter() + .all(|(key, value)| workload_labels.get(key).map_or(false, |v| v == value)); + + if !matches { + continue; + } + + for port in &service.ports { + let key = (port.port, port.target_port, port.protocol.clone()); + if seen.insert(key) { + ports.push(port.clone()); + } + } + } + + ports +} From ff677fcd02bf0fd405b94bf78f944556ed3230d6 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 08:44:04 +0000 Subject: [PATCH 130/334] udpate --- crates/api/src/kube_controller.rs | 2 + crates/common/src/kube.rs | 2 + .../entities/src/kube_app_catalog_workload.rs | 2 + ...000002_create_kube_app_catalog_workload.rs | 7 + crates/db/src/api.rs | 11 +- crates/kube-manager/src/manager.rs | 141 ++++++++++++++++-- crates/kube-manager/src/manager_rpc.rs | 3 +- crates/kube/src/server.rs | 1 + 8 files changed, 150 insertions(+), 19 deletions(-) diff --git a/crates/api/src/kube_controller.rs b/crates/api/src/kube_controller.rs index 90d17d9..41f73b2 100644 --- a/crates/api/src/kube_controller.rs +++ b/crates/api/src/kube_controller.rs @@ -1145,6 +1145,7 @@ impl KubeController { kind: workload.kind, containers, ports: workload.ports, + workload_yaml: workload.workload_yaml.unwrap_or_default(), } }) .collect(); @@ -1343,6 +1344,7 @@ impl KubeController { kind, containers, ports: workload.ports, + workload_yaml: String::new(), } }) }) diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index debbf1b..a8ccb82 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -190,6 +190,7 @@ pub struct KubeAppCatalogWorkload { pub kind: KubeWorkloadKind, pub containers: Vec, pub ports: Vec, + pub workload_yaml: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -270,6 +271,7 @@ pub struct KubeWorkloadDetails { pub kind: KubeWorkloadKind, pub containers: Vec, pub ports: Vec, + pub workload_yaml: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/db/entities/src/kube_app_catalog_workload.rs b/crates/db/entities/src/kube_app_catalog_workload.rs index bb7b508..c4cafc1 100644 --- a/crates/db/entities/src/kube_app_catalog_workload.rs +++ b/crates/db/entities/src/kube_app_catalog_workload.rs @@ -16,6 +16,8 @@ pub struct Model { pub kind: String, pub containers: Json, pub ports: Json, + #[sea_orm(column_type = "Text")] + pub workload_yaml: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs b/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs index b406fdd..0e32c96 100644 --- a/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs +++ b/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs @@ -66,6 +66,12 @@ impl MigrationTrait for Migration { .json() .not_null(), ) + .col( + ColumnDef::new(KubeAppCatalogWorkload::WorkloadYaml) + .text() + .not_null() + .default(""), + ) .foreign_key( ForeignKey::create() .from( @@ -146,4 +152,5 @@ pub enum KubeAppCatalogWorkload { Kind, Containers, Ports, + WorkloadYaml, } diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 7366e81..98a2c2d 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -1297,6 +1297,11 @@ impl DbApi { .unwrap_or(lapdev_common::kube::KubeWorkloadKind::Deployment), containers, ports, + workload_yaml: if w.workload_yaml.is_empty() { + None + } else { + Some(w.workload_yaml.clone()) + }, }) }) .collect()) @@ -1697,8 +1702,9 @@ impl DbApi { name: ActiveValue::Set(workload.name.clone()), namespace: ActiveValue::Set(workload.namespace.clone()), kind: ActiveValue::Set(workload.kind.to_string()), - containers: ActiveValue::Set(serde_json::json!([])), - ports: ActiveValue::Set(serde_json::json!([])), + containers: ActiveValue::Set(Json::from(serde_json::json!([]))), + ports: ActiveValue::Set(Json::from(serde_json::json!([]))), + workload_yaml: ActiveValue::Set(String::new()), } .insert(txn) .await?; @@ -1735,6 +1741,7 @@ impl DbApi { kind: ActiveValue::Set(workload.kind.to_string()), containers: ActiveValue::Set(containers_json), ports: ActiveValue::Set(ports_json), + workload_yaml: ActiveValue::Set(workload.workload_yaml), } .insert(txn) .await?; diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index 8e0ae20..6e2b165 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc, time::Instant}; +use std::{collections::HashMap, future::Future, sync::Arc, time::Instant}; use anyhow::{anyhow, Result}; use base64::{engine::general_purpose::STANDARD, Engine}; @@ -29,6 +29,7 @@ use lapdev_kube_rpc::{ KubeWorkloadYamlOnly, KubeWorkloadsWithResources, TunnelStatus, }; use lapdev_rpc::spawn_twoway; +use serde::de::DeserializeOwned; use serde::Deserialize; use tarpc::server::{BaseChannel, Channel}; use tokio::time::{sleep, Duration}; @@ -1778,6 +1779,44 @@ impl KubeManager { }) } + async fn load_cached_workload( + &self, + cached_yaml: Option<&str>, + fetch_future: F, + namespace: &str, + name: &str, + kind: &'static str, + ) -> Result + where + T: DeserializeOwned, + F: Future>, + { + if let Some(yaml_str) = cached_yaml { + match serde_yaml::from_str::(yaml_str) { + Ok(obj) => return Ok(obj), + Err(err) => { + tracing::warn!( + namespace = namespace, + workload = name, + kind, + error = ?err, + "Failed to parse cached workload YAML; refetching from cluster" + ); + } + } + } + + fetch_future.await.map_err(|e| { + anyhow!( + "Failed to fetch {} {}/{} from cluster: {}", + kind, + namespace, + name, + e + ) + }) + } + async fn retrieve_single_workload_with_resources( &self, client: &kube::Client, @@ -1788,7 +1827,15 @@ impl KubeManager { KubeWorkloadKind::Deployment => { let api: kube::Api = kube::Api::namespaced((*client).clone(), &workload.namespace); - let deployment = api.get(&workload.name).await?; + let deployment = self + .load_cached_workload( + workload.workload_yaml.as_deref(), + api.get(&workload.name), + &workload.namespace, + &workload.name, + "Deployment", + ) + .await?; // Get labels for service matching let workload_labels = deployment @@ -1823,7 +1870,15 @@ impl KubeManager { KubeWorkloadKind::StatefulSet => { let api: kube::Api = kube::Api::namespaced((*client).clone(), &workload.namespace); - let statefulset = api.get(&workload.name).await?; + let statefulset = self + .load_cached_workload( + workload.workload_yaml.as_deref(), + api.get(&workload.name), + &workload.namespace, + &workload.name, + "StatefulSet", + ) + .await?; let workload_labels = statefulset .spec @@ -1855,7 +1910,15 @@ impl KubeManager { KubeWorkloadKind::DaemonSet => { let api: kube::Api = kube::Api::namespaced((*client).clone(), &workload.namespace); - let daemonset = api.get(&workload.name).await?; + let daemonset = self + .load_cached_workload( + workload.workload_yaml.as_deref(), + api.get(&workload.name), + &workload.namespace, + &workload.name, + "DaemonSet", + ) + .await?; let workload_labels = daemonset .spec @@ -1887,7 +1950,15 @@ impl KubeManager { KubeWorkloadKind::Pod => { let api: kube::Api = kube::Api::namespaced((*client).clone(), &workload.namespace); - let pod = api.get(&workload.name).await?; + let pod = self + .load_cached_workload( + workload.workload_yaml.as_deref(), + api.get(&workload.name), + &workload.namespace, + &workload.name, + "Pod", + ) + .await?; let workload_labels = pod.metadata.labels.as_ref().cloned().unwrap_or_default(); let services = @@ -1912,7 +1983,15 @@ impl KubeManager { KubeWorkloadKind::Job => { let api: kube::Api = kube::Api::namespaced((*client).clone(), &workload.namespace); - let job = api.get(&workload.name).await?; + let job = self + .load_cached_workload( + workload.workload_yaml.as_deref(), + api.get(&workload.name), + &workload.namespace, + &workload.name, + "Job", + ) + .await?; let workload_labels = job .spec @@ -1944,7 +2023,15 @@ impl KubeManager { KubeWorkloadKind::CronJob => { let api: kube::Api = kube::Api::namespaced((*client).clone(), &workload.namespace); - let cronjob = api.get(&workload.name).await?; + let cronjob = self + .load_cached_workload( + workload.workload_yaml.as_deref(), + api.get(&workload.name), + &workload.namespace, + &workload.name, + "CronJob", + ) + .await?; let workload_labels = cronjob .spec @@ -1977,7 +2064,15 @@ impl KubeManager { KubeWorkloadKind::ReplicaSet => { let api: kube::Api = kube::Api::namespaced((*client).clone(), &workload.namespace); - let replicaset = api.get(&workload.name).await?; + let replicaset = self + .load_cached_workload( + workload.workload_yaml.as_deref(), + api.get(&workload.name), + &workload.namespace, + &workload.name, + "ReplicaSet", + ) + .await?; let workload_labels = replicaset .spec @@ -3257,7 +3352,7 @@ impl KubeManager { namespace: &str, kind: &KubeWorkloadKind, all_services: &[Service], - ) -> Result<(Vec, Vec)> { + ) -> Result<(Vec, Vec, String)> { let client = &self.kube_client; match kind { @@ -3279,7 +3374,9 @@ impl KubeManager { if let Some(spec) = &deployment.spec { if let Some(pod_spec) = &spec.template.spec { let containers = self.extract_pod_resource_info(pod_spec)?; - return Ok((containers, ports)); + let clean = self.clean_deployment(deployment, &containers); + let workload_yaml = serde_yaml::to_string(&clean)?; + return Ok((containers, ports, workload_yaml)); } } } @@ -3302,7 +3399,9 @@ impl KubeManager { if let Some(spec) = &statefulset.spec { if let Some(pod_spec) = &spec.template.spec { let containers = self.extract_pod_resource_info(pod_spec)?; - return Ok((containers, ports)); + let clean = self.clean_statefulset(statefulset, &containers); + let workload_yaml = serde_yaml::to_string(&clean)?; + return Ok((containers, ports, workload_yaml)); } } } @@ -3325,7 +3424,9 @@ impl KubeManager { if let Some(spec) = &daemonset.spec { if let Some(pod_spec) = &spec.template.spec { let containers = self.extract_pod_resource_info(pod_spec)?; - return Ok((containers, ports)); + let clean = self.clean_daemonset(daemonset, &containers); + let workload_yaml = serde_yaml::to_string(&clean)?; + return Ok((containers, ports, workload_yaml)); } } } @@ -3340,7 +3441,9 @@ impl KubeManager { if let Some(spec) = &pod.spec { let containers = self.extract_pod_resource_info(spec)?; - return Ok((containers, ports)); + let clean = self.clean_pod(pod, &containers); + let workload_yaml = serde_yaml::to_string(&clean)?; + return Ok((containers, ports, workload_yaml)); } } } @@ -3361,7 +3464,9 @@ impl KubeManager { if let Some(spec) = &job.spec { if let Some(pod_spec) = &spec.template.spec { let containers = self.extract_pod_resource_info(pod_spec)?; - return Ok((containers, ports)); + let clean = self.clean_job(job, &containers); + let workload_yaml = serde_yaml::to_string(&clean)?; + return Ok((containers, ports, workload_yaml)); } } } @@ -3385,7 +3490,9 @@ impl KubeManager { if let Some(job_template) = &spec.job_template.spec { if let Some(pod_spec) = &job_template.template.spec { let containers = self.extract_pod_resource_info(pod_spec)?; - return Ok((containers, ports)); + let clean = self.clean_cronjob(cronjob, &containers); + let workload_yaml = serde_yaml::to_string(&clean)?; + return Ok((containers, ports, workload_yaml)); } } } @@ -3394,7 +3501,7 @@ impl KubeManager { _ => {} } - Ok((Vec::new(), Vec::new())) + Ok((Vec::new(), Vec::new(), String::new())) } fn extract_pod_resource_info( @@ -3517,6 +3624,7 @@ impl KubeManager { kind, containers, ports: Vec::new(), // Ports will be fetched from services during YAML retrieval + workload_yaml: None, }; // Step 2: Get the current workload YAML with all its resources @@ -3600,6 +3708,7 @@ impl KubeManager { kind, containers: containers.clone(), // Use the customized containers for the branch ports: Vec::new(), // Ports will be fetched from services during YAML retrieval + workload_yaml: None, }; // Step 2: Get the base workload YAML with all its resources diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index f9ba5d4..13ee5f2 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -244,13 +244,14 @@ impl KubeManagerRpc for KubeManagerRpcServer { ) .await { - Ok((containers, ports)) => { + Ok((containers, ports, workload_yaml)) => { details.push(lapdev_common::kube::KubeWorkloadDetails { name: workload.name, namespace: workload.namespace, kind: workload.kind, containers, ports, + workload_yaml, }); } Err(e) => { diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index 4e19bdf..1faf733 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -297,6 +297,7 @@ impl KubeClusterServer { id: ActiveValue::Set(workload.id), containers: ActiveValue::Set(Json::from(containers_json)), ports: ActiveValue::Set(Json::from(ports_json)), + workload_yaml: ActiveValue::Set(yaml.clone()), ..Default::default() }; From 9ab68cba04ef7a5e097fd4c8aec6cb3f696b25a3 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 09:11:22 +0000 Subject: [PATCH 131/334] update --- Cargo.lock | 2 + crates/api/Cargo.toml | 2 + crates/api/src/kube_controller.rs | 306 ++++++++++++++++++++++++- crates/kube-manager/src/manager.rs | 50 ++++ crates/kube-manager/src/manager_rpc.rs | 39 +++- crates/kube-rpc/src/lib.rs | 12 + 6 files changed, 397 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e5f672..a343fd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3160,6 +3160,7 @@ dependencies = [ "hyper-util", "include_dir", "itertools 0.12.1", + "k8s-openapi", "lapdev-api-hrpc", "lapdev-common", "lapdev-conductor", @@ -3184,6 +3185,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "serde_yaml", "sqlx", "tarpc", "tokio", diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index a25e11a..f96f7c3 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -37,6 +37,7 @@ futures.workspace = true futures-util.workspace = true serde.workspace = true serde_json.workspace = true +serde_yaml.workspace = true tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true @@ -48,6 +49,7 @@ lapdev-rpc.workspace = true lapdev-kube-rpc.workspace = true lapdev-devbox-rpc.workspace = true lapdev-common.workspace = true +k8s-openapi.workspace = true lapdev-api-hrpc.workspace = true lapdev-db.workspace = true lapdev-db-entities.workspace = true diff --git a/crates/api/src/kube_controller.rs b/crates/api/src/kube_controller.rs index 41f73b2..bf65dcf 100644 --- a/crates/api/src/kube_controller.rs +++ b/crates/api/src/kube_controller.rs @@ -1,24 +1,37 @@ -use std::{collections::HashMap, str::FromStr, sync::Arc}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + str::FromStr, + sync::Arc, +}; use tokio::sync::RwLock; use uuid::Uuid; -use anyhow::Result; +use anyhow::{anyhow, Result}; +use k8s_openapi::api::core::v1::PodSpec; +use k8s_openapi::api::{ + apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}, + batch::v1::{CronJob, Job}, + core::v1::Pod, +}; use lapdev_common::{ kube::{ CreateKubeClusterResponse, KubeAppCatalog, KubeAppCatalogWorkload, KubeAppCatalogWorkloadCreate, KubeCluster, KubeClusterInfo, KubeClusterStatus, - KubeContainerImage, KubeEnvironment, KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, - KubeWorkloadList, PagePaginationParams, PaginatedInfo, PaginatedResult, PaginationParams, + KubeContainerImage, KubeContainerInfo, KubeContainerPort, KubeEnvironment, + KubeNamespaceInfo, KubeServicePort, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, + PagePaginationParams, PaginatedInfo, PaginatedResult, PaginationParams, }, token::PlainToken, utils::rand_string, }; -use lapdev_db::api::DbApi; +use lapdev_db::api::{CachedClusterService, DbApi}; use lapdev_kube::server::KubeClusterServer; use lapdev_kube::tunnel::TunnelRegistry; +use lapdev_kube_rpc::KubeRawWorkloadYaml; use lapdev_rpc::error::ApiError; use sea_orm::TransactionTrait; use secrecy::ExposeSecret; +use serde_yaml; enum EnvironmentNamespaceKind { Personal, @@ -463,16 +476,45 @@ impl KubeController { }) .collect(); - // Get detailed information from KubeManager - let workload_details = cluster_server + let raw_workloads = cluster_server .rpc_client - .get_workloads_details(tarpc::context::current(), workload_identifiers) + .get_workloads_raw_yaml(tarpc::context::current(), workload_identifiers) .await - .map_err(|e| { - ApiError::InvalidRequest(format!("Failed to get workload details: {}", e)) - })?; + .map_err(|e| ApiError::InvalidRequest(format!("Failed to get workload YAML: {}", e)))? + .map_err(|e| ApiError::InvalidRequest(format!("KubeManager error: {}", e)))?; + + let mut namespaces: HashSet = + raw_workloads.iter().map(|w| w.namespace.clone()).collect(); + namespaces.extend(workloads.iter().map(|w| w.namespace.clone())); + + let mut services_cache: HashMap> = HashMap::new(); + for namespace in namespaces { + let services = self + .db + .get_active_cluster_services(cluster_id, &namespace) + .await + .map_err(ApiError::from)?; + services_cache.insert(namespace, services); + } + + let mut results = Vec::with_capacity(raw_workloads.len()); + for raw in raw_workloads { + let services = services_cache + .get(&raw.namespace) + .map(|s| s.as_slice()) + .unwrap_or(&[]); + match build_workload_details_from_yaml(raw, services) { + Ok(details) => results.push(details), + Err(err) => { + return Err(ApiError::InvalidRequest(format!( + "Failed to process workload YAML: {}", + err + ))) + } + } + } - workload_details.map_err(|e| ApiError::InvalidRequest(format!("KubeManager error: {}", e))) + Ok(results) } pub async fn create_app_catalog( @@ -2313,6 +2355,246 @@ impl KubeController { } } +fn build_workload_details_from_yaml( + raw: KubeRawWorkloadYaml, + services: &[CachedClusterService], +) -> anyhow::Result { + let KubeRawWorkloadYaml { + name, + namespace, + kind, + workload_yaml, + } = raw; + + let (containers, labels) = extract_containers_and_labels(&kind, &workload_yaml)?; + let ports = ports_from_cached_services(&labels, services); + + Ok(lapdev_common::kube::KubeWorkloadDetails { + name, + namespace, + kind, + containers, + ports, + workload_yaml, + }) +} + +fn extract_containers_and_labels( + kind: &KubeWorkloadKind, + workload_yaml: &str, +) -> anyhow::Result<(Vec, BTreeMap)> { + match kind { + KubeWorkloadKind::Deployment => { + let deployment: Deployment = serde_yaml::from_str(workload_yaml)?; + let labels = deployment + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = deployment + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .ok_or_else(|| anyhow!("Deployment missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::StatefulSet => { + let statefulset: StatefulSet = serde_yaml::from_str(workload_yaml)?; + let labels = statefulset + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = statefulset + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .ok_or_else(|| anyhow!("StatefulSet missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::DaemonSet => { + let daemonset: DaemonSet = serde_yaml::from_str(workload_yaml)?; + let labels = daemonset + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = daemonset + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .ok_or_else(|| anyhow!("DaemonSet missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::ReplicaSet => { + let replicaset: ReplicaSet = serde_yaml::from_str(workload_yaml)?; + let labels = replicaset + .spec + .as_ref() + .and_then(|s| s.template.as_ref()) + .and_then(|t| t.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = replicaset + .spec + .as_ref() + .and_then(|s| s.template.as_ref()) + .and_then(|t| t.spec.as_ref()) + .ok_or_else(|| anyhow!("ReplicaSet missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::Pod => { + let pod: Pod = serde_yaml::from_str(workload_yaml)?; + let labels = pod.metadata.labels.clone().unwrap_or_default(); + let pod_spec = pod + .spec + .as_ref() + .ok_or_else(|| anyhow!("Pod missing spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::Job => { + let job: Job = serde_yaml::from_str(workload_yaml)?; + let labels = job + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = job + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .ok_or_else(|| anyhow!("Job missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::CronJob => { + let cronjob: CronJob = serde_yaml::from_str(workload_yaml)?; + let labels = cronjob + .spec + .as_ref() + .and_then(|s| s.job_template.spec.as_ref()) + .and_then(|js| js.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = cronjob + .spec + .as_ref() + .and_then(|s| s.job_template.spec.as_ref()) + .and_then(|js| js.template.spec.as_ref()) + .ok_or_else(|| anyhow!("CronJob missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + } +} + +fn extract_pod_spec_containers(pod_spec: &PodSpec) -> anyhow::Result> { + pod_spec + .containers + .iter() + .map(|container| { + let mut cpu_request = None; + let mut cpu_limit = None; + let mut memory_request = None; + let mut memory_limit = None; + + if let Some(resources) = &container.resources { + if let Some(requests) = &resources.requests { + if let Some(cpu_req) = requests.get("cpu") { + cpu_request = Some(cpu_req.0.clone()); + } + if let Some(memory_req) = requests.get("memory") { + memory_request = Some(memory_req.0.clone()); + } + } + + if let Some(limits) = &resources.limits { + if let Some(cpu_lim) = limits.get("cpu") { + cpu_limit = Some(cpu_lim.0.clone()); + } + if let Some(memory_lim) = limits.get("memory") { + memory_limit = Some(memory_lim.0.clone()); + } + } + } + + let image = container + .image + .clone() + .ok_or_else(|| anyhow!("Container '{}' has no image specified", container.name))?; + + let ports = container + .ports + .as_ref() + .map(|ports| { + ports + .iter() + .map(|port| KubeContainerPort { + name: port.name.clone(), + container_port: port.container_port, + protocol: port.protocol.clone(), + }) + .collect::>() + }) + .unwrap_or_default(); + + Ok(KubeContainerInfo { + name: container.name.clone(), + original_image: image.clone(), + image: KubeContainerImage::FollowOriginal, + cpu_request, + cpu_limit, + memory_request, + memory_limit, + env_vars: Vec::new(), + original_env_vars: Vec::new(), + ports, + }) + }) + .collect() +} + +fn ports_from_cached_services( + workload_labels: &BTreeMap, + services: &[CachedClusterService], +) -> Vec { + let mut seen = HashSet::new(); + let mut ports = Vec::new(); + + for service in services { + if service.selector.is_empty() { + continue; + } + + let matches = service + .selector + .iter() + .all(|(key, value)| workload_labels.get(key).map_or(false, |v| v == value)); + + if !matches { + continue; + } + + for port in &service.ports { + let key = (port.port, port.target_port, port.protocol.clone()); + if seen.insert(key) { + ports.push(port.clone()); + } + } + } + + ports +} + #[cfg(test)] mod tests { use super::KubeController; diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index 6e2b165..c1ffcfd 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -3504,6 +3504,56 @@ impl KubeManager { Ok((Vec::new(), Vec::new(), String::new())) } + pub(crate) async fn get_raw_workload_yaml( + &self, + name: &str, + namespace: &str, + kind: &KubeWorkloadKind, + ) -> Result { + let client = &self.kube_client; + match kind { + KubeWorkloadKind::Deployment => { + let api: kube::Api = + kube::Api::namespaced((**client).clone(), namespace); + let deployment = api.get(name).await?; + Ok(serde_yaml::to_string(&deployment)?) + } + KubeWorkloadKind::StatefulSet => { + let api: kube::Api = + kube::Api::namespaced((**client).clone(), namespace); + let statefulset = api.get(name).await?; + Ok(serde_yaml::to_string(&statefulset)?) + } + KubeWorkloadKind::DaemonSet => { + let api: kube::Api = + kube::Api::namespaced((**client).clone(), namespace); + let daemonset = api.get(name).await?; + Ok(serde_yaml::to_string(&daemonset)?) + } + KubeWorkloadKind::ReplicaSet => { + let api: kube::Api = + kube::Api::namespaced((**client).clone(), namespace); + let replicaset = api.get(name).await?; + Ok(serde_yaml::to_string(&replicaset)?) + } + KubeWorkloadKind::Pod => { + let api: kube::Api = kube::Api::namespaced((**client).clone(), namespace); + let pod = api.get(name).await?; + Ok(serde_yaml::to_string(&pod)?) + } + KubeWorkloadKind::Job => { + let api: kube::Api = kube::Api::namespaced((**client).clone(), namespace); + let job = api.get(name).await?; + Ok(serde_yaml::to_string(&job)?) + } + KubeWorkloadKind::CronJob => { + let api: kube::Api = kube::Api::namespaced((**client).clone(), namespace); + let cronjob = api.get(name).await?; + Ok(serde_yaml::to_string(&cronjob)?) + } + } + } + fn extract_pod_resource_info( &self, pod_spec: &k8s_openapi::api::core::v1::PodSpec, diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index 13ee5f2..3c4713a 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -4,8 +4,8 @@ use lapdev_common::kube::{ PaginationParams, }; use lapdev_kube_rpc::{ - KubeClusterRpcClient, KubeManagerRpc, KubeWorkloadsWithResources, TunnelStatus, - WorkloadIdentifier, + KubeClusterRpcClient, KubeManagerRpc, KubeRawWorkloadYaml, KubeWorkloadsWithResources, + TunnelStatus, WorkloadIdentifier, }; use uuid::Uuid; @@ -276,6 +276,41 @@ impl KubeManagerRpc for KubeManagerRpcServer { Ok(details) } + async fn get_workloads_raw_yaml( + self, + _context: ::tarpc::context::Context, + workloads: Vec, + ) -> Result, String> { + let mut result = Vec::with_capacity(workloads.len()); + + for workload in workloads { + match self + .manager + .get_raw_workload_yaml(&workload.name, &workload.namespace, &workload.kind) + .await + { + Ok(yaml) => { + result.push(KubeRawWorkloadYaml { + name: workload.name, + namespace: workload.namespace, + kind: workload.kind, + workload_yaml: yaml, + }); + } + Err(e) => { + let msg = format!( + "Failed to fetch raw YAML for {}/{}: {e}", + workload.namespace, workload.name + ); + tracing::error!("{}", msg); + return Err(msg); + } + } + } + + Ok(result) + } + async fn update_workload_containers( self, _context: ::tarpc::context::Context, diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index 50953db..c0de845 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -341,6 +341,14 @@ pub struct KubeWorkloadsWithResources { pub secrets: HashMap, // name -> YAML content } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KubeRawWorkloadYaml { + pub name: String, + pub namespace: String, + pub kind: KubeWorkloadKind, + pub workload_yaml: String, +} + #[tarpc::service] pub trait KubeClusterRpc { async fn report_cluster_info(cluster_info: KubeClusterInfo) -> Result<(), String>; @@ -394,6 +402,10 @@ pub trait KubeManagerRpc { workloads: Vec, ) -> Result, String>; + async fn get_workloads_raw_yaml( + workloads: Vec, + ) -> Result, String>; + async fn configure_watches(namespaces: Vec) -> Result<(), String>; async fn update_workload_containers( From 60e76d342eeef0c6783c943bd43f16c9b9b1bf4b Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 09:46:37 +0000 Subject: [PATCH 132/334] update --- crates/api/src/kube_controller.rs | 2655 ----------------- crates/api/src/kube_controller/app_catalog.rs | 363 +++ crates/api/src/kube_controller/cluster.rs | 389 +++ crates/api/src/kube_controller/deployment.rs | 111 + crates/api/src/kube_controller/environment.rs | 777 +++++ crates/api/src/kube_controller/mod.rs | 59 + crates/api/src/kube_controller/preview_url.rs | 281 ++ crates/api/src/kube_controller/service.rs | 37 + crates/api/src/kube_controller/validation.rs | 216 ++ crates/api/src/kube_controller/workload.rs | 246 ++ crates/api/src/kube_controller/yaml_parser.rs | 254 ++ 11 files changed, 2733 insertions(+), 2655 deletions(-) delete mode 100644 crates/api/src/kube_controller.rs create mode 100644 crates/api/src/kube_controller/app_catalog.rs create mode 100644 crates/api/src/kube_controller/cluster.rs create mode 100644 crates/api/src/kube_controller/deployment.rs create mode 100644 crates/api/src/kube_controller/environment.rs create mode 100644 crates/api/src/kube_controller/mod.rs create mode 100644 crates/api/src/kube_controller/preview_url.rs create mode 100644 crates/api/src/kube_controller/service.rs create mode 100644 crates/api/src/kube_controller/validation.rs create mode 100644 crates/api/src/kube_controller/workload.rs create mode 100644 crates/api/src/kube_controller/yaml_parser.rs diff --git a/crates/api/src/kube_controller.rs b/crates/api/src/kube_controller.rs deleted file mode 100644 index bf65dcf..0000000 --- a/crates/api/src/kube_controller.rs +++ /dev/null @@ -1,2655 +0,0 @@ -use std::{ - collections::{BTreeMap, HashMap, HashSet}, - str::FromStr, - sync::Arc, -}; -use tokio::sync::RwLock; -use uuid::Uuid; - -use anyhow::{anyhow, Result}; -use k8s_openapi::api::core::v1::PodSpec; -use k8s_openapi::api::{ - apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}, - batch::v1::{CronJob, Job}, - core::v1::Pod, -}; -use lapdev_common::{ - kube::{ - CreateKubeClusterResponse, KubeAppCatalog, KubeAppCatalogWorkload, - KubeAppCatalogWorkloadCreate, KubeCluster, KubeClusterInfo, KubeClusterStatus, - KubeContainerImage, KubeContainerInfo, KubeContainerPort, KubeEnvironment, - KubeNamespaceInfo, KubeServicePort, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, - PagePaginationParams, PaginatedInfo, PaginatedResult, PaginationParams, - }, - token::PlainToken, - utils::rand_string, -}; -use lapdev_db::api::{CachedClusterService, DbApi}; -use lapdev_kube::server::KubeClusterServer; -use lapdev_kube::tunnel::TunnelRegistry; -use lapdev_kube_rpc::KubeRawWorkloadYaml; -use lapdev_rpc::error::ApiError; -use sea_orm::TransactionTrait; -use secrecy::ExposeSecret; -use serde_yaml; - -enum EnvironmentNamespaceKind { - Personal, - Shared, - Branch, -} - -#[derive(Clone)] -pub struct KubeController { - // KubeManager connections per cluster - pub kube_cluster_servers: Arc>>>, - // Tunnel registry for preview URL functionality - pub tunnel_registry: Arc, - // Database API - pub db: DbApi, -} - -impl KubeController { - pub fn new(db: DbApi) -> Self { - Self { - kube_cluster_servers: Arc::new(RwLock::new(HashMap::new())), - tunnel_registry: Arc::new(TunnelRegistry::new()), - db, - } - } - - pub async fn get_random_kube_cluster_server( - &self, - cluster_id: Uuid, - ) -> Option { - let servers = self.kube_cluster_servers.read().await; - servers.get(&cluster_id)?.last().cloned() - } - - async fn generate_unique_namespace( - &self, - _cluster_id: Uuid, - kind: EnvironmentNamespaceKind, - ) -> Result { - let prefix = match kind { - EnvironmentNamespaceKind::Personal => "lapdev-personal", - EnvironmentNamespaceKind::Shared => "lapdev-shared", - EnvironmentNamespaceKind::Branch => "lapdev-branch", - }; - - Ok(format!("{prefix}-{}", rand_string(12))) - } - - pub async fn get_all_kube_clusters(&self, org_id: Uuid) -> Result, ApiError> { - let clusters = self - .db - .get_all_kube_clusters(org_id) - .await - .map_err(ApiError::from)? - .into_iter() - .map(|c| KubeCluster { - id: c.id, - name: c.name.clone(), - can_deploy_personal: c.can_deploy_personal, - can_deploy_shared: c.can_deploy_shared, - info: KubeClusterInfo { - cluster_name: Some(c.name), - cluster_version: c.cluster_version.unwrap_or("Unknown".to_string()), - node_count: 0, // TODO: Get actual node count from kube-manager - available_cpu: "N/A".to_string(), // TODO: Get actual CPU from kube-manager - available_memory: "N/A".to_string(), // TODO: Get actual memory from kube-manager - provider: None, // TODO: Get provider info - region: c.region, - status: KubeClusterStatus::from_str(&c.status) - .unwrap_or(KubeClusterStatus::NotReady), - }, - }) - .collect(); - Ok(clusters) - } - - pub async fn create_kube_cluster( - &self, - org_id: Uuid, - user_id: Uuid, - name: String, - ) -> Result { - // Generate cluster ID and token - let cluster_id = Uuid::new_v4(); - let token = PlainToken::generate(); - let hashed_token = token.hashed(); - let token_name = format!("{name}-default"); - - // Create the cluster - self.db - .create_kube_cluster( - cluster_id, - org_id, - user_id, - name, - KubeClusterStatus::Provisioning.to_string(), - ) - .await - .map_err(ApiError::from)?; - - // Create the cluster token - self.db - .create_kube_cluster_token( - cluster_id, - user_id, - token_name, - hashed_token.expose_secret().to_vec(), - ) - .await - .map_err(ApiError::from)?; - - Ok(CreateKubeClusterResponse { - cluster_id, - token: token.expose_secret().to_string(), - }) - } - - pub async fn delete_kube_cluster( - &self, - org_id: Uuid, - cluster_id: Uuid, - ) -> Result<(), ApiError> { - // Verify cluster belongs to the organization - let cluster = self - .db - .get_kube_cluster(cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - if cluster.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Check for dependencies - kube_app_catalog - let has_app_catalogs = self - .db - .check_kube_cluster_has_app_catalogs(cluster_id) - .await - .map_err(ApiError::from)?; - - if has_app_catalogs { - return Err(ApiError::InvalidRequest( - "Cannot delete cluster: it has active app catalogs. Please delete them first." - .to_string(), - )); - } - - // Check for dependencies - kube_environment - let has_environments = self - .db - .check_kube_cluster_has_environments(cluster_id) - .await - .map_err(ApiError::from)?; - - if has_environments { - return Err(ApiError::InvalidRequest( - "Cannot delete cluster: it has active environments. Please delete them first." - .to_string(), - )); - } - - // Soft delete the cluster - self.db - .delete_kube_cluster(cluster_id) - .await - .map_err(ApiError::from) - } - - pub async fn set_cluster_deployable( - &self, - org_id: Uuid, - cluster_id: Uuid, - can_deploy_personal: bool, - can_deploy_shared: bool, - ) -> Result<(), ApiError> { - // Verify cluster belongs to the organization - let cluster = self - .db - .get_kube_cluster(cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - if cluster.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Update the deployment capability fields - self.db - .set_cluster_deployable(cluster_id, can_deploy_personal, can_deploy_shared) - .await - .map_err(ApiError::from)?; - - Ok(()) - } - - pub async fn set_cluster_personal_deployable( - &self, - org_id: Uuid, - cluster_id: Uuid, - can_deploy: bool, - ) -> Result<(), ApiError> { - // Verify cluster belongs to the organization - let cluster = self - .db - .get_kube_cluster(cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - if cluster.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Update only the personal deployment capability - self.db - .set_cluster_deployable(cluster_id, can_deploy, cluster.can_deploy_shared) - .await - .map_err(ApiError::from)?; - - Ok(()) - } - - pub async fn set_cluster_shared_deployable( - &self, - org_id: Uuid, - cluster_id: Uuid, - can_deploy: bool, - ) -> Result<(), ApiError> { - // Verify cluster belongs to the organization - let cluster = self - .db - .get_kube_cluster(cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - if cluster.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Update only the shared deployment capability - self.db - .set_cluster_deployable(cluster_id, cluster.can_deploy_personal, can_deploy) - .await - .map_err(ApiError::from)?; - - Ok(()) - } - - pub async fn get_workloads( - &self, - org_id: Uuid, - cluster_id: Uuid, - namespace: Option, - workload_kind_filter: Option, - include_system_workloads: bool, - pagination: Option, - ) -> Result { - // Verify cluster belongs to the organization - let cluster = self - .db - .get_kube_cluster(cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - if cluster.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Get a connected KubeClusterServer for this cluster - let server = self - .get_random_kube_cluster_server(cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) - })?; - - let pagination = pagination.unwrap_or(PaginationParams { - cursor: None, - limit: 20, - }); - - // Call KubeManager to get workloads - match server - .rpc_client - .get_workloads( - tarpc::context::current(), - namespace, - workload_kind_filter, - include_system_workloads, - Some(pagination), - ) - .await - { - Ok(Ok(workload_list)) => Ok(workload_list), - Ok(Err(e)) => Err(ApiError::InvalidRequest(format!( - "KubeManager error: {}", - e - ))), - Err(e) => Err(ApiError::InvalidRequest(format!("Connection error: {}", e))), - } - } - - pub async fn get_workload_details( - &self, - org_id: Uuid, - cluster_id: Uuid, - name: String, - namespace: String, - ) -> Result, ApiError> { - // Verify cluster belongs to the organization - let cluster = self - .db - .get_kube_cluster(cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - if cluster.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Get a connected KubeClusterServer for this cluster - let server = self - .get_random_kube_cluster_server(cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) - })?; - - // Call KubeManager to get workload details - match server - .rpc_client - .get_workload_details(tarpc::context::current(), name, namespace) - .await - { - Ok(Ok(workload_details)) => Ok(workload_details), - Ok(Err(e)) => Err(ApiError::InvalidRequest(format!( - "KubeManager error: {}", - e - ))), - Err(e) => Err(ApiError::InvalidRequest(format!("Connection error: {}", e))), - } - } - - pub async fn get_cluster_namespaces( - &self, - org_id: Uuid, - cluster_id: Uuid, - ) -> Result, ApiError> { - // Verify cluster belongs to the organization - let cluster = self - .db - .get_kube_cluster(cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - if cluster.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Get a connected KubeClusterServer for this cluster - let server = self - .get_random_kube_cluster_server(cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) - })?; - - // Call KubeManager to get namespaces - match server - .rpc_client - .get_namespaces(tarpc::context::current()) - .await - { - Ok(Ok(namespaces)) => Ok(namespaces), - Ok(Err(e)) => Err(ApiError::InvalidRequest(format!( - "KubeManager error: {}", - e - ))), - Err(e) => Err(ApiError::InvalidRequest(format!("Connection error: {}", e))), - } - } - - pub async fn get_cluster_info( - &self, - org_id: Uuid, - cluster_id: Uuid, - ) -> Result { - // Get cluster from database - let cluster = self - .db - .get_kube_cluster(cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - if cluster.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Convert database cluster to KubeClusterInfo - let cluster_info = KubeClusterInfo { - cluster_name: Some(cluster.name), - cluster_version: cluster.cluster_version.unwrap_or("Unknown".to_string()), - node_count: 0, // TODO: Get actual node count from kube-manager - available_cpu: "N/A".to_string(), // TODO: Get actual CPU from kube-manager - available_memory: "N/A".to_string(), // TODO: Get actual memory from kube-manager - provider: None, // TODO: Get provider info - region: cluster.region, - status: KubeClusterStatus::from_str(&cluster.status) - .unwrap_or(KubeClusterStatus::NotReady), - }; - - Ok(cluster_info) - } - - async fn enrich_workloads_with_details( - &self, - cluster_id: Uuid, - workloads: Vec, - ) -> Result, ApiError> { - // Get cluster connection - let cluster_server = self - .get_random_kube_cluster_server(cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) - })?; - - // Convert to WorkloadIdentifier for RPC call - let workload_identifiers: Vec = workloads - .iter() - .map(|w| lapdev_kube_rpc::WorkloadIdentifier { - name: w.name.clone(), - namespace: w.namespace.clone(), - kind: w.kind.clone(), - }) - .collect(); - - let raw_workloads = cluster_server - .rpc_client - .get_workloads_raw_yaml(tarpc::context::current(), workload_identifiers) - .await - .map_err(|e| ApiError::InvalidRequest(format!("Failed to get workload YAML: {}", e)))? - .map_err(|e| ApiError::InvalidRequest(format!("KubeManager error: {}", e)))?; - - let mut namespaces: HashSet = - raw_workloads.iter().map(|w| w.namespace.clone()).collect(); - namespaces.extend(workloads.iter().map(|w| w.namespace.clone())); - - let mut services_cache: HashMap> = HashMap::new(); - for namespace in namespaces { - let services = self - .db - .get_active_cluster_services(cluster_id, &namespace) - .await - .map_err(ApiError::from)?; - services_cache.insert(namespace, services); - } - - let mut results = Vec::with_capacity(raw_workloads.len()); - for raw in raw_workloads { - let services = services_cache - .get(&raw.namespace) - .map(|s| s.as_slice()) - .unwrap_or(&[]); - match build_workload_details_from_yaml(raw, services) { - Ok(details) => results.push(details), - Err(err) => { - return Err(ApiError::InvalidRequest(format!( - "Failed to process workload YAML: {}", - err - ))) - } - } - } - - Ok(results) - } - - pub async fn create_app_catalog( - &self, - org_id: Uuid, - user_id: Uuid, - cluster_id: Uuid, - name: String, - description: Option, - workloads: Vec, - ) -> Result { - // Get enriched workload details from KubeManager - let enriched_workloads = self - .enrich_workloads_with_details(cluster_id, workloads) - .await?; - - self.db - .create_app_catalog_with_enriched_workloads( - org_id, - user_id, - cluster_id, - name, - description, - enriched_workloads, - ) - .await - .map_err(ApiError::from) - } - - pub async fn get_all_app_catalogs( - &self, - org_id: Uuid, - search: Option, - pagination: Option, - ) -> Result, ApiError> { - let pagination = pagination.unwrap_or_default(); - - let (catalogs_with_clusters, total_count) = self - .db - .get_all_app_catalogs_paginated(org_id, search, Some(pagination.clone())) - .await - .map_err(ApiError::from)?; - - let app_catalogs = catalogs_with_clusters - .into_iter() - .filter_map(|(catalog, cluster)| { - let cluster = cluster?; - Some(KubeAppCatalog { - id: catalog.id, - name: catalog.name, - description: catalog.description, - created_at: catalog.created_at, - created_by: catalog.created_by, - cluster_id: catalog.cluster_id, - cluster_name: cluster.name, - }) - }) - .collect(); - - let total_pages = (total_count + pagination.page_size - 1) / pagination.page_size; - - Ok(PaginatedResult { - data: app_catalogs, - pagination_info: PaginatedInfo { - total_count, - page: pagination.page, - page_size: pagination.page_size, - total_pages, - }, - }) - } - - pub async fn get_all_kube_environments( - &self, - org_id: Uuid, - user_id: Uuid, - search: Option, - is_shared: bool, - is_branch: bool, - pagination: Option, - ) -> Result, ApiError> { - let pagination = pagination.unwrap_or_default(); - - let (environments_with_catalogs_and_clusters, total_count) = self - .db - .get_all_kube_environments_paginated( - org_id, - user_id, - search, - is_shared, - is_branch, - Some(pagination.clone()), - ) - .await - .map_err(ApiError::from)?; - - let kube_environments = environments_with_catalogs_and_clusters - .into_iter() - .filter_map(|(env, catalog, cluster, base_environment_name)| { - let catalog = catalog?; - let cluster = cluster?; - Some(KubeEnvironment { - id: env.id, - name: env.name, - namespace: env.namespace, - app_catalog_id: env.app_catalog_id, - app_catalog_name: catalog.name, - cluster_id: env.cluster_id, - cluster_name: cluster.name, - status: env.status, - created_at: env.created_at.to_string(), - user_id: env.user_id, - is_shared: env.is_shared, - base_environment_id: env.base_environment_id, - base_environment_name, - }) - }) - .collect(); - - let total_pages = (total_count + pagination.page_size - 1) / pagination.page_size; - - Ok(PaginatedResult { - data: kube_environments, - pagination_info: PaginatedInfo { - total_count, - page: pagination.page, - page_size: pagination.page_size, - total_pages, - }, - }) - } - - pub async fn get_kube_environment( - &self, - org_id: Uuid, - user_id: Uuid, - environment_id: Uuid, - ) -> Result { - // Get the environment from database - let environment = self - .db - .get_kube_environment(environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; - - // Check authorization - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // If it's a personal environment, check ownership - if !environment.is_shared && environment.user_id != user_id { - return Err(ApiError::Unauthorized); - } - - // Get related catalog and cluster info - let catalog = self - .db - .get_app_catalog(environment.app_catalog_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - - let cluster = self - .db - .get_kube_cluster(environment.cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - // Get base environment name if this is a branch environment - let base_environment_name = if let Some(base_env_id) = environment.base_environment_id { - self.db - .get_kube_environment(base_env_id) - .await - .map_err(ApiError::from)? - .map(|base_env| base_env.name) - } else { - None - }; - - Ok(KubeEnvironment { - id: environment.id, - user_id: environment.user_id, - name: environment.name, - namespace: environment.namespace, - app_catalog_id: environment.app_catalog_id, - app_catalog_name: catalog.name, - cluster_id: environment.cluster_id, - cluster_name: cluster.name, - status: environment.status, - created_at: environment - .created_at - .format("%Y-%m-%d %H:%M:%S%.f %z") - .to_string(), - is_shared: environment.is_shared, - base_environment_id: environment.base_environment_id, - base_environment_name, - }) - } - - pub async fn delete_kube_environment( - &self, - org_id: Uuid, - user_id: Uuid, - environment_id: Uuid, - ) -> Result<(), ApiError> { - // First get the environment to check ownership - let environment = self - .db - .get_kube_environment(environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; - - // Check authorization - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // If it's a personal environment, check ownership - if !environment.is_shared && environment.user_id != user_id { - return Err(ApiError::Unauthorized); - } - - // If it's a shared environment, check for depending branch environments - if environment.is_shared { - let has_branches = self - .db - .check_environment_has_branches(environment_id) - .await - .map_err(ApiError::from)?; - - if has_branches { - return Err(ApiError::InvalidRequest( - "Cannot delete shared environment: it has active branch environments. Please delete them first.".to_string() - )); - } - } - - let rpc_client = self - .get_random_kube_cluster_server(environment.cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest( - "No connected KubeManager for this cluster; cannot delete environment" - .to_string(), - ) - })? - .rpc_client - .clone(); - - // If this is a branch environment, notify the base environment's devbox-proxy before deletion - if let Some(base_env_id) = environment.base_environment_id { - match rpc_client - .remove_branch_environment(tarpc::context::current(), base_env_id, environment_id) - .await - { - Ok(Ok(())) => { - tracing::info!( - "Successfully notified devbox-proxy about branch environment {} deletion", - environment_id - ); - } - Ok(Err(e)) => { - tracing::error!( - "Failed to notify devbox-proxy about branch environment {} deletion: {}", - environment_id, - e - ); - return Err(ApiError::InvalidRequest(format!( - "Failed to notify devbox-proxy about branch environment deletion: {e}" - ))); - } - Err(e) => { - tracing::error!( - "RPC call failed when notifying about branch environment {} deletion: {}", - environment_id, - e - ); - return Err(ApiError::InvalidRequest(format!( - "Connection error while notifying devbox-proxy: {e}" - ))); - } - } - } - - match rpc_client - .destroy_environment( - tarpc::context::current(), - environment.id, - environment.namespace.clone(), - ) - .await - { - Ok(Ok(())) => { - tracing::info!( - "Successfully deleted resources for environment {} in namespace {}", - environment.id, - environment.namespace - ); - } - Ok(Err(e)) => { - tracing::error!( - "KubeManager error when deleting environment {}: {}", - environment.id, - e - ); - return Err(ApiError::InvalidRequest(format!( - "Failed to delete environment resources: {e}" - ))); - } - Err(e) => { - tracing::error!( - "Connection error when deleting environment {}: {}", - environment.id, - e - ); - return Err(ApiError::InvalidRequest(format!( - "Failed to communicate with KubeManager to delete environment: {e}" - ))); - } - } - - // Delete from database (soft delete) - self.db - .delete_kube_environment(environment_id) - .await - .map_err(ApiError::from)?; - - Ok(()) - } - - pub async fn get_app_catalog( - &self, - org_id: Uuid, - catalog_id: Uuid, - ) -> Result { - // Get catalog with cluster info - let (catalog, cluster) = self - .db - .get_all_app_catalogs_paginated(org_id, None, None) - .await - .map_err(ApiError::from)? - .0 - .into_iter() - .find(|(cat, _)| cat.id == catalog_id) - .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - - let cluster = - cluster.ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - Ok(KubeAppCatalog { - id: catalog.id, - name: catalog.name, - description: catalog.description, - created_at: catalog.created_at, - created_by: catalog.created_by, - cluster_id: catalog.cluster_id, - cluster_name: cluster.name, - }) - } - - pub async fn get_app_catalog_workloads( - &self, - org_id: Uuid, - catalog_id: Uuid, - ) -> Result, ApiError> { - // Verify catalog belongs to the organization - let catalog = self - .db - .get_app_catalog(catalog_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - - if catalog.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - self.db - .get_app_catalog_workloads(catalog_id) - .await - .map_err(ApiError::from) - } - - pub async fn delete_app_catalog(&self, org_id: Uuid, catalog_id: Uuid) -> Result<(), ApiError> { - // Verify catalog belongs to the organization - let catalog = self - .db - .get_app_catalog(catalog_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - - if catalog.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Check for dependencies - kube_environment (any user's environments using this catalog) - let has_environments = self - .db - .check_app_catalog_has_environments(catalog_id) - .await - .map_err(ApiError::from)?; - - if has_environments { - return Err(ApiError::InvalidRequest( - "Cannot delete app catalog: it has active environments. Please delete them first." - .to_string(), - )); - } - - // Soft delete the app catalog - self.db - .delete_app_catalog(catalog_id) - .await - .map_err(ApiError::from) - } - - pub async fn delete_app_catalog_workload( - &self, - org_id: Uuid, - workload_id: Uuid, - ) -> Result<(), ApiError> { - // First get the workload to find its catalog - let workload = self - .db - .get_app_catalog_workload(workload_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Workload not found".to_string()))?; - - // Verify the catalog belongs to the organization - let catalog = self - .db - .get_app_catalog(workload.app_catalog_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - - if catalog.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Delete the workload - self.db - .delete_app_catalog_workload(workload_id) - .await - .map_err(ApiError::from) - } - - pub async fn update_app_catalog_workload( - &self, - org_id: Uuid, - workload_id: Uuid, - containers: Vec, - ) -> Result<(), ApiError> { - // Validate containers - Self::validate_containers(&containers)?; - - // First get the workload to find its catalog - let workload = self - .db - .get_app_catalog_workload(workload_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Workload not found".to_string()))?; - - // Verify the catalog belongs to the organization - let catalog = self - .db - .get_app_catalog(workload.app_catalog_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - - if catalog.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Update the workload containers - self.db - .update_app_catalog_workload(workload_id, containers) - .await - .map_err(ApiError::from) - } - - pub async fn add_workloads_to_app_catalog( - &self, - org_id: Uuid, - _user_id: Uuid, - catalog_id: Uuid, - workloads: Vec, - ) -> Result<(), ApiError> { - // Verify the catalog belongs to the organization - let catalog = self - .db - .get_app_catalog(catalog_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - - if catalog.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Enrich workloads with details from KubeManager - let enriched_workloads = self - .enrich_workloads_with_details(catalog.cluster_id, workloads) - .await?; - - // Add enriched workloads to the catalog - let txn = self.db.conn.begin().await.map_err(ApiError::from)?; - let now = chrono::Utc::now().into(); - - match self - .db - .insert_enriched_workloads_to_catalog( - &txn, - catalog_id, - catalog.cluster_id, - enriched_workloads, - now, - ) - .await - { - Ok(_) => { - txn.commit().await.map_err(ApiError::from)?; - Ok(()) - } - Err(db_err) => { - txn.rollback().await.map_err(ApiError::from)?; - // Check if this is a unique constraint violation using SeaORM's sql_err() method - if matches!( - db_err.sql_err(), - Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) - ) { - Err(ApiError::InvalidRequest( - "One or more selected workloads already exist in this catalog".to_string(), - )) - } else { - Err(ApiError::from(anyhow::Error::from(db_err))) - } - } - } - } - - pub async fn create_kube_environment( - &self, - org_id: Uuid, - user_id: Uuid, - app_catalog_id: Uuid, - cluster_id: Uuid, - name: String, - is_shared: bool, - ) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(ApiError::InvalidRequest( - "Environment name cannot be empty".to_string(), - )); - } - let name = name.to_string(); - - // Verify app catalog belongs to the organization - let app_catalog = self - .db - .get_app_catalog(app_catalog_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - - if app_catalog.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Verify cluster belongs to the organization - let cluster = self - .db - .get_kube_cluster(cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - if cluster.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Check if the cluster allows deployments for the requested environment type - if is_shared && !cluster.can_deploy_shared { - return Err(ApiError::InvalidRequest( - "Shared deployments are not allowed on this cluster".to_string(), - )); - } - - if !is_shared && !cluster.can_deploy_personal { - return Err(ApiError::InvalidRequest( - "Personal deployments are not allowed on this cluster".to_string(), - )); - } - - // Get a connected KubeClusterServer for this cluster - let server = self - .get_random_kube_cluster_server(cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest( - "No connected KubeManager for the environment target cluster".to_string(), - ) - })?; - - let workloads = self - .db - .get_app_catalog_workloads(app_catalog.id) - .await - .map_err(ApiError::from)?; - - if workloads.is_empty() { - return Err(ApiError::InvalidRequest(format!( - "No workloads found for app catalog '{}'", - app_catalog.name - ))); - } - - // First, get workloads YAML before creating in database to validate success - let workloads_with_resources = self - .get_workloads_yaml_for_catalog(&app_catalog, workloads.clone()) - .await?; - - let namespace = self - .generate_unique_namespace( - cluster_id, - if is_shared { - EnvironmentNamespaceKind::Shared - } else { - EnvironmentNamespaceKind::Personal - }, - ) - .await?; - - let services_map = workloads_with_resources.services.clone(); - - // Store the environment workloads in the database before deployment - let workload_details: Vec = workloads - .into_iter() - .map(|workload| { - let mut containers = workload.containers; - for container in &mut containers { - // Preserve the original environment variables - container.original_env_vars = container.env_vars.clone(); - container.env_vars.clear(); - - // If the app catalog has a customized image, use it as the original_image - // for the new environment (so the environment starts from the customized state) - match &container.image { - KubeContainerImage::Custom(custom_image) => { - container.original_image = custom_image.clone(); - container.image = KubeContainerImage::FollowOriginal; - } - KubeContainerImage::FollowOriginal => { - // Keep the current original_image and FollowOriginal setting - } - } - } - lapdev_common::kube::KubeWorkloadDetails { - name: workload.name, - namespace: namespace.clone(), - kind: workload.kind, - containers, - ports: workload.ports, - workload_yaml: workload.workload_yaml.unwrap_or_default(), - } - }) - .collect(); - - let created_env = match self - .db - .create_kube_environment( - org_id, - user_id, - app_catalog_id, - cluster_id, - name.clone(), - namespace.clone(), - "Pending".to_string(), - is_shared, - None, // No base environment for regular environments - workload_details, - services_map, - ) - .await - { - Ok(env) => env, - Err(db_err) => { - if let Some(sea_orm::SqlErr::UniqueConstraintViolation(constraint)) = - db_err.sql_err() - { - if constraint == "kube_environment_app_cluster_namespace_unique_idx" { - return Err(ApiError::InvalidRequest( - "This app catalog is already deployed to the specified namespace in this cluster".to_string(), - )); - } - if constraint == "kube_environment_cluster_namespace_unique_idx" { - return Err(ApiError::InternalError( - "Namespace allocation conflict. Please retry environment creation." - .to_string(), - )); - } - } - return Err(ApiError::from(anyhow::Error::from(db_err))); - } - }; - - // Deploy the app catalog resources to the cluster using pre-fetched YAML - self.deploy_app_catalog_with_yaml( - &server, - &created_env.namespace, - &name, - created_env.id, - Some(created_env.auth_token.clone()), - workloads_with_resources, - ) - .await?; - - // Convert the database model to the API type - Ok(lapdev_common::kube::KubeEnvironment { - id: created_env.id, - user_id: created_env.user_id, - name: created_env.name, - namespace: created_env.namespace, - status: created_env.status, - is_shared: created_env.is_shared, - app_catalog_id: created_env.app_catalog_id, - app_catalog_name: app_catalog.name, - cluster_id: created_env.cluster_id, - cluster_name: cluster.name, - created_at: created_env.created_at.to_string(), - base_environment_id: created_env.base_environment_id, - base_environment_name: None, // Regular environments have no base environment - }) - } - - pub async fn create_branch_environment( - &self, - org_id: Uuid, - user_id: Uuid, - base_environment_id: Uuid, - name: String, - ) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(ApiError::InvalidRequest( - "Environment name cannot be empty".to_string(), - )); - } - let name = name.to_string(); - - // Get the base environment - let base_environment = self - .db - .get_kube_environment(base_environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Base environment not found".to_string()))?; - - // Verify base environment belongs to the same organization - if base_environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // Verify base environment is shared (only shared environments can be used as base) - if !base_environment.is_shared { - return Err(ApiError::InvalidRequest( - "Only shared environments can be used as base environments".to_string(), - )); - } - - // Verify base environment is not itself a branch environment - if base_environment.base_environment_id.is_some() { - return Err(ApiError::InvalidRequest( - "Cannot create a branch from another branch environment".to_string(), - )); - } - - // Get the cluster to verify personal deployments are allowed - let cluster = self - .db - .get_kube_cluster(base_environment.cluster_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - - // Get workloads and services from the base environment - let base_workloads = self - .db - .get_environment_workloads(base_environment_id) - .await - .map_err(ApiError::from)?; - - let base_services = self - .db - .get_environment_services(base_environment_id) - .await - .map_err(ApiError::from)?; - - let namespace = self - .generate_unique_namespace( - base_environment.cluster_id, - EnvironmentNamespaceKind::Branch, - ) - .await?; - - let services_map: std::collections::HashMap< - String, - lapdev_common::kube::KubeServiceWithYaml, - > = base_services - .into_iter() - .map(|service| { - ( - service.name.clone(), - lapdev_common::kube::KubeServiceWithYaml { - yaml: service.yaml, - details: lapdev_common::kube::KubeServiceDetails { - name: service.name, - ports: service.ports, - selector: service.selector, - }, - }, - ) - }) - .collect(); - - // Get app catalog info for the response - let app_catalog = self - .db - .get_app_catalog(base_environment.app_catalog_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - - // Convert workloads to the format needed for database creation - let workload_details: Vec = base_workloads - .into_iter() - .filter_map(|workload| { - workload.kind.parse().ok().map(|kind| { - let mut containers = workload.containers; - for container in &mut containers { - // Preserve the original environment variables - container.original_env_vars = container.env_vars.clone(); - container.env_vars.clear(); - - // If the base environment has a customized image, use it as the original_image - // for the new branch environment (so the branch starts from the customized state) - match &container.image { - KubeContainerImage::Custom(custom_image) => { - container.original_image = custom_image.clone(); - container.image = KubeContainerImage::FollowOriginal; - } - KubeContainerImage::FollowOriginal => { - // Keep the current original_image and FollowOriginal setting - } - } - } - lapdev_common::kube::KubeWorkloadDetails { - name: workload.name, - namespace: namespace.clone(), - kind, - containers, - ports: workload.ports, - workload_yaml: String::new(), - } - }) - }) - .collect(); - - let created_env = match self - .db - .create_kube_environment( - org_id, - user_id, - base_environment.app_catalog_id, - base_environment.cluster_id, - name.clone(), - namespace.clone(), - "Pending".to_string(), - false, // Branch environments are always personal (not shared) - Some(base_environment_id), // Set the base environment reference - workload_details, - services_map, - ) - .await - { - Ok(env) => env, - Err(db_err) => { - if let Some(sea_orm::SqlErr::UniqueConstraintViolation(constraint)) = - db_err.sql_err() - { - if constraint == "kube_environment_cluster_namespace_unique_idx" { - return Err(ApiError::InternalError( - "Namespace allocation conflict. Please retry branch environment creation.".to_string(), - )); - } - } - return Err(ApiError::from(anyhow::Error::from(db_err))); - } - }; - - // Notify kube-manager about the new branch environment via RPC - if let Some(server) = self - .get_random_kube_cluster_server(base_environment.cluster_id) - .await - { - let branch_info = lapdev_kube_rpc::BranchEnvironmentInfo { - environment_id: created_env.id, - auth_token: created_env.auth_token.clone(), - namespace: created_env.namespace.clone(), - }; - - match server - .rpc_client - .add_branch_environment(tarpc::context::current(), base_environment_id, branch_info) - .await - { - Ok(Ok(())) => { - tracing::info!( - "Successfully notified devbox-proxy about new branch environment {}", - created_env.id - ); - } - Ok(Err(e)) => { - tracing::error!( - "Failed to notify devbox-proxy about new branch environment {}: {}", - created_env.id, - e - ); - } - Err(e) => { - tracing::error!( - "RPC call failed when notifying about new branch environment {}: {}", - created_env.id, - e - ); - } - } - } else { - tracing::warn!( - "No connected KubeManager for cluster {} - branch environment {} not registered with devbox-proxy", - base_environment.cluster_id, - created_env.id - ); - } - - // Convert the database model to the API type - Ok(lapdev_common::kube::KubeEnvironment { - id: created_env.id, - user_id: created_env.user_id, - name: created_env.name, - namespace: created_env.namespace, - status: created_env.status, - is_shared: created_env.is_shared, - app_catalog_id: created_env.app_catalog_id, - app_catalog_name: app_catalog.name, - cluster_id: created_env.cluster_id, - cluster_name: cluster.name, - created_at: created_env.created_at.to_string(), - base_environment_id: created_env.base_environment_id, - base_environment_name: Some(base_environment.name), // Branch environments have the base environment name - }) - } - - // Kube Namespace operations - pub async fn create_kube_namespace( - &self, - org_id: Uuid, - user_id: Uuid, - name: String, - description: Option, - is_shared: bool, - ) -> Result { - let namespace = self - .db - .create_kube_namespace(org_id, user_id, name, description, is_shared) - .await - .map_err(ApiError::from)?; - - Ok(lapdev_common::kube::KubeNamespace { - id: namespace.id, - name: namespace.name, - description: namespace.description, - is_shared: namespace.is_shared, - created_at: namespace.created_at, - created_by: namespace.user_id, - }) - } - - pub async fn get_all_kube_namespaces( - &self, - org_id: Uuid, - user_id: Uuid, - is_shared: bool, - ) -> Result, ApiError> { - let namespaces = self - .db - .get_all_kube_namespaces(org_id, user_id, is_shared) - .await - .map_err(ApiError::from)?; - - let kube_namespaces = namespaces - .into_iter() - .map(|ns| lapdev_common::kube::KubeNamespace { - id: ns.id, - name: ns.name, - description: ns.description, - is_shared: ns.is_shared, - created_at: ns.created_at, - created_by: ns.user_id, - }) - .collect(); - - Ok(kube_namespaces) - } - - pub async fn delete_kube_namespace( - &self, - _org_id: Uuid, - namespace_id: Uuid, - ) -> Result<(), ApiError> { - self.db - .delete_kube_namespace(namespace_id) - .await - .map_err(ApiError::from)?; - - Ok(()) - } - - async fn get_workloads_yaml_for_catalog( - &self, - app_catalog: &lapdev_db_entities::kube_app_catalog::Model, - workloads: Vec, - ) -> Result { - let source_server = self - .get_random_kube_cluster_server(app_catalog.cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest( - "No connected KubeManager for the app catalog's source cluster".to_string(), - ) - })?; - - match source_server - .rpc_client - .get_workloads_yaml(tarpc::context::current(), workloads) - .await - { - Ok(Ok(workloads_with_resources)) => Ok(workloads_with_resources), - Ok(Err(e)) => { - tracing::error!( - "Failed to get YAML for workloads from source cluster: {}", - e - ); - Err(ApiError::InvalidRequest(format!( - "Failed to get YAML for workloads from source cluster: {e}" - ))) - } - Err(e) => Err(ApiError::InvalidRequest(format!( - "Connection error to source cluster: {e}" - ))), - } - } - - async fn deploy_app_catalog_with_yaml( - &self, - target_server: &KubeClusterServer, - namespace: &str, - environment_name: &str, - environment_id: Uuid, - environment_auth_token: Option, - workloads_with_resources: lapdev_kube_rpc::KubeWorkloadsWithResources, - ) -> Result<(), ApiError> { - tracing::info!( - "Deploying app catalog resources for environment '{}' in namespace '{}'", - environment_name, - namespace - ); - - if workloads_with_resources.workloads.is_empty() { - tracing::warn!("No workloads found for environment '{}'", environment_name); - return Ok(()); - } - - tracing::info!( - "Found {} workloads to deploy for environment '{}'", - workloads_with_resources.workloads.len(), - environment_name - ); - - // Prepare environment-specific labels - let mut environment_labels = std::collections::HashMap::new(); - environment_labels.insert( - "lapdev.environment".to_string(), - environment_name.to_string(), - ); - environment_labels.insert("lapdev.managed-by".to_string(), "lapdev".to_string()); - - // Deploy all workloads and resources in a single call - let auth_token = environment_auth_token.ok_or_else(|| { - ApiError::InvalidRequest("Environment auth token is required".to_string()) - })?; - - match target_server - .rpc_client - .deploy_workload_yaml( - tarpc::context::current(), - environment_id, - auth_token, - namespace.to_string(), - workloads_with_resources, - environment_labels.clone(), - ) - .await - { - Ok(Ok(())) => { - tracing::info!("Successfully deployed all workloads to target cluster"); - Ok(()) - } - Ok(Err(e)) => { - tracing::error!("Failed to deploy workloads to target cluster: {}", e); - Err(ApiError::InvalidRequest(format!( - "Failed to deploy workloads to target cluster: {e}" - ))) - } - Err(e) => Err(ApiError::InvalidRequest(format!( - "Connection error to target cluster: {e}" - ))), - } - } - - fn validate_containers( - containers: &[lapdev_common::kube::KubeContainerInfo], - ) -> Result<(), ApiError> { - if containers.is_empty() { - return Err(ApiError::InvalidRequest( - "At least one container is required".to_string(), - )); - } - - for (index, container) in containers.iter().enumerate() { - if container.name.trim().is_empty() { - return Err(ApiError::InvalidRequest(format!( - "Container {} name cannot be empty", - index + 1 - ))); - } - - match &container.image { - KubeContainerImage::Custom(image) => { - if image.trim().is_empty() { - return Err(ApiError::InvalidRequest(format!( - "Container '{}' custom image cannot be empty", - container.name - ))); - } - } - KubeContainerImage::FollowOriginal => { - // No validation needed for FollowOriginal - } - } - - // Validate CPU resources - if let Some(cpu_request) = &container.cpu_request { - if !Self::is_valid_cpu_quantity(cpu_request) { - return Err(ApiError::InvalidRequest(format!("Container '{}' has invalid CPU request format. Use formats like '100m', '0.1', '1'. Minimum precision is 1m (0.001 CPU)", container.name))); - } - } - - if let Some(cpu_limit) = &container.cpu_limit { - if !Self::is_valid_cpu_quantity(cpu_limit) { - return Err(ApiError::InvalidRequest(format!("Container '{}' has invalid CPU limit format. Use formats like '100m', '0.1', '1'. Minimum precision is 1m (0.001 CPU)", container.name))); - } - } - - // Validate memory resources - if let Some(memory_request) = &container.memory_request { - if !Self::is_valid_memory_quantity(memory_request) { - return Err(ApiError::InvalidRequest(format!("Container '{}' has invalid memory request format. Use formats like '128Mi', '1Gi', '512M'. Maximum 3 decimal places allowed", container.name))); - } - } - - if let Some(memory_limit) = &container.memory_limit { - if !Self::is_valid_memory_quantity(memory_limit) { - return Err(ApiError::InvalidRequest(format!("Container '{}' has invalid memory limit format. Use formats like '128Mi', '1Gi', '512M'. Maximum 3 decimal places allowed", container.name))); - } - } - - // Validate environment variables - for env_var in &container.env_vars { - if env_var.name.trim().is_empty() { - return Err(ApiError::InvalidRequest(format!( - "Container '{}' has environment variable with empty name", - container.name - ))); - } - } - } - - Ok(()) - } - - fn is_valid_cpu_quantity(quantity: &str) -> bool { - // CPU validation for Kubernetes - // Supports formats like: 100m, 0.1, 1, 2.5, etc. - // Kubernetes minimum precision is 1m (0.001 CPU) - if quantity.trim().is_empty() { - return false; // Empty values are invalid - must specify a resource amount - } - - let quantity = quantity.trim(); - - // CPU can have 'm' suffix for millicores or be a plain decimal number - let re = match regex::Regex::new(r"^(\d+\.?\d*|\.\d+)m?$") { - Ok(regex) => regex, - Err(_) => return false, - }; - - if !re.is_match(quantity) { - return false; - } - - // Parse the numeric part and validate precision constraints - let (numeric_part, has_millicore_suffix) = if quantity.ends_with('m') { - (&quantity[..quantity.len() - 1], true) - } else { - (quantity, false) - }; - - if let Ok(value) = numeric_part.parse::() { - if value <= 0.0 { - return false; // Must be positive - } - - if has_millicore_suffix { - // For millicores (m suffix), minimum is 1m, so value must be >= 1.0 - value >= 1.0 - } else { - // For plain decimal CPU values, minimum precision is 0.001 (1m equivalent) - value >= 0.001 - } - } else { - false - } - } - - fn is_valid_memory_quantity(quantity: &str) -> bool { - // Memory validation for Kubernetes - // Supports formats like: 128Mi, 1Gi, 512M, 1000000000, etc. - // Maximum precision is 3 decimal places - if quantity.trim().is_empty() { - return false; // Empty values are invalid - must specify a resource amount - } - - let quantity = quantity.trim(); - - // Memory can have binary (Ki, Mi, Gi, Ti, Pi, Ei) or decimal (K, M, G, T, P, E) suffixes - let valid_suffixes = [ - "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "K", "M", "G", "T", "P", "E", - ]; - - let (numeric_part, _suffix) = - if let Some(suffix) = valid_suffixes.iter().find(|&s| quantity.ends_with(s)) { - (&quantity[..quantity.len() - suffix.len()], Some(*suffix)) - } else { - // No suffix means it's in bytes - (quantity, None) - }; - - // Validate the numeric part is a positive number with max 3 decimal places - if let Ok(value) = numeric_part.parse::() { - if value <= 0.0 { - return false; // Must be positive - } - - // Check decimal places constraint (max 3 decimal places) - if let Some(decimal_pos) = numeric_part.find('.') { - let decimal_part = &numeric_part[decimal_pos + 1..]; - if decimal_part.len() > 3 { - return false; // More than 3 decimal places - } - } - - true - } else { - false - } - } - - pub async fn get_environment_workloads( - &self, - org_id: Uuid, - user_id: Uuid, - environment_id: Uuid, - ) -> Result, ApiError> { - // Verify environment belongs to the organization - let environment = self - .db - .get_kube_environment(environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; - - // Check authorization - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // If it's a personal environment, check ownership - if !environment.is_shared && environment.user_id != user_id { - return Err(ApiError::Unauthorized); - } - - self.db - .get_environment_workloads(environment_id) - .await - .map_err(ApiError::from) - } - - pub async fn get_environment_workload( - &self, - org_id: Uuid, - workload_id: Uuid, - ) -> Result, ApiError> { - // First get the workload to find its environment - if let Some(workload) = self - .db - .get_environment_workload(workload_id) - .await - .map_err(ApiError::from)? - { - // Verify the environment belongs to the organization - let environment = self - .db - .get_kube_environment(workload.environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - Ok(Some(workload)) - } else { - Ok(None) - } - } - - pub async fn delete_environment_workload( - &self, - org_id: Uuid, - user_id: Uuid, - workload_id: Uuid, - environment: lapdev_db_entities::kube_environment::Model, - ) -> Result<(), ApiError> { - // Verify the environment belongs to the organization - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // For personal/branch environments, verify ownership - if !environment.is_shared && environment.user_id != user_id { - return Err(ApiError::Unauthorized); - } - - // Delete the workload - self.db - .delete_environment_workload(workload_id) - .await - .map_err(ApiError::from) - } - - pub async fn update_environment_workload( - &self, - org_id: Uuid, - user_id: Uuid, - workload_id: Uuid, - containers: Vec, - environment: lapdev_db_entities::kube_environment::Model, - ) -> Result<(), ApiError> { - // Verify the environment belongs to the organization - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // For personal/branch environments, verify ownership - if !environment.is_shared && environment.user_id != user_id { - return Err(ApiError::Unauthorized); - } - - // Update the workload containers in database - let updated_db_model = self - .db - .update_environment_workload(workload_id, containers) - .await - .map_err(ApiError::from)?; - - // Convert database model to API type - let updated_workload = { - let containers: Vec = - serde_json::from_value(updated_db_model.containers.clone()).unwrap_or_default(); - - let ports: Vec = - serde_json::from_value(updated_db_model.ports.clone()).unwrap_or_default(); - - lapdev_common::kube::KubeEnvironmentWorkload { - id: updated_db_model.id, - created_at: updated_db_model.created_at, - environment_id: updated_db_model.environment_id, - name: updated_db_model.name.clone(), - namespace: updated_db_model.namespace.clone(), - kind: updated_db_model.kind.clone(), - containers, - ports, - } - }; - - // After successful database update, deploy the workload to the cluster - let cluster_server = self - .get_random_kube_cluster_server(environment.cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) - })?; - - // Convert to proper workload kind - let workload_kind = updated_workload.kind.parse().map_err(|_| { - ApiError::InvalidRequest(format!("Invalid workload kind: {}", updated_workload.kind)) - })?; - - // Prepare environment-specific labels - let mut environment_labels = std::collections::HashMap::new(); - environment_labels.insert("lapdev.environment".to_string(), environment.name.clone()); - environment_labels.insert("lapdev.managed-by".to_string(), "lapdev".to_string()); - - // Check if this is a branch environment - if so, create a new deployment - if environment.base_environment_id.is_some() { - tracing::info!( - "Creating branch deployment for workload '{}' in branch environment '{}' (namespace '{}')", - updated_workload.name, - environment.name, - environment.namespace - ); - - // For branch environments, we need to get the base workload name and create a branch deployment - // The base workload name is the original workload name without branch suffix - let base_workload_name = updated_workload.name.clone(); - let branch_environment_id = environment.id; - - match cluster_server - .rpc_client - .create_branch_workload( - tarpc::context::current(), - updated_workload.id, - base_workload_name.clone(), - branch_environment_id, - environment.auth_token.clone(), - updated_workload.namespace.clone(), - workload_kind, - updated_workload.containers, - environment_labels, - ) - .await - { - Ok(Ok(())) => { - tracing::info!( - "Successfully created branch deployment for workload '{}' in branch environment '{}' (namespace '{}')", - updated_workload.name, - environment.name, - environment.namespace - ); - Ok(()) - } - Ok(Err(e)) => { - tracing::error!("Failed to create branch deployment: {}", e); - Err(ApiError::InvalidRequest(format!( - "Failed to create branch deployment: {e}" - ))) - } - Err(e) => Err(ApiError::InvalidRequest(format!( - "Connection error during branch deployment creation: {e}" - ))), - } - } else { - // For regular environments, update the existing workload containers - tracing::info!( - "Updating workload containers for '{}' in regular environment '{}' (namespace '{}')", - updated_workload.name, - environment.name, - environment.namespace - ); - - match cluster_server - .rpc_client - .update_workload_containers( - tarpc::context::current(), - environment.id, - environment.auth_token.clone(), - updated_workload.id, - updated_workload.name.clone(), - updated_workload.namespace.clone(), - workload_kind, - updated_workload.containers, - environment_labels, - ) - .await - { - Ok(Ok(())) => { - tracing::info!( - "Successfully updated workload containers for '{}' in environment '{}' (namespace '{}')", - updated_workload.name, - environment.name, - environment.namespace - ); - Ok(()) - } - Ok(Err(e)) => { - tracing::error!("Failed to atomically update workload containers: {}", e); - Err(ApiError::InvalidRequest(format!( - "Failed to update workload containers: {e}" - ))) - } - Err(e) => Err(ApiError::InvalidRequest(format!( - "Connection error during atomic workload update: {e}" - ))), - } - } - } - - pub async fn get_environment_services( - &self, - org_id: Uuid, - user_id: Uuid, - environment_id: Uuid, - ) -> Result, ApiError> { - // Verify environment belongs to the organization - let environment = self - .db - .get_kube_environment(environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; - - // Check authorization - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // If it's a personal environment, check ownership - if !environment.is_shared && environment.user_id != user_id { - return Err(ApiError::Unauthorized); - } - - self.db - .get_environment_services(environment_id) - .await - .map_err(ApiError::from) - } - - pub async fn create_environment_preview_url( - &self, - org_id: Uuid, - user_id: Uuid, - environment_id: Uuid, - request: lapdev_common::kube::CreateKubeEnvironmentPreviewUrlRequest, - ) -> Result { - // Verify environment belongs to the organization and check ownership - let environment = self - .db - .get_kube_environment(environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; - - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // If it's a personal environment, check ownership - if !environment.is_shared && environment.user_id != user_id { - return Err(ApiError::Unauthorized); - } - - // Verify service exists and belongs to the environment - let service = self - .db - .get_kube_environment_service_by_id(request.service_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Service not found".to_string()))?; - - // Verify service belongs to the environment - if service.environment_id != environment_id { - return Err(ApiError::InvalidRequest( - "Service not found in environment".to_string(), - )); - } - - // Validate the port exists on the service - let port_exists = service.ports.iter().any(|p| { - p.port == request.port - && (request.port_name.is_none() || p.name.as_ref() == request.port_name.as_ref()) - }); - - if !port_exists { - return Err(ApiError::InvalidRequest( - "Specified port does not exist on the service".to_string(), - )); - } - - // Auto-generate name based on service and port - let auto_name = format!("{}-{}-{}", request.port, service.name, rand_string(12)); - - // Set defaults - let protocol = request.protocol.unwrap_or_else(|| "HTTP".to_string()); - let access_level = request - .access_level - .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal); - - let url = format!("https://{auto_name}.app.lap.dev"); - - // Create preview URL in database - let preview_url = match self - .db - .create_environment_preview_url( - environment_id, - request.service_id, - user_id, - auto_name, - request.description, - request.port, - request.port_name, - protocol.clone(), - access_level.clone(), - ) - .await - { - Ok(url) => url, - Err(db_err) => { - // Check if this is a unique constraint violation - if matches!( - db_err.sql_err(), - Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) - ) { - return Err(ApiError::InvalidRequest( - "A preview URL already exists for this service and port combination" - .to_string(), - )); - } else { - return Err(ApiError::from(anyhow::Error::from(db_err))); - } - } - }; - - Ok(lapdev_common::kube::KubeEnvironmentPreviewUrl { - id: preview_url.id, - created_at: preview_url.created_at, - environment_id: preview_url.environment_id, - service_id: preview_url.service_id, - name: preview_url.name, - description: preview_url.description, - port: preview_url.port, - port_name: preview_url.port_name, - protocol: preview_url.protocol, - access_level: preview_url - .access_level - .parse() - .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal), - created_by: preview_url.created_by, - last_accessed_at: preview_url.last_accessed_at, - url, - }) - } - - pub async fn get_environment_preview_urls( - &self, - org_id: Uuid, - user_id: Uuid, - environment_id: Uuid, - ) -> Result, ApiError> { - // Verify environment belongs to the organization and check ownership - let environment = self - .db - .get_kube_environment(environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; - - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // If it's a personal environment, check ownership - if !environment.is_shared && environment.user_id != user_id { - return Err(ApiError::Unauthorized); - } - - let preview_urls = self - .db - .get_environment_preview_urls(environment_id) - .await - .map_err(ApiError::from)?; - - Ok(preview_urls - .into_iter() - .map(|preview_url| { - let url = format!("https://{}.app.lap.dev", preview_url.name); - - lapdev_common::kube::KubeEnvironmentPreviewUrl { - id: preview_url.id, - created_at: preview_url.created_at, - environment_id: preview_url.environment_id, - service_id: preview_url.service_id, - name: preview_url.name, - description: preview_url.description, - port: preview_url.port, - port_name: preview_url.port_name, - protocol: preview_url.protocol, - access_level: preview_url - .access_level - .parse() - .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal), - created_by: preview_url.created_by, - last_accessed_at: preview_url.last_accessed_at, - url, - } - }) - .collect()) - } - - pub async fn update_environment_preview_url( - &self, - org_id: Uuid, - user_id: Uuid, - preview_url_id: Uuid, - request: lapdev_common::kube::UpdateKubeEnvironmentPreviewUrlRequest, - ) -> Result { - // Get the preview URL and verify ownership - let preview_url = self - .db - .get_environment_preview_url(preview_url_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Preview URL not found".to_string()))?; - - // Verify environment belongs to the organization and check ownership - let environment = self - .db - .get_kube_environment(preview_url.environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; - - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // If it's a personal environment, check ownership - if !environment.is_shared && environment.user_id != user_id { - return Err(ApiError::Unauthorized); - } - - // Update the preview URL - let updated_preview_url = self - .db - .update_environment_preview_url( - preview_url_id, - request.description, - request.access_level, - ) - .await - .map_err(ApiError::from)?; - - let url = format!("https://{}.app.lap.dev", updated_preview_url.name); - - Ok(lapdev_common::kube::KubeEnvironmentPreviewUrl { - id: updated_preview_url.id, - created_at: updated_preview_url.created_at, - environment_id: updated_preview_url.environment_id, - service_id: updated_preview_url.service_id, - name: updated_preview_url.name, - description: updated_preview_url.description, - port: updated_preview_url.port, - port_name: updated_preview_url.port_name, - protocol: updated_preview_url.protocol, - access_level: updated_preview_url - .access_level - .parse() - .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal), - created_by: updated_preview_url.created_by, - last_accessed_at: updated_preview_url.last_accessed_at, - url, - }) - } - - pub async fn delete_environment_preview_url( - &self, - org_id: Uuid, - user_id: Uuid, - preview_url_id: Uuid, - ) -> Result<(), ApiError> { - // Get the preview URL and verify ownership - let preview_url = self - .db - .get_environment_preview_url(preview_url_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Preview URL not found".to_string()))?; - - // Verify environment belongs to the organization and check ownership - let environment = self - .db - .get_kube_environment(preview_url.environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; - - if environment.organization_id != org_id { - return Err(ApiError::Unauthorized); - } - - // If it's a personal environment, check ownership - if !environment.is_shared && environment.user_id != user_id { - return Err(ApiError::Unauthorized); - } - - self.db - .delete_environment_preview_url(preview_url_id) - .await - .map_err(ApiError::from) - } -} - -fn build_workload_details_from_yaml( - raw: KubeRawWorkloadYaml, - services: &[CachedClusterService], -) -> anyhow::Result { - let KubeRawWorkloadYaml { - name, - namespace, - kind, - workload_yaml, - } = raw; - - let (containers, labels) = extract_containers_and_labels(&kind, &workload_yaml)?; - let ports = ports_from_cached_services(&labels, services); - - Ok(lapdev_common::kube::KubeWorkloadDetails { - name, - namespace, - kind, - containers, - ports, - workload_yaml, - }) -} - -fn extract_containers_and_labels( - kind: &KubeWorkloadKind, - workload_yaml: &str, -) -> anyhow::Result<(Vec, BTreeMap)> { - match kind { - KubeWorkloadKind::Deployment => { - let deployment: Deployment = serde_yaml::from_str(workload_yaml)?; - let labels = deployment - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.clone()) - .unwrap_or_default(); - let pod_spec = deployment - .spec - .as_ref() - .and_then(|s| s.template.spec.as_ref()) - .ok_or_else(|| anyhow!("Deployment missing pod spec"))?; - let containers = extract_pod_spec_containers(pod_spec)?; - Ok((containers, labels)) - } - KubeWorkloadKind::StatefulSet => { - let statefulset: StatefulSet = serde_yaml::from_str(workload_yaml)?; - let labels = statefulset - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.clone()) - .unwrap_or_default(); - let pod_spec = statefulset - .spec - .as_ref() - .and_then(|s| s.template.spec.as_ref()) - .ok_or_else(|| anyhow!("StatefulSet missing pod spec"))?; - let containers = extract_pod_spec_containers(pod_spec)?; - Ok((containers, labels)) - } - KubeWorkloadKind::DaemonSet => { - let daemonset: DaemonSet = serde_yaml::from_str(workload_yaml)?; - let labels = daemonset - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.clone()) - .unwrap_or_default(); - let pod_spec = daemonset - .spec - .as_ref() - .and_then(|s| s.template.spec.as_ref()) - .ok_or_else(|| anyhow!("DaemonSet missing pod spec"))?; - let containers = extract_pod_spec_containers(pod_spec)?; - Ok((containers, labels)) - } - KubeWorkloadKind::ReplicaSet => { - let replicaset: ReplicaSet = serde_yaml::from_str(workload_yaml)?; - let labels = replicaset - .spec - .as_ref() - .and_then(|s| s.template.as_ref()) - .and_then(|t| t.metadata.as_ref()) - .and_then(|m| m.labels.clone()) - .unwrap_or_default(); - let pod_spec = replicaset - .spec - .as_ref() - .and_then(|s| s.template.as_ref()) - .and_then(|t| t.spec.as_ref()) - .ok_or_else(|| anyhow!("ReplicaSet missing pod spec"))?; - let containers = extract_pod_spec_containers(pod_spec)?; - Ok((containers, labels)) - } - KubeWorkloadKind::Pod => { - let pod: Pod = serde_yaml::from_str(workload_yaml)?; - let labels = pod.metadata.labels.clone().unwrap_or_default(); - let pod_spec = pod - .spec - .as_ref() - .ok_or_else(|| anyhow!("Pod missing spec"))?; - let containers = extract_pod_spec_containers(pod_spec)?; - Ok((containers, labels)) - } - KubeWorkloadKind::Job => { - let job: Job = serde_yaml::from_str(workload_yaml)?; - let labels = job - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.clone()) - .unwrap_or_default(); - let pod_spec = job - .spec - .as_ref() - .and_then(|s| s.template.spec.as_ref()) - .ok_or_else(|| anyhow!("Job missing pod spec"))?; - let containers = extract_pod_spec_containers(pod_spec)?; - Ok((containers, labels)) - } - KubeWorkloadKind::CronJob => { - let cronjob: CronJob = serde_yaml::from_str(workload_yaml)?; - let labels = cronjob - .spec - .as_ref() - .and_then(|s| s.job_template.spec.as_ref()) - .and_then(|js| js.template.metadata.as_ref()) - .and_then(|m| m.labels.clone()) - .unwrap_or_default(); - let pod_spec = cronjob - .spec - .as_ref() - .and_then(|s| s.job_template.spec.as_ref()) - .and_then(|js| js.template.spec.as_ref()) - .ok_or_else(|| anyhow!("CronJob missing pod spec"))?; - let containers = extract_pod_spec_containers(pod_spec)?; - Ok((containers, labels)) - } - } -} - -fn extract_pod_spec_containers(pod_spec: &PodSpec) -> anyhow::Result> { - pod_spec - .containers - .iter() - .map(|container| { - let mut cpu_request = None; - let mut cpu_limit = None; - let mut memory_request = None; - let mut memory_limit = None; - - if let Some(resources) = &container.resources { - if let Some(requests) = &resources.requests { - if let Some(cpu_req) = requests.get("cpu") { - cpu_request = Some(cpu_req.0.clone()); - } - if let Some(memory_req) = requests.get("memory") { - memory_request = Some(memory_req.0.clone()); - } - } - - if let Some(limits) = &resources.limits { - if let Some(cpu_lim) = limits.get("cpu") { - cpu_limit = Some(cpu_lim.0.clone()); - } - if let Some(memory_lim) = limits.get("memory") { - memory_limit = Some(memory_lim.0.clone()); - } - } - } - - let image = container - .image - .clone() - .ok_or_else(|| anyhow!("Container '{}' has no image specified", container.name))?; - - let ports = container - .ports - .as_ref() - .map(|ports| { - ports - .iter() - .map(|port| KubeContainerPort { - name: port.name.clone(), - container_port: port.container_port, - protocol: port.protocol.clone(), - }) - .collect::>() - }) - .unwrap_or_default(); - - Ok(KubeContainerInfo { - name: container.name.clone(), - original_image: image.clone(), - image: KubeContainerImage::FollowOriginal, - cpu_request, - cpu_limit, - memory_request, - memory_limit, - env_vars: Vec::new(), - original_env_vars: Vec::new(), - ports, - }) - }) - .collect() -} - -fn ports_from_cached_services( - workload_labels: &BTreeMap, - services: &[CachedClusterService], -) -> Vec { - let mut seen = HashSet::new(); - let mut ports = Vec::new(); - - for service in services { - if service.selector.is_empty() { - continue; - } - - let matches = service - .selector - .iter() - .all(|(key, value)| workload_labels.get(key).map_or(false, |v| v == value)); - - if !matches { - continue; - } - - for port in &service.ports { - let key = (port.port, port.target_port, port.protocol.clone()); - if seen.insert(key) { - ports.push(port.clone()); - } - } - } - - ports -} - -#[cfg(test)] -mod tests { - use super::KubeController; - use lapdev_common::kube::{KubeContainerImage, KubeContainerInfo}; - - #[test] - fn test_cpu_validation() { - // Valid CPU values - assert!(KubeController::is_valid_cpu_quantity("100m")); // 100 millicores - assert!(KubeController::is_valid_cpu_quantity("1m")); // 1 millicore (minimum) - assert!(KubeController::is_valid_cpu_quantity("0.1")); // 0.1 CPU (100m equivalent) - assert!(KubeController::is_valid_cpu_quantity("1")); // 1 CPU - assert!(KubeController::is_valid_cpu_quantity("2.5")); // 2.5 CPUs - assert!(KubeController::is_valid_cpu_quantity("500m")); // 500 millicores - assert!(KubeController::is_valid_cpu_quantity("1.5m")); // 1.5 millicores - assert!(KubeController::is_valid_cpu_quantity("0.001")); // Minimum decimal precision - - // Invalid CPU values - assert!(!KubeController::is_valid_cpu_quantity("")); // Empty - assert!(!KubeController::is_valid_cpu_quantity(" ")); // Whitespace - assert!(!KubeController::is_valid_cpu_quantity("0.5m")); // Below 1m minimum - assert!(!KubeController::is_valid_cpu_quantity("0.0005")); // Below 0.001 minimum - assert!(!KubeController::is_valid_cpu_quantity("0m")); // Zero millicores - assert!(!KubeController::is_valid_cpu_quantity("0")); // Zero CPU - assert!(!KubeController::is_valid_cpu_quantity("100Mi")); // Wrong suffix - assert!(!KubeController::is_valid_cpu_quantity("-100m")); // Negative - assert!(!KubeController::is_valid_cpu_quantity("abc")); // Non-numeric - assert!(!KubeController::is_valid_cpu_quantity("100x")); // Invalid suffix - } - - #[test] - fn test_memory_validation() { - // Valid memory values - assert!(KubeController::is_valid_memory_quantity("128Mi")); - assert!(KubeController::is_valid_memory_quantity("1Gi")); - assert!(KubeController::is_valid_memory_quantity("512M")); - assert!(KubeController::is_valid_memory_quantity("1000000000")); // Raw bytes - assert!(KubeController::is_valid_memory_quantity("2Ti")); - assert!(KubeController::is_valid_memory_quantity("1.5")); // 1 decimal place - assert!(KubeController::is_valid_memory_quantity("1.5Gi")); // 1 decimal place - assert!(KubeController::is_valid_memory_quantity("128.25Mi")); // 2 decimal places - assert!(KubeController::is_valid_memory_quantity("1.125Gi")); // 3 decimal places (max) - assert!(KubeController::is_valid_memory_quantity("0.5Gi")); // Decimal with suffix - - // Invalid memory values - assert!(!KubeController::is_valid_memory_quantity("")); // Empty - assert!(!KubeController::is_valid_memory_quantity(" ")); // Whitespace - assert!(!KubeController::is_valid_memory_quantity("100m")); // CPU suffix on memory - assert!(!KubeController::is_valid_memory_quantity("-128Mi")); // Negative - assert!(!KubeController::is_valid_memory_quantity("abc")); // Non-numeric - assert!(!KubeController::is_valid_memory_quantity("100x")); // Invalid suffix - assert!(!KubeController::is_valid_memory_quantity("0Mi")); // Zero - assert!(!KubeController::is_valid_memory_quantity("1.1234Gi")); // 4 decimal places (too many) - assert!(!KubeController::is_valid_memory_quantity("1.1234")); // 4 decimal places (too many) - assert!(!KubeController::is_valid_memory_quantity("128.12345Mi")); // 5 decimal places (too many) - assert!(!KubeController::is_valid_memory_quantity("128.12345")); // 5 decimal places (too many) - } -} diff --git a/crates/api/src/kube_controller/app_catalog.rs b/crates/api/src/kube_controller/app_catalog.rs new file mode 100644 index 0000000..243e096 --- /dev/null +++ b/crates/api/src/kube_controller/app_catalog.rs @@ -0,0 +1,363 @@ +use std::collections::{HashMap, HashSet}; +use uuid::Uuid; + +use lapdev_common::kube::{ + KubeAppCatalog, KubeAppCatalogWorkload, KubeAppCatalogWorkloadCreate, PagePaginationParams, + PaginatedInfo, PaginatedResult, +}; +use lapdev_db::api::CachedClusterService; +use lapdev_rpc::error::ApiError; +use sea_orm::TransactionTrait; + +use super::{yaml_parser::build_workload_details_from_yaml, KubeController}; + +impl KubeController { + pub(super) async fn enrich_workloads_with_details( + &self, + cluster_id: Uuid, + workloads: Vec, + ) -> Result, ApiError> { + // Get cluster connection + let cluster_server = self + .get_random_kube_cluster_server(cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) + })?; + + // Convert to WorkloadIdentifier for RPC call + let workload_identifiers: Vec = workloads + .iter() + .map(|w| lapdev_kube_rpc::WorkloadIdentifier { + name: w.name.clone(), + namespace: w.namespace.clone(), + kind: w.kind.clone(), + }) + .collect(); + + let raw_workloads = cluster_server + .rpc_client + .get_workloads_raw_yaml(tarpc::context::current(), workload_identifiers) + .await + .map_err(|e| ApiError::InvalidRequest(format!("Failed to get workload YAML: {}", e)))? + .map_err(|e| ApiError::InvalidRequest(format!("KubeManager error: {}", e)))?; + + let mut namespaces: HashSet = + raw_workloads.iter().map(|w| w.namespace.clone()).collect(); + namespaces.extend(workloads.iter().map(|w| w.namespace.clone())); + + let mut services_cache: HashMap> = HashMap::new(); + for namespace in namespaces { + let services = self + .db + .get_active_cluster_services(cluster_id, &namespace) + .await + .map_err(ApiError::from)?; + services_cache.insert(namespace, services); + } + + let mut results = Vec::with_capacity(raw_workloads.len()); + for raw in raw_workloads { + let services = services_cache + .get(&raw.namespace) + .map(|s| s.as_slice()) + .unwrap_or(&[]); + match build_workload_details_from_yaml(raw, services) { + Ok(details) => results.push(details), + Err(err) => { + return Err(ApiError::InvalidRequest(format!( + "Failed to process workload YAML: {}", + err + ))) + } + } + } + + Ok(results) + } + + pub async fn create_app_catalog( + &self, + org_id: Uuid, + user_id: Uuid, + cluster_id: Uuid, + name: String, + description: Option, + workloads: Vec, + ) -> Result { + // Get enriched workload details from KubeManager + let enriched_workloads = self + .enrich_workloads_with_details(cluster_id, workloads) + .await?; + + self.db + .create_app_catalog_with_enriched_workloads( + org_id, + user_id, + cluster_id, + name, + description, + enriched_workloads, + ) + .await + .map_err(ApiError::from) + } + + pub async fn get_all_app_catalogs( + &self, + org_id: Uuid, + search: Option, + pagination: Option, + ) -> Result, ApiError> { + let pagination = pagination.unwrap_or_default(); + + let (catalogs_with_clusters, total_count) = self + .db + .get_all_app_catalogs_paginated(org_id, search, Some(pagination.clone())) + .await + .map_err(ApiError::from)?; + + let app_catalogs = catalogs_with_clusters + .into_iter() + .filter_map(|(catalog, cluster)| { + let cluster = cluster?; + Some(KubeAppCatalog { + id: catalog.id, + name: catalog.name, + description: catalog.description, + created_at: catalog.created_at, + created_by: catalog.created_by, + cluster_id: catalog.cluster_id, + cluster_name: cluster.name, + }) + }) + .collect(); + + let total_pages = (total_count + pagination.page_size - 1) / pagination.page_size; + + Ok(PaginatedResult { + data: app_catalogs, + pagination_info: PaginatedInfo { + total_count, + page: pagination.page, + page_size: pagination.page_size, + total_pages, + }, + }) + } + + pub async fn get_app_catalog( + &self, + org_id: Uuid, + catalog_id: Uuid, + ) -> Result { + // Get catalog with cluster info + let (catalog, cluster) = self + .db + .get_all_app_catalogs_paginated(org_id, None, None) + .await + .map_err(ApiError::from)? + .0 + .into_iter() + .find(|(cat, _)| cat.id == catalog_id) + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + let cluster = + cluster.ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + Ok(KubeAppCatalog { + id: catalog.id, + name: catalog.name, + description: catalog.description, + created_at: catalog.created_at, + created_by: catalog.created_by, + cluster_id: catalog.cluster_id, + cluster_name: cluster.name, + }) + } + + pub async fn get_app_catalog_workloads( + &self, + org_id: Uuid, + catalog_id: Uuid, + ) -> Result, ApiError> { + // Verify catalog belongs to the organization + let catalog = self + .db + .get_app_catalog(catalog_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + if catalog.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + self.db + .get_app_catalog_workloads(catalog_id) + .await + .map_err(ApiError::from) + } + + pub async fn delete_app_catalog(&self, org_id: Uuid, catalog_id: Uuid) -> Result<(), ApiError> { + // Verify catalog belongs to the organization + let catalog = self + .db + .get_app_catalog(catalog_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + if catalog.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Check for dependencies - kube_environment (any user's environments using this catalog) + let has_environments = self + .db + .check_app_catalog_has_environments(catalog_id) + .await + .map_err(ApiError::from)?; + + if has_environments { + return Err(ApiError::InvalidRequest( + "Cannot delete app catalog: it has active environments. Please delete them first." + .to_string(), + )); + } + + // Soft delete the app catalog + self.db + .delete_app_catalog(catalog_id) + .await + .map_err(ApiError::from) + } + + pub async fn delete_app_catalog_workload( + &self, + org_id: Uuid, + workload_id: Uuid, + ) -> Result<(), ApiError> { + // First get the workload to find its catalog + let workload = self + .db + .get_app_catalog_workload(workload_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Workload not found".to_string()))?; + + // Verify the catalog belongs to the organization + let catalog = self + .db + .get_app_catalog(workload.app_catalog_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + if catalog.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Delete the workload + self.db + .delete_app_catalog_workload(workload_id) + .await + .map_err(ApiError::from) + } + + pub async fn update_app_catalog_workload( + &self, + org_id: Uuid, + workload_id: Uuid, + containers: Vec, + ) -> Result<(), ApiError> { + // Validate containers + super::validation::validate_containers(&containers)?; + + // First get the workload to find its catalog + let workload = self + .db + .get_app_catalog_workload(workload_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Workload not found".to_string()))?; + + // Verify the catalog belongs to the organization + let catalog = self + .db + .get_app_catalog(workload.app_catalog_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + if catalog.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Update the workload containers + self.db + .update_app_catalog_workload(workload_id, containers) + .await + .map_err(ApiError::from) + } + + pub async fn add_workloads_to_app_catalog( + &self, + org_id: Uuid, + _user_id: Uuid, + catalog_id: Uuid, + workloads: Vec, + ) -> Result<(), ApiError> { + // Verify the catalog belongs to the organization + let catalog = self + .db + .get_app_catalog(catalog_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + if catalog.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Enrich workloads with details from KubeManager + let enriched_workloads = self + .enrich_workloads_with_details(catalog.cluster_id, workloads) + .await?; + + // Add enriched workloads to the catalog + let txn = self.db.conn.begin().await.map_err(ApiError::from)?; + let now = chrono::Utc::now().into(); + + match self + .db + .insert_enriched_workloads_to_catalog( + &txn, + catalog_id, + catalog.cluster_id, + enriched_workloads, + now, + ) + .await + { + Ok(_) => { + txn.commit().await.map_err(ApiError::from)?; + Ok(()) + } + Err(db_err) => { + txn.rollback().await.map_err(ApiError::from)?; + // Check if this is a unique constraint violation using SeaORM's sql_err() method + if matches!( + db_err.sql_err(), + Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) + ) { + Err(ApiError::InvalidRequest( + "One or more selected workloads already exist in this catalog".to_string(), + )) + } else { + Err(ApiError::from(anyhow::Error::from(db_err))) + } + } + } + } +} diff --git a/crates/api/src/kube_controller/cluster.rs b/crates/api/src/kube_controller/cluster.rs new file mode 100644 index 0000000..3598c53 --- /dev/null +++ b/crates/api/src/kube_controller/cluster.rs @@ -0,0 +1,389 @@ +use std::str::FromStr; +use uuid::Uuid; + +use lapdev_common::{ + kube::{ + CreateKubeClusterResponse, KubeCluster, KubeClusterInfo, KubeClusterStatus, + KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, PaginationParams, + }, + token::PlainToken, +}; +use lapdev_rpc::error::ApiError; +use secrecy::ExposeSecret; + +use super::KubeController; + +impl KubeController { + pub async fn get_all_kube_clusters(&self, org_id: Uuid) -> Result, ApiError> { + let clusters = self + .db + .get_all_kube_clusters(org_id) + .await + .map_err(ApiError::from)? + .into_iter() + .map(|c| KubeCluster { + id: c.id, + name: c.name.clone(), + can_deploy_personal: c.can_deploy_personal, + can_deploy_shared: c.can_deploy_shared, + info: KubeClusterInfo { + cluster_name: Some(c.name), + cluster_version: c.cluster_version.unwrap_or("Unknown".to_string()), + node_count: 0, // TODO: Get actual node count from kube-manager + available_cpu: "N/A".to_string(), // TODO: Get actual CPU from kube-manager + available_memory: "N/A".to_string(), // TODO: Get actual memory from kube-manager + provider: None, // TODO: Get provider info + region: c.region, + status: KubeClusterStatus::from_str(&c.status) + .unwrap_or(KubeClusterStatus::NotReady), + }, + }) + .collect(); + Ok(clusters) + } + + pub async fn create_kube_cluster( + &self, + org_id: Uuid, + user_id: Uuid, + name: String, + ) -> Result { + // Generate cluster ID and token + let cluster_id = Uuid::new_v4(); + let token = PlainToken::generate(); + let hashed_token = token.hashed(); + let token_name = format!("{name}-default"); + + // Create the cluster + self.db + .create_kube_cluster( + cluster_id, + org_id, + user_id, + name, + KubeClusterStatus::Provisioning.to_string(), + ) + .await + .map_err(ApiError::from)?; + + // Create the cluster token + self.db + .create_kube_cluster_token( + cluster_id, + user_id, + token_name, + hashed_token.expose_secret().to_vec(), + ) + .await + .map_err(ApiError::from)?; + + Ok(CreateKubeClusterResponse { + cluster_id, + token: token.expose_secret().to_string(), + }) + } + + pub async fn delete_kube_cluster( + &self, + org_id: Uuid, + cluster_id: Uuid, + ) -> Result<(), ApiError> { + // Verify cluster belongs to the organization + let cluster = self + .db + .get_kube_cluster(cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + if cluster.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Check for dependencies - kube_app_catalog + let has_app_catalogs = self + .db + .check_kube_cluster_has_app_catalogs(cluster_id) + .await + .map_err(ApiError::from)?; + + if has_app_catalogs { + return Err(ApiError::InvalidRequest( + "Cannot delete cluster: it has active app catalogs. Please delete them first." + .to_string(), + )); + } + + // Check for dependencies - kube_environment + let has_environments = self + .db + .check_kube_cluster_has_environments(cluster_id) + .await + .map_err(ApiError::from)?; + + if has_environments { + return Err(ApiError::InvalidRequest( + "Cannot delete cluster: it has active environments. Please delete them first." + .to_string(), + )); + } + + // Soft delete the cluster + self.db + .delete_kube_cluster(cluster_id) + .await + .map_err(ApiError::from) + } + + pub async fn set_cluster_deployable( + &self, + org_id: Uuid, + cluster_id: Uuid, + can_deploy_personal: bool, + can_deploy_shared: bool, + ) -> Result<(), ApiError> { + // Verify cluster belongs to the organization + let cluster = self + .db + .get_kube_cluster(cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + if cluster.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Update the deployment capability fields + self.db + .set_cluster_deployable(cluster_id, can_deploy_personal, can_deploy_shared) + .await + .map_err(ApiError::from)?; + + Ok(()) + } + + pub async fn set_cluster_personal_deployable( + &self, + org_id: Uuid, + cluster_id: Uuid, + can_deploy: bool, + ) -> Result<(), ApiError> { + // Verify cluster belongs to the organization + let cluster = self + .db + .get_kube_cluster(cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + if cluster.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Update only the personal deployment capability + self.db + .set_cluster_deployable(cluster_id, can_deploy, cluster.can_deploy_shared) + .await + .map_err(ApiError::from)?; + + Ok(()) + } + + pub async fn set_cluster_shared_deployable( + &self, + org_id: Uuid, + cluster_id: Uuid, + can_deploy: bool, + ) -> Result<(), ApiError> { + // Verify cluster belongs to the organization + let cluster = self + .db + .get_kube_cluster(cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + if cluster.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Update only the shared deployment capability + self.db + .set_cluster_deployable(cluster_id, cluster.can_deploy_personal, can_deploy) + .await + .map_err(ApiError::from)?; + + Ok(()) + } + + pub async fn get_workloads( + &self, + org_id: Uuid, + cluster_id: Uuid, + namespace: Option, + workload_kind_filter: Option, + include_system_workloads: bool, + pagination: Option, + ) -> Result { + // Verify cluster belongs to the organization + let cluster = self + .db + .get_kube_cluster(cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + if cluster.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Get a connected KubeClusterServer for this cluster + let server = self + .get_random_kube_cluster_server(cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) + })?; + + let pagination = pagination.unwrap_or(PaginationParams { + cursor: None, + limit: 20, + }); + + // Call KubeManager to get workloads + match server + .rpc_client + .get_workloads( + tarpc::context::current(), + namespace, + workload_kind_filter, + include_system_workloads, + Some(pagination), + ) + .await + { + Ok(Ok(workload_list)) => Ok(workload_list), + Ok(Err(e)) => Err(ApiError::InvalidRequest(format!( + "KubeManager error: {}", + e + ))), + Err(e) => Err(ApiError::InvalidRequest(format!("Connection error: {}", e))), + } + } + + pub async fn get_workload_details( + &self, + org_id: Uuid, + cluster_id: Uuid, + name: String, + namespace: String, + ) -> Result, ApiError> { + // Verify cluster belongs to the organization + let cluster = self + .db + .get_kube_cluster(cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + if cluster.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Get a connected KubeClusterServer for this cluster + let server = self + .get_random_kube_cluster_server(cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) + })?; + + // Call KubeManager to get workload details + match server + .rpc_client + .get_workload_details(tarpc::context::current(), name, namespace) + .await + { + Ok(Ok(workload_details)) => Ok(workload_details), + Ok(Err(e)) => Err(ApiError::InvalidRequest(format!( + "KubeManager error: {}", + e + ))), + Err(e) => Err(ApiError::InvalidRequest(format!("Connection error: {}", e))), + } + } + + pub async fn get_cluster_namespaces( + &self, + org_id: Uuid, + cluster_id: Uuid, + ) -> Result, ApiError> { + // Verify cluster belongs to the organization + let cluster = self + .db + .get_kube_cluster(cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + if cluster.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Get a connected KubeClusterServer for this cluster + let server = self + .get_random_kube_cluster_server(cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) + })?; + + // Call KubeManager to get namespaces + match server + .rpc_client + .get_namespaces(tarpc::context::current()) + .await + { + Ok(Ok(namespaces)) => Ok(namespaces), + Ok(Err(e)) => Err(ApiError::InvalidRequest(format!( + "KubeManager error: {}", + e + ))), + Err(e) => Err(ApiError::InvalidRequest(format!("Connection error: {}", e))), + } + } + + pub async fn get_cluster_info( + &self, + org_id: Uuid, + cluster_id: Uuid, + ) -> Result { + // Get cluster from database + let cluster = self + .db + .get_kube_cluster(cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + if cluster.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Convert database cluster to KubeClusterInfo + let cluster_info = KubeClusterInfo { + cluster_name: Some(cluster.name), + cluster_version: cluster.cluster_version.unwrap_or("Unknown".to_string()), + node_count: 0, // TODO: Get actual node count from kube-manager + available_cpu: "N/A".to_string(), // TODO: Get actual CPU from kube-manager + available_memory: "N/A".to_string(), // TODO: Get actual memory from kube-manager + provider: None, // TODO: Get provider info + region: cluster.region, + status: KubeClusterStatus::from_str(&cluster.status) + .unwrap_or(KubeClusterStatus::NotReady), + }; + + Ok(cluster_info) + } +} diff --git a/crates/api/src/kube_controller/deployment.rs b/crates/api/src/kube_controller/deployment.rs new file mode 100644 index 0000000..c43688c --- /dev/null +++ b/crates/api/src/kube_controller/deployment.rs @@ -0,0 +1,111 @@ +use uuid::Uuid; + +use lapdev_common::kube::KubeAppCatalogWorkload; +use lapdev_kube::server::KubeClusterServer; +use lapdev_rpc::error::ApiError; + +use super::KubeController; + +impl KubeController { + pub(super) async fn get_workloads_yaml_for_catalog( + &self, + app_catalog: &lapdev_db_entities::kube_app_catalog::Model, + workloads: Vec, + ) -> Result { + let source_server = self + .get_random_kube_cluster_server(app_catalog.cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest( + "No connected KubeManager for the app catalog's source cluster".to_string(), + ) + })?; + + match source_server + .rpc_client + .get_workloads_yaml(tarpc::context::current(), workloads) + .await + { + Ok(Ok(workloads_with_resources)) => Ok(workloads_with_resources), + Ok(Err(e)) => { + tracing::error!( + "Failed to get YAML for workloads from source cluster: {}", + e + ); + Err(ApiError::InvalidRequest(format!( + "Failed to get YAML for workloads from source cluster: {e}" + ))) + } + Err(e) => Err(ApiError::InvalidRequest(format!( + "Connection error to source cluster: {e}" + ))), + } + } + + pub(super) async fn deploy_app_catalog_with_yaml( + &self, + target_server: &KubeClusterServer, + namespace: &str, + environment_name: &str, + environment_id: Uuid, + environment_auth_token: Option, + workloads_with_resources: lapdev_kube_rpc::KubeWorkloadsWithResources, + ) -> Result<(), ApiError> { + tracing::info!( + "Deploying app catalog resources for environment '{}' in namespace '{}'", + environment_name, + namespace + ); + + if workloads_with_resources.workloads.is_empty() { + tracing::warn!("No workloads found for environment '{}'", environment_name); + return Ok(()); + } + + tracing::info!( + "Found {} workloads to deploy for environment '{}'", + workloads_with_resources.workloads.len(), + environment_name + ); + + // Prepare environment-specific labels + let mut environment_labels = std::collections::HashMap::new(); + environment_labels.insert( + "lapdev.environment".to_string(), + environment_name.to_string(), + ); + environment_labels.insert("lapdev.managed-by".to_string(), "lapdev".to_string()); + + // Deploy all workloads and resources in a single call + let auth_token = environment_auth_token.ok_or_else(|| { + ApiError::InvalidRequest("Environment auth token is required".to_string()) + })?; + + match target_server + .rpc_client + .deploy_workload_yaml( + tarpc::context::current(), + environment_id, + auth_token, + namespace.to_string(), + workloads_with_resources, + environment_labels.clone(), + ) + .await + { + Ok(Ok(())) => { + tracing::info!("Successfully deployed all workloads to target cluster"); + Ok(()) + } + Ok(Err(e)) => { + tracing::error!("Failed to deploy workloads to target cluster: {}", e); + Err(ApiError::InvalidRequest(format!( + "Failed to deploy workloads to target cluster: {e}" + ))) + } + Err(e) => Err(ApiError::InvalidRequest(format!( + "Connection error to target cluster: {e}" + ))), + } + } +} diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs new file mode 100644 index 0000000..4217baf --- /dev/null +++ b/crates/api/src/kube_controller/environment.rs @@ -0,0 +1,777 @@ +use uuid::Uuid; + +use lapdev_common::{ + kube::{ + KubeContainerImage, KubeEnvironment, PagePaginationParams, PaginatedInfo, PaginatedResult, + }, + utils::rand_string, +}; +use lapdev_rpc::error::ApiError; + +use super::{EnvironmentNamespaceKind, KubeController}; + +impl KubeController { + pub(super) async fn generate_unique_namespace( + &self, + _cluster_id: Uuid, + kind: EnvironmentNamespaceKind, + ) -> Result { + let prefix = match kind { + EnvironmentNamespaceKind::Personal => "lapdev-personal", + EnvironmentNamespaceKind::Shared => "lapdev-shared", + EnvironmentNamespaceKind::Branch => "lapdev-branch", + }; + + Ok(format!("{prefix}-{}", rand_string(12))) + } + + pub async fn get_all_kube_environments( + &self, + org_id: Uuid, + user_id: Uuid, + search: Option, + is_shared: bool, + is_branch: bool, + pagination: Option, + ) -> Result, ApiError> { + let pagination = pagination.unwrap_or_default(); + + let (environments_with_catalogs_and_clusters, total_count) = self + .db + .get_all_kube_environments_paginated( + org_id, + user_id, + search, + is_shared, + is_branch, + Some(pagination.clone()), + ) + .await + .map_err(ApiError::from)?; + + let kube_environments = environments_with_catalogs_and_clusters + .into_iter() + .filter_map(|(env, catalog, cluster, base_environment_name)| { + let catalog = catalog?; + let cluster = cluster?; + Some(KubeEnvironment { + id: env.id, + name: env.name, + namespace: env.namespace, + app_catalog_id: env.app_catalog_id, + app_catalog_name: catalog.name, + cluster_id: env.cluster_id, + cluster_name: cluster.name, + status: env.status, + created_at: env.created_at.to_string(), + user_id: env.user_id, + is_shared: env.is_shared, + base_environment_id: env.base_environment_id, + base_environment_name, + }) + }) + .collect(); + + let total_pages = (total_count + pagination.page_size - 1) / pagination.page_size; + + Ok(PaginatedResult { + data: kube_environments, + pagination_info: PaginatedInfo { + total_count, + page: pagination.page, + page_size: pagination.page_size, + total_pages, + }, + }) + } + + pub async fn get_kube_environment( + &self, + org_id: Uuid, + user_id: Uuid, + environment_id: Uuid, + ) -> Result { + // Get the environment from database + let environment = self + .db + .get_kube_environment(environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + + // Check authorization + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // If it's a personal environment, check ownership + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + // Get related catalog and cluster info + let catalog = self + .db + .get_app_catalog(environment.app_catalog_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + let cluster = self + .db + .get_kube_cluster(environment.cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + // Get base environment name if this is a branch environment + let base_environment_name = if let Some(base_env_id) = environment.base_environment_id { + self.db + .get_kube_environment(base_env_id) + .await + .map_err(ApiError::from)? + .map(|base_env| base_env.name) + } else { + None + }; + + Ok(KubeEnvironment { + id: environment.id, + user_id: environment.user_id, + name: environment.name, + namespace: environment.namespace, + app_catalog_id: environment.app_catalog_id, + app_catalog_name: catalog.name, + cluster_id: environment.cluster_id, + cluster_name: cluster.name, + status: environment.status, + created_at: environment + .created_at + .format("%Y-%m-%d %H:%M:%S%.f %z") + .to_string(), + is_shared: environment.is_shared, + base_environment_id: environment.base_environment_id, + base_environment_name, + }) + } + + pub async fn delete_kube_environment( + &self, + org_id: Uuid, + user_id: Uuid, + environment_id: Uuid, + ) -> Result<(), ApiError> { + // First get the environment to check ownership + let environment = self + .db + .get_kube_environment(environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + + // Check authorization + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // If it's a personal environment, check ownership + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + // If it's a shared environment, check for depending branch environments + if environment.is_shared { + let has_branches = self + .db + .check_environment_has_branches(environment_id) + .await + .map_err(ApiError::from)?; + + if has_branches { + return Err(ApiError::InvalidRequest( + "Cannot delete shared environment: it has active branch environments. Please delete them first.".to_string() + )); + } + } + + let rpc_client = self + .get_random_kube_cluster_server(environment.cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest( + "No connected KubeManager for this cluster; cannot delete environment" + .to_string(), + ) + })? + .rpc_client + .clone(); + + // If this is a branch environment, notify the base environment's devbox-proxy before deletion + if let Some(base_env_id) = environment.base_environment_id { + match rpc_client + .remove_branch_environment(tarpc::context::current(), base_env_id, environment_id) + .await + { + Ok(Ok(())) => { + tracing::info!( + "Successfully notified devbox-proxy about branch environment {} deletion", + environment_id + ); + } + Ok(Err(e)) => { + tracing::error!( + "Failed to notify devbox-proxy about branch environment {} deletion: {}", + environment_id, + e + ); + return Err(ApiError::InvalidRequest(format!( + "Failed to notify devbox-proxy about branch environment deletion: {e}" + ))); + } + Err(e) => { + tracing::error!( + "RPC call failed when notifying about branch environment {} deletion: {}", + environment_id, + e + ); + return Err(ApiError::InvalidRequest(format!( + "Connection error while notifying devbox-proxy: {e}" + ))); + } + } + } + + match rpc_client + .destroy_environment( + tarpc::context::current(), + environment.id, + environment.namespace.clone(), + ) + .await + { + Ok(Ok(())) => { + tracing::info!( + "Successfully deleted resources for environment {} in namespace {}", + environment.id, + environment.namespace + ); + } + Ok(Err(e)) => { + tracing::error!( + "KubeManager error when deleting environment {}: {}", + environment.id, + e + ); + return Err(ApiError::InvalidRequest(format!( + "Failed to delete environment resources: {e}" + ))); + } + Err(e) => { + tracing::error!( + "Connection error when deleting environment {}: {}", + environment.id, + e + ); + return Err(ApiError::InvalidRequest(format!( + "Failed to communicate with KubeManager to delete environment: {e}" + ))); + } + } + + // Delete from database (soft delete) + self.db + .delete_kube_environment(environment_id) + .await + .map_err(ApiError::from)?; + + Ok(()) + } + + pub async fn create_kube_environment( + &self, + org_id: Uuid, + user_id: Uuid, + app_catalog_id: Uuid, + cluster_id: Uuid, + name: String, + is_shared: bool, + ) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(ApiError::InvalidRequest( + "Environment name cannot be empty".to_string(), + )); + } + let name = name.to_string(); + + // Verify app catalog belongs to the organization + let app_catalog = self + .db + .get_app_catalog(app_catalog_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + if app_catalog.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Verify cluster belongs to the organization + let cluster = self + .db + .get_kube_cluster(cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + if cluster.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Check if the cluster allows deployments for the requested environment type + if is_shared && !cluster.can_deploy_shared { + return Err(ApiError::InvalidRequest( + "Shared deployments are not allowed on this cluster".to_string(), + )); + } + + if !is_shared && !cluster.can_deploy_personal { + return Err(ApiError::InvalidRequest( + "Personal deployments are not allowed on this cluster".to_string(), + )); + } + + // Get a connected KubeClusterServer for this cluster + let server = self + .get_random_kube_cluster_server(cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest( + "No connected KubeManager for the environment target cluster".to_string(), + ) + })?; + + let workloads = self + .db + .get_app_catalog_workloads(app_catalog.id) + .await + .map_err(ApiError::from)?; + + if workloads.is_empty() { + return Err(ApiError::InvalidRequest(format!( + "No workloads found for app catalog '{}'", + app_catalog.name + ))); + } + + // First, get workloads YAML before creating in database to validate success + let workloads_with_resources = self + .get_workloads_yaml_for_catalog(&app_catalog, workloads.clone()) + .await?; + + let namespace = self + .generate_unique_namespace( + cluster_id, + if is_shared { + EnvironmentNamespaceKind::Shared + } else { + EnvironmentNamespaceKind::Personal + }, + ) + .await?; + + let services_map = workloads_with_resources.services.clone(); + + // Store the environment workloads in the database before deployment + let workload_details: Vec = workloads + .into_iter() + .map(|workload| { + let mut containers = workload.containers; + for container in &mut containers { + // Preserve the original environment variables + container.original_env_vars = container.env_vars.clone(); + container.env_vars.clear(); + + // If the app catalog has a customized image, use it as the original_image + // for the new environment (so the environment starts from the customized state) + match &container.image { + KubeContainerImage::Custom(custom_image) => { + container.original_image = custom_image.clone(); + container.image = KubeContainerImage::FollowOriginal; + } + KubeContainerImage::FollowOriginal => { + // Keep the current original_image and FollowOriginal setting + } + } + } + lapdev_common::kube::KubeWorkloadDetails { + name: workload.name, + namespace: namespace.clone(), + kind: workload.kind, + containers, + ports: workload.ports, + workload_yaml: workload.workload_yaml.unwrap_or_default(), + } + }) + .collect(); + + let created_env = match self + .db + .create_kube_environment( + org_id, + user_id, + app_catalog_id, + cluster_id, + name.clone(), + namespace.clone(), + "Pending".to_string(), + is_shared, + None, // No base environment for regular environments + workload_details, + services_map, + ) + .await + { + Ok(env) => env, + Err(db_err) => { + if let Some(sea_orm::SqlErr::UniqueConstraintViolation(constraint)) = + db_err.sql_err() + { + if constraint == "kube_environment_app_cluster_namespace_unique_idx" { + return Err(ApiError::InvalidRequest( + "This app catalog is already deployed to the specified namespace in this cluster".to_string(), + )); + } + if constraint == "kube_environment_cluster_namespace_unique_idx" { + return Err(ApiError::InternalError( + "Namespace allocation conflict. Please retry environment creation." + .to_string(), + )); + } + } + return Err(ApiError::from(anyhow::Error::from(db_err))); + } + }; + + // Deploy the app catalog resources to the cluster using pre-fetched YAML + self.deploy_app_catalog_with_yaml( + &server, + &created_env.namespace, + &name, + created_env.id, + Some(created_env.auth_token.clone()), + workloads_with_resources, + ) + .await?; + + // Convert the database model to the API type + Ok(lapdev_common::kube::KubeEnvironment { + id: created_env.id, + user_id: created_env.user_id, + name: created_env.name, + namespace: created_env.namespace, + status: created_env.status, + is_shared: created_env.is_shared, + app_catalog_id: created_env.app_catalog_id, + app_catalog_name: app_catalog.name, + cluster_id: created_env.cluster_id, + cluster_name: cluster.name, + created_at: created_env.created_at.to_string(), + base_environment_id: created_env.base_environment_id, + base_environment_name: None, // Regular environments have no base environment + }) + } + + pub async fn create_branch_environment( + &self, + org_id: Uuid, + user_id: Uuid, + base_environment_id: Uuid, + name: String, + ) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(ApiError::InvalidRequest( + "Environment name cannot be empty".to_string(), + )); + } + let name = name.to_string(); + + // Get the base environment + let base_environment = self + .db + .get_kube_environment(base_environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Base environment not found".to_string()))?; + + // Verify base environment belongs to the same organization + if base_environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // Verify base environment is shared (only shared environments can be used as base) + if !base_environment.is_shared { + return Err(ApiError::InvalidRequest( + "Only shared environments can be used as base environments".to_string(), + )); + } + + // Verify base environment is not itself a branch environment + if base_environment.base_environment_id.is_some() { + return Err(ApiError::InvalidRequest( + "Cannot create a branch from another branch environment".to_string(), + )); + } + + // Get the cluster to verify personal deployments are allowed + let cluster = self + .db + .get_kube_cluster(base_environment.cluster_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + // Get workloads and services from the base environment + let base_workloads = self + .db + .get_environment_workloads(base_environment_id) + .await + .map_err(ApiError::from)?; + + let base_services = self + .db + .get_environment_services(base_environment_id) + .await + .map_err(ApiError::from)?; + + let namespace = self + .generate_unique_namespace( + base_environment.cluster_id, + EnvironmentNamespaceKind::Branch, + ) + .await?; + + let services_map: std::collections::HashMap< + String, + lapdev_common::kube::KubeServiceWithYaml, + > = base_services + .into_iter() + .map(|service| { + ( + service.name.clone(), + lapdev_common::kube::KubeServiceWithYaml { + yaml: service.yaml, + details: lapdev_common::kube::KubeServiceDetails { + name: service.name, + ports: service.ports, + selector: service.selector, + }, + }, + ) + }) + .collect(); + + // Get app catalog info for the response + let app_catalog = self + .db + .get_app_catalog(base_environment.app_catalog_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + // Convert workloads to the format needed for database creation + let workload_details: Vec = base_workloads + .into_iter() + .filter_map(|workload| { + workload.kind.parse().ok().map(|kind| { + let mut containers = workload.containers; + for container in &mut containers { + // Preserve the original environment variables + container.original_env_vars = container.env_vars.clone(); + container.env_vars.clear(); + + // If the base environment has a customized image, use it as the original_image + // for the new branch environment (so the branch starts from the customized state) + match &container.image { + KubeContainerImage::Custom(custom_image) => { + container.original_image = custom_image.clone(); + container.image = KubeContainerImage::FollowOriginal; + } + KubeContainerImage::FollowOriginal => { + // Keep the current original_image and FollowOriginal setting + } + } + } + lapdev_common::kube::KubeWorkloadDetails { + name: workload.name, + namespace: namespace.clone(), + kind, + containers, + ports: workload.ports, + workload_yaml: String::new(), + } + }) + }) + .collect(); + + let created_env = match self + .db + .create_kube_environment( + org_id, + user_id, + base_environment.app_catalog_id, + base_environment.cluster_id, + name.clone(), + namespace.clone(), + "Pending".to_string(), + false, // Branch environments are always personal (not shared) + Some(base_environment_id), // Set the base environment reference + workload_details, + services_map, + ) + .await + { + Ok(env) => env, + Err(db_err) => { + if let Some(sea_orm::SqlErr::UniqueConstraintViolation(constraint)) = + db_err.sql_err() + { + if constraint == "kube_environment_cluster_namespace_unique_idx" { + return Err(ApiError::InternalError( + "Namespace allocation conflict. Please retry branch environment creation.".to_string(), + )); + } + } + return Err(ApiError::from(anyhow::Error::from(db_err))); + } + }; + + // Notify kube-manager about the new branch environment via RPC + if let Some(server) = self + .get_random_kube_cluster_server(base_environment.cluster_id) + .await + { + let branch_info = lapdev_kube_rpc::BranchEnvironmentInfo { + environment_id: created_env.id, + auth_token: created_env.auth_token.clone(), + namespace: created_env.namespace.clone(), + }; + + match server + .rpc_client + .add_branch_environment(tarpc::context::current(), base_environment_id, branch_info) + .await + { + Ok(Ok(())) => { + tracing::info!( + "Successfully notified devbox-proxy about new branch environment {}", + created_env.id + ); + } + Ok(Err(e)) => { + tracing::error!( + "Failed to notify devbox-proxy about new branch environment {}: {}", + created_env.id, + e + ); + } + Err(e) => { + tracing::error!( + "RPC call failed when notifying about new branch environment {}: {}", + created_env.id, + e + ); + } + } + } else { + tracing::warn!( + "No connected KubeManager for cluster {} - branch environment {} not registered with devbox-proxy", + base_environment.cluster_id, + created_env.id + ); + } + + // Convert the database model to the API type + Ok(lapdev_common::kube::KubeEnvironment { + id: created_env.id, + user_id: created_env.user_id, + name: created_env.name, + namespace: created_env.namespace, + status: created_env.status, + is_shared: created_env.is_shared, + app_catalog_id: created_env.app_catalog_id, + app_catalog_name: app_catalog.name, + cluster_id: created_env.cluster_id, + cluster_name: cluster.name, + created_at: created_env.created_at.to_string(), + base_environment_id: created_env.base_environment_id, + base_environment_name: Some(base_environment.name), // Branch environments have the base environment name + }) + } + + // Kube Namespace operations + pub async fn create_kube_namespace( + &self, + org_id: Uuid, + user_id: Uuid, + name: String, + description: Option, + is_shared: bool, + ) -> Result { + let namespace = self + .db + .create_kube_namespace(org_id, user_id, name, description, is_shared) + .await + .map_err(ApiError::from)?; + + Ok(lapdev_common::kube::KubeNamespace { + id: namespace.id, + name: namespace.name, + description: namespace.description, + is_shared: namespace.is_shared, + created_at: namespace.created_at, + created_by: namespace.user_id, + }) + } + + pub async fn get_all_kube_namespaces( + &self, + org_id: Uuid, + user_id: Uuid, + is_shared: bool, + ) -> Result, ApiError> { + let namespaces = self + .db + .get_all_kube_namespaces(org_id, user_id, is_shared) + .await + .map_err(ApiError::from)?; + + let kube_namespaces = namespaces + .into_iter() + .map(|ns| lapdev_common::kube::KubeNamespace { + id: ns.id, + name: ns.name, + description: ns.description, + is_shared: ns.is_shared, + created_at: ns.created_at, + created_by: ns.user_id, + }) + .collect(); + + Ok(kube_namespaces) + } + + pub async fn delete_kube_namespace( + &self, + _org_id: Uuid, + namespace_id: Uuid, + ) -> Result<(), ApiError> { + self.db + .delete_kube_namespace(namespace_id) + .await + .map_err(ApiError::from)?; + + Ok(()) + } +} diff --git a/crates/api/src/kube_controller/mod.rs b/crates/api/src/kube_controller/mod.rs new file mode 100644 index 0000000..1843cbf --- /dev/null +++ b/crates/api/src/kube_controller/mod.rs @@ -0,0 +1,59 @@ +use std::{ + collections::HashMap, + sync::Arc, +}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use lapdev_db::api::DbApi; +use lapdev_kube::server::KubeClusterServer; +use lapdev_kube::tunnel::TunnelRegistry; + +// Submodules +pub mod validation; +pub mod yaml_parser; +mod cluster; +mod app_catalog; +mod environment; +mod workload; +mod service; +mod preview_url; +mod deployment; + +// Re-exports +pub use validation::*; +pub use yaml_parser::*; + +pub(crate) enum EnvironmentNamespaceKind { + Personal, + Shared, + Branch, +} + +#[derive(Clone)] +pub struct KubeController { + // KubeManager connections per cluster + pub kube_cluster_servers: Arc>>>, + // Tunnel registry for preview URL functionality + pub tunnel_registry: Arc, + // Database API + pub db: DbApi, +} + +impl KubeController { + pub fn new(db: DbApi) -> Self { + Self { + kube_cluster_servers: Arc::new(RwLock::new(HashMap::new())), + tunnel_registry: Arc::new(TunnelRegistry::new()), + db, + } + } + + pub async fn get_random_kube_cluster_server( + &self, + cluster_id: Uuid, + ) -> Option { + let servers = self.kube_cluster_servers.read().await; + servers.get(&cluster_id)?.last().cloned() + } +} diff --git a/crates/api/src/kube_controller/preview_url.rs b/crates/api/src/kube_controller/preview_url.rs new file mode 100644 index 0000000..831eb4d --- /dev/null +++ b/crates/api/src/kube_controller/preview_url.rs @@ -0,0 +1,281 @@ +use uuid::Uuid; + +use lapdev_common::{kube::KubeEnvironmentPreviewUrl, utils::rand_string}; +use lapdev_rpc::error::ApiError; + +use super::KubeController; + +impl KubeController { + pub async fn create_environment_preview_url( + &self, + org_id: Uuid, + user_id: Uuid, + environment_id: Uuid, + request: lapdev_common::kube::CreateKubeEnvironmentPreviewUrlRequest, + ) -> Result { + // Verify environment belongs to the organization and check ownership + let environment = self + .db + .get_kube_environment(environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // If it's a personal environment, check ownership + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + // Verify service exists and belongs to the environment + let service = self + .db + .get_kube_environment_service_by_id(request.service_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Service not found".to_string()))?; + + // Verify service belongs to the environment + if service.environment_id != environment_id { + return Err(ApiError::InvalidRequest( + "Service not found in environment".to_string(), + )); + } + + // Validate the port exists on the service + let port_exists = service.ports.iter().any(|p| { + p.port == request.port + && (request.port_name.is_none() || p.name.as_ref() == request.port_name.as_ref()) + }); + + if !port_exists { + return Err(ApiError::InvalidRequest( + "Specified port does not exist on the service".to_string(), + )); + } + + // Auto-generate name based on service and port + let auto_name = format!("{}-{}-{}", request.port, service.name, rand_string(12)); + + // Set defaults + let protocol = request.protocol.unwrap_or_else(|| "HTTP".to_string()); + let access_level = request + .access_level + .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal); + + let url = format!("https://{auto_name}.app.lap.dev"); + + // Create preview URL in database + let preview_url = match self + .db + .create_environment_preview_url( + environment_id, + request.service_id, + user_id, + auto_name, + request.description, + request.port, + request.port_name, + protocol.clone(), + access_level.clone(), + ) + .await + { + Ok(url) => url, + Err(db_err) => { + // Check if this is a unique constraint violation + if matches!( + db_err.sql_err(), + Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) + ) { + return Err(ApiError::InvalidRequest( + "A preview URL already exists for this service and port combination" + .to_string(), + )); + } else { + return Err(ApiError::from(anyhow::Error::from(db_err))); + } + } + }; + + Ok(KubeEnvironmentPreviewUrl { + id: preview_url.id, + created_at: preview_url.created_at, + environment_id: preview_url.environment_id, + service_id: preview_url.service_id, + name: preview_url.name, + description: preview_url.description, + port: preview_url.port, + port_name: preview_url.port_name, + protocol: preview_url.protocol, + access_level: preview_url + .access_level + .parse() + .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal), + created_by: preview_url.created_by, + last_accessed_at: preview_url.last_accessed_at, + url, + }) + } + + pub async fn get_environment_preview_urls( + &self, + org_id: Uuid, + user_id: Uuid, + environment_id: Uuid, + ) -> Result, ApiError> { + // Verify environment belongs to the organization and check ownership + let environment = self + .db + .get_kube_environment(environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // If it's a personal environment, check ownership + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + let preview_urls = self + .db + .get_environment_preview_urls(environment_id) + .await + .map_err(ApiError::from)?; + + Ok(preview_urls + .into_iter() + .map(|preview_url| { + let url = format!("https://{}.app.lap.dev", preview_url.name); + + KubeEnvironmentPreviewUrl { + id: preview_url.id, + created_at: preview_url.created_at, + environment_id: preview_url.environment_id, + service_id: preview_url.service_id, + name: preview_url.name, + description: preview_url.description, + port: preview_url.port, + port_name: preview_url.port_name, + protocol: preview_url.protocol, + access_level: preview_url + .access_level + .parse() + .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal), + created_by: preview_url.created_by, + last_accessed_at: preview_url.last_accessed_at, + url, + } + }) + .collect()) + } + + pub async fn update_environment_preview_url( + &self, + org_id: Uuid, + user_id: Uuid, + preview_url_id: Uuid, + request: lapdev_common::kube::UpdateKubeEnvironmentPreviewUrlRequest, + ) -> Result { + // Get the preview URL and verify ownership + let preview_url = self + .db + .get_environment_preview_url(preview_url_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Preview URL not found".to_string()))?; + + // Verify environment belongs to the organization and check ownership + let environment = self + .db + .get_kube_environment(preview_url.environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // If it's a personal environment, check ownership + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + // Update the preview URL + let updated_preview_url = self + .db + .update_environment_preview_url( + preview_url_id, + request.description, + request.access_level, + ) + .await + .map_err(ApiError::from)?; + + let url = format!("https://{}.app.lap.dev", updated_preview_url.name); + + Ok(KubeEnvironmentPreviewUrl { + id: updated_preview_url.id, + created_at: updated_preview_url.created_at, + environment_id: updated_preview_url.environment_id, + service_id: updated_preview_url.service_id, + name: updated_preview_url.name, + description: updated_preview_url.description, + port: updated_preview_url.port, + port_name: updated_preview_url.port_name, + protocol: updated_preview_url.protocol, + access_level: updated_preview_url + .access_level + .parse() + .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal), + created_by: updated_preview_url.created_by, + last_accessed_at: updated_preview_url.last_accessed_at, + url, + }) + } + + pub async fn delete_environment_preview_url( + &self, + org_id: Uuid, + user_id: Uuid, + preview_url_id: Uuid, + ) -> Result<(), ApiError> { + // Get the preview URL and verify ownership + let preview_url = self + .db + .get_environment_preview_url(preview_url_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Preview URL not found".to_string()))?; + + // Verify environment belongs to the organization and check ownership + let environment = self + .db + .get_kube_environment(preview_url.environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // If it's a personal environment, check ownership + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + self.db + .delete_environment_preview_url(preview_url_id) + .await + .map_err(ApiError::from) + } +} diff --git a/crates/api/src/kube_controller/service.rs b/crates/api/src/kube_controller/service.rs new file mode 100644 index 0000000..09ed01b --- /dev/null +++ b/crates/api/src/kube_controller/service.rs @@ -0,0 +1,37 @@ +use uuid::Uuid; + +use lapdev_rpc::error::ApiError; + +use super::KubeController; + +impl KubeController { + pub async fn get_environment_services( + &self, + org_id: Uuid, + user_id: Uuid, + environment_id: Uuid, + ) -> Result, ApiError> { + // Verify environment belongs to the organization + let environment = self + .db + .get_kube_environment(environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + + // Check authorization + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // If it's a personal environment, check ownership + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + self.db + .get_environment_services(environment_id) + .await + .map_err(ApiError::from) + } +} diff --git a/crates/api/src/kube_controller/validation.rs b/crates/api/src/kube_controller/validation.rs new file mode 100644 index 0000000..695a6fc --- /dev/null +++ b/crates/api/src/kube_controller/validation.rs @@ -0,0 +1,216 @@ +use lapdev_common::kube::{KubeContainerImage, KubeContainerInfo}; +use lapdev_rpc::error::ApiError; + +pub fn validate_containers(containers: &[KubeContainerInfo]) -> Result<(), ApiError> { + if containers.is_empty() { + return Err(ApiError::InvalidRequest( + "At least one container is required".to_string(), + )); + } + + for (index, container) in containers.iter().enumerate() { + if container.name.trim().is_empty() { + return Err(ApiError::InvalidRequest(format!( + "Container {} name cannot be empty", + index + 1 + ))); + } + + match &container.image { + KubeContainerImage::Custom(image) => { + if image.trim().is_empty() { + return Err(ApiError::InvalidRequest(format!( + "Container '{}' custom image cannot be empty", + container.name + ))); + } + } + KubeContainerImage::FollowOriginal => { + // No validation needed for FollowOriginal + } + } + + // Validate CPU resources + if let Some(cpu_request) = &container.cpu_request { + if !is_valid_cpu_quantity(cpu_request) { + return Err(ApiError::InvalidRequest(format!("Container '{}' has invalid CPU request format. Use formats like '100m', '0.1', '1'. Minimum precision is 1m (0.001 CPU)", container.name))); + } + } + + if let Some(cpu_limit) = &container.cpu_limit { + if !is_valid_cpu_quantity(cpu_limit) { + return Err(ApiError::InvalidRequest(format!("Container '{}' has invalid CPU limit format. Use formats like '100m', '0.1', '1'. Minimum precision is 1m (0.001 CPU)", container.name))); + } + } + + // Validate memory resources + if let Some(memory_request) = &container.memory_request { + if !is_valid_memory_quantity(memory_request) { + return Err(ApiError::InvalidRequest(format!("Container '{}' has invalid memory request format. Use formats like '128Mi', '1Gi', '512M'. Maximum 3 decimal places allowed", container.name))); + } + } + + if let Some(memory_limit) = &container.memory_limit { + if !is_valid_memory_quantity(memory_limit) { + return Err(ApiError::InvalidRequest(format!("Container '{}' has invalid memory limit format. Use formats like '128Mi', '1Gi', '512M'. Maximum 3 decimal places allowed", container.name))); + } + } + + // Validate environment variables + for env_var in &container.env_vars { + if env_var.name.trim().is_empty() { + return Err(ApiError::InvalidRequest(format!( + "Container '{}' has environment variable with empty name", + container.name + ))); + } + } + } + + Ok(()) +} + +pub fn is_valid_cpu_quantity(quantity: &str) -> bool { + // CPU validation for Kubernetes + // Supports formats like: 100m, 0.1, 1, 2.5, etc. + // Kubernetes minimum precision is 1m (0.001 CPU) + if quantity.trim().is_empty() { + return false; // Empty values are invalid - must specify a resource amount + } + + let quantity = quantity.trim(); + + // CPU can have 'm' suffix for millicores or be a plain decimal number + let re = match regex::Regex::new(r"^(\d+\.?\d*|\.\d+)m?$") { + Ok(regex) => regex, + Err(_) => return false, + }; + + if !re.is_match(quantity) { + return false; + } + + // Parse the numeric part and validate precision constraints + let (numeric_part, has_millicore_suffix) = if quantity.ends_with('m') { + (&quantity[..quantity.len() - 1], true) + } else { + (quantity, false) + }; + + if let Ok(value) = numeric_part.parse::() { + if value <= 0.0 { + return false; // Must be positive + } + + if has_millicore_suffix { + // For millicores (m suffix), minimum is 1m, so value must be >= 1.0 + value >= 1.0 + } else { + // For plain decimal CPU values, minimum precision is 0.001 (1m equivalent) + value >= 0.001 + } + } else { + false + } +} + +pub fn is_valid_memory_quantity(quantity: &str) -> bool { + // Memory validation for Kubernetes + // Supports formats like: 128Mi, 1Gi, 512M, 1000000000, etc. + // Maximum precision is 3 decimal places + if quantity.trim().is_empty() { + return false; // Empty values are invalid - must specify a resource amount + } + + let quantity = quantity.trim(); + + // Memory can have binary (Ki, Mi, Gi, Ti, Pi, Ei) or decimal (K, M, G, T, P, E) suffixes + let valid_suffixes = [ + "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "K", "M", "G", "T", "P", "E", + ]; + + let (numeric_part, _suffix) = + if let Some(suffix) = valid_suffixes.iter().find(|&s| quantity.ends_with(s)) { + (&quantity[..quantity.len() - suffix.len()], Some(*suffix)) + } else { + // No suffix means it's in bytes + (quantity, None) + }; + + // Validate the numeric part is a positive number with max 3 decimal places + if let Ok(value) = numeric_part.parse::() { + if value <= 0.0 { + return false; // Must be positive + } + + // Check decimal places constraint (max 3 decimal places) + if let Some(decimal_pos) = numeric_part.find('.') { + let decimal_part = &numeric_part[decimal_pos + 1..]; + if decimal_part.len() > 3 { + return false; // More than 3 decimal places + } + } + + true + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cpu_validation() { + // Valid CPU values + assert!(is_valid_cpu_quantity("100m")); // 100 millicores + assert!(is_valid_cpu_quantity("1m")); // 1 millicore (minimum) + assert!(is_valid_cpu_quantity("0.1")); // 0.1 CPU (100m equivalent) + assert!(is_valid_cpu_quantity("1")); // 1 CPU + assert!(is_valid_cpu_quantity("2.5")); // 2.5 CPUs + assert!(is_valid_cpu_quantity("500m")); // 500 millicores + assert!(is_valid_cpu_quantity("1.5m")); // 1.5 millicores + assert!(is_valid_cpu_quantity("0.001")); // Minimum decimal precision + + // Invalid CPU values + assert!(!is_valid_cpu_quantity("")); // Empty + assert!(!is_valid_cpu_quantity(" ")); // Whitespace + assert!(!is_valid_cpu_quantity("0.5m")); // Below 1m minimum + assert!(!is_valid_cpu_quantity("0.0005")); // Below 0.001 minimum + assert!(!is_valid_cpu_quantity("0m")); // Zero millicores + assert!(!is_valid_cpu_quantity("0")); // Zero CPU + assert!(!is_valid_cpu_quantity("100Mi")); // Wrong suffix + assert!(!is_valid_cpu_quantity("-100m")); // Negative + assert!(!is_valid_cpu_quantity("abc")); // Non-numeric + assert!(!is_valid_cpu_quantity("100x")); // Invalid suffix + } + + #[test] + fn test_memory_validation() { + // Valid memory values + assert!(is_valid_memory_quantity("128Mi")); + assert!(is_valid_memory_quantity("1Gi")); + assert!(is_valid_memory_quantity("512M")); + assert!(is_valid_memory_quantity("1000000000")); // Raw bytes + assert!(is_valid_memory_quantity("2Ti")); + assert!(is_valid_memory_quantity("1.5")); // 1 decimal place + assert!(is_valid_memory_quantity("1.5Gi")); // 1 decimal place + assert!(is_valid_memory_quantity("128.25Mi")); // 2 decimal places + assert!(is_valid_memory_quantity("1.125Gi")); // 3 decimal places (max) + assert!(is_valid_memory_quantity("0.5Gi")); // Decimal with suffix + + // Invalid memory values + assert!(!is_valid_memory_quantity("")); // Empty + assert!(!is_valid_memory_quantity(" ")); // Whitespace + assert!(!is_valid_memory_quantity("100m")); // CPU suffix on memory + assert!(!is_valid_memory_quantity("-128Mi")); // Negative + assert!(!is_valid_memory_quantity("abc")); // Non-numeric + assert!(!is_valid_memory_quantity("100x")); // Invalid suffix + assert!(!is_valid_memory_quantity("0Mi")); // Zero + assert!(!is_valid_memory_quantity("1.1234Gi")); // 4 decimal places (too many) + assert!(!is_valid_memory_quantity("1.1234")); // 4 decimal places (too many) + assert!(!is_valid_memory_quantity("128.12345Mi")); // 5 decimal places (too many) + assert!(!is_valid_memory_quantity("128.12345")); // 5 decimal places (too many) + } +} diff --git a/crates/api/src/kube_controller/workload.rs b/crates/api/src/kube_controller/workload.rs new file mode 100644 index 0000000..361cf1e --- /dev/null +++ b/crates/api/src/kube_controller/workload.rs @@ -0,0 +1,246 @@ +use uuid::Uuid; + +use lapdev_rpc::error::ApiError; + +use super::KubeController; + +impl KubeController { + pub async fn get_environment_workloads( + &self, + org_id: Uuid, + user_id: Uuid, + environment_id: Uuid, + ) -> Result, ApiError> { + // Verify environment belongs to the organization + let environment = self + .db + .get_kube_environment(environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + + // Check authorization + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // If it's a personal environment, check ownership + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + self.db + .get_environment_workloads(environment_id) + .await + .map_err(ApiError::from) + } + + pub async fn get_environment_workload( + &self, + org_id: Uuid, + workload_id: Uuid, + ) -> Result, ApiError> { + // First get the workload to find its environment + if let Some(workload) = self + .db + .get_environment_workload(workload_id) + .await + .map_err(ApiError::from)? + { + // Verify the environment belongs to the organization + let environment = self + .db + .get_kube_environment(workload.environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + Ok(Some(workload)) + } else { + Ok(None) + } + } + + pub async fn delete_environment_workload( + &self, + org_id: Uuid, + user_id: Uuid, + workload_id: Uuid, + environment: lapdev_db_entities::kube_environment::Model, + ) -> Result<(), ApiError> { + // Verify the environment belongs to the organization + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // For personal/branch environments, verify ownership + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + // Delete the workload + self.db + .delete_environment_workload(workload_id) + .await + .map_err(ApiError::from) + } + + pub async fn update_environment_workload( + &self, + org_id: Uuid, + user_id: Uuid, + workload_id: Uuid, + containers: Vec, + environment: lapdev_db_entities::kube_environment::Model, + ) -> Result<(), ApiError> { + // Verify the environment belongs to the organization + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + // For personal/branch environments, verify ownership + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + // Update the workload containers in database + let updated_db_model = self + .db + .update_environment_workload(workload_id, containers) + .await + .map_err(ApiError::from)?; + + // Convert database model to API type + let updated_workload = { + let containers: Vec = + serde_json::from_value(updated_db_model.containers.clone()).unwrap_or_default(); + + let ports: Vec = + serde_json::from_value(updated_db_model.ports.clone()).unwrap_or_default(); + + lapdev_common::kube::KubeEnvironmentWorkload { + id: updated_db_model.id, + created_at: updated_db_model.created_at, + environment_id: updated_db_model.environment_id, + name: updated_db_model.name.clone(), + namespace: updated_db_model.namespace.clone(), + kind: updated_db_model.kind.clone(), + containers, + ports, + } + }; + + // After successful database update, deploy the workload to the cluster + let cluster_server = self + .get_random_kube_cluster_server(environment.cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) + })?; + + // Convert to proper workload kind + let workload_kind = updated_workload.kind.parse().map_err(|_| { + ApiError::InvalidRequest(format!("Invalid workload kind: {}", updated_workload.kind)) + })?; + + // Prepare environment-specific labels + let mut environment_labels = std::collections::HashMap::new(); + environment_labels.insert("lapdev.environment".to_string(), environment.name.clone()); + environment_labels.insert("lapdev.managed-by".to_string(), "lapdev".to_string()); + + // Check if this is a branch environment - if so, create a new deployment + if environment.base_environment_id.is_some() { + tracing::info!( + "Creating branch deployment for workload '{}' in branch environment '{}' (namespace '{}')", + updated_workload.name, + environment.name, + environment.namespace + ); + + // For branch environments, we need to get the base workload name and create a branch deployment + // The base workload name is the original workload name without branch suffix + let base_workload_name = updated_workload.name.clone(); + let branch_environment_id = environment.id; + + match cluster_server + .rpc_client + .create_branch_workload( + tarpc::context::current(), + updated_workload.id, + base_workload_name.clone(), + branch_environment_id, + environment.auth_token.clone(), + updated_workload.namespace.clone(), + workload_kind, + updated_workload.containers, + environment_labels, + ) + .await + { + Ok(Ok(())) => { + tracing::info!( + "Successfully created branch deployment for workload '{}' in branch environment '{}' (namespace '{}')", + updated_workload.name, + environment.name, + environment.namespace + ); + Ok(()) + } + Ok(Err(e)) => { + tracing::error!("Failed to create branch deployment: {}", e); + Err(ApiError::InvalidRequest(format!( + "Failed to create branch deployment: {e}" + ))) + } + Err(e) => Err(ApiError::InvalidRequest(format!( + "Connection error during branch deployment creation: {e}" + ))), + } + } else { + // For regular environments, update the existing workload containers + tracing::info!( + "Updating workload containers for '{}' in regular environment '{}' (namespace '{}')", + updated_workload.name, + environment.name, + environment.namespace + ); + + match cluster_server + .rpc_client + .update_workload_containers( + tarpc::context::current(), + environment.id, + environment.auth_token.clone(), + updated_workload.id, + updated_workload.name.clone(), + updated_workload.namespace.clone(), + workload_kind, + updated_workload.containers, + environment_labels, + ) + .await + { + Ok(Ok(())) => { + tracing::info!( + "Successfully updated workload containers for '{}' in environment '{}' (namespace '{}')", + updated_workload.name, + environment.name, + environment.namespace + ); + Ok(()) + } + Ok(Err(e)) => { + tracing::error!("Failed to atomically update workload containers: {}", e); + Err(ApiError::InvalidRequest(format!( + "Failed to update workload containers: {e}" + ))) + } + Err(e) => Err(ApiError::InvalidRequest(format!( + "Connection error during atomic workload update: {e}" + ))), + } + } + } +} diff --git a/crates/api/src/kube_controller/yaml_parser.rs b/crates/api/src/kube_controller/yaml_parser.rs new file mode 100644 index 0000000..551819a --- /dev/null +++ b/crates/api/src/kube_controller/yaml_parser.rs @@ -0,0 +1,254 @@ +use std::collections::{BTreeMap, HashSet}; + +use anyhow::anyhow; +use k8s_openapi::api::core::v1::PodSpec; +use k8s_openapi::api::{ + apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}, + batch::v1::{CronJob, Job}, + core::v1::Pod, +}; +use lapdev_common::kube::{ + KubeContainerImage, KubeContainerInfo, KubeContainerPort, KubeServicePort, KubeWorkloadKind, +}; +use lapdev_db::api::CachedClusterService; +use lapdev_kube_rpc::KubeRawWorkloadYaml; + +pub fn build_workload_details_from_yaml( + raw: KubeRawWorkloadYaml, + services: &[CachedClusterService], +) -> anyhow::Result { + let KubeRawWorkloadYaml { + name, + namespace, + kind, + workload_yaml, + } = raw; + + let (containers, labels) = extract_containers_and_labels(&kind, &workload_yaml)?; + let ports = ports_from_cached_services(&labels, services); + + Ok(lapdev_common::kube::KubeWorkloadDetails { + name, + namespace, + kind, + containers, + ports, + workload_yaml, + }) +} + +fn extract_containers_and_labels( + kind: &KubeWorkloadKind, + workload_yaml: &str, +) -> anyhow::Result<(Vec, BTreeMap)> { + match kind { + KubeWorkloadKind::Deployment => { + let deployment: Deployment = serde_yaml::from_str(workload_yaml)?; + let labels = deployment + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = deployment + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .ok_or_else(|| anyhow!("Deployment missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::StatefulSet => { + let statefulset: StatefulSet = serde_yaml::from_str(workload_yaml)?; + let labels = statefulset + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = statefulset + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .ok_or_else(|| anyhow!("StatefulSet missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::DaemonSet => { + let daemonset: DaemonSet = serde_yaml::from_str(workload_yaml)?; + let labels = daemonset + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = daemonset + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .ok_or_else(|| anyhow!("DaemonSet missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::ReplicaSet => { + let replicaset: ReplicaSet = serde_yaml::from_str(workload_yaml)?; + let labels = replicaset + .spec + .as_ref() + .and_then(|s| s.template.as_ref()) + .and_then(|t| t.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = replicaset + .spec + .as_ref() + .and_then(|s| s.template.as_ref()) + .and_then(|t| t.spec.as_ref()) + .ok_or_else(|| anyhow!("ReplicaSet missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::Pod => { + let pod: Pod = serde_yaml::from_str(workload_yaml)?; + let labels = pod.metadata.labels.clone().unwrap_or_default(); + let pod_spec = pod + .spec + .as_ref() + .ok_or_else(|| anyhow!("Pod missing spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::Job => { + let job: Job = serde_yaml::from_str(workload_yaml)?; + let labels = job + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = job + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .ok_or_else(|| anyhow!("Job missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + KubeWorkloadKind::CronJob => { + let cronjob: CronJob = serde_yaml::from_str(workload_yaml)?; + let labels = cronjob + .spec + .as_ref() + .and_then(|s| s.job_template.spec.as_ref()) + .and_then(|js| js.template.metadata.as_ref()) + .and_then(|m| m.labels.clone()) + .unwrap_or_default(); + let pod_spec = cronjob + .spec + .as_ref() + .and_then(|s| s.job_template.spec.as_ref()) + .and_then(|js| js.template.spec.as_ref()) + .ok_or_else(|| anyhow!("CronJob missing pod spec"))?; + let containers = extract_pod_spec_containers(pod_spec)?; + Ok((containers, labels)) + } + } +} + +fn extract_pod_spec_containers(pod_spec: &PodSpec) -> anyhow::Result> { + pod_spec + .containers + .iter() + .map(|container| { + let mut cpu_request = None; + let mut cpu_limit = None; + let mut memory_request = None; + let mut memory_limit = None; + + if let Some(resources) = &container.resources { + if let Some(requests) = &resources.requests { + if let Some(cpu_req) = requests.get("cpu") { + cpu_request = Some(cpu_req.0.clone()); + } + if let Some(memory_req) = requests.get("memory") { + memory_request = Some(memory_req.0.clone()); + } + } + + if let Some(limits) = &resources.limits { + if let Some(cpu_lim) = limits.get("cpu") { + cpu_limit = Some(cpu_lim.0.clone()); + } + if let Some(memory_lim) = limits.get("memory") { + memory_limit = Some(memory_lim.0.clone()); + } + } + } + + let image = container + .image + .clone() + .ok_or_else(|| anyhow!("Container '{}' has no image specified", container.name))?; + + let ports = container + .ports + .as_ref() + .map(|ports| { + ports + .iter() + .map(|port| KubeContainerPort { + name: port.name.clone(), + container_port: port.container_port, + protocol: port.protocol.clone(), + }) + .collect::>() + }) + .unwrap_or_default(); + + Ok(KubeContainerInfo { + name: container.name.clone(), + original_image: image.clone(), + image: KubeContainerImage::FollowOriginal, + cpu_request, + cpu_limit, + memory_request, + memory_limit, + env_vars: Vec::new(), + original_env_vars: Vec::new(), + ports, + }) + }) + .collect() +} + +fn ports_from_cached_services( + workload_labels: &BTreeMap, + services: &[CachedClusterService], +) -> Vec { + let mut seen = HashSet::new(); + let mut ports = Vec::new(); + + for service in services { + if service.selector.is_empty() { + continue; + } + + let matches = service + .selector + .iter() + .all(|(key, value)| workload_labels.get(key).map_or(false, |v| v == value)); + + if !matches { + continue; + } + + for port in &service.ports { + let key = (port.port, port.target_port, port.protocol.clone()); + if seen.insert(key) { + ports.push(port.clone()); + } + } + } + + ports +} From b6ca1654f51f2e09998b88153192fe9369dc4264 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 09:55:57 +0000 Subject: [PATCH 133/334] update --- crates/api/src/kube_controller/environment.rs | 8 ++++++++ crates/api/src/kube_controller/mod.rs | 17 +++++++---------- crates/common/src/kube.rs | 2 ++ crates/db/entities/src/kube_app_catalog.rs | 2 ++ crates/db/entities/src/kube_environment.rs | 2 ++ .../m20250801_000000_create_kube_app_catalog.rs | 9 +++++++++ .../m20250809_000001_create_kube_environment.rs | 12 ++++++++++++ crates/db/src/api.rs | 6 ++++++ 8 files changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index 4217baf..f37144d 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -68,6 +68,8 @@ impl KubeController { is_shared: env.is_shared, base_environment_id: env.base_environment_id, base_environment_name, + catalog_sync_version: env.catalog_sync_version, + last_catalog_synced_at: env.last_catalog_synced_at.map(|dt| dt.to_string()), }) }) .collect(); @@ -152,6 +154,8 @@ impl KubeController { is_shared: environment.is_shared, base_environment_id: environment.base_environment_id, base_environment_name, + catalog_sync_version: environment.catalog_sync_version, + last_catalog_synced_at: environment.last_catalog_synced_at.map(|dt| dt.to_string()), }) } @@ -479,6 +483,8 @@ impl KubeController { created_at: created_env.created_at.to_string(), base_environment_id: created_env.base_environment_id, base_environment_name: None, // Regular environments have no base environment + catalog_sync_version: created_env.catalog_sync_version, + last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), }) } @@ -707,6 +713,8 @@ impl KubeController { created_at: created_env.created_at.to_string(), base_environment_id: created_env.base_environment_id, base_environment_name: Some(base_environment.name), // Branch environments have the base environment name + catalog_sync_version: created_env.catalog_sync_version, + last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), }) } diff --git a/crates/api/src/kube_controller/mod.rs b/crates/api/src/kube_controller/mod.rs index 1843cbf..0a0395c 100644 --- a/crates/api/src/kube_controller/mod.rs +++ b/crates/api/src/kube_controller/mod.rs @@ -1,7 +1,4 @@ -use std::{ - collections::HashMap, - sync::Arc, -}; +use std::{collections::HashMap, sync::Arc}; use tokio::sync::RwLock; use uuid::Uuid; @@ -10,15 +7,15 @@ use lapdev_kube::server::KubeClusterServer; use lapdev_kube::tunnel::TunnelRegistry; // Submodules -pub mod validation; -pub mod yaml_parser; -mod cluster; mod app_catalog; +mod cluster; +mod deployment; mod environment; -mod workload; -mod service; mod preview_url; -mod deployment; +mod service; +pub mod validation; +mod workload; +pub mod yaml_parser; // Re-exports pub use validation::*; diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index a8ccb82..01eb9a0 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -289,6 +289,8 @@ pub struct KubeEnvironment { pub is_shared: bool, pub base_environment_id: Option, pub base_environment_name: Option, + pub catalog_sync_version: i64, + pub last_catalog_synced_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/db/entities/src/kube_app_catalog.rs b/crates/db/entities/src/kube_app_catalog.rs index 5b8545b..5a31eaa 100644 --- a/crates/db/entities/src/kube_app_catalog.rs +++ b/crates/db/entities/src/kube_app_catalog.rs @@ -17,6 +17,8 @@ pub struct Model { #[sea_orm(column_type = "Text")] pub resources: String, pub cluster_id: Uuid, + pub sync_version: i64, + pub last_synced_at: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/db/entities/src/kube_environment.rs b/crates/db/entities/src/kube_environment.rs index 2e13530..dd43aeb 100644 --- a/crates/db/entities/src/kube_environment.rs +++ b/crates/db/entities/src/kube_environment.rs @@ -17,6 +17,8 @@ pub struct Model { pub namespace: String, pub status: String, pub is_shared: bool, + pub catalog_sync_version: i64, + pub last_catalog_synced_at: Option, pub base_environment_id: Option, pub auth_token: String, } diff --git a/crates/db/migration/src/m20250801_000000_create_kube_app_catalog.rs b/crates/db/migration/src/m20250801_000000_create_kube_app_catalog.rs index 9621cc8..07f1c88 100644 --- a/crates/db/migration/src/m20250801_000000_create_kube_app_catalog.rs +++ b/crates/db/migration/src/m20250801_000000_create_kube_app_catalog.rs @@ -35,6 +35,13 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(KubeAppCatalog::Description).text()) .col(ColumnDef::new(KubeAppCatalog::Resources).text().not_null()) .col(ColumnDef::new(KubeAppCatalog::ClusterId).uuid().not_null()) + .col( + ColumnDef::new(KubeAppCatalog::SyncVersion) + .big_integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(KubeAppCatalog::LastSyncedAt).timestamp_with_time_zone()) .foreign_key( ForeignKey::create() .from(KubeAppCatalog::Table, KubeAppCatalog::ClusterId) @@ -106,4 +113,6 @@ pub enum KubeAppCatalog { Description, Resources, ClusterId, + SyncVersion, + LastSyncedAt, } diff --git a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs index d6e01c4..0d3bcab 100644 --- a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs +++ b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs @@ -50,6 +50,16 @@ impl MigrationTrait for Migration { .boolean() .not_null(), ) + .col( + ColumnDef::new(KubeEnvironment::CatalogSyncVersion) + .big_integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(KubeEnvironment::LastCatalogSyncedAt) + .timestamp_with_time_zone(), + ) .col(ColumnDef::new(KubeEnvironment::BaseEnvironmentId).uuid()) .col( ColumnDef::new(KubeEnvironment::AuthToken) @@ -173,6 +183,8 @@ pub enum KubeEnvironment { Namespace, Status, IsShared, + CatalogSyncVersion, + LastCatalogSyncedAt, BaseEnvironmentId, AuthToken, } diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 98a2c2d..e664eac 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -1139,6 +1139,8 @@ impl DbApi { created_by: ActiveValue::Set(user_id), organization_id: ActiveValue::Set(org_id), deleted_at: ActiveValue::Set(None), + sync_version: ActiveValue::Set(0), + last_synced_at: ActiveValue::Set(None), } .insert(&txn) .await?; @@ -1176,6 +1178,8 @@ impl DbApi { created_by: ActiveValue::Set(user_id), organization_id: ActiveValue::Set(org_id), deleted_at: ActiveValue::Set(None), + sync_version: ActiveValue::Set(0), + last_synced_at: ActiveValue::Set(None), } .insert(&txn) .await?; @@ -1896,6 +1900,8 @@ impl DbApi { namespace: ActiveValue::Set(namespace.clone()), status: ActiveValue::Set(status), is_shared: ActiveValue::Set(is_shared), + catalog_sync_version: ActiveValue::Set(0), + last_catalog_synced_at: ActiveValue::Set(None), base_environment_id: ActiveValue::Set(base_environment_id), auth_token: ActiveValue::Set(auth_token), } From 649ffec43be50684aecfb3f80c723c1a5ae10b59 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 10:02:52 +0000 Subject: [PATCH 134/334] update --- crates/api/src/kube_controller/app_catalog.rs | 21 ++++++-- crates/db/src/api.rs | 53 +++++++++++++++++++ crates/kube/src/server.rs | 21 +++++++- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/crates/api/src/kube_controller/app_catalog.rs b/crates/api/src/kube_controller/app_catalog.rs index 243e096..51bd8c6 100644 --- a/crates/api/src/kube_controller/app_catalog.rs +++ b/crates/api/src/kube_controller/app_catalog.rs @@ -1,6 +1,3 @@ -use std::collections::{HashMap, HashSet}; -use uuid::Uuid; - use lapdev_common::kube::{ KubeAppCatalog, KubeAppCatalogWorkload, KubeAppCatalogWorkloadCreate, PagePaginationParams, PaginatedInfo, PaginatedResult, @@ -8,8 +5,11 @@ use lapdev_common::kube::{ use lapdev_db::api::CachedClusterService; use lapdev_rpc::error::ApiError; use sea_orm::TransactionTrait; +use std::collections::{HashMap, HashSet}; +use uuid::Uuid; use super::{yaml_parser::build_workload_details_from_yaml, KubeController}; +use chrono::Utc; impl KubeController { pub(super) async fn enrich_workloads_with_details( @@ -262,6 +262,11 @@ impl KubeController { self.db .delete_app_catalog_workload(workload_id) .await + .map_err(ApiError::from)?; + + self.db + .bump_app_catalog_sync_version(workload.app_catalog_id, Utc::now().into()) + .await .map_err(ApiError::from) } @@ -298,6 +303,11 @@ impl KubeController { self.db .update_app_catalog_workload(workload_id, containers) .await + .map_err(ApiError::from)?; + + self.db + .bump_app_catalog_sync_version(catalog.id, Utc::now().into()) + .await .map_err(ApiError::from) } @@ -342,7 +352,10 @@ impl KubeController { { Ok(_) => { txn.commit().await.map_err(ApiError::from)?; - Ok(()) + self.db + .bump_app_catalog_sync_version(catalog.id, Utc::now().into()) + .await + .map_err(ApiError::from) } Err(db_err) => { txn.rollback().await.map_err(ApiError::from)?; diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index e664eac..bf2d04d 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -48,10 +48,14 @@ struct KubeEnvironmentWithRelated { pub env_deleted_at: Option, pub env_base_environment_id: Option, pub env_auth_token: String, + pub env_catalog_sync_version: i64, + pub env_last_catalog_synced_at: Option, // App catalog fields pub catalog_name: Option, pub catalog_description: Option, + pub catalog_sync_version: Option, + pub catalog_last_synced_at: Option, // Cluster fields pub cluster_name: Option, @@ -1334,6 +1338,35 @@ impl DbApi { Ok(()) } + pub async fn bump_app_catalog_sync_version( + &self, + catalog_id: Uuid, + synced_at: DateTimeWithTimeZone, + ) -> Result<()> { + let updated = lapdev_db_entities::kube_app_catalog::Entity::update_many() + .filter(lapdev_db_entities::kube_app_catalog::Column::Id.eq(catalog_id)) + .filter(lapdev_db_entities::kube_app_catalog::Column::DeletedAt.is_null()) + .col_expr( + lapdev_db_entities::kube_app_catalog::Column::SyncVersion, + Expr::col(lapdev_db_entities::kube_app_catalog::Column::SyncVersion).add(1), + ) + .col_expr( + lapdev_db_entities::kube_app_catalog::Column::LastSyncedAt, + Expr::value(synced_at), + ) + .exec_with_returning(&self.conn) + .await?; + + if updated.is_empty() { + return Err(anyhow!( + "App catalog {} not found or already deleted", + catalog_id + )); + } + + Ok(()) + } + pub async fn update_app_catalog_workload( &self, workload_id: Uuid, @@ -1476,6 +1509,14 @@ impl DbApi { lapdev_db_entities::kube_environment::Column::IsShared, "env_is_shared", ) + .column_as( + lapdev_db_entities::kube_environment::Column::CatalogSyncVersion, + "env_catalog_sync_version", + ) + .column_as( + lapdev_db_entities::kube_environment::Column::LastCatalogSyncedAt, + "env_last_catalog_synced_at", + ) .column_as( lapdev_db_entities::kube_environment::Column::OrganizationId, "env_organization_id", @@ -1509,6 +1550,14 @@ impl DbApi { lapdev_db_entities::kube_app_catalog::Column::Description, "catalog_description", ) + .column_as( + lapdev_db_entities::kube_app_catalog::Column::SyncVersion, + "catalog_sync_version", + ) + .column_as( + lapdev_db_entities::kube_app_catalog::Column::LastSyncedAt, + "catalog_last_synced_at", + ) // Join and select cluster columns .join( JoinType::LeftJoin, @@ -1551,6 +1600,8 @@ impl DbApi { namespace: related.env_namespace, status: related.env_status, is_shared: related.env_is_shared, + catalog_sync_version: related.env_catalog_sync_version, + last_catalog_synced_at: related.env_last_catalog_synced_at, base_environment_id: related.env_base_environment_id, auth_token: related.env_auth_token, }; @@ -1568,6 +1619,8 @@ impl DbApi { created_by: related.env_user_id, organization_id: related.env_organization_id, deleted_at: None, + sync_version: related.catalog_sync_version.unwrap_or(0), + last_synced_at: related.catalog_last_synced_at, }); let cluster = diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index 1faf733..d0783aa 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -13,7 +13,7 @@ use lapdev_db_entities::kube_app_catalog_workload::{self, Entity as CatalogWorkl use lapdev_kube_rpc::{ KubeClusterRpc, KubeManagerRpcClient, ResourceChangeEvent, ResourceChangeType, ResourceType, }; -use sea_orm::prelude::Json; +use sea_orm::prelude::{DateTimeWithTimeZone, Json}; use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; use serde_json::json; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -264,6 +264,8 @@ impl KubeClusterServer { return Ok(()); } + let mut touched_catalogs = HashSet::new(); + for workload in workloads { // Ensure the stored kind matches; if not, skip but log. if let Ok(stored_kind) = workload.kind.parse::() { @@ -312,6 +314,23 @@ impl KubeClusterServer { resource_name = %event.resource_name, "Updated catalog workload containers from cluster event" ); + + touched_catalogs.insert(workload.app_catalog_id); + } + + if !touched_catalogs.is_empty() { + let synced_at: DateTimeWithTimeZone = event.timestamp.into(); + for catalog_id in touched_catalogs { + self.db + .bump_app_catalog_sync_version(catalog_id, synced_at.clone()) + .await + .with_context(|| { + format!( + "failed to bump sync version for catalog {} after workload update", + catalog_id + ) + })?; + } } Ok(()) From 9d044b5b6546460e5bd114348c0bfebcfeb90ddc Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 10:10:41 +0000 Subject: [PATCH 135/334] update --- crates/api/src/kube_controller/environment.rs | 14 +++++- crates/common/src/kube.rs | 1 + crates/dashboard/src/kube_environment.rs | 33 +++++++++++--- .../dashboard/src/kube_environment_detail.rs | 44 +++++++++++++++---- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index f37144d..6496bea 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -54,6 +54,10 @@ impl KubeController { .filter_map(|(env, catalog, cluster, base_environment_name)| { let catalog = catalog?; let cluster = cluster?; + let catalog_sync_version = env.catalog_sync_version; + let last_catalog_synced_at = env.last_catalog_synced_at.map(|dt| dt.to_string()); + let catalog_update_available = catalog.sync_version > catalog_sync_version; + Some(KubeEnvironment { id: env.id, name: env.name, @@ -68,8 +72,9 @@ impl KubeController { is_shared: env.is_shared, base_environment_id: env.base_environment_id, base_environment_name, - catalog_sync_version: env.catalog_sync_version, - last_catalog_synced_at: env.last_catalog_synced_at.map(|dt| dt.to_string()), + catalog_sync_version, + last_catalog_synced_at, + catalog_update_available, }) }) .collect(); @@ -137,6 +142,8 @@ impl KubeController { None }; + let catalog_update_available = catalog.sync_version > environment.catalog_sync_version; + Ok(KubeEnvironment { id: environment.id, user_id: environment.user_id, @@ -156,6 +163,7 @@ impl KubeController { base_environment_name, catalog_sync_version: environment.catalog_sync_version, last_catalog_synced_at: environment.last_catalog_synced_at.map(|dt| dt.to_string()), + catalog_update_available, }) } @@ -485,6 +493,7 @@ impl KubeController { base_environment_name: None, // Regular environments have no base environment catalog_sync_version: created_env.catalog_sync_version, last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), + catalog_update_available: false, }) } @@ -715,6 +724,7 @@ impl KubeController { base_environment_name: Some(base_environment.name), // Branch environments have the base environment name catalog_sync_version: created_env.catalog_sync_version, last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), + catalog_update_available: false, }) } diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index 01eb9a0..324fe07 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -291,6 +291,7 @@ pub struct KubeEnvironment { pub base_environment_name: Option, pub catalog_sync_version: i64, pub last_catalog_synced_at: Option, + pub catalog_update_available: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/dashboard/src/kube_environment.rs b/crates/dashboard/src/kube_environment.rs index c1db800..4e4e158 100644 --- a/crates/dashboard/src/kube_environment.rs +++ b/crates/dashboard/src/kube_environment.rs @@ -357,6 +357,7 @@ pub fn KubeEnvironmentItem(environment: KubeEnvironment) -> impl IntoView { let navigate = leptos_router::hooks::use_navigate(); let environment_id = environment.id; + let needs_catalog_sync = environment.catalog_update_available; // let view_details = move |_| { // let url = format!("/kubernetes/environments/{}", environment_id); // navigate(&url, Default::default()); @@ -390,11 +391,20 @@ pub fn KubeEnvironmentItem(environment: KubeEnvironment) -> impl IntoView { view! { -
- - +
+ + + + {if needs_catalog_sync { + view! { + "Sync required" + }.into_any() + } else { + view! { <> }.into_any() + }} +
{environment.namespace.clone()} @@ -484,6 +494,19 @@ pub fn KubeEnvironmentItem(environment: KubeEnvironment) -> impl IntoView { open=dropdown_expanded.read_only() class="min-w-48 -translate-x-2" > + {if needs_catalog_sync { + view! { + + + + }.into_any() + } else { + view! { <> }.into_any() + }} + diff --git a/crates/dashboard/src/kube_environment_detail.rs b/crates/dashboard/src/kube_environment_detail.rs index bcb4e5c..dbcafb5 100644 --- a/crates/dashboard/src/kube_environment_detail.rs +++ b/crates/dashboard/src/kube_environment_detail.rs @@ -20,6 +20,7 @@ use uuid::Uuid; use crate::{ app::AppConfig, component::{ + alert::{Alert, AlertDescription, AlertTitle}, badge::{Badge, BadgeVariant}, button::{Button, ButtonSize, ButtonVariant}, card::Card, @@ -509,6 +510,12 @@ pub fn EnvironmentInfoCard( let app_catalog_name = environment.app_catalog_name.clone(); let cluster_id = environment.cluster_id; let cluster_name = environment.cluster_name.clone(); + let catalog_update_available = environment.catalog_update_available; + let last_catalog_synced_at = environment.last_catalog_synced_at.clone(); + let last_sync_message = last_catalog_synced_at + .clone() + .map(|ts| format!("Last synced at {ts}")) + .unwrap_or_else(|| "This environment has not been synced from the catalog yet.".to_string()); let status_variant = match env_status.as_str() { "Running" => BadgeVariant::Secondary, @@ -526,7 +533,27 @@ pub fn EnvironmentInfoCard( }; view! { - +
+ {if catalog_update_available { + view! { + + + Catalog update available + + "New catalog changes are ready to apply. Sync the environment to pull the latest workloads." + {last_sync_message.clone()} + + + + }.into_any() + } else { + view! { <> }.into_any() + }} + +
// Header with actions
@@ -677,14 +704,15 @@ pub fn EnvironmentInfoCard(
-
+ - // Create Branch Environment Modal - + // Create Branch Environment Modal + +
} } From bfb49f4ef1e694c7421a1777743241b6397115e2 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 10:32:16 +0000 Subject: [PATCH 136/334] update --- crates/api-hrpc/src/lib.rs | 6 + crates/api/src/hrpc_service.rs | 13 ++ crates/api/src/kube_controller/environment.rs | 133 +++++++++++++++++- .../dashboard/src/kube_environment_detail.rs | 51 ++++++- 4 files changed, 198 insertions(+), 5 deletions(-) diff --git a/crates/api-hrpc/src/lib.rs b/crates/api-hrpc/src/lib.rs index 5a525f9..6d0c25c 100644 --- a/crates/api-hrpc/src/lib.rs +++ b/crates/api-hrpc/src/lib.rs @@ -198,6 +198,12 @@ pub trait HrpcService { environment_id: Uuid, ) -> Result<(), HrpcError>; + async fn sync_environment_from_catalog( + &self, + org_id: Uuid, + environment_id: Uuid, + ) -> Result<(), HrpcError>; + // Kube Environment Workload operations async fn get_environment_workloads( &self, diff --git a/crates/api/src/hrpc_service.rs b/crates/api/src/hrpc_service.rs index 829af51..4d0d6a0 100644 --- a/crates/api/src/hrpc_service.rs +++ b/crates/api/src/hrpc_service.rs @@ -685,6 +685,19 @@ impl HrpcService for CoreState { .map_err(HrpcError::from) } + async fn sync_environment_from_catalog( + &self, + headers: &axum::http::HeaderMap, + org_id: Uuid, + environment_id: Uuid, + ) -> Result<(), HrpcError> { + let user = self.authorize(headers, org_id, None).await?; + self.kube_controller + .sync_environment_from_catalog(org_id, user.id, environment_id) + .await + .map_err(HrpcError::from) + } + async fn delete_app_catalog( &self, headers: &axum::http::HeaderMap, diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index 6496bea..c65a057 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -1,12 +1,17 @@ -use uuid::Uuid; - +use chrono::Utc; use lapdev_common::{ kube::{ - KubeContainerImage, KubeEnvironment, PagePaginationParams, PaginatedInfo, PaginatedResult, + KubeContainerImage, KubeEnvironment, KubeWorkloadDetails, PagePaginationParams, + PaginatedInfo, PaginatedResult, }, utils::rand_string, }; use lapdev_rpc::error::ApiError; +use sea_orm::{ + prelude::Json, sea_query::Expr, ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, + QueryFilter, TransactionTrait, +}; +use uuid::Uuid; use super::{EnvironmentNamespaceKind, KubeController}; @@ -728,6 +733,128 @@ impl KubeController { }) } + pub async fn sync_environment_from_catalog( + &self, + org_id: Uuid, + user_id: Uuid, + environment_id: Uuid, + ) -> Result<(), ApiError> { + let environment = self + .db + .get_kube_environment(environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + if !environment.is_shared && environment.user_id != user_id { + return Err(ApiError::Unauthorized); + } + + let catalog = self + .db + .get_app_catalog(environment.app_catalog_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + if catalog.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + if catalog.sync_version == environment.catalog_sync_version { + // Already up to date; nothing to do + return Ok(()); + } + + let target_server = self + .get_random_kube_cluster_server(environment.cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest( + "No connected KubeManager for the environment target cluster".to_string(), + ) + })?; + + let catalog_workloads = self + .db + .get_app_catalog_workloads(catalog.id) + .await + .map_err(ApiError::from)?; + + let workloads_with_resources = self + .get_workloads_yaml_for_catalog(&catalog, catalog_workloads.clone()) + .await?; + + self.deploy_app_catalog_with_yaml( + &target_server, + &environment.namespace, + &environment.name, + environment.id, + Some(environment.auth_token.clone()), + workloads_with_resources, + ) + .await?; + + let now = Utc::now().into(); + let txn = self.db.conn.begin().await.map_err(ApiError::from)?; + + lapdev_db_entities::kube_environment_workload::Entity::update_many() + .filter( + lapdev_db_entities::kube_environment_workload::Column::EnvironmentId + .eq(environment.id), + ) + .filter(lapdev_db_entities::kube_environment_workload::Column::DeletedAt.is_null()) + .col_expr( + lapdev_db_entities::kube_environment_workload::Column::DeletedAt, + Expr::value(now), + ) + .exec(&txn) + .await + .map_err(ApiError::from)?; + + for workload in &catalog_workloads { + let containers_json = serde_json::to_value(&workload.containers) + .map(Json::from) + .unwrap_or_else(|_| Json::from(serde_json::json!([]))); + let ports_json = serde_json::to_value(&workload.ports) + .map(Json::from) + .unwrap_or_else(|_| Json::from(serde_json::json!([]))); + + lapdev_db_entities::kube_environment_workload::ActiveModel { + id: ActiveValue::Set(Uuid::new_v4()), + created_at: ActiveValue::Set(now), + deleted_at: ActiveValue::Set(None), + environment_id: ActiveValue::Set(environment.id), + name: ActiveValue::Set(workload.name.clone()), + namespace: ActiveValue::Set(environment.namespace.clone()), + kind: ActiveValue::Set(workload.kind.to_string()), + containers: ActiveValue::Set(containers_json), + ports: ActiveValue::Set(ports_json), + } + .insert(&txn) + .await + .map_err(ApiError::from)?; + } + + lapdev_db_entities::kube_environment::ActiveModel { + id: ActiveValue::Set(environment.id), + catalog_sync_version: ActiveValue::Set(catalog.sync_version), + last_catalog_synced_at: ActiveValue::Set(Some(now)), + ..Default::default() + } + .update(&txn) + .await + .map_err(ApiError::from)?; + + txn.commit().await.map_err(ApiError::from)?; + + Ok(()) + } + // Kube Namespace operations pub async fn create_kube_namespace( &self, diff --git a/crates/dashboard/src/kube_environment_detail.rs b/crates/dashboard/src/kube_environment_detail.rs index dbcafb5..9faa276 100644 --- a/crates/dashboard/src/kube_environment_detail.rs +++ b/crates/dashboard/src/kube_environment_detail.rs @@ -194,6 +194,20 @@ async fn create_branch_environment( Ok(env) } +async fn sync_environment_from_catalog( + org: Signal>, + environment_id: Uuid, +) -> Result<(), ErrorResponse> { + let org = org.get().ok_or_else(|| anyhow!("can't get org"))?; + let client = HrpcServiceClient::new("/api/rpc".to_string()); + + client + .sync_environment_from_catalog(org.id, environment_id) + .await??; + + Ok(()) +} + async fn get_active_devbox_session() -> Result> { let client = HrpcServiceClient::new("/api/rpc".to_string()); let response = client.devbox_session_list_sessions().await??; @@ -424,6 +438,18 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { } }); + let sync_action = Action::new_local(move |_| async move { + match sync_environment_from_catalog(org, environment_id).await { + Ok(_) => { + // Refresh environment data and resources + environment_result.refetch(); + update_counter.update(|c| *c += 1); + Ok(()) + } + Err(e) => Err(e), + } + }); + view! {
// Loading State @@ -445,6 +471,7 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { create_branch_modal_open create_branch_action branch_name + sync_action /> @@ -493,6 +520,7 @@ pub fn EnvironmentInfoCard( create_branch_modal_open: RwSignal, create_branch_action: Action<(), Result<(), ErrorResponse>>, branch_name: RwSignal, + sync_action: Action<(), Result<(), ErrorResponse>>, ) -> impl IntoView { view! {
}> @@ -535,6 +563,9 @@ pub fn EnvironmentInfoCard( view! {
{if catalog_update_available { + let sync_pending = sync_action.pending(); + let sync_result = sync_action.value(); + view! { @@ -542,10 +573,26 @@ pub fn EnvironmentInfoCard( "New catalog changes are ready to apply. Sync the environment to pull the latest workloads." {last_sync_message.clone()} - + + + {move || { + sync_result + .get() + .and_then(|res| res.err()) + .map(|err| err.error) + .unwrap_or_default() + }} + + }.into_any() From 2ae48a55093710400557ddb9c787aa4102d2226c Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 11:33:34 +0000 Subject: [PATCH 137/334] update --- crates/api/src/kube_controller/environment.rs | 98 ++++++++++++++----- crates/common/src/kube.rs | 8 ++ .../dashboard/src/kube_environment_detail.rs | 43 ++++++-- crates/db/entities/src/kube_environment.rs | 1 + ...20250809_000001_create_kube_environment.rs | 7 ++ 5 files changed, 123 insertions(+), 34 deletions(-) diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index c65a057..3af08ae 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -1,11 +1,12 @@ use chrono::Utc; use lapdev_common::{ kube::{ - KubeContainerImage, KubeEnvironment, KubeWorkloadDetails, PagePaginationParams, - PaginatedInfo, PaginatedResult, + KubeContainerImage, KubeEnvironment, KubeEnvironmentSyncStatus, KubeWorkloadDetails, + PagePaginationParams, PaginatedInfo, PaginatedResult, }, utils::rand_string, }; +use std::str::FromStr; use lapdev_rpc::error::ApiError; use sea_orm::{ prelude::Json, sea_query::Expr, ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, @@ -80,6 +81,9 @@ impl KubeController { catalog_sync_version, last_catalog_synced_at, catalog_update_available, + catalog_last_sync_actor_id: catalog.last_sync_actor_id, + sync_status: KubeEnvironmentSyncStatus::from_str(&env.sync_status) + .unwrap_or(KubeEnvironmentSyncStatus::Idle), }) }) .collect(); @@ -169,6 +173,9 @@ impl KubeController { catalog_sync_version: environment.catalog_sync_version, last_catalog_synced_at: environment.last_catalog_synced_at.map(|dt| dt.to_string()), catalog_update_available, + catalog_last_sync_actor_id: catalog.last_sync_actor_id, + sync_status: KubeEnvironmentSyncStatus::from_str(&environment.sync_status) + .unwrap_or(KubeEnvironmentSyncStatus::Idle), }) } @@ -499,6 +506,9 @@ impl KubeController { catalog_sync_version: created_env.catalog_sync_version, last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), catalog_update_available: false, + catalog_last_sync_actor_id: None, // New environment, no sync yet + sync_status: KubeEnvironmentSyncStatus::from_str(&created_env.sync_status) + .unwrap_or(KubeEnvironmentSyncStatus::Idle), }) } @@ -730,6 +740,9 @@ impl KubeController { catalog_sync_version: created_env.catalog_sync_version, last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), catalog_update_available: false, + catalog_last_sync_actor_id: None, // New branch environment, no sync yet + sync_status: KubeEnvironmentSyncStatus::from_str(&created_env.sync_status) + .unwrap_or(KubeEnvironmentSyncStatus::Idle), }) } @@ -770,34 +783,66 @@ impl KubeController { return Ok(()); } - let target_server = self - .get_random_kube_cluster_server(environment.cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest( - "No connected KubeManager for the environment target cluster".to_string(), - ) - })?; + // Set sync_status to "syncing" before starting + lapdev_db_entities::kube_environment::ActiveModel { + id: ActiveValue::Set(environment.id), + sync_status: ActiveValue::Set(KubeEnvironmentSyncStatus::Syncing.to_string()), + ..Default::default() + } + .update(&self.db.conn) + .await + .map_err(ApiError::from)?; - let catalog_workloads = self - .db - .get_app_catalog_workloads(catalog.id) - .await - .map_err(ApiError::from)?; + // Perform sync operations; reset status to idle on error + let sync_result = async { + let target_server = self + .get_random_kube_cluster_server(environment.cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest( + "No connected KubeManager for the environment target cluster".to_string(), + ) + })?; - let workloads_with_resources = self - .get_workloads_yaml_for_catalog(&catalog, catalog_workloads.clone()) + let catalog_workloads = self + .db + .get_app_catalog_workloads(catalog.id) + .await + .map_err(ApiError::from)?; + + let workloads_with_resources = self + .get_workloads_yaml_for_catalog(&catalog, catalog_workloads.clone()) + .await?; + + self.deploy_app_catalog_with_yaml( + &target_server, + &environment.namespace, + &environment.name, + environment.id, + Some(environment.auth_token.clone()), + workloads_with_resources, + ) .await?; - self.deploy_app_catalog_with_yaml( - &target_server, - &environment.namespace, - &environment.name, - environment.id, - Some(environment.auth_token.clone()), - workloads_with_resources, - ) - .await?; + Ok::<_, ApiError>(catalog_workloads) + } + .await; + + // If sync failed, reset status to idle and return error + let catalog_workloads = match sync_result { + Ok(workloads) => workloads, + Err(e) => { + // Reset sync_status to idle on failure + let _ = lapdev_db_entities::kube_environment::ActiveModel { + id: ActiveValue::Set(environment.id), + sync_status: ActiveValue::Set(KubeEnvironmentSyncStatus::Idle.to_string()), + ..Default::default() + } + .update(&self.db.conn) + .await; + return Err(e); + } + }; let now = Utc::now().into(); let txn = self.db.conn.begin().await.map_err(ApiError::from)?; @@ -844,6 +889,7 @@ impl KubeController { id: ActiveValue::Set(environment.id), catalog_sync_version: ActiveValue::Set(catalog.sync_version), last_catalog_synced_at: ActiveValue::Set(Some(now)), + sync_status: ActiveValue::Set(KubeEnvironmentSyncStatus::Idle.to_string()), ..Default::default() } .update(&txn) diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index 324fe07..30690cc 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -46,6 +46,12 @@ pub enum KubeClusterStatus { Error, } +#[derive(Debug, Clone, Serialize, Deserialize, strum_macros::EnumString, strum_macros::Display, PartialEq)] +pub enum KubeEnvironmentSyncStatus { + Idle, + Syncing, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KubeWorkload { pub name: String, @@ -292,6 +298,8 @@ pub struct KubeEnvironment { pub catalog_sync_version: i64, pub last_catalog_synced_at: Option, pub catalog_update_available: bool, + pub catalog_last_sync_actor_id: Option, // NULL = cluster auto-update, Some(user_id) = admin edit + pub sync_status: KubeEnvironmentSyncStatus, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/dashboard/src/kube_environment_detail.rs b/crates/dashboard/src/kube_environment_detail.rs index 9faa276..443d05b 100644 --- a/crates/dashboard/src/kube_environment_detail.rs +++ b/crates/dashboard/src/kube_environment_detail.rs @@ -10,7 +10,7 @@ use lapdev_common::{ }, kube::{ KubeClusterInfo, KubeContainerInfo, KubeEnvironment, KubeEnvironmentService, - KubeEnvironmentWorkload, + KubeEnvironmentSyncStatus, KubeEnvironmentWorkload, }, }; use leptos::prelude::*; @@ -540,11 +540,23 @@ pub fn EnvironmentInfoCard( let cluster_name = environment.cluster_name.clone(); let catalog_update_available = environment.catalog_update_available; let last_catalog_synced_at = environment.last_catalog_synced_at.clone(); + let catalog_last_sync_actor_id = environment.catalog_last_sync_actor_id; + let sync_status = environment.sync_status; let last_sync_message = last_catalog_synced_at .clone() .map(|ts| format!("Last synced at {ts}")) .unwrap_or_else(|| "This environment has not been synced from the catalog yet.".to_string()); + // Determine if this is a cluster auto-update or admin edit + let is_cluster_update = catalog_last_sync_actor_id.is_none(); + let sync_source = if is_cluster_update { "cluster" } else { "catalog" }; + let sync_button_text = if is_cluster_update { "Sync From Cluster" } else { "Sync From Catalog" }; + let sync_description = if is_cluster_update { + "New cluster changes are ready to apply. Sync the environment to pull the latest workloads from the production cluster." + } else { + "New catalog changes are ready to apply. Sync the environment to pull the latest workloads." + }; + let status_variant = match env_status.as_str() { "Running" => BadgeVariant::Secondary, "Pending" => BadgeVariant::Outline, @@ -562,25 +574,40 @@ pub fn EnvironmentInfoCard( view! {
- {if catalog_update_available { + {if catalog_update_available || sync_status == KubeEnvironmentSyncStatus::Syncing { let sync_pending = sync_action.pending(); let sync_result = sync_action.value(); + let is_syncing = sync_status == KubeEnvironmentSyncStatus::Syncing || sync_pending.get(); view! { - Catalog update available + { + if is_syncing { + "Sync in progress".to_string() + } else { + format!("Update available from {}", sync_source) + } + } - "New catalog changes are ready to apply. Sync the environment to pull the latest workloads." - {last_sync_message.clone()} + { + if is_syncing { + "Syncing environment with latest changes..." + } else { + sync_description + } + } + + {last_sync_message.clone()} + diff --git a/crates/db/entities/src/kube_environment.rs b/crates/db/entities/src/kube_environment.rs index dd43aeb..ccf095b 100644 --- a/crates/db/entities/src/kube_environment.rs +++ b/crates/db/entities/src/kube_environment.rs @@ -19,6 +19,7 @@ pub struct Model { pub is_shared: bool, pub catalog_sync_version: i64, pub last_catalog_synced_at: Option, + pub sync_status: String, pub base_environment_id: Option, pub auth_token: String, } diff --git a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs index 0d3bcab..0058eea 100644 --- a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs +++ b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs @@ -60,6 +60,12 @@ impl MigrationTrait for Migration { ColumnDef::new(KubeEnvironment::LastCatalogSyncedAt) .timestamp_with_time_zone(), ) + .col( + ColumnDef::new(KubeEnvironment::SyncStatus) + .string() + .not_null() + .default("idle"), + ) .col(ColumnDef::new(KubeEnvironment::BaseEnvironmentId).uuid()) .col( ColumnDef::new(KubeEnvironment::AuthToken) @@ -185,6 +191,7 @@ pub enum KubeEnvironment { IsShared, CatalogSyncVersion, LastCatalogSyncedAt, + SyncStatus, BaseEnvironmentId, AuthToken, } From 75aa3014a4ade6170824e9712287ecf547347078 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 12:30:08 +0000 Subject: [PATCH 138/334] update --- Cargo.lock | 1 + crates/api/src/kube_controller/environment.rs | 10 +- crates/common/src/kube.rs | 5 +- crates/db/Cargo.toml | 1 + .../src/kube_app_catalog_workload_label.rs | 64 +++++ crates/db/entities/src/lib.rs | 1 + crates/db/entities/src/prelude.rs | 1 + crates/db/migration/src/lib.rs | 2 + ..._create_kube_app_catalog_workload_label.rs | 144 ++++++++++ crates/db/src/api.rs | 247 +++++++++++++++++- crates/kube/src/server.rs | 130 ++++++++- 11 files changed, 581 insertions(+), 25 deletions(-) create mode 100644 crates/db/entities/src/kube_app_catalog_workload_label.rs create mode 100644 crates/db/migration/src/m20250825_000001_create_kube_app_catalog_workload_label.rs diff --git a/Cargo.lock b/Cargo.lock index a343fd8..35f4f6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3328,6 +3328,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "serde_yaml", "sqlx", "tokio", "uuid", diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index 3af08ae..bf244d5 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -6,12 +6,12 @@ use lapdev_common::{ }, utils::rand_string, }; -use std::str::FromStr; use lapdev_rpc::error::ApiError; use sea_orm::{ prelude::Json, sea_query::Expr, ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait, }; +use std::str::FromStr; use uuid::Uuid; use super::{EnvironmentNamespaceKind, KubeController}; @@ -81,7 +81,6 @@ impl KubeController { catalog_sync_version, last_catalog_synced_at, catalog_update_available, - catalog_last_sync_actor_id: catalog.last_sync_actor_id, sync_status: KubeEnvironmentSyncStatus::from_str(&env.sync_status) .unwrap_or(KubeEnvironmentSyncStatus::Idle), }) @@ -173,7 +172,6 @@ impl KubeController { catalog_sync_version: environment.catalog_sync_version, last_catalog_synced_at: environment.last_catalog_synced_at.map(|dt| dt.to_string()), catalog_update_available, - catalog_last_sync_actor_id: catalog.last_sync_actor_id, sync_status: KubeEnvironmentSyncStatus::from_str(&environment.sync_status) .unwrap_or(KubeEnvironmentSyncStatus::Idle), }) @@ -407,7 +405,7 @@ impl KubeController { let services_map = workloads_with_resources.services.clone(); // Store the environment workloads in the database before deployment - let workload_details: Vec = workloads + let workload_details: Vec = workloads .into_iter() .map(|workload| { let mut containers = workload.containers; @@ -506,7 +504,6 @@ impl KubeController { catalog_sync_version: created_env.catalog_sync_version, last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), catalog_update_available: false, - catalog_last_sync_actor_id: None, // New environment, no sync yet sync_status: KubeEnvironmentSyncStatus::from_str(&created_env.sync_status) .unwrap_or(KubeEnvironmentSyncStatus::Idle), }) @@ -611,7 +608,7 @@ impl KubeController { .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; // Convert workloads to the format needed for database creation - let workload_details: Vec = base_workloads + let workload_details: Vec = base_workloads .into_iter() .filter_map(|workload| { workload.kind.parse().ok().map(|kind| { @@ -740,7 +737,6 @@ impl KubeController { catalog_sync_version: created_env.catalog_sync_version, last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), catalog_update_available: false, - catalog_last_sync_actor_id: None, // New branch environment, no sync yet sync_status: KubeEnvironmentSyncStatus::from_str(&created_env.sync_status) .unwrap_or(KubeEnvironmentSyncStatus::Idle), }) diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index 30690cc..9693e92 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -46,7 +46,9 @@ pub enum KubeClusterStatus { Error, } -#[derive(Debug, Clone, Serialize, Deserialize, strum_macros::EnumString, strum_macros::Display, PartialEq)] +#[derive( + Debug, Clone, Serialize, Deserialize, strum_macros::EnumString, strum_macros::Display, PartialEq, +)] pub enum KubeEnvironmentSyncStatus { Idle, Syncing, @@ -298,7 +300,6 @@ pub struct KubeEnvironment { pub catalog_sync_version: i64, pub last_catalog_synced_at: Option, pub catalog_update_available: bool, - pub catalog_last_sync_actor_id: Option, // NULL = cluster auto-update, Some(user_id) = admin edit pub sync_status: KubeEnvironmentSyncStatus, } diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index d8190e0..f586531 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -23,3 +23,4 @@ lapdev-db-migration.workspace = true lapdev-db-entities.workspace = true lapdev-common.workspace = true secrecy.workspace = true +serde_yaml.workspace = true diff --git a/crates/db/entities/src/kube_app_catalog_workload_label.rs b/crates/db/entities/src/kube_app_catalog_workload_label.rs new file mode 100644 index 0000000..63cf299 --- /dev/null +++ b/crates/db/entities/src/kube_app_catalog_workload_label.rs @@ -0,0 +1,64 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "kube_app_catalog_workload_label")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub created_at: DateTimeWithTimeZone, + pub deleted_at: Option, + pub app_catalog_id: Uuid, + pub workload_id: Uuid, + pub cluster_id: Uuid, + pub namespace: String, + pub label_key: String, + pub label_value: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::kube_app_catalog::Entity", + from = "Column::AppCatalogId", + to = "super::kube_app_catalog::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeAppCatalog, + #[sea_orm( + belongs_to = "super::kube_app_catalog_workload::Entity", + from = "Column::WorkloadId", + to = "super::kube_app_catalog_workload::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeAppCatalogWorkload, + #[sea_orm( + belongs_to = "super::kube_cluster::Entity", + from = "Column::ClusterId", + to = "super::kube_cluster::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeCluster, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeAppCatalog.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeAppCatalogWorkload.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeCluster.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/db/entities/src/lib.rs b/crates/db/entities/src/lib.rs index 93f3abd..c31138a 100644 --- a/crates/db/entities/src/lib.rs +++ b/crates/db/entities/src/lib.rs @@ -6,6 +6,7 @@ pub mod audit_log; pub mod config; pub mod kube_app_catalog; pub mod kube_app_catalog_workload; +pub mod kube_app_catalog_workload_label; pub mod kube_cluster; pub mod kube_cluster_service; pub mod kube_cluster_token; diff --git a/crates/db/entities/src/prelude.rs b/crates/db/entities/src/prelude.rs index 9e0d716..ddca735 100644 --- a/crates/db/entities/src/prelude.rs +++ b/crates/db/entities/src/prelude.rs @@ -4,6 +4,7 @@ pub use super::audit_log::Entity as AuditLog; pub use super::config::Entity as Config; pub use super::kube_app_catalog::Entity as KubeAppCatalog; pub use super::kube_app_catalog_workload::Entity as KubeAppCatalogWorkload; +pub use super::kube_app_catalog_workload_label::Entity as KubeAppCatalogWorkloadLabel; pub use super::kube_cluster::Entity as KubeCluster; pub use super::kube_cluster_service::Entity as KubeClusterService; pub use super::kube_cluster_token::Entity as KubeClusterToken; diff --git a/crates/db/migration/src/lib.rs b/crates/db/migration/src/lib.rs index e190c02..84af7e8 100644 --- a/crates/db/migration/src/lib.rs +++ b/crates/db/migration/src/lib.rs @@ -28,6 +28,7 @@ mod m20250809_000002_create_kube_environment_workload; mod m20250809_000003_create_kube_environment_service; mod m20250815_000001_create_kube_environment_preview_url; mod m20250820_000001_create_kube_cluster_service; +mod m20250825_000001_create_kube_app_catalog_workload_label; mod m20251008_000001_create_kube_devbox_session; mod m20251008_000002_create_kube_devbox_workload_intercept; @@ -65,6 +66,7 @@ impl MigratorTrait for Migrator { Box::new(m20250809_000003_create_kube_environment_service::Migration), Box::new(m20250815_000001_create_kube_environment_preview_url::Migration), Box::new(m20250820_000001_create_kube_cluster_service::Migration), + Box::new(m20250825_000001_create_kube_app_catalog_workload_label::Migration), Box::new(m20251008_000001_create_kube_devbox_session::Migration), Box::new(m20251008_000002_create_kube_devbox_workload_intercept::Migration), ] diff --git a/crates/db/migration/src/m20250825_000001_create_kube_app_catalog_workload_label.rs b/crates/db/migration/src/m20250825_000001_create_kube_app_catalog_workload_label.rs new file mode 100644 index 0000000..3717f50 --- /dev/null +++ b/crates/db/migration/src/m20250825_000001_create_kube_app_catalog_workload_label.rs @@ -0,0 +1,144 @@ +use sea_orm_migration::prelude::*; + +use crate::{ + m20250729_082625_create_kube_cluster::KubeCluster, + m20250801_000000_create_kube_app_catalog::KubeAppCatalog, + m20250801_000002_create_kube_app_catalog_workload::KubeAppCatalogWorkload, +}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(KubeAppCatalogWorkloadLabel::Table) + .if_not_exists() + .col( + ColumnDef::new(KubeAppCatalogWorkloadLabel::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadLabel::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadLabel::DeletedAt) + .timestamp_with_time_zone(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadLabel::AppCatalogId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadLabel::WorkloadId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadLabel::ClusterId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadLabel::Namespace) + .string() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadLabel::LabelKey) + .string_len(255) + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadLabel::LabelValue) + .string_len(255) + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from( + KubeAppCatalogWorkloadLabel::Table, + KubeAppCatalogWorkloadLabel::AppCatalogId, + ) + .to(KubeAppCatalog::Table, KubeAppCatalog::Id), + ) + .foreign_key( + ForeignKey::create() + .from( + KubeAppCatalogWorkloadLabel::Table, + KubeAppCatalogWorkloadLabel::WorkloadId, + ) + .to(KubeAppCatalogWorkload::Table, KubeAppCatalogWorkload::Id), + ) + .foreign_key( + ForeignKey::create() + .from( + KubeAppCatalogWorkloadLabel::Table, + KubeAppCatalogWorkloadLabel::ClusterId, + ) + .to(KubeCluster::Table, KubeCluster::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_app_catalog_workload_label_workload_idx") + .table(KubeAppCatalogWorkloadLabel::Table) + .col(KubeAppCatalogWorkloadLabel::WorkloadId) + .col(KubeAppCatalogWorkloadLabel::DeletedAt) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_app_catalog_workload_label_selector_idx") + .table(KubeAppCatalogWorkloadLabel::Table) + .col(KubeAppCatalogWorkloadLabel::ClusterId) + .col(KubeAppCatalogWorkloadLabel::Namespace) + .col(KubeAppCatalogWorkloadLabel::LabelKey) + .col(KubeAppCatalogWorkloadLabel::LabelValue) + .col(KubeAppCatalogWorkloadLabel::DeletedAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(KubeAppCatalogWorkloadLabel::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +pub enum KubeAppCatalogWorkloadLabel { + Table, + Id, + CreatedAt, + DeletedAt, + AppCatalogId, + WorkloadId, + ClusterId, + Namespace, + LabelKey, + LabelValue, +} diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index bf2d04d..61acbe4 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -18,14 +18,15 @@ use pasetors::{ }; use sea_orm::{ prelude::{DateTimeWithTimeZone, Json}, - sea_query::{Alias, Expr, Func, OnConflict}, - ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, DatabaseTransaction, - EntityTrait, FromQueryResult, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, - RelationTrait, TransactionTrait, + sea_query::{Alias, Condition, Expr, Func, OnConflict}, + ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, DatabaseConnection, + DatabaseTransaction, EntityTrait, FromQueryResult, JoinType, PaginatorTrait, QueryFilter, + QueryOrder, QuerySelect, RelationTrait, TransactionTrait, }; use sea_orm_migration::MigratorTrait; use serde::Deserialize; use serde_json; +use serde_yaml::Value; use sqlx::PgPool; use std::collections::BTreeMap; use std::convert::TryFrom; @@ -50,6 +51,7 @@ struct KubeEnvironmentWithRelated { pub env_auth_token: String, pub env_catalog_sync_version: i64, pub env_last_catalog_synced_at: Option, + pub env_sync_status: String, // App catalog fields pub catalog_name: Option, @@ -1537,6 +1539,10 @@ impl DbApi { lapdev_db_entities::kube_environment::Column::AuthToken, "env_auth_token", ) + .column_as( + lapdev_db_entities::kube_environment::Column::SyncStatus, + "env_sync_status", + ) // Join and select app catalog columns .join( JoinType::LeftJoin, @@ -1602,6 +1608,7 @@ impl DbApi { is_shared: related.env_is_shared, catalog_sync_version: related.env_catalog_sync_version, last_catalog_synced_at: related.env_last_catalog_synced_at, + sync_status: related.env_sync_status.clone(), base_environment_id: related.env_base_environment_id, auth_token: related.env_auth_token, }; @@ -1778,34 +1785,154 @@ impl DbApi { created_at: sea_orm::prelude::DateTimeWithTimeZone, ) -> Result<(), sea_orm::DbErr> { for workload in enriched_workloads { - // Serialize all containers - let containers_json = serde_json::to_value(&workload.containers) + let KubeWorkloadDetails { + name, + namespace, + kind, + containers, + ports, + workload_yaml, + } = workload; + + let containers_json = serde_json::to_value(&containers) .map(Json::from) .unwrap_or_else(|_| Json::from(serde_json::json!([]))); - let ports_json = serde_json::to_value(&workload.ports) + let ports_json = serde_json::to_value(&ports) .map(Json::from) .unwrap_or_else(|_| Json::from(serde_json::json!([]))); + let workload_id = Uuid::new_v4(); + let labels = labels_from_workload_yaml(&kind, &workload_yaml); + lapdev_db_entities::kube_app_catalog_workload::ActiveModel { - id: ActiveValue::Set(Uuid::new_v4()), + id: ActiveValue::Set(workload_id), created_at: ActiveValue::Set(created_at), deleted_at: ActiveValue::Set(None), app_catalog_id: ActiveValue::Set(catalog_id), cluster_id: ActiveValue::Set(cluster_id), - name: ActiveValue::Set(workload.name), - namespace: ActiveValue::Set(workload.namespace), - kind: ActiveValue::Set(workload.kind.to_string()), + name: ActiveValue::Set(name.clone()), + namespace: ActiveValue::Set(namespace.clone()), + kind: ActiveValue::Set(kind.to_string()), containers: ActiveValue::Set(containers_json), ports: ActiveValue::Set(ports_json), - workload_yaml: ActiveValue::Set(workload.workload_yaml), + workload_yaml: ActiveValue::Set(workload_yaml), } .insert(txn) .await?; + + self.replace_workload_labels_txn( + txn, + workload_id, + catalog_id, + cluster_id, + &namespace, + &labels, + created_at, + ) + .await?; } Ok(()) } + pub async fn replace_workload_labels_txn( + &self, + txn: &DatabaseTransaction, + workload_id: Uuid, + app_catalog_id: Uuid, + cluster_id: Uuid, + namespace: &str, + labels: &BTreeMap, + timestamp: DateTimeWithTimeZone, + ) -> Result<(), sea_orm::DbErr> { + replace_workload_labels_with_conn( + txn, + workload_id, + app_catalog_id, + cluster_id, + namespace, + labels, + timestamp, + ) + .await + } + + pub async fn replace_workload_labels( + &self, + workload_id: Uuid, + app_catalog_id: Uuid, + cluster_id: Uuid, + namespace: &str, + labels: &BTreeMap, + timestamp: DateTimeWithTimeZone, + ) -> Result<(), sea_orm::DbErr> { + replace_workload_labels_with_conn( + &self.conn, + workload_id, + app_catalog_id, + cluster_id, + namespace, + labels, + timestamp, + ) + .await + } + + pub async fn find_workloads_matching_selector( + &self, + cluster_id: Uuid, + namespace: &str, + selector: &BTreeMap, + ) -> Result> { + if selector.is_empty() { + return Ok(vec![]); + } + + let mut condition = Condition::any(); + for (key, value) in selector { + condition = condition.add( + Condition::all() + .add( + lapdev_db_entities::kube_app_catalog_workload_label::Column::LabelKey + .eq(key.clone()), + ) + .add( + lapdev_db_entities::kube_app_catalog_workload_label::Column::LabelValue + .eq(value.clone()), + ), + ); + } + + let required_matches = selector.len() as i32; + + let workloads = lapdev_db_entities::kube_app_catalog_workload_label::Entity::find() + .select_only() + .column(lapdev_db_entities::kube_app_catalog_workload_label::Column::WorkloadId) + .filter( + lapdev_db_entities::kube_app_catalog_workload_label::Column::ClusterId + .eq(cluster_id), + ) + .filter( + lapdev_db_entities::kube_app_catalog_workload_label::Column::Namespace + .eq(namespace.to_string()), + ) + .filter( + lapdev_db_entities::kube_app_catalog_workload_label::Column::DeletedAt.is_null(), + ) + .filter(condition) + .group_by(lapdev_db_entities::kube_app_catalog_workload_label::Column::WorkloadId) + .having( + Expr::col(lapdev_db_entities::kube_app_catalog_workload_label::Column::WorkloadId) + .count() + .eq(required_matches), + ) + .into_tuple::() + .all(&self.conn) + .await?; + + Ok(workloads) + } + pub async fn get_environment_workloads( &self, environment_id: Uuid, @@ -1955,6 +2082,7 @@ impl DbApi { is_shared: ActiveValue::Set(is_shared), catalog_sync_version: ActiveValue::Set(0), last_catalog_synced_at: ActiveValue::Set(None), + sync_status: ActiveValue::Set("idle".to_string()), base_environment_id: ActiveValue::Set(base_environment_id), auth_token: ActiveValue::Set(auth_token), } @@ -2654,3 +2782,98 @@ impl DbApi { Ok(intercepts) } } + +fn labels_from_workload_yaml( + kind: &lapdev_common::kube::KubeWorkloadKind, + yaml: &str, +) -> BTreeMap { + let value: Value = match serde_yaml::from_str(yaml) { + Ok(v) => v, + Err(_) => return BTreeMap::new(), + }; + + let path: &[&str] = match kind { + lapdev_common::kube::KubeWorkloadKind::Deployment + | lapdev_common::kube::KubeWorkloadKind::StatefulSet + | lapdev_common::kube::KubeWorkloadKind::DaemonSet + | lapdev_common::kube::KubeWorkloadKind::ReplicaSet + | lapdev_common::kube::KubeWorkloadKind::Job => &["spec", "template", "metadata", "labels"], + lapdev_common::kube::KubeWorkloadKind::Pod => &["metadata", "labels"], + lapdev_common::kube::KubeWorkloadKind::CronJob => &[ + "spec", + "jobTemplate", + "spec", + "template", + "metadata", + "labels", + ], + }; + + traverse_yaml(&value, path) + .map(mapping_to_labels) + .unwrap_or_default() +} + +fn traverse_yaml<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut current = value; + for key in path { + let mapping = current.as_mapping()?; + current = mapping.get(&Value::String((*key).to_string()))?; + } + Some(current) +} + +fn mapping_to_labels(node: &Value) -> BTreeMap { + let mut labels = BTreeMap::new(); + if let Some(mapping) = node.as_mapping() { + for (key, value) in mapping { + if let (Value::String(k), Value::String(v)) = (key, value) { + labels.insert(k.clone(), v.clone()); + } + } + } + labels +} + +async fn replace_workload_labels_with_conn( + conn: &C, + workload_id: Uuid, + app_catalog_id: Uuid, + cluster_id: Uuid, + namespace: &str, + labels: &BTreeMap, + timestamp: DateTimeWithTimeZone, +) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait + Send + Sync, +{ + lapdev_db_entities::kube_app_catalog_workload_label::Entity::update_many() + .filter( + lapdev_db_entities::kube_app_catalog_workload_label::Column::WorkloadId.eq(workload_id), + ) + .filter(lapdev_db_entities::kube_app_catalog_workload_label::Column::DeletedAt.is_null()) + .col_expr( + lapdev_db_entities::kube_app_catalog_workload_label::Column::DeletedAt, + Expr::value(timestamp), + ) + .exec(conn) + .await?; + + for (key, value) in labels { + lapdev_db_entities::kube_app_catalog_workload_label::ActiveModel { + id: ActiveValue::Set(Uuid::new_v4()), + created_at: ActiveValue::Set(timestamp), + deleted_at: ActiveValue::Set(None), + app_catalog_id: ActiveValue::Set(app_catalog_id), + workload_id: ActiveValue::Set(workload_id), + cluster_id: ActiveValue::Set(cluster_id), + namespace: ActiveValue::Set(namespace.to_owned()), + label_key: ActiveValue::Set(key.clone()), + label_value: ActiveValue::Set(value.clone()), + } + .insert(conn) + .await?; + } + + Ok(()) +} diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index d0783aa..b738db4 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -9,7 +9,10 @@ use lapdev_common::kube::{ KubeWorkloadKind, }; use lapdev_db::api::{CachedClusterService, DbApi}; -use lapdev_db_entities::kube_app_catalog_workload::{self, Entity as CatalogWorkloadEntity}; +use lapdev_db_entities::{ + kube_app_catalog_workload::{self, Entity as CatalogWorkloadEntity}, + kube_app_catalog_workload_label, +}; use lapdev_kube_rpc::{ KubeClusterRpc, KubeManagerRpcClient, ResourceChangeEvent, ResourceChangeType, ResourceType, }; @@ -308,6 +311,23 @@ impl KubeClusterServer { .await .with_context(|| format!("failed to update workload {}", workload.id))?; + self.db + .replace_workload_labels( + workload.id, + workload.app_catalog_id, + workload.cluster_id, + &workload.namespace, + &workload_labels, + event.timestamp.into(), + ) + .await + .with_context(|| { + format!( + "failed to update workload label mapping for {}", + workload.id + ) + })?; + tracing::info!( workload_id = %workload.id, namespace = %event.namespace, @@ -356,12 +376,13 @@ impl KubeClusterServer { let service: Service = serde_yaml::from_str(yaml)?; - let (selector_json, ports_json, service_type, cluster_ip) = { + let (selector_map, selector_json, ports_json, service_type, cluster_ip) = { let spec = service.spec.as_ref(); let selector = spec .and_then(|spec| spec.selector.clone()) .unwrap_or_default(); - let selector_json = json!(selector); + let selector_map: BTreeMap = selector.into_iter().collect(); + let selector_json = json!(selector_map); let ports = spec .and_then(|spec| spec.ports.clone()) @@ -392,7 +413,13 @@ impl KubeClusterServer { let service_type = spec.and_then(|spec| spec.type_.clone()); let cluster_ip = spec.and_then(|spec| spec.cluster_ip.clone()); - (selector_json, ports_json, service_type, cluster_ip) + ( + selector_map, + selector_json, + ports_json, + service_type, + cluster_ip, + ) }; self.db @@ -410,6 +437,101 @@ impl KubeClusterServer { ) .await?; + if selector_map.is_empty() { + return Ok(()); + } + + let matching_workload_ids = self + .db + .find_workloads_matching_selector(self.cluster_id, &event.namespace, &selector_map) + .await + .with_context(|| { + format!( + "failed to resolve workloads matching service selector for {}", + event.resource_name + ) + })?; + + if matching_workload_ids.is_empty() { + return Ok(()); + } + + let workloads = CatalogWorkloadEntity::find() + .filter(kube_app_catalog_workload::Column::Id.is_in(matching_workload_ids.clone())) + .filter(kube_app_catalog_workload::Column::DeletedAt.is_null()) + .all(&self.db.conn) + .await + .with_context(|| { + format!( + "failed querying catalog workloads for service selector {}/{}", + event.namespace, event.resource_name + ) + })?; + + if workloads.is_empty() { + return Ok(()); + } + + let cached_services = self + .db + .get_active_cluster_services(self.cluster_id, &event.namespace) + .await?; + + let label_rows = kube_app_catalog_workload_label::Entity::find() + .filter( + kube_app_catalog_workload_label::Column::WorkloadId.is_in(matching_workload_ids), + ) + .filter(kube_app_catalog_workload_label::Column::DeletedAt.is_null()) + .all(&self.db.conn) + .await? + .into_iter() + .fold( + HashMap::>::new(), + |mut acc, row| { + acc.entry(row.workload_id) + .or_default() + .insert(row.label_key, row.label_value); + acc + }, + ); + + let mut touched_catalogs = HashSet::new(); + + for workload in workloads { + let labels = label_rows.get(&workload.id).cloned().unwrap_or_default(); + let service_ports = ports_from_cached_services(&labels, &cached_services); + let ports_json = Json::from(serde_json::to_value(&service_ports)?); + + if ports_json != workload.ports { + let active_model = kube_app_catalog_workload::ActiveModel { + id: ActiveValue::Set(workload.id), + ports: ActiveValue::Set(ports_json), + ..Default::default() + }; + + active_model.update(&self.db.conn).await.with_context(|| { + format!("failed to update workload ports for {}", workload.id) + })?; + + touched_catalogs.insert(workload.app_catalog_id); + } + } + + if !touched_catalogs.is_empty() { + let synced_at: DateTimeWithTimeZone = event.timestamp.into(); + for catalog_id in touched_catalogs { + self.db + .bump_app_catalog_sync_version(catalog_id, synced_at.clone()) + .await + .with_context(|| { + format!( + "failed to bump sync version for catalog {} after service update", + catalog_id + ) + })?; + } + } + Ok(()) } } From eff2a8074b2e316df3a9ab0bf76e03608ed411b4 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 12:42:05 +0000 Subject: [PATCH 139/334] update --- .../src/kube_cluster_service_selector.rs | 49 ++++++++ crates/db/entities/src/lib.rs | 1 + crates/db/entities/src/prelude.rs | 1 + crates/db/migration/src/lib.rs | 2 + ...02_create_kube_cluster_service_selector.rs | 118 ++++++++++++++++++ crates/db/src/api.rs | 98 ++++++++++++++- 6 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 crates/db/entities/src/kube_cluster_service_selector.rs create mode 100644 crates/db/migration/src/m20250825_000002_create_kube_cluster_service_selector.rs diff --git a/crates/db/entities/src/kube_cluster_service_selector.rs b/crates/db/entities/src/kube_cluster_service_selector.rs new file mode 100644 index 0000000..5319d6d --- /dev/null +++ b/crates/db/entities/src/kube_cluster_service_selector.rs @@ -0,0 +1,49 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "kube_cluster_service_selector")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub created_at: DateTimeWithTimeZone, + pub deleted_at: Option, + pub cluster_id: Uuid, + pub namespace: String, + pub service_id: Uuid, + pub label_key: String, + pub label_value: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::kube_cluster_service::Entity", + from = "Column::ServiceId", + to = "super::kube_cluster_service::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeClusterService, + #[sea_orm( + belongs_to = "super::kube_cluster::Entity", + from = "Column::ClusterId", + to = "super::kube_cluster::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeCluster, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeClusterService.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeCluster.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/db/entities/src/lib.rs b/crates/db/entities/src/lib.rs index c31138a..abb50b7 100644 --- a/crates/db/entities/src/lib.rs +++ b/crates/db/entities/src/lib.rs @@ -9,6 +9,7 @@ pub mod kube_app_catalog_workload; pub mod kube_app_catalog_workload_label; pub mod kube_cluster; pub mod kube_cluster_service; +pub mod kube_cluster_service_selector; pub mod kube_cluster_token; pub mod kube_devbox_session; pub mod kube_devbox_workload_intercept; diff --git a/crates/db/entities/src/prelude.rs b/crates/db/entities/src/prelude.rs index ddca735..046a0ff 100644 --- a/crates/db/entities/src/prelude.rs +++ b/crates/db/entities/src/prelude.rs @@ -7,6 +7,7 @@ pub use super::kube_app_catalog_workload::Entity as KubeAppCatalogWorkload; pub use super::kube_app_catalog_workload_label::Entity as KubeAppCatalogWorkloadLabel; pub use super::kube_cluster::Entity as KubeCluster; pub use super::kube_cluster_service::Entity as KubeClusterService; +pub use super::kube_cluster_service_selector::Entity as KubeClusterServiceSelector; pub use super::kube_cluster_token::Entity as KubeClusterToken; pub use super::kube_devbox_session::Entity as KubeDevboxSession; pub use super::kube_devbox_workload_intercept::Entity as KubeDevboxWorkloadIntercept; diff --git a/crates/db/migration/src/lib.rs b/crates/db/migration/src/lib.rs index 84af7e8..52bb86e 100644 --- a/crates/db/migration/src/lib.rs +++ b/crates/db/migration/src/lib.rs @@ -29,6 +29,7 @@ mod m20250809_000003_create_kube_environment_service; mod m20250815_000001_create_kube_environment_preview_url; mod m20250820_000001_create_kube_cluster_service; mod m20250825_000001_create_kube_app_catalog_workload_label; +mod m20250825_000002_create_kube_cluster_service_selector; mod m20251008_000001_create_kube_devbox_session; mod m20251008_000002_create_kube_devbox_workload_intercept; @@ -67,6 +68,7 @@ impl MigratorTrait for Migrator { Box::new(m20250815_000001_create_kube_environment_preview_url::Migration), Box::new(m20250820_000001_create_kube_cluster_service::Migration), Box::new(m20250825_000001_create_kube_app_catalog_workload_label::Migration), + Box::new(m20250825_000002_create_kube_cluster_service_selector::Migration), Box::new(m20251008_000001_create_kube_devbox_session::Migration), Box::new(m20251008_000002_create_kube_devbox_workload_intercept::Migration), ] diff --git a/crates/db/migration/src/m20250825_000002_create_kube_cluster_service_selector.rs b/crates/db/migration/src/m20250825_000002_create_kube_cluster_service_selector.rs new file mode 100644 index 0000000..009f538 --- /dev/null +++ b/crates/db/migration/src/m20250825_000002_create_kube_cluster_service_selector.rs @@ -0,0 +1,118 @@ +use sea_orm_migration::prelude::*; + +use crate::m20250820_000001_create_kube_cluster_service::KubeClusterService; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(KubeClusterServiceSelector::Table) + .if_not_exists() + .col( + ColumnDef::new(KubeClusterServiceSelector::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(KubeClusterServiceSelector::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(KubeClusterServiceSelector::DeletedAt) + .timestamp_with_time_zone(), + ) + .col( + ColumnDef::new(KubeClusterServiceSelector::ClusterId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeClusterServiceSelector::Namespace) + .string() + .not_null(), + ) + .col( + ColumnDef::new(KubeClusterServiceSelector::ServiceId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeClusterServiceSelector::LabelKey) + .string_len(255) + .not_null(), + ) + .col( + ColumnDef::new(KubeClusterServiceSelector::LabelValue) + .string_len(255) + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from( + KubeClusterServiceSelector::Table, + KubeClusterServiceSelector::ServiceId, + ) + .to(KubeClusterService::Table, KubeClusterService::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_cluster_service_selector_service_idx") + .table(KubeClusterServiceSelector::Table) + .col(KubeClusterServiceSelector::ServiceId) + .col(KubeClusterServiceSelector::DeletedAt) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_cluster_service_selector_lookup_idx") + .table(KubeClusterServiceSelector::Table) + .col(KubeClusterServiceSelector::ClusterId) + .col(KubeClusterServiceSelector::Namespace) + .col(KubeClusterServiceSelector::LabelKey) + .col(KubeClusterServiceSelector::LabelValue) + .col(KubeClusterServiceSelector::DeletedAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(KubeClusterServiceSelector::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +pub enum KubeClusterServiceSelector { + Table, + Id, + CreatedAt, + DeletedAt, + ClusterId, + Namespace, + ServiceId, + LabelKey, + LabelValue, +} diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 61acbe4..765d63c 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -10,7 +10,7 @@ use lapdev_common::{ AuthProvider, ProviderUser, UserRole, WorkspaceStatus, LAPDEV_BASE_HOSTNAME, LAPDEV_ISOLATE_CONTAINER, }; -use lapdev_db_entities::kube_cluster_service; +use lapdev_db_entities::{kube_cluster_service, kube_cluster_service_selector}; use lapdev_db_migration::Migrator; use pasetors::{ keys::{Generate, SymmetricKey}, @@ -1041,6 +1041,8 @@ impl DbApi { observed_at: chrono::DateTime, ) -> Result { let observed = observed_at.into(); + let selector_map: BTreeMap = + serde_json::from_value(selector.clone()).unwrap_or_default(); let selector_json = Json::from(selector); let ports_json = Json::from(ports); @@ -1067,6 +1069,14 @@ impl DbApi { active.deleted_at = ActiveValue::Set(None); let updated = active.update(&self.conn).await?; + self.replace_service_selectors( + updated.id, + cluster_id, + namespace, + &selector_map, + observed, + ) + .await?; Ok(updated.id) } else { let new_id = Uuid::new_v4(); @@ -1088,6 +1098,8 @@ impl DbApi { }; active.insert(&self.conn).await?; + self.replace_service_selectors(new_id, cluster_id, namespace, &selector_map, observed) + .await?; Ok(new_id) } } @@ -1111,11 +1123,14 @@ impl DbApi { }; let observed = observed_at.into(); + let service_id = model.id; let mut active: kube_cluster_service::ActiveModel = model.into(); active.deleted_at = ActiveValue::Set(Some(observed)); active.updated_at = ActiveValue::Set(chrono::Utc::now().into()); active.last_observed_at = ActiveValue::Set(observed); active.update(&self.conn).await?; + self.mark_service_selectors_deleted(service_id, observed) + .await?; Ok(()) } @@ -1933,6 +1948,28 @@ impl DbApi { Ok(workloads) } + pub async fn replace_service_selectors( + &self, + service_id: Uuid, + cluster_id: Uuid, + namespace: &str, + selectors: &BTreeMap, + timestamp: DateTimeWithTimeZone, + ) -> Result<(), sea_orm::DbErr> { + replace_service_selectors_with_conn( + &self.conn, service_id, cluster_id, namespace, selectors, timestamp, + ) + .await + } + + pub async fn mark_service_selectors_deleted( + &self, + service_id: Uuid, + timestamp: DateTimeWithTimeZone, + ) -> Result<(), sea_orm::DbErr> { + mark_service_selectors_deleted_with_conn(&self.conn, service_id, timestamp).await + } + pub async fn get_environment_workloads( &self, environment_id: Uuid, @@ -2877,3 +2914,62 @@ where Ok(()) } + +async fn replace_service_selectors_with_conn( + conn: &C, + service_id: Uuid, + cluster_id: Uuid, + namespace: &str, + selectors: &BTreeMap, + timestamp: DateTimeWithTimeZone, +) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait + Send + Sync, +{ + kube_cluster_service_selector::Entity::update_many() + .filter(kube_cluster_service_selector::Column::ServiceId.eq(service_id)) + .filter(kube_cluster_service_selector::Column::DeletedAt.is_null()) + .col_expr( + kube_cluster_service_selector::Column::DeletedAt, + Expr::value(timestamp), + ) + .exec(conn) + .await?; + + for (key, value) in selectors { + kube_cluster_service_selector::ActiveModel { + id: ActiveValue::Set(Uuid::new_v4()), + created_at: ActiveValue::Set(timestamp), + deleted_at: ActiveValue::Set(None), + cluster_id: ActiveValue::Set(cluster_id), + namespace: ActiveValue::Set(namespace.to_owned()), + service_id: ActiveValue::Set(service_id), + label_key: ActiveValue::Set(key.clone()), + label_value: ActiveValue::Set(value.clone()), + } + .insert(conn) + .await?; + } + + Ok(()) +} + +async fn mark_service_selectors_deleted_with_conn( + conn: &C, + service_id: Uuid, + timestamp: DateTimeWithTimeZone, +) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait + Send + Sync, +{ + kube_cluster_service_selector::Entity::update_many() + .filter(kube_cluster_service_selector::Column::ServiceId.eq(service_id)) + .filter(kube_cluster_service_selector::Column::DeletedAt.is_null()) + .col_expr( + kube_cluster_service_selector::Column::DeletedAt, + Expr::value(timestamp), + ) + .exec(conn) + .await?; + Ok(()) +} From 8ed244ceb11c531b96c236c1beb45c2ef2568a37 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 12:47:54 +0000 Subject: [PATCH 140/334] update --- crates/db/src/api.rs | 74 ++++++++++++++++++++++++++++++++++++++- crates/kube/src/server.rs | 23 ++++++------ 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 765d63c..a2975d7 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -28,7 +28,7 @@ use serde::Deserialize; use serde_json; use serde_yaml::Value; use sqlx::PgPool; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::convert::TryFrom; use uuid::Uuid; @@ -1948,6 +1948,78 @@ impl DbApi { Ok(workloads) } + pub async fn get_matching_cluster_services( + &self, + cluster_id: Uuid, + namespace: &str, + labels: &BTreeMap, + ) -> Result> { + if labels.is_empty() { + return Ok(Vec::new()); + } + + let selector_rows = kube_cluster_service_selector::Entity::find() + .filter(kube_cluster_service_selector::Column::ClusterId.eq(cluster_id)) + .filter(kube_cluster_service_selector::Column::Namespace.eq(namespace)) + .filter(kube_cluster_service_selector::Column::DeletedAt.is_null()) + .all(&self.conn) + .await?; + + let mut grouped: HashMap> = HashMap::new(); + for row in selector_rows { + grouped + .entry(row.service_id) + .or_default() + .push((row.label_key, row.label_value)); + } + + let matching_ids: Vec = grouped + .into_iter() + .filter_map(|(service_id, selectors)| { + if selectors.iter().all(|(key, value)| { + labels + .get(key) + .map(|existing| existing == value) + .unwrap_or(false) + }) { + Some(service_id) + } else { + None + } + }) + .collect(); + + if matching_ids.is_empty() { + return Ok(Vec::new()); + } + + let services = kube_cluster_service::Entity::find() + .filter(kube_cluster_service::Column::Id.is_in(matching_ids)) + .filter(kube_cluster_service::Column::DeletedAt.is_null()) + .all(&self.conn) + .await?; + + let mut results = Vec::with_capacity(services.len()); + for svc in services { + let selector: BTreeMap = + serde_json::from_value(svc.selector.clone()).unwrap_or_default(); + let ports_raw: Vec = + serde_json::from_value(svc.ports.clone()).unwrap_or_default(); + let ports = ports_raw + .into_iter() + .filter_map(StoredServicePort::into_service_port) + .collect(); + + results.push(CachedClusterService { + name: svc.name, + selector, + ports, + }); + } + + Ok(results) + } + pub async fn replace_service_selectors( &self, service_id: Uuid, diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index b738db4..e7a6c3c 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -239,11 +239,6 @@ impl KubeClusterServer { return Ok(()); } - let cached_services = self - .db - .get_active_cluster_services(self.cluster_id, &event.namespace) - .await?; - let workloads = CatalogWorkloadEntity::find() .filter(kube_app_catalog_workload::Column::Name.eq(event.resource_name.clone())) .filter(kube_app_catalog_workload::Column::Namespace.eq(event.namespace.clone())) @@ -269,6 +264,11 @@ impl KubeClusterServer { let mut touched_catalogs = HashSet::new(); + let matching_services = self + .db + .get_matching_cluster_services(self.cluster_id, &event.namespace, &workload_labels) + .await?; + for workload in workloads { // Ensure the stored kind matches; if not, skip but log. if let Ok(stored_kind) = workload.kind.parse::() { @@ -291,7 +291,7 @@ impl KubeClusterServer { workload.id ) })?; - let service_ports = ports_from_cached_services(&workload_labels, &cached_services); + let service_ports = ports_from_cached_services(&workload_labels, &matching_services); let containers_json = serde_json::to_value(&merged_containers) .context("failed to serialize merged container definition")?; @@ -472,11 +472,6 @@ impl KubeClusterServer { return Ok(()); } - let cached_services = self - .db - .get_active_cluster_services(self.cluster_id, &event.namespace) - .await?; - let label_rows = kube_app_catalog_workload_label::Entity::find() .filter( kube_app_catalog_workload_label::Column::WorkloadId.is_in(matching_workload_ids), @@ -499,7 +494,11 @@ impl KubeClusterServer { for workload in workloads { let labels = label_rows.get(&workload.id).cloned().unwrap_or_default(); - let service_ports = ports_from_cached_services(&labels, &cached_services); + let matching_services = self + .db + .get_matching_cluster_services(self.cluster_id, &event.namespace, &labels) + .await?; + let service_ports = ports_from_cached_services(&labels, &matching_services); let ports_json = Json::from(serde_json::to_value(&service_ports)?); if ports_json != workload.ports { From 70cd6ff1f6e394d2e05859d23e22ef4f68318cdd Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 13:13:18 +0000 Subject: [PATCH 141/334] update --- crates/api/src/kube_controller/app_catalog.rs | 47 +++++++----- crates/api/src/kube_controller/environment.rs | 3 + crates/api/src/kube_controller/workload.rs | 1 + crates/common/src/kube.rs | 2 + .../entities/src/kube_app_catalog_workload.rs | 1 + .../entities/src/kube_environment_workload.rs | 1 + ...000002_create_kube_app_catalog_workload.rs | 7 ++ ...000002_create_kube_environment_workload.rs | 7 ++ crates/db/src/api.rs | 71 ++++++++++++++----- crates/kube-manager/src/manager.rs | 2 + crates/kube/src/server.rs | 46 +++++++++--- 11 files changed, 144 insertions(+), 44 deletions(-) diff --git a/crates/api/src/kube_controller/app_catalog.rs b/crates/api/src/kube_controller/app_catalog.rs index 51bd8c6..a877534 100644 --- a/crates/api/src/kube_controller/app_catalog.rs +++ b/crates/api/src/kube_controller/app_catalog.rs @@ -264,10 +264,13 @@ impl KubeController { .await .map_err(ApiError::from)?; - self.db + let _ = self + .db .bump_app_catalog_sync_version(workload.app_catalog_id, Utc::now().into()) .await - .map_err(ApiError::from) + .map_err(ApiError::from)?; + + Ok(()) } pub async fn update_app_catalog_workload( @@ -305,9 +308,15 @@ impl KubeController { .await .map_err(ApiError::from)?; - self.db + let new_version = self + .db .bump_app_catalog_sync_version(catalog.id, Utc::now().into()) .await + .map_err(ApiError::from)?; + + self.db + .update_catalog_workload_versions(&[workload.id], new_version) + .await .map_err(ApiError::from) } @@ -339,7 +348,7 @@ impl KubeController { let txn = self.db.conn.begin().await.map_err(ApiError::from)?; let now = chrono::Utc::now().into(); - match self + let inserted_ids = match self .db .insert_enriched_workloads_to_catalog( &txn, @@ -350,27 +359,33 @@ impl KubeController { ) .await { - Ok(_) => { - txn.commit().await.map_err(ApiError::from)?; - self.db - .bump_app_catalog_sync_version(catalog.id, Utc::now().into()) - .await - .map_err(ApiError::from) - } + Ok(ids) => ids, Err(db_err) => { txn.rollback().await.map_err(ApiError::from)?; - // Check if this is a unique constraint violation using SeaORM's sql_err() method if matches!( db_err.sql_err(), Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) ) { - Err(ApiError::InvalidRequest( + return Err(ApiError::InvalidRequest( "One or more selected workloads already exist in this catalog".to_string(), - )) + )); } else { - Err(ApiError::from(anyhow::Error::from(db_err))) + return Err(ApiError::from(anyhow::Error::from(db_err))); } } - } + }; + + txn.commit().await.map_err(ApiError::from)?; + + let new_version = self + .db + .bump_app_catalog_sync_version(catalog.id, Utc::now().into()) + .await + .map_err(ApiError::from)?; + + self.db + .update_catalog_workload_versions(&inserted_ids, new_version) + .await + .map_err(ApiError::from) } } diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index bf244d5..25b889a 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -448,6 +448,7 @@ impl KubeController { namespace.clone(), "Pending".to_string(), is_shared, + app_catalog.sync_version, None, // No base environment for regular environments workload_details, services_map, @@ -653,6 +654,7 @@ impl KubeController { namespace.clone(), "Pending".to_string(), false, // Branch environments are always personal (not shared) + base_environment.catalog_sync_version, Some(base_environment_id), // Set the base environment reference workload_details, services_map, @@ -875,6 +877,7 @@ impl KubeController { kind: ActiveValue::Set(workload.kind.to_string()), containers: ActiveValue::Set(containers_json), ports: ActiveValue::Set(ports_json), + catalog_sync_version: ActiveValue::Set(catalog.sync_version), } .insert(&txn) .await diff --git a/crates/api/src/kube_controller/workload.rs b/crates/api/src/kube_controller/workload.rs index 361cf1e..3ca6a07 100644 --- a/crates/api/src/kube_controller/workload.rs +++ b/crates/api/src/kube_controller/workload.rs @@ -129,6 +129,7 @@ impl KubeController { kind: updated_db_model.kind.clone(), containers, ports, + catalog_sync_version: updated_db_model.catalog_sync_version, } }; diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index 9693e92..76518c2 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -199,6 +199,7 @@ pub struct KubeAppCatalogWorkload { pub containers: Vec, pub ports: Vec, pub workload_yaml: Option, + pub catalog_sync_version: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -218,6 +219,7 @@ pub struct KubeEnvironmentWorkload { pub kind: String, pub containers: Vec, pub ports: Vec, + pub catalog_sync_version: i64, } impl KubeEnvironmentWorkload { diff --git a/crates/db/entities/src/kube_app_catalog_workload.rs b/crates/db/entities/src/kube_app_catalog_workload.rs index c4cafc1..4976860 100644 --- a/crates/db/entities/src/kube_app_catalog_workload.rs +++ b/crates/db/entities/src/kube_app_catalog_workload.rs @@ -18,6 +18,7 @@ pub struct Model { pub ports: Json, #[sea_orm(column_type = "Text")] pub workload_yaml: String, + pub catalog_sync_version: i64, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/db/entities/src/kube_environment_workload.rs b/crates/db/entities/src/kube_environment_workload.rs index 3f1d277..e6dc826 100644 --- a/crates/db/entities/src/kube_environment_workload.rs +++ b/crates/db/entities/src/kube_environment_workload.rs @@ -15,6 +15,7 @@ pub struct Model { pub kind: String, pub containers: Json, pub ports: Json, + pub catalog_sync_version: i64, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs b/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs index 0e32c96..9fca783 100644 --- a/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs +++ b/crates/db/migration/src/m20250801_000002_create_kube_app_catalog_workload.rs @@ -72,6 +72,12 @@ impl MigrationTrait for Migration { .not_null() .default(""), ) + .col( + ColumnDef::new(KubeAppCatalogWorkload::CatalogSyncVersion) + .big_integer() + .not_null() + .default(0), + ) .foreign_key( ForeignKey::create() .from( @@ -153,4 +159,5 @@ pub enum KubeAppCatalogWorkload { Containers, Ports, WorkloadYaml, + CatalogSyncVersion, } diff --git a/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs b/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs index 31be76f..9702538 100644 --- a/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs +++ b/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs @@ -58,6 +58,12 @@ impl MigrationTrait for Migration { .json() .not_null(), ) + .col( + ColumnDef::new(KubeEnvironmentWorkload::CatalogSyncVersion) + .big_integer() + .not_null() + .default(0), + ) .foreign_key( ForeignKey::create() .from( @@ -115,4 +121,5 @@ pub enum KubeEnvironmentWorkload { Kind, Containers, Ports, + CatalogSyncVersion, } diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index a2975d7..3e7a559 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -1206,14 +1206,15 @@ impl DbApi { .await?; // Insert enriched workloads - self.insert_enriched_workloads_to_catalog( - &txn, - catalog_id, - cluster_id, - enriched_workloads, - now, - ) - .await?; + let _ = self + .insert_enriched_workloads_to_catalog( + &txn, + catalog_id, + cluster_id, + enriched_workloads, + now, + ) + .await?; txn.commit().await?; Ok(catalog_id) @@ -1327,6 +1328,7 @@ impl DbApi { } else { Some(w.workload_yaml.clone()) }, + catalog_sync_version: w.catalog_sync_version, }) }) .collect()) @@ -1359,7 +1361,7 @@ impl DbApi { &self, catalog_id: Uuid, synced_at: DateTimeWithTimeZone, - ) -> Result<()> { + ) -> Result { let updated = lapdev_db_entities::kube_app_catalog::Entity::update_many() .filter(lapdev_db_entities::kube_app_catalog::Column::Id.eq(catalog_id)) .filter(lapdev_db_entities::kube_app_catalog::Column::DeletedAt.is_null()) @@ -1374,14 +1376,14 @@ impl DbApi { .exec_with_returning(&self.conn) .await?; - if updated.is_empty() { - return Err(anyhow!( + if let Some(model) = updated.into_iter().next() { + Ok(model.sync_version) + } else { + Err(anyhow!( "App catalog {} not found or already deleted", catalog_id - )); + )) } - - Ok(()) } pub async fn update_app_catalog_workload( @@ -1784,6 +1786,7 @@ impl DbApi { containers: ActiveValue::Set(Json::from(serde_json::json!([]))), ports: ActiveValue::Set(Json::from(serde_json::json!([]))), workload_yaml: ActiveValue::Set(String::new()), + catalog_sync_version: ActiveValue::Set(0), } .insert(txn) .await?; @@ -1798,7 +1801,8 @@ impl DbApi { cluster_id: Uuid, enriched_workloads: Vec, created_at: sea_orm::prelude::DateTimeWithTimeZone, - ) -> Result<(), sea_orm::DbErr> { + ) -> Result, sea_orm::DbErr> { + let mut inserted_ids = Vec::new(); for workload in enriched_workloads { let KubeWorkloadDetails { name, @@ -1832,6 +1836,7 @@ impl DbApi { containers: ActiveValue::Set(containers_json), ports: ActiveValue::Set(ports_json), workload_yaml: ActiveValue::Set(workload_yaml), + catalog_sync_version: ActiveValue::Set(0), } .insert(txn) .await?; @@ -1846,8 +1851,10 @@ impl DbApi { created_at, ) .await?; + + inserted_ids.push(workload_id); } - Ok(()) + Ok(inserted_ids) } pub async fn replace_workload_labels_txn( @@ -2020,6 +2027,30 @@ impl DbApi { Ok(results) } + pub async fn update_catalog_workload_versions( + &self, + workload_ids: &[Uuid], + version: i64, + ) -> Result<()> { + if workload_ids.is_empty() { + return Ok(()); + } + + lapdev_db_entities::kube_app_catalog_workload::Entity::update_many() + .filter( + lapdev_db_entities::kube_app_catalog_workload::Column::Id + .is_in(workload_ids.iter().cloned().collect::>()), + ) + .col_expr( + lapdev_db_entities::kube_app_catalog_workload::Column::CatalogSyncVersion, + Expr::value(version), + ) + .exec(&self.conn) + .await?; + + Ok(()) + } + pub async fn replace_service_selectors( &self, service_id: Uuid, @@ -2080,6 +2111,7 @@ impl DbApi { kind: workload.kind, containers, ports, + catalog_sync_version: workload.catalog_sync_version, }); } Ok(result) @@ -2119,6 +2151,7 @@ impl DbApi { kind: workload.kind, containers, ports, + catalog_sync_version: workload.catalog_sync_version, })) } else { Ok(None) @@ -2164,6 +2197,7 @@ impl DbApi { namespace: String, status: String, is_shared: bool, + catalog_sync_version: i64, base_environment_id: Option, workloads: Vec, services: std::collections::HashMap, @@ -2189,8 +2223,8 @@ impl DbApi { namespace: ActiveValue::Set(namespace.clone()), status: ActiveValue::Set(status), is_shared: ActiveValue::Set(is_shared), - catalog_sync_version: ActiveValue::Set(0), - last_catalog_synced_at: ActiveValue::Set(None), + catalog_sync_version: ActiveValue::Set(catalog_sync_version), + last_catalog_synced_at: ActiveValue::Set(Some(created_at)), sync_status: ActiveValue::Set("idle".to_string()), base_environment_id: ActiveValue::Set(base_environment_id), auth_token: ActiveValue::Set(auth_token), @@ -2220,6 +2254,7 @@ impl DbApi { kind: ActiveValue::Set(workload.kind.to_string()), containers: ActiveValue::Set(containers_json), ports: ActiveValue::Set(ports_json), + catalog_sync_version: ActiveValue::Set(catalog_sync_version), } .insert(&txn) .await?; diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index c1ffcfd..0af5260 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -3675,6 +3675,7 @@ impl KubeManager { containers, ports: Vec::new(), // Ports will be fetched from services during YAML retrieval workload_yaml: None, + catalog_sync_version: 0, }; // Step 2: Get the current workload YAML with all its resources @@ -3759,6 +3760,7 @@ impl KubeManager { containers: containers.clone(), // Use the customized containers for the branch ports: Vec::new(), // Ports will be fetched from services during YAML retrieval workload_yaml: None, + catalog_sync_version: 0, }; // Step 2: Get the base workload YAML with all its resources diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index e7a6c3c..a918ebc 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -262,7 +262,7 @@ impl KubeClusterServer { return Ok(()); } - let mut touched_catalogs = HashSet::new(); + let mut workloads_by_catalog: HashMap> = HashMap::new(); let matching_services = self .db @@ -335,13 +335,17 @@ impl KubeClusterServer { "Updated catalog workload containers from cluster event" ); - touched_catalogs.insert(workload.app_catalog_id); + workloads_by_catalog + .entry(workload.app_catalog_id) + .or_default() + .push(workload.id); } - if !touched_catalogs.is_empty() { + if !workloads_by_catalog.is_empty() { let synced_at: DateTimeWithTimeZone = event.timestamp.into(); - for catalog_id in touched_catalogs { - self.db + for (catalog_id, workload_ids) in workloads_by_catalog { + let new_version = self + .db .bump_app_catalog_sync_version(catalog_id, synced_at.clone()) .await .with_context(|| { @@ -350,6 +354,15 @@ impl KubeClusterServer { catalog_id ) })?; + self.db + .update_catalog_workload_versions(&workload_ids, new_version) + .await + .with_context(|| { + format!( + "failed to update workload sync version for catalog {}", + catalog_id + ) + })?; } } @@ -490,7 +503,7 @@ impl KubeClusterServer { }, ); - let mut touched_catalogs = HashSet::new(); + let mut workloads_by_catalog: HashMap> = HashMap::new(); for workload in workloads { let labels = label_rows.get(&workload.id).cloned().unwrap_or_default(); @@ -512,14 +525,18 @@ impl KubeClusterServer { format!("failed to update workload ports for {}", workload.id) })?; - touched_catalogs.insert(workload.app_catalog_id); + workloads_by_catalog + .entry(workload.app_catalog_id) + .or_default() + .push(workload.id); } } - if !touched_catalogs.is_empty() { + if !workloads_by_catalog.is_empty() { let synced_at: DateTimeWithTimeZone = event.timestamp.into(); - for catalog_id in touched_catalogs { - self.db + for (catalog_id, workload_ids) in workloads_by_catalog { + let new_version = self + .db .bump_app_catalog_sync_version(catalog_id, synced_at.clone()) .await .with_context(|| { @@ -528,6 +545,15 @@ impl KubeClusterServer { catalog_id ) })?; + self.db + .update_catalog_workload_versions(&workload_ids, new_version) + .await + .with_context(|| { + format!( + "failed to update workload sync version for catalog {}", + catalog_id + ) + })?; } } From 5db8206750849a11d1188025db5b6525f2335b51 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 15:46:14 +0000 Subject: [PATCH 142/334] update --- .../dashboard/src/kube_environment_detail.rs | 47 ++- .../kube_app_catalog_workload_dependency.rs | 64 ++++ crates/db/entities/src/lib.rs | 1 + crates/db/entities/src/prelude.rs | 1 + crates/db/migration/src/lib.rs | 2 + ...te_kube_app_catalog_workload_dependency.rs | 142 +++++++++ crates/db/src/api.rs | 289 +++++++++++++++++- crates/kube/src/server.rs | 213 ++++++++++++- 8 files changed, 739 insertions(+), 20 deletions(-) create mode 100644 crates/db/entities/src/kube_app_catalog_workload_dependency.rs create mode 100644 crates/db/migration/src/m20250825_000003_create_kube_app_catalog_workload_dependency.rs diff --git a/crates/dashboard/src/kube_environment_detail.rs b/crates/dashboard/src/kube_environment_detail.rs index 443d05b..a825c47 100644 --- a/crates/dashboard/src/kube_environment_detail.rs +++ b/crates/dashboard/src/kube_environment_detail.rs @@ -367,6 +367,12 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { LocalResource::new(move || async move { get_active_devbox_session().await.ok().flatten() }); let environment_info = Signal::derive(move || environment_result.get().flatten()); + let environment_catalog_version = Signal::derive(move || { + environment_info + .get() + .map(|env| env.catalog_sync_version) + .unwrap_or(0) + }); let all_workloads = Signal::derive(move || workloads_result.get().unwrap_or_default()); let all_services = Signal::derive(move || services_result.get().unwrap_or_default()); let all_preview_urls = Signal::derive(move || preview_urls_result.get().unwrap_or_default()); @@ -374,17 +380,18 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { // Filter workloads based on search query let filtered_workloads = Signal::derive(move || { + let env_version = environment_catalog_version.get(); let workloads = all_workloads.get(); let search_term = debounced_search.get().to_lowercase(); - if search_term.trim().is_empty() { - workloads - } else { - workloads - .into_iter() - .filter(|workload| workload.name.to_lowercase().contains(&search_term)) - .collect() - } + workloads + .into_iter() + .filter(|workload| { + workload.catalog_sync_version > env_version + && (search_term.trim().is_empty() + || workload.name.to_lowercase().contains(&search_term)) + }) + .collect() }); let navigate = leptos_router::hooks::use_navigate(); @@ -995,8 +1002,8 @@ pub fn EnvironmentWorkloadsContent( } + children=move |workload| { + view! { } } /> @@ -1058,11 +1065,20 @@ pub fn EnvironmentWorkloadItem( all_intercepts: Signal>, active_session: LocalResource>, update_counter: RwSignal, + env_catalog_version: Signal, ) -> impl IntoView { let workload_id = workload.id; let workload_name = workload.name.clone(); let workload_containers = workload.containers.clone(); let workload_ports = workload.ports.clone(); + let workload_catalog_version = workload.catalog_sync_version; + let row_class = Signal::derive(move || { + if env_catalog_version.get() < workload_catalog_version { + "bg-amber-50/70 dark:bg-amber-900/20 border-l-4 border-amber-500" + } else { + "" + } + }); // Find intercepts for this workload let workload_intercepts = Signal::derive(move || { @@ -1117,11 +1133,20 @@ pub fn EnvironmentWorkloadItem( view! { <> - + diff --git a/crates/db/entities/src/kube_app_catalog_workload_dependency.rs b/crates/db/entities/src/kube_app_catalog_workload_dependency.rs new file mode 100644 index 0000000..5a452dd --- /dev/null +++ b/crates/db/entities/src/kube_app_catalog_workload_dependency.rs @@ -0,0 +1,64 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "kube_app_catalog_workload_dependency")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub created_at: DateTimeWithTimeZone, + pub deleted_at: Option, + pub app_catalog_id: Uuid, + pub workload_id: Uuid, + pub cluster_id: Uuid, + pub namespace: String, + pub resource_name: String, + pub resource_type: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::kube_app_catalog::Entity", + from = "Column::AppCatalogId", + to = "super::kube_app_catalog::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeAppCatalog, + #[sea_orm( + belongs_to = "super::kube_app_catalog_workload::Entity", + from = "Column::WorkloadId", + to = "super::kube_app_catalog_workload::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeAppCatalogWorkload, + #[sea_orm( + belongs_to = "super::kube_cluster::Entity", + from = "Column::ClusterId", + to = "super::kube_cluster::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeCluster, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeAppCatalog.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeAppCatalogWorkload.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeCluster.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/db/entities/src/lib.rs b/crates/db/entities/src/lib.rs index abb50b7..374c890 100644 --- a/crates/db/entities/src/lib.rs +++ b/crates/db/entities/src/lib.rs @@ -6,6 +6,7 @@ pub mod audit_log; pub mod config; pub mod kube_app_catalog; pub mod kube_app_catalog_workload; +pub mod kube_app_catalog_workload_dependency; pub mod kube_app_catalog_workload_label; pub mod kube_cluster; pub mod kube_cluster_service; diff --git a/crates/db/entities/src/prelude.rs b/crates/db/entities/src/prelude.rs index 046a0ff..b8ce108 100644 --- a/crates/db/entities/src/prelude.rs +++ b/crates/db/entities/src/prelude.rs @@ -4,6 +4,7 @@ pub use super::audit_log::Entity as AuditLog; pub use super::config::Entity as Config; pub use super::kube_app_catalog::Entity as KubeAppCatalog; pub use super::kube_app_catalog_workload::Entity as KubeAppCatalogWorkload; +pub use super::kube_app_catalog_workload_dependency::Entity as KubeAppCatalogWorkloadDependency; pub use super::kube_app_catalog_workload_label::Entity as KubeAppCatalogWorkloadLabel; pub use super::kube_cluster::Entity as KubeCluster; pub use super::kube_cluster_service::Entity as KubeClusterService; diff --git a/crates/db/migration/src/lib.rs b/crates/db/migration/src/lib.rs index 52bb86e..bedbfb7 100644 --- a/crates/db/migration/src/lib.rs +++ b/crates/db/migration/src/lib.rs @@ -30,6 +30,7 @@ mod m20250815_000001_create_kube_environment_preview_url; mod m20250820_000001_create_kube_cluster_service; mod m20250825_000001_create_kube_app_catalog_workload_label; mod m20250825_000002_create_kube_cluster_service_selector; +mod m20250825_000003_create_kube_app_catalog_workload_dependency; mod m20251008_000001_create_kube_devbox_session; mod m20251008_000002_create_kube_devbox_workload_intercept; @@ -69,6 +70,7 @@ impl MigratorTrait for Migrator { Box::new(m20250820_000001_create_kube_cluster_service::Migration), Box::new(m20250825_000001_create_kube_app_catalog_workload_label::Migration), Box::new(m20250825_000002_create_kube_cluster_service_selector::Migration), + Box::new(m20250825_000003_create_kube_app_catalog_workload_dependency::Migration), Box::new(m20251008_000001_create_kube_devbox_session::Migration), Box::new(m20251008_000002_create_kube_devbox_workload_intercept::Migration), ] diff --git a/crates/db/migration/src/m20250825_000003_create_kube_app_catalog_workload_dependency.rs b/crates/db/migration/src/m20250825_000003_create_kube_app_catalog_workload_dependency.rs new file mode 100644 index 0000000..8ddcb04 --- /dev/null +++ b/crates/db/migration/src/m20250825_000003_create_kube_app_catalog_workload_dependency.rs @@ -0,0 +1,142 @@ +use sea_orm_migration::prelude::*; + +use crate::m20250729_082625_create_kube_cluster::KubeCluster; +use crate::m20250801_000000_create_kube_app_catalog::KubeAppCatalog; +use crate::m20250801_000002_create_kube_app_catalog_workload::KubeAppCatalogWorkload; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(KubeAppCatalogWorkloadDependency::Table) + .if_not_exists() + .col( + ColumnDef::new(KubeAppCatalogWorkloadDependency::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadDependency::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadDependency::DeletedAt) + .timestamp_with_time_zone(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadDependency::AppCatalogId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadDependency::WorkloadId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadDependency::ClusterId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadDependency::Namespace) + .string() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadDependency::ResourceName) + .string() + .not_null(), + ) + .col( + ColumnDef::new(KubeAppCatalogWorkloadDependency::ResourceType) + .string_len(32) + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from( + KubeAppCatalogWorkloadDependency::Table, + KubeAppCatalogWorkloadDependency::AppCatalogId, + ) + .to(KubeAppCatalog::Table, KubeAppCatalog::Id), + ) + .foreign_key( + ForeignKey::create() + .from( + KubeAppCatalogWorkloadDependency::Table, + KubeAppCatalogWorkloadDependency::WorkloadId, + ) + .to(KubeAppCatalogWorkload::Table, KubeAppCatalogWorkload::Id), + ) + .foreign_key( + ForeignKey::create() + .from( + KubeAppCatalogWorkloadDependency::Table, + KubeAppCatalogWorkloadDependency::ClusterId, + ) + .to(KubeCluster::Table, KubeCluster::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_app_catalog_workload_dep_workload_idx") + .table(KubeAppCatalogWorkloadDependency::Table) + .col(KubeAppCatalogWorkloadDependency::WorkloadId) + .col(KubeAppCatalogWorkloadDependency::DeletedAt) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_app_catalog_workload_dep_lookup_idx") + .table(KubeAppCatalogWorkloadDependency::Table) + .col(KubeAppCatalogWorkloadDependency::ClusterId) + .col(KubeAppCatalogWorkloadDependency::Namespace) + .col(KubeAppCatalogWorkloadDependency::ResourceType) + .col(KubeAppCatalogWorkloadDependency::ResourceName) + .col(KubeAppCatalogWorkloadDependency::DeletedAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(KubeAppCatalogWorkloadDependency::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +pub enum KubeAppCatalogWorkloadDependency { + Table, + Id, + CreatedAt, + DeletedAt, + AppCatalogId, + WorkloadId, + ClusterId, + Namespace, + ResourceName, + ResourceType, +} diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 3e7a559..2d0b905 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -10,7 +10,9 @@ use lapdev_common::{ AuthProvider, ProviderUser, UserRole, WorkspaceStatus, LAPDEV_BASE_HOSTNAME, LAPDEV_ISOLATE_CONTAINER, }; -use lapdev_db_entities::{kube_cluster_service, kube_cluster_service_selector}; +use lapdev_db_entities::{ + kube_app_catalog_workload_dependency, kube_cluster_service, kube_cluster_service_selector, +}; use lapdev_db_migration::Migrator; use pasetors::{ keys::{Generate, SymmetricKey}, @@ -28,7 +30,7 @@ use serde::Deserialize; use serde_json; use serde_yaml::Value; use sqlx::PgPool; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::convert::TryFrom; use uuid::Uuid; @@ -1823,6 +1825,8 @@ impl DbApi { let workload_id = Uuid::new_v4(); let labels = labels_from_workload_yaml(&kind, &workload_yaml); + let (configmap_refs, secret_refs) = + dependencies_from_workload_yaml(&kind, &workload_yaml); lapdev_db_entities::kube_app_catalog_workload::ActiveModel { id: ActiveValue::Set(workload_id), @@ -1852,6 +1856,18 @@ impl DbApi { ) .await?; + self.replace_workload_dependencies_txn( + txn, + workload_id, + catalog_id, + cluster_id, + &namespace, + &configmap_refs, + &secret_refs, + created_at, + ) + .await?; + inserted_ids.push(workload_id); } Ok(inserted_ids) @@ -1900,6 +1916,53 @@ impl DbApi { .await } + pub async fn replace_workload_dependencies_txn( + &self, + txn: &DatabaseTransaction, + workload_id: Uuid, + app_catalog_id: Uuid, + cluster_id: Uuid, + namespace: &str, + configmaps: &BTreeSet, + secrets: &BTreeSet, + timestamp: DateTimeWithTimeZone, + ) -> Result<(), sea_orm::DbErr> { + replace_workload_dependencies_with_conn( + txn, + workload_id, + app_catalog_id, + cluster_id, + namespace, + configmaps, + secrets, + timestamp, + ) + .await + } + + pub async fn replace_workload_dependencies( + &self, + workload_id: Uuid, + app_catalog_id: Uuid, + cluster_id: Uuid, + namespace: &str, + configmaps: &BTreeSet, + secrets: &BTreeSet, + timestamp: DateTimeWithTimeZone, + ) -> Result<(), sea_orm::DbErr> { + replace_workload_dependencies_with_conn( + &self.conn, + workload_id, + app_catalog_id, + cluster_id, + namespace, + configmaps, + secrets, + timestamp, + ) + .await + } + pub async fn find_workloads_matching_selector( &self, cluster_id: Uuid, @@ -2027,6 +2090,29 @@ impl DbApi { Ok(results) } + pub async fn find_workloads_by_dependency( + &self, + cluster_id: Uuid, + namespace: &str, + resource_type: &str, + resource_name: &str, + ) -> Result> { + let rows = kube_app_catalog_workload_dependency::Entity::find() + .select_only() + .column(kube_app_catalog_workload_dependency::Column::WorkloadId) + .column(kube_app_catalog_workload_dependency::Column::AppCatalogId) + .filter(kube_app_catalog_workload_dependency::Column::ClusterId.eq(cluster_id)) + .filter(kube_app_catalog_workload_dependency::Column::Namespace.eq(namespace)) + .filter(kube_app_catalog_workload_dependency::Column::ResourceType.eq(resource_type)) + .filter(kube_app_catalog_workload_dependency::Column::ResourceName.eq(resource_name)) + .filter(kube_app_catalog_workload_dependency::Column::DeletedAt.is_null()) + .into_tuple::<(Uuid, Uuid)>() + .all(&self.conn) + .await?; + + Ok(rows) + } + pub async fn update_catalog_workload_versions( &self, workload_ids: &[Uuid], @@ -2979,6 +3065,147 @@ fn mapping_to_labels(node: &Value) -> BTreeMap { labels } +fn dependencies_from_workload_yaml( + kind: &lapdev_common::kube::KubeWorkloadKind, + yaml: &str, +) -> (BTreeSet, BTreeSet) { + let value: Value = match serde_yaml::from_str(yaml) { + Ok(v) => v, + Err(_) => return (BTreeSet::new(), BTreeSet::new()), + }; + + let pod_spec_path: &[&str] = match kind { + lapdev_common::kube::KubeWorkloadKind::Deployment + | lapdev_common::kube::KubeWorkloadKind::StatefulSet + | lapdev_common::kube::KubeWorkloadKind::DaemonSet + | lapdev_common::kube::KubeWorkloadKind::ReplicaSet + | lapdev_common::kube::KubeWorkloadKind::Job => &["spec", "template", "spec"], + lapdev_common::kube::KubeWorkloadKind::Pod => &["spec"], + lapdev_common::kube::KubeWorkloadKind::CronJob => { + &["spec", "jobTemplate", "spec", "template", "spec"] + } + }; + + let pod_spec = match traverse_yaml(&value, pod_spec_path) { + Some(spec) => spec, + None => return (BTreeSet::new(), BTreeSet::new()), + }; + + collect_dependencies_from_spec(pod_spec) +} + +fn collect_dependencies_from_spec(spec: &Value) -> (BTreeSet, BTreeSet) { + let mut configmaps = BTreeSet::new(); + let mut secrets = BTreeSet::new(); + + let collect_env = + |container: &Value, configmaps: &mut BTreeSet, secrets: &mut BTreeSet| { + if let Some(envs) = container.get("env").and_then(|v| v.as_sequence()) { + for env in envs { + if let Some(value_from) = env.get("valueFrom") { + if let Some(cfg) = value_from + .get("configMapKeyRef") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + { + configmaps.insert(cfg.to_string()); + } + if let Some(sec) = value_from + .get("secretKeyRef") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + { + secrets.insert(sec.to_string()); + } + } + } + } + + if let Some(env_from) = container.get("envFrom").and_then(|v| v.as_sequence()) { + for source in env_from { + if let Some(cfg) = source + .get("configMapRef") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + { + configmaps.insert(cfg.to_string()); + } + if let Some(sec) = source + .get("secretRef") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + { + secrets.insert(sec.to_string()); + } + } + } + }; + + if let Some(containers) = spec.get("containers").and_then(|v| v.as_sequence()) { + for container in containers { + collect_env(container, &mut configmaps, &mut secrets); + } + } + + if let Some(init_containers) = spec.get("initContainers").and_then(|v| v.as_sequence()) { + for container in init_containers { + collect_env(container, &mut configmaps, &mut secrets); + } + } + + if let Some(ephemeral) = spec + .get("ephemeralContainers") + .and_then(|v| v.as_sequence()) + { + for container in ephemeral { + collect_env(container, &mut configmaps, &mut secrets); + } + } + + if let Some(volumes) = spec.get("volumes").and_then(|v| v.as_sequence()) { + for volume in volumes { + if let Some(cfg) = volume + .get("configMap") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + { + configmaps.insert(cfg.to_string()); + } + if let Some(sec) = volume + .get("secret") + .and_then(|v| v.get("secretName")) + .and_then(|v| v.as_str()) + { + secrets.insert(sec.to_string()); + } + if let Some(projected_sources) = volume + .get("projected") + .and_then(|v| v.get("sources")) + .and_then(|v| v.as_sequence()) + { + for source in projected_sources { + if let Some(cfg) = source + .get("configMap") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + { + configmaps.insert(cfg.to_string()); + } + if let Some(sec) = source + .get("secret") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + { + secrets.insert(sec.to_string()); + } + } + } + } + } + + (configmaps, secrets) +} + async fn replace_workload_labels_with_conn( conn: &C, workload_id: Uuid, @@ -3022,6 +3249,64 @@ where Ok(()) } +async fn replace_workload_dependencies_with_conn( + conn: &C, + workload_id: Uuid, + app_catalog_id: Uuid, + cluster_id: Uuid, + namespace: &str, + configmaps: &BTreeSet, + secrets: &BTreeSet, + timestamp: DateTimeWithTimeZone, +) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait + Send + Sync, +{ + kube_app_catalog_workload_dependency::Entity::update_many() + .filter(kube_app_catalog_workload_dependency::Column::WorkloadId.eq(workload_id)) + .filter(kube_app_catalog_workload_dependency::Column::DeletedAt.is_null()) + .col_expr( + kube_app_catalog_workload_dependency::Column::DeletedAt, + Expr::value(timestamp), + ) + .exec(conn) + .await?; + + for name in configmaps { + kube_app_catalog_workload_dependency::ActiveModel { + id: ActiveValue::Set(Uuid::new_v4()), + created_at: ActiveValue::Set(timestamp), + deleted_at: ActiveValue::Set(None), + app_catalog_id: ActiveValue::Set(app_catalog_id), + workload_id: ActiveValue::Set(workload_id), + cluster_id: ActiveValue::Set(cluster_id), + namespace: ActiveValue::Set(namespace.to_owned()), + resource_name: ActiveValue::Set(name.clone()), + resource_type: ActiveValue::Set("configmap".to_string()), + } + .insert(conn) + .await?; + } + + for name in secrets { + kube_app_catalog_workload_dependency::ActiveModel { + id: ActiveValue::Set(Uuid::new_v4()), + created_at: ActiveValue::Set(timestamp), + deleted_at: ActiveValue::Set(None), + app_catalog_id: ActiveValue::Set(app_catalog_id), + workload_id: ActiveValue::Set(workload_id), + cluster_id: ActiveValue::Set(cluster_id), + namespace: ActiveValue::Set(namespace.to_owned()), + resource_name: ActiveValue::Set(name.clone()), + resource_type: ActiveValue::Set("secret".to_string()), + } + .insert(conn) + .await?; + } + + Ok(()) +} + async fn replace_service_selectors_with_conn( conn: &C, service_id: Uuid, diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index a918ebc..11d204f 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -19,7 +19,7 @@ use lapdev_kube_rpc::{ use sea_orm::prelude::{DateTimeWithTimeZone, Json}; use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; use serde_json::json; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; @@ -181,6 +181,8 @@ impl KubeClusterRpc for KubeClusterServer { let result = match event.resource_type { ResourceType::Service => self.handle_service_change(&event).await, + ResourceType::ConfigMap => self.handle_dependency_change(&event, "configmap").await, + ResourceType::Secret => self.handle_dependency_change(&event, "secret").await, _ => self.handle_workload_change(&event).await, }; @@ -229,6 +231,8 @@ impl KubeClusterServer { })?; let new_containers = extracted.containers; let workload_labels = extracted.labels; + let configmap_refs = extracted.configmap_refs; + let secret_refs = extracted.secret_refs; if new_containers.is_empty() { tracing::warn!( @@ -328,6 +332,24 @@ impl KubeClusterServer { ) })?; + self.db + .replace_workload_dependencies( + workload.id, + workload.app_catalog_id, + workload.cluster_id, + &workload.namespace, + &configmap_refs, + &secret_refs, + event.timestamp.into(), + ) + .await + .with_context(|| { + format!( + "failed to update workload dependency mapping for {}", + workload.id + ) + })?; + tracing::info!( workload_id = %workload.id, namespace = %event.namespace, @@ -559,6 +581,60 @@ impl KubeClusterServer { Ok(()) } + + async fn handle_dependency_change( + &self, + event: &ResourceChangeEvent, + resource_type: &str, + ) -> AnyResult<()> { + let workloads = self + .db + .find_workloads_by_dependency( + self.cluster_id, + &event.namespace, + resource_type, + &event.resource_name, + ) + .await?; + + if workloads.is_empty() { + return Ok(()); + } + + let mut workloads_by_catalog: HashMap> = HashMap::new(); + for (workload_id, catalog_id) in workloads { + workloads_by_catalog + .entry(catalog_id) + .or_default() + .push(workload_id); + } + + let synced_at: DateTimeWithTimeZone = event.timestamp.into(); + for (catalog_id, workload_ids) in workloads_by_catalog { + let new_version = self + .db + .bump_app_catalog_sync_version(catalog_id, synced_at.clone()) + .await + .with_context(|| { + format!( + "failed to bump sync version for catalog {} after {} change", + catalog_id, resource_type + ) + })?; + + self.db + .update_catalog_workload_versions(&workload_ids, new_version) + .await + .with_context(|| { + format!( + "failed to update workload sync version for catalog {}", + catalog_id + ) + })?; + } + + Ok(()) + } } fn workload_kind_for(resource_type: ResourceType) -> Option { @@ -576,6 +652,8 @@ fn workload_kind_for(resource_type: ResourceType) -> Option { struct ExtractedWorkload { containers: Vec, labels: BTreeMap, + configmap_refs: BTreeSet, + secret_refs: BTreeSet, } fn extract_workload_from_yaml( @@ -597,7 +675,13 @@ fn extract_workload_from_yaml( .and_then(|m| m.labels.clone()) .unwrap_or_default(); let containers = extract_pod_spec_containers(pod_spec)?; - Ok(ExtractedWorkload { containers, labels }) + let (configmap_refs, secret_refs) = extract_pod_spec_dependencies(pod_spec); + Ok(ExtractedWorkload { + containers, + labels, + configmap_refs, + secret_refs, + }) } ResourceType::StatefulSet => { let statefulset: StatefulSet = serde_yaml::from_str(yaml)?; @@ -613,7 +697,13 @@ fn extract_workload_from_yaml( .and_then(|m| m.labels.clone()) .unwrap_or_default(); let containers = extract_pod_spec_containers(pod_spec)?; - Ok(ExtractedWorkload { containers, labels }) + let (configmap_refs, secret_refs) = extract_pod_spec_dependencies(pod_spec); + Ok(ExtractedWorkload { + containers, + labels, + configmap_refs, + secret_refs, + }) } ResourceType::DaemonSet => { let daemonset: DaemonSet = serde_yaml::from_str(yaml)?; @@ -629,7 +719,13 @@ fn extract_workload_from_yaml( .and_then(|m| m.labels.clone()) .unwrap_or_default(); let containers = extract_pod_spec_containers(pod_spec)?; - Ok(ExtractedWorkload { containers, labels }) + let (configmap_refs, secret_refs) = extract_pod_spec_dependencies(pod_spec); + Ok(ExtractedWorkload { + containers, + labels, + configmap_refs, + secret_refs, + }) } ResourceType::ReplicaSet => { let replicaset: ReplicaSet = serde_yaml::from_str(yaml)?; @@ -647,7 +743,13 @@ fn extract_workload_from_yaml( .and_then(|m| m.labels.clone()) .unwrap_or_default(); let containers = extract_pod_spec_containers(pod_spec)?; - Ok(ExtractedWorkload { containers, labels }) + let (configmap_refs, secret_refs) = extract_pod_spec_dependencies(pod_spec); + Ok(ExtractedWorkload { + containers, + labels, + configmap_refs, + secret_refs, + }) } ResourceType::Job => { let job: Job = serde_yaml::from_str(yaml)?; @@ -663,7 +765,13 @@ fn extract_workload_from_yaml( .and_then(|m| m.labels.clone()) .unwrap_or_default(); let containers = extract_pod_spec_containers(pod_spec)?; - Ok(ExtractedWorkload { containers, labels }) + let (configmap_refs, secret_refs) = extract_pod_spec_dependencies(pod_spec); + Ok(ExtractedWorkload { + containers, + labels, + configmap_refs, + secret_refs, + }) } ResourceType::CronJob => { let cron_job: CronJob = serde_yaml::from_str(yaml)?; @@ -680,12 +788,20 @@ fn extract_workload_from_yaml( .and_then(|m| m.labels.clone()) .unwrap_or_default(); let containers = extract_pod_spec_containers(pod_spec)?; - Ok(ExtractedWorkload { containers, labels }) + let (configmap_refs, secret_refs) = extract_pod_spec_dependencies(pod_spec); + Ok(ExtractedWorkload { + containers, + labels, + configmap_refs, + secret_refs, + }) } ResourceType::ConfigMap | ResourceType::Secret | ResourceType::Service => { Ok(ExtractedWorkload { containers: Vec::new(), labels: BTreeMap::new(), + configmap_refs: BTreeSet::new(), + secret_refs: BTreeSet::new(), }) } } @@ -756,6 +872,89 @@ fn extract_pod_spec_containers(pod_spec: &PodSpec) -> AnyResult (BTreeSet, BTreeSet) { + let mut configmaps = BTreeSet::new(); + let mut secrets = BTreeSet::new(); + + let insert_trimmed = |set: &mut BTreeSet, raw: &str| { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + set.insert(trimmed.to_owned()); + } + }; + + let mut process_env_sources = + |env: &Option>, + env_from: &Option>| { + if let Some(envs) = env { + for item in envs { + if let Some(value_from) = &item.value_from { + if let Some(cfg) = &value_from.config_map_key_ref { + insert_trimmed(&mut configmaps, &cfg.name); + } + if let Some(sec) = &value_from.secret_key_ref { + insert_trimmed(&mut secrets, &sec.name); + } + } + } + } + + if let Some(env_from) = env_from { + for source in env_from { + if let Some(cfg) = &source.config_map_ref { + insert_trimmed(&mut configmaps, &cfg.name); + } + if let Some(sec) = &source.secret_ref { + insert_trimmed(&mut secrets, &sec.name); + } + } + } + }; + + for container in &pod_spec.containers { + process_env_sources(&container.env, &container.env_from); + } + + if let Some(init_containers) = pod_spec.init_containers.as_ref() { + for container in init_containers { + process_env_sources(&container.env, &container.env_from); + } + } + + if let Some(ephemeral) = pod_spec.ephemeral_containers.as_ref() { + for container in ephemeral { + process_env_sources(&container.env, &container.env_from); + } + } + + if let Some(volumes) = pod_spec.volumes.as_ref() { + for volume in volumes { + if let Some(cfg) = &volume.config_map { + insert_trimmed(&mut configmaps, &cfg.name); + } + if let Some(secret) = &volume.secret { + if let Some(name) = secret.secret_name.as_ref() { + insert_trimmed(&mut secrets, name); + } + } + if let Some(projected) = &volume.projected { + if let Some(sources) = projected.sources.as_ref() { + for source in sources { + if let Some(cfg) = &source.config_map { + insert_trimmed(&mut configmaps, &cfg.name); + } + if let Some(secret) = &source.secret { + insert_trimmed(&mut secrets, &secret.name); + } + } + } + } + } + } + + (configmaps, secrets) +} + fn merge_containers( existing: &Json, new_containers: &[KubeContainerInfo], From e35066449977411f865336d1f4d53e98f33a6e6c Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 16:02:38 +0000 Subject: [PATCH 143/334] update --- crates/api/src/kube_controller/environment.rs | 158 ++++++++++++++++-- 1 file changed, 140 insertions(+), 18 deletions(-) diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index 25b889a..293b98e 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -11,7 +11,7 @@ use sea_orm::{ prelude::Json, sea_query::Expr, ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait, }; -use std::str::FromStr; +use std::{collections::HashSet, str::FromStr}; use uuid::Uuid; use super::{EnvironmentNamespaceKind, KubeController}; @@ -802,33 +802,88 @@ impl KubeController { ) })?; + // Get existing environment workloads + let existing_workloads = self + .db + .get_environment_workloads(environment.id) + .await + .map_err(ApiError::from)?; + + // Build a map of existing workloads by name for quick lookup + let existing_workloads_map: std::collections::HashMap = existing_workloads + .into_iter() + .map(|w| (w.name.clone(), w)) + .collect(); + let catalog_workloads = self .db .get_app_catalog_workloads(catalog.id) .await .map_err(ApiError::from)?; - let workloads_with_resources = self - .get_workloads_yaml_for_catalog(&catalog, catalog_workloads.clone()) + // Determine which workloads need to be deployed (added or updated) + let workloads_to_deploy: Vec<_> = catalog_workloads + .iter() + .filter(|catalog_workload| { + match existing_workloads_map.get(&catalog_workload.name) { + Some(existing_workload) => { + // Workload needs update if catalog version is newer + catalog_workload.catalog_sync_version > existing_workload.catalog_sync_version + } + None => { + // New workload, needs to be deployed + true + } + } + }) + .cloned() + .collect(); + + // Only fetch YAML and deploy if there are workloads that need updating + let service_names = if !workloads_to_deploy.is_empty() { + tracing::info!( + "Syncing {}/{} workloads for environment {} (only changed ones)", + workloads_to_deploy.len(), + catalog_workloads.len(), + environment.name + ); + + let workloads_with_resources = self + .get_workloads_yaml_for_catalog(&catalog, workloads_to_deploy.clone()) + .await?; + + let service_names: HashSet = workloads_with_resources + .services + .keys() + .cloned() + .collect(); + + self.deploy_app_catalog_with_yaml( + &target_server, + &environment.namespace, + &environment.name, + environment.id, + Some(environment.auth_token.clone()), + workloads_with_resources, + ) .await?; - self.deploy_app_catalog_with_yaml( - &target_server, - &environment.namespace, - &environment.name, - environment.id, - Some(environment.auth_token.clone()), - workloads_with_resources, - ) - .await?; + service_names + } else { + tracing::info!( + "No workload changes detected for environment {} - skipping K8s deployment", + environment.name + ); + HashSet::new() + }; - Ok::<_, ApiError>(catalog_workloads) + Ok::<_, ApiError>((catalog_workloads, workloads_to_deploy, service_names)) } .await; // If sync failed, reset status to idle and return error - let catalog_workloads = match sync_result { - Ok(workloads) => workloads, + let (catalog_workloads, workloads_to_deploy, service_names) = match sync_result { + Ok(result) => result, Err(e) => { // Reset sync_status to idle on failure let _ = lapdev_db_entities::kube_environment::ActiveModel { @@ -845,21 +900,66 @@ impl KubeController { let now = Utc::now().into(); let txn = self.db.conn.begin().await.map_err(ApiError::from)?; - lapdev_db_entities::kube_environment_workload::Entity::update_many() + // Build a set of catalog workload names for efficient lookup + let catalog_workload_names: HashSet = catalog_workloads + .iter() + .map(|w| w.name.clone()) + .collect(); + + // Only soft-delete workloads that no longer exist in the catalog + let deleted_workloads = lapdev_db_entities::kube_environment_workload::Entity::find() .filter( lapdev_db_entities::kube_environment_workload::Column::EnvironmentId .eq(environment.id), ) .filter(lapdev_db_entities::kube_environment_workload::Column::DeletedAt.is_null()) + .all(&txn) + .await + .map_err(ApiError::from)?; + + for existing_workload in deleted_workloads { + if !catalog_workload_names.contains(&existing_workload.name) { + // This workload no longer exists in the catalog, soft-delete it + lapdev_db_entities::kube_environment_workload::ActiveModel { + id: ActiveValue::Set(existing_workload.id), + deleted_at: ActiveValue::Set(Some(now)), + ..Default::default() + } + .update(&txn) + .await + .map_err(ApiError::from)?; + } + } + + let mut service_delete_query = + lapdev_db_entities::kube_environment_service::Entity::update_many() + .filter( + lapdev_db_entities::kube_environment_service::Column::EnvironmentId + .eq(environment.id), + ) + .filter( + lapdev_db_entities::kube_environment_service::Column::DeletedAt.is_null(), + ); + + if !service_names.is_empty() { + let existing: Vec = service_names.into_iter().collect(); + service_delete_query = service_delete_query + .filter(lapdev_db_entities::kube_environment_service::Column::Name.is_not_in( + existing, + )); + } + + service_delete_query .col_expr( - lapdev_db_entities::kube_environment_workload::Column::DeletedAt, + lapdev_db_entities::kube_environment_service::Column::DeletedAt, Expr::value(now), ) .exec(&txn) .await .map_err(ApiError::from)?; - for workload in &catalog_workloads { + // Only insert/update workloads that were actually deployed + for workload in &workloads_to_deploy { let containers_json = serde_json::to_value(&workload.containers) .map(Json::from) .unwrap_or_else(|_| Json::from(serde_json::json!([]))); @@ -867,6 +967,28 @@ impl KubeController { .map(Json::from) .unwrap_or_else(|_| Json::from(serde_json::json!([]))); + // First, soft-delete any existing workload with the same name + lapdev_db_entities::kube_environment_workload::Entity::update_many() + .filter( + lapdev_db_entities::kube_environment_workload::Column::EnvironmentId + .eq(environment.id), + ) + .filter( + lapdev_db_entities::kube_environment_workload::Column::Name + .eq(workload.name.clone()), + ) + .filter( + lapdev_db_entities::kube_environment_workload::Column::DeletedAt.is_null(), + ) + .col_expr( + lapdev_db_entities::kube_environment_workload::Column::DeletedAt, + Expr::value(now), + ) + .exec(&txn) + .await + .map_err(ApiError::from)?; + + // Then insert the new version lapdev_db_entities::kube_environment_workload::ActiveModel { id: ActiveValue::Set(Uuid::new_v4()), created_at: ActiveValue::Set(now), From ba43e993ce08cd61dc8c421d71f21149fd4eca38 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 16:15:53 +0000 Subject: [PATCH 144/334] update --- crates/api/src/kube_controller/deployment.rs | 100 ++++- crates/api/src/kube_controller/environment.rs | 341 ++++++++++++------ 2 files changed, 321 insertions(+), 120 deletions(-) diff --git a/crates/api/src/kube_controller/deployment.rs b/crates/api/src/kube_controller/deployment.rs index c43688c..def9e3f 100644 --- a/crates/api/src/kube_controller/deployment.rs +++ b/crates/api/src/kube_controller/deployment.rs @@ -1,12 +1,110 @@ +use std::collections::HashMap; use uuid::Uuid; -use lapdev_common::kube::KubeAppCatalogWorkload; +use lapdev_common::kube::{KubeAppCatalogWorkload, KubeServiceDetails, KubeServiceWithYaml}; use lapdev_kube::server::KubeClusterServer; +use lapdev_kube_rpc::{KubeWorkloadYamlOnly, KubeWorkloadsWithResources}; use lapdev_rpc::error::ApiError; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use super::KubeController; impl KubeController { + /// Build KubeWorkloadsWithResources from database-cached data instead of querying Kubernetes. + /// This is much faster and doesn't require connectivity to the source cluster. + pub(super) async fn get_workloads_yaml_from_db( + &self, + cluster_id: Uuid, + namespace: &str, + workloads: Vec, + ) -> Result { + // Build workload YAMLs from database + let mut workload_yamls = Vec::new(); + for workload in &workloads { + let yaml = workload.workload_yaml.clone().ok_or_else(|| { + ApiError::InvalidRequest(format!( + "Workload '{}' has no cached YAML in database", + workload.name + )) + })?; + + let workload_yaml_only = match workload.kind { + lapdev_common::kube::KubeWorkloadKind::Deployment => { + KubeWorkloadYamlOnly::Deployment(yaml) + } + lapdev_common::kube::KubeWorkloadKind::StatefulSet => { + KubeWorkloadYamlOnly::StatefulSet(yaml) + } + lapdev_common::kube::KubeWorkloadKind::DaemonSet => { + KubeWorkloadYamlOnly::DaemonSet(yaml) + } + lapdev_common::kube::KubeWorkloadKind::ReplicaSet => { + KubeWorkloadYamlOnly::ReplicaSet(yaml) + } + lapdev_common::kube::KubeWorkloadKind::Pod => { + KubeWorkloadYamlOnly::Pod(yaml) + } + lapdev_common::kube::KubeWorkloadKind::Job => { + KubeWorkloadYamlOnly::Job(yaml) + } + lapdev_common::kube::KubeWorkloadKind::CronJob => { + KubeWorkloadYamlOnly::CronJob(yaml) + } + }; + + workload_yamls.push(workload_yaml_only); + } + + // Get services from database cache + let services = lapdev_db_entities::kube_cluster_service::Entity::find() + .filter(lapdev_db_entities::kube_cluster_service::Column::ClusterId.eq(cluster_id)) + .filter(lapdev_db_entities::kube_cluster_service::Column::Namespace.eq(namespace)) + .filter(lapdev_db_entities::kube_cluster_service::Column::DeletedAt.is_null()) + .all(&self.db.conn) + .await + .map_err(ApiError::from)?; + + let mut services_map = HashMap::new(); + for service in services { + let ports: Vec = + serde_json::from_value(service.ports.clone()).unwrap_or_default(); + let selector_btree: std::collections::BTreeMap = + serde_json::from_value(service.selector.clone()).unwrap_or_default(); + let selector: HashMap = selector_btree.into_iter().collect(); + + services_map.insert( + service.name.clone(), + KubeServiceWithYaml { + yaml: service.service_yaml, + details: KubeServiceDetails { + name: service.name, + ports, + selector, + }, + }, + ); + } + + tracing::info!( + "Built workload resources from DB: {} workloads, {} services (cluster: {}, namespace: {})", + workload_yamls.len(), + services_map.len(), + cluster_id, + namespace + ); + + Ok(KubeWorkloadsWithResources { + workloads: workload_yamls, + services: services_map, + // ConfigMaps and Secrets aren't cached in DB yet, but the workload YAML + // should already reference them, so they'll be deployed as part of the workload + configmaps: HashMap::new(), + secrets: HashMap::new(), + }) + } + + /// Legacy method that queries Kubernetes via RPC for workload YAML. + /// Consider using get_workloads_yaml_from_db instead for better performance. pub(super) async fn get_workloads_yaml_for_catalog( &self, app_catalog: &lapdev_db_entities::kube_app_catalog::Model, diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index 293b98e..d6b5313 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -744,12 +744,19 @@ impl KubeController { }) } - pub async fn sync_environment_from_catalog( + /// Authorize and validate that the environment can be synced from catalog + async fn authorize_and_validate_sync( &self, org_id: Uuid, user_id: Uuid, environment_id: Uuid, - ) -> Result<(), ApiError> { + ) -> Result< + ( + lapdev_db_entities::kube_environment::Model, + lapdev_db_entities::kube_app_catalog::Model, + ), + ApiError, + > { let environment = self .db .get_kube_environment(environment_id) @@ -777,126 +784,142 @@ impl KubeController { } if catalog.sync_version == environment.catalog_sync_version { - // Already up to date; nothing to do - return Ok(()); + return Err(ApiError::InvalidRequest( + "Environment is already up to date".to_string(), + )); } - // Set sync_status to "syncing" before starting - lapdev_db_entities::kube_environment::ActiveModel { - id: ActiveValue::Set(environment.id), - sync_status: ActiveValue::Set(KubeEnvironmentSyncStatus::Syncing.to_string()), - ..Default::default() - } - .update(&self.db.conn) - .await - .map_err(ApiError::from)?; + Ok((environment, catalog)) + } - // Perform sync operations; reset status to idle on error - let sync_result = async { - let target_server = self - .get_random_kube_cluster_server(environment.cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest( - "No connected KubeManager for the environment target cluster".to_string(), - ) - })?; - - // Get existing environment workloads - let existing_workloads = self - .db - .get_environment_workloads(environment.id) - .await - .map_err(ApiError::from)?; + /// Determine which workloads need to be synced by comparing sync versions + async fn determine_workloads_to_sync( + &self, + environment_id: Uuid, + catalog_id: Uuid, + ) -> Result< + ( + Vec, + Vec, + ), + ApiError, + > { + // Get existing environment workloads + let existing_workloads = self + .db + .get_environment_workloads(environment_id) + .await + .map_err(ApiError::from)?; - // Build a map of existing workloads by name for quick lookup - let existing_workloads_map: std::collections::HashMap = existing_workloads - .into_iter() - .map(|w| (w.name.clone(), w)) - .collect(); + // Build a map of existing workloads by name for quick lookup + let existing_workloads_map: std::collections::HashMap = existing_workloads + .into_iter() + .map(|w| (w.name.clone(), w)) + .collect(); - let catalog_workloads = self - .db - .get_app_catalog_workloads(catalog.id) - .await - .map_err(ApiError::from)?; + let catalog_workloads = self + .db + .get_app_catalog_workloads(catalog_id) + .await + .map_err(ApiError::from)?; - // Determine which workloads need to be deployed (added or updated) - let workloads_to_deploy: Vec<_> = catalog_workloads - .iter() - .filter(|catalog_workload| { - match existing_workloads_map.get(&catalog_workload.name) { - Some(existing_workload) => { - // Workload needs update if catalog version is newer - catalog_workload.catalog_sync_version > existing_workload.catalog_sync_version - } - None => { - // New workload, needs to be deployed - true - } + // Determine which workloads need to be deployed (added or updated) + let workloads_to_deploy: Vec<_> = catalog_workloads + .iter() + .filter(|catalog_workload| { + match existing_workloads_map.get(&catalog_workload.name) { + Some(existing_workload) => { + // Workload needs update if catalog version is newer + catalog_workload.catalog_sync_version > existing_workload.catalog_sync_version } - }) - .cloned() - .collect(); + None => { + // New workload, needs to be deployed + true + } + } + }) + .cloned() + .collect(); - // Only fetch YAML and deploy if there are workloads that need updating - let service_names = if !workloads_to_deploy.is_empty() { - tracing::info!( - "Syncing {}/{} workloads for environment {} (only changed ones)", - workloads_to_deploy.len(), - catalog_workloads.len(), - environment.name - ); + Ok((catalog_workloads, workloads_to_deploy)) + } - let workloads_with_resources = self - .get_workloads_yaml_for_catalog(&catalog, workloads_to_deploy.clone()) - .await?; + /// Deploy changed workloads to Kubernetes + async fn perform_workload_deployment( + &self, + environment: &lapdev_db_entities::kube_environment::Model, + catalog: &lapdev_db_entities::kube_app_catalog::Model, + workloads_to_deploy: &[lapdev_common::kube::KubeAppCatalogWorkload], + total_workloads: usize, + ) -> Result, ApiError> { + if workloads_to_deploy.is_empty() { + tracing::info!( + "No workload changes detected for environment {} - skipping K8s deployment", + environment.name + ); + return Ok(HashSet::new()); + } - let service_names: HashSet = workloads_with_resources - .services - .keys() - .cloned() - .collect(); + tracing::info!( + "Syncing {}/{} workloads for environment {} (only changed ones)", + workloads_to_deploy.len(), + total_workloads, + environment.name + ); - self.deploy_app_catalog_with_yaml( - &target_server, - &environment.namespace, - &environment.name, - environment.id, - Some(environment.auth_token.clone()), - workloads_with_resources, + let target_server = self + .get_random_kube_cluster_server(environment.cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest( + "No connected KubeManager for the environment target cluster".to_string(), ) - .await?; + })?; - service_names - } else { - tracing::info!( - "No workload changes detected for environment {} - skipping K8s deployment", - environment.name - ); - HashSet::new() - }; + // Get workload YAML from database cache instead of querying Kubernetes + // Use the first workload's namespace (all catalog workloads should be in the same namespace) + let catalog_namespace = workloads_to_deploy + .first() + .map(|w| w.namespace.as_str()) + .unwrap_or(""); - Ok::<_, ApiError>((catalog_workloads, workloads_to_deploy, service_names)) - } - .await; + let workloads_with_resources = self + .get_workloads_yaml_from_db( + catalog.cluster_id, + catalog_namespace, + workloads_to_deploy.to_vec(), + ) + .await?; - // If sync failed, reset status to idle and return error - let (catalog_workloads, workloads_to_deploy, service_names) = match sync_result { - Ok(result) => result, - Err(e) => { - // Reset sync_status to idle on failure - let _ = lapdev_db_entities::kube_environment::ActiveModel { - id: ActiveValue::Set(environment.id), - sync_status: ActiveValue::Set(KubeEnvironmentSyncStatus::Idle.to_string()), - ..Default::default() - } - .update(&self.db.conn) - .await; - return Err(e); - } - }; + let service_names: HashSet = workloads_with_resources + .services + .keys() + .cloned() + .collect(); + self.deploy_app_catalog_with_yaml( + &target_server, + &environment.namespace, + &environment.name, + environment.id, + Some(environment.auth_token.clone()), + workloads_with_resources, + ) + .await?; + + Ok(service_names) + } + + /// Update environment workloads in the database after successful deployment + async fn update_environment_workloads_in_db( + &self, + environment_id: Uuid, + environment_namespace: &str, + catalog_workloads: &[lapdev_common::kube::KubeAppCatalogWorkload], + workloads_to_deploy: &[lapdev_common::kube::KubeAppCatalogWorkload], + service_names: HashSet, + new_catalog_sync_version: i64, + ) -> Result<(), ApiError> { let now = Utc::now().into(); let txn = self.db.conn.begin().await.map_err(ApiError::from)?; @@ -910,7 +933,7 @@ impl KubeController { let deleted_workloads = lapdev_db_entities::kube_environment_workload::Entity::find() .filter( lapdev_db_entities::kube_environment_workload::Column::EnvironmentId - .eq(environment.id), + .eq(environment_id), ) .filter(lapdev_db_entities::kube_environment_workload::Column::DeletedAt.is_null()) .all(&txn) @@ -931,11 +954,12 @@ impl KubeController { } } + // Soft-delete services that are no longer in use let mut service_delete_query = lapdev_db_entities::kube_environment_service::Entity::update_many() .filter( lapdev_db_entities::kube_environment_service::Column::EnvironmentId - .eq(environment.id), + .eq(environment_id), ) .filter( lapdev_db_entities::kube_environment_service::Column::DeletedAt.is_null(), @@ -943,10 +967,9 @@ impl KubeController { if !service_names.is_empty() { let existing: Vec = service_names.into_iter().collect(); - service_delete_query = service_delete_query - .filter(lapdev_db_entities::kube_environment_service::Column::Name.is_not_in( - existing, - )); + service_delete_query = service_delete_query.filter( + lapdev_db_entities::kube_environment_service::Column::Name.is_not_in(existing), + ); } service_delete_query @@ -959,7 +982,7 @@ impl KubeController { .map_err(ApiError::from)?; // Only insert/update workloads that were actually deployed - for workload in &workloads_to_deploy { + for workload in workloads_to_deploy { let containers_json = serde_json::to_value(&workload.containers) .map(Json::from) .unwrap_or_else(|_| Json::from(serde_json::json!([]))); @@ -971,7 +994,7 @@ impl KubeController { lapdev_db_entities::kube_environment_workload::Entity::update_many() .filter( lapdev_db_entities::kube_environment_workload::Column::EnvironmentId - .eq(environment.id), + .eq(environment_id), ) .filter( lapdev_db_entities::kube_environment_workload::Column::Name @@ -993,22 +1016,23 @@ impl KubeController { id: ActiveValue::Set(Uuid::new_v4()), created_at: ActiveValue::Set(now), deleted_at: ActiveValue::Set(None), - environment_id: ActiveValue::Set(environment.id), + environment_id: ActiveValue::Set(environment_id), name: ActiveValue::Set(workload.name.clone()), - namespace: ActiveValue::Set(environment.namespace.clone()), + namespace: ActiveValue::Set(environment_namespace.to_string()), kind: ActiveValue::Set(workload.kind.to_string()), containers: ActiveValue::Set(containers_json), ports: ActiveValue::Set(ports_json), - catalog_sync_version: ActiveValue::Set(catalog.sync_version), + catalog_sync_version: ActiveValue::Set(new_catalog_sync_version), } .insert(&txn) .await .map_err(ApiError::from)?; } + // Update environment's catalog sync version and timestamp lapdev_db_entities::kube_environment::ActiveModel { - id: ActiveValue::Set(environment.id), - catalog_sync_version: ActiveValue::Set(catalog.sync_version), + id: ActiveValue::Set(environment_id), + catalog_sync_version: ActiveValue::Set(new_catalog_sync_version), last_catalog_synced_at: ActiveValue::Set(Some(now)), sync_status: ActiveValue::Set(KubeEnvironmentSyncStatus::Idle.to_string()), ..Default::default() @@ -1022,6 +1046,85 @@ impl KubeController { Ok(()) } + pub async fn sync_environment_from_catalog( + &self, + org_id: Uuid, + user_id: Uuid, + environment_id: Uuid, + ) -> Result<(), ApiError> { + // Authorize and validate that sync is needed + let (environment, catalog) = match self + .authorize_and_validate_sync(org_id, user_id, environment_id) + .await + { + Ok(result) => result, + Err(ApiError::InvalidRequest(msg)) if msg == "Environment is already up to date" => { + return Ok(()) + } + Err(e) => return Err(e), + }; + + // Mark environment as syncing + lapdev_db_entities::kube_environment::ActiveModel { + id: ActiveValue::Set(environment.id), + sync_status: ActiveValue::Set(KubeEnvironmentSyncStatus::Syncing.to_string()), + ..Default::default() + } + .update(&self.db.conn) + .await + .map_err(ApiError::from)?; + + // Perform sync operations; reset status to idle on error + let sync_result = async { + // Determine which workloads need syncing + let (catalog_workloads, workloads_to_deploy) = self + .determine_workloads_to_sync(environment.id, catalog.id) + .await?; + + // Deploy changed workloads to Kubernetes + let service_names = self + .perform_workload_deployment( + &environment, + &catalog, + &workloads_to_deploy, + catalog_workloads.len(), + ) + .await?; + + Ok::<_, ApiError>((catalog_workloads, workloads_to_deploy, service_names)) + } + .await; + + // Handle sync result - reset status on failure + let (catalog_workloads, workloads_to_deploy, service_names) = match sync_result { + Ok(result) => result, + Err(e) => { + // Reset sync_status to idle on failure + let _ = lapdev_db_entities::kube_environment::ActiveModel { + id: ActiveValue::Set(environment.id), + sync_status: ActiveValue::Set(KubeEnvironmentSyncStatus::Idle.to_string()), + ..Default::default() + } + .update(&self.db.conn) + .await; + return Err(e); + } + }; + + // Update database with synced workloads + self.update_environment_workloads_in_db( + environment.id, + &environment.namespace, + &catalog_workloads, + &workloads_to_deploy, + service_names, + catalog.sync_version, + ) + .await?; + + Ok(()) + } + // Kube Namespace operations pub async fn create_kube_namespace( &self, From cd1066df9a6ca1eff893fc4cbdae29103cd15416 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 16:42:33 +0000 Subject: [PATCH 145/334] update --- crates/api/src/kube_controller/app_catalog.rs | 101 ++- crates/api/src/kube_controller/deployment.rs | 100 +-- crates/api/src/kube_controller/environment.rs | 594 +++++++++++------- 3 files changed, 457 insertions(+), 338 deletions(-) diff --git a/crates/api/src/kube_controller/app_catalog.rs b/crates/api/src/kube_controller/app_catalog.rs index a877534..cb65ad2 100644 --- a/crates/api/src/kube_controller/app_catalog.rs +++ b/crates/api/src/kube_controller/app_catalog.rs @@ -1,10 +1,11 @@ use lapdev_common::kube::{ - KubeAppCatalog, KubeAppCatalogWorkload, KubeAppCatalogWorkloadCreate, PagePaginationParams, - PaginatedInfo, PaginatedResult, + KubeAppCatalog, KubeAppCatalogWorkload, KubeAppCatalogWorkloadCreate, KubeServiceDetails, + KubeServiceWithYaml, PagePaginationParams, PaginatedInfo, PaginatedResult, }; use lapdev_db::api::CachedClusterService; +use lapdev_kube_rpc::{KubeWorkloadYamlOnly, KubeWorkloadsWithResources}; use lapdev_rpc::error::ApiError; -use sea_orm::TransactionTrait; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, TransactionTrait}; use std::collections::{HashMap, HashSet}; use uuid::Uuid; @@ -76,6 +77,100 @@ impl KubeController { Ok(results) } + /// Build KubeWorkloadsWithResources from database-cached catalog data. + /// This retrieves workload YAML and services from the database cache instead of querying Kubernetes. + /// Much faster and doesn't require connectivity to the source cluster. + pub(super) async fn get_catalog_workloads_with_yaml_from_db( + &self, + cluster_id: Uuid, + namespace: &str, + workloads: Vec, + ) -> Result { + // Build workload YAMLs from database + let mut workload_yamls = Vec::new(); + for workload in &workloads { + let yaml = workload.workload_yaml.clone().ok_or_else(|| { + ApiError::InvalidRequest(format!( + "Workload '{}' has no cached YAML in database", + workload.name + )) + })?; + + let workload_yaml_only = match workload.kind { + lapdev_common::kube::KubeWorkloadKind::Deployment => { + KubeWorkloadYamlOnly::Deployment(yaml) + } + lapdev_common::kube::KubeWorkloadKind::StatefulSet => { + KubeWorkloadYamlOnly::StatefulSet(yaml) + } + lapdev_common::kube::KubeWorkloadKind::DaemonSet => { + KubeWorkloadYamlOnly::DaemonSet(yaml) + } + lapdev_common::kube::KubeWorkloadKind::ReplicaSet => { + KubeWorkloadYamlOnly::ReplicaSet(yaml) + } + lapdev_common::kube::KubeWorkloadKind::Pod => { + KubeWorkloadYamlOnly::Pod(yaml) + } + lapdev_common::kube::KubeWorkloadKind::Job => { + KubeWorkloadYamlOnly::Job(yaml) + } + lapdev_common::kube::KubeWorkloadKind::CronJob => { + KubeWorkloadYamlOnly::CronJob(yaml) + } + }; + + workload_yamls.push(workload_yaml_only); + } + + // Get services from database cache + let services = lapdev_db_entities::kube_cluster_service::Entity::find() + .filter(lapdev_db_entities::kube_cluster_service::Column::ClusterId.eq(cluster_id)) + .filter(lapdev_db_entities::kube_cluster_service::Column::Namespace.eq(namespace)) + .filter(lapdev_db_entities::kube_cluster_service::Column::DeletedAt.is_null()) + .all(&self.db.conn) + .await + .map_err(ApiError::from)?; + + let mut services_map = HashMap::new(); + for service in services { + let ports: Vec = + serde_json::from_value(service.ports.clone()).unwrap_or_default(); + let selector_btree: std::collections::BTreeMap = + serde_json::from_value(service.selector.clone()).unwrap_or_default(); + let selector: HashMap = selector_btree.into_iter().collect(); + + services_map.insert( + service.name.clone(), + KubeServiceWithYaml { + yaml: service.service_yaml, + details: KubeServiceDetails { + name: service.name, + ports, + selector, + }, + }, + ); + } + + tracing::info!( + "Built catalog workload resources from DB: {} workloads, {} services (cluster: {}, namespace: {})", + workload_yamls.len(), + services_map.len(), + cluster_id, + namespace + ); + + Ok(KubeWorkloadsWithResources { + workloads: workload_yamls, + services: services_map, + // ConfigMaps and Secrets aren't cached in DB yet, but the workload YAML + // should already reference them, so they'll be deployed as part of the workload + configmaps: HashMap::new(), + secrets: HashMap::new(), + }) + } + pub async fn create_app_catalog( &self, org_id: Uuid, diff --git a/crates/api/src/kube_controller/deployment.rs b/crates/api/src/kube_controller/deployment.rs index def9e3f..2c1f36d 100644 --- a/crates/api/src/kube_controller/deployment.rs +++ b/crates/api/src/kube_controller/deployment.rs @@ -1,110 +1,14 @@ -use std::collections::HashMap; use uuid::Uuid; -use lapdev_common::kube::{KubeAppCatalogWorkload, KubeServiceDetails, KubeServiceWithYaml}; +use lapdev_common::kube::KubeAppCatalogWorkload; use lapdev_kube::server::KubeClusterServer; -use lapdev_kube_rpc::{KubeWorkloadYamlOnly, KubeWorkloadsWithResources}; use lapdev_rpc::error::ApiError; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use super::KubeController; impl KubeController { - /// Build KubeWorkloadsWithResources from database-cached data instead of querying Kubernetes. - /// This is much faster and doesn't require connectivity to the source cluster. - pub(super) async fn get_workloads_yaml_from_db( - &self, - cluster_id: Uuid, - namespace: &str, - workloads: Vec, - ) -> Result { - // Build workload YAMLs from database - let mut workload_yamls = Vec::new(); - for workload in &workloads { - let yaml = workload.workload_yaml.clone().ok_or_else(|| { - ApiError::InvalidRequest(format!( - "Workload '{}' has no cached YAML in database", - workload.name - )) - })?; - - let workload_yaml_only = match workload.kind { - lapdev_common::kube::KubeWorkloadKind::Deployment => { - KubeWorkloadYamlOnly::Deployment(yaml) - } - lapdev_common::kube::KubeWorkloadKind::StatefulSet => { - KubeWorkloadYamlOnly::StatefulSet(yaml) - } - lapdev_common::kube::KubeWorkloadKind::DaemonSet => { - KubeWorkloadYamlOnly::DaemonSet(yaml) - } - lapdev_common::kube::KubeWorkloadKind::ReplicaSet => { - KubeWorkloadYamlOnly::ReplicaSet(yaml) - } - lapdev_common::kube::KubeWorkloadKind::Pod => { - KubeWorkloadYamlOnly::Pod(yaml) - } - lapdev_common::kube::KubeWorkloadKind::Job => { - KubeWorkloadYamlOnly::Job(yaml) - } - lapdev_common::kube::KubeWorkloadKind::CronJob => { - KubeWorkloadYamlOnly::CronJob(yaml) - } - }; - - workload_yamls.push(workload_yaml_only); - } - - // Get services from database cache - let services = lapdev_db_entities::kube_cluster_service::Entity::find() - .filter(lapdev_db_entities::kube_cluster_service::Column::ClusterId.eq(cluster_id)) - .filter(lapdev_db_entities::kube_cluster_service::Column::Namespace.eq(namespace)) - .filter(lapdev_db_entities::kube_cluster_service::Column::DeletedAt.is_null()) - .all(&self.db.conn) - .await - .map_err(ApiError::from)?; - - let mut services_map = HashMap::new(); - for service in services { - let ports: Vec = - serde_json::from_value(service.ports.clone()).unwrap_or_default(); - let selector_btree: std::collections::BTreeMap = - serde_json::from_value(service.selector.clone()).unwrap_or_default(); - let selector: HashMap = selector_btree.into_iter().collect(); - - services_map.insert( - service.name.clone(), - KubeServiceWithYaml { - yaml: service.service_yaml, - details: KubeServiceDetails { - name: service.name, - ports, - selector, - }, - }, - ); - } - - tracing::info!( - "Built workload resources from DB: {} workloads, {} services (cluster: {}, namespace: {})", - workload_yamls.len(), - services_map.len(), - cluster_id, - namespace - ); - - Ok(KubeWorkloadsWithResources { - workloads: workload_yamls, - services: services_map, - // ConfigMaps and Secrets aren't cached in DB yet, but the workload YAML - // should already reference them, so they'll be deployed as part of the workload - configmaps: HashMap::new(), - secrets: HashMap::new(), - }) - } - /// Legacy method that queries Kubernetes via RPC for workload YAML. - /// Consider using get_workloads_yaml_from_db instead for better performance. + /// Consider using get_catalog_workloads_with_yaml_from_db in app_catalog.rs instead for better performance. pub(super) async fn get_workloads_yaml_for_catalog( &self, app_catalog: &lapdev_db_entities::kube_app_catalog::Model, diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index d6b5313..5919cac 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -177,13 +177,81 @@ impl KubeController { }) } - pub async fn delete_kube_environment( + /// Prepare workload details for environment creation by copying from catalog workloads + fn prepare_workload_details_from_catalog( + workloads: Vec, + namespace: &str, + ) -> Vec { + workloads + .into_iter() + .map(|workload| { + let mut containers = workload.containers; + for container in &mut containers { + // Preserve the original environment variables + container.original_env_vars = container.env_vars.clone(); + container.env_vars.clear(); + + // If the app catalog has a customized image, use it as the original_image + // for the new environment (so the environment starts from the customized state) + match &container.image { + KubeContainerImage::Custom(custom_image) => { + container.original_image = custom_image.clone(); + container.image = KubeContainerImage::FollowOriginal; + } + KubeContainerImage::FollowOriginal => { + // Keep the current original_image and FollowOriginal setting + } + } + } + lapdev_common::kube::KubeWorkloadDetails { + name: workload.name, + namespace: namespace.to_string(), + kind: workload.kind, + containers, + ports: workload.ports, + workload_yaml: workload.workload_yaml.unwrap_or_default(), + } + }) + .collect() + } + + /// Build KubeEnvironment response from database model + fn build_environment_response( + created_env: lapdev_db_entities::kube_environment::Model, + app_catalog_name: String, + cluster_name: String, + base_environment_name: Option, + ) -> lapdev_common::kube::KubeEnvironment { + lapdev_common::kube::KubeEnvironment { + id: created_env.id, + user_id: created_env.user_id, + name: created_env.name, + namespace: created_env.namespace, + status: created_env.status, + is_shared: created_env.is_shared, + app_catalog_id: created_env.app_catalog_id, + app_catalog_name, + cluster_id: created_env.cluster_id, + cluster_name, + created_at: created_env.created_at.to_string(), + base_environment_id: created_env.base_environment_id, + base_environment_name, + catalog_sync_version: created_env.catalog_sync_version, + last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), + catalog_update_available: false, + sync_status: KubeEnvironmentSyncStatus::from_str(&created_env.sync_status) + .unwrap_or(KubeEnvironmentSyncStatus::Idle), + } + } + + /// Authorize environment deletion + async fn authorize_environment_deletion( &self, org_id: Uuid, user_id: Uuid, environment_id: Uuid, - ) -> Result<(), ApiError> { - // First get the environment to check ownership + ) -> Result { + // Get the environment to check ownership let environment = self .db .get_kube_environment(environment_id) @@ -216,53 +284,56 @@ impl KubeController { } } - let rpc_client = self - .get_random_kube_cluster_server(environment.cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest( - "No connected KubeManager for this cluster; cannot delete environment" - .to_string(), - ) - })? - .rpc_client - .clone(); + Ok(environment) + } - // If this is a branch environment, notify the base environment's devbox-proxy before deletion - if let Some(base_env_id) = environment.base_environment_id { - match rpc_client - .remove_branch_environment(tarpc::context::current(), base_env_id, environment_id) - .await - { - Ok(Ok(())) => { - tracing::info!( - "Successfully notified devbox-proxy about branch environment {} deletion", - environment_id - ); - } - Ok(Err(e)) => { - tracing::error!( - "Failed to notify devbox-proxy about branch environment {} deletion: {}", - environment_id, - e - ); - return Err(ApiError::InvalidRequest(format!( - "Failed to notify devbox-proxy about branch environment deletion: {e}" - ))); - } - Err(e) => { - tracing::error!( - "RPC call failed when notifying about branch environment {} deletion: {}", - environment_id, - e - ); - return Err(ApiError::InvalidRequest(format!( - "Connection error while notifying devbox-proxy: {e}" - ))); - } + /// Notify devbox-proxy about branch environment deletion + async fn notify_branch_environment_deletion( + &self, + rpc_client: &lapdev_kube_rpc::KubeManagerRpcClient, + base_env_id: Uuid, + environment_id: Uuid, + ) -> Result<(), ApiError> { + match rpc_client + .remove_branch_environment(tarpc::context::current(), base_env_id, environment_id) + .await + { + Ok(Ok(())) => { + tracing::info!( + "Successfully notified devbox-proxy about branch environment {} deletion", + environment_id + ); + Ok(()) + } + Ok(Err(e)) => { + tracing::error!( + "Failed to notify devbox-proxy about branch environment {} deletion: {}", + environment_id, + e + ); + Err(ApiError::InvalidRequest(format!( + "Failed to notify devbox-proxy about branch environment deletion: {e}" + ))) + } + Err(e) => { + tracing::error!( + "RPC call failed when notifying about branch environment {} deletion: {}", + environment_id, + e + ); + Err(ApiError::InvalidRequest(format!( + "Connection error while notifying devbox-proxy: {e}" + ))) } } + } + /// Destroy environment resources via RPC + async fn destroy_environment_resources( + &self, + rpc_client: &lapdev_kube_rpc::KubeManagerRpcClient, + environment: &lapdev_db_entities::kube_environment::Model, + ) -> Result<(), ApiError> { match rpc_client .destroy_environment( tarpc::context::current(), @@ -277,6 +348,7 @@ impl KubeController { environment.id, environment.namespace ); + Ok(()) } Ok(Err(e)) => { tracing::error!( @@ -284,9 +356,9 @@ impl KubeController { environment.id, e ); - return Err(ApiError::InvalidRequest(format!( + Err(ApiError::InvalidRequest(format!( "Failed to delete environment resources: {e}" - ))); + ))) } Err(e) => { tracing::error!( @@ -294,11 +366,46 @@ impl KubeController { environment.id, e ); - return Err(ApiError::InvalidRequest(format!( + Err(ApiError::InvalidRequest(format!( "Failed to communicate with KubeManager to delete environment: {e}" - ))); + ))) } } + } + + pub async fn delete_kube_environment( + &self, + org_id: Uuid, + user_id: Uuid, + environment_id: Uuid, + ) -> Result<(), ApiError> { + // Authorize deletion + let environment = self + .authorize_environment_deletion(org_id, user_id, environment_id) + .await?; + + // Get RPC client for cluster operations + let rpc_client = self + .get_random_kube_cluster_server(environment.cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest( + "No connected KubeManager for this cluster; cannot delete environment" + .to_string(), + ) + })? + .rpc_client + .clone(); + + // If this is a branch environment, notify the base environment's devbox-proxy + if let Some(base_env_id) = environment.base_environment_id { + self.notify_branch_environment_deletion(&rpc_client, base_env_id, environment_id) + .await?; + } + + // Destroy environment resources in Kubernetes + self.destroy_environment_resources(&rpc_client, &environment) + .await?; // Delete from database (soft delete) self.db @@ -309,23 +416,20 @@ impl KubeController { Ok(()) } - pub async fn create_kube_environment( + /// Validate and get app catalog and cluster for environment creation + async fn validate_environment_creation( &self, org_id: Uuid, - user_id: Uuid, app_catalog_id: Uuid, cluster_id: Uuid, - name: String, is_shared: bool, - ) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(ApiError::InvalidRequest( - "Environment name cannot be empty".to_string(), - )); - } - let name = name.to_string(); - + ) -> Result< + ( + lapdev_db_entities::kube_app_catalog::Model, + lapdev_db_entities::kube_cluster::Model, + ), + ApiError, + > { // Verify app catalog belongs to the organization let app_catalog = self .db @@ -363,7 +467,32 @@ impl KubeController { )); } - // Get a connected KubeClusterServer for this cluster + Ok((app_catalog, cluster)) + } + + pub async fn create_kube_environment( + &self, + org_id: Uuid, + user_id: Uuid, + app_catalog_id: Uuid, + cluster_id: Uuid, + name: String, + is_shared: bool, + ) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(ApiError::InvalidRequest( + "Environment name cannot be empty".to_string(), + )); + } + let name = name.to_string(); + + // Validate catalog and cluster + let (app_catalog, cluster) = self + .validate_environment_creation(org_id, app_catalog_id, cluster_id, is_shared) + .await?; + + // Get a connected KubeClusterServer for deployment let server = self .get_random_kube_cluster_server(cluster_id) .await @@ -373,6 +502,7 @@ impl KubeController { ) })?; + // Get workloads from catalog let workloads = self .db .get_app_catalog_workloads(app_catalog.id) @@ -386,11 +516,12 @@ impl KubeController { ))); } - // First, get workloads YAML before creating in database to validate success + // Get workloads YAML to validate before creating in database let workloads_with_resources = self .get_workloads_yaml_for_catalog(&app_catalog, workloads.clone()) .await?; + // Generate unique namespace let namespace = self .generate_unique_namespace( cluster_id, @@ -404,39 +535,11 @@ impl KubeController { let services_map = workloads_with_resources.services.clone(); - // Store the environment workloads in the database before deployment - let workload_details: Vec = workloads - .into_iter() - .map(|workload| { - let mut containers = workload.containers; - for container in &mut containers { - // Preserve the original environment variables - container.original_env_vars = container.env_vars.clone(); - container.env_vars.clear(); - - // If the app catalog has a customized image, use it as the original_image - // for the new environment (so the environment starts from the customized state) - match &container.image { - KubeContainerImage::Custom(custom_image) => { - container.original_image = custom_image.clone(); - container.image = KubeContainerImage::FollowOriginal; - } - KubeContainerImage::FollowOriginal => { - // Keep the current original_image and FollowOriginal setting - } - } - } - lapdev_common::kube::KubeWorkloadDetails { - name: workload.name, - namespace: namespace.clone(), - kind: workload.kind, - containers, - ports: workload.ports, - workload_yaml: workload.workload_yaml.unwrap_or_default(), - } - }) - .collect(); + // Prepare workload details for database + let workload_details = + Self::prepare_workload_details_from_catalog(workloads, &namespace); + // Create environment in database let created_env = match self .db .create_kube_environment( @@ -476,7 +579,7 @@ impl KubeController { } }; - // Deploy the app catalog resources to the cluster using pre-fetched YAML + // Deploy resources to Kubernetes self.deploy_app_catalog_with_yaml( &server, &created_env.namespace, @@ -487,44 +590,28 @@ impl KubeController { ) .await?; - // Convert the database model to the API type - Ok(lapdev_common::kube::KubeEnvironment { - id: created_env.id, - user_id: created_env.user_id, - name: created_env.name, - namespace: created_env.namespace, - status: created_env.status, - is_shared: created_env.is_shared, - app_catalog_id: created_env.app_catalog_id, - app_catalog_name: app_catalog.name, - cluster_id: created_env.cluster_id, - cluster_name: cluster.name, - created_at: created_env.created_at.to_string(), - base_environment_id: created_env.base_environment_id, - base_environment_name: None, // Regular environments have no base environment - catalog_sync_version: created_env.catalog_sync_version, - last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), - catalog_update_available: false, - sync_status: KubeEnvironmentSyncStatus::from_str(&created_env.sync_status) - .unwrap_or(KubeEnvironmentSyncStatus::Idle), - }) + // Build and return response + Ok(Self::build_environment_response( + created_env, + app_catalog.name, + cluster.name, + None, + )) } - pub async fn create_branch_environment( + /// Validate base environment for branch creation + async fn validate_base_environment( &self, org_id: Uuid, - user_id: Uuid, base_environment_id: Uuid, - name: String, - ) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(ApiError::InvalidRequest( - "Environment name cannot be empty".to_string(), - )); - } - let name = name.to_string(); - + ) -> Result< + ( + lapdev_db_entities::kube_environment::Model, + lapdev_db_entities::kube_cluster::Model, + lapdev_db_entities::kube_app_catalog::Model, + ), + ApiError, + > { // Get the base environment let base_environment = self .db @@ -552,7 +639,7 @@ impl KubeController { )); } - // Get the cluster to verify personal deployments are allowed + // Get the cluster let cluster = self .db .get_kube_cluster(base_environment.cluster_id) @@ -560,47 +647,7 @@ impl KubeController { .map_err(ApiError::from)? .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; - // Get workloads and services from the base environment - let base_workloads = self - .db - .get_environment_workloads(base_environment_id) - .await - .map_err(ApiError::from)?; - - let base_services = self - .db - .get_environment_services(base_environment_id) - .await - .map_err(ApiError::from)?; - - let namespace = self - .generate_unique_namespace( - base_environment.cluster_id, - EnvironmentNamespaceKind::Branch, - ) - .await?; - - let services_map: std::collections::HashMap< - String, - lapdev_common::kube::KubeServiceWithYaml, - > = base_services - .into_iter() - .map(|service| { - ( - service.name.clone(), - lapdev_common::kube::KubeServiceWithYaml { - yaml: service.yaml, - details: lapdev_common::kube::KubeServiceDetails { - name: service.name, - ports: service.ports, - selector: service.selector, - }, - }, - ) - }) - .collect(); - - // Get app catalog info for the response + // Get app catalog let app_catalog = self .db .get_app_catalog(base_environment.app_catalog_id) @@ -608,8 +655,15 @@ impl KubeController { .map_err(ApiError::from)? .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; - // Convert workloads to the format needed for database creation - let workload_details: Vec = base_workloads + Ok((base_environment, cluster, app_catalog)) + } + + /// Prepare workload details from base environment workloads + fn prepare_workload_details_from_base( + base_workloads: Vec, + namespace: &str, + ) -> Vec { + base_workloads .into_iter() .filter_map(|workload| { workload.kind.parse().ok().map(|kind| { @@ -633,7 +687,7 @@ impl KubeController { } lapdev_common::kube::KubeWorkloadDetails { name: workload.name, - namespace: namespace.clone(), + namespace: namespace.to_string(), kind, containers, ports: workload.ports, @@ -641,46 +695,17 @@ impl KubeController { } }) }) - .collect(); - - let created_env = match self - .db - .create_kube_environment( - org_id, - user_id, - base_environment.app_catalog_id, - base_environment.cluster_id, - name.clone(), - namespace.clone(), - "Pending".to_string(), - false, // Branch environments are always personal (not shared) - base_environment.catalog_sync_version, - Some(base_environment_id), // Set the base environment reference - workload_details, - services_map, - ) - .await - { - Ok(env) => env, - Err(db_err) => { - if let Some(sea_orm::SqlErr::UniqueConstraintViolation(constraint)) = - db_err.sql_err() - { - if constraint == "kube_environment_cluster_namespace_unique_idx" { - return Err(ApiError::InternalError( - "Namespace allocation conflict. Please retry branch environment creation.".to_string(), - )); - } - } - return Err(ApiError::from(anyhow::Error::from(db_err))); - } - }; + .collect() + } - // Notify kube-manager about the new branch environment via RPC - if let Some(server) = self - .get_random_kube_cluster_server(base_environment.cluster_id) - .await - { + /// Notify devbox-proxy about new branch environment + async fn notify_branch_environment_creation( + &self, + base_environment_id: Uuid, + cluster_id: Uuid, + created_env: &lapdev_db_entities::kube_environment::Model, + ) { + if let Some(server) = self.get_random_kube_cluster_server(cluster_id).await { let branch_info = lapdev_kube_rpc::BranchEnvironmentInfo { environment_id: created_env.id, auth_token: created_env.auth_token.clone(), @@ -716,32 +741,127 @@ impl KubeController { } else { tracing::warn!( "No connected KubeManager for cluster {} - branch environment {} not registered with devbox-proxy", - base_environment.cluster_id, + cluster_id, created_env.id ); } + } - // Convert the database model to the API type - Ok(lapdev_common::kube::KubeEnvironment { - id: created_env.id, - user_id: created_env.user_id, - name: created_env.name, - namespace: created_env.namespace, - status: created_env.status, - is_shared: created_env.is_shared, - app_catalog_id: created_env.app_catalog_id, - app_catalog_name: app_catalog.name, - cluster_id: created_env.cluster_id, - cluster_name: cluster.name, - created_at: created_env.created_at.to_string(), - base_environment_id: created_env.base_environment_id, - base_environment_name: Some(base_environment.name), // Branch environments have the base environment name - catalog_sync_version: created_env.catalog_sync_version, - last_catalog_synced_at: created_env.last_catalog_synced_at.map(|dt| dt.to_string()), - catalog_update_available: false, - sync_status: KubeEnvironmentSyncStatus::from_str(&created_env.sync_status) - .unwrap_or(KubeEnvironmentSyncStatus::Idle), - }) + pub async fn create_branch_environment( + &self, + org_id: Uuid, + user_id: Uuid, + base_environment_id: Uuid, + name: String, + ) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(ApiError::InvalidRequest( + "Environment name cannot be empty".to_string(), + )); + } + let name = name.to_string(); + + // Validate base environment + let (base_environment, cluster, app_catalog) = self + .validate_base_environment(org_id, base_environment_id) + .await?; + + // Get workloads and services from the base environment + let base_workloads = self + .db + .get_environment_workloads(base_environment_id) + .await + .map_err(ApiError::from)?; + + let base_services = self + .db + .get_environment_services(base_environment_id) + .await + .map_err(ApiError::from)?; + + // Generate unique namespace + let namespace = self + .generate_unique_namespace( + base_environment.cluster_id, + EnvironmentNamespaceKind::Branch, + ) + .await?; + + // Prepare services map + let services_map: std::collections::HashMap< + String, + lapdev_common::kube::KubeServiceWithYaml, + > = base_services + .into_iter() + .map(|service| { + ( + service.name.clone(), + lapdev_common::kube::KubeServiceWithYaml { + yaml: service.yaml, + details: lapdev_common::kube::KubeServiceDetails { + name: service.name, + ports: service.ports, + selector: service.selector, + }, + }, + ) + }) + .collect(); + + // Prepare workload details + let workload_details = + Self::prepare_workload_details_from_base(base_workloads, &namespace); + + // Create environment in database + let created_env = match self + .db + .create_kube_environment( + org_id, + user_id, + base_environment.app_catalog_id, + base_environment.cluster_id, + name.clone(), + namespace.clone(), + "Pending".to_string(), + false, // Branch environments are always personal (not shared) + base_environment.catalog_sync_version, + Some(base_environment_id), // Set the base environment reference + workload_details, + services_map, + ) + .await + { + Ok(env) => env, + Err(db_err) => { + if let Some(sea_orm::SqlErr::UniqueConstraintViolation(constraint)) = + db_err.sql_err() + { + if constraint == "kube_environment_cluster_namespace_unique_idx" { + return Err(ApiError::InternalError( + "Namespace allocation conflict. Please retry branch environment creation.".to_string(), + )); + } + } + return Err(ApiError::from(anyhow::Error::from(db_err))); + } + }; + + // Notify kube-manager about the new branch environment + self.notify_branch_environment_creation( + base_environment_id, + base_environment.cluster_id, + &created_env, + ) + .await; + + // Build and return response + Ok(Self::build_environment_response( + created_env, + app_catalog.name, + cluster.name, + Some(base_environment.name), + )) } /// Authorize and validate that the environment can be synced from catalog @@ -884,7 +1004,7 @@ impl KubeController { .unwrap_or(""); let workloads_with_resources = self - .get_workloads_yaml_from_db( + .get_catalog_workloads_with_yaml_from_db( catalog.cluster_id, catalog_namespace, workloads_to_deploy.to_vec(), From 3cb4c801fb8a66cc3dc05fdfc8eacda07b2fb963 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 17:49:19 +0000 Subject: [PATCH 146/334] update --- crates/api/src/kube_controller/app_catalog.rs | 51 +++-- crates/api/src/kube_controller/deployment.rs | 38 ---- crates/api/src/kube_controller/environment.rs | 40 +--- crates/db/src/api.rs | 206 +++++++++++++++++- crates/kube/src/server.rs | 39 +++- 5 files changed, 280 insertions(+), 94 deletions(-) diff --git a/crates/api/src/kube_controller/app_catalog.rs b/crates/api/src/kube_controller/app_catalog.rs index cb65ad2..767bcd2 100644 --- a/crates/api/src/kube_controller/app_catalog.rs +++ b/crates/api/src/kube_controller/app_catalog.rs @@ -80,10 +80,10 @@ impl KubeController { /// Build KubeWorkloadsWithResources from database-cached catalog data. /// This retrieves workload YAML and services from the database cache instead of querying Kubernetes. /// Much faster and doesn't require connectivity to the source cluster. + /// Uses workload label and service selector tables to find only services that select the workloads' pods. pub(super) async fn get_catalog_workloads_with_yaml_from_db( &self, cluster_id: Uuid, - namespace: &str, workloads: Vec, ) -> Result { // Build workload YAMLs from database @@ -109,12 +109,8 @@ impl KubeController { lapdev_common::kube::KubeWorkloadKind::ReplicaSet => { KubeWorkloadYamlOnly::ReplicaSet(yaml) } - lapdev_common::kube::KubeWorkloadKind::Pod => { - KubeWorkloadYamlOnly::Pod(yaml) - } - lapdev_common::kube::KubeWorkloadKind::Job => { - KubeWorkloadYamlOnly::Job(yaml) - } + lapdev_common::kube::KubeWorkloadKind::Pod => KubeWorkloadYamlOnly::Pod(yaml), + lapdev_common::kube::KubeWorkloadKind::Job => KubeWorkloadYamlOnly::Job(yaml), lapdev_common::kube::KubeWorkloadKind::CronJob => { KubeWorkloadYamlOnly::CronJob(yaml) } @@ -123,29 +119,43 @@ impl KubeController { workload_yamls.push(workload_yaml_only); } - // Get services from database cache - let services = lapdev_db_entities::kube_cluster_service::Entity::find() - .filter(lapdev_db_entities::kube_cluster_service::Column::ClusterId.eq(cluster_id)) - .filter(lapdev_db_entities::kube_cluster_service::Column::Namespace.eq(namespace)) - .filter(lapdev_db_entities::kube_cluster_service::Column::DeletedAt.is_null()) - .all(&self.db.conn) + // Get services that select these workloads using the label and selector tables + let workload_ids: Vec = workloads.iter().map(|w| w.id).collect(); + let cached_services = self + .db + .get_services_for_catalog_workloads(cluster_id, &workload_ids) .await .map_err(ApiError::from)?; + // Convert CachedClusterService to KubeServiceWithYaml format + // Note: We need to fetch the service YAML from the database + let service_names: Vec = cached_services.iter().map(|s| s.name.clone()).collect(); + let service_entities = if !service_names.is_empty() { + lapdev_db_entities::kube_cluster_service::Entity::find() + .filter(lapdev_db_entities::kube_cluster_service::Column::ClusterId.eq(cluster_id)) + .filter(lapdev_db_entities::kube_cluster_service::Column::Name.is_in(service_names)) + .filter(lapdev_db_entities::kube_cluster_service::Column::DeletedAt.is_null()) + .all(&self.db.conn) + .await + .map_err(ApiError::from)? + } else { + Vec::new() + }; + let mut services_map = HashMap::new(); - for service in services { + for service_entity in service_entities { let ports: Vec = - serde_json::from_value(service.ports.clone()).unwrap_or_default(); + serde_json::from_value(service_entity.ports.clone()).unwrap_or_default(); let selector_btree: std::collections::BTreeMap = - serde_json::from_value(service.selector.clone()).unwrap_or_default(); + serde_json::from_value(service_entity.selector.clone()).unwrap_or_default(); let selector: HashMap = selector_btree.into_iter().collect(); services_map.insert( - service.name.clone(), + service_entity.name.clone(), KubeServiceWithYaml { - yaml: service.service_yaml, + yaml: service_entity.service_yaml, details: KubeServiceDetails { - name: service.name, + name: service_entity.name, ports, selector, }, @@ -154,11 +164,10 @@ impl KubeController { } tracing::info!( - "Built catalog workload resources from DB: {} workloads, {} services (cluster: {}, namespace: {})", + "Built catalog workload resources from DB: {} workloads, {} matching services (cluster: {})", workload_yamls.len(), services_map.len(), cluster_id, - namespace ); Ok(KubeWorkloadsWithResources { diff --git a/crates/api/src/kube_controller/deployment.rs b/crates/api/src/kube_controller/deployment.rs index 2c1f36d..5cfbff0 100644 --- a/crates/api/src/kube_controller/deployment.rs +++ b/crates/api/src/kube_controller/deployment.rs @@ -1,49 +1,11 @@ use uuid::Uuid; -use lapdev_common::kube::KubeAppCatalogWorkload; use lapdev_kube::server::KubeClusterServer; use lapdev_rpc::error::ApiError; use super::KubeController; impl KubeController { - /// Legacy method that queries Kubernetes via RPC for workload YAML. - /// Consider using get_catalog_workloads_with_yaml_from_db in app_catalog.rs instead for better performance. - pub(super) async fn get_workloads_yaml_for_catalog( - &self, - app_catalog: &lapdev_db_entities::kube_app_catalog::Model, - workloads: Vec, - ) -> Result { - let source_server = self - .get_random_kube_cluster_server(app_catalog.cluster_id) - .await - .ok_or_else(|| { - ApiError::InvalidRequest( - "No connected KubeManager for the app catalog's source cluster".to_string(), - ) - })?; - - match source_server - .rpc_client - .get_workloads_yaml(tarpc::context::current(), workloads) - .await - { - Ok(Ok(workloads_with_resources)) => Ok(workloads_with_resources), - Ok(Err(e)) => { - tracing::error!( - "Failed to get YAML for workloads from source cluster: {}", - e - ); - Err(ApiError::InvalidRequest(format!( - "Failed to get YAML for workloads from source cluster: {e}" - ))) - } - Err(e) => Err(ApiError::InvalidRequest(format!( - "Connection error to source cluster: {e}" - ))), - } - } - pub(super) async fn deploy_app_catalog_with_yaml( &self, target_server: &KubeClusterServer, diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index 5919cac..ce158fc 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -518,7 +518,7 @@ impl KubeController { // Get workloads YAML to validate before creating in database let workloads_with_resources = self - .get_workloads_yaml_for_catalog(&app_catalog, workloads.clone()) + .get_catalog_workloads_with_yaml_from_db(app_catalog.cluster_id, workloads.clone()) .await?; // Generate unique namespace @@ -536,8 +536,7 @@ impl KubeController { let services_map = workloads_with_resources.services.clone(); // Prepare workload details for database - let workload_details = - Self::prepare_workload_details_from_catalog(workloads, &namespace); + let workload_details = Self::prepare_workload_details_from_catalog(workloads, &namespace); // Create environment in database let created_env = match self @@ -810,8 +809,7 @@ impl KubeController { .collect(); // Prepare workload details - let workload_details = - Self::prepare_workload_details_from_base(base_workloads, &namespace); + let workload_details = Self::prepare_workload_details_from_base(base_workloads, &namespace); // Create environment in database let created_env = match self @@ -950,7 +948,8 @@ impl KubeController { match existing_workloads_map.get(&catalog_workload.name) { Some(existing_workload) => { // Workload needs update if catalog version is newer - catalog_workload.catalog_sync_version > existing_workload.catalog_sync_version + catalog_workload.catalog_sync_version + > existing_workload.catalog_sync_version } None => { // New workload, needs to be deployed @@ -997,25 +996,16 @@ impl KubeController { })?; // Get workload YAML from database cache instead of querying Kubernetes - // Use the first workload's namespace (all catalog workloads should be in the same namespace) - let catalog_namespace = workloads_to_deploy - .first() - .map(|w| w.namespace.as_str()) - .unwrap_or(""); - + // Handles workloads from multiple namespaces let workloads_with_resources = self .get_catalog_workloads_with_yaml_from_db( catalog.cluster_id, - catalog_namespace, workloads_to_deploy.to_vec(), ) .await?; - let service_names: HashSet = workloads_with_resources - .services - .keys() - .cloned() - .collect(); + let service_names: HashSet = + workloads_with_resources.services.keys().cloned().collect(); self.deploy_app_catalog_with_yaml( &target_server, @@ -1044,10 +1034,8 @@ impl KubeController { let txn = self.db.conn.begin().await.map_err(ApiError::from)?; // Build a set of catalog workload names for efficient lookup - let catalog_workload_names: HashSet = catalog_workloads - .iter() - .map(|w| w.name.clone()) - .collect(); + let catalog_workload_names: HashSet = + catalog_workloads.iter().map(|w| w.name.clone()).collect(); // Only soft-delete workloads that no longer exist in the catalog let deleted_workloads = lapdev_db_entities::kube_environment_workload::Entity::find() @@ -1081,9 +1069,7 @@ impl KubeController { lapdev_db_entities::kube_environment_service::Column::EnvironmentId .eq(environment_id), ) - .filter( - lapdev_db_entities::kube_environment_service::Column::DeletedAt.is_null(), - ); + .filter(lapdev_db_entities::kube_environment_service::Column::DeletedAt.is_null()); if !service_names.is_empty() { let existing: Vec = service_names.into_iter().collect(); @@ -1120,9 +1106,7 @@ impl KubeController { lapdev_db_entities::kube_environment_workload::Column::Name .eq(workload.name.clone()), ) - .filter( - lapdev_db_entities::kube_environment_workload::Column::DeletedAt.is_null(), - ) + .filter(lapdev_db_entities::kube_environment_workload::Column::DeletedAt.is_null()) .col_expr( lapdev_db_entities::kube_environment_workload::Column::DeletedAt, Expr::value(now), diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 2d0b905..79b7d05 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -11,7 +11,8 @@ use lapdev_common::{ LAPDEV_ISOLATE_CONTAINER, }; use lapdev_db_entities::{ - kube_app_catalog_workload_dependency, kube_cluster_service, kube_cluster_service_selector, + kube_app_catalog_workload_dependency, kube_app_catalog_workload_label, kube_cluster_service, + kube_cluster_service_selector, }; use lapdev_db_migration::Migrator; use pasetors::{ @@ -30,7 +31,7 @@ use serde::Deserialize; use serde_json; use serde_yaml::Value; use sqlx::PgPool; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::convert::TryFrom; use uuid::Uuid; @@ -1106,6 +1107,28 @@ impl DbApi { } } + pub async fn get_service_selector_map( + &self, + cluster_id: Uuid, + namespace: &str, + name: &str, + ) -> Result>> { + let Some(model) = kube_cluster_service::Entity::find() + .filter(kube_cluster_service::Column::ClusterId.eq(cluster_id)) + .filter(kube_cluster_service::Column::Namespace.eq(namespace)) + .filter(kube_cluster_service::Column::Name.eq(name)) + .one(&self.conn) + .await? + else { + return Ok(None); + }; + + let selector: BTreeMap = + serde_json::from_value(model.selector.clone()).unwrap_or_default(); + + Ok(Some(selector)) + } + pub async fn mark_cluster_service_deleted( &self, cluster_id: Uuid, @@ -2090,6 +2113,185 @@ impl DbApi { Ok(results) } + /// Get services that select the given catalog workloads based on their pod labels. + /// Uses the workload label table and service selector table for efficient matching. + /// Processes each workload individually and queries service selectors by namespace and label pairs. + pub async fn get_services_for_catalog_workloads( + &self, + cluster_id: Uuid, + workload_ids: &[Uuid], + ) -> Result> { + if workload_ids.is_empty() { + return Ok(Vec::new()); + } + + let workload_ids_vec: Vec = workload_ids.to_vec(); + + let label_rows = kube_app_catalog_workload_label::Entity::find() + .filter( + kube_app_catalog_workload_label::Column::WorkloadId.is_in(workload_ids_vec.clone()), + ) + .filter(kube_app_catalog_workload_label::Column::DeletedAt.is_null()) + .all(&self.conn) + .await?; + + if label_rows.is_empty() { + return Ok(Vec::new()); + } + + let mut workload_labels: HashMap> = HashMap::new(); + let mut workload_namespaces: HashMap = HashMap::new(); + + for row in label_rows { + workload_labels + .entry(row.workload_id) + .or_default() + .insert(row.label_key.clone(), row.label_value.clone()); + workload_namespaces + .entry(row.workload_id) + .or_insert(row.namespace); + } + + let mut workloads_by_namespace: HashMap> = HashMap::new(); + for workload_id in workload_ids_vec.iter().copied() { + if let (Some(labels), Some(namespace)) = ( + workload_labels.get(&workload_id), + workload_namespaces.get(&workload_id), + ) { + if !labels.is_empty() { + workloads_by_namespace + .entry(namespace.clone()) + .or_default() + .push(workload_id); + } + } + } + + if workloads_by_namespace.is_empty() { + return Ok(Vec::new()); + } + + let mut all_matching_service_ids: HashSet = HashSet::new(); + + for (namespace, workloads_in_namespace) in workloads_by_namespace { + let mut label_pairs: BTreeSet<(String, String)> = BTreeSet::new(); + for workload_id in &workloads_in_namespace { + if let Some(labels) = workload_labels.get(workload_id) { + for (key, value) in labels { + label_pairs.insert((key.clone(), value.clone())); + } + } + } + + if label_pairs.is_empty() { + continue; + } + + let mut label_condition = Condition::any(); + for (key, value) in &label_pairs { + label_condition = label_condition.add( + Condition::all() + .add(kube_cluster_service_selector::Column::LabelKey.eq(key.clone())) + .add(kube_cluster_service_selector::Column::LabelValue.eq(value.clone())), + ); + } + + let selector_rows = kube_cluster_service_selector::Entity::find() + .filter(kube_cluster_service_selector::Column::ClusterId.eq(cluster_id)) + .filter(kube_cluster_service_selector::Column::Namespace.eq(namespace.clone())) + .filter(kube_cluster_service_selector::Column::DeletedAt.is_null()) + .filter(label_condition) + .all(&self.conn) + .await?; + + if selector_rows.is_empty() { + continue; + } + + let candidate_service_ids: HashSet = + selector_rows.iter().map(|row| row.service_id).collect(); + + if candidate_service_ids.is_empty() { + continue; + } + + let candidate_service_ids_vec: Vec = + candidate_service_ids.iter().copied().collect(); + + let selector_rows_full = kube_cluster_service_selector::Entity::find() + .filter(kube_cluster_service_selector::Column::ClusterId.eq(cluster_id)) + .filter(kube_cluster_service_selector::Column::Namespace.eq(namespace.clone())) + .filter(kube_cluster_service_selector::Column::DeletedAt.is_null()) + .filter( + kube_cluster_service_selector::Column::ServiceId + .is_in(candidate_service_ids_vec), + ) + .all(&self.conn) + .await?; + + if selector_rows_full.is_empty() { + continue; + } + + let mut selectors_by_service: HashMap> = HashMap::new(); + for row in selector_rows_full { + selectors_by_service + .entry(row.service_id) + .or_default() + .push((row.label_key, row.label_value)); + } + + for workload_id in workloads_in_namespace { + if let Some(labels) = workload_labels.get(&workload_id) { + for (service_id, selectors) in &selectors_by_service { + if selectors.iter().all(|(key, value)| { + labels + .get(key) + .map(|existing| existing == value) + .unwrap_or(false) + }) { + all_matching_service_ids.insert(*service_id); + } + } + } + } + } + + if all_matching_service_ids.is_empty() { + return Ok(Vec::new()); + } + + // Fetch the actual service records + let services = kube_cluster_service::Entity::find() + .filter( + kube_cluster_service::Column::Id + .is_in(all_matching_service_ids.into_iter().collect::>()), + ) + .filter(kube_cluster_service::Column::DeletedAt.is_null()) + .all(&self.conn) + .await?; + + let mut results = Vec::with_capacity(services.len()); + for svc in services { + let selector: BTreeMap = + serde_json::from_value(svc.selector.clone()).unwrap_or_default(); + let ports_raw: Vec = + serde_json::from_value(svc.ports.clone()).unwrap_or_default(); + let ports = ports_raw + .into_iter() + .filter_map(StoredServicePort::into_service_port) + .collect(); + + results.push(CachedClusterService { + name: svc.name, + selector, + ports, + }); + } + + Ok(results) + } + pub async fn find_workloads_by_dependency( &self, cluster_id: Uuid, diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index 11d204f..3d8c86e 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -393,6 +393,10 @@ impl KubeClusterServer { async fn handle_service_change(&self, event: &ResourceChangeEvent) -> AnyResult<()> { if matches!(event.change_type, ResourceChangeType::Deleted) { + let selector_map = self + .db + .get_service_selector_map(self.cluster_id, &event.namespace, &event.resource_name) + .await?; self.db .mark_cluster_service_deleted( self.cluster_id, @@ -401,6 +405,15 @@ impl KubeClusterServer { event.timestamp, ) .await?; + if let Some(selector_map) = selector_map { + self.reconcile_workloads_for_selector( + &event.namespace, + &event.resource_name, + &selector_map, + event.timestamp, + ) + .await?; + } return Ok(()); } @@ -472,18 +485,34 @@ impl KubeClusterServer { ) .await?; + self.reconcile_workloads_for_selector( + &event.namespace, + &event.resource_name, + &selector_map, + event.timestamp, + ) + .await + } + + async fn reconcile_workloads_for_selector( + &self, + namespace: &str, + resource_name: &str, + selector_map: &BTreeMap, + observed_at: chrono::DateTime, + ) -> AnyResult<()> { if selector_map.is_empty() { return Ok(()); } let matching_workload_ids = self .db - .find_workloads_matching_selector(self.cluster_id, &event.namespace, &selector_map) + .find_workloads_matching_selector(self.cluster_id, namespace, selector_map) .await .with_context(|| { format!( "failed to resolve workloads matching service selector for {}", - event.resource_name + resource_name ) })?; @@ -499,7 +528,7 @@ impl KubeClusterServer { .with_context(|| { format!( "failed querying catalog workloads for service selector {}/{}", - event.namespace, event.resource_name + namespace, resource_name ) })?; @@ -531,7 +560,7 @@ impl KubeClusterServer { let labels = label_rows.get(&workload.id).cloned().unwrap_or_default(); let matching_services = self .db - .get_matching_cluster_services(self.cluster_id, &event.namespace, &labels) + .get_matching_cluster_services(self.cluster_id, namespace, &labels) .await?; let service_ports = ports_from_cached_services(&labels, &matching_services); let ports_json = Json::from(serde_json::to_value(&service_ports)?); @@ -555,7 +584,7 @@ impl KubeClusterServer { } if !workloads_by_catalog.is_empty() { - let synced_at: DateTimeWithTimeZone = event.timestamp.into(); + let synced_at: DateTimeWithTimeZone = observed_at.into(); for (catalog_id, workload_ids) in workloads_by_catalog { let new_version = self .db From 37949c72960129b7676d669057cbeb8b2ced8435 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 18:06:47 +0000 Subject: [PATCH 147/334] update --- crates/api/src/kube_controller/app_catalog.rs | 35 +- crates/api/src/kube_controller/deployment.rs | 4 +- crates/api/src/kube_controller/environment.rs | 4 +- crates/api/src/kube_controller/mod.rs | 1 + .../kube_controller/workload_yaml_cleaner.rs | 422 ++++++++++++++++++ 5 files changed, 453 insertions(+), 13 deletions(-) create mode 100644 crates/api/src/kube_controller/workload_yaml_cleaner.rs diff --git a/crates/api/src/kube_controller/app_catalog.rs b/crates/api/src/kube_controller/app_catalog.rs index 767bcd2..622bf15 100644 --- a/crates/api/src/kube_controller/app_catalog.rs +++ b/crates/api/src/kube_controller/app_catalog.rs @@ -9,7 +9,10 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, TransactionTrait}; use std::collections::{HashMap, HashSet}; use uuid::Uuid; -use super::{yaml_parser::build_workload_details_from_yaml, KubeController}; +use super::{ + workload_yaml_cleaner::rebuild_workload_yaml, yaml_parser::build_workload_details_from_yaml, + KubeController, +}; use chrono::Utc; impl KubeController { @@ -89,30 +92,44 @@ impl KubeController { // Build workload YAMLs from database let mut workload_yamls = Vec::new(); for workload in &workloads { - let yaml = workload.workload_yaml.clone().ok_or_else(|| { + let raw_yaml = workload.workload_yaml.clone().ok_or_else(|| { ApiError::InvalidRequest(format!( "Workload '{}' has no cached YAML in database", workload.name )) })?; + let rebuilt_yaml = + rebuild_workload_yaml(&workload.kind, &raw_yaml, &workload.containers).map_err( + |err| { + ApiError::InvalidRequest(format!( + "Failed to reconstruct workload YAML for '{}': {}", + workload.name, err + )) + }, + )?; + let workload_yaml_only = match workload.kind { lapdev_common::kube::KubeWorkloadKind::Deployment => { - KubeWorkloadYamlOnly::Deployment(yaml) + KubeWorkloadYamlOnly::Deployment(rebuilt_yaml) } lapdev_common::kube::KubeWorkloadKind::StatefulSet => { - KubeWorkloadYamlOnly::StatefulSet(yaml) + KubeWorkloadYamlOnly::StatefulSet(rebuilt_yaml) } lapdev_common::kube::KubeWorkloadKind::DaemonSet => { - KubeWorkloadYamlOnly::DaemonSet(yaml) + KubeWorkloadYamlOnly::DaemonSet(rebuilt_yaml) } lapdev_common::kube::KubeWorkloadKind::ReplicaSet => { - KubeWorkloadYamlOnly::ReplicaSet(yaml) + KubeWorkloadYamlOnly::ReplicaSet(rebuilt_yaml) + } + lapdev_common::kube::KubeWorkloadKind::Pod => { + KubeWorkloadYamlOnly::Pod(rebuilt_yaml) + } + lapdev_common::kube::KubeWorkloadKind::Job => { + KubeWorkloadYamlOnly::Job(rebuilt_yaml) } - lapdev_common::kube::KubeWorkloadKind::Pod => KubeWorkloadYamlOnly::Pod(yaml), - lapdev_common::kube::KubeWorkloadKind::Job => KubeWorkloadYamlOnly::Job(yaml), lapdev_common::kube::KubeWorkloadKind::CronJob => { - KubeWorkloadYamlOnly::CronJob(yaml) + KubeWorkloadYamlOnly::CronJob(rebuilt_yaml) } }; diff --git a/crates/api/src/kube_controller/deployment.rs b/crates/api/src/kube_controller/deployment.rs index 5cfbff0..82f9570 100644 --- a/crates/api/src/kube_controller/deployment.rs +++ b/crates/api/src/kube_controller/deployment.rs @@ -6,7 +6,7 @@ use lapdev_rpc::error::ApiError; use super::KubeController; impl KubeController { - pub(super) async fn deploy_app_catalog_with_yaml( + pub(super) async fn deploy_environment_resources( &self, target_server: &KubeClusterServer, namespace: &str, @@ -16,7 +16,7 @@ impl KubeController { workloads_with_resources: lapdev_kube_rpc::KubeWorkloadsWithResources, ) -> Result<(), ApiError> { tracing::info!( - "Deploying app catalog resources for environment '{}' in namespace '{}'", + "Deploying environment resources for '{}' in namespace '{}'", environment_name, namespace ); diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index ce158fc..95d1e28 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -579,7 +579,7 @@ impl KubeController { }; // Deploy resources to Kubernetes - self.deploy_app_catalog_with_yaml( + self.deploy_environment_resources( &server, &created_env.namespace, &name, @@ -1007,7 +1007,7 @@ impl KubeController { let service_names: HashSet = workloads_with_resources.services.keys().cloned().collect(); - self.deploy_app_catalog_with_yaml( + self.deploy_environment_resources( &target_server, &environment.namespace, &environment.name, diff --git a/crates/api/src/kube_controller/mod.rs b/crates/api/src/kube_controller/mod.rs index 0a0395c..45bccf0 100644 --- a/crates/api/src/kube_controller/mod.rs +++ b/crates/api/src/kube_controller/mod.rs @@ -15,6 +15,7 @@ mod preview_url; mod service; pub mod validation; mod workload; +mod workload_yaml_cleaner; pub mod yaml_parser; // Re-exports diff --git a/crates/api/src/kube_controller/workload_yaml_cleaner.rs b/crates/api/src/kube_controller/workload_yaml_cleaner.rs new file mode 100644 index 0000000..4834b42 --- /dev/null +++ b/crates/api/src/kube_controller/workload_yaml_cleaner.rs @@ -0,0 +1,422 @@ +use std::collections::HashMap; + +use anyhow::Result; +use k8s_openapi::{ + api::{ + apps::v1::{ + DaemonSet, DaemonSetSpec, Deployment, DeploymentSpec, ReplicaSet, ReplicaSetSpec, + StatefulSet, StatefulSetSpec, + }, + batch::v1::{CronJob, CronJobSpec, Job, JobSpec}, + core::v1::{Container, ContainerPort, EnvVar, EnvVarSource, Pod, PodSpec, PodTemplateSpec}, + }, + apimachinery::pkg::{api::resource::Quantity, apis::meta::v1::ObjectMeta}, +}; +use lapdev_common::kube::{KubeContainerImage, KubeContainerInfo, KubeWorkloadKind}; + +/// Rebuild workload YAML so it reflects the latest container configuration while +/// stripping server-managed metadata. Mirrors kube-manager's clean_* helpers. +pub fn rebuild_workload_yaml( + kind: &KubeWorkloadKind, + yaml: &str, + containers: &[KubeContainerInfo], +) -> Result { + match kind { + KubeWorkloadKind::Deployment => { + let deployment: Deployment = serde_yaml::from_str(yaml)?; + let cleaned = clean_deployment(deployment, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::StatefulSet => { + let statefulset: StatefulSet = serde_yaml::from_str(yaml)?; + let cleaned = clean_statefulset(statefulset, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::DaemonSet => { + let daemonset: DaemonSet = serde_yaml::from_str(yaml)?; + let cleaned = clean_daemonset(daemonset, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::ReplicaSet => { + let replicaset: ReplicaSet = serde_yaml::from_str(yaml)?; + let cleaned = clean_replicaset(replicaset, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::Pod => { + let pod: Pod = serde_yaml::from_str(yaml)?; + let cleaned = clean_pod(pod, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::Job => { + let job: Job = serde_yaml::from_str(yaml)?; + let cleaned = clean_job(job, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::CronJob => { + let cronjob: CronJob = serde_yaml::from_str(yaml)?; + let cleaned = clean_cronjob(cronjob, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + } +} + +fn clean_metadata(metadata: ObjectMeta) -> ObjectMeta { + ObjectMeta { + name: metadata.name, + labels: metadata.labels, + ..Default::default() + } +} + +fn clean_deployment( + deployment: Deployment, + workload_containers: &[KubeContainerInfo], +) -> Deployment { + let clean_spec = deployment.spec.map(|original_spec| { + let template = merge_template_containers(original_spec.template, workload_containers); + + DeploymentSpec { + replicas: original_spec.replicas, + selector: original_spec.selector, + template, + min_ready_seconds: original_spec.min_ready_seconds, + paused: original_spec.paused, + progress_deadline_seconds: original_spec.progress_deadline_seconds, + revision_history_limit: original_spec.revision_history_limit, + strategy: original_spec.strategy, + } + }); + + Deployment { + metadata: clean_metadata(deployment.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_statefulset( + statefulset: StatefulSet, + workload_containers: &[KubeContainerInfo], +) -> StatefulSet { + let clean_spec = statefulset.spec.map(|original_spec| { + let template = merge_template_containers(original_spec.template, workload_containers); + + StatefulSetSpec { + service_name: original_spec.service_name, + replicas: original_spec.replicas, + selector: original_spec.selector, + template, + volume_claim_templates: original_spec.volume_claim_templates, + update_strategy: original_spec.update_strategy, + min_ready_seconds: original_spec.min_ready_seconds, + persistent_volume_claim_retention_policy: original_spec + .persistent_volume_claim_retention_policy, + ordinals: original_spec.ordinals, + revision_history_limit: original_spec.revision_history_limit, + pod_management_policy: original_spec.pod_management_policy, + } + }); + + StatefulSet { + metadata: clean_metadata(statefulset.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_daemonset(daemonset: DaemonSet, workload_containers: &[KubeContainerInfo]) -> DaemonSet { + let clean_spec = daemonset.spec.map(|original_spec| { + let template = merge_template_containers(original_spec.template, workload_containers); + + DaemonSetSpec { + selector: original_spec.selector, + template, + update_strategy: original_spec.update_strategy, + min_ready_seconds: original_spec.min_ready_seconds, + revision_history_limit: original_spec.revision_history_limit, + } + }); + + DaemonSet { + metadata: clean_metadata(daemonset.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_replicaset( + replicaset: ReplicaSet, + workload_containers: &[KubeContainerInfo], +) -> ReplicaSet { + let clean_spec = replicaset.spec.map(|original_spec| { + let template = original_spec + .template + .map(|t| merge_template_containers(t, workload_containers)); + + ReplicaSetSpec { + replicas: original_spec.replicas, + selector: original_spec.selector, + template, + min_ready_seconds: original_spec.min_ready_seconds, + } + }); + + ReplicaSet { + metadata: clean_metadata(replicaset.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_pod(pod: Pod, workload_containers: &[KubeContainerInfo]) -> Pod { + let clean_spec = pod.spec.map(|original_spec| { + let merged_containers = merge_containers(original_spec.containers, workload_containers); + + PodSpec { + active_deadline_seconds: original_spec.active_deadline_seconds, + containers: merged_containers, + init_containers: original_spec.init_containers, + ephemeral_containers: original_spec.ephemeral_containers, + volumes: original_spec.volumes, + restart_policy: original_spec.restart_policy, + termination_grace_period_seconds: original_spec.termination_grace_period_seconds, + dns_policy: original_spec.dns_policy, + dns_config: original_spec.dns_config, + node_selector: original_spec.node_selector, + service_account_name: original_spec.service_account_name, + service_account: original_spec.service_account, + automount_service_account_token: original_spec.automount_service_account_token, + security_context: original_spec.security_context, + image_pull_secrets: original_spec.image_pull_secrets, + affinity: original_spec.affinity, + tolerations: original_spec.tolerations, + topology_spread_constraints: original_spec.topology_spread_constraints, + priority_class_name: original_spec.priority_class_name, + priority: original_spec.priority, + preemption_policy: original_spec.preemption_policy, + overhead: original_spec.overhead, + enable_service_links: original_spec.enable_service_links, + os: original_spec.os, + host_users: original_spec.host_users, + scheduling_gates: original_spec.scheduling_gates, + resource_claims: original_spec.resource_claims, + ..Default::default() + } + }); + + Pod { + metadata: clean_metadata(pod.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_job(job: Job, workload_containers: &[KubeContainerInfo]) -> Job { + let clean_spec = job.spec.map(|original_spec| { + let template = merge_template_containers(original_spec.template, workload_containers); + + JobSpec { + template, + parallelism: original_spec.parallelism, + completions: original_spec.completions, + completion_mode: original_spec.completion_mode, + active_deadline_seconds: original_spec.active_deadline_seconds, + backoff_limit: original_spec.backoff_limit, + backoff_limit_per_index: original_spec.backoff_limit_per_index, + max_failed_indexes: original_spec.max_failed_indexes, + selector: original_spec.selector, + manual_selector: original_spec.manual_selector, + ttl_seconds_after_finished: original_spec.ttl_seconds_after_finished, + suspend: original_spec.suspend, + pod_failure_policy: original_spec.pod_failure_policy, + pod_replacement_policy: original_spec.pod_replacement_policy, + managed_by: original_spec.managed_by, + success_policy: original_spec.success_policy, + } + }); + + Job { + metadata: clean_metadata(job.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_cronjob(cronjob: CronJob, workload_containers: &[KubeContainerInfo]) -> CronJob { + let clean_spec = cronjob.spec.map(|original_spec| { + let mut job_template = original_spec.job_template; + if let Some(job_spec) = &mut job_template.spec { + job_spec.template = + merge_template_containers(job_spec.template.clone(), workload_containers); + } + + CronJobSpec { + schedule: original_spec.schedule, + time_zone: original_spec.time_zone, + starting_deadline_seconds: original_spec.starting_deadline_seconds, + concurrency_policy: original_spec.concurrency_policy, + suspend: original_spec.suspend, + job_template, + successful_jobs_history_limit: original_spec.successful_jobs_history_limit, + failed_jobs_history_limit: original_spec.failed_jobs_history_limit, + } + }); + + CronJob { + metadata: clean_metadata(cronjob.metadata), + spec: clean_spec, + status: None, + } +} + +fn merge_template_containers( + template: PodTemplateSpec, + workload_containers: &[KubeContainerInfo], +) -> PodTemplateSpec { + let pod_spec = template.spec.map(|original_pod_spec| { + let merged_containers = original_pod_spec + .containers + .into_iter() + .map(|container| { + if let Some(workload_container) = workload_containers + .iter() + .find(|wc| wc.name == container.name) + { + merge_single_container(container, workload_container) + } else { + container + } + }) + .collect(); + + PodSpec { + containers: merged_containers, + ..original_pod_spec + } + }); + + PodTemplateSpec { + spec: pod_spec, + ..template + } +} + +fn merge_containers( + containers: Vec, + workload_containers: &[KubeContainerInfo], +) -> Vec { + containers + .into_iter() + .map(|container| { + if let Some(workload_container) = workload_containers + .iter() + .find(|wc| wc.name == container.name) + { + merge_single_container(container, workload_container) + } else { + container + } + }) + .collect() +} + +fn merge_single_container( + container: Container, + workload_container: &KubeContainerInfo, +) -> Container { + let mut new_container = container.clone(); + + match &workload_container.image { + KubeContainerImage::FollowOriginal => {} + KubeContainerImage::Custom(custom_image) => { + if !custom_image.is_empty() { + new_container.image = Some(custom_image.clone()); + } + } + } + + let mut resources = container.resources.unwrap_or_default(); + let mut requests = resources.requests.unwrap_or_default(); + let mut limits = resources.limits.unwrap_or_default(); + + if let Some(cpu_request) = &workload_container.cpu_request { + if !cpu_request.is_empty() { + requests.insert("cpu".to_string(), Quantity(cpu_request.clone())); + } + } + if let Some(memory_request) = &workload_container.memory_request { + if !memory_request.is_empty() { + requests.insert("memory".to_string(), Quantity(memory_request.clone())); + } + } + + if let Some(cpu_limit) = &workload_container.cpu_limit { + if !cpu_limit.is_empty() { + limits.insert("cpu".to_string(), Quantity(cpu_limit.clone())); + } + } + if let Some(memory_limit) = &workload_container.memory_limit { + if !memory_limit.is_empty() { + limits.insert("memory".to_string(), Quantity(memory_limit.clone())); + } + } + + resources.requests = if requests.is_empty() { + None + } else { + Some(requests) + }; + resources.limits = if limits.is_empty() { + None + } else { + Some(limits) + }; + new_container.resources = Some(resources); + + let mut env_map: HashMap, Option)> = HashMap::new(); + + if let Some(original_env) = container.env { + for env_var in original_env { + env_map.insert(env_var.name.clone(), (env_var.value, env_var.value_from)); + } + } + + for kube_env_var in &workload_container.env_vars { + env_map.insert( + kube_env_var.name.clone(), + (Some(kube_env_var.value.clone()), None), + ); + } + + let merged_env: Vec = env_map + .into_iter() + .map(|(name, (value, value_from))| EnvVar { + name, + value, + value_from, + }) + .collect(); + + new_container.env = if merged_env.is_empty() { + None + } else { + Some(merged_env) + }; + + // Preserve defined container ports; ensure they match desired ones + if !workload_container.ports.is_empty() { + let ports: Vec = workload_container + .ports + .iter() + .map(|port| ContainerPort { + name: port.name.clone(), + container_port: port.container_port, + protocol: port.protocol.clone(), + ..Default::default() + }) + .collect(); + new_container.ports = Some(ports); + } + + new_container +} From 3972ff4f7c92647c2be57b09501451270f27c7a1 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 18:33:41 +0000 Subject: [PATCH 148/334] update --- crates/api/src/kube_controller/app_catalog.rs | 131 +++++- crates/api/src/kube_controller/mod.rs | 2 +- .../src/kube_controller/resource_cleaner.rs | 30 ++ crates/api/src/kube_controller/resources.rs | 445 ++++++++++++++++++ crates/kube-manager/src/manager.rs | 55 ++- crates/kube-manager/src/manager_rpc.rs | 16 +- crates/kube-rpc/src/lib.rs | 18 + 7 files changed, 687 insertions(+), 10 deletions(-) create mode 100644 crates/api/src/kube_controller/resource_cleaner.rs create mode 100644 crates/api/src/kube_controller/resources.rs diff --git a/crates/api/src/kube_controller/app_catalog.rs b/crates/api/src/kube_controller/app_catalog.rs index 622bf15..506e086 100644 --- a/crates/api/src/kube_controller/app_catalog.rs +++ b/crates/api/src/kube_controller/app_catalog.rs @@ -1,16 +1,21 @@ +use k8s_openapi::api::core::v1::{ConfigMap, Secret}; use lapdev_common::kube::{ KubeAppCatalog, KubeAppCatalogWorkload, KubeAppCatalogWorkloadCreate, KubeServiceDetails, KubeServiceWithYaml, PagePaginationParams, PaginatedInfo, PaginatedResult, }; use lapdev_db::api::CachedClusterService; -use lapdev_kube_rpc::{KubeWorkloadYamlOnly, KubeWorkloadsWithResources}; +use lapdev_db_entities::kube_app_catalog_workload_dependency; +use lapdev_kube_rpc::{ + KubeWorkloadYamlOnly, KubeWorkloadsWithResources, NamespacedResourceRequest, +}; use lapdev_rpc::error::ApiError; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, TransactionTrait}; use std::collections::{HashMap, HashSet}; use uuid::Uuid; use super::{ - workload_yaml_cleaner::rebuild_workload_yaml, yaml_parser::build_workload_details_from_yaml, + resources::{clean_configmap, clean_secret, rebuild_workload_yaml}, + yaml_parser::build_workload_details_from_yaml, KubeController, }; use chrono::Utc; @@ -180,20 +185,132 @@ impl KubeController { ); } + let dependency_rows = if workload_ids.is_empty() { + Vec::new() + } else { + kube_app_catalog_workload_dependency::Entity::find() + .filter( + kube_app_catalog_workload_dependency::Column::WorkloadId + .is_in(workload_ids.clone()), + ) + .filter(kube_app_catalog_workload_dependency::Column::DeletedAt.is_null()) + .all(&self.db.conn) + .await + .map_err(ApiError::from)? + }; + + let mut dependency_requests: HashMap, HashSet)> = + HashMap::new(); + + for row in dependency_rows { + let namespace = row.namespace.clone(); + let resource_name = row.resource_name.clone(); + let entry = dependency_requests.entry(namespace).or_default(); + match row.resource_type.as_str() { + "configmap" => { + entry.0.insert(resource_name); + } + "secret" => { + entry.1.insert(resource_name); + } + _ => {} + } + } + + let mut configmaps_map: HashMap = HashMap::new(); + let mut secrets_map: HashMap = HashMap::new(); + + if !dependency_requests.is_empty() { + let cluster_server = self + .get_random_kube_cluster_server(cluster_id) + .await + .ok_or_else(|| { + ApiError::InvalidRequest( + "No connected KubeManager for the app catalog's source cluster".to_string(), + ) + })?; + + let requests: Vec = dependency_requests + .into_iter() + .map( + |(namespace, (configmaps, secrets))| NamespacedResourceRequest { + namespace, + configmaps: configmaps.into_iter().collect(), + secrets: secrets.into_iter().collect(), + }, + ) + .collect(); + + if !requests.is_empty() { + let responses = match cluster_server + .rpc_client + .get_namespaced_resources(tarpc::context::current(), requests) + .await + { + Ok(Ok(res)) => res, + Ok(Err(e)) => { + return Err(ApiError::InvalidRequest(format!( + "Failed to fetch ConfigMaps/Secrets: {e}" + ))) + } + Err(e) => { + return Err(ApiError::InvalidRequest(format!( + "Connection error to source cluster: {e}" + ))) + } + }; + + for response in responses { + for (name, yaml) in response.configmaps { + let parsed: ConfigMap = serde_yaml::from_str(&yaml).map_err(|err| { + ApiError::InvalidRequest(format!( + "Failed to parse ConfigMap '{name}' from namespace {}: {err}", + response.namespace + )) + })?; + let cleaned = clean_configmap(parsed); + let cleaned_yaml = serde_yaml::to_string(&cleaned).map_err(|err| { + ApiError::InvalidRequest(format!( + "Failed to serialize ConfigMap '{name}' from namespace {}: {err}", + response.namespace + )) + })?; + configmaps_map.insert(name, cleaned_yaml); + } + for (name, yaml) in response.secrets { + let parsed: Secret = serde_yaml::from_str(&yaml).map_err(|err| { + ApiError::InvalidRequest(format!( + "Failed to parse Secret '{name}' from namespace {}: {err}", + response.namespace + )) + })?; + let cleaned = clean_secret(parsed); + let cleaned_yaml = serde_yaml::to_string(&cleaned).map_err(|err| { + ApiError::InvalidRequest(format!( + "Failed to serialize Secret '{name}' from namespace {}: {err}", + response.namespace + )) + })?; + secrets_map.insert(name, cleaned_yaml); + } + } + } + } + tracing::info!( - "Built catalog workload resources from DB: {} workloads, {} matching services (cluster: {})", + "Built catalog workload resources from DB: {} workloads, {} services, {} configmaps, {} secrets (cluster: {})", workload_yamls.len(), services_map.len(), + configmaps_map.len(), + secrets_map.len(), cluster_id, ); Ok(KubeWorkloadsWithResources { workloads: workload_yamls, services: services_map, - // ConfigMaps and Secrets aren't cached in DB yet, but the workload YAML - // should already reference them, so they'll be deployed as part of the workload - configmaps: HashMap::new(), - secrets: HashMap::new(), + configmaps: configmaps_map, + secrets: secrets_map, }) } diff --git a/crates/api/src/kube_controller/mod.rs b/crates/api/src/kube_controller/mod.rs index 45bccf0..dfd953e 100644 --- a/crates/api/src/kube_controller/mod.rs +++ b/crates/api/src/kube_controller/mod.rs @@ -12,10 +12,10 @@ mod cluster; mod deployment; mod environment; mod preview_url; +mod resources; mod service; pub mod validation; mod workload; -mod workload_yaml_cleaner; pub mod yaml_parser; // Re-exports diff --git a/crates/api/src/kube_controller/resource_cleaner.rs b/crates/api/src/kube_controller/resource_cleaner.rs new file mode 100644 index 0000000..122a8fa --- /dev/null +++ b/crates/api/src/kube_controller/resource_cleaner.rs @@ -0,0 +1,30 @@ +use k8s_openapi::api::core::v1::{ConfigMap, Secret}; + +pub fn clean_configmap(configmap: ConfigMap) -> ConfigMap { + ConfigMap { + metadata: clean_metadata(configmap.metadata), + data: configmap.data, + binary_data: configmap.binary_data, + immutable: configmap.immutable, + } +} + +pub fn clean_secret(secret: Secret) -> Secret { + Secret { + metadata: clean_metadata(secret.metadata), + data: secret.data, + string_data: secret.string_data, + type_: secret.type_, + immutable: secret.immutable, + } +} + +fn clean_metadata( + metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta, +) -> k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta { + k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta { + name: metadata.name, + labels: metadata.labels, + ..Default::default() + } +} diff --git a/crates/api/src/kube_controller/resources.rs b/crates/api/src/kube_controller/resources.rs new file mode 100644 index 0000000..a5d31f6 --- /dev/null +++ b/crates/api/src/kube_controller/resources.rs @@ -0,0 +1,445 @@ +use std::collections::HashMap; + +use anyhow::Result; +use k8s_openapi::{ + api::{ + apps::v1::{ + DaemonSet, DaemonSetSpec, Deployment, DeploymentSpec, ReplicaSet, ReplicaSetSpec, + StatefulSet, StatefulSetSpec, + }, + batch::v1::{CronJob, CronJobSpec, Job, JobSpec}, + core::v1::{ + ConfigMap, Container, ContainerPort, EnvVar, EnvVarSource, Pod, PodSpec, + PodTemplateSpec, Secret, + }, + }, + apimachinery::pkg::{api::resource::Quantity, apis::meta::v1::ObjectMeta}, +}; +use lapdev_common::kube::{KubeContainerImage, KubeContainerInfo, KubeWorkloadKind}; + +/// Rebuild workload YAML so it reflects the latest container configuration while +/// stripping server-managed metadata. Mirrors kube-manager's clean_* helpers. +pub fn rebuild_workload_yaml( + kind: &KubeWorkloadKind, + yaml: &str, + containers: &[KubeContainerInfo], +) -> Result { + match kind { + KubeWorkloadKind::Deployment => { + let deployment: Deployment = serde_yaml::from_str(yaml)?; + let cleaned = clean_deployment(deployment, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::StatefulSet => { + let statefulset: StatefulSet = serde_yaml::from_str(yaml)?; + let cleaned = clean_statefulset(statefulset, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::DaemonSet => { + let daemonset: DaemonSet = serde_yaml::from_str(yaml)?; + let cleaned = clean_daemonset(daemonset, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::ReplicaSet => { + let replicaset: ReplicaSet = serde_yaml::from_str(yaml)?; + let cleaned = clean_replicaset(replicaset, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::Pod => { + let pod: Pod = serde_yaml::from_str(yaml)?; + let cleaned = clean_pod(pod, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::Job => { + let job: Job = serde_yaml::from_str(yaml)?; + let cleaned = clean_job(job, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + KubeWorkloadKind::CronJob => { + let cronjob: CronJob = serde_yaml::from_str(yaml)?; + let cleaned = clean_cronjob(cronjob, containers); + Ok(serde_yaml::to_string(&cleaned)?) + } + } +} + +#[allow(dead_code)] +pub fn clean_configmap(configmap: ConfigMap) -> ConfigMap { + ConfigMap { + metadata: clean_metadata(configmap.metadata), + data: configmap.data, + binary_data: configmap.binary_data, + immutable: configmap.immutable, + } +} + +#[allow(dead_code)] +pub fn clean_secret(secret: Secret) -> Secret { + Secret { + metadata: clean_metadata(secret.metadata), + data: secret.data, + string_data: secret.string_data, + type_: secret.type_, + immutable: secret.immutable, + } +} + +fn clean_metadata(metadata: ObjectMeta) -> ObjectMeta { + ObjectMeta { + name: metadata.name, + labels: metadata.labels, + ..Default::default() + } +} + +fn clean_deployment( + deployment: Deployment, + workload_containers: &[KubeContainerInfo], +) -> Deployment { + let clean_spec = deployment.spec.map(|original_spec| { + let template = merge_template_containers(original_spec.template, workload_containers); + + DeploymentSpec { + replicas: original_spec.replicas, + selector: original_spec.selector, + template, + min_ready_seconds: original_spec.min_ready_seconds, + paused: original_spec.paused, + progress_deadline_seconds: original_spec.progress_deadline_seconds, + revision_history_limit: original_spec.revision_history_limit, + strategy: original_spec.strategy, + } + }); + + Deployment { + metadata: clean_metadata(deployment.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_statefulset( + statefulset: StatefulSet, + workload_containers: &[KubeContainerInfo], +) -> StatefulSet { + let clean_spec = statefulset.spec.map(|original_spec| { + let template = merge_template_containers(original_spec.template, workload_containers); + + StatefulSetSpec { + service_name: original_spec.service_name, + replicas: original_spec.replicas, + selector: original_spec.selector, + template, + volume_claim_templates: original_spec.volume_claim_templates, + update_strategy: original_spec.update_strategy, + min_ready_seconds: original_spec.min_ready_seconds, + persistent_volume_claim_retention_policy: original_spec + .persistent_volume_claim_retention_policy, + ordinals: original_spec.ordinals, + revision_history_limit: original_spec.revision_history_limit, + pod_management_policy: original_spec.pod_management_policy, + } + }); + + StatefulSet { + metadata: clean_metadata(statefulset.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_daemonset(daemonset: DaemonSet, workload_containers: &[KubeContainerInfo]) -> DaemonSet { + let clean_spec = daemonset.spec.map(|original_spec| { + let template = merge_template_containers(original_spec.template, workload_containers); + + DaemonSetSpec { + selector: original_spec.selector, + template, + update_strategy: original_spec.update_strategy, + min_ready_seconds: original_spec.min_ready_seconds, + revision_history_limit: original_spec.revision_history_limit, + } + }); + + DaemonSet { + metadata: clean_metadata(daemonset.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_replicaset( + replicaset: ReplicaSet, + workload_containers: &[KubeContainerInfo], +) -> ReplicaSet { + let clean_spec = replicaset.spec.map(|original_spec| { + let template = original_spec + .template + .map(|t| merge_template_containers(t, workload_containers)); + + ReplicaSetSpec { + replicas: original_spec.replicas, + selector: original_spec.selector, + template, + min_ready_seconds: original_spec.min_ready_seconds, + } + }); + + ReplicaSet { + metadata: clean_metadata(replicaset.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_pod(pod: Pod, workload_containers: &[KubeContainerInfo]) -> Pod { + let clean_spec = pod.spec.map(|original_spec| { + let merged_containers = merge_containers(original_spec.containers, workload_containers); + + PodSpec { + active_deadline_seconds: original_spec.active_deadline_seconds, + containers: merged_containers, + init_containers: original_spec.init_containers, + ephemeral_containers: original_spec.ephemeral_containers, + volumes: original_spec.volumes, + restart_policy: original_spec.restart_policy, + termination_grace_period_seconds: original_spec.termination_grace_period_seconds, + dns_policy: original_spec.dns_policy, + dns_config: original_spec.dns_config, + node_selector: original_spec.node_selector, + service_account_name: original_spec.service_account_name, + service_account: original_spec.service_account, + automount_service_account_token: original_spec.automount_service_account_token, + security_context: original_spec.security_context, + image_pull_secrets: original_spec.image_pull_secrets, + affinity: original_spec.affinity, + tolerations: original_spec.tolerations, + topology_spread_constraints: original_spec.topology_spread_constraints, + priority_class_name: original_spec.priority_class_name, + priority: original_spec.priority, + preemption_policy: original_spec.preemption_policy, + overhead: original_spec.overhead, + enable_service_links: original_spec.enable_service_links, + os: original_spec.os, + host_users: original_spec.host_users, + scheduling_gates: original_spec.scheduling_gates, + resource_claims: original_spec.resource_claims, + ..Default::default() + } + }); + + Pod { + metadata: clean_metadata(pod.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_job(job: Job, workload_containers: &[KubeContainerInfo]) -> Job { + let clean_spec = job.spec.map(|original_spec| { + let template = merge_template_containers(original_spec.template, workload_containers); + + JobSpec { + template, + parallelism: original_spec.parallelism, + completions: original_spec.completions, + completion_mode: original_spec.completion_mode, + active_deadline_seconds: original_spec.active_deadline_seconds, + backoff_limit: original_spec.backoff_limit, + backoff_limit_per_index: original_spec.backoff_limit_per_index, + max_failed_indexes: original_spec.max_failed_indexes, + selector: original_spec.selector, + manual_selector: original_spec.manual_selector, + ttl_seconds_after_finished: original_spec.ttl_seconds_after_finished, + suspend: original_spec.suspend, + pod_failure_policy: original_spec.pod_failure_policy, + pod_replacement_policy: original_spec.pod_replacement_policy, + managed_by: original_spec.managed_by, + success_policy: original_spec.success_policy, + } + }); + + Job { + metadata: clean_metadata(job.metadata), + spec: clean_spec, + status: None, + } +} + +fn clean_cronjob(cronjob: CronJob, workload_containers: &[KubeContainerInfo]) -> CronJob { + let clean_spec = cronjob.spec.map(|original_spec| { + let mut job_template = original_spec.job_template; + if let Some(job_spec) = &mut job_template.spec { + job_spec.template = + merge_template_containers(job_spec.template.clone(), workload_containers); + } + + CronJobSpec { + schedule: original_spec.schedule, + time_zone: original_spec.time_zone, + starting_deadline_seconds: original_spec.starting_deadline_seconds, + concurrency_policy: original_spec.concurrency_policy, + suspend: original_spec.suspend, + job_template, + successful_jobs_history_limit: original_spec.successful_jobs_history_limit, + failed_jobs_history_limit: original_spec.failed_jobs_history_limit, + } + }); + + CronJob { + metadata: clean_metadata(cronjob.metadata), + spec: clean_spec, + status: None, + } +} + +fn merge_template_containers( + template: PodTemplateSpec, + workload_containers: &[KubeContainerInfo], +) -> PodTemplateSpec { + let pod_spec = template.spec.map(|original_pod_spec| { + let merged_containers = original_pod_spec + .containers + .into_iter() + .map(|container| { + if let Some(workload_container) = workload_containers + .iter() + .find(|wc| wc.name == container.name) + { + merge_single_container(container, workload_container) + } else { + container + } + }) + .collect(); + + PodSpec { + containers: merged_containers, + ..original_pod_spec + } + }); + + PodTemplateSpec { + spec: pod_spec, + ..template + } +} + +fn merge_containers( + containers: Vec, + workload_containers: &[KubeContainerInfo], +) -> Vec { + containers + .into_iter() + .map(|container| { + if let Some(workload_container) = workload_containers + .iter() + .find(|wc| wc.name == container.name) + { + merge_single_container(container, workload_container) + } else { + container + } + }) + .collect() +} + +fn merge_single_container( + container: Container, + workload_container: &KubeContainerInfo, +) -> Container { + let mut new_container = container.clone(); + + match &workload_container.image { + KubeContainerImage::FollowOriginal => {} + KubeContainerImage::Custom(custom_image) => { + if !custom_image.is_empty() { + new_container.image = Some(custom_image.clone()); + } + } + } + + let mut resources = container.resources.unwrap_or_default(); + let mut requests = resources.requests.unwrap_or_default(); + let mut limits = resources.limits.unwrap_or_default(); + + if let Some(cpu_request) = &workload_container.cpu_request { + if !cpu_request.is_empty() { + requests.insert("cpu".to_string(), Quantity(cpu_request.clone())); + } + } + if let Some(memory_request) = &workload_container.memory_request { + if !memory_request.is_empty() { + requests.insert("memory".to_string(), Quantity(memory_request.clone())); + } + } + + if let Some(cpu_limit) = &workload_container.cpu_limit { + if !cpu_limit.is_empty() { + limits.insert("cpu".to_string(), Quantity(cpu_limit.clone())); + } + } + if let Some(memory_limit) = &workload_container.memory_limit { + if !memory_limit.is_empty() { + limits.insert("memory".to_string(), Quantity(memory_limit.clone())); + } + } + + resources.requests = if requests.is_empty() { + None + } else { + Some(requests) + }; + resources.limits = if limits.is_empty() { + None + } else { + Some(limits) + }; + new_container.resources = Some(resources); + + let mut env_map: HashMap, Option)> = HashMap::new(); + + if let Some(original_env) = container.env { + for env_var in original_env { + env_map.insert(env_var.name.clone(), (env_var.value, env_var.value_from)); + } + } + + for kube_env_var in &workload_container.env_vars { + env_map.insert( + kube_env_var.name.clone(), + (Some(kube_env_var.value.clone()), None), + ); + } + + let merged_env: Vec = env_map + .into_iter() + .map(|(name, (value, value_from))| EnvVar { + name, + value, + value_from, + }) + .collect(); + + new_container.env = if merged_env.is_empty() { + None + } else { + Some(merged_env) + }; + + if !workload_container.ports.is_empty() { + let ports: Vec = workload_container + .ports + .iter() + .map(|port| ContainerPort { + name: port.name.clone(), + container_port: port.container_port, + protocol: port.protocol.clone(), + ..Default::default() + }) + .collect(); + new_container.ports = Some(ports); + } + + new_container +} diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index 0af5260..fc9a7ce 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -26,7 +26,8 @@ use lapdev_common::kube::{ }; use lapdev_kube_rpc::{ KubeClusterRpcClient, KubeManagerRpc, KubeWorkloadWithServices, KubeWorkloadYaml, - KubeWorkloadYamlOnly, KubeWorkloadsWithResources, TunnelStatus, + KubeWorkloadYamlOnly, KubeWorkloadsWithResources, NamespacedResourceRequest, + NamespacedResourceResponse, TunnelStatus, }; use lapdev_rpc::spawn_twoway; use serde::de::DeserializeOwned; @@ -2504,6 +2505,58 @@ impl KubeManager { Ok((services_yaml_map, configmaps_yaml_map, secrets_yaml_map)) } + pub(crate) async fn fetch_namespaced_resources( + &self, + requests: Vec, + ) -> Result> { + let client = &self.kube_client; + let mut results = Vec::with_capacity(requests.len()); + + for request in requests { + let namespace = request.namespace.clone(); + let configmaps_api: kube::Api = + kube::Api::namespaced((**client).clone(), &namespace); + let secrets_api: kube::Api = + kube::Api::namespaced((**client).clone(), &namespace); + + let mut configmap_yamls = HashMap::new(); + for name in request.configmaps { + match configmaps_api.get(&name).await { + Ok(configmap) => { + if let Ok(yaml) = serde_yaml::to_string(&configmap) { + configmap_yamls.insert(name.clone(), yaml); + } + } + Err(err) => { + tracing::warn!("Could not fetch ConfigMap {}/{}: {}", namespace, name, err); + } + } + } + + let mut secret_yamls = HashMap::new(); + for name in request.secrets { + match secrets_api.get(&name).await { + Ok(secret) => { + if let Ok(yaml) = serde_yaml::to_string(&secret) { + secret_yamls.insert(name.clone(), yaml); + } + } + Err(err) => { + tracing::warn!("Could not fetch Secret {}/{}: {}", namespace, name, err); + } + } + } + + results.push(NamespacedResourceResponse { + namespace, + configmaps: configmap_yamls, + secrets: secret_yamls, + }); + } + + Ok(results) + } + pub(crate) async fn apply_workloads_with_resources( &self, environment_id: Option, diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index 3c4713a..a016027 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -5,7 +5,7 @@ use lapdev_common::kube::{ }; use lapdev_kube_rpc::{ KubeClusterRpcClient, KubeManagerRpc, KubeRawWorkloadYaml, KubeWorkloadsWithResources, - TunnelStatus, WorkloadIdentifier, + NamespacedResourceRequest, NamespacedResourceResponse, TunnelStatus, WorkloadIdentifier, }; use uuid::Uuid; @@ -311,6 +311,20 @@ impl KubeManagerRpc for KubeManagerRpcServer { Ok(result) } + async fn get_namespaced_resources( + self, + _context: ::tarpc::context::Context, + requests: Vec, + ) -> Result, String> { + match self.manager.fetch_namespaced_resources(requests).await { + Ok(resources) => Ok(resources), + Err(e) => { + tracing::error!("Failed to fetch namespaced resources: {e}"); + Err(format!("Failed to fetch namespaced resources: {e}")) + } + } + } + async fn update_workload_containers( self, _context: ::tarpc::context::Context, diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index c0de845..1b9a83c 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -341,6 +341,20 @@ pub struct KubeWorkloadsWithResources { pub secrets: HashMap, // name -> YAML content } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespacedResourceRequest { + pub namespace: String, + pub configmaps: Vec, + pub secrets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespacedResourceResponse { + pub namespace: String, + pub configmaps: HashMap, + pub secrets: HashMap, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KubeRawWorkloadYaml { pub name: String, @@ -406,6 +420,10 @@ pub trait KubeManagerRpc { workloads: Vec, ) -> Result, String>; + async fn get_namespaced_resources( + requests: Vec, + ) -> Result, String>; + async fn configure_watches(namespaces: Vec) -> Result<(), String>; async fn update_workload_containers( From a612702ed530d9d5ad6e78d767ca31dad1e73d7d Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 19:03:43 +0000 Subject: [PATCH 149/334] update --- crates/kube-manager/src/manager_rpc.rs | 158 +------------------------ crates/kube-rpc/src/lib.rs | 18 +-- 2 files changed, 3 insertions(+), 173 deletions(-) diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index a016027..c7bbdbc 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -1,7 +1,6 @@ use anyhow::Result; use lapdev_common::kube::{ - KubeAppCatalogWorkload, KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, - PaginationParams, + KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, PaginationParams, }; use lapdev_kube_rpc::{ KubeClusterRpcClient, KubeManagerRpc, KubeRawWorkloadYaml, KubeWorkloadsWithResources, @@ -117,63 +116,6 @@ impl KubeManagerRpc for KubeManagerRpcServer { } } - async fn get_workloads_yaml( - self, - _context: ::tarpc::context::Context, - workloads: Vec, - ) -> Result { - match self - .manager - .retrieve_workloads_yaml(workloads.clone()) - .await - { - Ok(workloads_with_resources) => { - tracing::info!( - "Successfully retrieved {} workloads with {} services, {} configmaps, {} secrets", - workloads_with_resources.workloads.len(), - workloads_with_resources.services.len(), - workloads_with_resources.configmaps.len(), - workloads_with_resources.secrets.len() - ); - Ok(workloads_with_resources) - } - Err(e) => { - tracing::error!("Failed to retrieve workloads YAML: {}", e); - Err(format!("Failed to retrieve workloads YAML: {}", e)) - } - } - } - - async fn get_workload_yaml( - self, - _context: ::tarpc::context::Context, - workload: KubeAppCatalogWorkload, - ) -> Result { - match self - .manager - .retrieve_single_workload_yaml(workload.clone()) - .await - { - Ok(workload_yaml) => { - tracing::info!( - "Successfully retrieved workload YAML for {}/{}", - workload.namespace, - workload.name - ); - Ok(workload_yaml) - } - Err(e) => { - tracing::error!( - "Failed to retrieve workload YAML for {}/{}: {}", - workload.namespace, - workload.name, - e - ); - Err(format!("Failed to retrieve workload YAML: {}", e)) - } - } - } - async fn deploy_workload_yaml( self, _context: ::tarpc::context::Context, @@ -205,77 +147,6 @@ impl KubeManagerRpc for KubeManagerRpcServer { } } - async fn get_workloads_details( - self, - _context: ::tarpc::context::Context, - workloads: Vec, - ) -> Result, String> { - let mut details = Vec::new(); - - // Fetch all services once before the loop - let all_services = if !workloads.is_empty() { - let first_namespace = &workloads[0].namespace; - let service_api: kube::Api = - kube::Api::namespaced((*self.manager.kube_client).clone(), first_namespace); - - match service_api.list(&Default::default()).await { - Ok(service_list) => service_list.items, - Err(e) => { - tracing::warn!( - "Failed to list services in namespace {}: {}", - first_namespace, - e - ); - Vec::new() - } - } - } else { - Vec::new() - }; - - for workload in workloads { - match self - .manager - .get_workload_resource_details( - &workload.name, - &workload.namespace, - &workload.kind, - &all_services, - ) - .await - { - Ok((containers, ports, workload_yaml)) => { - details.push(lapdev_common::kube::KubeWorkloadDetails { - name: workload.name, - namespace: workload.namespace, - kind: workload.kind, - containers, - ports, - workload_yaml, - }); - } - Err(e) => { - tracing::error!( - "Failed to get workload details for {}/{}: {}", - workload.namespace, - workload.name, - e - ); - return Err(format!( - "Failed to get workload details for {}/{}: {}", - workload.namespace, workload.name, e - )); - } - } - } - - tracing::info!( - "Successfully retrieved details for {} workloads", - details.len() - ); - Ok(details) - } - async fn get_workloads_raw_yaml( self, _context: ::tarpc::context::Context, @@ -429,33 +300,6 @@ impl KubeManagerRpc for KubeManagerRpcServer { } } - async fn configure_watches( - self, - _context: ::tarpc::context::Context, - namespaces: Vec, - ) -> Result<(), String> { - tracing::info!( - "Configuring namespace watches for {} namespaces", - namespaces.len() - ); - - match self - .manager - .watch_manager - .configure_watches(namespaces) - .await - { - Ok(_) => { - tracing::info!("Successfully configured namespace watches"); - Ok(()) - } - Err(e) => { - tracing::error!("Failed to configure namespace watches: {e}"); - Err(format!("Failed to configure namespace watches: {e}")) - } - } - } - async fn destroy_environment( self, _context: ::tarpc::context::Context, diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index 1b9a83c..d5a93b7 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use lapdev_common::kube::{ - KubeAppCatalogWorkload, KubeClusterInfo, KubeNamespaceInfo, KubeServiceWithYaml, - KubeWorkloadDetails, KubeWorkloadKind, KubeWorkloadList, PaginationParams, + KubeClusterInfo, KubeNamespaceInfo, KubeServiceWithYaml, KubeWorkloadKind, KubeWorkloadList, + PaginationParams, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -396,14 +396,6 @@ pub trait KubeManagerRpc { async fn get_namespaces() -> Result, String>; - async fn get_workloads_yaml( - workloads: Vec, - ) -> Result; - - async fn get_workload_yaml( - workload: KubeAppCatalogWorkload, - ) -> Result; - async fn deploy_workload_yaml( environment_id: uuid::Uuid, environment_auth_token: String, @@ -412,10 +404,6 @@ pub trait KubeManagerRpc { labels: std::collections::HashMap, ) -> Result<(), String>; - async fn get_workloads_details( - workloads: Vec, - ) -> Result, String>; - async fn get_workloads_raw_yaml( workloads: Vec, ) -> Result, String>; @@ -424,8 +412,6 @@ pub trait KubeManagerRpc { requests: Vec, ) -> Result, String>; - async fn configure_watches(namespaces: Vec) -> Result<(), String>; - async fn update_workload_containers( environment_id: Uuid, environment_auth_token: String, From 12769ae8eed4132c811c8a1924bdd0feb0352887 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 19:16:31 +0000 Subject: [PATCH 150/334] update --- crates/api/src/kube_controller/resources.rs | 105 +++++++ crates/api/src/kube_controller/workload.rs | 183 ++++++++---- crates/kube-manager/src/manager.rs | 293 +------------------- crates/kube-manager/src/manager_rpc.rs | 73 ++--- crates/kube-rpc/src/lib.rs | 13 +- 5 files changed, 270 insertions(+), 397 deletions(-) diff --git a/crates/api/src/kube_controller/resources.rs b/crates/api/src/kube_controller/resources.rs index a5d31f6..69f548e 100644 --- a/crates/api/src/kube_controller/resources.rs +++ b/crates/api/src/kube_controller/resources.rs @@ -16,6 +16,7 @@ use k8s_openapi::{ apimachinery::pkg::{api::resource::Quantity, apis::meta::v1::ObjectMeta}, }; use lapdev_common::kube::{KubeContainerImage, KubeContainerInfo, KubeWorkloadKind}; +use lapdev_kube_rpc::KubeWorkloadYamlOnly; /// Rebuild workload YAML so it reflects the latest container configuration while /// stripping server-managed metadata. Mirrors kube-manager's clean_* helpers. @@ -443,3 +444,107 @@ fn merge_single_container( new_container } + +pub fn rename_workload_yaml(workload: &mut KubeWorkloadYamlOnly, new_name: &str) -> Result<()> { + match workload { + KubeWorkloadYamlOnly::Deployment(yaml) => *yaml = rename_deployment_yaml(yaml, new_name)?, + KubeWorkloadYamlOnly::StatefulSet(yaml) => *yaml = rename_statefulset_yaml(yaml, new_name)?, + KubeWorkloadYamlOnly::DaemonSet(yaml) => *yaml = rename_daemonset_yaml(yaml, new_name)?, + KubeWorkloadYamlOnly::ReplicaSet(yaml) => *yaml = rename_replicaset_yaml(yaml, new_name)?, + _ => {} + } + Ok(()) +} + +pub fn rename_service_yaml( + yaml: &str, + new_service_name: &str, + new_workload_name: &str, +) -> Result { + use k8s_openapi::api::core::v1::Service; + + let mut service: Service = serde_yaml::from_str(yaml)?; + service.metadata.name = Some(new_service_name.to_string()); + + if let Some(ref mut spec) = service.spec { + if let Some(ref mut selector) = spec.selector { + selector.insert("app".to_string(), new_workload_name.to_string()); + } + } + + Ok(serde_yaml::to_string(&service)?) +} + +fn rename_deployment_yaml(yaml: &str, new_name: &str) -> Result { + let mut deployment: Deployment = serde_yaml::from_str(yaml)?; + deployment.metadata.name = Some(new_name.to_string()); + + if let Some(ref mut spec) = deployment.spec { + if let Some(ref mut selector) = spec.selector.match_labels { + selector.insert("app".to_string(), new_name.to_string()); + } + if let Some(ref mut metadata) = &mut spec.template.metadata { + if let Some(ref mut labels) = &mut metadata.labels { + labels.insert("app".to_string(), new_name.to_string()); + } + } + } + + Ok(serde_yaml::to_string(&deployment)?) +} + +fn rename_statefulset_yaml(yaml: &str, new_name: &str) -> Result { + let mut statefulset: StatefulSet = serde_yaml::from_str(yaml)?; + statefulset.metadata.name = Some(new_name.to_string()); + + if let Some(ref mut spec) = statefulset.spec { + if let Some(ref mut selector) = spec.selector.match_labels { + selector.insert("app".to_string(), new_name.to_string()); + } + if let Some(ref mut metadata) = &mut spec.template.metadata { + if let Some(ref mut labels) = &mut metadata.labels { + labels.insert("app".to_string(), new_name.to_string()); + } + } + } + + Ok(serde_yaml::to_string(&statefulset)?) +} + +fn rename_daemonset_yaml(yaml: &str, new_name: &str) -> Result { + let mut daemonset: DaemonSet = serde_yaml::from_str(yaml)?; + daemonset.metadata.name = Some(new_name.to_string()); + + if let Some(ref mut spec) = daemonset.spec { + if let Some(ref mut selector) = spec.selector.match_labels { + selector.insert("app".to_string(), new_name.to_string()); + } + if let Some(ref mut metadata) = &mut spec.template.metadata { + if let Some(ref mut labels) = &mut metadata.labels { + labels.insert("app".to_string(), new_name.to_string()); + } + } + } + + Ok(serde_yaml::to_string(&daemonset)?) +} + +fn rename_replicaset_yaml(yaml: &str, new_name: &str) -> Result { + let mut replicaset: ReplicaSet = serde_yaml::from_str(yaml)?; + replicaset.metadata.name = Some(new_name.to_string()); + + if let Some(ref mut spec) = replicaset.spec { + if let Some(ref mut selector) = spec.selector.match_labels { + selector.insert("app".to_string(), new_name.to_string()); + } + if let Some(ref mut template) = &mut spec.template { + if let Some(ref mut metadata) = &mut template.metadata { + if let Some(ref mut labels) = &mut metadata.labels { + labels.insert("app".to_string(), new_name.to_string()); + } + } + } + } + + Ok(serde_yaml::to_string(&replicaset)?) +} diff --git a/crates/api/src/kube_controller/workload.rs b/crates/api/src/kube_controller/workload.rs index 3ca6a07..48df6c5 100644 --- a/crates/api/src/kube_controller/workload.rs +++ b/crates/api/src/kube_controller/workload.rs @@ -1,8 +1,14 @@ use uuid::Uuid; +use std::collections::HashMap; + +use lapdev_common::kube::{KubeAppCatalogWorkload, KubeServiceWithYaml}; use lapdev_rpc::error::ApiError; -use super::KubeController; +use super::{ + resources::{rename_service_yaml, rename_workload_yaml}, + KubeController, +}; impl KubeController { pub async fn get_environment_workloads( @@ -151,54 +157,15 @@ impl KubeController { environment_labels.insert("lapdev.environment".to_string(), environment.name.clone()); environment_labels.insert("lapdev.managed-by".to_string(), "lapdev".to_string()); - // Check if this is a branch environment - if so, create a new deployment if environment.base_environment_id.is_some() { - tracing::info!( - "Creating branch deployment for workload '{}' in branch environment '{}' (namespace '{}')", - updated_workload.name, - environment.name, - environment.namespace - ); - - // For branch environments, we need to get the base workload name and create a branch deployment - // The base workload name is the original workload name without branch suffix - let base_workload_name = updated_workload.name.clone(); - let branch_environment_id = environment.id; - - match cluster_server - .rpc_client - .create_branch_workload( - tarpc::context::current(), - updated_workload.id, - base_workload_name.clone(), - branch_environment_id, - environment.auth_token.clone(), - updated_workload.namespace.clone(), - workload_kind, - updated_workload.containers, - environment_labels, - ) - .await - { - Ok(Ok(())) => { - tracing::info!( - "Successfully created branch deployment for workload '{}' in branch environment '{}' (namespace '{}')", - updated_workload.name, - environment.name, - environment.namespace - ); - Ok(()) - } - Ok(Err(e)) => { - tracing::error!("Failed to create branch deployment: {}", e); - Err(ApiError::InvalidRequest(format!( - "Failed to create branch deployment: {e}" - ))) - } - Err(e) => Err(ApiError::InvalidRequest(format!( - "Connection error during branch deployment creation: {e}" - ))), - } + self.deploy_branch_workload( + &cluster_server, + &environment, + &updated_workload.name, + updated_workload.containers, + environment_labels, + ) + .await } else { // For regular environments, update the existing workload containers tracing::info!( @@ -244,4 +211,124 @@ impl KubeController { } } } + + async fn deploy_branch_workload( + &self, + cluster_server: &lapdev_kube::server::KubeClusterServer, + environment: &lapdev_db_entities::kube_environment::Model, + base_workload_name: &str, + containers: Vec, + mut environment_labels: HashMap, + ) -> Result<(), ApiError> { + tracing::info!( + "Creating branch deployment for workload '{}' in branch environment '{}' (namespace '{}')", + base_workload_name, + environment.name, + environment.namespace + ); + + let app_workloads = self + .db + .get_app_catalog_workloads(environment.app_catalog_id) + .await + .map_err(ApiError::from)?; + + let base_catalog_workload = app_workloads + .into_iter() + .find(|w| w.name == base_workload_name) + .ok_or_else(|| { + ApiError::InvalidRequest(format!( + "Base workload '{}' not found in app catalog", + base_workload_name + )) + })?; + + let branch_workload = KubeAppCatalogWorkload { + containers, + ..base_catalog_workload + }; + + let mut workloads_with_resources = self + .get_catalog_workloads_with_yaml_from_db( + environment.cluster_id, + vec![branch_workload.clone()], + ) + .await?; + + let branch_deployment_name = format!("{}-{}", base_workload_name, environment.id); + for workload_yaml in &mut workloads_with_resources.workloads { + rename_workload_yaml(workload_yaml, &branch_deployment_name) + .map_err(|e| ApiError::InvalidRequest(e.to_string()))?; + } + + let mut updated_services = HashMap::new(); + for (service_name, service_with_yaml) in workloads_with_resources.services.into_iter() { + let branch_service_name = format!("{}-{}", service_name, environment.id); + let updated_yaml = rename_service_yaml( + &service_with_yaml.yaml, + &branch_service_name, + &branch_deployment_name, + ) + .map_err(|e| ApiError::InvalidRequest(e.to_string()))?; + + let mut details = service_with_yaml.details.clone(); + details.name = branch_service_name.clone(); + + updated_services.insert( + branch_service_name, + KubeServiceWithYaml { + yaml: updated_yaml, + details, + }, + ); + } + workloads_with_resources.services = updated_services; + workloads_with_resources.configmaps.clear(); + workloads_with_resources.secrets.clear(); + + environment_labels.insert( + "lapdev.branch-environment".to_string(), + environment.id.to_string(), + ); + environment_labels.insert( + "lapdev.base-workload".to_string(), + base_workload_name.to_string(), + ); + environment_labels.insert( + "lapdev.io/branch-environment-id".to_string(), + environment.id.to_string(), + ); + environment_labels.insert( + "lapdev.io/routing-key".to_string(), + format!("branch-{}", environment.id), + ); + environment_labels.insert( + "lapdev.io/proxy-target-port".to_string(), + "8080".to_string(), + ); + + self.deploy_environment_resources( + cluster_server, + &environment.namespace, + &environment.name, + environment.id, + Some(environment.auth_token.clone()), + workloads_with_resources, + ) + .await?; + + if let Err(err) = cluster_server + .rpc_client + .refresh_branch_service_routes(tarpc::context::current(), environment.id) + .await + { + tracing::warn!( + "Failed to refresh branch service routes for environment {}: {}", + environment.id, + err + ); + } + + Ok(()) + } } diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index fc9a7ce..4aceaca 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -3783,293 +3783,6 @@ impl KubeManager { Ok(()) } - /// Creates a new deployment for a branch environment by: - /// 1. Retrieving the base workload YAML - /// 2. Creating a new deployment with a unique name based on branch environment - /// 3. Applying the container customizations - /// 4. Deploying the new branch deployment - pub(crate) async fn create_branch_workload_atomic( - &self, - base_workload_id: uuid::Uuid, - base_workload_name: String, - branch_environment_id: uuid::Uuid, - branch_environment_auth_token: String, - namespace: String, - kind: lapdev_common::kube::KubeWorkloadKind, - containers: Vec, - labels: std::collections::HashMap, - ) -> Result<()> { - tracing::info!( - "Starting atomic branch deployment creation for environment '{}' based on {}/{} (id: {}) with {} containers", - branch_environment_id, namespace, base_workload_name, base_workload_id, containers.len() - ); - - // Step 1: Create KubeAppCatalogWorkload for the base workload to get its YAML - let base_catalog_workload = lapdev_common::kube::KubeAppCatalogWorkload { - id: base_workload_id, - name: base_workload_name.clone(), - namespace: namespace.clone(), - kind, - containers: containers.clone(), // Use the customized containers for the branch - ports: Vec::new(), // Ports will be fetched from services during YAML retrieval - workload_yaml: None, - catalog_sync_version: 0, - }; - - // Step 2: Get the base workload YAML with all its resources - let mut workloads_with_resources = self - .retrieve_workloads_yaml(vec![base_catalog_workload]) - .await - .map_err(|e| { - tracing::error!( - "Failed to retrieve base workload YAML for {}/{}: {}", - namespace, - base_workload_name, - e - ); - anyhow::anyhow!("Failed to retrieve base workload YAML: {}", e) - })?; - - // Step 3: Modify the workload names to create branch-specific deployments - let branch_deployment_name = format!("{base_workload_name}-{branch_environment_id}",); - - // Update the workload YAML to use the branch deployment name - for workload_yaml in &mut workloads_with_resources.workloads { - match workload_yaml { - lapdev_kube_rpc::KubeWorkloadYamlOnly::Deployment(yaml) => { - *yaml = self.update_deployment_name_in_yaml(yaml, &branch_deployment_name)?; - } - lapdev_kube_rpc::KubeWorkloadYamlOnly::StatefulSet(yaml) => { - *yaml = self.update_statefulset_name_in_yaml(yaml, &branch_deployment_name)?; - } - lapdev_kube_rpc::KubeWorkloadYamlOnly::DaemonSet(yaml) => { - *yaml = self.update_daemonset_name_in_yaml(yaml, &branch_deployment_name)?; - } - lapdev_kube_rpc::KubeWorkloadYamlOnly::ReplicaSet(yaml) => { - *yaml = self.update_replicaset_name_in_yaml(yaml, &branch_deployment_name)?; - } - _ => { - // For other workload types, we'll need to implement similar methods if needed - tracing::warn!( - "Branch deployment creation not fully supported for workload type: {:?}", - workload_yaml - ); - } - } - } - - // Step 4: Update service names to match the branch deployment - let mut updated_services = HashMap::new(); - for (service_name, service_with_yaml) in workloads_with_resources.services { - let branch_service_name = format!("{service_name}-{branch_environment_id}"); - let updated_yaml = self.update_service_name_in_yaml( - &service_with_yaml.yaml, - &branch_service_name, - &branch_deployment_name, - )?; - - updated_services.insert( - branch_service_name, - KubeServiceWithYaml { - yaml: updated_yaml, - details: service_with_yaml.details, - }, - ); - } - workloads_with_resources.services = updated_services; - - // Step 5: Clear ConfigMaps and Secrets - branch workloads share these with base - // Only workloads and services need to be created for branches - workloads_with_resources.configmaps.clear(); - workloads_with_resources.secrets.clear(); - - // Step 6: Add branch-specific labels to distinguish from base environment - let mut branch_labels = labels.clone(); - branch_labels.insert( - "lapdev.branch-environment".to_string(), - branch_environment_id.to_string(), - ); - branch_labels.insert( - "lapdev.base-workload".to_string(), - base_workload_name.clone(), - ); - - // Add sidecar proxy routing annotations for automatic discovery - branch_labels.insert( - "lapdev.io/branch-environment-id".to_string(), - branch_environment_id.to_string(), - ); - branch_labels.insert( - "lapdev.io/routing-key".to_string(), - format!("branch-{}", branch_environment_id), - ); - branch_labels.insert( - "lapdev.io/proxy-target-port".to_string(), - "8080".to_string(), // Default port, could be made configurable - ); - - tracing::info!( - "Retrieved and modified workload YAML for branch '{}' based on {}/{}: {} workloads, {} services (configmaps and secrets shared with base)", - branch_environment_id, namespace, base_workload_name, - workloads_with_resources.workloads.len(), - workloads_with_resources.services.len() - ); - - // Step 7: Apply the branch workload with resources atomically - self.apply_workloads_with_resources( - Some(branch_environment_id), - branch_environment_auth_token, - namespace.clone(), - workloads_with_resources, - branch_labels, - ) - .await - .map_err(|e| { - tracing::error!( - "Failed to apply branch workload for environment '{}' based on {}/{}: {}", - branch_environment_id, - namespace, - base_workload_name, - e - ); - anyhow::anyhow!("Failed to apply branch workload: {}", e) - })?; - - tracing::info!( - "Successfully created branch deployment '{}' based on {}/{} (base id: {})", - branch_environment_id, - namespace, - base_workload_name, - base_workload_id - ); - - if let Err(err) = self - .proxy_manager - .set_service_routes_if_registered(branch_environment_id) - .await - { - tracing::warn!( - "Failed to update sidecar service routes for branch environment {}: {}", - branch_environment_id, - err - ); - } - Ok(()) - } - - // Helper method to update deployment name in YAML - fn update_deployment_name_in_yaml(&self, yaml: &str, new_name: &str) -> Result { - use k8s_openapi::api::apps::v1::Deployment; - - let mut deployment: Deployment = serde_yaml::from_str(yaml)?; - deployment.metadata.name = Some(new_name.to_string()); - - // Also update the selector labels to ensure they match the new deployment - if let Some(ref mut spec) = deployment.spec { - if let Some(ref mut selector) = spec.selector.match_labels { - selector.insert("app".to_string(), new_name.to_string()); - } - if let Some(ref mut metadata) = &mut spec.template.metadata { - if let Some(ref mut labels) = &mut metadata.labels { - labels.insert("app".to_string(), new_name.to_string()); - } - } - } - - serde_yaml::to_string(&deployment) - .map_err(|e| anyhow::anyhow!("Failed to serialize deployment YAML: {}", e)) - } - - // Helper method to update statefulset name in YAML - fn update_statefulset_name_in_yaml(&self, yaml: &str, new_name: &str) -> Result { - use k8s_openapi::api::apps::v1::StatefulSet; - - let mut statefulset: StatefulSet = serde_yaml::from_str(yaml)?; - statefulset.metadata.name = Some(new_name.to_string()); - - if let Some(ref mut spec) = statefulset.spec { - if let Some(ref mut selector) = spec.selector.match_labels { - selector.insert("app".to_string(), new_name.to_string()); - } - if let Some(ref mut metadata) = &mut spec.template.metadata { - if let Some(ref mut labels) = &mut metadata.labels { - labels.insert("app".to_string(), new_name.to_string()); - } - } - } - - serde_yaml::to_string(&statefulset) - .map_err(|e| anyhow::anyhow!("Failed to serialize statefulset YAML: {}", e)) - } - - // Helper method to update daemonset name in YAML - fn update_daemonset_name_in_yaml(&self, yaml: &str, new_name: &str) -> Result { - use k8s_openapi::api::apps::v1::DaemonSet; - - let mut daemonset: DaemonSet = serde_yaml::from_str(yaml)?; - daemonset.metadata.name = Some(new_name.to_string()); - - if let Some(ref mut spec) = daemonset.spec { - if let Some(ref mut selector) = spec.selector.match_labels { - selector.insert("app".to_string(), new_name.to_string()); - } - if let Some(ref mut metadata) = &mut spec.template.metadata { - if let Some(ref mut labels) = &mut metadata.labels { - labels.insert("app".to_string(), new_name.to_string()); - } - } - } - - serde_yaml::to_string(&daemonset) - .map_err(|e| anyhow::anyhow!("Failed to serialize daemonset YAML: {}", e)) - } - - // Helper method to update replicaset name in YAML - fn update_replicaset_name_in_yaml(&self, yaml: &str, new_name: &str) -> Result { - use k8s_openapi::api::apps::v1::ReplicaSet; - - let mut replicaset: ReplicaSet = serde_yaml::from_str(yaml)?; - replicaset.metadata.name = Some(new_name.to_string()); - - if let Some(ref mut spec) = replicaset.spec { - if let Some(ref mut selector) = spec.selector.match_labels { - selector.insert("app".to_string(), new_name.to_string()); - } - if let Some(ref mut template) = &mut spec.template { - if let Some(ref mut metadata) = &mut template.metadata { - if let Some(ref mut labels) = &mut metadata.labels { - labels.insert("app".to_string(), new_name.to_string()); - } - } - } - } - - serde_yaml::to_string(&replicaset) - .map_err(|e| anyhow::anyhow!("Failed to serialize replicaset YAML: {}", e)) - } - - // Helper method to update service name in YAML and update selector to match new workload name - fn update_service_name_in_yaml( - &self, - yaml: &str, - new_service_name: &str, - new_workload_name: &str, - ) -> Result { - let mut service: Service = serde_yaml::from_str(yaml)?; - service.metadata.name = Some(new_service_name.to_string()); - - // Update the selector to match the new workload name - if let Some(ref mut spec) = service.spec { - if let Some(ref mut selector) = spec.selector { - // Update the app label in the selector to match the new workload name - selector.insert("app".to_string(), new_workload_name.to_string()); - } - } - - serde_yaml::to_string(&service) - .map_err(|e| anyhow::anyhow!("Failed to serialize service YAML: {}", e)) - } - pub async fn get_tunnel_status(&self) -> Result { self.tunnel_manager.get_tunnel_status().await } @@ -4077,6 +3790,12 @@ impl KubeManager { pub async fn close_tunnel_connection(&self, tunnel_id: String) -> Result<()> { self.tunnel_manager.close_tunnel_connection(tunnel_id).await } + + pub async fn refresh_branch_service_routes(&self, environment_id: Uuid) -> Result<()> { + self.proxy_manager + .set_service_routes_if_registered(environment_id) + .await + } } #[cfg(test)] diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index c7bbdbc..db313fe 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -196,6 +196,28 @@ impl KubeManagerRpc for KubeManagerRpcServer { } } + async fn refresh_branch_service_routes( + self, + _context: ::tarpc::context::Context, + environment_id: Uuid, + ) -> Result<(), String> { + match self + .manager + .refresh_branch_service_routes(environment_id) + .await + { + Ok(()) => Ok(()), + Err(e) => { + tracing::warn!( + "Failed to refresh branch service routes for environment {}: {}", + environment_id, + e + ); + Err(format!("Failed to refresh branch service routes: {}", e)) + } + } + } + async fn update_workload_containers( self, _context: ::tarpc::context::Context, @@ -249,57 +271,6 @@ impl KubeManagerRpc for KubeManagerRpcServer { } } - async fn create_branch_workload( - self, - _context: ::tarpc::context::Context, - base_workload_id: uuid::Uuid, - base_workload_name: String, - branch_environment_id: uuid::Uuid, - branch_environment_auth_token: String, - namespace: String, - kind: lapdev_common::kube::KubeWorkloadKind, - containers: Vec, - labels: std::collections::HashMap, - ) -> Result<(), String> { - tracing::info!( - "Creating branch deployment for environment '{}' based on '{}' in namespace '{}' (base id: {})", - branch_environment_id, base_workload_name, namespace, base_workload_id - ); - - match self - .manager - .create_branch_workload_atomic( - base_workload_id, - base_workload_name.clone(), - branch_environment_id, - branch_environment_auth_token, - namespace.clone(), - kind, - containers, - labels, - ) - .await - { - Ok(()) => { - tracing::info!( - "Successfully created branch deployment for environment '{}' based on '{}/{}' (id: {})", - branch_environment_id, namespace, base_workload_name, base_workload_id - ); - Ok(()) - } - Err(e) => { - tracing::error!( - "Failed to create branch deployment for environment '{}' based on '{}/{}': {}", - branch_environment_id, - namespace, - base_workload_name, - e - ); - Err(format!("Failed to create branch deployment: {}", e)) - } - } - } - async fn destroy_environment( self, _context: ::tarpc::context::Context, diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index d5a93b7..52bc8a8 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -412,6 +412,8 @@ pub trait KubeManagerRpc { requests: Vec, ) -> Result, String>; + async fn refresh_branch_service_routes(environment_id: Uuid) -> Result<(), String>; + async fn update_workload_containers( environment_id: Uuid, environment_auth_token: String, @@ -423,17 +425,6 @@ pub trait KubeManagerRpc { labels: std::collections::HashMap, ) -> Result<(), String>; - async fn create_branch_workload( - base_workload_id: uuid::Uuid, - base_workload_name: String, - branch_environment_id: uuid::Uuid, - branch_environment_auth_token: String, - namespace: String, - kind: KubeWorkloadKind, - containers: Vec, - labels: std::collections::HashMap, - ) -> Result<(), String>; - async fn destroy_environment(environment_id: Uuid, namespace: String) -> Result<(), String>; // Preview URL tunnel methods From c75c69a3de280d6754ce7bf7f8166c5ae0a2e66c Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 19:25:28 +0000 Subject: [PATCH 151/334] update --- crates/api/src/kube_controller/workload.rs | 132 +++++++++++++-------- crates/kube-manager/src/manager.rs | 112 ++--------------- crates/kube-manager/src/manager_rpc.rs | 53 --------- crates/kube-rpc/src/lib.rs | 11 -- 4 files changed, 93 insertions(+), 215 deletions(-) diff --git a/crates/api/src/kube_controller/workload.rs b/crates/api/src/kube_controller/workload.rs index 48df6c5..6f02ad9 100644 --- a/crates/api/src/kube_controller/workload.rs +++ b/crates/api/src/kube_controller/workload.rs @@ -147,11 +147,6 @@ impl KubeController { ApiError::InvalidRequest("No connected KubeManager for this cluster".to_string()) })?; - // Convert to proper workload kind - let workload_kind = updated_workload.kind.parse().map_err(|_| { - ApiError::InvalidRequest(format!("Invalid workload kind: {}", updated_workload.kind)) - })?; - // Prepare environment-specific labels let mut environment_labels = std::collections::HashMap::new(); environment_labels.insert("lapdev.environment".to_string(), environment.name.clone()); @@ -167,48 +162,13 @@ impl KubeController { ) .await } else { - // For regular environments, update the existing workload containers - tracing::info!( - "Updating workload containers for '{}' in regular environment '{}' (namespace '{}')", - updated_workload.name, - environment.name, - environment.namespace - ); - - match cluster_server - .rpc_client - .update_workload_containers( - tarpc::context::current(), - environment.id, - environment.auth_token.clone(), - updated_workload.id, - updated_workload.name.clone(), - updated_workload.namespace.clone(), - workload_kind, - updated_workload.containers, - environment_labels, - ) - .await - { - Ok(Ok(())) => { - tracing::info!( - "Successfully updated workload containers for '{}' in environment '{}' (namespace '{}')", - updated_workload.name, - environment.name, - environment.namespace - ); - Ok(()) - } - Ok(Err(e)) => { - tracing::error!("Failed to atomically update workload containers: {}", e); - Err(ApiError::InvalidRequest(format!( - "Failed to update workload containers: {e}" - ))) - } - Err(e) => Err(ApiError::InvalidRequest(format!( - "Connection error during atomic workload update: {e}" - ))), - } + self.update_regular_workload( + &cluster_server, + &environment, + &updated_workload.name, + updated_workload.containers, + ) + .await } } @@ -331,4 +291,82 @@ impl KubeController { Ok(()) } + + async fn update_regular_workload( + &self, + cluster_server: &lapdev_kube::server::KubeClusterServer, + environment: &lapdev_db_entities::kube_environment::Model, + base_workload_name: &str, + containers: Vec, + ) -> Result<(), ApiError> { + tracing::info!( + "Updating workload containers for '{}' in regular environment '{}' (namespace '{}')", + base_workload_name, + environment.name, + environment.namespace + ); + + let app_workloads = self + .db + .get_app_catalog_workloads(environment.app_catalog_id) + .await + .map_err(ApiError::from)?; + + let base_catalog_workload = app_workloads + .into_iter() + .find(|w| w.name == base_workload_name) + .ok_or_else(|| { + ApiError::InvalidRequest(format!( + "Base workload '{}' not found in app catalog", + base_workload_name + )) + })?; + + let regular_workload = KubeAppCatalogWorkload { + containers, + ..base_catalog_workload + }; + + let mut workloads_with_resources = self + .get_catalog_workloads_with_yaml_from_db( + environment.cluster_id, + vec![regular_workload.clone()], + ) + .await?; + + let deployment_name = base_workload_name.to_string(); + for workload_yaml in &mut workloads_with_resources.workloads { + rename_workload_yaml(workload_yaml, &deployment_name) + .map_err(|e| ApiError::InvalidRequest(e.to_string()))?; + } + + let mut updated_services = HashMap::new(); + for (service_name, service_with_yaml) in workloads_with_resources.services.into_iter() { + let updated_yaml = + rename_service_yaml(&service_with_yaml.yaml, &service_name, &deployment_name) + .map_err(|e| ApiError::InvalidRequest(e.to_string()))?; + + updated_services.insert( + service_name.clone(), + KubeServiceWithYaml { + yaml: updated_yaml, + details: service_with_yaml.details, + }, + ); + } + workloads_with_resources.services = updated_services; + + workloads_with_resources.configmaps.clear(); + workloads_with_resources.secrets.clear(); + + self.deploy_environment_resources( + cluster_server, + &environment.namespace, + &environment.name, + environment.id, + Some(environment.auth_token.clone()), + workloads_with_resources, + ) + .await + } } diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index 4aceaca..fc9d1f5 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -1635,6 +1635,7 @@ impl KubeManager { Ok(all_services) } + #[allow(dead_code)] pub(crate) async fn retrieve_single_workload_yaml( &self, catalog_workload: KubeAppCatalogWorkload, @@ -1651,6 +1652,7 @@ impl KubeManager { .await } + #[allow(dead_code)] pub(crate) async fn retrieve_workloads_yaml( &self, catalog_workloads: Vec, @@ -2130,6 +2132,7 @@ impl KubeManager { Ok(matching_service_names) } + #[allow(dead_code)] fn get_ports_from_matching_services( &self, workload_labels: &std::collections::BTreeMap, @@ -2613,6 +2616,7 @@ impl KubeManager { Ok(()) } + #[allow(dead_code)] async fn apply_services( &self, client: &kube::Client, @@ -2661,6 +2665,7 @@ impl KubeManager { Ok(()) } + #[allow(dead_code)] async fn apply_configmaps( &self, client: &kube::Client, @@ -2711,6 +2716,7 @@ impl KubeManager { Ok(()) } + #[allow(dead_code)] async fn apply_secrets( &self, client: &kube::Client, @@ -3399,6 +3405,7 @@ impl KubeManager { } } + #[allow(dead_code)] pub(crate) async fn get_workload_resource_details( &self, name: &str, @@ -3607,6 +3614,7 @@ impl KubeManager { } } + #[allow(dead_code)] fn extract_pod_resource_info( &self, pod_spec: &k8s_openapi::api::core::v1::PodSpec, @@ -3679,110 +3687,6 @@ impl KubeManager { .collect() } - fn format_memory_bytes(bytes: u64) -> String { - const KI: u64 = 1024; - const MI: u64 = KI * 1024; - const GI: u64 = MI * 1024; - - if bytes >= GI { - format!("{}Gi", bytes / GI) - } else if bytes >= MI { - format!("{}Mi", bytes / MI) - } else if bytes >= KI { - format!("{}Ki", bytes / KI) - } else { - format!("{}B", bytes) - } - } - - /// Atomically updates a workload's containers by: - /// 1. Retrieving the current workload YAML - /// 2. Applying the updated container information - /// 3. Deploying the updated YAML - /// This combines get_workloads_yaml + deploy_workload_yaml into one atomic operation - pub(crate) async fn update_workload_containers_atomic( - &self, - environment_id: Uuid, - environment_auth_token: String, - workload_id: Uuid, - name: String, - namespace: String, - kind: lapdev_common::kube::KubeWorkloadKind, - containers: Vec, - labels: std::collections::HashMap, - ) -> Result<()> { - tracing::info!( - "Starting atomic workload update for {}/{} (id: {}) with {} containers", - namespace, - name, - workload_id, - containers.len() - ); - - // Step 1: Create KubeAppCatalogWorkload for the workload we want to update - let catalog_workload = lapdev_common::kube::KubeAppCatalogWorkload { - id: workload_id, - name: name.clone(), - namespace: namespace.clone(), - kind, - containers, - ports: Vec::new(), // Ports will be fetched from services during YAML retrieval - workload_yaml: None, - catalog_sync_version: 0, - }; - - // Step 2: Get the current workload YAML with all its resources - let workloads_with_resources = self - .retrieve_workloads_yaml(vec![catalog_workload]) - .await - .map_err(|e| { - tracing::error!( - "Failed to retrieve workload YAML for {}/{}: {}", - namespace, - name, - e - ); - anyhow::anyhow!("Failed to retrieve workload YAML: {}", e) - })?; - - tracing::info!( - "Retrieved workload YAML for {}/{}: {} workloads, {} services, {} configmaps, {} secrets", - namespace, name, - workloads_with_resources.workloads.len(), - workloads_with_resources.services.len(), - workloads_with_resources.configmaps.len(), - workloads_with_resources.secrets.len() - ); - - // Step 3: Apply the updated workload with resources atomically - self.apply_workloads_with_resources( - Some(environment_id), - environment_auth_token, - namespace.clone(), - workloads_with_resources, - labels, - ) - .await - .map_err(|e| { - tracing::error!( - "Failed to apply updated workload {}/{}: {}", - namespace, - name, - e - ); - anyhow::anyhow!("Failed to apply updated workload: {}", e) - })?; - - tracing::info!( - "Successfully completed atomic update for workload {}/{} (id: {})", - namespace, - name, - workload_id - ); - - Ok(()) - } - pub async fn get_tunnel_status(&self) -> Result { self.tunnel_manager.get_tunnel_status().await } diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index db313fe..c4c5de0 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -218,59 +218,6 @@ impl KubeManagerRpc for KubeManagerRpcServer { } } - async fn update_workload_containers( - self, - _context: ::tarpc::context::Context, - environment_id: Uuid, - environment_auth_token: String, - workload_id: uuid::Uuid, - name: String, - namespace: String, - kind: lapdev_common::kube::KubeWorkloadKind, - containers: Vec, - labels: std::collections::HashMap, - ) -> Result<(), String> { - tracing::info!( - "Starting atomic update for workload {}/{} (id: {})", - namespace, - name, - workload_id - ); - - match self - .manager - .update_workload_containers_atomic( - environment_id, - environment_auth_token, - workload_id, - name.clone(), - namespace.clone(), - kind, - containers, - labels, - ) - .await - { - Ok(()) => { - tracing::info!( - "Successfully updated workload containers for {}/{}", - namespace, - name - ); - Ok(()) - } - Err(e) => { - tracing::error!( - "Failed to update workload containers for {}/{}: {}", - namespace, - name, - e - ); - Err(format!("Failed to update workload containers: {}", e)) - } - } - } - async fn destroy_environment( self, _context: ::tarpc::context::Context, diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index 52bc8a8..21231b7 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -414,17 +414,6 @@ pub trait KubeManagerRpc { async fn refresh_branch_service_routes(environment_id: Uuid) -> Result<(), String>; - async fn update_workload_containers( - environment_id: Uuid, - environment_auth_token: String, - workload_id: Uuid, - name: String, - namespace: String, - kind: KubeWorkloadKind, - containers: Vec, - labels: std::collections::HashMap, - ) -> Result<(), String>; - async fn destroy_environment(environment_id: Uuid, namespace: String) -> Result<(), String>; // Preview URL tunnel methods From b12e20f7927b43a6cceba718c7c19f39962f0c29 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 19:34:07 +0000 Subject: [PATCH 152/334] update --- crates/kube-manager/src/manager.rs | 2489 +--------------------------- 1 file changed, 7 insertions(+), 2482 deletions(-) diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index fc9d1f5..014a0e1 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -1,9 +1,8 @@ -use std::{collections::HashMap, future::Future, sync::Arc, time::Instant}; +use std::{collections::HashMap, sync::Arc, time::Instant}; use anyhow::{anyhow, Result}; use base64::{engine::general_purpose::STANDARD, Engine}; use futures::StreamExt; -use k8s_openapi::serde_json; use k8s_openapi::{ api::{ apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}, @@ -17,20 +16,16 @@ use kube::{ config::AuthInfo, }; use lapdev_common::kube::{ - KubeAppCatalogWorkload, KubeClusterInfo, KubeClusterStatus, KubeContainerImage, - KubeContainerInfo, KubeContainerPort, KubeNamespaceInfo, KubeServiceDetails, KubeServicePort, - KubeServiceWithYaml, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, KubeWorkloadStatus, - PaginationCursor, PaginationParams, DEFAULT_KUBE_CLUSTER_TUNNEL_URL, DEFAULT_KUBE_CLUSTER_URL, - KUBE_CLUSTER_TOKEN_ENV_VAR, KUBE_CLUSTER_TOKEN_HEADER, KUBE_CLUSTER_TUNNEL_URL_ENV_VAR, - KUBE_CLUSTER_URL_ENV_VAR, + KubeClusterInfo, KubeClusterStatus, KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, + KubeWorkloadList, KubeWorkloadStatus, PaginationCursor, PaginationParams, + DEFAULT_KUBE_CLUSTER_TUNNEL_URL, DEFAULT_KUBE_CLUSTER_URL, KUBE_CLUSTER_TOKEN_ENV_VAR, + KUBE_CLUSTER_TOKEN_HEADER, KUBE_CLUSTER_TUNNEL_URL_ENV_VAR, KUBE_CLUSTER_URL_ENV_VAR, }; use lapdev_kube_rpc::{ - KubeClusterRpcClient, KubeManagerRpc, KubeWorkloadWithServices, KubeWorkloadYaml, - KubeWorkloadYamlOnly, KubeWorkloadsWithResources, NamespacedResourceRequest, - NamespacedResourceResponse, TunnelStatus, + KubeClusterRpcClient, KubeManagerRpc, KubeWorkloadYamlOnly, KubeWorkloadsWithResources, + NamespacedResourceRequest, NamespacedResourceResponse, TunnelStatus, }; use lapdev_rpc::spawn_twoway; -use serde::de::DeserializeOwned; use serde::Deserialize; use tarpc::server::{BaseChannel, Channel}; use tokio::time::{sleep, Duration}; @@ -1081,1433 +1076,6 @@ impl KubeManager { Ok(None) } - fn clean_metadata( - &self, - original_metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta, - ) -> k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta { - use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; - - ObjectMeta { - name: original_metadata.name, - labels: original_metadata.labels, - ..Default::default() - } - } - - fn extract_service_details(&self, service: &Service) -> KubeServiceDetails { - let name = service - .metadata - .name - .as_deref() - .unwrap_or("unknown") - .to_string(); - - let ports = service - .spec - .as_ref() - .and_then(|spec| spec.ports.as_ref()) - .map(|ports| { - ports - .iter() - .map(|port| KubeServicePort { - name: port.name.clone(), - port: port.port, - target_port: port.target_port.as_ref().and_then(|tp| match tp { - k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int(i) => { - Some(*i) - } - _ => None, - }), - protocol: port.protocol.clone(), - node_port: port.node_port, - }) - .collect() - }) - .unwrap_or_default(); - - let selector = service - .spec - .as_ref() - .and_then(|spec| spec.selector.clone()) - .map(|btree| btree.into_iter().collect()) - .unwrap_or_default(); - - KubeServiceDetails { - name, - ports, - selector, - } - } - - fn clean_service_spec(&self, service: Service) -> Service { - use k8s_openapi::api::core::v1::ServiceSpec; - - // Create clean service spec with only essential fields - let clean_spec = service.spec.map(|original_spec| ServiceSpec { - // Only the essential fields for basic service functionality - selector: original_spec.selector, - ports: original_spec.ports, - type_: original_spec.type_, - - // All other fields use defaults - let target cluster manage them - ..Default::default() - }); - - // Create new clean service - Service { - metadata: self.clean_metadata(service.metadata), - spec: clean_spec, - status: None, // Never copy status - } - } - - fn clean_configmap(&self, configmap: ConfigMap) -> ConfigMap { - ConfigMap { - metadata: self.clean_metadata(configmap.metadata), - data: configmap.data, - binary_data: configmap.binary_data, - immutable: configmap.immutable, - } - } - - fn clean_secret(&self, secret: Secret) -> Secret { - Secret { - metadata: self.clean_metadata(secret.metadata), - data: secret.data, - string_data: secret.string_data, - type_: secret.type_, - immutable: secret.immutable, - } - } - - fn merge_single_container( - &self, - container: k8s_openapi::api::core::v1::Container, - workload_container: &KubeContainerInfo, - ) -> k8s_openapi::api::core::v1::Container { - use k8s_openapi::api::core::v1::EnvVar; - use k8s_openapi::apimachinery::pkg::api::resource::Quantity; - use std::collections::HashMap; - - let mut new_container = container.clone(); - - // Update image based on the enum - match &workload_container.image { - KubeContainerImage::FollowOriginal => { - // Keep the original image from the workload (no change) - } - KubeContainerImage::Custom(custom_image) => { - if !custom_image.is_empty() { - new_container.image = Some(custom_image.clone()); - } - } - } - - // Update resource requirements - let mut resources = container.resources.unwrap_or_default(); - let mut requests = resources.requests.unwrap_or_default(); - let mut limits = resources.limits.unwrap_or_default(); - - // Update CPU and memory requests - if let Some(cpu_request) = &workload_container.cpu_request { - if !cpu_request.is_empty() { - requests.insert("cpu".to_string(), Quantity(cpu_request.clone())); - } - } - if let Some(memory_request) = &workload_container.memory_request { - if !memory_request.is_empty() { - requests.insert("memory".to_string(), Quantity(memory_request.clone())); - } - } - - // Update CPU and memory limits - if let Some(cpu_limit) = &workload_container.cpu_limit { - if !cpu_limit.is_empty() { - limits.insert("cpu".to_string(), Quantity(cpu_limit.clone())); - } - } - if let Some(memory_limit) = &workload_container.memory_limit { - if !memory_limit.is_empty() { - limits.insert("memory".to_string(), Quantity(memory_limit.clone())); - } - } - - // Set the updated resources back - resources.requests = if requests.is_empty() { - None - } else { - Some(requests) - }; - resources.limits = if limits.is_empty() { - None - } else { - Some(limits) - }; - new_container.resources = Some(resources); - - // Merge environment variables - let mut env_map: HashMap< - String, - ( - Option, - Option, - ), - > = HashMap::new(); - - // Start with original container's env vars (if any) - if let Some(original_env) = container.env { - for env_var in original_env { - env_map.insert(env_var.name.clone(), (env_var.value, env_var.value_from)); - } - } - - // Add/override with environment variables from workload_container - for kube_env_var in &workload_container.env_vars { - env_map.insert( - kube_env_var.name.clone(), - (Some(kube_env_var.value.clone()), None), - ); - } - - // Convert back to k8s EnvVar format - let merged_env: Vec = env_map - .into_iter() - .map(|(name, (value, value_from))| EnvVar { - name, - value, - value_from, - }) - .collect(); - - // Set the merged environment variables - new_container.env = if merged_env.is_empty() { - None - } else { - Some(merged_env) - }; - - new_container - } - - fn merge_template_containers( - &self, - template: k8s_openapi::api::core::v1::PodTemplateSpec, - workload_containers: &[KubeContainerInfo], - ) -> k8s_openapi::api::core::v1::PodTemplateSpec { - use k8s_openapi::api::core::v1::{PodSpec, PodTemplateSpec}; - - let pod_spec = template.spec.map(|original_pod_spec| { - let merged_containers = original_pod_spec - .containers - .into_iter() - .map(|container| { - // Find matching container in workload by name - if let Some(workload_container) = workload_containers - .iter() - .find(|wc| wc.name == container.name) - { - self.merge_single_container(container, workload_container) - } else { - container - } - }) - .collect(); - - PodSpec { - containers: merged_containers, - ..original_pod_spec - } - }); - - PodTemplateSpec { - spec: pod_spec, - ..template - } - } - - fn clean_deployment( - &self, - deployment: Deployment, - workload_containers: &[KubeContainerInfo], - ) -> Deployment { - use k8s_openapi::api::apps::v1::DeploymentSpec; - - let clean_spec = deployment.spec.map(|original_spec| { - // Merge container specs with template - let template = - self.merge_template_containers(original_spec.template, workload_containers); - - DeploymentSpec { - // Only the essential fields for basic deployment functionality - replicas: original_spec.replicas, - selector: original_spec.selector, - template, - min_ready_seconds: original_spec.min_ready_seconds, - paused: original_spec.paused, - progress_deadline_seconds: original_spec.progress_deadline_seconds, - revision_history_limit: original_spec.revision_history_limit, - strategy: original_spec.strategy, - } - }); - - Deployment { - metadata: self.clean_metadata(deployment.metadata), - spec: clean_spec, - status: None, // Never copy status - } - } - - fn clean_statefulset( - &self, - statefulset: StatefulSet, - workload_containers: &[KubeContainerInfo], - ) -> StatefulSet { - use k8s_openapi::api::apps::v1::StatefulSetSpec; - - let clean_spec = statefulset.spec.map(|original_spec| { - // Merge container specs with template - let template = - self.merge_template_containers(original_spec.template, workload_containers); - - StatefulSetSpec { - service_name: original_spec.service_name, - replicas: original_spec.replicas, - selector: original_spec.selector, - template, - volume_claim_templates: original_spec.volume_claim_templates, - update_strategy: original_spec.update_strategy, - min_ready_seconds: original_spec.min_ready_seconds, - persistent_volume_claim_retention_policy: original_spec - .persistent_volume_claim_retention_policy, - ordinals: original_spec.ordinals, - revision_history_limit: original_spec.revision_history_limit, - pod_management_policy: original_spec.pod_management_policy, - } - }); - - StatefulSet { - metadata: self.clean_metadata(statefulset.metadata), - spec: clean_spec, - status: None, - } - } - - fn clean_daemonset( - &self, - daemonset: DaemonSet, - workload_containers: &[KubeContainerInfo], - ) -> DaemonSet { - use k8s_openapi::api::apps::v1::DaemonSetSpec; - - let clean_spec = daemonset.spec.map(|original_spec| { - // Merge container specs with template - let template = - self.merge_template_containers(original_spec.template, workload_containers); - - DaemonSetSpec { - selector: original_spec.selector, - template, - update_strategy: original_spec.update_strategy, - min_ready_seconds: original_spec.min_ready_seconds, - revision_history_limit: original_spec.revision_history_limit, - } - }); - - DaemonSet { - metadata: self.clean_metadata(daemonset.metadata), - spec: clean_spec, - status: None, - } - } - - fn merge_containers( - &self, - containers: Vec, - workload_containers: &[KubeContainerInfo], - ) -> Vec { - containers - .into_iter() - .map(|container| { - // Find matching container in workload by name - if let Some(workload_container) = workload_containers - .iter() - .find(|wc| wc.name == container.name) - { - self.merge_single_container(container, workload_container) - } else { - container - } - }) - .collect() - } - - fn clean_pod(&self, pod: Pod, workload_containers: &[KubeContainerInfo]) -> Pod { - use k8s_openapi::api::core::v1::PodSpec; - - let clean_spec = pod.spec.map(|original_spec| { - let merged_containers = - self.merge_containers(original_spec.containers, workload_containers); - - PodSpec { - active_deadline_seconds: original_spec.active_deadline_seconds, - containers: merged_containers, - init_containers: original_spec.init_containers, - ephemeral_containers: original_spec.ephemeral_containers, - volumes: original_spec.volumes, - restart_policy: original_spec.restart_policy, - termination_grace_period_seconds: original_spec.termination_grace_period_seconds, - dns_policy: original_spec.dns_policy, - dns_config: original_spec.dns_config, - node_selector: original_spec.node_selector, - service_account_name: original_spec.service_account_name, - service_account: original_spec.service_account, - automount_service_account_token: original_spec.automount_service_account_token, - security_context: original_spec.security_context, - image_pull_secrets: original_spec.image_pull_secrets, - affinity: original_spec.affinity, - tolerations: original_spec.tolerations, - topology_spread_constraints: original_spec.topology_spread_constraints, - priority_class_name: original_spec.priority_class_name, - priority: original_spec.priority, - preemption_policy: original_spec.preemption_policy, - overhead: original_spec.overhead, - enable_service_links: original_spec.enable_service_links, - os: original_spec.os, - host_users: original_spec.host_users, - scheduling_gates: original_spec.scheduling_gates, - resource_claims: original_spec.resource_claims, - - // Remove runtime/node-specific fields by using Default - ..Default::default() - } - }); - - Pod { - metadata: self.clean_metadata(pod.metadata), - spec: clean_spec, - status: None, - } - } - - fn clean_job(&self, job: Job, workload_containers: &[KubeContainerInfo]) -> Job { - use k8s_openapi::api::batch::v1::JobSpec; - - let clean_spec = job.spec.map(|original_spec| { - // Merge container specs with template - let template = - self.merge_template_containers(original_spec.template, workload_containers); - - JobSpec { - template, - parallelism: original_spec.parallelism, - completions: original_spec.completions, - completion_mode: original_spec.completion_mode, - active_deadline_seconds: original_spec.active_deadline_seconds, - backoff_limit: original_spec.backoff_limit, - backoff_limit_per_index: original_spec.backoff_limit_per_index, - max_failed_indexes: original_spec.max_failed_indexes, - selector: original_spec.selector, - manual_selector: original_spec.manual_selector, - ttl_seconds_after_finished: original_spec.ttl_seconds_after_finished, - suspend: original_spec.suspend, - pod_failure_policy: original_spec.pod_failure_policy, - pod_replacement_policy: original_spec.pod_replacement_policy, - managed_by: original_spec.managed_by, - success_policy: original_spec.success_policy, - } - }); - - Job { - metadata: self.clean_metadata(job.metadata), - spec: clean_spec, - status: None, - } - } - - fn clean_cronjob( - &self, - cronjob: CronJob, - workload_containers: &[KubeContainerInfo], - ) -> CronJob { - use k8s_openapi::api::batch::v1::CronJobSpec; - - let clean_spec = cronjob.spec.map(|original_spec| { - // Handle job_template which contains a JobTemplateSpec - let job_template = { - let mut template = original_spec.job_template; - if let Some(job_spec) = &mut template.spec { - // Merge container specs with the job template's template - job_spec.template = self - .merge_template_containers(job_spec.template.clone(), workload_containers); - } - template - }; - - CronJobSpec { - schedule: original_spec.schedule, - time_zone: original_spec.time_zone, - starting_deadline_seconds: original_spec.starting_deadline_seconds, - concurrency_policy: original_spec.concurrency_policy, - suspend: original_spec.suspend, - job_template, - successful_jobs_history_limit: original_spec.successful_jobs_history_limit, - failed_jobs_history_limit: original_spec.failed_jobs_history_limit, - } - }); - - CronJob { - metadata: self.clean_metadata(cronjob.metadata), - spec: clean_spec, - status: None, - } - } - - fn clean_replicaset( - &self, - replicaset: ReplicaSet, - workload_containers: &[KubeContainerInfo], - ) -> ReplicaSet { - use k8s_openapi::api::apps::v1::ReplicaSetSpec; - - let clean_spec = replicaset.spec.map(|original_spec| { - // Merge container specs with template - let template = original_spec - .template - .map(|t| self.merge_template_containers(t, workload_containers)); - - ReplicaSetSpec { - replicas: original_spec.replicas, - selector: original_spec.selector, - template, - min_ready_seconds: original_spec.min_ready_seconds, - } - }); - - ReplicaSet { - metadata: self.clean_metadata(replicaset.metadata), - spec: clean_spec, - status: None, - } - } - - async fn retrieve_cluster_ip_services_in_namespace( - &self, - client: &kube::Client, - namespace: &str, - ) -> Result> { - let services_api: kube::Api = kube::Api::namespaced((*client).clone(), namespace); - - let mut all_services = Vec::new(); - - // Retrieve all services in the namespace - let mut continue_token: Option = None; - loop { - let mut list_params = ListParams::default().limit(100); - if let Some(token) = &continue_token { - list_params = list_params.continue_token(token); - } - - let services_list = services_api.list(&list_params).await?; - - // Filter for ClusterIP services only (other types are cluster-specific) - let cluster_ip_services: Vec = services_list - .items - .into_iter() - .filter(|service| { - service - .spec - .as_ref() - .map(|spec| { - spec.type_.as_deref() == Some("ClusterIP") || spec.type_.is_none() - }) - .unwrap_or(false) - }) - .collect(); - - all_services.extend(cluster_ip_services); - - continue_token = services_list.metadata.continue_; - if continue_token.is_none() { - break; - } - } - - Ok(all_services) - } - - #[allow(dead_code)] - pub(crate) async fn retrieve_single_workload_yaml( - &self, - catalog_workload: KubeAppCatalogWorkload, - ) -> Result { - let client = &self.kube_client; - - // Get ClusterIP services for the workload's namespace - let all_services = self - .retrieve_cluster_ip_services_in_namespace(client, &catalog_workload.namespace) - .await?; - - // Retrieve the single workload with its associated resources - self.retrieve_single_workload_with_resources(client, &catalog_workload, &all_services) - .await - } - - #[allow(dead_code)] - pub(crate) async fn retrieve_workloads_yaml( - &self, - catalog_workloads: Vec, - ) -> Result { - let client = &self.kube_client; - - let mut workloads = Vec::new(); - let mut all_services_set_by_namespace: HashMap> = - HashMap::new(); - let mut all_configmaps_set_by_namespace: HashMap< - String, - std::collections::HashSet, - > = HashMap::new(); - let mut all_secrets_set_by_namespace: HashMap> = - HashMap::new(); - - // Store all resources by namespace for later serialization - let mut all_services_by_namespace: HashMap> = HashMap::new(); - - // Group workloads by namespace - let mut workloads_by_namespace: std::collections::HashMap< - String, - Vec, - > = std::collections::HashMap::new(); - for catalog_workload in catalog_workloads { - workloads_by_namespace - .entry(catalog_workload.namespace.clone()) - .or_insert_with(Vec::new) - .push(catalog_workload); - } - - // Process each namespace separately - for (namespace, namespace_workloads) in workloads_by_namespace { - // Get all ClusterIP services once for this namespace and reuse for all workloads - let all_services = self - .retrieve_cluster_ip_services_in_namespace(client, &namespace) - .await?; - - // Store services by namespace for later serialization - all_services_by_namespace.insert(namespace.clone(), all_services.clone()); - - // Process each workload in this namespace - for workload in namespace_workloads { - let workload_yaml_result = self - .retrieve_single_workload_with_resources(client, &workload, &all_services) - .await?; - - // Helper to collect resource names by namespace - let mut collect_resources = - |services: Vec, configmaps: Vec, secrets: Vec| { - let services_set = all_services_set_by_namespace - .entry(namespace.clone()) - .or_default(); - let configmaps_set = all_configmaps_set_by_namespace - .entry(namespace.clone()) - .or_default(); - let secrets_set = all_secrets_set_by_namespace - .entry(namespace.clone()) - .or_default(); - - for service in services { - services_set.insert(service); - } - for configmap in configmaps { - configmaps_set.insert(configmap); - } - for secret in secrets { - secrets_set.insert(secret); - } - }; - - // Extract just the workload YAML and collect associated resources - match workload_yaml_result { - KubeWorkloadYaml::Deployment(ws) => { - workloads.push(KubeWorkloadYamlOnly::Deployment(ws.workload_yaml)); - collect_resources(ws.services, ws.configmaps, ws.secrets); - } - KubeWorkloadYaml::StatefulSet(ws) => { - workloads.push(KubeWorkloadYamlOnly::StatefulSet(ws.workload_yaml)); - collect_resources(ws.services, ws.configmaps, ws.secrets); - } - KubeWorkloadYaml::DaemonSet(ws) => { - workloads.push(KubeWorkloadYamlOnly::DaemonSet(ws.workload_yaml)); - collect_resources(ws.services, ws.configmaps, ws.secrets); - } - KubeWorkloadYaml::ReplicaSet(ws) => { - workloads.push(KubeWorkloadYamlOnly::ReplicaSet(ws.workload_yaml)); - collect_resources(ws.services, ws.configmaps, ws.secrets); - } - KubeWorkloadYaml::Pod(ws) => { - workloads.push(KubeWorkloadYamlOnly::Pod(ws.workload_yaml)); - collect_resources(ws.services, ws.configmaps, ws.secrets); - } - KubeWorkloadYaml::Job(ws) => { - workloads.push(KubeWorkloadYamlOnly::Job(ws.workload_yaml)); - collect_resources(ws.services, ws.configmaps, ws.secrets); - } - KubeWorkloadYaml::CronJob(ws) => { - workloads.push(KubeWorkloadYamlOnly::CronJob(ws.workload_yaml)); - collect_resources(ws.services, ws.configmaps, ws.secrets); - } - } - } - - tracing::debug!( - "Retrieved workloads with services from namespace {}", - namespace - ); - } - - // Now build the actual YAML content maps from the already-fetched resources - let (services_yaml_map, configmaps_yaml_map, secrets_yaml_map) = self - .build_resource_yaml_maps( - client, - all_services_by_namespace, - &all_services_set_by_namespace, - &all_configmaps_set_by_namespace, - &all_secrets_set_by_namespace, - ) - .await?; - - Ok(KubeWorkloadsWithResources { - workloads, - services: services_yaml_map, - configmaps: configmaps_yaml_map, - secrets: secrets_yaml_map, - }) - } - - async fn load_cached_workload( - &self, - cached_yaml: Option<&str>, - fetch_future: F, - namespace: &str, - name: &str, - kind: &'static str, - ) -> Result - where - T: DeserializeOwned, - F: Future>, - { - if let Some(yaml_str) = cached_yaml { - match serde_yaml::from_str::(yaml_str) { - Ok(obj) => return Ok(obj), - Err(err) => { - tracing::warn!( - namespace = namespace, - workload = name, - kind, - error = ?err, - "Failed to parse cached workload YAML; refetching from cluster" - ); - } - } - } - - fetch_future.await.map_err(|e| { - anyhow!( - "Failed to fetch {} {}/{} from cluster: {}", - kind, - namespace, - name, - e - ) - }) - } - - async fn retrieve_single_workload_with_resources( - &self, - client: &kube::Client, - workload: &KubeAppCatalogWorkload, - all_services: &[Service], - ) -> Result { - match workload.kind { - KubeWorkloadKind::Deployment => { - let api: kube::Api = - kube::Api::namespaced((*client).clone(), &workload.namespace); - let deployment = self - .load_cached_workload( - workload.workload_yaml.as_deref(), - api.get(&workload.name), - &workload.namespace, - &workload.name, - "Deployment", - ) - .await?; - - // Get labels for service matching - let workload_labels = deployment - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - // Find matching services from pre-loaded services - let services = - self.find_matching_services_from_list(&workload_labels, all_services)?; - - // Extract ConfigMap and Secret references - let deployment_json = serde_json::to_value(&deployment)?; - let configmaps = Self::extract_configmap_references(&deployment_json); - let secrets = Self::extract_secret_references(&deployment_json); - - // Clean server-managed fields and merge container specs - let clean_deployment = self.clean_deployment(deployment, &workload.containers); - let workload_yaml = serde_yaml::to_string(&clean_deployment) - .map_err(|e| anyhow!("Failed to serialize to YAML: {}", e))?; - - Ok(KubeWorkloadYaml::Deployment(KubeWorkloadWithServices { - workload_yaml, - services, - configmaps, - secrets, - })) - } - KubeWorkloadKind::StatefulSet => { - let api: kube::Api = - kube::Api::namespaced((*client).clone(), &workload.namespace); - let statefulset = self - .load_cached_workload( - workload.workload_yaml.as_deref(), - api.get(&workload.name), - &workload.namespace, - &workload.name, - "StatefulSet", - ) - .await?; - - let workload_labels = statefulset - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - let services = - self.find_matching_services_from_list(&workload_labels, all_services)?; - - // Extract ConfigMap and Secret references - let statefulset_json = serde_json::to_value(&statefulset)?; - let configmaps = Self::extract_configmap_references(&statefulset_json); - let secrets = Self::extract_secret_references(&statefulset_json); - - let clean_statefulset = self.clean_statefulset(statefulset, &workload.containers); - let workload_yaml = serde_yaml::to_string(&clean_statefulset) - .map_err(|e| anyhow!("Failed to serialize to YAML: {}", e))?; - - Ok(KubeWorkloadYaml::StatefulSet(KubeWorkloadWithServices { - workload_yaml, - services, - configmaps, - secrets, - })) - } - KubeWorkloadKind::DaemonSet => { - let api: kube::Api = - kube::Api::namespaced((*client).clone(), &workload.namespace); - let daemonset = self - .load_cached_workload( - workload.workload_yaml.as_deref(), - api.get(&workload.name), - &workload.namespace, - &workload.name, - "DaemonSet", - ) - .await?; - - let workload_labels = daemonset - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - let services = - self.find_matching_services_from_list(&workload_labels, all_services)?; - - // Extract ConfigMap and Secret references - let daemonset_json = serde_json::to_value(&daemonset)?; - let configmaps = Self::extract_configmap_references(&daemonset_json); - let secrets = Self::extract_secret_references(&daemonset_json); - - let clean_daemonset = self.clean_daemonset(daemonset, &workload.containers); - let workload_yaml = serde_yaml::to_string(&clean_daemonset) - .map_err(|e| anyhow!("Failed to serialize to YAML: {}", e))?; - - Ok(KubeWorkloadYaml::DaemonSet(KubeWorkloadWithServices { - workload_yaml, - services, - configmaps, - secrets, - })) - } - KubeWorkloadKind::Pod => { - let api: kube::Api = - kube::Api::namespaced((*client).clone(), &workload.namespace); - let pod = self - .load_cached_workload( - workload.workload_yaml.as_deref(), - api.get(&workload.name), - &workload.namespace, - &workload.name, - "Pod", - ) - .await?; - - let workload_labels = pod.metadata.labels.as_ref().cloned().unwrap_or_default(); - let services = - self.find_matching_services_from_list(&workload_labels, all_services)?; - - // Extract ConfigMap and Secret references - let pod_json = serde_json::to_value(&pod)?; - let configmaps = Self::extract_configmap_references(&pod_json); - let secrets = Self::extract_secret_references(&pod_json); - - let clean_pod = self.clean_pod(pod, &workload.containers); - let workload_yaml = serde_yaml::to_string(&clean_pod) - .map_err(|e| anyhow!("Failed to serialize to YAML: {}", e))?; - - Ok(KubeWorkloadYaml::Pod(KubeWorkloadWithServices { - workload_yaml, - services, - configmaps, - secrets, - })) - } - KubeWorkloadKind::Job => { - let api: kube::Api = - kube::Api::namespaced((*client).clone(), &workload.namespace); - let job = self - .load_cached_workload( - workload.workload_yaml.as_deref(), - api.get(&workload.name), - &workload.namespace, - &workload.name, - "Job", - ) - .await?; - - let workload_labels = job - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - let services = - self.find_matching_services_from_list(&workload_labels, all_services)?; - - // Extract ConfigMap and Secret references - let job_json = serde_json::to_value(&job)?; - let configmaps = Self::extract_configmap_references(&job_json); - let secrets = Self::extract_secret_references(&job_json); - - let clean_job = self.clean_job(job, &workload.containers); - let workload_yaml = serde_yaml::to_string(&clean_job) - .map_err(|e| anyhow!("Failed to serialize to YAML: {}", e))?; - - Ok(KubeWorkloadYaml::Job(KubeWorkloadWithServices { - workload_yaml, - services, - configmaps, - secrets, - })) - } - KubeWorkloadKind::CronJob => { - let api: kube::Api = - kube::Api::namespaced((*client).clone(), &workload.namespace); - let cronjob = self - .load_cached_workload( - workload.workload_yaml.as_deref(), - api.get(&workload.name), - &workload.namespace, - &workload.name, - "CronJob", - ) - .await?; - - let workload_labels = cronjob - .spec - .as_ref() - .and_then(|s| s.job_template.spec.as_ref()) - .and_then(|js| js.template.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - let services = - self.find_matching_services_from_list(&workload_labels, all_services)?; - - // Extract ConfigMap and Secret references - let cronjob_json = serde_json::to_value(&cronjob)?; - let configmaps = Self::extract_configmap_references(&cronjob_json); - let secrets = Self::extract_secret_references(&cronjob_json); - - let clean_cronjob = self.clean_cronjob(cronjob, &workload.containers); - let workload_yaml = serde_yaml::to_string(&clean_cronjob) - .map_err(|e| anyhow!("Failed to serialize to YAML: {}", e))?; - - Ok(KubeWorkloadYaml::CronJob(KubeWorkloadWithServices { - workload_yaml, - services, - configmaps, - secrets, - })) - } - KubeWorkloadKind::ReplicaSet => { - let api: kube::Api = - kube::Api::namespaced((*client).clone(), &workload.namespace); - let replicaset = self - .load_cached_workload( - workload.workload_yaml.as_deref(), - api.get(&workload.name), - &workload.namespace, - &workload.name, - "ReplicaSet", - ) - .await?; - - let workload_labels = replicaset - .spec - .as_ref() - .and_then(|s| s.template.as_ref()) - .and_then(|t| t.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - let services = - self.find_matching_services_from_list(&workload_labels, all_services)?; - - // Extract ConfigMap and Secret references - let replicaset_json = serde_json::to_value(&replicaset)?; - let configmaps = Self::extract_configmap_references(&replicaset_json); - let secrets = Self::extract_secret_references(&replicaset_json); - - let clean_replicaset = self.clean_replicaset(replicaset, &workload.containers); - let workload_yaml = serde_yaml::to_string(&clean_replicaset) - .map_err(|e| anyhow!("Failed to serialize to YAML: {}", e))?; - - Ok(KubeWorkloadYaml::ReplicaSet(KubeWorkloadWithServices { - workload_yaml, - services, - configmaps, - secrets, - })) - } - } - } - - fn find_matching_services_from_list( - &self, - workload_labels: &std::collections::BTreeMap, - all_services: &[Service], - ) -> Result> { - let mut matching_service_names = Vec::new(); - - for service in all_services { - if let Some(selector) = &service.spec.as_ref().and_then(|s| s.selector.as_ref()) { - let matches = selector - .iter() - .all(|(key, value)| workload_labels.get(key).map_or(false, |v| v == value)); - - if matches && !selector.is_empty() { - if let Some(service_name) = &service.metadata.name { - matching_service_names.push(service_name.clone()); - } - } - } - } - - Ok(matching_service_names) - } - - #[allow(dead_code)] - fn get_ports_from_matching_services( - &self, - workload_labels: &std::collections::BTreeMap, - all_services: &[Service], - ) -> Result> { - let mut ports = Vec::new(); - let mut seen_ports = std::collections::HashSet::new(); - - for service in all_services { - if let Some(selector) = &service.spec.as_ref().and_then(|s| s.selector.as_ref()) { - let matches = selector - .iter() - .all(|(key, value)| workload_labels.get(key).map_or(false, |v| v == value)); - - if matches && !selector.is_empty() { - if let Some(spec) = &service.spec { - if let Some(service_ports) = &spec.ports { - for port in service_ports { - let target_port = port.target_port.as_ref().and_then(|tp| match tp { - k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int(i) => Some(*i), - _ => None, - }); - - // Deduplicate based on target_port and protocol - let port_key = ( - target_port, - port.protocol.clone().unwrap_or_else(|| "TCP".to_string()), - ); - - if seen_ports.insert(port_key) { - ports.push(KubeServicePort { - name: port.name.clone(), - port: port.port, - target_port, - protocol: port.protocol.clone(), - node_port: port.node_port, - }); - } - } - } - } - } - } - } - - Ok(ports) - } - - fn extract_configmap_references(workload_spec: &serde_json::Value) -> Vec { - let mut configmap_names = std::collections::HashSet::new(); - - // Extract ConfigMap references from various places in the spec - Self::extract_from_containers(workload_spec, &mut configmap_names, "configMap"); - Self::extract_from_volumes(workload_spec, &mut configmap_names, "configMap"); - - configmap_names.into_iter().collect() - } - - fn extract_secret_references(workload_spec: &serde_json::Value) -> Vec { - let mut secret_names = std::collections::HashSet::new(); - - // Extract Secret references from various places in the spec - Self::extract_from_containers(workload_spec, &mut secret_names, "secret"); - Self::extract_from_volumes(workload_spec, &mut secret_names, "secret"); - Self::extract_image_pull_secrets(workload_spec, &mut secret_names); - - secret_names.into_iter().collect() - } - - fn extract_from_containers( - spec: &serde_json::Value, - names: &mut std::collections::HashSet, - resource_type: &str, - ) { - // Look in spec.template.spec.containers (for Deployments, StatefulSets, etc.) - if let Some(containers) = spec - .pointer("/spec/template/spec/containers") - .and_then(|c| c.as_array()) - { - for container in containers { - Self::extract_from_container_env(container, names, resource_type); - Self::extract_from_container_env_from(container, names, resource_type); - Self::extract_from_container_volume_mounts(container, spec, names, resource_type); - } - } - - // Look in spec.template.spec.initContainers (for Deployments, StatefulSets, etc.) - if let Some(init_containers) = spec - .pointer("/spec/template/spec/initContainers") - .and_then(|c| c.as_array()) - { - for container in init_containers { - Self::extract_from_container_env(container, names, resource_type); - Self::extract_from_container_env_from(container, names, resource_type); - Self::extract_from_container_volume_mounts(container, spec, names, resource_type); - } - } - - // Look in spec.containers (for Pods) - if let Some(containers) = spec.pointer("/spec/containers").and_then(|c| c.as_array()) { - for container in containers { - Self::extract_from_container_env(container, names, resource_type); - Self::extract_from_container_env_from(container, names, resource_type); - Self::extract_from_container_volume_mounts(container, spec, names, resource_type); - } - } - - // Look in spec.initContainers (for Pods) - if let Some(init_containers) = spec - .pointer("/spec/initContainers") - .and_then(|c| c.as_array()) - { - for container in init_containers { - Self::extract_from_container_env(container, names, resource_type); - Self::extract_from_container_env_from(container, names, resource_type); - Self::extract_from_container_volume_mounts(container, spec, names, resource_type); - } - } - } - - fn extract_from_container_env( - container: &serde_json::Value, - names: &mut std::collections::HashSet, - resource_type: &str, - ) { - if let Some(env_vars) = container.pointer("/env").and_then(|e| e.as_array()) { - for env_var in env_vars { - if let Some(value_from) = env_var.get("valueFrom") { - let key_ref_name = if resource_type == "configMap" { - "configMapKeyRef" - } else { - "secretKeyRef" - }; - if let Some(name) = value_from - .pointer(&format!("/{}/name", key_ref_name)) - .and_then(|n| n.as_str()) - { - names.insert(name.to_string()); - } - } - } - } - } - - fn extract_from_container_env_from( - container: &serde_json::Value, - names: &mut std::collections::HashSet, - resource_type: &str, - ) { - if let Some(env_from) = container.pointer("/envFrom").and_then(|e| e.as_array()) { - for env_from_source in env_from { - let ref_name = if resource_type == "configMap" { - "configMapRef" - } else { - "secretRef" - }; - if let Some(name) = env_from_source - .pointer(&format!("/{}/name", ref_name)) - .and_then(|n| n.as_str()) - { - names.insert(name.to_string()); - } - } - } - } - - fn extract_from_container_volume_mounts( - container: &serde_json::Value, - spec: &serde_json::Value, - names: &mut std::collections::HashSet, - resource_type: &str, - ) { - if let Some(volume_mounts) = container - .pointer("/volumeMounts") - .and_then(|v| v.as_array()) - { - for volume_mount in volume_mounts { - if let Some(volume_name) = volume_mount.get("name").and_then(|n| n.as_str()) { - // Find the corresponding volume in spec - Self::find_volume_source(spec, volume_name, names, resource_type); - } - } - } - } - - fn extract_from_volumes( - spec: &serde_json::Value, - names: &mut std::collections::HashSet, - resource_type: &str, - ) { - // Look in spec.template.spec.volumes (for Deployments, StatefulSets, etc.) - if let Some(volumes) = spec - .pointer("/spec/template/spec/volumes") - .and_then(|v| v.as_array()) - { - for volume in volumes { - let name_field = if resource_type == "secret" { - "secretName" - } else { - "name" - }; - if let Some(name) = volume - .pointer(&format!("/{}/{}", resource_type, name_field)) - .and_then(|n| n.as_str()) - { - names.insert(name.to_string()); - } - } - } - - // Look in spec.volumes (for Pods) - if let Some(volumes) = spec.pointer("/spec/volumes").and_then(|v| v.as_array()) { - for volume in volumes { - let name_field = if resource_type == "secret" { - "secretName" - } else { - "name" - }; - if let Some(name) = volume - .pointer(&format!("/{}/{}", resource_type, name_field)) - .and_then(|n| n.as_str()) - { - names.insert(name.to_string()); - } - } - } - } - - fn find_volume_source( - spec: &serde_json::Value, - volume_name: &str, - names: &mut std::collections::HashSet, - resource_type: &str, - ) { - let volume_paths = [ - "/spec/template/spec/volumes", // For Deployments, StatefulSets, etc. - "/spec/volumes", // For Pods - ]; - - for volume_path in &volume_paths { - if let Some(volumes) = spec.pointer(volume_path).and_then(|v| v.as_array()) { - for volume in volumes { - if let Some(name) = volume.get("name").and_then(|n| n.as_str()) { - if name == volume_name { - if let Some(resource_name) = volume - .pointer(&format!("/{}/name", resource_type)) - .and_then(|n| n.as_str()) - { - names.insert(resource_name.to_string()); - } - return; - } - } - } - } - } - } - - fn extract_image_pull_secrets( - spec: &serde_json::Value, - names: &mut std::collections::HashSet, - ) { - let pull_secret_paths = [ - "/spec/template/spec/imagePullSecrets", // For Deployments, StatefulSets, etc. - "/spec/imagePullSecrets", // For Pods - ]; - - for path in &pull_secret_paths { - if let Some(pull_secrets) = spec.pointer(path).and_then(|p| p.as_array()) { - for pull_secret in pull_secrets { - if let Some(name) = pull_secret.get("name").and_then(|n| n.as_str()) { - names.insert(name.to_string()); - } - } - } - } - } - - async fn build_resource_yaml_maps( - &self, - client: &kube::Client, - all_services_by_namespace: HashMap>, - needed_services_by_namespace: &HashMap>, - needed_configmaps_by_namespace: &HashMap>, - needed_secrets_by_namespace: &HashMap>, - ) -> Result<( - HashMap, - HashMap, - HashMap, - )> { - let mut services_yaml_map = HashMap::new(); - let mut configmaps_yaml_map = HashMap::new(); - let mut secrets_yaml_map = HashMap::new(); - - // Serialize only the services that are actually needed - for (namespace, services) in all_services_by_namespace { - if let Some(needed_services) = needed_services_by_namespace.get(&namespace) { - for service in services { - if let Some(service_name) = &service.metadata.name { - if needed_services.contains(service_name) { - let clean_service = self.clean_service_spec(service.clone()); - let service_details = self.extract_service_details(&service); - - if let Ok(service_yaml) = serde_yaml::to_string(&clean_service) { - services_yaml_map.insert( - service_name.clone(), - KubeServiceWithYaml { - yaml: service_yaml, - details: service_details, - }, - ); - } - } - } - } - } - } - - // Fetch and serialize only the configmaps that are actually needed - for (namespace, needed_configmaps) in needed_configmaps_by_namespace { - let configmaps_api: kube::Api = - kube::Api::namespaced((*client).clone(), namespace); - - for configmap_name in needed_configmaps { - match configmaps_api.get(configmap_name).await { - Ok(configmap) => { - let clean_configmap = self.clean_configmap(configmap); - - if let Ok(configmap_yaml) = serde_yaml::to_string(&clean_configmap) { - configmaps_yaml_map.insert(configmap_name.clone(), configmap_yaml); - } - } - Err(e) => { - // Log error but continue - ConfigMap might not exist or be accessible - tracing::warn!( - "Could not fetch ConfigMap {}/{}: {}", - namespace, - configmap_name, - e - ); - } - } - } - } - - // Fetch and serialize only the secrets that are actually needed - for (namespace, needed_secrets) in needed_secrets_by_namespace { - let secrets_api: kube::Api = - kube::Api::namespaced((*client).clone(), namespace); - - for secret_name in needed_secrets { - match secrets_api.get(secret_name).await { - Ok(secret) => { - let clean_secret = self.clean_secret(secret); - - if let Ok(secret_yaml) = serde_yaml::to_string(&clean_secret) { - secrets_yaml_map.insert(secret_name.clone(), secret_yaml); - } - } - Err(e) => { - // Log error but continue - Secret might not exist or be accessible - tracing::warn!( - "Could not fetch Secret {}/{}: {}", - namespace, - secret_name, - e - ); - } - } - } - } - - Ok((services_yaml_map, configmaps_yaml_map, secrets_yaml_map)) - } - pub(crate) async fn fetch_namespaced_resources( &self, requests: Vec, @@ -2616,149 +1184,6 @@ impl KubeManager { Ok(()) } - #[allow(dead_code)] - async fn apply_services( - &self, - client: &kube::Client, - namespace: &str, - service_yamls: &[String], - labels: &std::collections::HashMap, - ) -> Result<()> { - for service_yaml in service_yamls { - let mut service: Service = serde_yaml::from_str(service_yaml)?; - - // Add environment labels to service - self.add_labels_to_metadata(&mut service.metadata, labels); - - // Force namespace - service.metadata.namespace = Some(namespace.to_string()); - - let services_api: kube::Api = - kube::Api::namespaced((*client).clone(), namespace); - - match services_api - .get_opt(&service.metadata.name.as_ref().unwrap()) - .await? - { - Some(_) => { - services_api - .replace( - &service.metadata.name.as_ref().unwrap(), - &Default::default(), - &service, - ) - .await?; - tracing::info!( - "Updated service: {}", - service.metadata.name.as_ref().unwrap() - ); - } - None => { - services_api.create(&Default::default(), &service).await?; - tracing::info!( - "Created service: {}", - service.metadata.name.as_ref().unwrap() - ); - } - } - } - Ok(()) - } - - #[allow(dead_code)] - async fn apply_configmaps( - &self, - client: &kube::Client, - namespace: &str, - configmap_yamls: &[String], - labels: &std::collections::HashMap, - ) -> Result<()> { - for configmap_yaml in configmap_yamls { - let mut configmap: ConfigMap = serde_yaml::from_str(configmap_yaml)?; - - // Add environment labels to configmap - self.add_labels_to_metadata(&mut configmap.metadata, labels); - - // Force namespace - configmap.metadata.namespace = Some(namespace.to_string()); - - let configmaps_api: kube::Api = - kube::Api::namespaced((*client).clone(), namespace); - - match configmaps_api - .get_opt(&configmap.metadata.name.as_ref().unwrap()) - .await? - { - Some(_) => { - configmaps_api - .replace( - &configmap.metadata.name.as_ref().unwrap(), - &Default::default(), - &configmap, - ) - .await?; - tracing::info!( - "Updated configmap: {}", - configmap.metadata.name.as_ref().unwrap() - ); - } - None => { - configmaps_api - .create(&Default::default(), &configmap) - .await?; - tracing::info!( - "Created configmap: {}", - configmap.metadata.name.as_ref().unwrap() - ); - } - } - } - Ok(()) - } - - #[allow(dead_code)] - async fn apply_secrets( - &self, - client: &kube::Client, - namespace: &str, - secret_yamls: &[String], - labels: &std::collections::HashMap, - ) -> Result<()> { - for secret_yaml in secret_yamls { - let mut secret: Secret = serde_yaml::from_str(secret_yaml)?; - - // Add environment labels to secret - self.add_labels_to_metadata(&mut secret.metadata, labels); - - // Force namespace - secret.metadata.namespace = Some(namespace.to_string()); - - let secrets_api: kube::Api = - kube::Api::namespaced((*client).clone(), namespace); - - match secrets_api - .get_opt(&secret.metadata.name.as_ref().unwrap()) - .await? - { - Some(_) => { - secrets_api - .replace( - &secret.metadata.name.as_ref().unwrap(), - &Default::default(), - &secret, - ) - .await?; - tracing::info!("Updated secret: {}", secret.metadata.name.as_ref().unwrap()); - } - None => { - secrets_api.create(&Default::default(), &secret).await?; - tracing::info!("Created secret: {}", secret.metadata.name.as_ref().unwrap()); - } - } - } - Ok(()) - } - async fn apply_single_configmap( &self, client: &kube::Client, @@ -3405,165 +1830,6 @@ impl KubeManager { } } - #[allow(dead_code)] - pub(crate) async fn get_workload_resource_details( - &self, - name: &str, - namespace: &str, - kind: &KubeWorkloadKind, - all_services: &[Service], - ) -> Result<(Vec, Vec, String)> { - let client = &self.kube_client; - - match kind { - KubeWorkloadKind::Deployment => { - let api: kube::Api = - kube::Api::namespaced((**client).clone(), namespace); - if let Ok(deployment) = api.get(name).await { - let workload_labels = deployment - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - let ports = - self.get_ports_from_matching_services(&workload_labels, all_services)?; - - if let Some(spec) = &deployment.spec { - if let Some(pod_spec) = &spec.template.spec { - let containers = self.extract_pod_resource_info(pod_spec)?; - let clean = self.clean_deployment(deployment, &containers); - let workload_yaml = serde_yaml::to_string(&clean)?; - return Ok((containers, ports, workload_yaml)); - } - } - } - } - KubeWorkloadKind::StatefulSet => { - let api: kube::Api = - kube::Api::namespaced((**client).clone(), namespace); - if let Ok(statefulset) = api.get(name).await { - let workload_labels = statefulset - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - let ports = - self.get_ports_from_matching_services(&workload_labels, all_services)?; - - if let Some(spec) = &statefulset.spec { - if let Some(pod_spec) = &spec.template.spec { - let containers = self.extract_pod_resource_info(pod_spec)?; - let clean = self.clean_statefulset(statefulset, &containers); - let workload_yaml = serde_yaml::to_string(&clean)?; - return Ok((containers, ports, workload_yaml)); - } - } - } - } - KubeWorkloadKind::DaemonSet => { - let api: kube::Api = - kube::Api::namespaced((**client).clone(), namespace); - if let Ok(daemonset) = api.get(name).await { - let workload_labels = daemonset - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - let ports = - self.get_ports_from_matching_services(&workload_labels, all_services)?; - - if let Some(spec) = &daemonset.spec { - if let Some(pod_spec) = &spec.template.spec { - let containers = self.extract_pod_resource_info(pod_spec)?; - let clean = self.clean_daemonset(daemonset, &containers); - let workload_yaml = serde_yaml::to_string(&clean)?; - return Ok((containers, ports, workload_yaml)); - } - } - } - } - KubeWorkloadKind::Pod => { - let api: kube::Api = kube::Api::namespaced((**client).clone(), namespace); - if let Ok(pod) = api.get(name).await { - let workload_labels = pod.metadata.labels.as_ref().cloned().unwrap_or_default(); - - let ports = - self.get_ports_from_matching_services(&workload_labels, all_services)?; - - if let Some(spec) = &pod.spec { - let containers = self.extract_pod_resource_info(spec)?; - let clean = self.clean_pod(pod, &containers); - let workload_yaml = serde_yaml::to_string(&clean)?; - return Ok((containers, ports, workload_yaml)); - } - } - } - KubeWorkloadKind::Job => { - let api: kube::Api = kube::Api::namespaced((**client).clone(), namespace); - if let Ok(job) = api.get(name).await { - let workload_labels = job - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - let ports = - self.get_ports_from_matching_services(&workload_labels, all_services)?; - - if let Some(spec) = &job.spec { - if let Some(pod_spec) = &spec.template.spec { - let containers = self.extract_pod_resource_info(pod_spec)?; - let clean = self.clean_job(job, &containers); - let workload_yaml = serde_yaml::to_string(&clean)?; - return Ok((containers, ports, workload_yaml)); - } - } - } - } - KubeWorkloadKind::CronJob => { - let api: kube::Api = kube::Api::namespaced((**client).clone(), namespace); - if let Ok(cronjob) = api.get(name).await { - let workload_labels = cronjob - .spec - .as_ref() - .and_then(|s| s.job_template.spec.as_ref()) - .and_then(|js| js.template.metadata.as_ref()) - .and_then(|m| m.labels.as_ref()) - .cloned() - .unwrap_or_default(); - - let ports = - self.get_ports_from_matching_services(&workload_labels, all_services)?; - - if let Some(spec) = &cronjob.spec { - if let Some(job_template) = &spec.job_template.spec { - if let Some(pod_spec) = &job_template.template.spec { - let containers = self.extract_pod_resource_info(pod_spec)?; - let clean = self.clean_cronjob(cronjob, &containers); - let workload_yaml = serde_yaml::to_string(&clean)?; - return Ok((containers, ports, workload_yaml)); - } - } - } - } - } - _ => {} - } - - Ok((Vec::new(), Vec::new(), String::new())) - } - pub(crate) async fn get_raw_workload_yaml( &self, name: &str, @@ -3614,79 +1880,6 @@ impl KubeManager { } } - #[allow(dead_code)] - fn extract_pod_resource_info( - &self, - pod_spec: &k8s_openapi::api::core::v1::PodSpec, - ) -> Result> { - pod_spec - .containers - .iter() - .map(|container| { - let mut cpu_request = None; - let mut cpu_limit = None; - let mut memory_request = None; - let mut memory_limit = None; - - if let Some(resources) = &container.resources { - // CPU and Memory requests - if let Some(requests) = &resources.requests { - if let Some(cpu_req) = requests.get("cpu") { - cpu_request = Some(cpu_req.0.clone()); - } - if let Some(memory_req) = requests.get("memory") { - memory_request = Some(memory_req.0.clone()); - } - } - - // CPU and Memory limits - if let Some(limits) = &resources.limits { - if let Some(cpu_lim) = limits.get("cpu") { - cpu_limit = Some(cpu_lim.0.clone()); - } - if let Some(memory_lim) = limits.get("memory") { - memory_limit = Some(memory_lim.0.clone()); - } - } - } - - // Error if container has no image - let image = container.image.clone().ok_or_else(|| { - anyhow!("Container '{}' has no image specified", container.name) - })?; - - // Extract container ports - let ports = container - .ports - .as_ref() - .map(|ports| { - ports - .iter() - .map(|port| KubeContainerPort { - name: port.name.clone(), - container_port: port.container_port, - protocol: port.protocol.clone(), - }) - .collect() - }) - .unwrap_or_default(); - - Ok(KubeContainerInfo { - name: container.name.clone(), - original_image: image.clone(), - image: KubeContainerImage::FollowOriginal, - cpu_request, - cpu_limit, - memory_request, - memory_limit, - env_vars: vec![], - original_env_vars: vec![], - ports, - }) - }) - .collect() - } - pub async fn get_tunnel_status(&self) -> Result { self.tunnel_manager.get_tunnel_status().await } @@ -3911,672 +2104,4 @@ mod tests { 3843684 * 1024 ); } - - #[test] - fn test_extract_configmap_references_from_env() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - containers: - - name: app - image: nginx - env: - - name: CONFIG_VALUE - valueFrom: - configMapKeyRef: - name: app-config - key: config-key -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let configmap_names = KubeManager::extract_configmap_references(&deployment_json); - assert_eq!(configmap_names, vec!["app-config"]); - } - - #[test] - fn test_extract_configmap_references_from_env_from() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - containers: - - name: app - image: nginx - envFrom: - - configMapRef: - name: app-env-config -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let configmap_names = KubeManager::extract_configmap_references(&deployment_json); - assert_eq!(configmap_names, vec!["app-env-config"]); - } - - #[test] - fn test_extract_configmap_references_from_volumes() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - volumes: - - name: config-volume - configMap: - name: volume-config - containers: - - name: app - image: nginx - volumeMounts: - - name: config-volume - mountPath: /etc/config -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let configmap_names = KubeManager::extract_configmap_references(&deployment_json); - assert_eq!(configmap_names, vec!["volume-config"]); - } - - #[test] - fn test_extract_configmap_references_from_init_containers() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - initContainers: - - name: init-app - image: busybox - env: - - name: INIT_CONFIG - valueFrom: - configMapKeyRef: - name: init-config - key: init-key - containers: - - name: app - image: nginx -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let configmap_names = KubeManager::extract_configmap_references(&deployment_json); - assert_eq!(configmap_names, vec!["init-config"]); - } - - #[test] - fn test_extract_configmap_references_from_pod_spec() { - let pod_yaml = r#" -apiVersion: v1 -kind: Pod -metadata: - name: test-pod -spec: - initContainers: - - name: init-app - image: busybox - envFrom: - - configMapRef: - name: pod-init-config - containers: - - name: app - image: nginx - env: - - name: CONFIG_VALUE - valueFrom: - configMapKeyRef: - name: pod-config - key: config-key -"#; - let pod: k8s_openapi::api::core::v1::Pod = serde_yaml::from_str(pod_yaml).unwrap(); - let pod_json = serde_json::to_value(&pod).unwrap(); - - let configmap_names = KubeManager::extract_configmap_references(&pod_json); - assert!(configmap_names.contains(&"pod-config".to_string())); - assert!(configmap_names.contains(&"pod-init-config".to_string())); - assert_eq!(configmap_names.len(), 2); - } - - #[test] - fn test_extract_configmap_references_multiple_sources() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - volumes: - - name: config-volume - configMap: - name: volume-config - initContainers: - - name: init-app - image: busybox - env: - - name: INIT_CONFIG - valueFrom: - configMapKeyRef: - name: init-config - key: init-key - volumeMounts: - - name: config-volume - mountPath: /etc/init-config - containers: - - name: app - image: nginx - env: - - name: CONFIG_VALUE - valueFrom: - configMapKeyRef: - name: app-config - key: config-key - envFrom: - - configMapRef: - name: app-env-config - volumeMounts: - - name: config-volume - mountPath: /etc/config -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let configmap_names = KubeManager::extract_configmap_references(&deployment_json); - assert!(configmap_names.contains(&"volume-config".to_string())); - assert!(configmap_names.contains(&"init-config".to_string())); - assert!(configmap_names.contains(&"app-config".to_string())); - assert!(configmap_names.contains(&"app-env-config".to_string())); - assert_eq!(configmap_names.len(), 4); - } - - #[test] - fn test_extract_configmap_references_deduplication() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - volumes: - - name: config-volume - configMap: - name: shared-config - containers: - - name: app1 - image: nginx - env: - - name: CONFIG_VALUE - valueFrom: - configMapKeyRef: - name: shared-config - key: config-key - volumeMounts: - - name: config-volume - mountPath: /etc/config1 - - name: app2 - image: nginx - envFrom: - - configMapRef: - name: shared-config - volumeMounts: - - name: config-volume - mountPath: /etc/config2 -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let configmap_names = KubeManager::extract_configmap_references(&deployment_json); - assert_eq!(configmap_names, vec!["shared-config"]); - } - - #[test] - fn test_extract_configmap_references_no_references() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - containers: - - name: app - image: nginx - env: - - name: SIMPLE_VALUE - value: "test" -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let configmap_names = KubeManager::extract_configmap_references(&deployment_json); - assert!(configmap_names.is_empty()); - } - - #[test] - fn test_extract_configmap_references_mixed_with_secrets() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - volumes: - - name: config-volume - configMap: - name: volume-config - - name: secret-volume - secret: - secretName: volume-secret - containers: - - name: app - image: nginx - env: - - name: CONFIG_VALUE - valueFrom: - configMapKeyRef: - name: app-config - key: config-key - - name: SECRET_VALUE - valueFrom: - secretKeyRef: - name: app-secret - key: secret-key - volumeMounts: - - name: config-volume - mountPath: /etc/config - - name: secret-volume - mountPath: /etc/secrets -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let configmap_names = KubeManager::extract_configmap_references(&deployment_json); - assert!(configmap_names.contains(&"volume-config".to_string())); - assert!(configmap_names.contains(&"app-config".to_string())); - assert_eq!(configmap_names.len(), 2); - // Should not contain secrets - assert!(!configmap_names.contains(&"volume-secret".to_string())); - assert!(!configmap_names.contains(&"app-secret".to_string())); - } - - #[test] - fn test_extract_secret_references_from_env() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - containers: - - name: app - image: nginx - env: - - name: SECRET_VALUE - valueFrom: - secretKeyRef: - name: app-secret - key: secret-key -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let secret_names = KubeManager::extract_secret_references(&deployment_json); - assert_eq!(secret_names, vec!["app-secret"]); - } - - #[test] - fn test_extract_secret_references_from_env_from() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - containers: - - name: app - image: nginx - envFrom: - - secretRef: - name: app-env-secret -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let secret_names = KubeManager::extract_secret_references(&deployment_json); - assert_eq!(secret_names, vec!["app-env-secret"]); - } - - #[test] - fn test_extract_secret_references_from_volumes() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - volumes: - - name: secret-volume - secret: - secretName: volume-secret - containers: - - name: app - image: nginx - volumeMounts: - - name: secret-volume - mountPath: /etc/secrets -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let secret_names = KubeManager::extract_secret_references(&deployment_json); - assert_eq!(secret_names, vec!["volume-secret"]); - } - - #[test] - fn test_extract_secret_references_from_image_pull_secrets() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - imagePullSecrets: - - name: registry-secret - containers: - - name: app - image: private-registry.com/app:latest -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let secret_names = KubeManager::extract_secret_references(&deployment_json); - assert_eq!(secret_names, vec!["registry-secret"]); - } - - #[test] - fn test_extract_secret_references_from_init_containers() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - initContainers: - - name: init-app - image: busybox - env: - - name: INIT_SECRET - valueFrom: - secretKeyRef: - name: init-secret - key: init-key - containers: - - name: app - image: nginx -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let secret_names = KubeManager::extract_secret_references(&deployment_json); - assert_eq!(secret_names, vec!["init-secret"]); - } - - #[test] - fn test_extract_secret_references_from_pod_spec() { - let pod_yaml = r#" -apiVersion: v1 -kind: Pod -metadata: - name: test-pod -spec: - imagePullSecrets: - - name: pod-registry-secret - initContainers: - - name: init-app - image: busybox - envFrom: - - secretRef: - name: pod-init-secret - containers: - - name: app - image: nginx - env: - - name: SECRET_VALUE - valueFrom: - secretKeyRef: - name: pod-secret - key: secret-key -"#; - let pod: k8s_openapi::api::core::v1::Pod = serde_yaml::from_str(pod_yaml).unwrap(); - let pod_json = serde_json::to_value(&pod).unwrap(); - - let secret_names = KubeManager::extract_secret_references(&pod_json); - assert!(secret_names.contains(&"pod-secret".to_string())); - assert!(secret_names.contains(&"pod-init-secret".to_string())); - assert!(secret_names.contains(&"pod-registry-secret".to_string())); - assert_eq!(secret_names.len(), 3); - } - - #[test] - fn test_extract_secret_references_multiple_sources() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - imagePullSecrets: - - name: registry-secret - volumes: - - name: secret-volume - secret: - secretName: volume-secret - initContainers: - - name: init-app - image: busybox - env: - - name: INIT_SECRET - valueFrom: - secretKeyRef: - name: init-secret - key: init-key - volumeMounts: - - name: secret-volume - mountPath: /etc/init-secrets - containers: - - name: app - image: nginx - env: - - name: SECRET_VALUE - valueFrom: - secretKeyRef: - name: app-secret - key: secret-key - envFrom: - - secretRef: - name: app-env-secret - volumeMounts: - - name: secret-volume - mountPath: /etc/secrets -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let secret_names = KubeManager::extract_secret_references(&deployment_json); - assert!(secret_names.contains(&"registry-secret".to_string())); - assert!(secret_names.contains(&"volume-secret".to_string())); - assert!(secret_names.contains(&"init-secret".to_string())); - assert!(secret_names.contains(&"app-secret".to_string())); - assert!(secret_names.contains(&"app-env-secret".to_string())); - assert_eq!(secret_names.len(), 5); - } - - #[test] - fn test_extract_secret_references_deduplication() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - imagePullSecrets: - - name: shared-secret - volumes: - - name: secret-volume - secret: - secretName: shared-secret - containers: - - name: app1 - image: nginx - env: - - name: SECRET_VALUE - valueFrom: - secretKeyRef: - name: shared-secret - key: secret-key - volumeMounts: - - name: secret-volume - mountPath: /etc/secrets1 - - name: app2 - image: nginx - envFrom: - - secretRef: - name: shared-secret - volumeMounts: - - name: secret-volume - mountPath: /etc/secrets2 -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let secret_names = KubeManager::extract_secret_references(&deployment_json); - assert_eq!(secret_names, vec!["shared-secret"]); - } - - #[test] - fn test_extract_secret_references_no_references() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - containers: - - name: app - image: nginx - env: - - name: SIMPLE_VALUE - value: "test" -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let secret_names = KubeManager::extract_secret_references(&deployment_json); - assert!(secret_names.is_empty()); - } - - #[test] - fn test_extract_secret_references_mixed_with_configmaps() { - let deployment_yaml = r#" -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-app -spec: - template: - spec: - volumes: - - name: config-volume - configMap: - name: volume-config - - name: secret-volume - secret: - secretName: volume-secret - containers: - - name: app - image: nginx - env: - - name: CONFIG_VALUE - valueFrom: - configMapKeyRef: - name: app-config - key: config-key - - name: SECRET_VALUE - valueFrom: - secretKeyRef: - name: app-secret - key: secret-key - volumeMounts: - - name: config-volume - mountPath: /etc/config - - name: secret-volume - mountPath: /etc/secrets -"#; - let deployment: k8s_openapi::api::apps::v1::Deployment = - serde_yaml::from_str(deployment_yaml).unwrap(); - let deployment_json = serde_json::to_value(&deployment).unwrap(); - - let secret_names = KubeManager::extract_secret_references(&deployment_json); - assert!(secret_names.contains(&"volume-secret".to_string())); - assert!(secret_names.contains(&"app-secret".to_string())); - assert_eq!(secret_names.len(), 2); - // Should not contain configmaps - assert!(!secret_names.contains(&"volume-config".to_string())); - assert!(!secret_names.contains(&"app-config".to_string())); - } } From 35df3961ebad1d6bb7a0c7e6527b413c642db32d Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 19 Oct 2025 19:51:05 +0000 Subject: [PATCH 153/334] update --- crates/api/src/kube_controller/environment.rs | 1 + crates/common/src/kube.rs | 1 + .../dashboard/src/kube_environment_detail.rs | 21 ++++++++++++------- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index 95d1e28..8584682 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -81,6 +81,7 @@ impl KubeController { catalog_sync_version, last_catalog_synced_at, catalog_update_available, + catalog_last_sync_actor_id: catalog.last_sync_actor_id, sync_status: KubeEnvironmentSyncStatus::from_str(&env.sync_status) .unwrap_or(KubeEnvironmentSyncStatus::Idle), }) diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index 76518c2..7cdbab7 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -302,6 +302,7 @@ pub struct KubeEnvironment { pub catalog_sync_version: i64, pub last_catalog_synced_at: Option, pub catalog_update_available: bool, + pub catalog_last_sync_actor_id: Option, pub sync_status: KubeEnvironmentSyncStatus, } diff --git a/crates/dashboard/src/kube_environment_detail.rs b/crates/dashboard/src/kube_environment_detail.rs index a825c47..0576ac8 100644 --- a/crates/dashboard/src/kube_environment_detail.rs +++ b/crates/dashboard/src/kube_environment_detail.rs @@ -373,6 +373,7 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { .map(|env| env.catalog_sync_version) .unwrap_or(0) }); + let env_catalog_version_for_filter = environment_catalog_version.clone(); let all_workloads = Signal::derive(move || workloads_result.get().unwrap_or_default()); let all_services = Signal::derive(move || services_result.get().unwrap_or_default()); let all_preview_urls = Signal::derive(move || preview_urls_result.get().unwrap_or_default()); @@ -380,7 +381,7 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { // Filter workloads based on search query let filtered_workloads = Signal::derive(move || { - let env_version = environment_catalog_version.get(); + let env_version = env_catalog_version_for_filter.get(); let workloads = all_workloads.get(); let search_term = debounced_search.get().to_lowercase(); @@ -498,6 +499,7 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { all_intercepts active_session update_counter + environment_catalog_version /> @@ -546,8 +548,8 @@ pub fn EnvironmentInfoCard( let cluster_id = environment.cluster_id; let cluster_name = environment.cluster_name.clone(); let catalog_update_available = environment.catalog_update_available; - let last_catalog_synced_at = environment.last_catalog_synced_at.clone(); let catalog_last_sync_actor_id = environment.catalog_last_sync_actor_id; + let last_catalog_synced_at = environment.last_catalog_synced_at.clone(); let sync_status = environment.sync_status; let last_sync_message = last_catalog_synced_at .clone() @@ -563,7 +565,6 @@ pub fn EnvironmentInfoCard( } else { "New catalog changes are ready to apply. Sync the environment to pull the latest workloads." }; - let status_variant = match env_status.as_str() { "Running" => BadgeVariant::Secondary, "Pending" => BadgeVariant::Outline, @@ -902,6 +903,7 @@ pub fn EnvironmentResourcesTabs( all_intercepts: Signal>, active_session: LocalResource>, update_counter: RwSignal, + environment_catalog_version: Signal, ) -> impl IntoView { view! { @@ -936,6 +938,7 @@ pub fn EnvironmentResourcesTabs( all_intercepts active_session update_counter + environment_catalog_version=environment_catalog_version.clone() /> @@ -969,7 +972,10 @@ pub fn EnvironmentWorkloadsContent( all_intercepts: Signal>, active_session: LocalResource>, update_counter: RwSignal, + environment_catalog_version: Signal, ) -> impl IntoView { + let env_catalog_version_signal = environment_catalog_version.clone(); + view! {
@@ -1003,7 +1009,8 @@ pub fn EnvironmentWorkloadsContent( each=move || filtered_workloads.get() key=|workload| format!("{}-{}-{}", workload.name, workload.namespace, workload.kind) children=move |workload| { - view! { } + let env_catalog_version = env_catalog_version_signal.clone(); + view! { } } /> @@ -1074,9 +1081,9 @@ pub fn EnvironmentWorkloadItem( let workload_catalog_version = workload.catalog_sync_version; let row_class = Signal::derive(move || { if env_catalog_version.get() < workload_catalog_version { - "bg-amber-50/70 dark:bg-amber-900/20 border-l-4 border-amber-500" + "bg-amber-50/70 dark:bg-amber-900/20 border-l-4 border-amber-500".to_string() } else { - "" + String::new() } }); @@ -1138,7 +1145,7 @@ pub fn EnvironmentWorkloadItem(
-
- +
+
+ + + + + {let can_pause = matches!( + env_status, + KubeEnvironmentStatus::Running + | KubeEnvironmentStatus::PauseFailed + ); + let can_resume = matches!( + env_status, + KubeEnvironmentStatus::Paused + | KubeEnvironmentStatus::PauseFailed + | KubeEnvironmentStatus::ResumeFailed, + ); + view! { + <> + + + + }.into_any()} + +
+ + + {move || { + pause_result + .get() + .and_then(|res| res.err()) + .map(|err| err.error) + .unwrap_or_default() + }} + + + + + {move || { + resume_result + .get() + .and_then(|res| res.err()) + .map(|err| err.error) + .unwrap_or_default() + }} + -
@@ -781,7 +918,7 @@ pub fn EnvironmentInfoCard(
- {env_status2} + {status_text.clone()}
diff --git a/crates/db/entities/src/kube_environment.rs b/crates/db/entities/src/kube_environment.rs index ccf095b..7b15bf0 100644 --- a/crates/db/entities/src/kube_environment.rs +++ b/crates/db/entities/src/kube_environment.rs @@ -19,6 +19,8 @@ pub struct Model { pub is_shared: bool, pub catalog_sync_version: i64, pub last_catalog_synced_at: Option, + pub paused_at: Option, + pub resumed_at: Option, pub sync_status: String, pub base_environment_id: Option, pub auth_token: String, diff --git a/crates/db/entities/src/kube_environment_workload.rs b/crates/db/entities/src/kube_environment_workload.rs index e6dc826..5e66f4e 100644 --- a/crates/db/entities/src/kube_environment_workload.rs +++ b/crates/db/entities/src/kube_environment_workload.rs @@ -15,6 +15,7 @@ pub struct Model { pub kind: String, pub containers: Json, pub ports: Json, + pub workload_yaml: String, pub catalog_sync_version: i64, } diff --git a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs index 0058eea..06264e5 100644 --- a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs +++ b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs @@ -60,6 +60,8 @@ impl MigrationTrait for Migration { ColumnDef::new(KubeEnvironment::LastCatalogSyncedAt) .timestamp_with_time_zone(), ) + .col(ColumnDef::new(KubeEnvironment::PausedAt).timestamp_with_time_zone()) + .col(ColumnDef::new(KubeEnvironment::ResumedAt).timestamp_with_time_zone()) .col( ColumnDef::new(KubeEnvironment::SyncStatus) .string() @@ -191,6 +193,8 @@ pub enum KubeEnvironment { IsShared, CatalogSyncVersion, LastCatalogSyncedAt, + PausedAt, + ResumedAt, SyncStatus, BaseEnvironmentId, AuthToken, diff --git a/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs b/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs index 9702538..c0dd4d3 100644 --- a/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs +++ b/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs @@ -58,6 +58,11 @@ impl MigrationTrait for Migration { .json() .not_null(), ) + .col( + ColumnDef::new(KubeEnvironmentWorkload::WorkloadYaml) + .text() + .not_null(), + ) .col( ColumnDef::new(KubeEnvironmentWorkload::CatalogSyncVersion) .big_integer() @@ -121,5 +126,6 @@ pub enum KubeEnvironmentWorkload { Kind, Containers, Ports, + WorkloadYaml, CatalogSyncVersion, } diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 63be933..a107813 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -54,6 +54,8 @@ struct KubeEnvironmentWithRelated { pub env_auth_token: String, pub env_catalog_sync_version: i64, pub env_last_catalog_synced_at: Option, + pub env_paused_at: Option, + pub env_resumed_at: Option, pub env_sync_status: String, // App catalog fields @@ -1336,7 +1338,10 @@ impl DbApi { Ok(workloads .into_iter() .filter_map(|w| { - // Deserialize containers from JSON, skip if invalid + if w.workload_yaml.is_empty() { + return None; + } + let containers = serde_json::from_value(w.containers.clone()).ok()?; let ports: Vec = serde_json::from_value(w.ports.clone()).unwrap_or_default(); @@ -1351,11 +1356,7 @@ impl DbApi { .unwrap_or(lapdev_common::kube::KubeWorkloadKind::Deployment), containers, ports, - workload_yaml: if w.workload_yaml.is_empty() { - None - } else { - Some(w.workload_yaml.clone()) - }, + workload_yaml: w.workload_yaml, catalog_sync_version: w.catalog_sync_version, }) }) @@ -1564,6 +1565,14 @@ impl DbApi { lapdev_db_entities::kube_environment::Column::LastCatalogSyncedAt, "env_last_catalog_synced_at", ) + .column_as( + lapdev_db_entities::kube_environment::Column::PausedAt, + "env_paused_at", + ) + .column_as( + lapdev_db_entities::kube_environment::Column::ResumedAt, + "env_resumed_at", + ) .column_as( lapdev_db_entities::kube_environment::Column::OrganizationId, "env_organization_id", @@ -1657,6 +1666,8 @@ impl DbApi { is_shared: related.env_is_shared, catalog_sync_version: related.env_catalog_sync_version, last_catalog_synced_at: related.env_last_catalog_synced_at, + paused_at: related.env_paused_at, + resumed_at: related.env_resumed_at, sync_status: related.env_sync_status.clone(), base_environment_id: related.env_base_environment_id, auth_token: related.env_auth_token, @@ -2407,6 +2418,7 @@ impl DbApi { kind: workload.kind, containers, ports, + workload_yaml: workload.workload_yaml, catalog_sync_version: workload.catalog_sync_version, }); } @@ -2447,6 +2459,7 @@ impl DbApi { kind: workload.kind, containers, ports, + workload_yaml: workload.workload_yaml, catalog_sync_version: workload.catalog_sync_version, })) } else { @@ -2469,11 +2482,13 @@ impl DbApi { &self, workload_id: Uuid, containers: Vec, + workload_yaml: String, ) -> Result { let containers_json = serde_json::to_value(containers)?; let updated_model = lapdev_db_entities::kube_environment_workload::ActiveModel { id: ActiveValue::Set(workload_id), containers: ActiveValue::Set(containers_json), + workload_yaml: ActiveValue::Set(workload_yaml), ..Default::default() } .update(&self.conn) @@ -2521,6 +2536,8 @@ impl DbApi { is_shared: ActiveValue::Set(is_shared), catalog_sync_version: ActiveValue::Set(catalog_sync_version), last_catalog_synced_at: ActiveValue::Set(Some(created_at)), + paused_at: ActiveValue::Set(None), + resumed_at: ActiveValue::Set(None), sync_status: ActiveValue::Set("idle".to_string()), base_environment_id: ActiveValue::Set(base_environment_id), auth_token: ActiveValue::Set(auth_token), @@ -2550,6 +2567,7 @@ impl DbApi { kind: ActiveValue::Set(workload.kind.to_string()), containers: ActiveValue::Set(containers_json), ports: ActiveValue::Set(ports_json), + workload_yaml: ActiveValue::Set(workload.workload_yaml.clone()), catalog_sync_version: ActiveValue::Set(catalog_sync_version), } .insert(&txn) From 883818cfdd23f92f29abe5d40e88379ac8d0d55e Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Mon, 20 Oct 2025 21:23:25 +0000 Subject: [PATCH 157/334] update --- Cargo.lock | 2 + crates/api/Cargo.toml | 1 + crates/api/src/environment_events.rs | 103 ++++++++++++++ crates/api/src/kube_controller/environment.rs | 41 ++++++ crates/api/src/kube_controller/mod.rs | 11 +- crates/api/src/lib.rs | 1 + crates/api/src/router.rs | 9 +- crates/api/src/state.rs | 44 +++++- crates/common/src/kube.rs | 2 +- .../dashboard/src/kube_environment_detail.rs | 129 +++++++++++------- 10 files changed, 288 insertions(+), 55 deletions(-) create mode 100644 crates/api/src/environment_events.rs diff --git a/Cargo.lock b/Cargo.lock index 35f4f6d..18d0f07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3190,6 +3190,7 @@ dependencies = [ "tarpc", "tokio", "tokio-rustls 0.25.0", + "tokio-stream", "tokio-util", "toml 0.8.23", "tower", @@ -7049,6 +7050,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index f96f7c3..a6c7d61 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -33,6 +33,7 @@ tower.workspace = true tower-http.workspace = true anyhow.workspace = true tokio.workspace = true +tokio-stream = { version = "0.1.15", features = ["sync"] } futures.workspace = true futures-util.workspace = true serde.workspace = true diff --git a/crates/api/src/environment_events.rs b/crates/api/src/environment_events.rs new file mode 100644 index 0000000..d07ac39 --- /dev/null +++ b/crates/api/src/environment_events.rs @@ -0,0 +1,103 @@ +use std::{convert::Infallible, str::FromStr, sync::Arc, time::Duration}; + +use axum::{ + extract::{Path, State}, + response::sse::{Event, KeepAlive, Sse}, +}; +use axum_extra::{headers, TypedHeader}; +use chrono::{DateTime, Utc}; +use futures::{stream, Stream, StreamExt}; +use lapdev_common::kube::KubeEnvironmentStatus; +use lapdev_rpc::error::ApiError; +use serde::{Deserialize, Serialize}; +use tokio_stream::wrappers::BroadcastStream; +use tracing::warn; +use uuid::Uuid; + +use crate::state::CoreState; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentLifecycleEvent { + pub organization_id: Uuid, + pub environment_id: Uuid, + pub status: KubeEnvironmentStatus, + pub paused_at: Option, + pub resumed_at: Option, + pub updated_at: DateTime, +} + +pub async fn stream_environment_events( + Path((org_id, environment_id)): Path<(Uuid, Uuid)>, + State(state): State>, + TypedHeader(cookies): TypedHeader, +) -> Result>>, ApiError> { + let user = state.authenticate(&cookies).await?; + state + .db + .get_organization_member(user.id, org_id) + .await + .map_err(|_| ApiError::Unauthorized)?; + + let environment = state + .db + .get_kube_environment(environment_id) + .await? + .ok_or_else(|| ApiError::InvalidRequest("Environment not found".to_string()))?; + + if environment.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + let status = KubeEnvironmentStatus::from_str(&environment.status) + .unwrap_or(KubeEnvironmentStatus::Pending); + let initial_event = EnvironmentLifecycleEvent { + organization_id: environment.organization_id, + environment_id, + status, + paused_at: environment.paused_at.map(|dt| dt.to_string()), + resumed_at: environment.resumed_at.map(|dt| dt.to_string()), + updated_at: Utc::now(), + }; + + let receiver = state.environment_events.subscribe(); + + let initial_stream = stream::iter( + build_sse_event(&initial_event) + .into_iter() + .map(Ok::), + ); + + let target_org = org_id; + let target_env = environment_id; + + let event_stream = BroadcastStream::new(receiver).filter_map(move |result| { + let target_org = target_org; + let target_env = target_env; + async move { + match result { + Ok(event) + if event.organization_id == target_org + && event.environment_id == target_env => + { + build_sse_event(&event).map(Ok) + } + Ok(_) => None, + Err(err) => { + warn!("environment event stream lagged: {err}"); + None + } + } + } + }); + + let stream = initial_stream.chain(event_stream); + let keep_alive = KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("keep-alive"); + + Ok(Sse::new(stream).keep_alive(keep_alive)) +} + +fn build_sse_event(event: &EnvironmentLifecycleEvent) -> Option { + Event::default().event("environment").json_data(event).ok() +} diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index b6219dd..35995de 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -20,6 +20,8 @@ use std::{ use tracing::{error, warn}; use uuid::Uuid; +use crate::environment_events::EnvironmentLifecycleEvent; + use super::{ resources::{set_cronjob_suspend, set_daemonset_paused, set_workload_replicas}, EnvironmentNamespaceKind, KubeController, @@ -1563,6 +1565,45 @@ impl KubeController { .await .map_err(ApiError::from)?; + if let Ok(Some(environment)) = self.db.get_kube_environment(environment_id).await { + let event = EnvironmentLifecycleEvent { + organization_id: environment.organization_id, + environment_id, + status: status.clone(), + paused_at: environment.paused_at.map(|dt| dt.to_string()), + resumed_at: environment.resumed_at.map(|dt| dt.to_string()), + updated_at: Utc::now(), + }; + + match serde_json::to_string(&event) { + Ok(payload) => { + if let Some(pool) = self.db.pool.clone() { + if let Err(err) = sqlx::query("SELECT pg_notify($1, $2)") + .bind("environment_lifecycle") + .bind(payload) + .execute(&pool) + .await + { + warn!( + error = %err, + "failed to publish environment lifecycle event via pg_notify" + ); + let _ = self.environment_events.send(event); + } + } else { + warn!("pg pool unavailable; broadcasting environment event locally"); + let _ = self.environment_events.send(event); + } + } + Err(err) => { + warn!( + error = %err, + "failed to serialize environment lifecycle event" + ); + } + } + } + Ok(()) } diff --git a/crates/api/src/kube_controller/mod.rs b/crates/api/src/kube_controller/mod.rs index dfd953e..caf6c18 100644 --- a/crates/api/src/kube_controller/mod.rs +++ b/crates/api/src/kube_controller/mod.rs @@ -1,7 +1,8 @@ use std::{collections::HashMap, sync::Arc}; -use tokio::sync::RwLock; +use tokio::sync::{broadcast, RwLock}; use uuid::Uuid; +use crate::environment_events::EnvironmentLifecycleEvent; use lapdev_db::api::DbApi; use lapdev_kube::server::KubeClusterServer; use lapdev_kube::tunnel::TunnelRegistry; @@ -36,14 +37,20 @@ pub struct KubeController { pub tunnel_registry: Arc, // Database API pub db: DbApi, + // Broadcast channel for environment lifecycle events + pub environment_events: broadcast::Sender, } impl KubeController { - pub fn new(db: DbApi) -> Self { + pub fn new( + db: DbApi, + environment_events: broadcast::Sender, + ) -> Self { Self { kube_cluster_servers: Arc::new(RwLock::new(HashMap::new())), tunnel_registry: Arc::new(TunnelRegistry::new()), db, + environment_events, } } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index ee2292d..96d4b12 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -5,6 +5,7 @@ pub mod cert; pub mod cli_auth; pub mod devbox; pub mod devbox_auth; +pub mod environment_events; pub mod github; pub mod gitlab; pub mod hrpc_service; diff --git a/crates/api/src/router.rs b/crates/api/src/router.rs index 4c0d9a4..fad1a16 100644 --- a/crates/api/src/router.rs +++ b/crates/api/src/router.rs @@ -1,10 +1,8 @@ use std::sync::Arc; use axum::{ - body::Body, debug_handler, - extract::{FromRequestParts, Path, Request, State, WebSocketUpgrade}, - http::{HeaderMap, Method, Uri}, + extract::{FromRequestParts, Request, State, WebSocketUpgrade}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{any, delete, get, post, put}, @@ -24,6 +22,7 @@ use crate::{ devbox_client_tunnel_websocket, devbox_intercept_tunnel_websocket, devbox_rpc_websocket, devbox_whoami, }, + environment_events, kube::{ devbox_proxy_tunnel_websocket, kube_cluster_rpc_websocket, kube_data_plane_websocket, sidecar_tunnel_websocket, @@ -142,6 +141,10 @@ fn v1_api_routes() -> Router> { "/organizations/{org_id}/quota", put(organization::update_organization_quota), ) + .route( + "/organizations/{org_id}/kube/environments/{environment_id}/events", + get(environment_events::stream_environment_events), + ) .route( "/organizations/{org_id}/projects", post(project::create_project), diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 6161cc7..f05565d 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -23,7 +23,7 @@ use pasetors::{ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use serde::Deserialize; use sqlx::postgres::PgNotification; -use tokio::sync::RwLock; +use tokio::sync::{broadcast, RwLock}; use tokio_rustls::rustls::sign::CertifiedKey; use uuid::Uuid; @@ -36,6 +36,8 @@ use crate::{ tunnel_broker::TunnelBroker, }; +use crate::environment_events::EnvironmentLifecycleEvent; + pub const TOKEN_COOKIE_NAME: &str = "token"; pub const LAPDEV_CERTS: &str = "lapdev-certs"; @@ -95,6 +97,8 @@ pub struct CoreState { pub pending_cli_auth: Arc>>, // Active devbox sessions (user_id -> DevboxSessionHandle) pub active_devbox_sessions: Arc>>, + // Lifecycle notifications for kube environments + pub environment_events: broadcast::Sender, } /// Handle for an active devbox session @@ -128,6 +132,7 @@ impl CoreState { .build(HttpConnector::new()); let db = conductor.db.clone(); + let (environment_events, _) = broadcast::channel(128); let state = Self { db: db.clone(), conductor, @@ -139,10 +144,11 @@ impl CoreState { ssh_proxy_display_port, hyper_client: Arc::new(hyper_client), static_dir: Arc::new(static_dir), - kube_controller: KubeController::new(db), + kube_controller: KubeController::new(db, environment_events.clone()), tunnel_broker: Arc::new(TunnelBroker::new()), pending_cli_auth: Arc::new(RwLock::new(HashMap::new())), active_devbox_sessions: Arc::new(RwLock::new(HashMap::new())), + environment_events, }; { @@ -161,6 +167,15 @@ impl CoreState { }); } + { + let state = state.clone(); + tokio::spawn(async move { + if let Err(e) = state.monitor_environment_events().await { + tracing::error!("api monitor environment events error: {e:#}"); + } + }); + } + state } @@ -225,6 +240,31 @@ impl CoreState { } } + async fn monitor_environment_events(&self) -> Result<()> { + let pool = self + .db + .pool + .clone() + .ok_or_else(|| anyhow!("db doesn't have pg pool"))?; + let mut listener = sqlx::postgres::PgListener::connect_with(&pool).await?; + listener.listen("environment_lifecycle").await?; + loop { + let notification = listener.recv().await?; + match serde_json::from_str::(notification.payload()) { + Ok(event) => { + let _ = self.environment_events.send(event); + } + Err(err) => { + tracing::error!( + payload = notification.payload(), + error = ?err, + "failed to deserialize environment lifecycle notification" + ); + } + } + } + } + fn cookie_token(&self, cookie: &headers::Cookie, name: &str) -> Result { let token = cookie.get(name).ok_or(ApiError::Unauthenticated)?; let untrusted_token = diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index f9edfce..9331294 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -78,8 +78,8 @@ pub enum KubeEnvironmentStatus { Error, Pausing, Paused, - Resuming, PauseFailed, + Resuming, ResumeFailed, } diff --git a/crates/dashboard/src/kube_environment_detail.rs b/crates/dashboard/src/kube_environment_detail.rs index 538b56f..17539a6 100644 --- a/crates/dashboard/src/kube_environment_detail.rs +++ b/crates/dashboard/src/kube_environment_detail.rs @@ -1,6 +1,8 @@ use std::str::FromStr; use anyhow::{anyhow, Result}; +use futures::StreamExt; +use gloo_net::eventsource::futures::EventSource; use lapdev_api_hrpc::HrpcServiceClient; use lapdev_common::{ console::Organization, @@ -13,7 +15,7 @@ use lapdev_common::{ KubeEnvironmentStatus, KubeEnvironmentSyncStatus, KubeEnvironmentWorkload, }, }; -use leptos::prelude::*; +use leptos::{prelude::*, task::spawn_local_scoped_with_cancellation}; use leptos_router::hooks::use_params_map; use uuid::Uuid; @@ -297,6 +299,7 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { let search_query = RwSignal::new(String::new()); let debounced_search = RwSignal::new(String::new()); let update_counter = RwSignal::new(0usize); + let sse_started = StoredValue::new(false); // Debounce search input (300ms delay) let search_timeout_handle: StoredValue> = @@ -394,6 +397,54 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { let active_session = LocalResource::new(move || async move { get_active_devbox_session().await.ok().flatten() }); + Effect::new(move |_| { + if sse_started.get_value() { + return; + } + + if let Some(org) = org.get() { + sse_started.set_value(true); + let org_id = org.id; + spawn_local_scoped_with_cancellation({ + async move { + let url = format!( + "/api/v1/organizations/{}/kube/environments/{}/events", + org_id, environment_id + ); + + match EventSource::new(&url) { + Ok(mut event_source) => { + match event_source.subscribe("environment") { + Ok(mut stream) => { + while let Some(event) = stream.next().await { + if event.is_ok() { + environment_result.refetch(); + update_counter.update(|c| *c += 1); + } + } + } + Err(err) => { + web_sys::console::error_1( + &format!( + "Failed to subscribe to environment events: {err}" + ) + .into(), + ); + } + } + event_source.close(); + } + Err(err) => { + web_sys::console::error_1( + &format!("Failed to connect to environment events: {err}").into(), + ); + } + } + } + }); + } + }); + let environment_info = Signal::derive(move || environment_result.get().flatten()); let environment_catalog_version = Signal::derive(move || { environment_info @@ -401,6 +452,7 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { .map(|env| env.catalog_sync_version) .unwrap_or(0) }); + let env_catalog_version_for_filter = environment_catalog_version.clone(); let all_workloads = Signal::derive(move || workloads_result.get().unwrap_or_default()); let all_services = Signal::derive(move || services_result.get().unwrap_or_default()); @@ -486,52 +538,27 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { } }); - let pause_action = { - let org = org.clone(); - let environment_result = environment_result.clone(); - let update_counter = update_counter.clone(); - Action::new_local(move |_| { - let org = org.clone(); - let environment_result = environment_result.clone(); - let update_counter = update_counter.clone(); - async move { - match pause_environment(org, environment_id).await { - Ok(_) => { - environment_result.refetch(); - update_counter.update(|c| *c += 1); - Ok(()) - } - Err(e) => Err(e), - } + let pause_action = Action::new_local(move |_| async move { + match pause_environment(org, environment_id).await { + Ok(_) => { + environment_result.refetch(); + update_counter.update(|c| *c += 1); + Ok(()) } - }) - }; + Err(e) => Err(e), + } + }); - let resume_action = { - let org = org.clone(); - let environment_result = environment_result.clone(); - let update_counter = update_counter.clone(); - Action::new_local(move |_| { - let org = org.clone(); - let environment_result = environment_result.clone(); - let update_counter = update_counter.clone(); - async move { - match resume_environment(org, environment_id).await { - Ok(_) => { - environment_result.refetch(); - update_counter.update(|c| *c += 1); - Ok(()) - } - Err(e) => Err(e), - } + let resume_action = Action::new_local(move |_| async move { + match resume_environment(org, environment_id).await { + Ok(_) => { + environment_result.refetch(); + update_counter.update(|c| *c += 1); + Ok(()) } - }) - }; - - let pause_pending = pause_action.pending(); - let resume_pending = resume_action.pending(); - let pause_result = pause_action.value(); - let resume_result = resume_action.value(); + Err(e) => Err(e), + } + }); view! {
@@ -555,6 +582,8 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { create_branch_action branch_name sync_action + pause_action + resume_action /> @@ -605,7 +634,14 @@ pub fn EnvironmentInfoCard( create_branch_action: Action<(), Result<(), ErrorResponse>>, branch_name: RwSignal, sync_action: Action<(), Result<(), ErrorResponse>>, + pause_action: Action<(), Result<(), ErrorResponse>>, + resume_action: Action<(), Result<(), ErrorResponse>>, ) -> impl IntoView { + let pause_pending = pause_action.pending(); + let resume_pending = resume_action.pending(); + let pause_result = pause_action.value(); + let resume_result = resume_action.value(); + view! {
}> { @@ -763,7 +799,7 @@ pub fn EnvironmentInfoCard( <> + {let is_deleting = matches!(env_status, KubeEnvironmentStatus::Deleting); + view! { + + }.into_any()}
From 75334117444316bdcbc76ea79ba73813c5a50c9a Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Tue, 21 Oct 2025 19:07:26 +0000 Subject: [PATCH 159/334] update --- crates/api/src/kube_controller/app_catalog.rs | 17 +- crates/api/src/kube_controller/mod.rs | 37 ++ crates/api/src/tunnel_broker.rs | 527 ++++++++++-------- crates/db/src/api.rs | 18 +- crates/kube-devbox-proxy/src/rpc_server.rs | 20 - crates/kube-manager/src/manager.rs | 18 +- crates/kube-manager/src/manager_rpc.rs | 25 +- crates/kube-manager/src/tunnel.rs | 77 +-- crates/kube-rpc/src/lib.rs | 10 +- crates/kube-sidecar-proxy/src/server.rs | 114 +++- crates/kube/src/server.rs | 56 +- crates/tunnel/src/client.rs | 165 +++++- crates/tunnel/src/server.rs | 257 ++++++--- 13 files changed, 839 insertions(+), 502 deletions(-) diff --git a/crates/api/src/kube_controller/app_catalog.rs b/crates/api/src/kube_controller/app_catalog.rs index 9e08d98..3ecf814 100644 --- a/crates/api/src/kube_controller/app_catalog.rs +++ b/crates/api/src/kube_controller/app_catalog.rs @@ -471,7 +471,12 @@ impl KubeController { self.db .delete_app_catalog(catalog_id) .await - .map_err(ApiError::from) + .map_err(ApiError::from)?; + + self.refresh_cluster_namespace_watches(catalog.cluster_id) + .await; + + Ok(()) } pub async fn delete_app_catalog_workload( @@ -511,6 +516,9 @@ impl KubeController { .await .map_err(ApiError::from)?; + self.refresh_cluster_namespace_watches(workload.cluster_id) + .await; + Ok(()) } @@ -627,6 +635,11 @@ impl KubeController { self.db .update_catalog_workload_versions(&inserted_ids, new_version) .await - .map_err(ApiError::from) + .map_err(ApiError::from)?; + + self.refresh_cluster_namespace_watches(catalog.cluster_id) + .await; + + Ok(()) } } diff --git a/crates/api/src/kube_controller/mod.rs b/crates/api/src/kube_controller/mod.rs index caf6c18..7558b30 100644 --- a/crates/api/src/kube_controller/mod.rs +++ b/crates/api/src/kube_controller/mod.rs @@ -6,6 +6,7 @@ use crate::environment_events::EnvironmentLifecycleEvent; use lapdev_db::api::DbApi; use lapdev_kube::server::KubeClusterServer; use lapdev_kube::tunnel::TunnelRegistry; +use tracing::{debug, warn}; // Submodules mod app_catalog; @@ -61,4 +62,40 @@ impl KubeController { let servers = self.kube_cluster_servers.read().await; servers.get(&cluster_id)?.last().cloned() } + + pub async fn refresh_cluster_namespace_watches(&self, cluster_id: Uuid) { + let namespaces = match self.db.get_cluster_catalog_namespaces(cluster_id).await { + Ok(namespaces) => namespaces, + Err(err) => { + warn!( + cluster_id = %cluster_id, + error = ?err, + "Failed to load namespaces for watch configuration" + ); + return; + } + }; + + let servers = self.kube_cluster_servers.read().await; + let Some(cluster_servers) = servers.get(&cluster_id) else { + debug!( + cluster_id = %cluster_id, + "No connected KubeManager instances; skipping namespace watch refresh" + ); + return; + }; + + for server in cluster_servers { + if let Err(err) = server + .send_namespace_watch_configuration(namespaces.clone()) + .await + { + warn!( + cluster_id = %cluster_id, + error = ?err, + "Failed to send namespace watch configuration to KubeManager" + ); + } + } + } } diff --git a/crates/api/src/tunnel_broker.rs b/crates/api/src/tunnel_broker.rs index 51bc86a..4826dcf 100644 --- a/crates/api/src/tunnel_broker.rs +++ b/crates/api/src/tunnel_broker.rs @@ -1,9 +1,9 @@ -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; use axum::extract::ws::WebSocket; use tokio::{ io, - sync::{oneshot, Mutex}, + sync::{mpsc, oneshot}, }; use tracing::{info, warn}; use uuid::Uuid; @@ -24,24 +24,17 @@ struct ProxyWaitingEntry { } struct PendingEndpoint { - socket: Option, - notify: Option>, + socket: WebSocket, + notify: oneshot::Sender<()>, } impl PendingEndpoint { fn new(socket: WebSocket, notify: oneshot::Sender<()>) -> Self { - Self { - socket: Some(socket), - notify: Some(notify), - } - } - - fn take_socket(&mut self) -> Option { - self.socket.take() + Self { socket, notify } } - fn take_notify(&mut self) -> Option> { - self.notify.take() + fn split(self) -> (WebSocket, oneshot::Sender<()>) { + (self.socket, self.notify) } } @@ -52,270 +45,157 @@ enum InterceptEndpointKind { } pub struct TunnelBroker { - intercept_sessions: Arc>>, - proxy_environments: Arc>>, + commands: mpsc::UnboundedSender, } -impl TunnelBroker { - pub fn new() -> Self { - Self { - intercept_sessions: Arc::new(Mutex::new(HashMap::new())), - proxy_environments: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub async fn register_devbox(&self, session_id: Uuid, socket: WebSocket) { - self.register_intercept_endpoint(session_id, InterceptEndpointKind::Devbox, socket) - .await; - } - - pub async fn register_sidecar(&self, session_id: Uuid, socket: WebSocket) { - self.register_intercept_endpoint(session_id, InterceptEndpointKind::Sidecar, socket) - .await; - } - - pub async fn register_devbox_proxy_client( - &self, +enum BrokerCommand { + RegisterIntercept { + session_id: Uuid, + kind: InterceptEndpointKind, + socket: WebSocket, + notify: oneshot::Sender<()>, + }, + RegisterProxyClient { environment_id: Uuid, session_id: Uuid, socket: WebSocket, - ) { - let (notify_tx, notify_rx) = oneshot::channel(); - let mut bridge_pair = None; + notify: oneshot::Sender<()>, + }, + RegisterProxy { + environment_id: Uuid, + socket: WebSocket, + notify: oneshot::Sender<()>, + }, +} - { - let mut environments = self.proxy_environments.lock().await; - let entry = environments.entry(environment_id).or_default(); +impl TunnelBroker { + pub fn new() -> Self { + let (commands, mut rx) = mpsc::unbounded_channel(); - if entry.devbox.is_some() { - warn!( - "Duplicate devbox proxy client endpoint for environment {} (session {})", - environment_id, session_id - ); - let _ = notify_tx.send(()); - return; + tokio::spawn(async move { + let mut intercept_sessions: HashMap = HashMap::new(); + let mut proxy_environments: HashMap = HashMap::new(); + + while let Some(command) = rx.recv().await { + match command { + BrokerCommand::RegisterIntercept { + session_id, + kind, + socket, + notify, + } => Self::handle_register_intercept( + &mut intercept_sessions, + session_id, + kind, + socket, + notify, + ), + BrokerCommand::RegisterProxyClient { + environment_id, + session_id, + socket, + notify, + } => Self::handle_register_proxy_client( + &mut proxy_environments, + environment_id, + session_id, + socket, + notify, + ), + BrokerCommand::RegisterProxy { + environment_id, + socket, + notify, + } => Self::handle_register_proxy( + &mut proxy_environments, + environment_id, + socket, + notify, + ), + } } + }); - entry.session_id = Some(session_id); - entry.devbox = Some(PendingEndpoint::new(socket, notify_tx)); - - if entry.proxy.is_some() { - let mut entry = environments.remove(&environment_id).unwrap_or_default(); - let mut devbox = entry.devbox.take().unwrap(); - let mut proxy = entry.proxy.take().unwrap(); - let devbox_socket = devbox.take_socket().unwrap(); - let proxy_socket = proxy.take_socket().unwrap(); - let devbox_notify = devbox.take_notify(); - let proxy_notify = proxy.take_notify(); - let session_id = entry.session_id.unwrap_or_else(|| { - warn!( - "Missing session id while bridging environment {}; defaulting to {}", - environment_id, session_id - ); - session_id - }); - bridge_pair = Some(( - environment_id, - session_id, - devbox_socket, - devbox_notify, - proxy_socket, - proxy_notify, - )); - } - } + Self { commands } + } - if let Some(( - environment_id, - session_id, - devbox_socket, - devbox_notify, - proxy_socket, - proxy_notify, - )) = bridge_pair - { - self.spawn_proxy_bridge( - environment_id, + pub async fn register_devbox(&self, session_id: Uuid, socket: WebSocket) { + let (notify_tx, notify_rx) = oneshot::channel(); + if self + .commands + .send(BrokerCommand::RegisterIntercept { session_id, - devbox_socket, - devbox_notify, - proxy_socket, - proxy_notify, - ); + kind: InterceptEndpointKind::Devbox, + socket, + notify: notify_tx, + }) + .is_err() + { + warn!("Tunnel broker command channel closed while registering devbox endpoint"); + return; } - let _ = notify_rx.await; } - pub async fn register_devbox_proxy(&self, environment_id: Uuid, socket: WebSocket) { + pub async fn register_sidecar(&self, session_id: Uuid, socket: WebSocket) { let (notify_tx, notify_rx) = oneshot::channel(); - let mut bridge_pair = None; - - { - let mut environments = self.proxy_environments.lock().await; - let entry = environments.entry(environment_id).or_default(); - - if entry.proxy.is_some() { - warn!( - "Duplicate devbox proxy endpoint for environment {}", - environment_id - ); - let _ = notify_tx.send(()); - return; - } - - entry.proxy = Some(PendingEndpoint::new(socket, notify_tx)); - - if entry.devbox.is_some() { - let mut entry = environments.remove(&environment_id).unwrap_or_default(); - let mut devbox = entry.devbox.take().unwrap(); - let mut proxy = entry.proxy.take().unwrap(); - let devbox_socket = devbox.take_socket().unwrap(); - let proxy_socket = proxy.take_socket().unwrap(); - let devbox_notify = devbox.take_notify(); - let proxy_notify = proxy.take_notify(); - let session_id = entry.session_id.unwrap_or_else(|| { - warn!( - "Missing session id while bridging environment {}; using zero UUID", - environment_id - ); - Uuid::nil() - }); - bridge_pair = Some(( - environment_id, - session_id, - devbox_socket, - devbox_notify, - proxy_socket, - proxy_notify, - )); - } - } - - if let Some(( - environment_id, - session_id, - devbox_socket, - devbox_notify, - proxy_socket, - proxy_notify, - )) = bridge_pair - { - self.spawn_proxy_bridge( - environment_id, + if self + .commands + .send(BrokerCommand::RegisterIntercept { session_id, - devbox_socket, - devbox_notify, - proxy_socket, - proxy_notify, - ); + kind: InterceptEndpointKind::Sidecar, + socket, + notify: notify_tx, + }) + .is_err() + { + warn!("Tunnel broker command channel closed while registering sidecar endpoint"); + return; } - let _ = notify_rx.await; } - async fn register_intercept_endpoint( + pub async fn register_devbox_proxy_client( &self, + environment_id: Uuid, session_id: Uuid, - kind: InterceptEndpointKind, socket: WebSocket, ) { let (notify_tx, notify_rx) = oneshot::channel(); - let mut bridge_pair = None; - + if self + .commands + .send(BrokerCommand::RegisterProxyClient { + environment_id, + session_id, + socket, + notify: notify_tx, + }) + .is_err() { - let mut sessions = self.intercept_sessions.lock().await; - let entry = sessions.entry(session_id).or_default(); - - match kind { - InterceptEndpointKind::Devbox => { - if entry.devbox.is_some() { - warn!( - "Duplicate devbox endpoint for session {} - cleaning up old connection", - session_id - ); - // Remove the old stale connection and replace with new one - entry.devbox = None; - } - entry.devbox = Some(PendingEndpoint::new(socket, notify_tx)); - } - InterceptEndpointKind::Sidecar => { - if entry.sidecar.is_some() { - warn!("Duplicate sidecar endpoint for session {} - cleaning up old connection", session_id); - // Remove the old stale connection and replace with new one - entry.sidecar = None; - } - entry.sidecar = Some(PendingEndpoint::new(socket, notify_tx)); - } - } - - if entry.devbox.is_some() && entry.sidecar.is_some() { - let mut entry = sessions.remove(&session_id).unwrap_or_default(); - let mut devbox = entry.devbox.take().unwrap(); - let mut sidecar = entry.sidecar.take().unwrap(); - let devbox_socket = devbox.take_socket().unwrap(); - let sidecar_socket = sidecar.take_socket().unwrap(); - let devbox_notify = devbox.take_notify(); - let sidecar_notify = sidecar.take_notify(); - bridge_pair = Some(( - session_id, - devbox_socket, - devbox_notify, - sidecar_socket, - sidecar_notify, - )); - } + warn!("Tunnel broker command channel closed while registering proxy client endpoint"); + return; } + let _ = notify_rx.await; + } - if let Some((session_id, devbox_socket, devbox_notify, sidecar_socket, sidecar_notify)) = - bridge_pair + pub async fn register_devbox_proxy(&self, environment_id: Uuid, socket: WebSocket) { + let (notify_tx, notify_rx) = oneshot::channel(); + if self + .commands + .send(BrokerCommand::RegisterProxy { + environment_id, + socket, + notify: notify_tx, + }) + .is_err() { - self.spawn_intercept_bridge( - session_id, - devbox_socket, - devbox_notify, - sidecar_socket, - sidecar_notify, - ); + warn!("Tunnel broker command channel closed while registering proxy endpoint"); + return; } - - // Wait for notification - either paired successfully or this connection closes let _ = notify_rx.await; - - // Cleanup if we were waiting and the connection closed - // This handles the case where one endpoint connects but the other never does - // and the WebSocket times out or disconnects - let mut sessions = self.intercept_sessions.lock().await; - if let Some(entry) = sessions.get_mut(&session_id) { - match kind { - InterceptEndpointKind::Devbox => { - if entry.devbox.is_some() { - info!( - "Cleaning up waiting devbox endpoint for session {}", - session_id - ); - entry.devbox = None; - } - } - InterceptEndpointKind::Sidecar => { - if entry.sidecar.is_some() { - info!( - "Cleaning up waiting sidecar endpoint for session {}", - session_id - ); - entry.sidecar = None; - } - } - } - // Remove the entire entry if both endpoints are now None - if entry.devbox.is_none() && entry.sidecar.is_none() { - sessions.remove(&session_id); - } - } } fn spawn_intercept_bridge( - &self, session_id: Uuid, devbox_socket: WebSocket, devbox_notify: Option>, @@ -349,7 +229,6 @@ impl TunnelBroker { } fn spawn_proxy_bridge( - &self, environment_id: Uuid, session_id: Uuid, devbox_socket: WebSocket, @@ -388,4 +267,164 @@ impl TunnelBroker { } }); } + + fn handle_register_intercept( + sessions: &mut HashMap, + session_id: Uuid, + kind: InterceptEndpointKind, + socket: WebSocket, + notify: oneshot::Sender<()>, + ) { + let entry = sessions.entry(session_id).or_default(); + match kind { + InterceptEndpointKind::Devbox => { + if entry + .devbox + .replace(PendingEndpoint::new(socket, notify)) + .is_some() + { + warn!( + "Duplicate devbox endpoint for session {} - replacing stale connection", + session_id + ); + } + } + InterceptEndpointKind::Sidecar => { + if entry + .sidecar + .replace(PendingEndpoint::new(socket, notify)) + .is_some() + { + warn!( + "Duplicate sidecar endpoint for session {} - replacing stale connection", + session_id + ); + } + } + } + + let ready = entry.devbox.is_some() && entry.sidecar.is_some(); + if !ready { + return; + } + + if let Some(entry) = sessions.remove(&session_id) { + let InterceptWaitingEntry { devbox, sidecar } = entry; + if let (Some(devbox), Some(sidecar)) = (devbox, sidecar) { + let (devbox_socket, devbox_notify) = devbox.split(); + let (sidecar_socket, sidecar_notify) = sidecar.split(); + Self::spawn_intercept_bridge( + session_id, + devbox_socket, + Some(devbox_notify), + sidecar_socket, + Some(sidecar_notify), + ); + } + } + } + + fn handle_register_proxy_client( + environments: &mut HashMap, + environment_id: Uuid, + session_id: Uuid, + socket: WebSocket, + notify: oneshot::Sender<()>, + ) { + let entry = environments.entry(environment_id).or_default(); + entry.session_id = Some(session_id); + if entry + .devbox + .replace(PendingEndpoint::new(socket, notify)) + .is_some() + { + warn!( + "Duplicate devbox proxy client endpoint for environment {} (session {})", + environment_id, session_id + ); + } + + if entry.proxy.is_none() { + return; + } + + if let Some(entry) = environments.remove(&environment_id) { + let ProxyWaitingEntry { + devbox, + proxy, + session_id: stored_session, + } = entry; + + if let (Some(devbox), Some(proxy)) = (devbox, proxy) { + let (devbox_socket, devbox_notify) = devbox.split(); + let (proxy_socket, proxy_notify) = proxy.split(); + let session_id = stored_session.unwrap_or_else(|| { + warn!( + "Missing session id while bridging environment {}; defaulting to {}", + environment_id, session_id + ); + session_id + }); + Self::spawn_proxy_bridge( + environment_id, + session_id, + devbox_socket, + Some(devbox_notify), + proxy_socket, + Some(proxy_notify), + ); + } + } + } + + fn handle_register_proxy( + environments: &mut HashMap, + environment_id: Uuid, + socket: WebSocket, + notify: oneshot::Sender<()>, + ) { + let entry = environments.entry(environment_id).or_default(); + if entry + .proxy + .replace(PendingEndpoint::new(socket, notify)) + .is_some() + { + warn!( + "Duplicate devbox proxy endpoint for environment {}", + environment_id + ); + } + + if entry.devbox.is_none() { + return; + } + + if let Some(entry) = environments.remove(&environment_id) { + let ProxyWaitingEntry { + devbox, + proxy, + session_id, + } = entry; + + if let (Some(devbox), Some(proxy)) = (devbox, proxy) { + let (devbox_socket, devbox_notify) = devbox.split(); + let (proxy_socket, proxy_notify) = proxy.split(); + let session_id = session_id.unwrap_or_else(|| { + warn!( + "Missing session id while bridging environment {}; using zero UUID", + environment_id + ); + Uuid::nil() + }); + Self::spawn_proxy_bridge( + environment_id, + session_id, + devbox_socket, + Some(devbox_notify), + proxy_socket, + Some(proxy_notify), + ); + } + } + } } diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index a107813..0f8654a 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -11,8 +11,8 @@ use lapdev_common::{ LAPDEV_ISOLATE_CONTAINER, }; use lapdev_db_entities::{ - kube_app_catalog_workload_dependency, kube_app_catalog_workload_label, kube_cluster_service, - kube_cluster_service_selector, + kube_app_catalog_workload, kube_app_catalog_workload_dependency, + kube_app_catalog_workload_label, kube_cluster_service, kube_cluster_service_selector, }; use lapdev_db_migration::Migrator; use pasetors::{ @@ -1375,6 +1375,20 @@ impl DbApi { Ok(workload) } + pub async fn get_cluster_catalog_namespaces(&self, cluster_id: Uuid) -> Result> { + let namespaces = kube_app_catalog_workload::Entity::find() + .filter(kube_app_catalog_workload::Column::ClusterId.eq(cluster_id)) + .filter(kube_app_catalog_workload::Column::DeletedAt.is_null()) + .select_only() + .column(kube_app_catalog_workload::Column::Namespace) + .distinct() + .into_tuple::() + .all(&self.conn) + .await?; + + Ok(namespaces) + } + pub async fn delete_app_catalog_workload(&self, workload_id: Uuid) -> Result<()> { lapdev_db_entities::kube_app_catalog_workload::ActiveModel { id: ActiveValue::Set(workload_id), diff --git a/crates/kube-devbox-proxy/src/rpc_server.rs b/crates/kube-devbox-proxy/src/rpc_server.rs index e846470..4c5ad82 100644 --- a/crates/kube-devbox-proxy/src/rpc_server.rs +++ b/crates/kube-devbox-proxy/src/rpc_server.rs @@ -191,26 +191,6 @@ impl DevboxProxyRpc for DevboxProxyRpcServer { } } } - - async fn get_tunnel_status( - self, - _context: ::tarpc::context::Context, - environment_id: Option, - ) -> Result { - match environment_id { - None => { - // Get base tunnel status for personal environment - let task_lock = self.base_tunnel_task.read().await; - Ok(task_lock.is_some()) - } - Some(branch_env_id) => { - // Get branch tunnel status for shared environment - self.branch_config - .get_branch_tunnel_status(branch_env_id) - .await - } - } - } } impl DevboxProxyRpcServer { diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index 014a0e1..cf5ae86 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -23,7 +23,7 @@ use lapdev_common::kube::{ }; use lapdev_kube_rpc::{ KubeClusterRpcClient, KubeManagerRpc, KubeWorkloadYamlOnly, KubeWorkloadsWithResources, - NamespacedResourceRequest, NamespacedResourceResponse, TunnelStatus, + NamespacedResourceRequest, NamespacedResourceResponse, }; use lapdev_rpc::spawn_twoway; use serde::Deserialize; @@ -398,6 +398,14 @@ impl KubeManager { } } + pub(crate) async fn configure_namespace_watches(&self, namespaces: Vec) -> Result<()> { + tracing::info!( + namespace_count = namespaces.len(), + "Configuring namespace watches" + ); + self.watch_manager.configure_watches(namespaces).await + } + fn parse_memory_resource( memory_quantity: Option<&k8s_openapi::apimachinery::pkg::api::resource::Quantity>, ) -> i64 { @@ -1880,14 +1888,6 @@ impl KubeManager { } } - pub async fn get_tunnel_status(&self) -> Result { - self.tunnel_manager.get_tunnel_status().await - } - - pub async fn close_tunnel_connection(&self, tunnel_id: String) -> Result<()> { - self.tunnel_manager.close_tunnel_connection(tunnel_id).await - } - pub async fn refresh_branch_service_routes(&self, environment_id: Uuid) -> Result<()> { self.proxy_manager .set_service_routes_if_registered(environment_id) diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index c4c5de0..ec82a8f 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -4,7 +4,7 @@ use lapdev_common::kube::{ }; use lapdev_kube_rpc::{ KubeClusterRpcClient, KubeManagerRpc, KubeRawWorkloadYaml, KubeWorkloadsWithResources, - NamespacedResourceRequest, NamespacedResourceResponse, TunnelStatus, WorkloadIdentifier, + NamespacedResourceRequest, NamespacedResourceResponse, WorkloadIdentifier, }; use uuid::Uuid; @@ -248,27 +248,18 @@ impl KubeManagerRpc for KubeManagerRpcServer { } } - async fn get_tunnel_status( + async fn configure_watches( self, _context: ::tarpc::context::Context, - ) -> Result { - self.manager - .get_tunnel_status() - .await - .map_err(|e| format!("Failed to get tunnel status: {}", e)) - } - - async fn close_tunnel_connection( - self, - _context: ::tarpc::context::Context, - tunnel_id: String, + namespaces: Vec, ) -> Result<(), String> { - tracing::info!("Closing tunnel connection: {}", tunnel_id); - self.manager - .close_tunnel_connection(tunnel_id) + .configure_namespace_watches(namespaces) .await - .map_err(|e| format!("Failed to close tunnel connection: {}", e)) + .map_err(|e| { + tracing::error!("Failed to configure namespace watches: {e}"); + format!("Failed to configure namespace watches: {e}") + }) } async fn add_branch_environment( diff --git a/crates/kube-manager/src/tunnel.rs b/crates/kube-manager/src/tunnel.rs index 5ce2de7..12fadb1 100644 --- a/crates/kube-manager/src/tunnel.rs +++ b/crates/kube-manager/src/tunnel.rs @@ -1,86 +1,15 @@ -use std::sync::Arc; - -use anyhow::{anyhow, Result}; -use lapdev_kube_rpc::TunnelStatus; +use anyhow::Result; use lapdev_tunnel::{run_tunnel_server, WebSocketTransport as TunnelWebSocketTransport}; -/// Tunnel client for managing data plane WebSocket connection -#[derive(Debug)] -pub struct TunnelClient { - pub tunnel_id: String, - pub websocket_endpoint: String, - pub connected_at: chrono::DateTime, - pub is_connected: bool, - pub active_connections: u32, - pub total_connections: u64, - pub bytes_transferred: u64, - // Data plane WebSocket connection for tunnel messages - pub data_plane_websocket: Option< - Arc< - tokio::sync::Mutex< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - >, - >, - >, -} -/// Tunnel manager for handling WebSocket tunnel connections +/// Manages the lifecycle of the background tunnel connection used for preview URLs. #[derive(Clone)] pub struct TunnelManager { tunnel_request: tokio_tungstenite::tungstenite::http::Request<()>, - tunnel_client: Arc>>, } impl TunnelManager { pub fn new(tunnel_request: tokio_tungstenite::tungstenite::http::Request<()>) -> Self { - Self { - tunnel_request, - tunnel_client: Arc::new(tokio::sync::RwLock::new(None)), - } - } - - /// Get current tunnel status - pub async fn get_tunnel_status(&self) -> Result { - let client_guard = self.tunnel_client.read().await; - - match client_guard.as_ref() { - Some(client) => Ok(TunnelStatus { - tunnel_id: Some(client.tunnel_id.clone()), - is_connected: client.is_connected, - connected_at: Some(client.connected_at), - last_heartbeat: Some(chrono::Utc::now()), - active_connections: client.active_connections, - total_connections: client.total_connections, - bytes_transferred: client.bytes_transferred, - }), - None => Ok(TunnelStatus { - tunnel_id: None, - is_connected: false, - connected_at: None, - last_heartbeat: None, - active_connections: 0, - total_connections: 0, - bytes_transferred: 0, - }), - } - } - - /// Close tunnel connection - pub async fn close_tunnel_connection(&self, tunnel_id: String) -> Result<()> { - tracing::info!("Closing tunnel connection: {}", tunnel_id); - - let mut client_guard = self.tunnel_client.write().await; - - match client_guard.as_mut() { - Some(client) if client.tunnel_id == tunnel_id => { - client.is_connected = false; - tracing::info!("Tunnel connection closed: {}", tunnel_id); - Ok(()) - } - Some(_) => Err(anyhow!("Tunnel ID mismatch")), - None => Err(anyhow!("No tunnel connection found")), - } + Self { tunnel_request } } /// Start the tunnel manager connection cycle diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index 21231b7..f9a27b9 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -416,10 +416,7 @@ pub trait KubeManagerRpc { async fn destroy_environment(environment_id: Uuid, namespace: String) -> Result<(), String>; - // Preview URL tunnel methods - async fn get_tunnel_status() -> Result; - - async fn close_tunnel_connection(tunnel_id: String) -> Result<(), String>; + async fn configure_watches(namespaces: Vec) -> Result<(), String>; // Devbox-proxy management methods async fn add_branch_environment( @@ -542,11 +539,6 @@ pub trait DevboxProxyRpc { /// - Personal environment: pass None to stop the base tunnel /// - Shared environment: pass Some(branch_environment_id) to stop a branch tunnel async fn stop_tunnel(environment_id: Option) -> Result<(), String>; - - /// Get tunnel status - /// - Personal environment: pass None to check base tunnel status - /// - Shared environment: pass Some(branch_environment_id) to check branch tunnel status - async fn get_tunnel_status(environment_id: Option) -> Result; } #[tarpc::service] diff --git a/crates/kube-sidecar-proxy/src/server.rs b/crates/kube-sidecar-proxy/src/server.rs index 5e977fd..e98af72 100644 --- a/crates/kube-sidecar-proxy/src/server.rs +++ b/crates/kube-sidecar-proxy/src/server.rs @@ -20,7 +20,7 @@ use tarpc::server::{BaseChannel, Channel}; use tokio::{ io::copy_bidirectional, net::{TcpListener, TcpStream}, - sync::{Mutex, RwLock}, + sync::{mpsc, oneshot, RwLock}, }; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -479,15 +479,61 @@ async fn check_devbox_tunnel_route( None } +#[derive(Clone)] struct DevboxTunnelManager { - clients: Mutex>>, + commands: mpsc::UnboundedSender, +} + +#[derive(Debug)] +enum ManagerCommand { + GetClient { + session_id: Uuid, + respond: oneshot::Sender>>, + }, + InsertClient { + session_id: Uuid, + client: Arc, + respond: oneshot::Sender>>, + }, + RemoveClient { + session_id: Uuid, + }, } impl DevboxTunnelManager { fn new() -> Self { - Self { - clients: Mutex::new(HashMap::new()), - } + let (commands, mut rx) = mpsc::unbounded_channel(); + + tokio::spawn(async move { + let mut clients: HashMap> = HashMap::new(); + while let Some(command) = rx.recv().await { + match command { + ManagerCommand::GetClient { + session_id, + respond, + } => { + let _ = respond.send(clients.get(&session_id).cloned()); + } + ManagerCommand::InsertClient { + session_id, + client, + respond, + } => { + if let Some(existing) = clients.get(&session_id) { + let _ = respond.send(Some(existing.clone())); + } else { + clients.insert(session_id, client); + let _ = respond.send(None); + } + } + ManagerCommand::RemoveClient { session_id } => { + clients.remove(&session_id); + } + } + } + }); + + Self { commands } } async fn connect_tcp_stream( @@ -526,19 +572,15 @@ impl DevboxTunnelManager { websocket_url: &str, auth_token: &str, ) -> std::result::Result, TunnelError> { - if let Some(existing) = self.clients.lock().await.get(&session_id) { - return Ok(existing.clone()); + if let Some(existing) = self.get_client(session_id).await { + return Ok(existing); } let new_client = Arc::new(self.create_client(websocket_url, auth_token).await?); - let mut clients = self.clients.lock().await; - match clients.entry(session_id) { - std::collections::hash_map::Entry::Occupied(entry) => Ok(entry.get().clone()), - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert(new_client.clone()); - Ok(new_client) - } + match self.insert_client(session_id, new_client.clone()).await { + Some(existing) => Ok(existing), + None => Ok(new_client), } } @@ -565,8 +607,48 @@ impl DevboxTunnelManager { } async fn remove_client(&self, session_id: Uuid) { - let mut clients = self.clients.lock().await; - clients.remove(&session_id); + self.send_command(ManagerCommand::RemoveClient { session_id }); + } + + async fn get_client(&self, session_id: Uuid) -> Option> { + let (respond, receiver) = oneshot::channel(); + if self + .commands + .send(ManagerCommand::GetClient { + session_id, + respond, + }) + .is_err() + { + return None; + } + receiver.await.ok().flatten() + } + + async fn insert_client( + &self, + session_id: Uuid, + client: Arc, + ) -> Option> { + let (respond, receiver) = oneshot::channel(); + if self + .commands + .send(ManagerCommand::InsertClient { + session_id, + client, + respond, + }) + .is_err() + { + return None; + } + receiver.await.ok().flatten() + } + + fn send_command(&self, command: ManagerCommand) { + if self.commands.send(command).is_err() { + debug!("Devbox tunnel manager command channel closed"); + } } } diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index 3d8c86e..57f16a9 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -59,15 +59,25 @@ impl KubeClusterServer { } pub async fn register(&self) { - let mut servers = self.kube_cluster_servers.write().await; - servers - .entry(self.cluster_id) - .or_insert_with(Vec::new) - .push(self.clone()); + { + let mut servers = self.kube_cluster_servers.write().await; + servers + .entry(self.cluster_id) + .or_insert_with(Vec::new) + .push(self.clone()); + } tracing::info!( "Registered KubeClusterServer for cluster {}", self.cluster_id ); + + if let Err(err) = self.sync_namespace_watches_from_db().await { + tracing::warn!( + cluster_id = %self.cluster_id, + error = ?err, + "Failed to send initial namespace watch configuration" + ); + } } pub async fn unregister(&self) { @@ -90,6 +100,42 @@ impl KubeClusterServer { } } } + + pub async fn send_namespace_watch_configuration( + &self, + namespaces: Vec, + ) -> AnyResult<()> { + let namespace_count = namespaces.len(); + tracing::info!( + cluster_id = %self.cluster_id, + namespace_count, + "Sending namespace watch configuration to KubeManager" + ); + + match self + .rpc_client + .configure_watches(tarpc::context::current(), namespaces) + .await + { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => Err(anyhow!( + "KubeManager rejected namespace watch configuration: {}", + err + )), + Err(err) => Err(anyhow!( + "Failed to send namespace watch configuration RPC: {}", + err + )), + } + } + + pub async fn sync_namespace_watches_from_db(&self) -> AnyResult<()> { + let namespaces = self + .db + .get_cluster_catalog_namespaces(self.cluster_id) + .await?; + self.send_namespace_watch_configuration(namespaces).await + } } impl KubeClusterRpc for KubeClusterServer { diff --git a/crates/tunnel/src/client.rs b/crates/tunnel/src/client.rs index 8ada5b1..e14d8bf 100644 --- a/crates/tunnel/src/client.rs +++ b/crates/tunnel/src/client.rs @@ -10,7 +10,7 @@ use bytes::{Bytes, BytesMut}; use futures::{SinkExt, StreamExt}; use tokio::{ io::{self, AsyncRead, AsyncWrite}, - sync::{mpsc, oneshot, Mutex, Notify}, + sync::{mpsc, oneshot, Notify}, task::JoinHandle, }; use tokio_util::codec::{Framed, LengthDelimitedCodec}; @@ -23,19 +23,89 @@ use crate::{ util::spawn_detached, }; +enum InnerCommand { + InsertPending { + tunnel_id: String, + sender: oneshot::Sender>, + }, + ResolvePending { + tunnel_id: String, + result: Result<(), TunnelError>, + }, + RegisterStream { + tunnel_id: String, + sender: mpsc::UnboundedSender, + }, + ForwardData { + tunnel_id: String, + payload: Vec, + }, + CloseStream { + tunnel_id: String, + }, + FailAll { + reason: String, + }, +} + struct Inner { send: mpsc::UnboundedSender, - pending: Mutex>>>, - streams: Mutex>>, + command_tx: mpsc::UnboundedSender, } impl Inner { fn new(send: mpsc::UnboundedSender) -> Self { - Self { - send, - pending: Mutex::new(HashMap::new()), - streams: Mutex::new(HashMap::new()), - } + let (command_tx, mut command_rx) = mpsc::unbounded_channel(); + + tokio::spawn(async move { + let mut pending: HashMap>> = + HashMap::new(); + let mut streams: HashMap> = HashMap::new(); + + while let Some(command) = command_rx.recv().await { + match command { + InnerCommand::InsertPending { tunnel_id, sender } => { + pending.insert(tunnel_id, sender); + } + InnerCommand::ResolvePending { tunnel_id, result } => { + if let Some(sender) = pending.remove(&tunnel_id) { + let _ = sender.send(result); + } + } + InnerCommand::RegisterStream { tunnel_id, sender } => { + streams.insert(tunnel_id, sender); + } + InnerCommand::ForwardData { tunnel_id, payload } => { + match streams.get(&tunnel_id) { + Some(sender) => { + if sender.send(Bytes::from(payload)).is_err() { + debug!("Failed to forward data for tunnel {}", tunnel_id); + } + } + None => { + debug!("Received data for unknown tunnel {}", tunnel_id); + } + } + } + InnerCommand::CloseStream { tunnel_id } => { + streams.remove(&tunnel_id); + } + InnerCommand::FailAll { reason } => { + for (_, sender) in pending.drain() { + let _ = sender.send(Err(TunnelError::Remote(reason.clone()))); + } + streams.clear(); + } + } + } + + for (_, sender) in pending.drain() { + let _ = sender.send(Err(TunnelError::ConnectionClosed)); + } + streams.clear(); + }); + + Self { send, command_tx } } fn send_message(&self, message: WireMessage) -> Result<(), TunnelError> { @@ -49,44 +119,83 @@ impl Inner { tunnel_id: String, tx: oneshot::Sender>, ) { - self.pending.lock().await.insert(tunnel_id, tx); + if let Err(err) = self.command_tx.send(InnerCommand::InsertPending { + tunnel_id, + sender: tx, + }) { + if let InnerCommand::InsertPending { sender, .. } = err.0 { + let _ = sender.send(Err(TunnelError::ConnectionClosed)); + } + } } async fn resolve_pending(&self, tunnel_id: &str, result: Result<(), TunnelError>) { - if let Some(sender) = self.pending.lock().await.remove(tunnel_id) { - let _ = sender.send(result); + if self + .command_tx + .send(InnerCommand::ResolvePending { + tunnel_id: tunnel_id.to_string(), + result, + }) + .is_err() + { + debug!( + "Failed to resolve pending tunnel {}; manager dropped", + tunnel_id + ); } } async fn register_stream(&self, tunnel_id: String, tx: mpsc::UnboundedSender) { - self.streams.lock().await.insert(tunnel_id, tx); + if self + .command_tx + .send(InnerCommand::RegisterStream { + tunnel_id, + sender: tx, + }) + .is_err() + { + debug!("Failed to register stream; manager dropped"); + } } async fn forward_data(&self, tunnel_id: &str, payload: Vec) { - let sender = self.streams.lock().await.get(tunnel_id).cloned(); - if let Some(sender) = sender { - if sender.send(Bytes::from(payload)).is_err() { - debug!("Failed to forward data for tunnel {}", tunnel_id); - } - } else { - debug!("Received data for unknown tunnel {}", tunnel_id); + if self + .command_tx + .send(InnerCommand::ForwardData { + tunnel_id: tunnel_id.to_string(), + payload, + }) + .is_err() + { + debug!( + "Failed to enqueue data for tunnel {}; manager dropped", + tunnel_id + ); } } async fn close_stream(&self, tunnel_id: &str) { - self.streams.lock().await.remove(tunnel_id); + if self + .command_tx + .send(InnerCommand::CloseStream { + tunnel_id: tunnel_id.to_string(), + }) + .is_err() + { + debug!("Failed to close stream {}; manager dropped", tunnel_id); + } } async fn fail_all(&self, reason: impl Into) { - let reason = reason.into(); - - let mut pending = self.pending.lock().await; - for (_, sender) in pending.drain() { - let _ = sender.send(Err(TunnelError::Remote(reason.clone()))); + if self + .command_tx + .send(InnerCommand::FailAll { + reason: reason.into(), + }) + .is_err() + { + debug!("Failed to fail pending tunnels; manager dropped"); } - drop(pending); - - self.streams.lock().await.clear(); } } diff --git a/crates/tunnel/src/server.rs b/crates/tunnel/src/server.rs index 5c75160..c4fe6de 100644 --- a/crates/tunnel/src/server.rs +++ b/crates/tunnel/src/server.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; use bytes::Bytes; use futures::{SinkExt, StreamExt}; @@ -6,7 +6,7 @@ use serde_json; use tokio::{ io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, net::TcpStream, - sync::{mpsc, watch, Mutex}, + sync::{mpsc, watch}, }; use tokio_util::codec::{Framed, LengthDelimitedCodec}; use tracing::{debug, error, warn}; @@ -17,14 +17,160 @@ use crate::{ util::spawn_detached, }; -type Connections = Arc>>; - #[derive(Clone)] +struct ConnectionManager { + command_tx: mpsc::UnboundedSender, +} + +#[derive(Debug)] +enum ConnectionCommand { + Register { + tunnel_id: String, + connection: ServerConnection, + }, + ForwardData { + tunnel_id: String, + payload: Vec, + }, + Terminate { + tunnel_id: String, + }, + ConnectionClosed { + tunnel_id: String, + reason: Option, + }, + Shutdown { + reason: Option, + }, +} + +#[derive(Clone, Debug)] struct ServerConnection { write_tx: mpsc::UnboundedSender, shutdown_tx: watch::Sender, } +impl ConnectionManager { + fn new(send: mpsc::UnboundedSender) -> Self { + let (command_tx, mut command_rx) = mpsc::unbounded_channel(); + + tokio::spawn(async move { + let mut connections: HashMap = HashMap::new(); + while let Some(command) = command_rx.recv().await { + match command { + ConnectionCommand::Register { + tunnel_id, + connection, + } => { + connections.insert(tunnel_id, connection); + } + ConnectionCommand::ForwardData { tunnel_id, payload } => { + match connections.get(&tunnel_id) { + Some(conn) => { + if conn.write_tx.send(Bytes::from(payload)).is_err() { + if let Some(conn) = connections.remove(&tunnel_id) { + finalize_connection( + &send, + tunnel_id, + conn, + Some("connection writer dropped".to_string()), + ); + } + } + } + None => { + debug!("Received data for unknown tunnel {}", tunnel_id); + } + } + } + ConnectionCommand::Terminate { tunnel_id } => { + if let Some(conn) = connections.remove(&tunnel_id) { + drop(conn.write_tx); + let _ = conn.shutdown_tx.send(true); + } + } + ConnectionCommand::ConnectionClosed { tunnel_id, reason } => { + if let Some(conn) = connections.remove(&tunnel_id) { + finalize_connection(&send, tunnel_id, conn, reason); + } + } + ConnectionCommand::Shutdown { reason } => { + let reason = reason.unwrap_or_else(|| "server shutdown".to_string()); + for (tunnel_id, conn) in connections.drain() { + finalize_connection(&send, tunnel_id, conn, Some(reason.clone())); + } + break; + } + } + } + + for (tunnel_id, conn) in connections.drain() { + finalize_connection(&send, tunnel_id, conn, Some("server shutdown".to_string())); + } + }); + + Self { command_tx } + } + + fn register(&self, tunnel_id: String, connection: ServerConnection) { + if self + .command_tx + .send(ConnectionCommand::Register { + tunnel_id, + connection, + }) + .is_err() + { + debug!("Connection manager dropped register command"); + } + } + + fn forward_data(&self, tunnel_id: String, payload: Vec) { + if self + .command_tx + .send(ConnectionCommand::ForwardData { tunnel_id, payload }) + .is_err() + { + debug!("Connection manager dropped data command"); + } + } + + fn terminate(&self, tunnel_id: String) { + if self + .command_tx + .send(ConnectionCommand::Terminate { tunnel_id }) + .is_err() + { + debug!("Connection manager dropped terminate command"); + } + } + + fn connection_closed(&self, tunnel_id: String, reason: Option) { + if self + .command_tx + .send(ConnectionCommand::ConnectionClosed { tunnel_id, reason }) + .is_err() + { + debug!("Connection manager dropped close command"); + } + } + + fn shutdown(&self, reason: Option) { + let _ = self.command_tx.send(ConnectionCommand::Shutdown { reason }); + } +} + +fn finalize_connection( + send: &mpsc::UnboundedSender, + tunnel_id: String, + conn: ServerConnection, + reason: Option, +) { + drop(conn.write_tx); + let _ = conn.shutdown_tx.send(true); + let _ = send.send(WireMessage::Close { tunnel_id, reason }); +} + /// Run a tunnel server on top of any async byte stream. pub async fn run_tunnel_server(stream: S) -> Result<(), TunnelError> where @@ -34,16 +180,17 @@ where let (mut writer, mut reader) = framed.split(); let (send_tx, mut send_rx) = mpsc::unbounded_channel::(); - let connections: Connections = Arc::new(Mutex::new(HashMap::new())); + let manager = ConnectionManager::new(send_tx.clone()); let writer_task = tokio::spawn({ - let connections = connections.clone(); + let manager = manager.clone(); async move { while let Some(message) = send_rx.recv().await { match serde_json::to_vec(&message) { Ok(payload) => { if let Err(err) = writer.send(Bytes::from(payload)).await { error!("Failed to send tunnel frame: {}", err); + manager.shutdown(Some(err.to_string())); break; } } @@ -53,21 +200,14 @@ where } } - // Ensure all connections are torn down when writer exits. - let mut map = connections.lock().await; - for (tunnel_id, conn) in map.drain() { - drop(conn.write_tx); - let _ = conn.shutdown_tx.send(true); - let payload = serde_json::to_vec(&WireMessage::Close { - tunnel_id, - reason: Some("server shutdown".to_string()), - }) - .unwrap_or_default(); - let _ = writer.send(Bytes::from(payload)).await; + if let Err(err) = writer.flush().await { + debug!("Failed to flush tunnel writer: {}", err); } } }); + let mut shutdown_reason: Option = None; + while let Some(frame) = reader.next().await { match frame { Ok(bytes) => match serde_json::from_slice::(&bytes) { @@ -76,13 +216,13 @@ where protocol, target, }) => { - handle_open(&send_tx, &connections, tunnel_id, protocol, target).await; + handle_open(&send_tx, &manager, tunnel_id, protocol, target).await; } Ok(WireMessage::Data { tunnel_id, payload }) => { - handle_data(&connections, tunnel_id, payload).await; + handle_data(&manager, tunnel_id, payload); } Ok(WireMessage::Close { tunnel_id, .. }) => { - terminate_connection(&connections, &tunnel_id).await; + terminate_connection(&manager, &tunnel_id); } Ok(WireMessage::OpenResult { .. }) => { warn!("Server received unexpected OpenResult message"); @@ -93,11 +233,14 @@ where }, Err(err) => { error!("Tunnel server receive error: {}", err); + shutdown_reason = Some(err.to_string()); break; } } } + let reason = shutdown_reason.unwrap_or_else(|| "server shutdown".to_string()); + manager.shutdown(Some(reason)); drop(send_tx); let _ = writer_task.await; Ok(()) @@ -105,7 +248,7 @@ where async fn handle_open( send: &mpsc::UnboundedSender, - connections: &Connections, + manager: &ConnectionManager, tunnel_id: String, protocol: Protocol, target: Target, @@ -119,16 +262,13 @@ async fn handle_open( let (write_tx, write_rx) = mpsc::unbounded_channel::(); let (shutdown_tx, _) = watch::channel(false); - { - let mut map = connections.lock().await; - map.insert( - tunnel_id.clone(), - ServerConnection { - write_tx: write_tx.clone(), - shutdown_tx: shutdown_tx.clone(), - }, - ); - } + manager.register( + tunnel_id.clone(), + ServerConnection { + write_tx: write_tx.clone(), + shutdown_tx: shutdown_tx.clone(), + }, + ); if send .send(WireMessage::OpenResult { @@ -145,9 +285,8 @@ async fn handle_open( write_half, write_rx, shutdown_tx.subscribe(), - send.clone(), tunnel_id.clone(), - connections.clone(), + manager.clone(), ); spawn_conn_reader( @@ -155,7 +294,7 @@ async fn handle_open( shutdown_tx.subscribe(), send.clone(), tunnel_id, - connections.clone(), + manager.clone(), ); } Err(err) => { @@ -177,28 +316,16 @@ async fn handle_open( } } -async fn handle_data(connections: &Connections, tunnel_id: String, payload: Vec) { - let write_tx = { - let map = connections.lock().await; - map.get(&tunnel_id).map(|conn| conn.write_tx.clone()) - }; - - if let Some(tx) = write_tx { - if tx.send(Bytes::from(payload)).is_err() { - debug!("Failed to dispatch data to tunnel {}", tunnel_id); - } - } else { - debug!("Received data for unknown tunnel {}", tunnel_id); - } +fn handle_data(manager: &ConnectionManager, tunnel_id: String, payload: Vec) { + manager.forward_data(tunnel_id, payload); } fn spawn_conn_writer( mut write_half: tokio::net::tcp::OwnedWriteHalf, mut data_rx: mpsc::UnboundedReceiver, mut shutdown_rx: watch::Receiver, - send: mpsc::UnboundedSender, tunnel_id: String, - connections: Connections, + manager: ConnectionManager, ) { spawn_detached(async move { let mut close_reason: Option = None; @@ -222,14 +349,7 @@ fn spawn_conn_writer( } if let Some(reason) = close_reason { - if let Some(conn) = remove_connection(&connections, &tunnel_id).await { - drop(conn.write_tx); - let _ = conn.shutdown_tx.send(true); - } - let _ = send.send(WireMessage::Close { - tunnel_id, - reason: Some(reason), - }); + manager.connection_closed(tunnel_id, Some(reason)); } }); } @@ -239,7 +359,7 @@ fn spawn_conn_reader( mut shutdown_rx: watch::Receiver, send: mpsc::UnboundedSender, tunnel_id: String, - connections: Connections, + manager: ConnectionManager, ) { spawn_detached(async move { let mut buffer = vec![0u8; 8192]; @@ -276,26 +396,11 @@ fn spawn_conn_reader( } if send_close { - if let Some(conn) = remove_connection(&connections, &tunnel_id).await { - drop(conn.write_tx); - let _ = conn.shutdown_tx.send(true); - } - let _ = send.send(WireMessage::Close { - tunnel_id, - reason: close_reason, - }); + manager.connection_closed(tunnel_id, close_reason); } }); } -async fn remove_connection(connections: &Connections, tunnel_id: &str) -> Option { - let mut map = connections.lock().await; - map.remove(tunnel_id) -} - -async fn terminate_connection(connections: &Connections, tunnel_id: &str) { - if let Some(conn) = remove_connection(connections, tunnel_id).await { - drop(conn.write_tx); - let _ = conn.shutdown_tx.send(true); - } +fn terminate_connection(manager: &ConnectionManager, tunnel_id: &str) { + manager.terminate(tunnel_id.to_string()); } From b8396b4b948caa7b6d915a2ed68110f8f3beb3d2 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Tue, 21 Oct 2025 19:21:56 +0000 Subject: [PATCH 160/334] update --- docs/core-concepts/environment.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core-concepts/environment.md b/docs/core-concepts/environment.md index bcd6bdc..cb9551a 100644 --- a/docs/core-concepts/environment.md +++ b/docs/core-concepts/environment.md @@ -41,7 +41,7 @@ Each personal environment runs in its **own Kubernetes namespace**, so: Because every personal environment contains a **full set of workloads**, it guarantees total isolation — but that comes with higher resource usage. -Lapdev helps mitigate this cost by allowing you to **start and stop environments on demand**, automatically scaling down resources when you’re not using them. +Lapdev helps mitigate this cost by allowing you to **pause and resume environments on demand**, automatically scaling down resources when you’re not using them. **Best for:**\ Developers who need full isolation or want to test complex changes safely. From dfb12693d0f0ef61585642f890bea8b958c1baef Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Tue, 21 Oct 2025 20:40:07 +0000 Subject: [PATCH 161/334] update --- crates/api/src/kube_controller/workload.rs | 15 +- .../kube-manager/src/sidecar_proxy_manager.rs | 43 ++++- crates/kube-rpc/src/lib.rs | 1 + crates/kube-sidecar-proxy/src/config.rs | 3 + crates/kube-sidecar-proxy/src/otel_routing.rs | 41 ++++- crates/kube-sidecar-proxy/src/proxy.rs | 1 + crates/kube-sidecar-proxy/src/rpc.rs | 2 + crates/kube-sidecar-proxy/src/server.rs | 171 +++++++++++++++--- 8 files changed, 238 insertions(+), 39 deletions(-) diff --git a/crates/api/src/kube_controller/workload.rs b/crates/api/src/kube_controller/workload.rs index a8c21a9..1a122bd 100644 --- a/crates/api/src/kube_controller/workload.rs +++ b/crates/api/src/kube_controller/workload.rs @@ -149,7 +149,16 @@ impl KubeController { &existing_workload.workload_yaml, &containers, )?; - (manifest, yaml, None) + let mut labels = HashMap::new(); + labels.insert( + "lapdev.base-workload".to_string(), + existing_workload.name.clone(), + ); + labels.insert( + "lapdev.base-workload-id".to_string(), + existing_workload.id.to_string(), + ); + (manifest, yaml, Some(labels)) }; let cluster_server = self @@ -305,6 +314,10 @@ impl KubeController { "lapdev.io/proxy-target-port".to_string(), "8080".to_string(), ); + extra_labels.insert( + "lapdev.base-workload-id".to_string(), + existing_workload.id.to_string(), + ); Ok((workloads_with_resources, persisted_yaml, extra_labels)) } diff --git a/crates/kube-manager/src/sidecar_proxy_manager.rs b/crates/kube-manager/src/sidecar_proxy_manager.rs index 8373369..8c867c8 100644 --- a/crates/kube-manager/src/sidecar_proxy_manager.rs +++ b/crates/kube-manager/src/sidecar_proxy_manager.rs @@ -89,22 +89,44 @@ impl SidecarProxyManager { return Ok(()); }; - let services_api: Api = - Api::namespaced(self.kube_client.clone(), ®istration.namespace); - - let label_selector = format!("lapdev.io/branch-environment-id={}", environment_id); - let services = services_api - .list(&ListParams::default().labels(&label_selector)) - .await? - .items; + let scoped_services = { + let selector = format!("lapdev.base-workload-id={}", registration.workload_id); + let services_api_all: Api = Api::all(self.kube_client.clone()); + let listed = services_api_all + .list(&ListParams::default().labels(&selector)) + .await? + .items; + + if listed.is_empty() { + // Backward compatibility: fall back to namespace-scoped lookup + let services_api_ns: Api = + Api::namespaced(self.kube_client.clone(), ®istration.namespace); + services_api_ns.list(&ListParams::default()).await?.items + } else { + listed + } + }; let mut routes = Vec::new(); - for svc in services { + for svc in scoped_services { let svc_name = svc.metadata.name.unwrap_or_default(); if svc_name.is_empty() { continue; } + let namespace = svc + .metadata + .namespace + .clone() + .unwrap_or_else(|| registration.namespace.clone()); + + let branch_env_id = svc + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("lapdev.io/branch-environment-id")) + .and_then(|value| Uuid::parse_str(value).ok()); + if let Some(spec) = svc.spec { if let Some(ports) = spec.ports { for port in ports { @@ -114,8 +136,9 @@ impl SidecarProxyManager { routes.push(ProxyRouteConfig { path: format!("/{}/*", svc_name), service_name: svc_name.clone(), - namespace: registration.namespace.clone(), + namespace: namespace.clone(), port: port_number, + branch_environment_id: branch_env_id, }); } } diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index f9a27b9..d2abc84 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -485,6 +485,7 @@ pub struct ProxyRouteConfig { pub service_name: String, pub namespace: String, pub port: u16, + pub branch_environment_id: Option, } #[tarpc::service] diff --git a/crates/kube-sidecar-proxy/src/config.rs b/crates/kube-sidecar-proxy/src/config.rs index 1382bc4..c67bd08 100644 --- a/crates/kube-sidecar-proxy/src/config.rs +++ b/crates/kube-sidecar-proxy/src/config.rs @@ -43,6 +43,9 @@ pub struct RouteConfig { /// Target service to route to pub target: RouteTarget, + /// Optional branch environment identifier this route applies to + pub branch_environment_id: Option, + /// Optional headers to add/modify pub headers: HashMap, diff --git a/crates/kube-sidecar-proxy/src/otel_routing.rs b/crates/kube-sidecar-proxy/src/otel_routing.rs index 240eb37..769b315 100644 --- a/crates/kube-sidecar-proxy/src/otel_routing.rs +++ b/crates/kube-sidecar-proxy/src/otel_routing.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use tracing::{debug, warn}; +use uuid::Uuid; /// OpenTelemetry header names pub const TRACEPARENT_HEADER: &str = "traceparent"; @@ -25,6 +26,7 @@ pub struct TraceContext { #[derive(Debug, Clone)] pub struct RoutingContext { pub trace_context: TraceContext, + pub lapdev_environment_id: Option, pub routing_key: Option, pub service_version: Option, pub canary_deployment: Option, @@ -35,9 +37,11 @@ pub struct RoutingContext { /// Extract OpenTelemetry and routing context from parsed headers pub fn extract_routing_context(headers: &[(String, String)]) -> RoutingContext { let trace_context = extract_trace_context(headers); + let lapdev_environment_id = extract_lapdev_environment_id(trace_context.trace_state.as_ref()); RoutingContext { trace_context, + lapdev_environment_id, routing_key: get_header_value(headers, X_ROUTING_KEY), service_version: get_header_value(headers, X_SERVICE_VERSION), canary_deployment: get_header_value(headers, X_CANARY_DEPLOYMENT), @@ -134,6 +138,21 @@ fn extract_custom_headers(headers: &[(String, String)]) -> HashMap) -> Option { + let tracestate = tracestate?; + + tracestate.split(',').find_map(|entry| { + let entry = entry.trim(); + let (key, value) = entry.split_once('=')?; + + if key.trim().eq_ignore_ascii_case("lapdev-env-id") { + Uuid::parse_str(value.trim()).ok() + } else { + None + } + }) +} + /// Determine routing target based on context pub fn determine_routing_target( routing_context: &RoutingContext, @@ -264,6 +283,7 @@ mod tests { context.trace_context.baggage.get("userId"), Some(&"alice".to_string()) ); + assert!(context.lapdev_environment_id.is_none()); } #[test] @@ -281,7 +301,7 @@ mod tests { #[test] fn test_routing_target_determination() { - let mut context = RoutingContext { + let context = RoutingContext { trace_context: TraceContext { trace_id: None, span_id: None, @@ -289,6 +309,7 @@ mod tests { trace_state: None, baggage: HashMap::new(), }, + lapdev_environment_id: None, routing_key: None, service_version: None, canary_deployment: Some("true".to_string()), @@ -305,4 +326,22 @@ mod tests { _ => panic!("Expected canary routing"), } } + + #[test] + fn test_extract_lapdev_environment_id() { + let headers = create_test_headers(&[( + "tracestate", + "vendor=value,lapdev-env-id=123e4567-e89b-12d3-a456-426614174000", + )]); + + let context = extract_routing_context(&headers); + + assert_eq!( + context.lapdev_environment_id, + Some( + Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000") + .expect("valid uuid literal") + ) + ); + } } diff --git a/crates/kube-sidecar-proxy/src/proxy.rs b/crates/kube-sidecar-proxy/src/proxy.rs index b12e425..38da117 100644 --- a/crates/kube-sidecar-proxy/src/proxy.rs +++ b/crates/kube-sidecar-proxy/src/proxy.rs @@ -187,6 +187,7 @@ impl ProxyHandler { Ok(RouteConfig { path: "/*".to_string(), target: RouteTarget::Address(config.default_target), + branch_environment_id: None, headers: std::collections::HashMap::new(), timeout_ms: None, requires_auth: true, diff --git a/crates/kube-sidecar-proxy/src/rpc.rs b/crates/kube-sidecar-proxy/src/rpc.rs index 27e52f0..15dbe00 100644 --- a/crates/kube-sidecar-proxy/src/rpc.rs +++ b/crates/kube-sidecar-proxy/src/rpc.rs @@ -78,6 +78,7 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { namespace: Some(route.namespace.clone()), port: route.port, }, + branch_environment_id: route.branch_environment_id, headers: std::collections::HashMap::new(), timeout_ms: None, requires_auth: true, @@ -135,6 +136,7 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { target_port: route.target_port, auth_token: route.auth_token, }, + branch_environment_id: None, headers: std::collections::HashMap::new(), timeout_ms: Some(30000), requires_auth: true, diff --git a/crates/kube-sidecar-proxy/src/server.rs b/crates/kube-sidecar-proxy/src/server.rs index e98af72..523964e 100644 --- a/crates/kube-sidecar-proxy/src/server.rs +++ b/crates/kube-sidecar-proxy/src/server.rs @@ -1,5 +1,5 @@ use crate::{ - config::ProxyConfig, + config::{ProxyConfig, RouteTarget}, error::Result, original_dest::get_original_destination, otel_routing::{determine_routing_target, extract_routing_context}, @@ -19,7 +19,7 @@ use std::{collections::HashMap, io, net::SocketAddr, str::FromStr, sync::Arc}; use tarpc::server::{BaseChannel, Channel}; use tokio::{ io::copy_bidirectional, - net::{TcpListener, TcpStream}, + net::{lookup_host, TcpListener, TcpStream}, sync::{mpsc, oneshot, RwLock}, }; use tokio_tungstenite::connect_async; @@ -324,12 +324,14 @@ async fn handle_connection( }; match protocol_type { - ProtocolType::Http { method, path } => { - info!( - "Detected HTTP {} {} from {} -> {} (local: {})", - method, path, client_addr, original_dest, local_target - ); - handle_http_proxy(inbound_stream, original_dest.port(), initial_data).await + ProtocolType::Http { .. } => { + handle_http_proxy( + inbound_stream, + original_dest, + initial_data, + Arc::clone(&config), + ) + .await } ProtocolType::Tcp => { info!( @@ -344,8 +346,9 @@ async fn handle_connection( /// Handle HTTP proxying with OpenTelemetry header parsing and intelligent routing async fn handle_http_proxy( mut inbound_stream: TcpStream, - original_port: u16, + original_dest: SocketAddr, mut initial_data: Vec, + config: Arc>, ) -> io::Result<()> { // Try to parse the HTTP request, reading more data if needed let (http_request, _body_start) = match http_parser::parse_complete_http_request( @@ -360,54 +363,168 @@ async fn handle_http_proxy( "Failed to parse HTTP request: {}, falling back to TCP proxy", e ); - let local_target = SocketAddr::new("127.0.0.1".parse().unwrap(), original_port); - return handle_tcp_proxy(inbound_stream, local_target, initial_data).await; + let fallback_target = + SocketAddr::new("127.0.0.1".parse().unwrap(), original_dest.port()); + return handle_tcp_proxy(inbound_stream, fallback_target, initial_data).await; } }; // Extract OpenTelemetry and routing context from headers let routing_context = extract_routing_context(&http_request.headers); + let fallback_target = SocketAddr::new("127.0.0.1".parse().unwrap(), original_dest.port()); - // Determine routing target based on headers - let routing_target = determine_routing_target(&routing_context, original_port); - - // Get the actual target address - let local_target = SocketAddr::new("127.0.0.1".parse().unwrap(), routing_target.get_port()); + if let Some(environment_id) = routing_context.lapdev_environment_id { + if let Some((service_name, namespace, branch_port)) = + find_branch_route(&config, &environment_id, original_dest.port()).await + { + match resolve_service_addresses(&service_name, &namespace, branch_port).await { + Ok(addresses) => { + if let Some(branch_addr) = addresses.into_iter().next() { + info!( + "HTTP {} {} routed to branch {} service {}.{} via {}", + http_request.method, + http_request.path, + environment_id, + service_name, + namespace, + branch_addr + ); + + match proxy_stream(&mut inbound_stream, branch_addr, &initial_data).await { + Ok(()) => return Ok(()), + Err(err) => { + warn!( + "Branch route {} ({}.{}) for env {} failed: {}. Falling back to shared target", + branch_addr, + service_name, + namespace, + environment_id, + err + ); + } + } + } else { + warn!( + "No DNS addresses resolved for branch service {}.{} (env {})", + service_name, namespace, environment_id + ); + } + } + Err(err) => { + warn!( + "Failed to resolve branch service {}.{} for env {}: {}", + service_name, namespace, environment_id, err + ); + } + } + } + } - // Log the routing decision + // Determine routing target based on headers for fallback logging + let routing_target = determine_routing_target(&routing_context, original_dest.port()); info!( "HTTP {} {} -> {} (routing: {}, trace_id: {:?})", http_request.method, http_request.path, - local_target, + fallback_target, routing_target.get_metadata(), routing_context.trace_context.trace_id ); - // Connect to the local service - let mut outbound_stream = TcpStream::connect(&local_target).await?; + proxy_stream(&mut inbound_stream, fallback_target, &initial_data).await +} + +async fn proxy_stream( + inbound_stream: &mut TcpStream, + target: SocketAddr, + initial_data: &[u8], +) -> io::Result<()> { + let mut outbound_stream = TcpStream::connect(target).await?; - // Send the initial data we read for protocol detection if !initial_data.is_empty() { - tokio::io::AsyncWriteExt::write_all(&mut outbound_stream, &initial_data).await?; + tokio::io::AsyncWriteExt::write_all(&mut outbound_stream, initial_data).await?; } - // Start bidirectional copying for the rest of the connection - match copy_bidirectional(&mut inbound_stream, &mut outbound_stream).await { + match copy_bidirectional(inbound_stream, &mut outbound_stream).await { Ok((bytes_tx, bytes_rx)) => { debug!( - "HTTP connection completed: {} bytes tx, {} bytes rx", - bytes_tx, bytes_rx + "HTTP connection completed via {}: {} bytes tx, {} bytes rx", + target, bytes_tx, bytes_rx ); } Err(e) => { - debug!("HTTP connection ended: {}", e); + debug!("HTTP connection via {} ended: {}", target, e); } } Ok(()) } +async fn find_branch_route( + config: &Arc>, + environment_id: &Uuid, + port: u16, +) -> Option<(String, String, u16)> { + let config = config.read().await; + + for route in &config.routes { + if route.branch_environment_id == Some(*environment_id) { + if let RouteTarget::Service { + name, + namespace, + port: route_port, + } = &route.target + { + if *route_port == port { + let ns = namespace + .clone() + .or_else(|| config.namespace.clone()) + .unwrap_or_else(|| "default".to_string()); + return Some((name.clone(), ns, *route_port)); + } + } + } + } + + None +} + +async fn resolve_service_addresses( + service_name: &str, + namespace: &str, + port: u16, +) -> io::Result> { + let search_hosts = [ + format!("{}.{}.svc.cluster.local", service_name, namespace), + format!("{}.{}.svc", service_name, namespace), + format!("{}.{}", service_name, namespace), + ]; + + let mut last_err: Option = None; + + for host in &search_hosts { + match lookup_host((host.as_str(), port)).await { + Ok(addresses) => { + let collected: Vec = addresses.collect(); + if !collected.is_empty() { + return Ok(collected); + } + } + Err(err) => last_err = Some(err), + } + } + + Err(last_err.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::Other, + format!( + "Unable to resolve service {} in namespace {} on port {}", + service_name, namespace, port + ), + ) + })) +} + /// Handle TCP proxying (raw byte forwarding) async fn handle_tcp_proxy( mut inbound_stream: TcpStream, From 3ed72852347e96f7d3fe18904e375c33712710ae Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Tue, 21 Oct 2025 21:02:35 +0000 Subject: [PATCH 162/334] update --- .../kube-manager/src/sidecar_proxy_manager.rs | 7 - crates/kube-rpc/src/lib.rs | 1 - crates/kube-sidecar-proxy/src/config.rs | 6 +- crates/kube-sidecar-proxy/src/proxy.rs | 38 ++--- crates/kube-sidecar-proxy/src/rpc.rs | 1 - crates/kube-sidecar-proxy/src/server.rs | 137 +++++++----------- 6 files changed, 63 insertions(+), 127 deletions(-) diff --git a/crates/kube-manager/src/sidecar_proxy_manager.rs b/crates/kube-manager/src/sidecar_proxy_manager.rs index 8c867c8..408bb5d 100644 --- a/crates/kube-manager/src/sidecar_proxy_manager.rs +++ b/crates/kube-manager/src/sidecar_proxy_manager.rs @@ -114,12 +114,6 @@ impl SidecarProxyManager { continue; } - let namespace = svc - .metadata - .namespace - .clone() - .unwrap_or_else(|| registration.namespace.clone()); - let branch_env_id = svc .metadata .labels @@ -136,7 +130,6 @@ impl SidecarProxyManager { routes.push(ProxyRouteConfig { path: format!("/{}/*", svc_name), service_name: svc_name.clone(), - namespace: namespace.clone(), port: port_number, branch_environment_id: branch_env_id, }); diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index d2abc84..099ff81 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -483,7 +483,6 @@ pub struct DevboxRouteConfig { pub struct ProxyRouteConfig { pub path: String, pub service_name: String, - pub namespace: String, pub port: u16, pub branch_environment_id: Option, } diff --git a/crates/kube-sidecar-proxy/src/config.rs b/crates/kube-sidecar-proxy/src/config.rs index c67bd08..948ecc2 100644 --- a/crates/kube-sidecar-proxy/src/config.rs +++ b/crates/kube-sidecar-proxy/src/config.rs @@ -66,11 +66,7 @@ pub enum RouteTarget { Address(SocketAddr), /// Kubernetes service - Service { - name: String, - namespace: Option, - port: u16, - }, + Service { name: String, port: u16 }, /// Load balance across multiple targets LoadBalance(Vec), diff --git a/crates/kube-sidecar-proxy/src/proxy.rs b/crates/kube-sidecar-proxy/src/proxy.rs index 38da117..a4c574b 100644 --- a/crates/kube-sidecar-proxy/src/proxy.rs +++ b/crates/kube-sidecar-proxy/src/proxy.rs @@ -210,25 +210,13 @@ impl ProxyHandler { async fn resolve_target(&self, target: &RouteTarget) -> Result { match target { RouteTarget::Address(addr) => Ok(*addr), - RouteTarget::Service { - name, - namespace, - port, - } => { - self.resolve_service_target(name, namespace.as_deref(), *port) - .await - } + RouteTarget::Service { name, port } => self.resolve_service_target(name, *port).await, RouteTarget::LoadBalance(targets) => { for target in targets { let attempt = match target { RouteTarget::Address(addr) => Ok(*addr), - RouteTarget::Service { - name, - namespace, - port, - } => { - self.resolve_service_target(name, namespace.as_deref(), *port) - .await + RouteTarget::Service { name, port } => { + self.resolve_service_target(name, *port).await } RouteTarget::LoadBalance(_) | RouteTarget::DevboxTunnel { .. } => continue, }; @@ -248,20 +236,12 @@ impl ProxyHandler { } } - async fn resolve_service_target( - &self, - name: &str, - namespace: Option<&str>, - port: u16, - ) -> Result { - let ns = match namespace { - Some(ns) => ns.to_string(), - None => { - let cfg = self.config.read().await; - cfg.namespace - .clone() - .unwrap_or_else(|| "default".to_string()) - } + async fn resolve_service_target(&self, name: &str, port: u16) -> Result { + let ns = { + let cfg = self.config.read().await; + cfg.namespace + .clone() + .unwrap_or_else(|| "default".to_string()) }; let host = format!("{}.{}.svc.cluster.local", name, ns); diff --git a/crates/kube-sidecar-proxy/src/rpc.rs b/crates/kube-sidecar-proxy/src/rpc.rs index 15dbe00..8b628be 100644 --- a/crates/kube-sidecar-proxy/src/rpc.rs +++ b/crates/kube-sidecar-proxy/src/rpc.rs @@ -75,7 +75,6 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { path: route.path.clone(), target: RouteTarget::Service { name: route.service_name.clone(), - namespace: Some(route.namespace.clone()), port: route.port, }, branch_environment_id: route.branch_environment_id, diff --git a/crates/kube-sidecar-proxy/src/server.rs b/crates/kube-sidecar-proxy/src/server.rs index 523964e..816c879 100644 --- a/crates/kube-sidecar-proxy/src/server.rs +++ b/crates/kube-sidecar-proxy/src/server.rs @@ -19,7 +19,7 @@ use std::{collections::HashMap, io, net::SocketAddr, str::FromStr, sync::Arc}; use tarpc::server::{BaseChannel, Channel}; use tokio::{ io::copy_bidirectional, - net::{lookup_host, TcpListener, TcpStream}, + net::{TcpListener, TcpStream}, sync::{mpsc, oneshot, RwLock}, }; use tokio_tungstenite::connect_async; @@ -374,48 +374,28 @@ async fn handle_http_proxy( let fallback_target = SocketAddr::new("127.0.0.1".parse().unwrap(), original_dest.port()); if let Some(environment_id) = routing_context.lapdev_environment_id { - if let Some((service_name, namespace, branch_port)) = + if let Some((service_name, branch_port)) = find_branch_route(&config, &environment_id, original_dest.port()).await { - match resolve_service_addresses(&service_name, &namespace, branch_port).await { - Ok(addresses) => { - if let Some(branch_addr) = addresses.into_iter().next() { - info!( - "HTTP {} {} routed to branch {} service {}.{} via {}", - http_request.method, - http_request.path, - environment_id, - service_name, - namespace, - branch_addr - ); - - match proxy_stream(&mut inbound_stream, branch_addr, &initial_data).await { - Ok(()) => return Ok(()), - Err(err) => { - warn!( - "Branch route {} ({}.{}) for env {} failed: {}. Falling back to shared target", - branch_addr, - service_name, - namespace, - environment_id, - err - ); - } - } - } else { - warn!( - "No DNS addresses resolved for branch service {}.{} (env {})", - service_name, namespace, environment_id - ); - } - } - Err(err) => { - warn!( - "Failed to resolve branch service {}.{} for env {}: {}", - service_name, namespace, environment_id, err - ); - } + info!( + "HTTP {} {} routing to branch {} service {}:{}", + http_request.method, http_request.path, environment_id, service_name, branch_port + ); + + if let Err(err) = proxy_branch_stream( + &mut inbound_stream, + service_name.as_str(), + branch_port, + &initial_data, + ) + .await + { + warn!( + "Branch route {}:{} for env {} failed: {}; falling back to shared target", + service_name, branch_port, environment_id, err + ); + } else { + return Ok(()); } } } @@ -460,27 +440,52 @@ async fn proxy_stream( Ok(()) } +async fn proxy_branch_stream( + inbound_stream: &mut TcpStream, + service_name: &str, + port: u16, + initial_data: &[u8], +) -> io::Result<()> { + let mut outbound_stream = TcpStream::connect((service_name, port)).await?; + + if !initial_data.is_empty() { + tokio::io::AsyncWriteExt::write_all(&mut outbound_stream, initial_data).await?; + } + + match copy_bidirectional(inbound_stream, &mut outbound_stream).await { + Ok((bytes_tx, bytes_rx)) => { + debug!( + "HTTP connection completed via branch service {}:{}: {} bytes tx, {} bytes rx", + service_name, port, bytes_tx, bytes_rx + ); + } + Err(e) => { + debug!( + "HTTP connection via branch service {}:{} ended: {}", + service_name, port, e + ); + } + } + + Ok(()) +} + async fn find_branch_route( config: &Arc>, environment_id: &Uuid, port: u16, -) -> Option<(String, String, u16)> { +) -> Option<(String, u16)> { let config = config.read().await; for route in &config.routes { if route.branch_environment_id == Some(*environment_id) { if let RouteTarget::Service { name, - namespace, port: route_port, } = &route.target { if *route_port == port { - let ns = namespace - .clone() - .or_else(|| config.namespace.clone()) - .unwrap_or_else(|| "default".to_string()); - return Some((name.clone(), ns, *route_port)); + return Some((name.clone(), *route_port)); } } } @@ -489,42 +494,6 @@ async fn find_branch_route( None } -async fn resolve_service_addresses( - service_name: &str, - namespace: &str, - port: u16, -) -> io::Result> { - let search_hosts = [ - format!("{}.{}.svc.cluster.local", service_name, namespace), - format!("{}.{}.svc", service_name, namespace), - format!("{}.{}", service_name, namespace), - ]; - - let mut last_err: Option = None; - - for host in &search_hosts { - match lookup_host((host.as_str(), port)).await { - Ok(addresses) => { - let collected: Vec = addresses.collect(); - if !collected.is_empty() { - return Ok(collected); - } - } - Err(err) => last_err = Some(err), - } - } - - Err(last_err.unwrap_or_else(|| { - io::Error::new( - io::ErrorKind::Other, - format!( - "Unable to resolve service {} in namespace {} on port {}", - service_name, namespace, port - ), - ) - })) -} - /// Handle TCP proxying (raw byte forwarding) async fn handle_tcp_proxy( mut inbound_stream: TcpStream, From f6053c034529d7761457490a467c17370de54611 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Tue, 21 Oct 2025 21:08:29 +0000 Subject: [PATCH 163/334] update --- plans/todo.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 plans/todo.md diff --git a/plans/todo.md b/plans/todo.md new file mode 100644 index 0000000..e69de29 From d0e91d04c985972507f1b948a5fa6abbe976c7e7 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Tue, 21 Oct 2025 21:27:42 +0000 Subject: [PATCH 164/334] update --- crates/db/src/api.rs | 15 ++ crates/kube-manager/src/manager.rs | 6 +- .../kube-manager/src/sidecar_proxy_manager.rs | 102 +++++++------ .../src/sidecar_proxy_manager_rpc.rs | 3 +- crates/kube-rpc/src/lib.rs | 6 + crates/kube/src/server.rs | 140 +++++++++++++++++- 6 files changed, 216 insertions(+), 56 deletions(-) diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 0f8654a..9ed7296 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -1741,6 +1741,21 @@ impl DbApi { .await } + pub async fn get_branch_environments( + &self, + base_environment_id: Uuid, + ) -> Result> { + let environments = lapdev_db_entities::kube_environment::Entity::find() + .filter( + lapdev_db_entities::kube_environment::Column::BaseEnvironmentId + .eq(base_environment_id), + ) + .filter(lapdev_db_entities::kube_environment::Column::DeletedAt.is_null()) + .all(&self.conn) + .await?; + Ok(environments) + } + pub async fn delete_kube_environment(&self, environment_id: Uuid) -> Result<()> { lapdev_db_entities::kube_environment::ActiveModel { id: ActiveValue::Set(environment_id), diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index cf5ae86..e1dec4c 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -105,7 +105,7 @@ impl KubeManager { e })?); - let proxy_manager = Arc::new(SidecarProxyManager::new(kube_client.as_ref().clone()).await?); + let proxy_manager = Arc::new(SidecarProxyManager::new().await?); let devbox_proxy_manager = Arc::new(DevboxProxyManager::new().await?); let watch_manager = Arc::new(WatchManager::new(kube_client.clone())); @@ -193,6 +193,9 @@ impl KubeManager { KubeClusterRpcClient::new(tarpc::client::Config::default(), client_chan).spawn(); self.watch_manager.set_rpc_client(rpc_client.clone()).await; + self.proxy_manager + .set_cluster_rpc_client(rpc_client.clone()) + .await; let rpc_server = KubeManagerRpcServer::new(self.clone(), rpc_client.clone()); @@ -228,6 +231,7 @@ impl KubeManager { let websocket_result = websocket_server_task.await; self.watch_manager.clear_rpc_client().await; + self.proxy_manager.clear_cluster_rpc_client().await; if let Err(e) = websocket_result { return Err(anyhow!("WebSocket RPC server task failed: {}", e)); diff --git a/crates/kube-manager/src/sidecar_proxy_manager.rs b/crates/kube-manager/src/sidecar_proxy_manager.rs index 408bb5d..878fbd7 100644 --- a/crates/kube-manager/src/sidecar_proxy_manager.rs +++ b/crates/kube-manager/src/sidecar_proxy_manager.rs @@ -1,33 +1,32 @@ use anyhow::Result; use futures::StreamExt; -use k8s_openapi::api::core::v1::Service; -use kube::{api::ListParams, Api, Client as KubeClient}; use lapdev_common::kube::{DEFAULT_SIDECAR_PROXY_MANAGER_PORT, SIDECAR_PROXY_MANAGER_PORT_ENV_VAR}; -use lapdev_kube_rpc::{ProxyRouteConfig, SidecarProxyManagerRpc, SidecarProxyRpcClient}; +use lapdev_kube_rpc::{ + KubeClusterRpcClient, SidecarProxyManagerRpc, SidecarProxyRpcClient, +}; use lapdev_rpc::spawn_twoway; use std::{collections::HashMap, sync::Arc}; use tarpc::server::{BaseChannel, Channel}; use tokio::sync::RwLock; -use tracing::{error, info}; +use tracing::{error, info, warn}; use uuid::Uuid; use crate::sidecar_proxy_manager_rpc::SidecarProxyManagerRpcServer; #[derive(Clone)] pub struct SidecarProxyManager { - kube_client: KubeClient, pub(crate) sidecar_proxies: Arc>>, + kube_cluster_rpc_client: Arc>>, } #[derive(Clone)] pub(crate) struct SidecarProxyRegistration { pub workload_id: Uuid, - pub namespace: String, pub rpc_client: SidecarProxyRpcClient, } impl SidecarProxyManager { - pub(crate) async fn new(kube_client: KubeClient) -> Result { + pub(crate) async fn new() -> Result { // Parse the URL to extract the port let port = std::env::var(SIDECAR_PROXY_MANAGER_PORT_ENV_VAR) .ok() @@ -44,8 +43,8 @@ impl SidecarProxyManager { info!("TCP server listening on: {}", bind_addr); let m = Self { - kube_client, sidecar_proxies: Arc::new(RwLock::new(HashMap::new())), + kube_cluster_rpc_client: Arc::new(RwLock::new(None)), }; { @@ -89,54 +88,43 @@ impl SidecarProxyManager { return Ok(()); }; - let scoped_services = { - let selector = format!("lapdev.base-workload-id={}", registration.workload_id); - let services_api_all: Api = Api::all(self.kube_client.clone()); - let listed = services_api_all - .list(&ListParams::default().labels(&selector)) - .await? - .items; - - if listed.is_empty() { - // Backward compatibility: fall back to namespace-scoped lookup - let services_api_ns: Api = - Api::namespaced(self.kube_client.clone(), ®istration.namespace); - services_api_ns.list(&ListParams::default()).await?.items - } else { - listed - } + let cluster_client = { + let guard = self.kube_cluster_rpc_client.read().await; + guard.clone() }; - let mut routes = Vec::new(); - for svc in scoped_services { - let svc_name = svc.metadata.name.unwrap_or_default(); - if svc_name.is_empty() { - continue; - } + let Some(cluster_client) = cluster_client else { + warn!( + "Cluster RPC client unavailable; skipping route sync for environment {}", + environment_id + ); + return Ok(()); + }; - let branch_env_id = svc - .metadata - .labels - .as_ref() - .and_then(|labels| labels.get("lapdev.io/branch-environment-id")) - .and_then(|value| Uuid::parse_str(value).ok()); - - if let Some(spec) = svc.spec { - if let Some(ports) = spec.ports { - for port in ports { - let Ok(port_number) = u16::try_from(port.port) else { - continue; - }; - routes.push(ProxyRouteConfig { - path: format!("/{}/*", svc_name), - service_name: svc_name.clone(), - port: port_number, - branch_environment_id: branch_env_id, - }); - } - } + let routes = match cluster_client + .list_branch_service_routes( + tarpc::context::current(), + environment_id, + registration.workload_id, + ) + .await + { + Ok(Ok(routes)) => routes, + Ok(Err(err)) => { + return Err(anyhow::anyhow!( + "API rejected route request for environment {}: {}", + environment_id, + err + )); } - } + Err(err) => { + return Err(anyhow::anyhow!( + "Failed to fetch routes for environment {}: {}", + environment_id, + err + )); + } + }; if let Err(e) = registration .rpc_client @@ -148,4 +136,14 @@ impl SidecarProxyManager { Ok(()) } + + pub async fn set_cluster_rpc_client(&self, client: KubeClusterRpcClient) { + let mut guard = self.kube_cluster_rpc_client.write().await; + *guard = Some(client); + } + + pub async fn clear_cluster_rpc_client(&self) { + let mut guard = self.kube_cluster_rpc_client.write().await; + *guard = None; + } } diff --git a/crates/kube-manager/src/sidecar_proxy_manager_rpc.rs b/crates/kube-manager/src/sidecar_proxy_manager_rpc.rs index c7dd4d6..8ca110e 100644 --- a/crates/kube-manager/src/sidecar_proxy_manager_rpc.rs +++ b/crates/kube-manager/src/sidecar_proxy_manager_rpc.rs @@ -28,13 +28,12 @@ impl SidecarProxyManagerRpc for SidecarProxyManagerRpcServer { _context: ::tarpc::context::Context, workload_id: Uuid, environment_id: Uuid, - namespace: String, + _namespace: String, ) -> Result<(), String> { self.manager.sidecar_proxies.write().await.insert( environment_id, crate::sidecar_proxy_manager::SidecarProxyRegistration { workload_id, - namespace: namespace.clone(), rpc_client: self.rpc_client.clone(), }, ); diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index 099ff81..c634c36 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -378,6 +378,12 @@ pub trait KubeClusterRpc { ) -> Result<(), String>; async fn report_resource_change(event: ResourceChangeEvent) -> Result<(), String>; + + /// Retrieve route configuration for branch-aware service routing + async fn list_branch_service_routes( + environment_id: Uuid, + workload_id: Uuid, + ) -> Result, String>; } #[tarpc::service] diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index 57f16a9..e64f20e 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -14,12 +14,15 @@ use lapdev_db_entities::{ kube_app_catalog_workload_label, }; use lapdev_kube_rpc::{ - KubeClusterRpc, KubeManagerRpcClient, ResourceChangeEvent, ResourceChangeType, ResourceType, + KubeClusterRpc, KubeManagerRpcClient, ProxyRouteConfig, ResourceChangeEvent, + ResourceChangeType, ResourceType, }; use sea_orm::prelude::{DateTimeWithTimeZone, Json}; use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; use serde_json::json; +use serde_yaml::Value; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::convert::TryFrom; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; @@ -245,6 +248,141 @@ impl KubeClusterRpc for KubeClusterServer { Ok(()) } + + async fn list_branch_service_routes( + self, + _context: ::tarpc::context::Context, + environment_id: Uuid, + workload_id: Uuid, + ) -> Result, String> { + let environment = self + .db + .get_kube_environment(environment_id) + .await + .map_err(|e| format!("Failed to fetch environment {}: {}", environment_id, e))? + .ok_or_else(|| format!("Environment {} not found", environment_id))?; + + if environment.cluster_id != self.cluster_id { + return Err(format!( + "Environment {} does not belong to cluster {}", + environment_id, self.cluster_id + )); + } + + let workload = self + .db + .get_environment_workload(workload_id) + .await + .map_err(|e| format!("Failed to fetch workload {}: {}", workload_id, e))? + .ok_or_else(|| format!("Workload {} not found", workload_id))?; + + let base_env_id = environment.base_environment_id.unwrap_or(environment.id); + let base_workload_id = + extract_label_uuid(&workload.workload_yaml, "lapdev.base-workload-id") + .unwrap_or(workload.id); + let base_workload_name = + extract_label_string(&workload.workload_yaml, "lapdev.base-workload") + .unwrap_or_else(|| workload.name.clone()); + + let mut routes = Vec::new(); + + let mut target_environments = Vec::new(); + target_environments.push(base_env_id); + + let branch_environments = + self.db + .get_branch_environments(base_env_id) + .await + .map_err(|e| { + format!( + "Failed to fetch branch environments for {}: {}", + base_env_id, e + ) + })?; + + for branch in branch_environments { + if branch.cluster_id == self.cluster_id { + target_environments.push(branch.id); + } + } + + let base_workload_id_str = base_workload_id.to_string(); + + for env_id in target_environments { + let services = self + .db + .get_environment_services(env_id) + .await + .map_err(|e| { + format!("Failed to fetch services for environment {}: {}", env_id, e) + })?; + + for service in services { + if !service_matches_workload( + &service.yaml, + &base_workload_id_str, + &base_workload_name, + &service.selector, + ) { + continue; + } + + for port in service.ports { + let Ok(port_number) = u16::try_from(port.port) else { + continue; + }; + + routes.push(ProxyRouteConfig { + path: format!("/{}/*", service.name), + service_name: service.name.clone(), + port: port_number, + branch_environment_id: if env_id == base_env_id { + None + } else { + Some(env_id) + }, + }); + } + } + } + + Ok(routes) + } +} + +fn extract_label_string(yaml: &str, key: &str) -> Option { + let value: Value = serde_yaml::from_str(yaml).ok()?; + let metadata = value.get("metadata")?; + let labels = metadata.get("labels")?.as_mapping()?; + labels + .get(&Value::String(key.to_string()))? + .as_str() + .map(|s| s.to_string()) +} + +fn extract_label_uuid(yaml: &str, key: &str) -> Option { + extract_label_string(yaml, key).and_then(|s| Uuid::parse_str(&s).ok()) +} + +fn service_matches_workload( + service_yaml: &str, + base_workload_id: &str, + base_workload_name: &str, + selector: &HashMap, +) -> bool { + if let Some(label_id) = extract_label_string(service_yaml, "lapdev.base-workload-id") { + if label_id == base_workload_id { + return true; + } + } + + if let Some(label_name) = extract_label_string(service_yaml, "lapdev.base-workload") { + if label_name == base_workload_name { + return true; + } + } + + selector.values().any(|value| value == base_workload_name) } impl KubeClusterServer { From e9939089e7d67fb95acde04f4e2038cf6fa0a8a4 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Wed, 22 Oct 2025 21:11:08 +0000 Subject: [PATCH 165/334] update --- crates/api/src/kube_controller/environment.rs | 3 + crates/api/src/kube_controller/yaml_parser.rs | 1 + crates/common/src/kube.rs | 2 + .../entities/src/kube_environment_workload.rs | 1 + .../src/kube_environment_workload_label.rs | 50 +++ crates/db/entities/src/lib.rs | 1 + crates/db/entities/src/prelude.rs | 1 + crates/db/migration/src/lib.rs | 2 + ...000002_create_kube_environment_workload.rs | 13 + ..._create_kube_environment_workload_label.rs | 124 +++++++ crates/db/src/api.rs | 178 +++++++++- .../kube-manager/src/sidecar_proxy_manager.rs | 4 +- crates/kube-rpc/src/lib.rs | 35 +- crates/kube-sidecar-proxy/src/config.rs | 332 ++++++++++++----- crates/kube-sidecar-proxy/src/lib.rs | 1 - crates/kube-sidecar-proxy/src/main.rs | 6 - crates/kube-sidecar-proxy/src/rpc.rs | 235 ++++++------ crates/kube-sidecar-proxy/src/server.rs | 336 ++++++++---------- crates/kube/src/server.rs | 159 ++++----- 19 files changed, 973 insertions(+), 511 deletions(-) create mode 100644 crates/db/entities/src/kube_environment_workload_label.rs create mode 100644 crates/db/migration/src/m20250901_000001_create_kube_environment_workload_label.rs diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index 62eb8ef..b7d92f0 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -241,6 +241,7 @@ impl KubeController { containers, ports: workload.ports, workload_yaml, + base_workload_id: None, }) }) .collect() @@ -1023,6 +1024,7 @@ impl KubeController { containers, ports: workload.ports, workload_yaml: workload.workload_yaml, + base_workload_id: Some(workload.id), }) }) .collect() @@ -1463,6 +1465,7 @@ impl KubeController { ports: ActiveValue::Set(ports_json), workload_yaml: ActiveValue::Set(workload_yaml), catalog_sync_version: ActiveValue::Set(new_catalog_sync_version), + base_workload_id: ActiveValue::Set(None), } .insert(&txn) .await diff --git a/crates/api/src/kube_controller/yaml_parser.rs b/crates/api/src/kube_controller/yaml_parser.rs index 551819a..02cd967 100644 --- a/crates/api/src/kube_controller/yaml_parser.rs +++ b/crates/api/src/kube_controller/yaml_parser.rs @@ -34,6 +34,7 @@ pub fn build_workload_details_from_yaml( containers, ports, workload_yaml, + base_workload_id: None, }) } diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index 794f40e..6a5df70 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -246,6 +246,7 @@ pub struct KubeEnvironmentWorkload { pub id: Uuid, pub created_at: DateTime, pub environment_id: Uuid, + pub base_workload_id: Option, pub name: String, pub namespace: String, pub kind: String, @@ -315,6 +316,7 @@ pub struct KubeWorkloadDetails { pub containers: Vec, pub ports: Vec, pub workload_yaml: String, + pub base_workload_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/db/entities/src/kube_environment_workload.rs b/crates/db/entities/src/kube_environment_workload.rs index 5e66f4e..c2ee0f1 100644 --- a/crates/db/entities/src/kube_environment_workload.rs +++ b/crates/db/entities/src/kube_environment_workload.rs @@ -10,6 +10,7 @@ pub struct Model { pub created_at: DateTimeWithTimeZone, pub deleted_at: Option, pub environment_id: Uuid, + pub base_workload_id: Option, pub name: String, pub namespace: String, pub kind: String, diff --git a/crates/db/entities/src/kube_environment_workload_label.rs b/crates/db/entities/src/kube_environment_workload_label.rs new file mode 100644 index 0000000..81752c6 --- /dev/null +++ b/crates/db/entities/src/kube_environment_workload_label.rs @@ -0,0 +1,50 @@ +//! `SeaORM` Entity, @generated manually + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "kube_environment_workload_label")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub created_at: DateTimeWithTimeZone, + pub deleted_at: Option, + pub environment_id: Uuid, + pub workload_id: Uuid, + pub label_key: String, + pub label_value: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::kube_environment::Entity", + from = "Column::EnvironmentId", + to = "super::kube_environment::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeEnvironment, + #[sea_orm( + belongs_to = "super::kube_environment_workload::Entity", + from = "Column::WorkloadId", + to = "super::kube_environment_workload::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + KubeEnvironmentWorkload, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeEnvironment.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::KubeEnvironmentWorkload.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/db/entities/src/lib.rs b/crates/db/entities/src/lib.rs index 374c890..6bf0e0b 100644 --- a/crates/db/entities/src/lib.rs +++ b/crates/db/entities/src/lib.rs @@ -18,6 +18,7 @@ pub mod kube_environment; pub mod kube_environment_preview_url; pub mod kube_environment_service; pub mod kube_environment_workload; +pub mod kube_environment_workload_label; pub mod kube_namespace; pub mod machine_type; pub mod oauth_connection; diff --git a/crates/db/entities/src/prelude.rs b/crates/db/entities/src/prelude.rs index b8ce108..7afbd9f 100644 --- a/crates/db/entities/src/prelude.rs +++ b/crates/db/entities/src/prelude.rs @@ -16,6 +16,7 @@ pub use super::kube_environment::Entity as KubeEnvironment; pub use super::kube_environment_preview_url::Entity as KubeEnvironmentPreviewUrl; pub use super::kube_environment_service::Entity as KubeEnvironmentService; pub use super::kube_environment_workload::Entity as KubeEnvironmentWorkload; +pub use super::kube_environment_workload_label::Entity as KubeEnvironmentWorkloadLabel; pub use super::kube_namespace::Entity as KubeNamespace; pub use super::machine_type::Entity as MachineType; pub use super::oauth_connection::Entity as OauthConnection; diff --git a/crates/db/migration/src/lib.rs b/crates/db/migration/src/lib.rs index bedbfb7..bb954bb 100644 --- a/crates/db/migration/src/lib.rs +++ b/crates/db/migration/src/lib.rs @@ -31,6 +31,7 @@ mod m20250820_000001_create_kube_cluster_service; mod m20250825_000001_create_kube_app_catalog_workload_label; mod m20250825_000002_create_kube_cluster_service_selector; mod m20250825_000003_create_kube_app_catalog_workload_dependency; +mod m20250901_000001_create_kube_environment_workload_label; mod m20251008_000001_create_kube_devbox_session; mod m20251008_000002_create_kube_devbox_workload_intercept; @@ -71,6 +72,7 @@ impl MigratorTrait for Migrator { Box::new(m20250825_000001_create_kube_app_catalog_workload_label::Migration), Box::new(m20250825_000002_create_kube_cluster_service_selector::Migration), Box::new(m20250825_000003_create_kube_app_catalog_workload_dependency::Migration), + Box::new(m20250901_000001_create_kube_environment_workload_label::Migration), Box::new(m20251008_000001_create_kube_devbox_session::Migration), Box::new(m20251008_000002_create_kube_devbox_workload_intercept::Migration), ] diff --git a/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs b/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs index c0dd4d3..106e7f2 100644 --- a/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs +++ b/crates/db/migration/src/m20250809_000002_create_kube_environment_workload.rs @@ -33,6 +33,7 @@ impl MigrationTrait for Migration { .uuid() .not_null(), ) + .col(ColumnDef::new(KubeEnvironmentWorkload::BaseWorkloadId).uuid()) .col( ColumnDef::new(KubeEnvironmentWorkload::Name) .string() @@ -93,6 +94,17 @@ impl MigrationTrait for Migration { ) .await?; + manager + .create_index( + Index::create() + .name("kube_environment_workload_base_deleted_idx") + .table(KubeEnvironmentWorkload::Table) + .col(KubeEnvironmentWorkload::BaseWorkloadId) + .col(KubeEnvironmentWorkload::DeletedAt) + .to_owned(), + ) + .await?; + // Create unique index to prevent duplicate workloads per environment manager .create_index( @@ -121,6 +133,7 @@ pub enum KubeEnvironmentWorkload { CreatedAt, DeletedAt, EnvironmentId, + BaseWorkloadId, Name, Namespace, Kind, diff --git a/crates/db/migration/src/m20250901_000001_create_kube_environment_workload_label.rs b/crates/db/migration/src/m20250901_000001_create_kube_environment_workload_label.rs new file mode 100644 index 0000000..fa9d474 --- /dev/null +++ b/crates/db/migration/src/m20250901_000001_create_kube_environment_workload_label.rs @@ -0,0 +1,124 @@ +use sea_orm_migration::prelude::*; + +use crate::m20250809_000001_create_kube_environment::KubeEnvironment; +use crate::m20250809_000002_create_kube_environment_workload::KubeEnvironmentWorkload; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(KubeEnvironmentWorkloadLabel::Table) + .if_not_exists() + .col( + ColumnDef::new(KubeEnvironmentWorkloadLabel::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(KubeEnvironmentWorkloadLabel::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(KubeEnvironmentWorkloadLabel::DeletedAt) + .timestamp_with_time_zone(), + ) + .col( + ColumnDef::new(KubeEnvironmentWorkloadLabel::EnvironmentId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeEnvironmentWorkloadLabel::WorkloadId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(KubeEnvironmentWorkloadLabel::LabelKey) + .string() + .not_null(), + ) + .col( + ColumnDef::new(KubeEnvironmentWorkloadLabel::LabelValue) + .string() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from( + KubeEnvironmentWorkloadLabel::Table, + KubeEnvironmentWorkloadLabel::EnvironmentId, + ) + .to(KubeEnvironment::Table, KubeEnvironment::Id), + ) + .foreign_key( + ForeignKey::create() + .from( + KubeEnvironmentWorkloadLabel::Table, + KubeEnvironmentWorkloadLabel::WorkloadId, + ) + .to(KubeEnvironmentWorkload::Table, KubeEnvironmentWorkload::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_env_workload_label_workload_deleted_idx") + .table(KubeEnvironmentWorkloadLabel::Table) + .col(KubeEnvironmentWorkloadLabel::WorkloadId) + .col(KubeEnvironmentWorkloadLabel::DeletedAt) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_env_workload_label_env_key_value_idx") + .table(KubeEnvironmentWorkloadLabel::Table) + .col(KubeEnvironmentWorkloadLabel::EnvironmentId) + .col(KubeEnvironmentWorkloadLabel::LabelKey) + .col(KubeEnvironmentWorkloadLabel::LabelValue) + .col(KubeEnvironmentWorkloadLabel::DeletedAt) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("kube_env_workload_label_unique_idx") + .table(KubeEnvironmentWorkloadLabel::Table) + .col(KubeEnvironmentWorkloadLabel::WorkloadId) + .col(KubeEnvironmentWorkloadLabel::LabelKey) + .col(KubeEnvironmentWorkloadLabel::DeletedAt) + .unique() + .nulls_not_distinct() + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +pub enum KubeEnvironmentWorkloadLabel { + Table, + Id, + CreatedAt, + DeletedAt, + EnvironmentId, + WorkloadId, + LabelKey, + LabelValue, +} diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 9ed7296..a0ad71b 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -5,7 +5,7 @@ use lapdev_common::{ config::LAPDEV_CLUSTER_NOT_INITIATED, kube::{ KubeAppCatalogWorkload, KubeContainerInfo, KubeEnvironmentWorkload, KubeServicePort, - KubeWorkloadDetails, PagePaginationParams, + KubeWorkloadDetails, KubeWorkloadKind, PagePaginationParams, }, AuthProvider, ProviderUser, UserRole, WorkspaceStatus, LAPDEV_BASE_HOSTNAME, LAPDEV_ISOLATE_CONTAINER, @@ -13,6 +13,7 @@ use lapdev_common::{ use lapdev_db_entities::{ kube_app_catalog_workload, kube_app_catalog_workload_dependency, kube_app_catalog_workload_label, kube_cluster_service, kube_cluster_service_selector, + kube_environment_workload_label, }; use lapdev_db_migration::Migrator; use pasetors::{ @@ -1884,6 +1885,7 @@ impl DbApi { containers, ports, workload_yaml, + .. } = workload; let containers_json = serde_json::to_value(&containers) @@ -2442,6 +2444,7 @@ impl DbApi { id: workload.id, created_at: workload.created_at, environment_id: workload.environment_id, + base_workload_id: workload.base_workload_id, name: workload.name, namespace: workload.namespace, kind: workload.kind, @@ -2483,6 +2486,7 @@ impl DbApi { id: workload.id, created_at: workload.created_at, environment_id: workload.environment_id, + base_workload_id: workload.base_workload_id, name: workload.name, namespace: workload.namespace, kind: workload.kind, @@ -2496,14 +2500,94 @@ impl DbApi { } } + pub async fn get_environment_workload_labels( + &self, + workload_id: Uuid, + ) -> Result> { + let rows = kube_environment_workload_label::Entity::find() + .filter(kube_environment_workload_label::Column::WorkloadId.eq(workload_id)) + .filter(kube_environment_workload_label::Column::DeletedAt.is_null()) + .all(&self.conn) + .await?; + + let mut map: BTreeMap = BTreeMap::new(); + + for row in rows { + map.insert(row.label_key, row.label_value); + } + + Ok(map) + } + + pub async fn get_workloads_by_base_workload_id( + &self, + base_workload_id: Uuid, + ) -> Result> { + let workloads = lapdev_db_entities::kube_environment_workload::Entity::find() + .filter( + lapdev_db_entities::kube_environment_workload::Column::BaseWorkloadId + .eq(base_workload_id), + ) + .filter(lapdev_db_entities::kube_environment_workload::Column::DeletedAt.is_null()) + .all(&self.conn) + .await?; + + let mut result = Vec::new(); + + for workload in workloads { + let containers: Vec = + if let Ok(containers) = serde_json::from_value(workload.containers.clone()) { + containers + } else { + vec![] + }; + + let ports: Vec = + if let Ok(ports) = serde_json::from_value(workload.ports.clone()) { + ports + } else { + vec![] + }; + + result.push(KubeEnvironmentWorkload { + id: workload.id, + created_at: workload.created_at, + environment_id: workload.environment_id, + base_workload_id: workload.base_workload_id, + name: workload.name, + namespace: workload.namespace, + kind: workload.kind, + containers, + ports, + workload_yaml: workload.workload_yaml, + catalog_sync_version: workload.catalog_sync_version, + }); + } + + Ok(result) + } + pub async fn delete_environment_workload(&self, workload_id: Uuid) -> Result<()> { + let timestamp = Utc::now().into(); + lapdev_db_entities::kube_environment_workload::ActiveModel { id: ActiveValue::Set(workload_id), - deleted_at: ActiveValue::Set(Some(Utc::now().into())), + deleted_at: ActiveValue::Set(Some(timestamp)), ..Default::default() } .update(&self.conn) .await?; + + kube_environment_workload_label::Entity::update_many() + .filter(kube_environment_workload_label::Column::WorkloadId.eq(workload_id)) + .filter(kube_environment_workload_label::Column::DeletedAt.is_null()) + .col_expr( + kube_environment_workload_label::Column::DeletedAt, + Expr::value(timestamp), + ) + .exec(&self.conn) + .await?; + Ok(()) } @@ -2522,6 +2606,20 @@ impl DbApi { } .update(&self.conn) .await?; + + if let Ok(kind) = updated_model.kind.parse::() { + let labels = labels_from_workload_yaml(&kind, &updated_model.workload_yaml); + let timestamp = Utc::now().into(); + replace_environment_workload_labels_with_conn( + &self.conn, + updated_model.id, + updated_model.environment_id, + &labels, + timestamp, + ) + .await?; + } + Ok(updated_model) } @@ -2576,31 +2674,56 @@ impl DbApi { // Create all associated workloads for workload in workloads { + let KubeWorkloadDetails { + name, + namespace: workload_namespace, + kind, + containers, + ports, + workload_yaml, + base_workload_id, + } = workload; + + let new_workload_id = Uuid::new_v4(); + let effective_base_workload_id = base_workload_id.unwrap_or(new_workload_id); + // Serialize all containers - let containers_json = serde_json::to_value(&workload.containers) + let containers_json = serde_json::to_value(&containers) .map(Json::from) .unwrap_or_else(|_| Json::from(serde_json::json!([]))); // Serialize ports - let ports_json = serde_json::to_value(&workload.ports) + let ports_json = serde_json::to_value(&ports) .map(Json::from) .unwrap_or_else(|_| Json::from(serde_json::json!([]))); + let labels = labels_from_workload_yaml(&kind, &workload_yaml); + lapdev_db_entities::kube_environment_workload::ActiveModel { - id: ActiveValue::Set(Uuid::new_v4()), + id: ActiveValue::Set(new_workload_id), created_at: ActiveValue::Set(created_at), deleted_at: ActiveValue::Set(None), environment_id: ActiveValue::Set(environment_id), - name: ActiveValue::Set(workload.name), - namespace: ActiveValue::Set(namespace.clone()), - kind: ActiveValue::Set(workload.kind.to_string()), + base_workload_id: ActiveValue::Set(Some(effective_base_workload_id)), + name: ActiveValue::Set(name), + namespace: ActiveValue::Set(workload_namespace), + kind: ActiveValue::Set(kind.to_string()), containers: ActiveValue::Set(containers_json), ports: ActiveValue::Set(ports_json), - workload_yaml: ActiveValue::Set(workload.workload_yaml.clone()), + workload_yaml: ActiveValue::Set(workload_yaml.clone()), catalog_sync_version: ActiveValue::Set(catalog_sync_version), } .insert(&txn) .await?; + + replace_environment_workload_labels_with_conn( + &txn, + new_workload_id, + environment_id, + &labels, + created_at, + ) + .await?; } // Create all associated services @@ -3506,6 +3629,43 @@ where Ok(()) } +async fn replace_environment_workload_labels_with_conn( + conn: &C, + workload_id: Uuid, + environment_id: Uuid, + labels: &BTreeMap, + timestamp: DateTimeWithTimeZone, +) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait + Send + Sync, +{ + kube_environment_workload_label::Entity::update_many() + .filter(kube_environment_workload_label::Column::WorkloadId.eq(workload_id)) + .filter(kube_environment_workload_label::Column::DeletedAt.is_null()) + .col_expr( + kube_environment_workload_label::Column::DeletedAt, + Expr::value(timestamp), + ) + .exec(conn) + .await?; + + for (key, value) in labels { + kube_environment_workload_label::ActiveModel { + id: ActiveValue::Set(Uuid::new_v4()), + created_at: ActiveValue::Set(timestamp), + deleted_at: ActiveValue::Set(None), + environment_id: ActiveValue::Set(environment_id), + workload_id: ActiveValue::Set(workload_id), + label_key: ActiveValue::Set(key.clone()), + label_value: ActiveValue::Set(value.clone()), + } + .insert(conn) + .await?; + } + + Ok(()) +} + async fn replace_workload_dependencies_with_conn( conn: &C, workload_id: Uuid, diff --git a/crates/kube-manager/src/sidecar_proxy_manager.rs b/crates/kube-manager/src/sidecar_proxy_manager.rs index 878fbd7..447429d 100644 --- a/crates/kube-manager/src/sidecar_proxy_manager.rs +++ b/crates/kube-manager/src/sidecar_proxy_manager.rs @@ -1,9 +1,7 @@ use anyhow::Result; use futures::StreamExt; use lapdev_common::kube::{DEFAULT_SIDECAR_PROXY_MANAGER_PORT, SIDECAR_PROXY_MANAGER_PORT_ENV_VAR}; -use lapdev_kube_rpc::{ - KubeClusterRpcClient, SidecarProxyManagerRpc, SidecarProxyRpcClient, -}; +use lapdev_kube_rpc::{KubeClusterRpcClient, SidecarProxyManagerRpc, SidecarProxyRpcClient}; use lapdev_rpc::spawn_twoway; use std::{collections::HashMap, sync::Arc}; use tarpc::server::{BaseChannel, Channel}; diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index c634c36..512b461 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -383,7 +383,7 @@ pub trait KubeClusterRpc { async fn list_branch_service_routes( environment_id: Uuid, workload_id: Uuid, - ) -> Result, String>; + ) -> Result, String>; } #[tarpc::service] @@ -481,16 +481,37 @@ pub struct DevboxRouteConfig { pub session_id: Uuid, pub target_port: u16, pub auth_token: String, + pub websocket_url: String, /// Path pattern for this route (e.g., "/*" for all traffic) pub path_pattern: String, + pub branch_environment_id: Option, + pub created_at_epoch_seconds: Option, + pub expires_at_epoch_seconds: Option, + pub port_mappings: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProxyRouteConfig { - pub path: String, - pub service_name: String, - pub port: u16, - pub branch_environment_id: Option, +pub struct ProxyBranchRouteConfig { + pub branch_environment_id: Uuid, + pub service_name: Option, + pub headers: HashMap, + pub requires_auth: bool, + pub access_level: ProxyRouteAccessLevel, + pub timeout_ms: Option, + pub devbox_route: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ProxyRouteAccessLevel { + Personal, + Shared, + Public, +} + +impl Default for ProxyRouteAccessLevel { + fn default() -> Self { + ProxyRouteAccessLevel::Personal + } } #[tarpc::service] @@ -498,7 +519,7 @@ pub trait SidecarProxyRpc { async fn heartbeat() -> Result<(), String>; /// Replace the service routes with the provided configuration - async fn set_service_routes(routes: Vec) -> Result<(), String>; + async fn set_service_routes(routes: Vec) -> Result<(), String>; /// Add a DevboxTunnel route for service interception /// Returns true if route was added successfully diff --git a/crates/kube-sidecar-proxy/src/config.rs b/crates/kube-sidecar-proxy/src/config.rs index 948ecc2..5930dc2 100644 --- a/crates/kube-sidecar-proxy/src/config.rs +++ b/crates/kube-sidecar-proxy/src/config.rs @@ -3,140 +3,267 @@ use std::collections::HashMap; use std::net::SocketAddr; use uuid::Uuid; -/// Configuration for the sidecar proxy +/// Immutable settings for the sidecar proxy determined at boot time. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProxyConfig { - /// Address to listen on for incoming requests +pub struct SidecarSettings { + /// Address the sidecar listens on for iptables redirected traffic. pub listen_addr: SocketAddr, + /// Namespace the sidecar is running in. + pub namespace: Option, + /// Pod name for identification/logging. + pub pod_name: Option, + /// Lapdev environment identifier for this workload. + pub environment_id: Uuid, + /// Lapdev environment scoped auth token used for RPC calls. + pub environment_auth_token: String, + /// Health check configuration for readiness/liveness. + pub health_check: HealthCheckConfig, + /// Metrics export configuration. + pub metrics: MetricsConfig, +} - /// Default target address for proxying requests - pub default_target: SocketAddr, +impl SidecarSettings { + pub fn new( + listen_addr: SocketAddr, + namespace: Option, + pod_name: Option, + environment_id: Uuid, + environment_auth_token: String, + ) -> Self { + Self { + listen_addr, + namespace, + pod_name, + environment_id, + environment_auth_token, + health_check: HealthCheckConfig::default(), + metrics: MetricsConfig::default(), + } + } +} - /// Kubernetes namespace to operate in - pub namespace: Option, +/// Mutable routing state shared between RPC handlers and the proxy loop. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutingTable { + pub branch_routes: HashMap, + pub default_route: DefaultRoute, +} - /// Pod name for self-identification - pub pod_name: Option, +impl Default for RoutingTable { + fn default() -> Self { + Self { + branch_routes: HashMap::new(), + default_route: DefaultRoute::default(), + } + } +} - /// Lapdev environment ID this proxy belongs to - pub environment_id: Option, +impl RoutingTable { + pub fn resolve_http(&self, port: u16, branch_id: Option) -> RouteDecision { + if let Some(branch_id) = branch_id { + if let Some(route) = self.branch_routes.get(&branch_id) { + match &route.mode { + BranchMode::Devbox(devbox) => { + return RouteDecision::BranchDevbox { + target_port: devbox.resolve_target_port(port), + route: devbox.clone(), + }; + } + BranchMode::Service => { + return RouteDecision::BranchService { + service: route.service.clone(), + }; + } + } + } + } - /// Lapdev environment auth token - pub environment_auth_token: Option, + if let DefaultRoute::Devbox(route) = &self.default_route { + return RouteDecision::DefaultDevbox { + target_port: route.resolve_target_port(port), + route: route.clone(), + }; + } - /// Route configurations - pub routes: Vec, + RouteDecision::DefaultLocal + } - /// Health check configuration - pub health_check: HealthCheckConfig, + pub fn resolve_tcp(&self, port: u16) -> RouteDecision { + if let DefaultRoute::Devbox(route) = &self.default_route { + return RouteDecision::DefaultDevbox { + target_port: route.resolve_target_port(port), + route: route.clone(), + }; + } - /// Metrics configuration - pub metrics: MetricsConfig, -} + RouteDecision::DefaultLocal + } -/// Individual route configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RouteConfig { - /// Path matcher (supports wildcards) - pub path: String, + pub fn replace_branch_routes( + &mut self, + routes: impl IntoIterator, + ) { + let mut new_routes = HashMap::new(); + + for (env_id, service_route) in routes { + if let Some(existing) = self.branch_routes.get(&env_id) { + let mode = match &existing.mode { + BranchMode::Devbox(devbox) => BranchMode::Devbox(devbox.clone()), + BranchMode::Service => BranchMode::Service, + }; + new_routes.insert( + env_id, + BranchRoute { + service: service_route, + mode, + }, + ); + continue; + } + new_routes.insert( + env_id, + BranchRoute { + service: service_route, + mode: BranchMode::Service, + }, + ); + } - /// Target service to route to - pub target: RouteTarget, + self.branch_routes = new_routes; + } - /// Optional branch environment identifier this route applies to - pub branch_environment_id: Option, + pub fn set_branch_devbox(&mut self, branch_id: &Uuid, devbox: DevboxRoute) -> bool { + let Some(entry) = self.branch_routes.get_mut(branch_id) else { + return false; + }; - /// Optional headers to add/modify - pub headers: HashMap, + entry.mode = BranchMode::Devbox(devbox); + true + } - /// Timeout for this route in milliseconds - pub timeout_ms: Option, + pub fn remove_branch_devbox_by_intercept(&mut self, intercept_id: &Uuid) -> Option { + for (branch_id, route) in self.branch_routes.iter_mut() { + if let BranchMode::Devbox(devbox) = &route.mode { + if devbox.intercept_id == *intercept_id { + route.mode = BranchMode::Service; + return Some(*branch_id); + } + } + } + None + } - /// Whether this route requires authentication - pub requires_auth: bool, + pub fn set_default_devbox(&mut self, route: DevboxRoute) { + self.default_route = DefaultRoute::Devbox(route); + } - /// Access level for this route + pub fn clear_default_devbox(&mut self) { + self.default_route = DefaultRoute::Local; + } + + pub fn default_devbox(&self) -> Option<&DevboxRoute> { + match &self.default_route { + DefaultRoute::Devbox(route) => Some(route), + DefaultRoute::Local => None, + } + } + + pub fn remove_default_devbox_by_intercept(&mut self, intercept_id: &Uuid) -> bool { + match &self.default_route { + DefaultRoute::Devbox(route) if &route.intercept_id == intercept_id => { + self.default_route = DefaultRoute::Local; + true + } + _ => false, + } + } +} + +#[derive(Debug, Clone)] +pub struct BranchRoute { + pub service: BranchServiceRoute, + pub mode: BranchMode, +} + +#[derive(Debug, Clone)] +pub struct BranchServiceRoute { + pub service_name: String, + pub headers: HashMap, + pub requires_auth: bool, pub access_level: AccessLevel, + pub timeout_ms: Option, +} + +impl BranchServiceRoute { + pub fn new_service(service_name: String) -> Self { + Self { + service_name, + headers: HashMap::new(), + requires_auth: true, + access_level: AccessLevel::Personal, + timeout_ms: None, + } + } +} + +#[derive(Debug, Clone)] +pub enum BranchMode { + Service, + Devbox(DevboxRoute), } -/// Target for routing #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum RouteTarget { - /// Direct address - Address(SocketAddr), +pub struct DevboxRoute { + pub intercept_id: Uuid, + pub session_id: Uuid, + pub target_port: u16, + pub auth_token: String, + pub websocket_url: String, + pub path_pattern: String, + pub port_mappings: HashMap, + pub created_at_epoch_seconds: Option, + pub expires_at_epoch_seconds: Option, +} - /// Kubernetes service - Service { name: String, port: u16 }, +impl DevboxRoute { + pub fn resolve_target_port(&self, original_port: u16) -> u16 { + self.port_mappings + .get(&original_port) + .copied() + .unwrap_or(original_port) + } +} - /// Load balance across multiple targets - LoadBalance(Vec), +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DefaultRoute { + Local, + Devbox(DevboxRoute), +} - /// Devbox tunnel for service interception - /// Routes traffic to a developer's local machine via WebSocket tunnel - DevboxTunnel { - intercept_id: Uuid, - session_id: Uuid, - target_port: u16, - auth_token: String, // short-lived token for tunnel authentication - }, +impl Default for DefaultRoute { + fn default() -> Self { + DefaultRoute::Local + } } -/// Access level for routes (matching Lapdev's preview URL access levels) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum AccessLevel { /// Only accessible by the owner with authentication Personal, - /// Accessible by organization members with authentication + /// Accessible by organization members with authentication Shared, /// Accessible by anyone without authentication Public, } -/// Health check configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthCheckConfig { - /// Health check endpoint path pub path: String, - - /// Interval between health checks in seconds pub interval_seconds: u64, - - /// Timeout for health checks in milliseconds pub timeout_ms: u64, - - /// Number of consecutive failures before marking unhealthy pub failure_threshold: u32, } -/// Metrics configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MetricsConfig { - /// Whether to enable metrics collection - pub enabled: bool, - - /// Metrics endpoint path - pub path: String, - - /// Port to serve metrics on (if different from main port) - pub port: Option, -} - -impl Default for ProxyConfig { - fn default() -> Self { - Self { - listen_addr: "0.0.0.0:8080".parse().unwrap(), - default_target: "127.0.0.1:3000".parse().unwrap(), - namespace: None, - pod_name: None, - environment_id: None, - environment_auth_token: None, - routes: Vec::new(), - health_check: HealthCheckConfig::default(), - metrics: MetricsConfig::default(), - } - } -} - impl Default for HealthCheckConfig { fn default() -> Self { Self { @@ -148,6 +275,13 @@ impl Default for HealthCheckConfig { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MetricsConfig { + pub enabled: bool, + pub path: String, + pub port: Option, +} + impl Default for MetricsConfig { fn default() -> Self { Self { @@ -158,6 +292,22 @@ impl Default for MetricsConfig { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum RouteDecision { + BranchService { + service: BranchServiceRoute, + }, + BranchDevbox { + route: DevboxRoute, + target_port: u16, + }, + DefaultDevbox { + route: DevboxRoute, + target_port: u16, + }, + DefaultLocal, +} + /// Kubernetes annotations used for configuration pub struct ProxyAnnotations; diff --git a/crates/kube-sidecar-proxy/src/lib.rs b/crates/kube-sidecar-proxy/src/lib.rs index f2d8ab0..616cbf9 100644 --- a/crates/kube-sidecar-proxy/src/lib.rs +++ b/crates/kube-sidecar-proxy/src/lib.rs @@ -3,7 +3,6 @@ pub mod error; pub mod original_dest; pub mod otel_routing; pub mod protocol_detector; -pub mod proxy; pub mod rpc; pub mod server; diff --git a/crates/kube-sidecar-proxy/src/main.rs b/crates/kube-sidecar-proxy/src/main.rs index b4814c0..20f306c 100644 --- a/crates/kube-sidecar-proxy/src/main.rs +++ b/crates/kube-sidecar-proxy/src/main.rs @@ -11,10 +11,6 @@ struct Args { #[arg(long, default_value = "0.0.0.0:8080")] listen_addr: String, - /// Target service address (where to proxy requests) - #[arg(long, default_value = "127.0.0.1:3000")] - target_addr: String, - /// Kubernetes namespace to watch for services #[arg(long, env = "KUBERNETES_NAMESPACE")] namespace: Option, @@ -53,7 +49,6 @@ async fn main() -> Result<()> { info!("Starting Lapdev Kubernetes Sidecar Proxy"); info!("Listen address: {}", args.listen_addr); - info!("Target address: {}", args.target_addr); info!("Namespace: {:?}", args.namespace); info!("Pod name: {:?}", args.pod_name); @@ -69,7 +64,6 @@ async fn main() -> Result<()> { let server = SidecarProxyServer::new( args.listen_addr.parse()?, - args.target_addr.parse()?, args.namespace, args.pod_name, environment_id, diff --git a/crates/kube-sidecar-proxy/src/rpc.rs b/crates/kube-sidecar-proxy/src/rpc.rs index 8b628be..b4d0df5 100644 --- a/crates/kube-sidecar-proxy/src/rpc.rs +++ b/crates/kube-sidecar-proxy/src/rpc.rs @@ -1,13 +1,14 @@ use anyhow::Result; use lapdev_kube_rpc::{ - DevboxRouteConfig, ProxyRouteConfig, SidecarProxyManagerRpcClient, SidecarProxyRpc, + DevboxRouteConfig, ProxyBranchRouteConfig, ProxyRouteAccessLevel, SidecarProxyManagerRpcClient, + SidecarProxyRpc, }; use std::sync::Arc; use tokio::sync::RwLock; use tracing::{info, warn}; use uuid::Uuid; -use crate::config::{AccessLevel, ProxyConfig, RouteConfig, RouteTarget}; +use crate::config::{AccessLevel, BranchMode, BranchServiceRoute, DevboxRoute, RoutingTable}; #[derive(Clone)] pub(crate) struct SidecarProxyRpcServer { @@ -15,7 +16,7 @@ pub(crate) struct SidecarProxyRpcServer { environment_id: Uuid, namespace: String, rpc_client: SidecarProxyManagerRpcClient, - config: Arc>, + routing_table: Arc>, } impl SidecarProxyRpcServer { @@ -24,14 +25,14 @@ impl SidecarProxyRpcServer { environment_id: Uuid, namespace: String, rpc_client: SidecarProxyManagerRpcClient, - config: Arc>, + routing_table: Arc>, ) -> Self { Self { workload_id, environment_id, namespace, rpc_client, - config, + routing_table, } } @@ -61,33 +62,45 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { async fn set_service_routes( self, _context: ::tarpc::context::Context, - routes: Vec, + routes: Vec, ) -> Result<(), String> { - let mut config = self.config.write().await; - - // Retain existing DevboxTunnel routes, remove all other service routes - config - .routes - .retain(|route| matches!(route.target, RouteTarget::DevboxTunnel { .. })); + let mut routing_table = self.routing_table.write().await; + let mut updates: Vec<(Uuid, BranchServiceRoute)> = Vec::new(); + let mut devbox_overrides: Vec<(Uuid, DevboxRoute)> = Vec::new(); for route in routes { - config.routes.push(RouteConfig { - path: route.path.clone(), - target: RouteTarget::Service { - name: route.service_name.clone(), - port: route.port, - }, - branch_environment_id: route.branch_environment_id, - headers: std::collections::HashMap::new(), - timeout_ms: None, - requires_auth: true, - access_level: AccessLevel::Personal, - }); + let branch_id = route.branch_environment_id; + + if let Some(service_name) = route.service_name.clone() { + let service_route = BranchServiceRoute { + service_name, + headers: route.headers.clone(), + requires_auth: route.requires_auth, + access_level: access_level_from_proxy(route.access_level), + timeout_ms: route.timeout_ms, + }; + updates.push((branch_id, service_route)); + } + + if let Some(devbox) = route.devbox_route.clone() { + devbox_overrides.push((branch_id, devbox_route_from_config(devbox))); + } + } + + routing_table.replace_branch_routes(updates); + + for (branch_id, devbox) in devbox_overrides { + if !routing_table.set_branch_devbox(&branch_id, devbox) { + warn!( + "Received devbox override for unknown branch environment {}", + branch_id + ); + } } info!( - "Updated service routes; total routes: {}", - config.routes.len() + "Updated branch routes; total routes: {}", + routing_table.branch_routes.len() ); Ok(()) @@ -103,53 +116,30 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { route.intercept_id, route.session_id, route.target_port, route.path_pattern ); - let mut config = self.config.write().await; - - // Check if route already exists - for existing_route in &config.routes { - if let RouteTarget::DevboxTunnel { intercept_id, .. } = &existing_route.target { - if intercept_id == &route.intercept_id { - warn!( - "Devbox route for intercept_id={} already exists, replacing", - route.intercept_id - ); - // Remove old route before adding new one - config.routes.retain(|r| { - if let RouteTarget::DevboxTunnel { intercept_id, .. } = &r.target { - intercept_id != &route.intercept_id - } else { - true - } - }); - break; - } + let mut routing_table = self.routing_table.write().await; + let devbox_route = devbox_route_from_config(route.clone()); + + if let Some(branch_id) = route.branch_environment_id { + if routing_table.set_branch_devbox(&branch_id, devbox_route.clone()) { + info!( + "Attached devbox route to branch {} (intercept_id={})", + branch_id, route.intercept_id + ); + } else { + warn!( + "Received branch devbox route for unknown environment {}", + branch_id + ); + return Ok(false); } + } else { + routing_table.set_default_devbox(devbox_route.clone()); + info!( + "Registered default devbox route (intercept_id={}, default_target_port={})", + route.intercept_id, route.target_port + ); } - // Add new DevboxTunnel route - let new_route = RouteConfig { - path: route.path_pattern.clone(), - target: RouteTarget::DevboxTunnel { - intercept_id: route.intercept_id, - session_id: route.session_id, - target_port: route.target_port, - auth_token: route.auth_token, - }, - branch_environment_id: None, - headers: std::collections::HashMap::new(), - timeout_ms: Some(30000), - requires_auth: true, - access_level: AccessLevel::Personal, - }; - - config.routes.push(new_route); - - info!( - "Successfully added devbox route for intercept_id={}. Total routes: {}", - route.intercept_id, - config.routes.len() - ); - Ok(true) } @@ -160,61 +150,46 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { ) -> Result { info!("Removing devbox route: intercept_id={}", intercept_id); - let mut config = self.config.write().await; - let before_count = config.routes.len(); - - // Remove routes matching the intercept_id - config.routes.retain(|route| { - if let RouteTarget::DevboxTunnel { - intercept_id: id, .. - } = &route.target - { - id != &intercept_id - } else { - true - } - }); - - let after_count = config.routes.len(); - let removed = before_count > after_count; + let mut routing_table = self.routing_table.write().await; - if removed { + if let Some(branch_id) = routing_table.remove_branch_devbox_by_intercept(&intercept_id) { info!( - "Successfully removed devbox route for intercept_id={}. Routes remaining: {}", - intercept_id, after_count + "Removed branch devbox route for environment {} (intercept_id={})", + branch_id, intercept_id ); - } else { - warn!( - "No devbox route found for intercept_id={} to remove", + return Ok(true); + } + + if routing_table.remove_default_devbox_by_intercept(&intercept_id) { + info!( + "Removed default devbox route (intercept_id={})", intercept_id ); + return Ok(true); } - Ok(removed) + warn!( + "No devbox route found for intercept_id={} to remove", + intercept_id + ); + + Ok(false) } async fn list_devbox_routes( self, _context: ::tarpc::context::Context, ) -> Result, String> { - let config = self.config.read().await; + let routing_table = self.routing_table.read().await; let mut routes = Vec::new(); - for route in &config.routes { - if let RouteTarget::DevboxTunnel { - intercept_id, - session_id, - target_port, - auth_token, - } = &route.target - { - routes.push(DevboxRouteConfig { - intercept_id: *intercept_id, - session_id: *session_id, - target_port: *target_port, - auth_token: auth_token.clone(), - path_pattern: route.path.clone(), - }); + if let Some(route) = routing_table.default_devbox() { + routes.push(devbox_route_config_from_route(route, None)); + } + + for (branch_id, branch_route) in &routing_table.branch_routes { + if let crate::config::BranchMode::Devbox(devbox) = &branch_route.mode { + routes.push(devbox_route_config_from_route(devbox, Some(*branch_id))); } } @@ -222,3 +197,43 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { Ok(routes) } } + +fn devbox_route_from_config(route: DevboxRouteConfig) -> DevboxRoute { + DevboxRoute { + intercept_id: route.intercept_id, + session_id: route.session_id, + target_port: route.target_port, + auth_token: route.auth_token, + websocket_url: route.websocket_url, + path_pattern: route.path_pattern, + port_mappings: route.port_mappings, + created_at_epoch_seconds: route.created_at_epoch_seconds, + expires_at_epoch_seconds: route.expires_at_epoch_seconds, + } +} + +fn devbox_route_config_from_route( + route: &DevboxRoute, + branch_id: Option, +) -> DevboxRouteConfig { + DevboxRouteConfig { + intercept_id: route.intercept_id, + session_id: route.session_id, + target_port: route.target_port, + auth_token: route.auth_token.clone(), + websocket_url: route.websocket_url.clone(), + path_pattern: route.path_pattern.clone(), + branch_environment_id: branch_id, + created_at_epoch_seconds: route.created_at_epoch_seconds, + expires_at_epoch_seconds: route.expires_at_epoch_seconds, + port_mappings: route.port_mappings.clone(), + } +} + +fn access_level_from_proxy(level: ProxyRouteAccessLevel) -> AccessLevel { + match level { + ProxyRouteAccessLevel::Personal => AccessLevel::Personal, + ProxyRouteAccessLevel::Shared => AccessLevel::Shared, + ProxyRouteAccessLevel::Public => AccessLevel::Public, + } +} diff --git a/crates/kube-sidecar-proxy/src/server.rs b/crates/kube-sidecar-proxy/src/server.rs index 816c879..a6c7d6b 100644 --- a/crates/kube-sidecar-proxy/src/server.rs +++ b/crates/kube-sidecar-proxy/src/server.rs @@ -1,5 +1,5 @@ use crate::{ - config::{ProxyConfig, RouteTarget}, + config::{BranchServiceRoute, DevboxRoute, RouteDecision, RoutingTable, SidecarSettings}, error::Result, original_dest::get_original_destination, otel_routing::{determine_routing_target, extract_routing_context}, @@ -31,11 +31,9 @@ use uuid::Uuid; #[derive(Clone)] pub struct SidecarProxyServer { workload_id: Uuid, - environment_id: Uuid, - namespace: Option, sidecar_proxy_manager_addr: String, - listen_addr: SocketAddr, - config: Arc>, + settings: Arc, + routing_table: Arc>, /// RPC client to kube-manager (None until connection established) rpc_client: Arc>>, devbox_tunnel_manager: Arc, @@ -45,7 +43,6 @@ impl SidecarProxyServer { /// Create a new sidecar proxy server pub async fn new( listen_addr: SocketAddr, - default_target: SocketAddr, namespace: Option, pod_name: Option, environment_id: String, @@ -79,26 +76,19 @@ impl SidecarProxyServer { let env_id = Uuid::parse_str(&environment_id) .map_err(|e| anyhow!("Failed to parse environment_id as UUID: {}", e))?; - // Create initial configuration - let config = ProxyConfig { + let settings = SidecarSettings::new( listen_addr, - default_target, - namespace: namespace.clone(), - pod_name: pod_name.clone(), - environment_id: Some(env_id), - environment_auth_token: Some(environment_auth_token), - ..Default::default() - }; - - let config = Arc::new(RwLock::new(config)); + namespace.clone(), + pod_name.clone(), + env_id, + environment_auth_token, + ); let server = Self { workload_id, - environment_id: env_id, - namespace: namespace.clone(), sidecar_proxy_manager_addr, - listen_addr, - config, + settings: Arc::new(settings), + routing_table: Arc::new(RwLock::new(RoutingTable::default())), rpc_client: Arc::new(RwLock::new(None)), devbox_tunnel_manager: Arc::new(DevboxTunnelManager::new()), }; @@ -120,11 +110,12 @@ impl SidecarProxyServer { } // Create TCP listener for iptables-redirected connections - let listener = TcpListener::bind(&self.listen_addr).await?; - info!("Sidecar proxy listening on: {}", self.listen_addr); + let listen_addr = self.settings.as_ref().listen_addr; + let listener = TcpListener::bind(&listen_addr).await?; + info!("Sidecar proxy listening on: {}", listen_addr); // Handle connections - let config_for_server = Arc::clone(&self.config); + let routing_table_for_server = Arc::clone(&self.routing_table); let rpc_client_for_server = Arc::clone(&self.rpc_client); let tunnel_manager_for_server = Arc::clone(&self.devbox_tunnel_manager); let server = async move { @@ -132,7 +123,7 @@ impl SidecarProxyServer { match listener.accept().await { Ok((inbound_stream, client_addr)) => { debug!("Accepted connection from {}", client_addr); - let config = Arc::clone(&config_for_server); + let routing_table = Arc::clone(&routing_table_for_server); let rpc_client = Arc::clone(&rpc_client_for_server); let tunnel_manager = Arc::clone(&tunnel_manager_for_server); @@ -140,7 +131,7 @@ impl SidecarProxyServer { if let Err(e) = handle_connection( inbound_stream, client_addr, - config, + routing_table, rpc_client, tunnel_manager, ) @@ -223,15 +214,17 @@ impl SidecarProxyServer { } let namespace = self + .settings + .as_ref() .namespace .clone() .unwrap_or_else(|| "default".to_string()); let rpc_server = SidecarProxyRpcServer::new( self.workload_id, - self.environment_id, + self.settings.as_ref().environment_id, namespace, rpc_client, - Arc::clone(&self.config), + Arc::clone(&self.routing_table), ); let rpc_server_clone = rpc_server.clone(); let rpc_server_task = tokio::spawn(async move { @@ -264,18 +257,13 @@ impl SidecarProxyServer { let shutdown_signal = std::future::pending::<()>(); self.serve_with_graceful_shutdown(shutdown_signal).await } - - /// Get the current configuration - pub async fn get_config(&self) -> ProxyConfig { - self.config.read().await.clone() - } } /// Handle a single TCP connection by extracting original destination and forwarding async fn handle_connection( mut inbound_stream: TcpStream, client_addr: SocketAddr, - config: Arc>, + routing_table: Arc>, rpc_client: Arc>>, tunnel_manager: Arc, ) -> io::Result<()> { @@ -293,27 +281,6 @@ async fn handle_connection( debug!("Original destination: {} -> {}", client_addr, original_dest); - // Check if there's a DevboxTunnel route for this destination - if let Some(devbox_route) = check_devbox_tunnel_route(&config, original_dest.port()).await { - info!( - "Devbox intercept detected for port {}: intercept_id={}, routing to devbox", - original_dest.port(), - devbox_route.intercept_id - ); - return handle_devbox_tunnel( - inbound_stream, - client_addr, - original_dest, - devbox_route, - rpc_client, - tunnel_manager, - ) - .await; - } - - // Convert to local address (same port, localhost) - let local_target = SocketAddr::new("127.0.0.1".parse().unwrap(), original_dest.port()); - // Detect protocol by reading initial data let (protocol_type, initial_data) = match detect_protocol(&mut inbound_stream).await { Ok(result) => result, @@ -327,18 +294,54 @@ async fn handle_connection( ProtocolType::Http { .. } => { handle_http_proxy( inbound_stream, + client_addr, original_dest, initial_data, - Arc::clone(&config), + Arc::clone(&routing_table), + rpc_client, + tunnel_manager, ) .await } ProtocolType::Tcp => { - info!( - "Proxying TCP from {} -> {} (local: {})", - client_addr, original_dest, local_target - ); - handle_tcp_proxy(inbound_stream, local_target, initial_data).await + let decision = { + let table = routing_table.read().await; + table.resolve_tcp(original_dest.port()) + }; + + match decision { + RouteDecision::DefaultDevbox { route, target_port } + | RouteDecision::BranchDevbox { route, target_port } => { + info!( + "TCP {} -> {} intercepted by Devbox (intercept_id={}, session_id={}, target_port={})", + client_addr, + original_dest, + route.intercept_id, + route.session_id, + target_port + ); + handle_devbox_tunnel( + inbound_stream, + client_addr, + original_dest, + initial_data, + target_port, + route, + rpc_client, + tunnel_manager, + ) + .await + } + _ => { + let local_target = + SocketAddr::new("127.0.0.1".parse().unwrap(), original_dest.port()); + info!( + "Proxying TCP from {} -> {} (local: {})", + client_addr, original_dest, local_target + ); + handle_tcp_proxy(inbound_stream, local_target, initial_data).await + } + } } } } @@ -346,9 +349,12 @@ async fn handle_connection( /// Handle HTTP proxying with OpenTelemetry header parsing and intelligent routing async fn handle_http_proxy( mut inbound_stream: TcpStream, + client_addr: SocketAddr, original_dest: SocketAddr, mut initial_data: Vec, - config: Arc>, + routing_table: Arc>, + rpc_client: Arc>>, + tunnel_manager: Arc, ) -> io::Result<()> { // Try to parse the HTTP request, reading more data if needed let (http_request, _body_start) = match http_parser::parse_complete_http_request( @@ -373,31 +379,85 @@ async fn handle_http_proxy( let routing_context = extract_routing_context(&http_request.headers); let fallback_target = SocketAddr::new("127.0.0.1".parse().unwrap(), original_dest.port()); - if let Some(environment_id) = routing_context.lapdev_environment_id { - if let Some((service_name, branch_port)) = - find_branch_route(&config, &environment_id, original_dest.port()).await - { + let branch_id = routing_context.lapdev_environment_id; + let decision = { + let table = routing_table.read().await; + table.resolve_http(original_dest.port(), branch_id) + }; + + match decision { + RouteDecision::BranchService { service } => { + let service_name = &service.service_name; info!( - "HTTP {} {} routing to branch {} service {}:{}", - http_request.method, http_request.path, environment_id, service_name, branch_port + "HTTP {} {} routing to branch {:?} service {}:{}", + http_request.method, + http_request.path, + branch_id, + service_name, + original_dest.port() ); if let Err(err) = proxy_branch_stream( &mut inbound_stream, service_name.as_str(), - branch_port, + original_dest.port(), &initial_data, ) .await { warn!( - "Branch route {}:{} for env {} failed: {}; falling back to shared target", - service_name, branch_port, environment_id, err + "Branch route {} for env {:?} failed: {}; falling back to shared target", + service_name, branch_id, err ); } else { return Ok(()); } } + RouteDecision::BranchDevbox { route, target_port } => { + info!( + "HTTP {} {} intercepted by branch devbox (env {:?}, intercept_id={}, session_id={}, target_port={})", + http_request.method, + http_request.path, + branch_id, + route.intercept_id, + route.session_id, + target_port + ); + return handle_devbox_tunnel( + inbound_stream, + client_addr, + original_dest, + initial_data, + target_port, + route, + rpc_client, + tunnel_manager, + ) + .await; + } + RouteDecision::DefaultDevbox { route, target_port } => { + info!( + "HTTP {} {} intercepted by shared devbox (port {}, intercept_id={}, session_id={}, target_port={})", + http_request.method, + http_request.path, + original_dest.port(), + route.intercept_id, + route.session_id, + target_port + ); + return handle_devbox_tunnel( + inbound_stream, + client_addr, + original_dest, + initial_data, + target_port, + route, + rpc_client, + tunnel_manager, + ) + .await; + } + RouteDecision::DefaultLocal => {} } // Determine routing target based on headers for fallback logging @@ -470,30 +530,6 @@ async fn proxy_branch_stream( Ok(()) } -async fn find_branch_route( - config: &Arc>, - environment_id: &Uuid, - port: u16, -) -> Option<(String, u16)> { - let config = config.read().await; - - for route in &config.routes { - if route.branch_environment_id == Some(*environment_id) { - if let RouteTarget::Service { - name, - port: route_port, - } = &route.target - { - if *route_port == port { - return Some((name.clone(), *route_port)); - } - } - } - } - - None -} - /// Handle TCP proxying (raw byte forwarding) async fn handle_tcp_proxy( mut inbound_stream: TcpStream, @@ -524,47 +560,6 @@ async fn handle_tcp_proxy( Ok(()) } -/// DevboxTunnel route information -#[derive(Debug, Clone)] -struct DevboxRouteInfo { - intercept_id: Uuid, - session_id: Uuid, - target_port: u16, -} - -/// Check if there's a DevboxTunnel route for the given port -async fn check_devbox_tunnel_route( - config: &Arc>, - port: u16, -) -> Option { - use crate::config::RouteTarget; - - let config = config.read().await; - - // Check all routes for DevboxTunnel targeting this port - for route in &config.routes { - if let RouteTarget::DevboxTunnel { - intercept_id, - session_id, - target_port, - auth_token: _, - } = &route.target - { - // Match if the original destination port corresponds to this intercept - // In a real implementation, you might also check the path pattern - if *target_port == port { - return Some(DevboxRouteInfo { - intercept_id: *intercept_id, - session_id: *session_id, - target_port: *target_port, - }); - } - } - } - - None -} - #[derive(Clone)] struct DevboxTunnelManager { commands: mpsc::UnboundedSender, @@ -747,73 +742,38 @@ where /// Handle a connection that should be routed through a devbox tunnel /// -/// Architecture: Hybrid control/data plane -/// Control: Sidecar → Kube-Manager (RPC) → API (setup tunnel) -/// Data: Sidecar → API (WebSocket) ↔ Devbox (direct streaming) +/// Architecture: +/// Sidecar opens a websocket directly to the Lapdev API using the intercept session token, +/// then proxies data between the cluster workload and the developer's machine over that tunnel. async fn handle_devbox_tunnel( mut inbound_stream: TcpStream, client_addr: SocketAddr, original_dest: SocketAddr, - devbox_route: DevboxRouteInfo, - rpc_client: Arc>>, + mut initial_data: Vec, + target_port: u16, + devbox_route: DevboxRoute, + _rpc_client: Arc>>, tunnel_manager: Arc, ) -> io::Result<()> { info!( - "Routing {} -> {} through devbox tunnel (intercept_id={}, session_id={})", - client_addr, original_dest, devbox_route.intercept_id, devbox_route.session_id + "Routing {} -> {} through devbox tunnel (intercept_id={}, session_id={}, target_port={})", + client_addr, original_dest, devbox_route.intercept_id, devbox_route.session_id, target_port ); - // Get RPC client - let client = { - let lock = rpc_client.read().await; - lock.clone() - }; - - let client = match client { - Some(c) => c, - None => { - error!("RPC client not available - cannot establish devbox tunnel"); - return Err(io::Error::new( - io::ErrorKind::NotConnected, - "RPC client not connected to kube-manager", - )); - } - }; - - // Step 1: Call RPC to get tunnel info (control plane) - info!( - "Requesting tunnel setup from kube-manager for intercept_id={}", - devbox_route.intercept_id - ); - let tunnel_info = client - .request_devbox_tunnel( - tarpc::context::current(), - devbox_route.intercept_id, - client_addr.to_string(), - devbox_route.target_port, - ) - .await - .map_err(|e| { - error!("Failed to request devbox tunnel: {}", e); - io::Error::new(io::ErrorKind::Other, format!("RPC error: {}", e)) - })? - .map_err(|e| { - error!("Kube-manager rejected tunnel request: {}", e); - io::Error::new(io::ErrorKind::PermissionDenied, e) - })?; + let websocket_url = devbox_route.websocket_url.clone(); info!( - "Tunnel setup successful: tunnel_id={}, connecting to {}", - tunnel_info.tunnel_id, tunnel_info.websocket_url + "Connecting to devbox tunnel websocket for intercept_id={} at {}", + devbox_route.intercept_id, websocket_url ); let mut devbox_stream = match tunnel_manager .connect_tcp_stream( devbox_route.session_id, - &tunnel_info.websocket_url, - &tunnel_info.auth_token, + &websocket_url, + &devbox_route.auth_token, "127.0.0.1", - devbox_route.target_port, + target_port, ) .await { @@ -827,6 +787,10 @@ async fn handle_devbox_tunnel( } }; + if !initial_data.is_empty() { + tokio::io::AsyncWriteExt::write_all(&mut devbox_stream, &initial_data).await?; + } + match copy_bidirectional(&mut inbound_stream, &mut devbox_stream).await { Ok((bytes_tx, bytes_rx)) => { info!( diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index e64f20e..524472a 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -14,15 +14,13 @@ use lapdev_db_entities::{ kube_app_catalog_workload_label, }; use lapdev_kube_rpc::{ - KubeClusterRpc, KubeManagerRpcClient, ProxyRouteConfig, ResourceChangeEvent, - ResourceChangeType, ResourceType, + KubeClusterRpc, KubeManagerRpcClient, ProxyBranchRouteConfig, ProxyRouteAccessLevel, + ResourceChangeEvent, ResourceChangeType, ResourceType, }; use sea_orm::prelude::{DateTimeWithTimeZone, Json}; use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; use serde_json::json; -use serde_yaml::Value; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::convert::TryFrom; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; @@ -254,7 +252,7 @@ impl KubeClusterRpc for KubeClusterServer { _context: ::tarpc::context::Context, environment_id: Uuid, workload_id: Uuid, - ) -> Result, String> { + ) -> Result, String> { let environment = self .db .get_kube_environment(environment_id) @@ -269,80 +267,80 @@ impl KubeClusterRpc for KubeClusterServer { )); } - let workload = self + let _workload = self .db .get_environment_workload(workload_id) .await .map_err(|e| format!("Failed to fetch workload {}: {}", workload_id, e))? .ok_or_else(|| format!("Workload {} not found", workload_id))?; - let base_env_id = environment.base_environment_id.unwrap_or(environment.id); - let base_workload_id = - extract_label_uuid(&workload.workload_yaml, "lapdev.base-workload-id") - .unwrap_or(workload.id); - let base_workload_name = - extract_label_string(&workload.workload_yaml, "lapdev.base-workload") - .unwrap_or_else(|| workload.name.clone()); + let workload_labels = self + .db + .get_environment_workload_labels(workload_id) + .await + .map_err(|e| format!("Failed to fetch labels for workload {}: {}", workload_id, e))?; - let mut routes = Vec::new(); + let shared_services = self + .db + .get_matching_cluster_services( + self.cluster_id, + &environment.namespace, + &workload_labels, + ) + .await + .map_err(|e| { + format!( + "Failed to resolve services for workload {} in namespace {}: {}", + workload_id, environment.namespace, e + ) + })?; - let mut target_environments = Vec::new(); - target_environments.push(base_env_id); + let branch_workloads = self + .db + .get_workloads_by_base_workload_id(workload_id) + .await + .map_err(|e| { + format!( + "Failed to fetch workloads for base workload {}: {}", + workload_id, e + ) + })?; - let branch_environments = - self.db - .get_branch_environments(base_env_id) - .await - .map_err(|e| { - format!( - "Failed to fetch branch environments for {}: {}", - base_env_id, e - ) - })?; + let mut routes = Vec::new(); - for branch in branch_environments { - if branch.cluster_id == self.cluster_id { - target_environments.push(branch.id); - } - } + for branch_workload in branch_workloads { + let branch_env_id = branch_workload.environment_id; - let base_workload_id_str = base_workload_id.to_string(); + if branch_env_id == environment_id { + continue; + } - for env_id in target_environments { - let services = self + let Some(environment) = self .db - .get_environment_services(env_id) + .get_kube_environment(branch_env_id) .await - .map_err(|e| { - format!("Failed to fetch services for environment {}: {}", env_id, e) - })?; + .map_err(|e| format!("Failed to fetch environment {}: {}", branch_env_id, e))? + else { + continue; + }; - for service in services { - if !service_matches_workload( - &service.yaml, - &base_workload_id_str, - &base_workload_name, - &service.selector, - ) { - continue; - } + if environment.cluster_id != self.cluster_id { + continue; + } - for port in service.ports { - let Ok(port_number) = u16::try_from(port.port) else { - continue; - }; - - routes.push(ProxyRouteConfig { - path: format!("/{}/*", service.name), - service_name: service.name.clone(), - port: port_number, - branch_environment_id: if env_id == base_env_id { - None - } else { - Some(env_id) - }, - }); - } + let branch_suffix = format!("-{}", branch_env_id); + + for service in &shared_services { + let branch_service_name = format!("{}{branch_suffix}", service.name); + routes.push(ProxyBranchRouteConfig { + branch_environment_id: branch_env_id, + service_name: Some(branch_service_name), + headers: HashMap::new(), + requires_auth: true, + access_level: ProxyRouteAccessLevel::Personal, + timeout_ms: None, + devbox_route: None, + }); } } @@ -350,41 +348,6 @@ impl KubeClusterRpc for KubeClusterServer { } } -fn extract_label_string(yaml: &str, key: &str) -> Option { - let value: Value = serde_yaml::from_str(yaml).ok()?; - let metadata = value.get("metadata")?; - let labels = metadata.get("labels")?.as_mapping()?; - labels - .get(&Value::String(key.to_string()))? - .as_str() - .map(|s| s.to_string()) -} - -fn extract_label_uuid(yaml: &str, key: &str) -> Option { - extract_label_string(yaml, key).and_then(|s| Uuid::parse_str(&s).ok()) -} - -fn service_matches_workload( - service_yaml: &str, - base_workload_id: &str, - base_workload_name: &str, - selector: &HashMap, -) -> bool { - if let Some(label_id) = extract_label_string(service_yaml, "lapdev.base-workload-id") { - if label_id == base_workload_id { - return true; - } - } - - if let Some(label_name) = extract_label_string(service_yaml, "lapdev.base-workload") { - if label_name == base_workload_name { - return true; - } - } - - selector.values().any(|value| value == base_workload_name) -} - impl KubeClusterServer { async fn handle_workload_change(&self, event: &ResourceChangeEvent) -> AnyResult<()> { let Some(workload_kind) = workload_kind_for(event.resource_type) else { From 8d1200637510251f7379dd1b6bb0ba8741a5f250 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Wed, 22 Oct 2025 21:26:54 +0000 Subject: [PATCH 166/334] update --- crates/kube-sidecar-proxy/src/config.rs | 196 +++++++++--- crates/kube-sidecar-proxy/src/proxy.rs | 398 ------------------------ crates/kube-sidecar-proxy/src/rpc.rs | 55 ++-- crates/kube-sidecar-proxy/src/server.rs | 286 +++-------------- 4 files changed, 237 insertions(+), 698 deletions(-) delete mode 100644 crates/kube-sidecar-proxy/src/proxy.rs diff --git a/crates/kube-sidecar-proxy/src/config.rs b/crates/kube-sidecar-proxy/src/config.rs index 5930dc2..268e36a 100644 --- a/crates/kube-sidecar-proxy/src/config.rs +++ b/crates/kube-sidecar-proxy/src/config.rs @@ -1,6 +1,12 @@ +use http::header::AUTHORIZATION; +use lapdev_tunnel::{ + TunnelClient, TunnelError, TunnelTcpStream, WebSocketTransport as TunnelWebSocketTransport, +}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::net::SocketAddr; +use std::{collections::HashMap, io, net::SocketAddr, sync::Arc}; +use tokio::sync::Mutex; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; use uuid::Uuid; /// Immutable settings for the sidecar proxy determined at boot time. @@ -43,7 +49,6 @@ impl SidecarSettings { } /// Mutable routing state shared between RPC handlers and the proxy loop. -#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RoutingTable { pub branch_routes: HashMap, pub default_route: DefaultRoute, @@ -63,10 +68,10 @@ impl RoutingTable { if let Some(branch_id) = branch_id { if let Some(route) = self.branch_routes.get(&branch_id) { match &route.mode { - BranchMode::Devbox(devbox) => { + BranchMode::Devbox(connection) => { return RouteDecision::BranchDevbox { - target_port: devbox.resolve_target_port(port), - route: devbox.clone(), + connection: connection.clone(), + target_port: connection.resolve_target_port(port), }; } BranchMode::Service => { @@ -78,10 +83,10 @@ impl RoutingTable { } } - if let DefaultRoute::Devbox(route) = &self.default_route { + if let DefaultRoute::Devbox(connection) = &self.default_route { return RouteDecision::DefaultDevbox { - target_port: route.resolve_target_port(port), - route: route.clone(), + connection: connection.clone(), + target_port: connection.resolve_target_port(port), }; } @@ -89,10 +94,10 @@ impl RoutingTable { } pub fn resolve_tcp(&self, port: u16) -> RouteDecision { - if let DefaultRoute::Devbox(route) = &self.default_route { + if let DefaultRoute::Devbox(connection) = &self.default_route { return RouteDecision::DefaultDevbox { - target_port: route.resolve_target_port(port), - route: route.clone(), + connection: connection.clone(), + target_port: connection.resolve_target_port(port), }; } @@ -108,7 +113,7 @@ impl RoutingTable { for (env_id, service_route) in routes { if let Some(existing) = self.branch_routes.get(&env_id) { let mode = match &existing.mode { - BranchMode::Devbox(devbox) => BranchMode::Devbox(devbox.clone()), + BranchMode::Devbox(connection) => BranchMode::Devbox(connection.clone()), BranchMode::Service => BranchMode::Service, }; new_routes.insert( @@ -132,19 +137,23 @@ impl RoutingTable { self.branch_routes = new_routes; } - pub fn set_branch_devbox(&mut self, branch_id: &Uuid, devbox: DevboxRoute) -> bool { + pub fn set_branch_devbox( + &mut self, + branch_id: &Uuid, + connection: Arc, + ) -> bool { let Some(entry) = self.branch_routes.get_mut(branch_id) else { return false; }; - entry.mode = BranchMode::Devbox(devbox); + entry.mode = BranchMode::Devbox(connection); true } pub fn remove_branch_devbox_by_intercept(&mut self, intercept_id: &Uuid) -> Option { for (branch_id, route) in self.branch_routes.iter_mut() { - if let BranchMode::Devbox(devbox) = &route.mode { - if devbox.intercept_id == *intercept_id { + if let BranchMode::Devbox(connection) = &route.mode { + if connection.metadata().intercept_id == *intercept_id { route.mode = BranchMode::Service; return Some(*branch_id); } @@ -153,24 +162,26 @@ impl RoutingTable { None } - pub fn set_default_devbox(&mut self, route: DevboxRoute) { - self.default_route = DefaultRoute::Devbox(route); + pub fn set_default_devbox(&mut self, connection: Arc) { + self.default_route = DefaultRoute::Devbox(connection); } pub fn clear_default_devbox(&mut self) { self.default_route = DefaultRoute::Local; } - pub fn default_devbox(&self) -> Option<&DevboxRoute> { + pub fn default_devbox(&self) -> Option> { match &self.default_route { - DefaultRoute::Devbox(route) => Some(route), + DefaultRoute::Devbox(connection) => Some(connection.clone()), DefaultRoute::Local => None, } } pub fn remove_default_devbox_by_intercept(&mut self, intercept_id: &Uuid) -> bool { match &self.default_route { - DefaultRoute::Devbox(route) if &route.intercept_id == intercept_id => { + DefaultRoute::Devbox(connection) + if connection.metadata().intercept_id == *intercept_id => + { self.default_route = DefaultRoute::Local; true } @@ -179,7 +190,7 @@ impl RoutingTable { } } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct BranchRoute { pub service: BranchServiceRoute, pub mode: BranchMode, @@ -206,14 +217,25 @@ impl BranchServiceRoute { } } -#[derive(Debug, Clone)] +#[derive(Clone)] pub enum BranchMode { Service, - Devbox(DevboxRoute), + Devbox(Arc), +} + +pub enum DefaultRoute { + Local, + Devbox(Arc), +} + +impl Default for DefaultRoute { + fn default() -> Self { + DefaultRoute::Local + } } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DevboxRoute { +pub struct DevboxRouteMetadata { pub intercept_id: Uuid, pub session_id: Uuid, pub target_port: u16, @@ -225,7 +247,7 @@ pub struct DevboxRoute { pub expires_at_epoch_seconds: Option, } -impl DevboxRoute { +impl DevboxRouteMetadata { pub fn resolve_target_port(&self, original_port: u16) -> u16 { self.port_mappings .get(&original_port) @@ -234,15 +256,109 @@ impl DevboxRoute { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DefaultRoute { - Local, - Devbox(DevboxRoute), +pub struct DevboxConnection { + metadata: DevboxRouteMetadata, + client: Arc, } -impl Default for DefaultRoute { - fn default() -> Self { - DefaultRoute::Local +impl DevboxConnection { + pub fn new(metadata: DevboxRouteMetadata) -> Self { + Self { + metadata, + client: Arc::new(ClientHandle::default()), + } + } + + pub fn metadata(&self) -> &DevboxRouteMetadata { + &self.metadata + } + + pub fn resolve_target_port(&self, original_port: u16) -> u16 { + self.metadata.resolve_target_port(original_port) + } + + pub async fn connect_tcp_stream( + &self, + target_host: &str, + target_port: u16, + ) -> io::Result { + let client = self.ensure_client().await.map_err(io::Error::from)?; + + match client + .connect_tcp(target_host.to_string(), target_port) + .await + { + Ok(stream) => Ok(stream), + Err(TunnelError::ConnectionClosed) => { + self.client.clear().await; + let client = self.ensure_client().await.map_err(io::Error::from)?; + client + .connect_tcp(target_host.to_string(), target_port) + .await + .map_err(io::Error::from) + } + Err(err) => Err(io::Error::from(err)), + } + } + + pub async fn clear_client(&self) { + self.client.clear().await; + } + + async fn ensure_client(&self) -> Result, TunnelError> { + if let Some(client) = self.client.get().await { + return Ok(client); + } + + let new_client = Arc::new(self.create_client().await?); + Ok(self.client.set_if_empty(new_client).await) + } + + async fn create_client(&self) -> Result { + let mut request = self + .metadata + .websocket_url + .clone() + .into_client_request() + .map_err(tunnel_transport_error)?; + + let header_value = format!("Bearer {}", self.metadata.auth_token) + .parse() + .map_err(tunnel_transport_error)?; + request.headers_mut().insert(AUTHORIZATION, header_value); + + let (stream, _) = connect_async(request) + .await + .map_err(tunnel_transport_error)?; + + let transport = TunnelWebSocketTransport::new(stream); + Ok(TunnelClient::connect(transport)) + } +} + +#[derive(Default)] +struct ClientHandle { + client: Mutex>>, +} + +impl ClientHandle { + async fn get(&self) -> Option> { + self.client.lock().await.clone() + } + + async fn set_if_empty(&self, client: Arc) -> Arc { + let mut guard = self.client.lock().await; + if let Some(existing) = guard.as_ref() { + existing.clone() + } else { + *guard = Some(client.clone()); + client + } + } + + async fn clear(&self) { + let mut guard = self.client.lock().await; + *guard = None; } } @@ -292,17 +408,16 @@ impl Default for MetricsConfig { } } -#[derive(Debug, Clone, Serialize, Deserialize)] pub enum RouteDecision { BranchService { service: BranchServiceRoute, }, BranchDevbox { - route: DevboxRoute, + connection: Arc, target_port: u16, }, DefaultDevbox { - route: DevboxRoute, + connection: Arc, target_port: u16, }, DefaultLocal, @@ -333,3 +448,10 @@ impl ProxyAnnotations { /// Annotation for timeout in milliseconds pub const TIMEOUT_MS: &'static str = "lapdev.io/proxy-timeout-ms"; } + +fn tunnel_transport_error(err: E) -> TunnelError +where + E: std::fmt::Display, +{ + TunnelError::Transport(io::Error::new(io::ErrorKind::Other, err.to_string())) +} diff --git a/crates/kube-sidecar-proxy/src/proxy.rs b/crates/kube-sidecar-proxy/src/proxy.rs deleted file mode 100644 index a4c574b..0000000 --- a/crates/kube-sidecar-proxy/src/proxy.rs +++ /dev/null @@ -1,398 +0,0 @@ -use crate::{ - config::{AccessLevel, ProxyConfig, RouteConfig, RouteTarget}, - error::{Result, SidecarProxyError}, -}; -use axum::{ - body::Body, - extract::{Request, State}, - http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}, - response::{IntoResponse, Response}, - routing::{any, get}, - Router, -}; -use hyper_util::{ - client::legacy::{connect::HttpConnector, Client}, - rt::TokioExecutor, -}; -use std::{net::SocketAddr, sync::Arc, time::Duration}; -use tokio::{net::lookup_host, sync::RwLock}; -use tower::ServiceBuilder; -use tower_http::{cors::CorsLayer, timeout::TimeoutLayer, trace::TraceLayer}; -use tracing::{debug, error, info, Span}; - -/// HTTP proxy handler that routes requests based on configuration -pub struct ProxyHandler { - client: Client, - config: Arc>, -} - -impl ProxyHandler { - pub fn new(config: Arc>) -> Self { - let client = Client::builder(TokioExecutor::new()).build_http::(); - - Self { client, config } - } - - /// Create the router with all proxy routes and middleware - pub fn create_router(self) -> Router { - let proxy_handler = Arc::new(self); - - Router::new() - // Health check endpoint (always available) - .route("/health", get(health_check)) - .route("/ready", get(readiness_check)) - // Metrics endpoint (if enabled) - .route("/metrics", get(metrics_handler)) - // Catch-all proxy route - .route("/*path", any(proxy_request)) - .fallback(proxy_request) - // Add state - .with_state(proxy_handler) - // Add middleware - .layer( - ServiceBuilder::new() - .layer(TraceLayer::new_for_http().on_request( - |req: &Request, _span: &Span| { - debug!( - method = %req.method(), - uri = %req.uri(), - "Processing request" - ); - }, - )) - .layer(CorsLayer::permissive()) - .layer(TimeoutLayer::new(Duration::from_secs(30))), - ) - } - - /// Proxy a request to the appropriate target - pub async fn proxy_request( - &self, - method: Method, - uri: Uri, - headers: HeaderMap, - body: Body, - ) -> Result { - let path = uri.path(); - - // Find matching route - let route = self.find_matching_route(path).await?; - - // Check if this is a DevboxTunnel route - handle differently - if let RouteTarget::DevboxTunnel { - intercept_id, - session_id, - target_port, - auth_token, - } = &route.target - { - return self - .proxy_request_via_devbox_tunnel( - method, - uri, - headers, - body, - *intercept_id, - *session_id, - *target_port, - auth_token.clone(), - ) - .await; - } - - // Resolve target address (for normal routes) - let target_addr = self.resolve_target(&route.target).await?; - - // Check authorization if required - if route.requires_auth { - self.check_authorization(&headers, &route.access_level) - .await?; - } - - // Build target URI - let target_uri = self.build_target_uri(&target_addr, &uri, &route)?; - - // Prepare headers - let proxy_headers = self.prepare_headers(&headers, &route)?; - - // Create the proxied request - let mut proxy_req = Request::builder().method(&method).uri(target_uri); - - // Set headers - for (name, value) in &proxy_headers { - proxy_req = proxy_req.header(name, value); - } - - let proxy_req = proxy_req.body(body)?; - - info!( - method = %method, - original_path = %uri.path(), - target = %target_addr, - "Proxying request" - ); - - // Execute the request with timeout - let timeout = Duration::from_millis(route.timeout_ms.unwrap_or(30000)); - - let response = tokio::time::timeout(timeout, self.client.request(proxy_req)) - .await - .map_err(|_| SidecarProxyError::Generic(anyhow::anyhow!("Request timeout")))? - .map_err(|e| SidecarProxyError::Generic(anyhow::anyhow!("HTTP client error: {}", e)))?; - - Ok(response.into_response()) - } - - /// Proxy a request via devbox tunnel for service interception - async fn proxy_request_via_devbox_tunnel( - &self, - _method: Method, - _uri: Uri, - _headers: HeaderMap, - _body: Body, - intercept_id: uuid::Uuid, - session_id: uuid::Uuid, - target_port: u16, - _auth_token: String, - ) -> Result { - // TODO: Full implementation requires: - // 1. Access to SidecarProxyManagerRpcClient to request tunnel - // 2. Bidirectional streaming between incoming request and tunnel data channel - // 3. Proper framing using ServerTunnelMessage protocol - // 4. Error handling and cleanup on connection close - - info!( - "Devbox tunnel proxy requested: intercept_id={}, session_id={}, target_port={}", - intercept_id, session_id, target_port - ); - - // For now, return error indicating feature is not yet fully implemented - Err(SidecarProxyError::Generic(anyhow::anyhow!( - "Devbox tunnel proxying not yet fully implemented. \ - This requires RPC client access and WebSocket tunnel streaming." - ))) - } - - async fn find_matching_route(&self, path: &str) -> Result { - let config = self.config.read().await; - - // Try to find a matching route - for route in &config.routes { - if self.path_matches(&route.path, path) { - return Ok(route.clone()); - } - } - - // Default route to default target - Ok(RouteConfig { - path: "/*".to_string(), - target: RouteTarget::Address(config.default_target), - branch_environment_id: None, - headers: std::collections::HashMap::new(), - timeout_ms: None, - requires_auth: true, - access_level: AccessLevel::Personal, - }) - } - - fn path_matches(&self, pattern: &str, path: &str) -> bool { - // Simple pattern matching - support wildcards - if pattern.ends_with("/*") { - let prefix = &pattern[..pattern.len() - 2]; - path.starts_with(prefix) - } else if pattern == "/*" { - true - } else { - pattern == path - } - } - - async fn resolve_target(&self, target: &RouteTarget) -> Result { - match target { - RouteTarget::Address(addr) => Ok(*addr), - RouteTarget::Service { name, port } => self.resolve_service_target(name, *port).await, - RouteTarget::LoadBalance(targets) => { - for target in targets { - let attempt = match target { - RouteTarget::Address(addr) => Ok(*addr), - RouteTarget::Service { name, port } => { - self.resolve_service_target(name, *port).await - } - RouteTarget::LoadBalance(_) | RouteTarget::DevboxTunnel { .. } => continue, - }; - - if let Ok(addr) = attempt { - return Ok(addr); - } - } - - Err(SidecarProxyError::ServiceDiscovery( - "No healthy targets available".to_string(), - )) - } - RouteTarget::DevboxTunnel { .. } => Err(SidecarProxyError::Generic(anyhow::anyhow!( - "DevboxTunnel routing requires tunnel establishment, not direct resolution" - ))), - } - } - - async fn resolve_service_target(&self, name: &str, port: u16) -> Result { - let ns = { - let cfg = self.config.read().await; - cfg.namespace - .clone() - .unwrap_or_else(|| "default".to_string()) - }; - - let host = format!("{}.{}.svc.cluster.local", name, ns); - let mut addrs = lookup_host((host.as_str(), port)).await.map_err(|e| { - SidecarProxyError::ServiceDiscovery(format!("DNS lookup failed for {}: {}", host, e)) - })?; - - addrs - .next() - .ok_or_else(|| SidecarProxyError::TargetNotFound { - service_name: name.to_string(), - }) - } - - async fn check_authorization( - &self, - headers: &HeaderMap, - access_level: &AccessLevel, - ) -> Result<()> { - match access_level { - AccessLevel::Public => { - // No authorization required - Ok(()) - } - AccessLevel::Personal | AccessLevel::Shared => { - // Check for valid authentication headers - // This would integrate with Lapdev's existing auth system - if headers.contains_key("authorization") || headers.contains_key("cookie") { - // TODO: Validate token/session against Lapdev auth system - debug!("Authorization check passed (stub implementation)"); - Ok(()) - } else { - Err(SidecarProxyError::Authorization( - "Authentication required".to_string(), - )) - } - } - } - } - - fn build_target_uri( - &self, - target_addr: &SocketAddr, - original_uri: &Uri, - _route: &RouteConfig, - ) -> Result { - let scheme = "http"; // Could be enhanced to support HTTPS - let path_and_query = original_uri - .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or("/"); - - let target_uri = format!("{}://{}{}", scheme, target_addr, path_and_query); - - target_uri - .parse() - .map_err(|e| SidecarProxyError::Generic(anyhow::anyhow!("Invalid target URI: {}", e))) - } - - fn prepare_headers(&self, original: &HeaderMap, route: &RouteConfig) -> Result { - let mut headers = original.clone(); - - // Remove hop-by-hop headers - headers.remove("connection"); - headers.remove("proxy-connection"); - headers.remove("te"); - headers.remove("trailers"); - headers.remove("upgrade"); - - // Add custom headers from route configuration - for (name, value) in &route.headers { - let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| { - SidecarProxyError::Generic(anyhow::anyhow!("Invalid header name: {}", e)) - })?; - let header_value = HeaderValue::from_str(value).map_err(|e| { - SidecarProxyError::Generic(anyhow::anyhow!("Invalid header value: {}", e)) - })?; - headers.insert(header_name, header_value); - } - - // Add X-Forwarded headers - if let Some(host) = headers.get("host") { - headers.insert("x-forwarded-host", host.clone()); - } - headers.insert("x-forwarded-proto", HeaderValue::from_static("http")); - - Ok(headers) - } -} - -// Handler functions - -async fn proxy_request( - State(handler): State>, - req: Request, -) -> impl IntoResponse { - let (parts, body) = req.into_parts(); - - match handler - .proxy_request(parts.method, parts.uri, parts.headers, body) - .await - { - Ok(response) => response, - Err(e) => { - error!("Proxy error: {}", e); - match e { - SidecarProxyError::TargetNotFound { .. } => { - (StatusCode::BAD_GATEWAY, format!("Target not found: {}", e)).into_response() - } - SidecarProxyError::Authorization(_) => { - (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() - } - _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(), - } - } - } -} - -async fn health_check() -> impl IntoResponse { - (StatusCode::OK, "OK") -} - -async fn readiness_check(State(handler): State>) -> impl IntoResponse { - // Check if we can reach the default target - let config = handler.config.read().await; - let target = config.default_target; - - // Simple TCP connection check - match tokio::net::TcpStream::connect(target).await { - Ok(_) => (StatusCode::OK, "Ready"), - Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "Not ready"), - } -} - -async fn metrics_handler() -> impl IntoResponse { - // TODO: Implement Prometheus metrics - // For now, return basic metrics - let metrics = r#" -# HELP http_requests_total Total number of HTTP requests -# TYPE http_requests_total counter -http_requests_total 0 - -# HELP http_request_duration_seconds HTTP request duration in seconds -# TYPE http_request_duration_seconds histogram -http_request_duration_seconds_bucket{le="0.1"} 0 -http_request_duration_seconds_bucket{le="0.5"} 0 -http_request_duration_seconds_bucket{le="1.0"} 0 -http_request_duration_seconds_bucket{le="+Inf"} 0 -http_request_duration_seconds_sum 0 -http_request_duration_seconds_count 0 -"#; - - (StatusCode::OK, metrics) -} diff --git a/crates/kube-sidecar-proxy/src/rpc.rs b/crates/kube-sidecar-proxy/src/rpc.rs index b4d0df5..d6400ed 100644 --- a/crates/kube-sidecar-proxy/src/rpc.rs +++ b/crates/kube-sidecar-proxy/src/rpc.rs @@ -8,7 +8,10 @@ use tokio::sync::RwLock; use tracing::{info, warn}; use uuid::Uuid; -use crate::config::{AccessLevel, BranchMode, BranchServiceRoute, DevboxRoute, RoutingTable}; +use crate::config::{ + AccessLevel, BranchMode, BranchServiceRoute, DevboxConnection, DevboxRouteMetadata, + RoutingTable, +}; #[derive(Clone)] pub(crate) struct SidecarProxyRpcServer { @@ -66,7 +69,7 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { ) -> Result<(), String> { let mut routing_table = self.routing_table.write().await; let mut updates: Vec<(Uuid, BranchServiceRoute)> = Vec::new(); - let mut devbox_overrides: Vec<(Uuid, DevboxRoute)> = Vec::new(); + let mut devbox_overrides: Vec<(Uuid, Arc)> = Vec::new(); for route in routes { let branch_id = route.branch_environment_id; @@ -83,7 +86,7 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { } if let Some(devbox) = route.devbox_route.clone() { - devbox_overrides.push((branch_id, devbox_route_from_config(devbox))); + devbox_overrides.push((branch_id, devbox_connection_from_config(devbox))); } } @@ -117,10 +120,10 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { ); let mut routing_table = self.routing_table.write().await; - let devbox_route = devbox_route_from_config(route.clone()); + let devbox_connection = devbox_connection_from_config(route.clone()); if let Some(branch_id) = route.branch_environment_id { - if routing_table.set_branch_devbox(&branch_id, devbox_route.clone()) { + if routing_table.set_branch_devbox(&branch_id, devbox_connection.clone()) { info!( "Attached devbox route to branch {} (intercept_id={})", branch_id, route.intercept_id @@ -133,7 +136,7 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { return Ok(false); } } else { - routing_table.set_default_devbox(devbox_route.clone()); + routing_table.set_default_devbox(devbox_connection.clone()); info!( "Registered default devbox route (intercept_id={}, default_target_port={})", route.intercept_id, route.target_port @@ -183,13 +186,16 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { let routing_table = self.routing_table.read().await; let mut routes = Vec::new(); - if let Some(route) = routing_table.default_devbox() { - routes.push(devbox_route_config_from_route(route, None)); + if let Some(connection) = routing_table.default_devbox() { + routes.push(devbox_route_config_from_connection(&connection, None)); } for (branch_id, branch_route) in &routing_table.branch_routes { - if let crate::config::BranchMode::Devbox(devbox) = &branch_route.mode { - routes.push(devbox_route_config_from_route(devbox, Some(*branch_id))); + if let crate::config::BranchMode::Devbox(connection) = &branch_route.mode { + routes.push(devbox_route_config_from_connection( + connection, + Some(*branch_id), + )); } } @@ -198,8 +204,8 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { } } -fn devbox_route_from_config(route: DevboxRouteConfig) -> DevboxRoute { - DevboxRoute { +fn devbox_connection_from_config(route: DevboxRouteConfig) -> Arc { + Arc::new(DevboxConnection::new(DevboxRouteMetadata { intercept_id: route.intercept_id, session_id: route.session_id, target_port: route.target_port, @@ -209,24 +215,25 @@ fn devbox_route_from_config(route: DevboxRouteConfig) -> DevboxRoute { port_mappings: route.port_mappings, created_at_epoch_seconds: route.created_at_epoch_seconds, expires_at_epoch_seconds: route.expires_at_epoch_seconds, - } + })) } -fn devbox_route_config_from_route( - route: &DevboxRoute, +fn devbox_route_config_from_connection( + connection: &DevboxConnection, branch_id: Option, ) -> DevboxRouteConfig { + let metadata = connection.metadata(); DevboxRouteConfig { - intercept_id: route.intercept_id, - session_id: route.session_id, - target_port: route.target_port, - auth_token: route.auth_token.clone(), - websocket_url: route.websocket_url.clone(), - path_pattern: route.path_pattern.clone(), + intercept_id: metadata.intercept_id, + session_id: metadata.session_id, + target_port: metadata.target_port, + auth_token: metadata.auth_token.clone(), + websocket_url: metadata.websocket_url.clone(), + path_pattern: metadata.path_pattern.clone(), branch_environment_id: branch_id, - created_at_epoch_seconds: route.created_at_epoch_seconds, - expires_at_epoch_seconds: route.expires_at_epoch_seconds, - port_mappings: route.port_mappings.clone(), + created_at_epoch_seconds: metadata.created_at_epoch_seconds, + expires_at_epoch_seconds: metadata.expires_at_epoch_seconds, + port_mappings: metadata.port_mappings.clone(), } } diff --git a/crates/kube-sidecar-proxy/src/server.rs b/crates/kube-sidecar-proxy/src/server.rs index a6c7d6b..152deda 100644 --- a/crates/kube-sidecar-proxy/src/server.rs +++ b/crates/kube-sidecar-proxy/src/server.rs @@ -1,5 +1,5 @@ use crate::{ - config::{BranchServiceRoute, DevboxRoute, RouteDecision, RoutingTable, SidecarSettings}, + config::{BranchServiceRoute, DevboxConnection, RouteDecision, RoutingTable, SidecarSettings}, error::Result, original_dest::get_original_destination, otel_routing::{determine_routing_target, extract_routing_context}, @@ -8,22 +8,16 @@ use crate::{ }; use anyhow::anyhow; use futures::StreamExt; -use http::header::AUTHORIZATION; use lapdev_common::kube::{SIDECAR_PROXY_MANAGER_ADDR_ENV_VAR, SIDECAR_PROXY_WORKLOAD_ENV_VAR}; use lapdev_kube_rpc::{http_parser, SidecarProxyManagerRpcClient, SidecarProxyRpc}; use lapdev_rpc::spawn_twoway; -use lapdev_tunnel::{ - TunnelClient, TunnelError, TunnelTcpStream, WebSocketTransport as TunnelWebSocketTransport, -}; -use std::{collections::HashMap, io, net::SocketAddr, str::FromStr, sync::Arc}; +use std::{io, net::SocketAddr, str::FromStr, sync::Arc}; use tarpc::server::{BaseChannel, Channel}; use tokio::{ io::copy_bidirectional, net::{TcpListener, TcpStream}, - sync::{mpsc, oneshot, RwLock}, + sync::RwLock, }; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tracing::{debug, error, info, warn}; use uuid::Uuid; @@ -36,7 +30,6 @@ pub struct SidecarProxyServer { routing_table: Arc>, /// RPC client to kube-manager (None until connection established) rpc_client: Arc>>, - devbox_tunnel_manager: Arc, } impl SidecarProxyServer { @@ -90,7 +83,6 @@ impl SidecarProxyServer { settings: Arc::new(settings), routing_table: Arc::new(RwLock::new(RoutingTable::default())), rpc_client: Arc::new(RwLock::new(None)), - devbox_tunnel_manager: Arc::new(DevboxTunnelManager::new()), }; Ok(server) @@ -117,7 +109,6 @@ impl SidecarProxyServer { // Handle connections let routing_table_for_server = Arc::clone(&self.routing_table); let rpc_client_for_server = Arc::clone(&self.rpc_client); - let tunnel_manager_for_server = Arc::clone(&self.devbox_tunnel_manager); let server = async move { loop { match listener.accept().await { @@ -125,7 +116,6 @@ impl SidecarProxyServer { debug!("Accepted connection from {}", client_addr); let routing_table = Arc::clone(&routing_table_for_server); let rpc_client = Arc::clone(&rpc_client_for_server); - let tunnel_manager = Arc::clone(&tunnel_manager_for_server); tokio::spawn(async move { if let Err(e) = handle_connection( @@ -133,7 +123,6 @@ impl SidecarProxyServer { client_addr, routing_table, rpc_client, - tunnel_manager, ) .await { @@ -264,8 +253,7 @@ async fn handle_connection( mut inbound_stream: TcpStream, client_addr: SocketAddr, routing_table: Arc>, - rpc_client: Arc>>, - tunnel_manager: Arc, + _rpc_client: Arc>>, ) -> io::Result<()> { // Extract the original destination from the iptables-redirected connection let original_dest = match get_original_destination(&inbound_stream) { @@ -298,8 +286,7 @@ async fn handle_connection( original_dest, initial_data, Arc::clone(&routing_table), - rpc_client, - tunnel_manager, + Arc::clone(&_rpc_client), ) .await } @@ -310,14 +297,21 @@ async fn handle_connection( }; match decision { - RouteDecision::DefaultDevbox { route, target_port } - | RouteDecision::BranchDevbox { route, target_port } => { + RouteDecision::DefaultDevbox { + connection, + target_port, + } + | RouteDecision::BranchDevbox { + connection, + target_port, + } => { + let metadata = connection.metadata(); info!( "TCP {} -> {} intercepted by Devbox (intercept_id={}, session_id={}, target_port={})", client_addr, original_dest, - route.intercept_id, - route.session_id, + metadata.intercept_id, + metadata.session_id, target_port ); handle_devbox_tunnel( @@ -325,10 +319,8 @@ async fn handle_connection( client_addr, original_dest, initial_data, + connection, target_port, - route, - rpc_client, - tunnel_manager, ) .await } @@ -353,8 +345,7 @@ async fn handle_http_proxy( original_dest: SocketAddr, mut initial_data: Vec, routing_table: Arc>, - rpc_client: Arc>>, - tunnel_manager: Arc, + _rpc_client: Arc>>, ) -> io::Result<()> { // Try to parse the HTTP request, reading more data if needed let (http_request, _body_start) = match http_parser::parse_complete_http_request( @@ -413,14 +404,18 @@ async fn handle_http_proxy( return Ok(()); } } - RouteDecision::BranchDevbox { route, target_port } => { + RouteDecision::BranchDevbox { + connection, + target_port, + } => { + let metadata = connection.metadata(); info!( "HTTP {} {} intercepted by branch devbox (env {:?}, intercept_id={}, session_id={}, target_port={})", http_request.method, http_request.path, branch_id, - route.intercept_id, - route.session_id, + metadata.intercept_id, + metadata.session_id, target_port ); return handle_devbox_tunnel( @@ -428,21 +423,23 @@ async fn handle_http_proxy( client_addr, original_dest, initial_data, + connection, target_port, - route, - rpc_client, - tunnel_manager, ) .await; } - RouteDecision::DefaultDevbox { route, target_port } => { + RouteDecision::DefaultDevbox { + connection, + target_port, + } => { + let metadata = connection.metadata(); info!( "HTTP {} {} intercepted by shared devbox (port {}, intercept_id={}, session_id={}, target_port={})", http_request.method, http_request.path, original_dest.port(), - route.intercept_id, - route.session_id, + metadata.intercept_id, + metadata.session_id, target_port ); return handle_devbox_tunnel( @@ -450,10 +447,8 @@ async fn handle_http_proxy( client_addr, original_dest, initial_data, + connection, target_port, - route, - rpc_client, - tunnel_manager, ) .await; } @@ -560,186 +555,6 @@ async fn handle_tcp_proxy( Ok(()) } -#[derive(Clone)] -struct DevboxTunnelManager { - commands: mpsc::UnboundedSender, -} - -#[derive(Debug)] -enum ManagerCommand { - GetClient { - session_id: Uuid, - respond: oneshot::Sender>>, - }, - InsertClient { - session_id: Uuid, - client: Arc, - respond: oneshot::Sender>>, - }, - RemoveClient { - session_id: Uuid, - }, -} - -impl DevboxTunnelManager { - fn new() -> Self { - let (commands, mut rx) = mpsc::unbounded_channel(); - - tokio::spawn(async move { - let mut clients: HashMap> = HashMap::new(); - while let Some(command) = rx.recv().await { - match command { - ManagerCommand::GetClient { - session_id, - respond, - } => { - let _ = respond.send(clients.get(&session_id).cloned()); - } - ManagerCommand::InsertClient { - session_id, - client, - respond, - } => { - if let Some(existing) = clients.get(&session_id) { - let _ = respond.send(Some(existing.clone())); - } else { - clients.insert(session_id, client); - let _ = respond.send(None); - } - } - ManagerCommand::RemoveClient { session_id } => { - clients.remove(&session_id); - } - } - } - }); - - Self { commands } - } - - async fn connect_tcp_stream( - &self, - session_id: Uuid, - websocket_url: &str, - auth_token: &str, - target_host: &str, - target_port: u16, - ) -> std::result::Result { - let client = self - .ensure_client(session_id, websocket_url, auth_token) - .await?; - - match client - .connect_tcp(target_host.to_string(), target_port) - .await - { - Ok(stream) => Ok(stream), - Err(TunnelError::ConnectionClosed) => { - self.remove_client(session_id).await; - let client = self - .ensure_client(session_id, websocket_url, auth_token) - .await?; - client - .connect_tcp(target_host.to_string(), target_port) - .await - } - Err(err) => Err(err), - } - } - - async fn ensure_client( - &self, - session_id: Uuid, - websocket_url: &str, - auth_token: &str, - ) -> std::result::Result, TunnelError> { - if let Some(existing) = self.get_client(session_id).await { - return Ok(existing); - } - - let new_client = Arc::new(self.create_client(websocket_url, auth_token).await?); - - match self.insert_client(session_id, new_client.clone()).await { - Some(existing) => Ok(existing), - None => Ok(new_client), - } - } - - async fn create_client( - &self, - websocket_url: &str, - auth_token: &str, - ) -> std::result::Result { - let mut request = websocket_url - .into_client_request() - .map_err(tunnel_transport_error)?; - - let header = format!("Bearer {}", auth_token) - .parse() - .map_err(tunnel_transport_error)?; - request.headers_mut().insert(AUTHORIZATION, header); - - let (stream, _) = connect_async(request) - .await - .map_err(tunnel_transport_error)?; - - let transport = TunnelWebSocketTransport::new(stream); - Ok(TunnelClient::connect(transport)) - } - - async fn remove_client(&self, session_id: Uuid) { - self.send_command(ManagerCommand::RemoveClient { session_id }); - } - - async fn get_client(&self, session_id: Uuid) -> Option> { - let (respond, receiver) = oneshot::channel(); - if self - .commands - .send(ManagerCommand::GetClient { - session_id, - respond, - }) - .is_err() - { - return None; - } - receiver.await.ok().flatten() - } - - async fn insert_client( - &self, - session_id: Uuid, - client: Arc, - ) -> Option> { - let (respond, receiver) = oneshot::channel(); - if self - .commands - .send(ManagerCommand::InsertClient { - session_id, - client, - respond, - }) - .is_err() - { - return None; - } - receiver.await.ok().flatten() - } - - fn send_command(&self, command: ManagerCommand) { - if self.commands.send(command).is_err() { - debug!("Devbox tunnel manager command channel closed"); - } - } -} - -fn tunnel_transport_error(err: E) -> TunnelError -where - E: std::fmt::Display, -{ - TunnelError::Transport(io::Error::new(io::ErrorKind::Other, err.to_string())) -} - /// Handle a connection that should be routed through a devbox tunnel /// /// Architecture: @@ -750,40 +565,33 @@ async fn handle_devbox_tunnel( client_addr: SocketAddr, original_dest: SocketAddr, mut initial_data: Vec, + connection: Arc, target_port: u16, - devbox_route: DevboxRoute, - _rpc_client: Arc>>, - tunnel_manager: Arc, ) -> io::Result<()> { + let metadata = connection.metadata(); + info!( "Routing {} -> {} through devbox tunnel (intercept_id={}, session_id={}, target_port={})", - client_addr, original_dest, devbox_route.intercept_id, devbox_route.session_id, target_port + client_addr, original_dest, metadata.intercept_id, metadata.session_id, target_port ); - let websocket_url = devbox_route.websocket_url.clone(); - info!( "Connecting to devbox tunnel websocket for intercept_id={} at {}", - devbox_route.intercept_id, websocket_url + metadata.intercept_id, metadata.websocket_url ); - let mut devbox_stream = match tunnel_manager - .connect_tcp_stream( - devbox_route.session_id, - &websocket_url, - &devbox_route.auth_token, - "127.0.0.1", - target_port, - ) + let mut devbox_stream = match connection + .connect_tcp_stream("127.0.0.1", target_port) .await { Ok(stream) => stream, Err(err) => { error!( "Failed to establish tunnel stream for intercept {}: {}", - devbox_route.intercept_id, err + metadata.intercept_id, err ); - return Err(io::Error::from(err)); + connection.clear_client().await; + return Err(err); } }; @@ -795,15 +603,15 @@ async fn handle_devbox_tunnel( Ok((bytes_tx, bytes_rx)) => { info!( "Devbox tunnel completed: {} bytes sent, {} bytes received (intercept_id={})", - bytes_tx, bytes_rx, devbox_route.intercept_id + bytes_tx, bytes_rx, metadata.intercept_id ); } Err(err) => { warn!( "Devbox tunnel error for intercept_id={}: {}", - devbox_route.intercept_id, err + metadata.intercept_id, err ); - tunnel_manager.remove_client(devbox_route.session_id).await; + connection.clear_client().await; return Err(err); } } From 153bad0412e520211df871381e1d1e40733bbcc4 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Wed, 22 Oct 2025 21:42:47 +0000 Subject: [PATCH 167/334] update --- crates/kube-sidecar-proxy/src/config.rs | 53 ++++++++----------------- crates/kube-sidecar-proxy/src/server.rs | 8 ++-- 2 files changed, 21 insertions(+), 40 deletions(-) diff --git a/crates/kube-sidecar-proxy/src/config.rs b/crates/kube-sidecar-proxy/src/config.rs index 268e36a..1b5210e 100644 --- a/crates/kube-sidecar-proxy/src/config.rs +++ b/crates/kube-sidecar-proxy/src/config.rs @@ -4,7 +4,7 @@ use lapdev_tunnel::{ }; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, io, net::SocketAddr, sync::Arc}; -use tokio::sync::Mutex; +use tokio::sync::OnceCell; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use uuid::Uuid; @@ -258,14 +258,14 @@ impl DevboxRouteMetadata { pub struct DevboxConnection { metadata: DevboxRouteMetadata, - client: Arc, + client: OnceCell>, } impl DevboxConnection { pub fn new(metadata: DevboxRouteMetadata) -> Self { Self { metadata, - client: Arc::new(ClientHandle::default()), + client: OnceCell::new(), } } @@ -290,7 +290,7 @@ impl DevboxConnection { { Ok(stream) => Ok(stream), Err(TunnelError::ConnectionClosed) => { - self.client.clear().await; + self.clear_client(); let client = self.ensure_client().await.map_err(io::Error::from)?; client .connect_tcp(target_host.to_string(), target_port) @@ -301,17 +301,24 @@ impl DevboxConnection { } } - pub async fn clear_client(&self) { - self.client.clear().await; + pub fn clear_client(&self) { + self.client.take(); } async fn ensure_client(&self) -> Result, TunnelError> { - if let Some(client) = self.client.get().await { - return Ok(client); + if let Some(client) = self.client.get() { + return Ok(client.clone()); } - let new_client = Arc::new(self.create_client().await?); - Ok(self.client.set_if_empty(new_client).await) + let client = self + .client + .get_or_try_init(|| async { + let client = self.create_client().await?; + Ok(Arc::new(client)) + }) + .await?; + + Ok(client.clone()) } async fn create_client(&self) -> Result { @@ -336,32 +343,6 @@ impl DevboxConnection { } } -#[derive(Default)] -struct ClientHandle { - client: Mutex>>, -} - -impl ClientHandle { - async fn get(&self) -> Option> { - self.client.lock().await.clone() - } - - async fn set_if_empty(&self, client: Arc) -> Arc { - let mut guard = self.client.lock().await; - if let Some(existing) = guard.as_ref() { - existing.clone() - } else { - *guard = Some(client.clone()); - client - } - } - - async fn clear(&self) { - let mut guard = self.client.lock().await; - *guard = None; - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum AccessLevel { /// Only accessible by the owner with authentication diff --git a/crates/kube-sidecar-proxy/src/server.rs b/crates/kube-sidecar-proxy/src/server.rs index 152deda..2c6c74f 100644 --- a/crates/kube-sidecar-proxy/src/server.rs +++ b/crates/kube-sidecar-proxy/src/server.rs @@ -590,8 +590,8 @@ async fn handle_devbox_tunnel( "Failed to establish tunnel stream for intercept {}: {}", metadata.intercept_id, err ); - connection.clear_client().await; - return Err(err); + connection.clear_client(); + return Err(io::Error::from(err)); } }; @@ -611,8 +611,8 @@ async fn handle_devbox_tunnel( "Devbox tunnel error for intercept_id={}: {}", metadata.intercept_id, err ); - connection.clear_client().await; - return Err(err); + connection.clear_client(); + return Err(io::Error::from(err)); } } From 4e3793a0091b0ce4ece98709e65ea34907e38076 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Wed, 22 Oct 2025 21:49:35 +0000 Subject: [PATCH 168/334] update --- crates/kube-sidecar-proxy/src/config.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/kube-sidecar-proxy/src/config.rs b/crates/kube-sidecar-proxy/src/config.rs index 1b5210e..a2cf605 100644 --- a/crates/kube-sidecar-proxy/src/config.rs +++ b/crates/kube-sidecar-proxy/src/config.rs @@ -4,7 +4,7 @@ use lapdev_tunnel::{ }; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, io, net::SocketAddr, sync::Arc}; -use tokio::sync::OnceCell; +use tokio::sync::{OnceCell, RwLock}; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use uuid::Uuid; @@ -258,14 +258,14 @@ impl DevboxRouteMetadata { pub struct DevboxConnection { metadata: DevboxRouteMetadata, - client: OnceCell>, + client: RwLock>>, } impl DevboxConnection { pub fn new(metadata: DevboxRouteMetadata) -> Self { Self { metadata, - client: OnceCell::new(), + client: RwLock::new(OnceCell::new()), } } @@ -290,7 +290,7 @@ impl DevboxConnection { { Ok(stream) => Ok(stream), Err(TunnelError::ConnectionClosed) => { - self.clear_client(); + self.clear_client().await; let client = self.ensure_client().await.map_err(io::Error::from)?; client .connect_tcp(target_host.to_string(), target_port) @@ -301,22 +301,25 @@ impl DevboxConnection { } } - pub fn clear_client(&self) { - self.client.take(); + pub async fn clear_client(&self) { + self.client.write().await.take(); } async fn ensure_client(&self) -> Result, TunnelError> { - if let Some(client) = self.client.get() { + if let Some(client) = self.client.read().await.get() { return Ok(client.clone()); } let client = self .client + .read() + .await .get_or_try_init(|| async { let client = self.create_client().await?; - Ok(Arc::new(client)) + Ok::, TunnelError>(Arc::new(client)) }) - .await?; + .await? + .clone(); Ok(client.clone()) } From a9356cc648b5d1cfa73ba0ab34ced8edf9708623 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Thu, 23 Oct 2025 18:25:43 +0000 Subject: [PATCH 169/334] update --- crates/api/src/devbox.rs | 95 +++- crates/api/src/hrpc_service.rs | 51 +- crates/api/src/kube.rs | 14 +- crates/api/src/kube_controller/mod.rs | 142 ++++- crates/api/src/kube_controller/workload.rs | 183 +++++- crates/api/src/router.rs | 2 +- crates/api/src/state.rs | 186 ++++++ crates/api/src/tunnel_broker.rs | 532 ++++++++++++++---- crates/cli/src/devbox/commands/connect.rs | 289 ++++++++-- crates/kube-manager/src/manager.rs | 51 +- crates/kube-manager/src/manager_rpc.rs | 54 +- .../kube-manager/src/sidecar_proxy_manager.rs | 213 +++++-- .../src/sidecar_proxy_manager_rpc.rs | 42 +- crates/kube-rpc/src/lib.rs | 49 +- crates/kube-sidecar-proxy/src/config.rs | 52 +- crates/kube-sidecar-proxy/src/rpc.rs | 170 +++--- crates/kube/src/server.rs | 44 +- 17 files changed, 1821 insertions(+), 348 deletions(-) diff --git a/crates/api/src/devbox.rs b/crates/api/src/devbox.rs index 75fbdca..014e4d8 100644 --- a/crates/api/src/devbox.rs +++ b/crates/api/src/devbox.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use axum::{ - extract::{ws::WebSocket, Path, State, WebSocketUpgrade}, + extract::{ws::WebSocket, Path, Query, State, WebSocketUpgrade}, http::HeaderMap, response::Response, Json, @@ -13,7 +13,7 @@ use lapdev_devbox_rpc::{ StartInterceptRequest, }; use lapdev_rpc::{error::ApiError, spawn_twoway}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tarpc::{ server::{BaseChannel, Channel}, tokio_util::codec::LengthDelimitedCodec, @@ -253,6 +253,17 @@ async fn handle_devbox_rpc( state.active_devbox_sessions.write().await.remove(&user_id); notification_task.abort(); + if let Ok(Some(session)) = state.db.get_devbox_session(session_id).await { + if let Some(environment_id) = session.active_environment_id { + let state_clone = state.clone(); + tokio::spawn(async move { + state_clone + .clear_devbox_routes_for_environment(environment_id) + .await; + }); + } + } + // Mark session as revoked in database if let Err(e) = state.db.revoke_devbox_session(session_id).await { tracing::error!( @@ -270,9 +281,15 @@ async fn handle_devbox_rpc( ); } +#[derive(Default, Deserialize)] +pub struct DevboxInterceptTunnelParams { + workload_id: Option, +} + /// WebSocket endpoint for devbox intercept tunnels (server side - receives connections from in-cluster services) pub async fn devbox_intercept_tunnel_websocket( Path(requested_session_id): Path, + Query(params): Query, websocket: WebSocketUpgrade, headers: HeaderMap, State(state): State>, @@ -322,9 +339,12 @@ pub async fn devbox_intercept_tunnel_websocket( let broker = state.tunnel_broker.clone(); let session_id = session.session_id; + let workload_id = params.workload_id; Ok(websocket.on_upgrade(move |socket| async move { - broker.register_devbox(session_id, socket).await; + broker + .register_devbox(session_id, workload_id, socket) + .await; })) } @@ -451,6 +471,17 @@ impl DevboxSessionRpc for DevboxSessionRpcServer { .map_err(|e| format!("Failed to fetch session: {}", e))? .ok_or_else(|| "Session not found".to_string())?; + if let Some(environment_id) = session.active_environment_id { + let state = self.state.clone(); + let session_id = self.session_id; + let user_id = self.user_id; + tokio::spawn(async move { + state + .push_devbox_routes(user_id, session_id, environment_id) + .await; + }); + } + Ok(DevboxSessionInfo { session_id: session.session_id, user_id: self.user_id, @@ -546,6 +577,14 @@ impl DevboxSessionRpc for DevboxSessionRpcServer { environment_id ); + let previous_environment = self + .state + .db + .get_devbox_session(self.session_id) + .await + .map_err(|e| format!("Failed to fetch session: {}", e))? + .and_then(|session| session.active_environment_id); + self.state .db .update_devbox_session_active_environment(self.session_id, Some(environment_id)) @@ -573,7 +612,7 @@ impl DevboxSessionRpc for DevboxSessionRpcServer { let env_info = lapdev_devbox_rpc::DevboxEnvironmentInfo { environment_id: environment.id, cluster_name: cluster.name, - namespace: environment.namespace, + namespace: environment.namespace.clone(), }; // Fire and forget - don't wait for client response @@ -601,6 +640,22 @@ impl DevboxSessionRpc for DevboxSessionRpcServer { } }); + let state = self.state.clone(); + let user_id = self.user_id; + let session_id = self.session_id; + tokio::spawn(async move { + state + .push_devbox_routes(user_id, session_id, environment_id) + .await; + }); + + if let Some(prev_env) = previous_environment.filter(|prev| *prev != environment_id) { + let state = self.state.clone(); + tokio::spawn(async move { + state.clear_devbox_routes_for_environment(prev_env).await; + }); + } + Ok(()) } @@ -803,7 +858,9 @@ impl DevboxInterceptRpc for DevboxInterceptRpcImpl { .map(|handle| (handle.session_id, handle.rpc_client.clone())) }; - if let Some((session_id, rpc_client)) = rpc_client_opt { + let session_for_routes = rpc_client_opt.as_ref().map(|(session_id, _)| *session_id); + + if let Some((session_id, rpc_client)) = rpc_client_opt.as_ref() { // Send start_intercept RPC to CLI tracing::info!( "Notifying CLI session {} to start intercept {}", @@ -820,6 +877,7 @@ impl DevboxInterceptRpc for DevboxInterceptRpcImpl { }; // Send RPC to CLI (fire and forget) + let rpc_client = rpc_client.clone(); tokio::spawn(async move { if let Err(e) = rpc_client .start_intercept(tarpc::context::current(), start_request) @@ -836,6 +894,17 @@ impl DevboxInterceptRpc for DevboxInterceptRpcImpl { ); } + if let Some(session_id) = session_for_routes { + let state = self.state.clone(); + let user_id = self.user_id; + let environment_id = environment.id; + tokio::spawn(async move { + state + .push_devbox_routes(user_id, session_id, environment_id) + .await; + }); + } + Ok(intercept.id) } @@ -879,7 +948,9 @@ impl DevboxInterceptRpc for DevboxInterceptRpcImpl { .map(|handle| (handle.session_id, handle.rpc_client.clone())) }; - if let Some((session_id, rpc_client)) = rpc_client_opt { + let session_for_routes = rpc_client_opt.as_ref().map(|(session_id, _)| *session_id); + + if let Some((session_id, rpc_client)) = rpc_client_opt.as_ref() { // Send stop_intercept RPC to CLI tracing::info!( "Notifying CLI session {} to stop intercept {}", @@ -887,6 +958,7 @@ impl DevboxInterceptRpc for DevboxInterceptRpcImpl { intercept_id ); + let rpc_client = rpc_client.clone(); tokio::spawn(async move { if let Err(e) = rpc_client .stop_intercept(tarpc::context::current(), intercept_id) @@ -897,6 +969,17 @@ impl DevboxInterceptRpc for DevboxInterceptRpcImpl { }); } + if let Some(session_id) = session_for_routes { + let state = self.state.clone(); + let user_id = self.user_id; + let environment_id = intercept.environment_id; + tokio::spawn(async move { + state + .push_devbox_routes(user_id, session_id, environment_id) + .await; + }); + } + Ok(()) } } diff --git a/crates/api/src/hrpc_service.rs b/crates/api/src/hrpc_service.rs index c562263..9886cfa 100644 --- a/crates/api/src/hrpc_service.rs +++ b/crates/api/src/hrpc_service.rs @@ -148,6 +148,7 @@ impl HrpcService for CoreState { environment_id: Uuid, ) -> Result<(), HrpcError> { let ctx = self.hrpc_resolve_active_devbox_session(headers).await?; + let previous_environment = ctx.session.active_environment_id; let environment = self .db .get_kube_environment(environment_id) @@ -163,6 +164,10 @@ impl HrpcService for CoreState { .await .map_err(hrpc_from_anyhow)?; + self.tunnel_broker + .set_session_environment(ctx.session.session_id, Some(environment_id)) + .await; + // Notify CLI about the environment change let cluster = self .db @@ -223,6 +228,22 @@ impl HrpcService for CoreState { ); } + let state = self.clone(); + let user_id = ctx.user.id; + let session_id = ctx.session.session_id; + tokio::spawn(async move { + state + .push_devbox_routes(user_id, session_id, environment_id) + .await; + }); + + if let Some(prev_env) = previous_environment.filter(|prev| *prev != environment_id) { + let state = self.clone(); + tokio::spawn(async move { + state.clear_devbox_routes_for_environment(prev_env).await; + }); + } + Ok(()) } @@ -347,10 +368,10 @@ impl HrpcService for CoreState { let sessions = self.active_devbox_sessions.read().await; sessions .get(&user.id) - .map(|handle| handle.rpc_client.clone()) + .map(|handle| (handle.session_id, handle.rpc_client.clone())) }; - if let Some(client) = rpc_client { + if let Some((session_id, client)) = rpc_client.as_ref() { let request = StartInterceptRequest { intercept_id: intercept.id, workload_id, @@ -359,11 +380,22 @@ impl HrpcService for CoreState { port_mappings: mappings.iter().map(devbox_port_mapping_to_rpc).collect(), }; + let client = client.clone(); tokio::spawn(async move { if let Err(err) = client.start_intercept(context::current(), request).await { tracing::error!(?err, "Failed to notify CLI to start intercept"); } }); + + let state = self.clone(); + let user_id = user.id; + let environment_id = environment.id; + let session_id = *session_id; + tokio::spawn(async move { + state + .push_devbox_routes(user_id, session_id, environment_id) + .await; + }); } Ok(DevboxStartWorkloadInterceptResponse { @@ -398,10 +430,11 @@ impl HrpcService for CoreState { let sessions = self.active_devbox_sessions.read().await; sessions .get(&user.id) - .map(|handle| handle.rpc_client.clone()) + .map(|handle| (handle.session_id, handle.rpc_client.clone())) }; - if let Some(client) = rpc_client { + if let Some((session_id, client)) = rpc_client.as_ref() { + let client = client.clone(); tokio::spawn(async move { if let Err(err) = client .stop_intercept(context::current(), intercept_id) @@ -410,6 +443,16 @@ impl HrpcService for CoreState { tracing::error!(?err, "Failed to notify CLI to stop intercept"); } }); + + let state = self.clone(); + let user_id = user.id; + let environment_id = intercept.environment_id; + let session_id = *session_id; + tokio::spawn(async move { + state + .push_devbox_routes(user_id, session_id, environment_id) + .await; + }); } Ok(()) diff --git a/crates/api/src/kube.rs b/crates/api/src/kube.rs index 05204d9..e2a6f55 100644 --- a/crates/api/src/kube.rs +++ b/crates/api/src/kube.rs @@ -165,14 +165,15 @@ async fn handle_data_plane_tunnel(socket: WebSocket, state: Arc, clus } pub async fn sidecar_tunnel_websocket( - Path((environment_id, session_id)): Path<(Uuid, Uuid)>, + Path((environment_id, workload_id, session_id)): Path<(Uuid, Uuid, Uuid)>, headers: HeaderMap, websocket: WebSocketUpgrade, State(state): State>, ) -> Result { tracing::debug!( - "Handling sidecar tunnel WebSocket for environment {} session {}", + "Handling sidecar tunnel WebSocket for environment {} workload {} session {}", environment_id, + workload_id, session_id ); @@ -197,14 +198,17 @@ pub async fn sidecar_tunnel_websocket( } tracing::debug!( - "Sidecar authenticated for environment {} using auth token", - environment.id + "Sidecar authenticated for environment {} workload {} using auth token", + environment.id, + workload_id ); let broker = state.tunnel_broker.clone(); Ok(websocket.on_upgrade(move |socket| async move { - broker.register_sidecar(session_id, socket).await; + broker + .register_sidecar(session_id, workload_id, socket) + .await; })) } diff --git a/crates/api/src/kube_controller/mod.rs b/crates/api/src/kube_controller/mod.rs index 7558b30..06f96ca 100644 --- a/crates/api/src/kube_controller/mod.rs +++ b/crates/api/src/kube_controller/mod.rs @@ -4,8 +4,8 @@ use uuid::Uuid; use crate::environment_events::EnvironmentLifecycleEvent; use lapdev_db::api::DbApi; -use lapdev_kube::server::KubeClusterServer; -use lapdev_kube::tunnel::TunnelRegistry; +use lapdev_kube::{server::KubeClusterServer, tunnel::TunnelRegistry}; +use lapdev_kube_rpc::{DevboxRouteConfig, ProxyBranchRouteConfig}; use tracing::{debug, warn}; // Submodules @@ -98,4 +98,142 @@ impl KubeController { } } } + + pub async fn set_devbox_routes( + &self, + cluster_id: Uuid, + environment_id: Uuid, + routes: HashMap, + ) -> Result<(), String> { + let servers = self.kube_cluster_servers.read().await; + let Some(cluster_servers) = servers.get(&cluster_id) else { + return Err(format!( + "No connected KubeManager instances for cluster {}", + cluster_id + )); + }; + + let mut last_err: Option = None; + for server in cluster_servers { + match server + .set_devbox_routes(environment_id, routes.clone()) + .await + { + Ok(()) => return Ok(()), + Err(err) => last_err = Some(err), + } + } + + Err(last_err.unwrap_or_else(|| { + "Failed to dispatch set_devbox_routes to any KubeManager instances".to_string() + })) + } + + pub async fn clear_devbox_routes( + &self, + cluster_id: Uuid, + environment_id: Uuid, + branch_environment_id: Option, + ) -> Result<(), String> { + let servers = self.kube_cluster_servers.read().await; + let Some(cluster_servers) = servers.get(&cluster_id) else { + return Err(format!( + "No connected KubeManager instances for cluster {}", + cluster_id + )); + }; + + let mut last_err: Option = None; + for server in cluster_servers { + match server + .clear_devbox_routes(environment_id, branch_environment_id) + .await + { + Ok(()) => return Ok(()), + Err(err) => last_err = Some(err), + } + } + + Err(last_err.unwrap_or_else(|| { + "Failed to dispatch clear_devbox_routes to any KubeManager instances".to_string() + })) + } + + pub async fn update_branch_service_route( + &self, + cluster_id: Uuid, + base_environment_id: Uuid, + workload_id: Uuid, + route: ProxyBranchRouteConfig, + ) -> Result<(), String> { + let servers = self.kube_cluster_servers.read().await; + let Some(cluster_servers) = servers.get(&cluster_id) else { + return Err(format!( + "No connected KubeManager instances for cluster {}", + cluster_id + )); + }; + + let mut last_err: Option = None; + for server in cluster_servers { + match server + .rpc_client + .update_branch_service_route( + tarpc::context::current(), + base_environment_id, + workload_id, + route.clone(), + ) + .await + { + Ok(Ok(())) => return Ok(()), + Ok(Err(err)) => last_err = Some(err), + Err(err) => last_err = Some(format!("{}", err)), + } + } + + Err(last_err.unwrap_or_else(|| { + "Failed to dispatch update_branch_service_route to any KubeManager instances" + .to_string() + })) + } + + pub async fn remove_branch_service_route( + &self, + cluster_id: Uuid, + base_environment_id: Uuid, + workload_id: Uuid, + branch_environment_id: Uuid, + ) -> Result<(), String> { + let servers = self.kube_cluster_servers.read().await; + let Some(cluster_servers) = servers.get(&cluster_id) else { + return Err(format!( + "No connected KubeManager instances for cluster {}", + cluster_id + )); + }; + + let mut last_err: Option = None; + for server in cluster_servers { + match server + .rpc_client + .remove_branch_service_route( + tarpc::context::current(), + base_environment_id, + workload_id, + branch_environment_id, + ) + .await + { + Ok(Ok(())) => return Ok(()), + Ok(Err(err)) => last_err = Some(err), + Err(err) => last_err = Some(format!("{}", err)), + } + } + + Err(last_err.unwrap_or_else(|| { + "Failed to dispatch remove_branch_service_route to any KubeManager instances" + .to_string() + })) + } } diff --git a/crates/api/src/kube_controller/workload.rs b/crates/api/src/kube_controller/workload.rs index 1a122bd..93293f1 100644 --- a/crates/api/src/kube_controller/workload.rs +++ b/crates/api/src/kube_controller/workload.rs @@ -12,7 +12,10 @@ use k8s_openapi::api::{ }; use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta}; use lapdev_common::kube::{KubeServiceDetails, KubeServiceWithYaml, KubeWorkloadKind}; -use lapdev_kube_rpc::{KubeWorkloadYamlOnly, KubeWorkloadsWithResources}; +use lapdev_kube_rpc::{ + KubeWorkloadYamlOnly, KubeWorkloadsWithResources, ProxyBranchRouteConfig, + ProxyRouteAccessLevel, +}; use lapdev_rpc::error::ApiError; use super::{resources::rebuild_workload_yaml, KubeController}; @@ -176,17 +179,124 @@ impl KubeController { ) .await?; - if environment.base_environment_id.is_some() { - if let Err(err) = cluster_server - .rpc_client - .refresh_branch_service_routes(tarpc::context::current(), environment.id) - .await - { + if let Some(base_environment_id) = environment.base_environment_id { + if let Some(base_workload_id) = existing_workload.base_workload_id { + match self + .build_branch_service_route_config( + base_environment_id, + base_workload_id, + environment.id, + ) + .await + { + Ok(Some(route)) => { + match cluster_server + .rpc_client + .update_branch_service_route( + tarpc::context::current(), + base_environment_id, + base_workload_id, + route, + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::warn!( + base_environment_id = %base_environment_id, + branch_environment_id = %environment.id, + workload_id = %base_workload_id, + error = %err, + "KubeManager rejected branch service route update" + ); + } + Err(err) => { + tracing::warn!( + base_environment_id = %base_environment_id, + branch_environment_id = %environment.id, + workload_id = %base_workload_id, + error = %err, + "Failed to send branch service route update to KubeManager" + ); + } + } + } + Ok(None) => { + match cluster_server + .rpc_client + .remove_branch_service_route( + tarpc::context::current(), + base_environment_id, + base_workload_id, + environment.id, + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::warn!( + base_environment_id = %base_environment_id, + branch_environment_id = %environment.id, + workload_id = %base_workload_id, + error = %err, + "KubeManager rejected branch service route removal" + ); + } + Err(err) => { + tracing::warn!( + base_environment_id = %base_environment_id, + branch_environment_id = %environment.id, + workload_id = %base_workload_id, + error = %err, + "Failed to send branch service route removal to KubeManager" + ); + } + } + } + Err(err) => { + tracing::warn!( + base_environment_id = %base_environment_id, + branch_environment_id = %environment.id, + workload_id = %base_workload_id, + error = ?err, + "Failed to build branch service route config; falling back to full refresh" + ); + if let Err(err) = cluster_server + .rpc_client + .refresh_branch_service_routes( + tarpc::context::current(), + base_environment_id, + ) + .await + { + tracing::warn!( + base_environment_id = %base_environment_id, + error = %err, + "Failed to refresh branch service routes during fallback" + ); + } + } + } + } else { tracing::warn!( - "Failed to refresh branch service routes for environment {}: {}", - environment.id, - err + environment_id = %environment.id, + workload_id = %workload_id, + "Branch workload missing base_workload_id; falling back to full refresh" ); + if let Err(err) = cluster_server + .rpc_client + .refresh_branch_service_routes( + tarpc::context::current(), + base_environment_id, + ) + .await + { + tracing::warn!( + base_environment_id = %base_environment_id, + error = %err, + "Failed to refresh branch service routes during fallback" + ); + } } } @@ -198,6 +308,59 @@ impl KubeController { Ok(()) } + async fn build_branch_service_route_config( + &self, + base_environment_id: Uuid, + base_workload_id: Uuid, + branch_environment_id: Uuid, + ) -> Result, ApiError> { + let base_environment = self + .db + .get_kube_environment(base_environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| { + ApiError::InvalidRequest(format!( + "Base environment {} not found", + base_environment_id + )) + })?; + + let workload_labels = self + .db + .get_environment_workload_labels(base_workload_id) + .await + .map_err(ApiError::from)?; + + let shared_services = self + .db + .get_matching_cluster_services( + base_environment.cluster_id, + &base_environment.namespace, + &workload_labels, + ) + .await + .map_err(ApiError::from)?; + + let Some(service) = shared_services.first() else { + return Ok(None); + }; + + let branch_service_name = format!("{}-{}", service.name, branch_environment_id); + + let route = ProxyBranchRouteConfig { + branch_environment_id, + service_name: Some(branch_service_name), + headers: HashMap::new(), + requires_auth: true, + access_level: ProxyRouteAccessLevel::Personal, + timeout_ms: None, + devbox_route: None, + }; + + Ok(Some(route)) + } + async fn build_branch_workload_manifest( &self, environment: &lapdev_db_entities::kube_environment::Model, diff --git a/crates/api/src/router.rs b/crates/api/src/router.rs index fad1a16..24335b7 100644 --- a/crates/api/src/router.rs +++ b/crates/api/src/router.rs @@ -89,7 +89,7 @@ fn v1_api_routes() -> Router> { any(devbox_client_tunnel_websocket), ) .route( - "/kube/sidecar/tunnel/{environment_id}/{session_id}", + "/kube/sidecar/tunnel/{environment_id}/{workload_id}/{session_id}", any(sidecar_tunnel_websocket), ) .route( diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index f05565d..74671bd 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -12,7 +12,9 @@ use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor}; use lapdev_common::{UserRole, LAPDEV_BASE_HOSTNAME}; use lapdev_conductor::{scheduler::LAPDEV_CPU_OVERCOMMIT, Conductor}; use lapdev_db::api::DbApi; +use lapdev_devbox_rpc::PortMapping; use lapdev_enterprise::license::LAPDEV_ENTERPRISE_LICENSE; +use lapdev_kube_rpc::DevboxRouteConfig; use lapdev_rpc::error::ApiError; use pasetors::{ claims::ClaimsValidationRules, @@ -265,6 +267,190 @@ impl CoreState { } } + async fn websocket_base_url(&self) -> String { + let from_env = std::env::var("LAPDEV_API_URL").ok(); + let host = if let Some(url) = from_env.filter(|s| !s.trim().is_empty()) { + url + } else { + self.conductor + .hostnames + .read() + .await + .get("") + .cloned() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "https://app.lap.dev".to_string()) + }; + + CoreState::normalize_ws_base(&host) + } + + fn normalize_ws_base(candidate: &str) -> String { + let trimmed = candidate.trim(); + let with_scheme = if trimmed.starts_with("http://") + || trimmed.starts_with("https://") + || trimmed.starts_with("ws://") + || trimmed.starts_with("wss://") + { + trimmed.to_string() + } else { + format!("https://{}", trimmed) + }; + + if with_scheme.starts_with("http://") { + with_scheme.replacen("http://", "ws://", 1) + } else if with_scheme.starts_with("https://") { + with_scheme.replacen("https://", "wss://", 1) + } else { + with_scheme + } + } + + pub async fn push_devbox_routes(&self, user_id: Uuid, session_id: Uuid, environment_id: Uuid) { + match self + .build_devbox_route_snapshot(user_id, session_id, environment_id) + .await + { + Ok((cluster_id, base_environment_id, routes)) => { + if !routes.is_empty() { + if let Err(err) = self + .kube_controller + .set_devbox_routes( + cluster_id, + base_environment_id.unwrap_or(environment_id), + routes, + ) + .await + { + tracing::warn!( + environment_id = %environment_id, + error = %err, + "Failed to push devbox routes to kube-manager" + ); + } + } + } + Err(err) => { + tracing::warn!( + environment_id = %environment_id, + error = %err, + "Failed to build devbox route snapshot" + ); + } + } + } + + pub async fn clear_devbox_routes_for_environment(&self, environment_id: Uuid) { + match self + .db + .get_kube_environment(environment_id) + .await + .map_err(|e| format!("Failed to fetch environment: {e}")) + { + Ok(Some(environment)) => { + if let Err(err) = self + .kube_controller + .clear_devbox_routes( + environment.cluster_id, + environment.base_environment_id.unwrap_or(environment_id), + environment.base_environment_id.map(|_| environment_id), + ) + .await + { + tracing::warn!( + environment_id = %environment_id, + error = %err, + "Failed to clear devbox routes for environment" + ); + } + } + Ok(None) => { + tracing::debug!( + environment_id = %environment_id, + "Environment not found while clearing devbox routes" + ); + } + Err(err) => { + tracing::warn!( + environment_id = %environment_id, + error = %err, + "Failed to clear devbox routes for environment" + ); + } + } + } + + async fn build_devbox_route_snapshot( + &self, + user_id: Uuid, + session_id: Uuid, + environment_id: Uuid, + ) -> Result<(Uuid, Option, HashMap), String> { + let environment = self + .db + .get_kube_environment(environment_id) + .await + .map_err(|e| format!("Failed to fetch environment: {e}"))? + .ok_or_else(|| "Environment not found".to_string())?; + + let intercepts = self + .db + .get_active_intercepts_for_environment(environment_id) + .await + .map_err(|e| format!("Failed to fetch intercepts: {e}"))?; + + let base = self.websocket_base_url().await; + let base_trimmed = base.trim_end_matches('/'); + + let mut routes: HashMap = HashMap::new(); + + for intercept in intercepts { + if intercept.user_id != user_id { + continue; + } + + let value: serde_json::Value = intercept.port_mappings.clone().into(); + let mappings: Vec = serde_json::from_value(value).map_err(|e| { + format!( + "Failed to parse port mappings for intercept {}: {e}", + intercept.id + ) + })?; + + let mut port_map = HashMap::with_capacity(mappings.len()); + for mapping in &mappings { + port_map.insert(mapping.workload_port, mapping.local_port); + } + + let websocket_url = format!( + "{}/api/v1/kube/sidecar/tunnel/{}/{}/{}", + base_trimmed, environment_id, intercept.workload_id, session_id + ); + + routes.insert( + intercept.workload_id, + DevboxRouteConfig { + intercept_id: intercept.id, + workload_id: intercept.workload_id, + session_id, + auth_token: environment.auth_token.clone(), + websocket_url, + path_pattern: "/*".to_string(), + branch_environment_id: environment.base_environment_id.map(|_| environment_id), + created_at_epoch_seconds: Some(intercept.created_at.timestamp()), + expires_at_epoch_seconds: intercept.stopped_at.map(|dt| dt.timestamp()), + port_mappings: port_map, + }, + ); + } + + Ok(( + environment.cluster_id, + environment.base_environment_id, + routes, + )) + } + fn cookie_token(&self, cookie: &headers::Cookie, name: &str) -> Result { let token = cookie.get(name).ok_or(ApiError::Unauthenticated)?; let untrusted_token = diff --git a/crates/api/src/tunnel_broker.rs b/crates/api/src/tunnel_broker.rs index 4826dcf..8985cd8 100644 --- a/crates/api/src/tunnel_broker.rs +++ b/crates/api/src/tunnel_broker.rs @@ -4,6 +4,7 @@ use axum::extract::ws::WebSocket; use tokio::{ io, sync::{mpsc, oneshot}, + task::JoinHandle, }; use tracing::{info, warn}; use uuid::Uuid; @@ -13,14 +14,25 @@ use crate::websocket_transport::WebSocketTransport; #[derive(Default)] struct InterceptWaitingEntry { devbox: Option, - sidecar: Option, + devbox_workload_id: Option, + sidecars: HashMap, } #[derive(Default)] -struct ProxyWaitingEntry { +struct ProxySessionEntry { devbox: Option, + active_environment: Option, +} + +#[derive(Default)] +struct ProxyEnvironmentEntry { proxy: Option, - session_id: Option, + active_session: Option, +} + +struct ActiveBridgeEntry { + environment_id: Uuid, + handle: JoinHandle<()>, } struct PendingEndpoint { @@ -44,20 +56,17 @@ enum InterceptEndpointKind { Sidecar, } -pub struct TunnelBroker { - commands: mpsc::UnboundedSender, -} - enum BrokerCommand { RegisterIntercept { session_id: Uuid, + workload_id: Option, kind: InterceptEndpointKind, socket: WebSocket, notify: oneshot::Sender<()>, }, RegisterProxyClient { - environment_id: Uuid, session_id: Uuid, + environment_id: Uuid, socket: WebSocket, notify: oneshot::Sender<()>, }, @@ -66,39 +75,60 @@ enum BrokerCommand { socket: WebSocket, notify: oneshot::Sender<()>, }, + UpdateSessionEnvironment { + session_id: Uuid, + environment_id: Option, + }, + ProxyBridgeClosed { + session_id: Uuid, + environment_id: Uuid, + }, +} + +pub struct TunnelBroker { + commands: mpsc::UnboundedSender, } impl TunnelBroker { pub fn new() -> Self { let (commands, mut rx) = mpsc::unbounded_channel(); + let command_tx = commands.clone(); tokio::spawn(async move { let mut intercept_sessions: HashMap = HashMap::new(); - let mut proxy_environments: HashMap = HashMap::new(); + let mut proxy_sessions: HashMap = HashMap::new(); + let mut proxy_environments: HashMap = HashMap::new(); + let mut active_bridges: HashMap = HashMap::new(); + let command_sender = command_tx; while let Some(command) = rx.recv().await { match command { BrokerCommand::RegisterIntercept { session_id, + workload_id, kind, socket, notify, } => Self::handle_register_intercept( &mut intercept_sessions, session_id, + workload_id, kind, socket, notify, ), BrokerCommand::RegisterProxyClient { - environment_id, session_id, + environment_id, socket, notify, } => Self::handle_register_proxy_client( + &command_sender, + &mut proxy_sessions, &mut proxy_environments, - environment_id, + &mut active_bridges, session_id, + environment_id, socket, notify, ), @@ -107,11 +137,35 @@ impl TunnelBroker { socket, notify, } => Self::handle_register_proxy( + &command_sender, + &mut proxy_sessions, &mut proxy_environments, + &mut active_bridges, environment_id, socket, notify, ), + BrokerCommand::UpdateSessionEnvironment { + session_id, + environment_id, + } => Self::handle_update_session_environment( + &command_sender, + &mut proxy_sessions, + &mut proxy_environments, + &mut active_bridges, + session_id, + environment_id, + ), + BrokerCommand::ProxyBridgeClosed { + session_id, + environment_id, + } => Self::handle_proxy_bridge_closed( + &mut proxy_sessions, + &mut proxy_environments, + &mut active_bridges, + session_id, + environment_id, + ), } } }); @@ -119,12 +173,18 @@ impl TunnelBroker { Self { commands } } - pub async fn register_devbox(&self, session_id: Uuid, socket: WebSocket) { + pub async fn register_devbox( + &self, + session_id: Uuid, + workload_id: Option, + socket: WebSocket, + ) { let (notify_tx, notify_rx) = oneshot::channel(); if self .commands .send(BrokerCommand::RegisterIntercept { session_id, + workload_id, kind: InterceptEndpointKind::Devbox, socket, notify: notify_tx, @@ -137,12 +197,13 @@ impl TunnelBroker { let _ = notify_rx.await; } - pub async fn register_sidecar(&self, session_id: Uuid, socket: WebSocket) { + pub async fn register_sidecar(&self, session_id: Uuid, workload_id: Uuid, socket: WebSocket) { let (notify_tx, notify_rx) = oneshot::channel(); if self .commands .send(BrokerCommand::RegisterIntercept { session_id, + workload_id: Some(workload_id), kind: InterceptEndpointKind::Sidecar, socket, notify: notify_tx, @@ -195,15 +256,39 @@ impl TunnelBroker { let _ = notify_rx.await; } + pub async fn set_session_environment(&self, session_id: Uuid, environment_id: Option) { + if self + .commands + .send(BrokerCommand::UpdateSessionEnvironment { + session_id, + environment_id, + }) + .is_err() + { + warn!("Tunnel broker command channel closed while updating session environment"); + } + } + fn spawn_intercept_bridge( session_id: Uuid, + workload_id: Option, devbox_socket: WebSocket, devbox_notify: Option>, sidecar_socket: WebSocket, sidecar_notify: Option>, ) { tokio::spawn(async move { - info!("Bridging intercept tunnel session {}", session_id); + match workload_id { + Some(workload) => { + info!( + "Bridging intercept tunnel session {} workload {}", + session_id, workload + ); + } + None => { + info!("Bridging intercept tunnel session {}", session_id); + } + } let mut devbox_transport = WebSocketTransport::new(devbox_socket); let mut sidecar_transport = WebSocketTransport::new(sidecar_socket); @@ -211,12 +296,32 @@ impl TunnelBroker { if let Err(err) = io::copy_bidirectional(&mut devbox_transport, &mut sidecar_transport).await { - warn!( - "Intercept tunnel session {} ended with error: {}", - session_id, err - ); + match workload_id { + Some(workload) => { + warn!( + "Intercept tunnel session {} workload {} ended with error: {}", + session_id, workload, err + ); + } + None => { + warn!( + "Intercept tunnel session {} ended with error: {}", + session_id, err + ); + } + } } else { - info!("Intercept tunnel session {} closed", session_id); + match workload_id { + Some(workload) => { + info!( + "Intercept tunnel session {} workload {} closed", + session_id, workload + ); + } + None => { + info!("Intercept tunnel session {} closed", session_id); + } + } } if let Some(tx) = devbox_notify { @@ -229,13 +334,14 @@ impl TunnelBroker { } fn spawn_proxy_bridge( + commands: mpsc::UnboundedSender, environment_id: Uuid, session_id: Uuid, devbox_socket: WebSocket, devbox_notify: Option>, proxy_socket: WebSocket, proxy_notify: Option>, - ) { + ) -> JoinHandle<()> { tokio::spawn(async move { info!( "Bridging proxy tunnel for environment {} session {}", @@ -265,12 +371,18 @@ impl TunnelBroker { if let Some(tx) = proxy_notify { let _ = tx.send(()); } - }); + + let _ = commands.send(BrokerCommand::ProxyBridgeClosed { + session_id, + environment_id, + }); + }) } fn handle_register_intercept( sessions: &mut HashMap, session_id: Uuid, + workload_id: Option, kind: InterceptEndpointKind, socket: WebSocket, notify: oneshot::Sender<()>, @@ -288,143 +400,337 @@ impl TunnelBroker { session_id ); } + entry.devbox_workload_id = workload_id; } InterceptEndpointKind::Sidecar => { + let Some(workload_id) = workload_id else { + warn!( + "Sidecar endpoint for session {} missing workload id; dropping connection", + session_id + ); + return; + }; + if entry - .sidecar - .replace(PendingEndpoint::new(socket, notify)) + .sidecars + .insert(workload_id, PendingEndpoint::new(socket, notify)) .is_some() { warn!( - "Duplicate sidecar endpoint for session {} - replacing stale connection", - session_id + "Duplicate sidecar endpoint for session {} workload {} - replacing stale connection", + session_id, workload_id ); } } } - let ready = entry.devbox.is_some() && entry.sidecar.is_some(); - if !ready { - return; - } + Self::try_pair_intercept(sessions, session_id); + } - if let Some(entry) = sessions.remove(&session_id) { - let InterceptWaitingEntry { devbox, sidecar } = entry; - if let (Some(devbox), Some(sidecar)) = (devbox, sidecar) { - let (devbox_socket, devbox_notify) = devbox.split(); - let (sidecar_socket, sidecar_notify) = sidecar.split(); - Self::spawn_intercept_bridge( - session_id, - devbox_socket, - Some(devbox_notify), - sidecar_socket, - Some(sidecar_notify), - ); - } + fn try_pair_intercept(sessions: &mut HashMap, session_id: Uuid) { + let (devbox, workload_id, sidecar, remove_entry) = { + let Some(entry) = sessions.get_mut(&session_id) else { + return; + }; + + let Some(devbox) = entry.devbox.take() else { + return; + }; + + let workload_id = entry.devbox_workload_id.take(); + + let (selected_workload, sidecar) = match workload_id { + Some(workload_id) => match entry.sidecars.remove(&workload_id) { + Some(endpoint) => (Some(workload_id), endpoint), + None => { + // No matching workload yet; restore and wait. + entry.devbox = Some(devbox); + entry.devbox_workload_id = Some(workload_id); + return; + } + }, + None => { + let Some((&workload_id, _)) = entry.sidecars.iter().next() else { + // No sidecars waiting; restore devbox endpoint. + entry.devbox = Some(devbox); + return; + }; + let endpoint = entry + .sidecars + .remove(&workload_id) + .expect("sidecar entry present"); + (Some(workload_id), endpoint) + } + }; + + let remove_entry = entry.devbox.is_none() && entry.sidecars.is_empty(); + + (devbox, selected_workload, sidecar, remove_entry) + }; + + if remove_entry { + sessions.remove(&session_id); } + + let (devbox_socket, devbox_notify) = devbox.split(); + let (sidecar_socket, sidecar_notify) = sidecar.split(); + Self::spawn_intercept_bridge( + session_id, + workload_id, + devbox_socket, + Some(devbox_notify), + sidecar_socket, + Some(sidecar_notify), + ); } fn handle_register_proxy_client( - environments: &mut HashMap, - environment_id: Uuid, + commands: &mpsc::UnboundedSender, + sessions: &mut HashMap, + environments: &mut HashMap, + active_bridges: &mut HashMap, session_id: Uuid, + environment_id: Uuid, socket: WebSocket, notify: oneshot::Sender<()>, ) { - let entry = environments.entry(environment_id).or_default(); - entry.session_id = Some(session_id); + let entry = sessions.entry(session_id).or_default(); if entry .devbox .replace(PendingEndpoint::new(socket, notify)) .is_some() { warn!( - "Duplicate devbox proxy client endpoint for environment {} (session {})", - environment_id, session_id + "Duplicate devbox proxy client endpoint for session {} - replacing stale connection", + session_id ); + Self::abort_active_bridge(active_bridges, environments, session_id); } - if entry.proxy.is_none() { - return; - } + Self::update_session_environment_internal( + sessions, + environments, + active_bridges, + session_id, + Some(environment_id), + ); - if let Some(entry) = environments.remove(&environment_id) { - let ProxyWaitingEntry { - devbox, - proxy, - session_id: stored_session, - } = entry; - - if let (Some(devbox), Some(proxy)) = (devbox, proxy) { - let (devbox_socket, devbox_notify) = devbox.split(); - let (proxy_socket, proxy_notify) = proxy.split(); - let session_id = stored_session.unwrap_or_else(|| { - warn!( - "Missing session id while bridging environment {}; defaulting to {}", - environment_id, session_id - ); - session_id - }); - Self::spawn_proxy_bridge( - environment_id, - session_id, - devbox_socket, - Some(devbox_notify), - proxy_socket, - Some(proxy_notify), - ); - } - } + Self::attempt_proxy_pair(commands, sessions, environments, active_bridges, session_id); } fn handle_register_proxy( - environments: &mut HashMap, + commands: &mpsc::UnboundedSender, + sessions: &mut HashMap, + environments: &mut HashMap, + active_bridges: &mut HashMap, environment_id: Uuid, socket: WebSocket, notify: oneshot::Sender<()>, ) { - let entry = environments.entry(environment_id).or_default(); - if entry - .proxy - .replace(PendingEndpoint::new(socket, notify)) - .is_some() - { - warn!( - "Duplicate devbox proxy endpoint for environment {}", - environment_id + let active_session = { + let entry = environments.entry(environment_id).or_default(); + if entry + .proxy + .replace(PendingEndpoint::new(socket, notify)) + .is_some() + { + warn!( + "Duplicate devbox proxy endpoint for environment {}", + environment_id + ); + } + entry.active_session + }; + + if let Some(session_id) = active_session { + Self::attempt_proxy_pair(commands, sessions, environments, active_bridges, session_id); + } + } + + fn handle_update_session_environment( + commands: &mpsc::UnboundedSender, + sessions: &mut HashMap, + environments: &mut HashMap, + active_bridges: &mut HashMap, + session_id: Uuid, + environment_id: Option, + ) { + Self::update_session_environment_internal( + sessions, + environments, + active_bridges, + session_id, + environment_id, + ); + + Self::attempt_proxy_pair(commands, sessions, environments, active_bridges, session_id); + } + + fn handle_proxy_bridge_closed( + sessions: &mut HashMap, + environments: &mut HashMap, + active_bridges: &mut HashMap, + session_id: Uuid, + environment_id: Uuid, + ) { + if let Some(active) = active_bridges.remove(&session_id) { + info!( + "Proxy tunnel between session {} and environment {} closed", + session_id, active.environment_id + ); + if active.environment_id != environment_id { + warn!( + "Proxy bridge closed event environment mismatch: expected {}, received {}", + active.environment_id, environment_id + ); + } + } else { + info!( + "Proxy tunnel closed for session {} environment {}", + session_id, environment_id ); } - if entry.devbox.is_none() { + if let Some(session_entry) = sessions.get(&session_id) { + if session_entry.active_environment != Some(environment_id) { + if let Some(env_entry) = environments.get_mut(&environment_id) { + if env_entry.active_session == Some(session_id) { + env_entry.active_session = None; + } + } + } + } + } + + fn update_session_environment_internal( + sessions: &mut HashMap, + environments: &mut HashMap, + active_bridges: &mut HashMap, + session_id: Uuid, + environment_id: Option, + ) { + let entry = sessions.entry(session_id).or_default(); + if entry.active_environment == environment_id { return; } - if let Some(entry) = environments.remove(&environment_id) { - let ProxyWaitingEntry { - devbox, - proxy, - session_id, - } = entry; + if let Some(active) = active_bridges.remove(&session_id) { + info!( + "Aborting proxy tunnel for session {} (environment {})", + session_id, active.environment_id + ); + active.handle.abort(); + if let Some(env_entry) = environments.get_mut(&active.environment_id) { + if env_entry.active_session == Some(session_id) { + env_entry.active_session = None; + } + } + } - if let (Some(devbox), Some(proxy)) = (devbox, proxy) { - let (devbox_socket, devbox_notify) = devbox.split(); - let (proxy_socket, proxy_notify) = proxy.split(); - let session_id = session_id.unwrap_or_else(|| { - warn!( - "Missing session id while bridging environment {}; using zero UUID", - environment_id - ); - Uuid::nil() - }); - Self::spawn_proxy_bridge( - environment_id, - session_id, - devbox_socket, - Some(devbox_notify), - proxy_socket, - Some(proxy_notify), - ); + if let Some(old_env) = entry.active_environment.take() { + if let Some(env_entry) = environments.get_mut(&old_env) { + if env_entry.active_session == Some(session_id) { + env_entry.active_session = None; + } + } + } + + entry.active_environment = environment_id; + + if let Some(new_env) = environment_id { + let previous_session = { + let env_entry = environments.entry(new_env).or_default(); + let previous = env_entry.active_session; + env_entry.active_session = Some(session_id); + previous + }; + + if let Some(previous_session) = previous_session { + if previous_session != session_id { + Self::abort_active_bridge(active_bridges, environments, previous_session); + } } } } + + fn abort_active_bridge( + active_bridges: &mut HashMap, + environments: &mut HashMap, + session_id: Uuid, + ) { + if let Some(active) = active_bridges.remove(&session_id) { + info!( + "Aborting existing proxy bridge for session {} environment {}", + session_id, active.environment_id + ); + active.handle.abort(); + if let Some(env_entry) = environments.get_mut(&active.environment_id) { + if env_entry.active_session == Some(session_id) { + env_entry.active_session = None; + } + } + } + } + + fn attempt_proxy_pair( + commands: &mpsc::UnboundedSender, + sessions: &mut HashMap, + environments: &mut HashMap, + active_bridges: &mut HashMap, + session_id: Uuid, + ) { + if active_bridges.contains_key(&session_id) { + return; + } + + let Some(session_entry) = sessions.get_mut(&session_id) else { + return; + }; + + let Some(environment_id) = session_entry.active_environment else { + return; + }; + + let Some(env_entry) = environments.get_mut(&environment_id) else { + return; + }; + + if env_entry.active_session != Some(session_id) { + return; + } + + if session_entry.devbox.is_none() || env_entry.proxy.is_none() { + return; + } + + let devbox = session_entry + .devbox + .take() + .expect("devbox endpoint missing despite earlier check"); + let proxy = env_entry + .proxy + .take() + .expect("proxy endpoint missing despite earlier check"); + + let (devbox_socket, devbox_notify) = devbox.split(); + let (proxy_socket, proxy_notify) = proxy.split(); + + let handle = Self::spawn_proxy_bridge( + commands.clone(), + environment_id, + session_id, + devbox_socket, + Some(devbox_notify), + proxy_socket, + Some(proxy_notify), + ); + + active_bridges.insert( + session_id, + ActiveBridgeEntry { + environment_id, + handle, + }, + ); + } } diff --git a/crates/cli/src/devbox/commands/connect.rs b/crates/cli/src/devbox/commands/connect.rs index 89c36a6..a8c76cd 100644 --- a/crates/cli/src/devbox/commands/connect.rs +++ b/crates/cli/src/devbox/commands/connect.rs @@ -7,7 +7,7 @@ use lapdev_tunnel::WebSocketTransport; use lapdev_tunnel::{ run_tunnel_server, TunnelClient, TunnelError, WebSocketTransport as TunnelWebSocketTransport, }; -use std::{sync::Arc, time::Duration}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use tarpc::server::{BaseChannel, Channel}; use tokio::{ signal, @@ -166,21 +166,6 @@ async fn connect_and_run_rpc( let rpc_client = DevboxSessionRpcClient::new(tarpc::client::Config::default(), client_chan).spawn(); - // Create RPC server (for server to call us) - let client_rpc_server = DevboxClientRpcServer::new(shutdown_tx.clone(), env_change_tx.clone()); - - // Spawn the RPC server task - let server_task = tokio::spawn(async move { - tracing::info!("Starting DevboxClientRpc server..."); - BaseChannel::with_defaults(server_chan) - .execute(client_rpc_server.serve()) - .for_each(|resp| async move { - tokio::spawn(resp); - }) - .await; - tracing::info!("DevboxClientRpc server stopped"); - }); - // Call whoami to get session info let session_info = match rpc_client .whoami(tarpc::context::current()) @@ -209,10 +194,28 @@ async fn connect_and_run_rpc( } }; - tunnel_manager - .ensure_intercept(api_url, token, session_info.session_id) - .await - .map_err(|e| anyhow::anyhow!("Failed to start intercept tunnel: {}", e))?; + // Create RPC server (for server to call us) + let client_rpc_server = DevboxClientRpcServer::new( + shutdown_tx.clone(), + env_change_tx.clone(), + Arc::clone(&tunnel_manager), + api_url.to_string(), + token.to_string(), + session_info.session_id, + ); + + // Spawn the RPC server task + let server_task = tokio::spawn(async move { + tracing::info!("Starting DevboxClientRpc server..."); + BaseChannel::with_defaults(server_chan) + .execute(client_rpc_server.serve()) + .for_each(|resp| async move { + tokio::spawn(resp); + }) + .await; + tracing::info!("DevboxClientRpc server stopped"); + }); + tunnel_manager .ensure_client(api_url, token, session_info.session_id) .await @@ -265,6 +268,24 @@ async fn connect_and_run_rpc( intercept.workload_name.cyan(), intercept.port_mappings.len() ); + + if let Err(err) = tunnel_manager + .ensure_intercept_for_workload( + api_url, + token, + session_info.session_id, + intercept.workload_id, + intercept.intercept_id, + ) + .await + { + tracing::error!( + workload_id = %intercept.workload_id, + intercept_id = %intercept.intercept_id, + "Failed to ensure intercept tunnel during rehydrate: {}", + err + ); + } } } Err(err) => { @@ -361,16 +382,28 @@ async fn connect_and_run_rpc( struct DevboxClientRpcServer { shutdown_tx: mpsc::UnboundedSender, env_change_tx: mpsc::UnboundedSender>, + tunnel_manager: Arc, + api_url: String, + token: String, + session_id: Uuid, } impl DevboxClientRpcServer { fn new( shutdown_tx: mpsc::UnboundedSender, env_change_tx: mpsc::UnboundedSender>, + tunnel_manager: Arc, + api_url: String, + token: String, + session_id: Uuid, ) -> Self { Self { shutdown_tx, env_change_tx, + tunnel_manager, + api_url, + token, + session_id, } } } @@ -381,6 +414,11 @@ impl DevboxClientRpc for DevboxClientRpcServer { _context: tarpc::context::Context, intercept: StartInterceptRequest, ) -> Result<(), String> { + let tunnel_manager = self.tunnel_manager.clone(); + let api_url = self.api_url.clone(); + let token = self.token.clone(); + let session_id = self.session_id; + println!( "{} Starting intercept for workload: {}/{}", "→".cyan(), @@ -388,7 +426,30 @@ impl DevboxClientRpc for DevboxClientRpcServer { intercept.workload_name ); - tracing::info!("Intercept {} acknowledged by CLI", intercept.intercept_id); + if let Err(err) = tunnel_manager + .ensure_intercept_for_workload( + &api_url, + &token, + session_id, + intercept.workload_id, + intercept.intercept_id, + ) + .await + { + tracing::error!( + workload_id = %intercept.workload_id, + intercept_id = %intercept.intercept_id, + "Failed to start intercept tunnel: {}", + err + ); + } else { + tracing::info!( + workload_id = %intercept.workload_id, + intercept_id = %intercept.intercept_id, + "Intercept {} acknowledged by CLI", + intercept.intercept_id + ); + } Ok(()) } @@ -398,6 +459,8 @@ impl DevboxClientRpc for DevboxClientRpcServer { intercept_id: Uuid, ) -> Result<(), String> { println!("{} Received stop intercept: {}", "✗".yellow(), intercept_id); + let tunnel_manager = self.tunnel_manager.clone(); + tunnel_manager.stop_intercept_by_id(intercept_id).await; tracing::info!("Intercept {} stop acknowledged by CLI", intercept_id); Ok(()) } @@ -416,6 +479,9 @@ impl DevboxClientRpc for DevboxClientRpcServer { environment: Option, ) { tracing::info!("environment_changed RPC handler called"); + let tunnel_manager = self.tunnel_manager.clone(); + tunnel_manager.stop_all_intercepts().await; + let env_change_tx = self.env_change_tx; if let Some(ref env) = environment { @@ -456,7 +522,7 @@ enum ShutdownSignal { } struct DevboxTunnelManager { - intercept_task: Mutex>, + intercepts: Mutex, client_task: Mutex>, /// Shared tunnel client for DNS service bridge tunnel_client: Arc>>>, @@ -468,10 +534,21 @@ struct DevboxTunnelManager { ip_allocator: Arc>, } +#[derive(Default)] +struct InterceptState { + tasks: HashMap, + intercept_to_workload: HashMap, +} + +struct InterceptTaskEntry { + intercept_id: Uuid, + task: TunnelTask, +} + impl DevboxTunnelManager { fn new() -> Self { Self { - intercept_task: Mutex::new(None), + intercepts: Mutex::new(InterceptState::default()), client_task: Mutex::new(None), tunnel_client: Arc::new(RwLock::new(None)), service_bridge: Arc::new(ServiceBridge::new()), @@ -618,6 +695,7 @@ impl DevboxTunnelManager { struct TunnelTask { kind: TunnelKind, + workload_id: Option, shutdown: oneshot::Sender<()>, handle: JoinHandle<()>, } @@ -642,26 +720,103 @@ impl TunnelKind { } impl DevboxTunnelManager { - async fn ensure_intercept( + async fn ensure_intercept_for_workload( &self, api_url: &str, token: &str, session_id: Uuid, + workload_id: Uuid, + intercept_id: Uuid, ) -> Result<(), String> { - let mut guard = self.intercept_task.lock().await; - if guard.is_some() { - return Ok(()); + loop { + let removed = { + let mut state = self.intercepts.lock().await; + + if let Some(entry) = state.tasks.get(&workload_id) { + if entry.intercept_id == intercept_id { + return Ok(()); + } + let removed = state.tasks.remove(&workload_id).unwrap(); + state.intercept_to_workload.remove(&removed.intercept_id); + Some(removed.task) + } else if let Some(existing_workload) = + state.intercept_to_workload.get(&intercept_id).copied() + { + if let Some(entry) = state.tasks.remove(&existing_workload) { + state.intercept_to_workload.remove(&intercept_id); + Some(entry.task) + } else { + state.intercept_to_workload.remove(&intercept_id); + None + } + } else { + let task = spawn_tunnel_task( + TunnelKind::Intercept, + api_url.trim_end_matches('/').to_string(), + token.to_string(), + session_id, + Some(workload_id), + None, + ); + state + .tasks + .insert(workload_id, InterceptTaskEntry { intercept_id, task }); + state + .intercept_to_workload + .insert(intercept_id, workload_id); + return Ok(()); + } + }; + + if let Some(task) = removed { + Self::stop_task(task, "Devbox intercept tunnel").await; + } else { + // Entry removed without task; retry to create the new one. + continue; + } } + } - let task = spawn_tunnel_task( - TunnelKind::Intercept, - api_url.trim_end_matches('/').to_string(), - token.to_string(), - session_id, - None, // Intercept tunnel doesn't need to share client - ); - *guard = Some(task); - Ok(()) + async fn stop_intercept_by_id(&self, intercept_id: Uuid) { + let task = { + let mut state = self.intercepts.lock().await; + if let Some(workload_id) = state.intercept_to_workload.remove(&intercept_id) { + state.tasks.remove(&workload_id).map(|entry| entry.task) + } else { + None + } + }; + + if let Some(task) = task { + Self::stop_task(task, "Devbox intercept tunnel").await; + } + } + + async fn stop_intercept_by_workload(&self, workload_id: Uuid) { + let task = { + let mut state = self.intercepts.lock().await; + state.tasks.remove(&workload_id).map(|entry| { + state.intercept_to_workload.remove(&entry.intercept_id); + entry.task + }) + }; + + if let Some(task) = task { + Self::stop_task(task, "Devbox intercept tunnel").await; + } + } + + async fn stop_all_intercepts(&self) { + let tasks: Vec = { + let mut state = self.intercepts.lock().await; + let entries = state.tasks.drain().collect::>(); + state.intercept_to_workload.clear(); + entries.into_iter().map(|(_, entry)| entry.task).collect() + }; + + for task in tasks { + Self::stop_task(task, "Devbox intercept tunnel").await; + } } async fn ensure_client( @@ -680,6 +835,7 @@ impl DevboxTunnelManager { api_url.trim_end_matches('/').to_string(), token.to_string(), session_id, + None, Some(Arc::clone(&self.tunnel_client)), // Share client tunnel for DNS ); *guard = Some(task); @@ -693,9 +849,7 @@ impl DevboxTunnelManager { } async fn shutdown(&self) { - if let Some(task) = self.intercept_task.lock().await.take() { - Self::stop_task(task, "Devbox intercept tunnel").await; - } + self.stop_all_intercepts().await; if let Some(task) = self.client_task.lock().await.take() { Self::stop_task(task, "Devbox client tunnel").await; } @@ -704,6 +858,7 @@ impl DevboxTunnelManager { async fn stop_task(task: TunnelTask, context: &str) { let TunnelTask { kind, + workload_id, shutdown, handle, } = task; @@ -711,6 +866,7 @@ impl DevboxTunnelManager { if let Err(err) = handle.await { tracing::warn!( tunnel_kind = kind.as_str(), + workload_id = workload_id.map(|id| id.to_string()), error = %err, "{} task exited with error", context @@ -724,6 +880,7 @@ fn spawn_tunnel_task( api_url: String, token: String, session_id: Uuid, + workload_id: Option, tunnel_client_slot: Option>>>>, ) -> TunnelTask { let (shutdown_tx, shutdown_rx) = oneshot::channel(); @@ -732,12 +889,14 @@ fn spawn_tunnel_task( api_url, token, session_id, + workload_id, shutdown_rx, tunnel_client_slot, )); TunnelTask { kind, + workload_id, shutdown: shutdown_tx, handle, } @@ -748,42 +907,74 @@ async fn run_tunnel_loop( api_url: String, token: String, session_id: Uuid, + workload_id: Option, mut shutdown_rx: oneshot::Receiver<()>, tunnel_client_slot: Option>>>>, ) { let ws_base = api_url .replace("https://", "wss://") .replace("http://", "ws://"); - let ws_url = format!( - "{}/api/v1/kube/devbox/tunnel/{}/{}", - ws_base.trim_end_matches('/'), - kind.path(), - session_id - ); + let ws_url = match (kind, workload_id) { + (TunnelKind::Intercept, Some(workload)) => format!( + "{}/api/v1/kube/devbox/tunnel/{}/{}?workload_id={}", + ws_base.trim_end_matches('/'), + kind.path(), + session_id, + workload + ), + _ => format!( + "{}/api/v1/kube/devbox/tunnel/{}/{}", + ws_base.trim_end_matches('/'), + kind.path(), + session_id + ), + }; + let workload_id_str = workload_id.map(|id| id.to_string()); let mut backoff = Duration::from_secs(1); loop { tokio::select! { _ = &mut shutdown_rx => { - tracing::info!(%session_id, tunnel_kind = kind.as_str(), "Devbox tunnel shutdown signal received"); + tracing::info!( + %session_id, + tunnel_kind = kind.as_str(), + workload_id = workload_id_str.as_deref(), + "Devbox tunnel shutdown signal received" + ); break; } result = connect_and_run_tunnel(&ws_url, &token, tunnel_client_slot.as_ref()) => { match result { Ok(()) => { - tracing::info!(%session_id, tunnel_kind = kind.as_str(), "Devbox tunnel closed gracefully"); + tracing::info!( + %session_id, + tunnel_kind = kind.as_str(), + workload_id = workload_id_str.as_deref(), + "Devbox tunnel closed gracefully" + ); backoff = Duration::from_secs(1); } Err(err) => { - tracing::warn!(%session_id, tunnel_kind = kind.as_str(), "Devbox tunnel disconnected: {}", err); + tracing::warn!( + %session_id, + tunnel_kind = kind.as_str(), + workload_id = workload_id_str.as_deref(), + "Devbox tunnel disconnected: {}", + err + ); backoff = (backoff.saturating_mul(2)).min(Duration::from_secs(30)); } } tokio::select! { _ = &mut shutdown_rx => { - tracing::info!(%session_id, tunnel_kind = kind.as_str(), "Devbox tunnel shutdown signal received"); + tracing::info!( + %session_id, + tunnel_kind = kind.as_str(), + workload_id = workload_id_str.as_deref(), + "Devbox tunnel shutdown signal received" + ); break; } _ = sleep(backoff) => {} diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index e1dec4c..25c57aa 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -22,8 +22,9 @@ use lapdev_common::kube::{ KUBE_CLUSTER_TOKEN_HEADER, KUBE_CLUSTER_TUNNEL_URL_ENV_VAR, KUBE_CLUSTER_URL_ENV_VAR, }; use lapdev_kube_rpc::{ - KubeClusterRpcClient, KubeManagerRpc, KubeWorkloadYamlOnly, KubeWorkloadsWithResources, - NamespacedResourceRequest, NamespacedResourceResponse, + DevboxRouteConfig, KubeClusterRpcClient, KubeManagerRpc, KubeWorkloadYamlOnly, + KubeWorkloadsWithResources, NamespacedResourceRequest, NamespacedResourceResponse, + ProxyBranchRouteConfig, }; use lapdev_rpc::spawn_twoway; use serde::Deserialize; @@ -1897,6 +1898,52 @@ impl KubeManager { .set_service_routes_if_registered(environment_id) .await } + + pub async fn set_devbox_routes( + &self, + environment_id: Uuid, + routes: HashMap, + ) -> Result<(), String> { + self.proxy_manager + .set_devbox_routes(environment_id, routes) + .await + } + + pub async fn update_branch_service_route( + &self, + base_environment_id: Uuid, + workload_id: Uuid, + route: ProxyBranchRouteConfig, + ) -> Result<(), String> { + self.proxy_manager + .upsert_branch_service_route(base_environment_id, workload_id, route) + .await + } + + pub async fn remove_branch_service_route( + &self, + base_environment_id: Uuid, + workload_id: Uuid, + branch_environment_id: Uuid, + ) -> Result<(), String> { + self.proxy_manager + .remove_branch_service_route( + base_environment_id, + workload_id, + branch_environment_id, + ) + .await + } + + pub async fn clear_devbox_routes( + &self, + environment_id: Uuid, + branch_environment_id: Option, + ) -> Result<(), String> { + self.proxy_manager + .clear_devbox_routes(environment_id, branch_environment_id) + .await + } } #[cfg(test)] diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index ec82a8f..b21e0a2 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -3,9 +3,11 @@ use lapdev_common::kube::{ KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, PaginationParams, }; use lapdev_kube_rpc::{ - KubeClusterRpcClient, KubeManagerRpc, KubeRawWorkloadYaml, KubeWorkloadsWithResources, - NamespacedResourceRequest, NamespacedResourceResponse, WorkloadIdentifier, + DevboxRouteConfig, KubeClusterRpcClient, KubeManagerRpc, KubeRawWorkloadYaml, + KubeWorkloadsWithResources, NamespacedResourceRequest, NamespacedResourceResponse, + ProxyBranchRouteConfig, WorkloadIdentifier, }; +use std::collections::HashMap; use uuid::Uuid; use crate::manager::KubeManager; @@ -196,6 +198,54 @@ impl KubeManagerRpc for KubeManagerRpcServer { } } + async fn set_devbox_routes( + self, + _context: ::tarpc::context::Context, + environment_id: Uuid, + routes: HashMap, + ) -> Result<(), String> { + self.manager.set_devbox_routes(environment_id, routes).await + } + + async fn update_branch_service_route( + self, + _context: ::tarpc::context::Context, + base_environment_id: Uuid, + workload_id: Uuid, + route: ProxyBranchRouteConfig, + ) -> Result<(), String> { + self.manager + .update_branch_service_route(base_environment_id, workload_id, route) + .await + } + + async fn remove_branch_service_route( + self, + _context: ::tarpc::context::Context, + base_environment_id: Uuid, + workload_id: Uuid, + branch_environment_id: Uuid, + ) -> Result<(), String> { + self.manager + .remove_branch_service_route( + base_environment_id, + workload_id, + branch_environment_id, + ) + .await + } + + async fn clear_devbox_routes( + self, + _context: ::tarpc::context::Context, + environment_id: Uuid, + branch_environment_id: Option, + ) -> Result<(), String> { + self.manager + .clear_devbox_routes(environment_id, branch_environment_id) + .await + } + async fn refresh_branch_service_routes( self, _context: ::tarpc::context::Context, diff --git a/crates/kube-manager/src/sidecar_proxy_manager.rs b/crates/kube-manager/src/sidecar_proxy_manager.rs index 447429d..2dbb484 100644 --- a/crates/kube-manager/src/sidecar_proxy_manager.rs +++ b/crates/kube-manager/src/sidecar_proxy_manager.rs @@ -1,7 +1,10 @@ use anyhow::Result; use futures::StreamExt; use lapdev_common::kube::{DEFAULT_SIDECAR_PROXY_MANAGER_PORT, SIDECAR_PROXY_MANAGER_PORT_ENV_VAR}; -use lapdev_kube_rpc::{KubeClusterRpcClient, SidecarProxyManagerRpc, SidecarProxyRpcClient}; +use lapdev_kube_rpc::{ + DevboxRouteConfig, KubeClusterRpcClient, ProxyBranchRouteConfig, SidecarProxyManagerRpc, + SidecarProxyRpcClient, +}; use lapdev_rpc::spawn_twoway; use std::{collections::HashMap, sync::Arc}; use tarpc::server::{BaseChannel, Channel}; @@ -13,16 +16,10 @@ use crate::sidecar_proxy_manager_rpc::SidecarProxyManagerRpcServer; #[derive(Clone)] pub struct SidecarProxyManager { - pub(crate) sidecar_proxies: Arc>>, + pub(crate) sidecar_proxies: Arc>>>, kube_cluster_rpc_client: Arc>>, } -#[derive(Clone)] -pub(crate) struct SidecarProxyRegistration { - pub workload_id: Uuid, - pub rpc_client: SidecarProxyRpcClient, -} - impl SidecarProxyManager { pub(crate) async fn new() -> Result { // Parse the URL to extract the port @@ -77,12 +74,12 @@ impl SidecarProxyManager { } pub async fn set_service_routes_if_registered(&self, environment_id: Uuid) -> Result<()> { - let registration = { + let connections = { let map = self.sidecar_proxies.read().await; map.get(&environment_id).cloned() }; - let Some(registration) = registration else { + let Some(connections) = connections else { return Ok(()); }; @@ -99,37 +96,189 @@ impl SidecarProxyManager { return Ok(()); }; - let routes = match cluster_client - .list_branch_service_routes( - tarpc::context::current(), - environment_id, - registration.workload_id, - ) - .await - { - Ok(Ok(routes)) => routes, - Ok(Err(err)) => { + for (workload_id, client) in connections { + let routes = match cluster_client + .clone() + .list_branch_service_routes(tarpc::context::current(), environment_id, workload_id) + .await + { + Ok(Ok(routes)) => routes, + Ok(Err(err)) => { + return Err(anyhow::anyhow!( + "API rejected route request for environment {} workload {}: {}", + environment_id, + workload_id, + err + )); + } + Err(err) => { + return Err(anyhow::anyhow!( + "Failed to fetch routes for environment {} workload {}: {}", + environment_id, + workload_id, + err + )); + } + }; + + if let Err(e) = client + .set_service_routes(tarpc::context::current(), routes) + .await + { return Err(anyhow::anyhow!( - "API rejected route request for environment {}: {}", - environment_id, - err + "Failed to push service routes to workload {}: {}", + workload_id, + e )); } - Err(err) => { - return Err(anyhow::anyhow!( - "Failed to fetch routes for environment {}: {}", - environment_id, - err + } + + Ok(()) + } + + pub async fn set_devbox_routes( + &self, + environment_id: Uuid, + mut routes: HashMap, + ) -> Result<(), String> { + let connections = { + let map = self.sidecar_proxies.read().await; + map.get(&environment_id).cloned() + }; + + let Some(connections) = connections else { + warn!( + "No sidecar proxy registered for environment {} when sending devbox routes", + environment_id + ); + return Ok(()); + }; + + for (workload_id, client) in connections { + if let Some(route) = routes.remove(&workload_id) { + if let Err(e) = client + .set_devbox_route(tarpc::context::current(), route) + .await + { + return Err(format!( + "Failed to send devbox routes to sidecar (workload {}): {}", + workload_id, e + )); + } + } else if let Err(e) = client.stop_devbox(tarpc::context::current(), None).await { + return Err(format!( + "Failed to clear devbox routes for workload {}: {}", + workload_id, e )); } + } + + for workload_id in routes.into_keys() { + warn!( + "No sidecar proxy registered for workload {} when sending devbox route", + workload_id + ); + } + + Ok(()) + } + + pub async fn upsert_branch_service_route( + &self, + base_environment_id: Uuid, + workload_id: Uuid, + route: ProxyBranchRouteConfig, + ) -> Result<(), String> { + let connections = { + let map = self.sidecar_proxies.read().await; + map.get(&base_environment_id).cloned() }; - if let Err(e) = registration - .rpc_client - .set_service_routes(tarpc::context::current(), routes) + let Some(connections) = connections else { + warn!( + "No sidecar proxy registered for environment {} when updating branch service route", + base_environment_id + ); + return Ok(()); + }; + + let Some(client) = connections.get(&workload_id) else { + warn!( + "No sidecar proxy registered for workload {} in environment {} when updating branch service route", + workload_id, base_environment_id + ); + return Ok(()); + }; + + if let Err(err) = client + .upsert_branch_service_route(tarpc::context::current(), route) .await { - return Err(anyhow::anyhow!("Failed to push service routes: {}", e)); + return Err(format!( + "Failed to update branch service route for workload {}: {}", + workload_id, err + )); + } + + Ok(()) + } + + pub async fn remove_branch_service_route( + &self, + base_environment_id: Uuid, + workload_id: Uuid, + branch_environment_id: Uuid, + ) -> Result<(), String> { + let connections = { + let map = self.sidecar_proxies.read().await; + map.get(&base_environment_id).cloned() + }; + + let Some(connections) = connections else { + return Ok(()); + }; + + let Some(client) = connections.get(&workload_id) else { + return Ok(()); + }; + + if let Err(err) = client + .remove_branch_service_route(tarpc::context::current(), branch_environment_id) + .await + { + return Err(format!( + "Failed to remove branch service route for workload {} branch {}: {}", + workload_id, branch_environment_id, err + )); + } + + Ok(()) + } + + pub async fn clear_devbox_routes( + &self, + environment_id: Uuid, + branch_environment_id: Option, + ) -> Result<(), String> { + let connections = { + let map = self.sidecar_proxies.read().await; + map.get(&environment_id).cloned() + }; + + let Some(connections) = connections else { + return Ok(()); + }; + + for (workload_id, client) in connections { + if let Err(err) = client + .stop_devbox(tarpc::context::current(), branch_environment_id) + .await + { + return Err(format!( + "Failed to clear devbox routes for workload {}: {}", + workload_id, err + )); + } } Ok(()) diff --git a/crates/kube-manager/src/sidecar_proxy_manager_rpc.rs b/crates/kube-manager/src/sidecar_proxy_manager_rpc.rs index 8ca110e..ee7ae9a 100644 --- a/crates/kube-manager/src/sidecar_proxy_manager_rpc.rs +++ b/crates/kube-manager/src/sidecar_proxy_manager_rpc.rs @@ -30,13 +30,12 @@ impl SidecarProxyManagerRpc for SidecarProxyManagerRpcServer { environment_id: Uuid, _namespace: String, ) -> Result<(), String> { - self.manager.sidecar_proxies.write().await.insert( - environment_id, - crate::sidecar_proxy_manager::SidecarProxyRegistration { - workload_id, - rpc_client: self.rpc_client.clone(), - }, - ); + { + let mut map = self.manager.sidecar_proxies.write().await; + map.entry(environment_id) + .or_default() + .insert(workload_id, self.rpc_client.clone()); + } self.manager .set_service_routes_if_registered(environment_id) @@ -55,33 +54,4 @@ impl SidecarProxyManagerRpc for SidecarProxyManagerRpcServer { ) -> Result<(), String> { todo!() } - - async fn request_devbox_tunnel( - self, - _context: ::tarpc::context::Context, - intercept_id: Uuid, - source_addr: String, - target_port: u16, - ) -> Result { - // TODO: Full implementation: - // 1. Validate the intercept_id exists and is active - // 2. Generate a unique tunnel_id - // 3. Call API to register the tunnel (so API knows to expect sidecar WebSocket) - // 4. Return WebSocket URL + auth token for sidecar to connect directly to API - // 5. API will broker data between sidecar WebSocket ↔ devbox WebSocket - // - // New architecture (hybrid control/data plane): - // Control: Sidecar → Kube-Manager (RPC) → API (setup) - // Data: Sidecar → API (WebSocket) → Devbox (direct streaming) - - tracing::info!( - "Requesting devbox tunnel for intercept_id={}, source_addr={}, target_port={}", - intercept_id, - source_addr, - target_port - ); - - // For now, return a placeholder with the expected structure - Err("request_devbox_tunnel not yet fully implemented - need to integrate with TunnelRegistry in API".to_string()) - } } diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index 512b461..f94bbcb 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -418,6 +418,28 @@ pub trait KubeManagerRpc { requests: Vec, ) -> Result, String>; + async fn set_devbox_routes( + environment_id: Uuid, + routes: HashMap, + ) -> Result<(), String>; + + async fn update_branch_service_route( + base_environment_id: Uuid, + workload_id: Uuid, + route: ProxyBranchRouteConfig, + ) -> Result<(), String>; + + async fn remove_branch_service_route( + base_environment_id: Uuid, + workload_id: Uuid, + branch_environment_id: Uuid, + ) -> Result<(), String>; + + async fn clear_devbox_routes( + environment_id: Uuid, + branch_environment_id: Option, + ) -> Result<(), String>; + async fn refresh_branch_service_routes(environment_id: Uuid) -> Result<(), String>; async fn destroy_environment(environment_id: Uuid, namespace: String) -> Result<(), String>; @@ -451,14 +473,6 @@ pub trait SidecarProxyManagerRpc { byte_count: u64, active_connections: u32, ) -> Result<(), String>; - - /// Request a devbox tunnel connection for service interception - /// Returns tunnel info for establishing direct WebSocket connection to API - async fn request_devbox_tunnel( - intercept_id: Uuid, - source_addr: String, - target_port: u16, - ) -> Result; } /// Information returned when requesting a devbox tunnel @@ -478,8 +492,8 @@ pub struct DevboxTunnelInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DevboxRouteConfig { pub intercept_id: Uuid, + pub workload_id: Uuid, pub session_id: Uuid, - pub target_port: u16, pub auth_token: String, pub websocket_url: String, /// Path pattern for this route (e.g., "/*" for all traffic) @@ -521,16 +535,17 @@ pub trait SidecarProxyRpc { /// Replace the service routes with the provided configuration async fn set_service_routes(routes: Vec) -> Result<(), String>; - /// Add a DevboxTunnel route for service interception - /// Returns true if route was added successfully - async fn add_devbox_route(route: DevboxRouteConfig) -> Result; + /// Set the DevboxTunnel route for this sidecar's workload + async fn set_devbox_route(route: DevboxRouteConfig) -> Result<(), String>; + + /// Stop the active devbox route for either the default environment or a branch environment + async fn stop_devbox(branch_environment: Option) -> Result<(), String>; - /// Remove a DevboxTunnel route by intercept_id - /// Returns true if route was found and removed - async fn remove_devbox_route(intercept_id: Uuid) -> Result; + /// Update (or insert) the branch service route for a specific branch environment + async fn upsert_branch_service_route(route: ProxyBranchRouteConfig) -> Result<(), String>; - /// List all active DevboxTunnel routes - async fn list_devbox_routes() -> Result, String>; + /// Remove the branch service route for a specific branch environment + async fn remove_branch_service_route(branch_environment_id: Uuid) -> Result<(), String>; } /// Information about a branch environment that shares the devbox-proxy diff --git a/crates/kube-sidecar-proxy/src/config.rs b/crates/kube-sidecar-proxy/src/config.rs index a2cf605..4542a6f 100644 --- a/crates/kube-sidecar-proxy/src/config.rs +++ b/crates/kube-sidecar-proxy/src/config.rs @@ -3,7 +3,12 @@ use lapdev_tunnel::{ TunnelClient, TunnelError, TunnelTcpStream, WebSocketTransport as TunnelWebSocketTransport, }; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, io, net::SocketAddr, sync::Arc}; +use std::{ + collections::{hash_map::Entry, HashMap}, + io, + net::SocketAddr, + sync::Arc, +}; use tokio::sync::{OnceCell, RwLock}; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -150,6 +155,49 @@ impl RoutingTable { true } + pub fn clear_branch_devboxes(&mut self) { + for route in self.branch_routes.values_mut() { + if matches!(route.mode, BranchMode::Devbox(_)) { + route.mode = BranchMode::Service; + } + } + } + + pub fn remove_branch_devbox(&mut self, branch_id: &Uuid) -> bool { + let Some(route) = self.branch_routes.get_mut(branch_id) else { + return false; + }; + + if matches!(route.mode, BranchMode::Devbox(_)) { + route.mode = BranchMode::Service; + true + } else { + false + } + } + + pub fn upsert_branch_service_route( + &mut self, + branch_id: Uuid, + service_route: BranchServiceRoute, + ) { + match self.branch_routes.entry(branch_id) { + Entry::Occupied(mut entry) => { + entry.get_mut().service = service_route; + } + Entry::Vacant(entry) => { + entry.insert(BranchRoute { + service: service_route, + mode: BranchMode::Service, + }); + } + } + } + + pub fn remove_branch_service_route(&mut self, branch_id: &Uuid) -> bool { + self.branch_routes.remove(branch_id).is_some() + } + pub fn remove_branch_devbox_by_intercept(&mut self, intercept_id: &Uuid) -> Option { for (branch_id, route) in self.branch_routes.iter_mut() { if let BranchMode::Devbox(connection) = &route.mode { @@ -237,8 +285,8 @@ impl Default for DefaultRoute { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DevboxRouteMetadata { pub intercept_id: Uuid, + pub workload_id: Uuid, pub session_id: Uuid, - pub target_port: u16, pub auth_token: String, pub websocket_url: String, pub path_pattern: String, diff --git a/crates/kube-sidecar-proxy/src/rpc.rs b/crates/kube-sidecar-proxy/src/rpc.rs index d6400ed..d8c568a 100644 --- a/crates/kube-sidecar-proxy/src/rpc.rs +++ b/crates/kube-sidecar-proxy/src/rpc.rs @@ -109,106 +109,146 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { Ok(()) } - async fn add_devbox_route( + async fn set_devbox_route( self, _context: ::tarpc::context::Context, route: DevboxRouteConfig, - ) -> Result { - info!( - "Adding devbox route: intercept_id={}, session_id={}, target_port={}, path={}", - route.intercept_id, route.session_id, route.target_port, route.path_pattern - ); + ) -> Result<(), String> { + if route.workload_id != self.workload_id { + warn!( + "Ignoring devbox route for workload {} on sidecar workload {}", + route.workload_id, self.workload_id + ); + return Ok(()); + } - let mut routing_table = self.routing_table.write().await; let devbox_connection = devbox_connection_from_config(route.clone()); - if let Some(branch_id) = route.branch_environment_id { - if routing_table.set_branch_devbox(&branch_id, devbox_connection.clone()) { + let mut routing_table = self.routing_table.write().await; + + match route.branch_environment_id { + Some(branch_id) => { + if routing_table.set_branch_devbox(&branch_id, devbox_connection.clone()) { + info!( + "Attached devbox route to branch {} (intercept_id={})", + branch_id, route.intercept_id + ); + } else { + warn!( + "Received branch devbox route for unknown environment {}", + branch_id + ); + } + } + None => { + routing_table.set_default_devbox(devbox_connection.clone()); info!( - "Attached devbox route to branch {} (intercept_id={})", - branch_id, route.intercept_id - ); - } else { - warn!( - "Received branch devbox route for unknown environment {}", - branch_id + "Registered default devbox route (intercept_id={})", + route.intercept_id ); - return Ok(false); } - } else { - routing_table.set_default_devbox(devbox_connection.clone()); - info!( - "Registered default devbox route (intercept_id={}, default_target_port={})", - route.intercept_id, route.target_port - ); } - Ok(true) + Ok(()) } - async fn remove_devbox_route( + async fn stop_devbox( self, _context: ::tarpc::context::Context, - intercept_id: Uuid, - ) -> Result { - info!("Removing devbox route: intercept_id={}", intercept_id); - + branch_environment: Option, + ) -> Result<(), String> { let mut routing_table = self.routing_table.write().await; - if let Some(branch_id) = routing_table.remove_branch_devbox_by_intercept(&intercept_id) { - info!( - "Removed branch devbox route for environment {} (intercept_id={})", - branch_id, intercept_id - ); - return Ok(true); + match branch_environment { + Some(branch_id) => { + if routing_table.remove_branch_devbox(&branch_id) { + info!( + "Cleared devbox route for branch environment {} on workload {}", + branch_id, self.workload_id + ); + } else { + info!( + "No devbox route to clear for branch environment {} on workload {}", + branch_id, self.workload_id + ); + } + } + None => { + routing_table.clear_default_devbox(); + info!( + "Cleared default devbox route for workload {}", + self.workload_id + ); + } } - if routing_table.remove_default_devbox_by_intercept(&intercept_id) { - info!( - "Removed default devbox route (intercept_id={})", - intercept_id - ); - return Ok(true); + Ok(()) + } + + async fn upsert_branch_service_route( + self, + _context: ::tarpc::context::Context, + route: ProxyBranchRouteConfig, + ) -> Result<(), String> { + let Some(service_name) = route.service_name.clone() else { + return Err("Missing service_name for branch service route update".to_string()); + }; + + let branch_id = route.branch_environment_id; + let service_route = BranchServiceRoute { + service_name, + headers: route.headers.clone(), + requires_auth: route.requires_auth, + access_level: access_level_from_proxy(route.access_level), + timeout_ms: route.timeout_ms, + }; + + let devbox_override = route + .devbox_route + .map(devbox_connection_from_config); + + let mut routing_table = self.routing_table.write().await; + routing_table.upsert_branch_service_route(branch_id, service_route); + + if let Some(connection) = devbox_override { + routing_table.set_branch_devbox(&branch_id, connection); } - warn!( - "No devbox route found for intercept_id={} to remove", - intercept_id + info!( + "Updated branch service route for branch {} on workload {}", + branch_id, self.workload_id ); - Ok(false) + Ok(()) } - async fn list_devbox_routes( + async fn remove_branch_service_route( self, _context: ::tarpc::context::Context, - ) -> Result, String> { - let routing_table = self.routing_table.read().await; - let mut routes = Vec::new(); - - if let Some(connection) = routing_table.default_devbox() { - routes.push(devbox_route_config_from_connection(&connection, None)); - } - - for (branch_id, branch_route) in &routing_table.branch_routes { - if let crate::config::BranchMode::Devbox(connection) = &branch_route.mode { - routes.push(devbox_route_config_from_connection( - connection, - Some(*branch_id), - )); - } + branch_environment_id: Uuid, + ) -> Result<(), String> { + let mut routing_table = self.routing_table.write().await; + if routing_table.remove_branch_service_route(&branch_environment_id) { + info!( + "Removed branch service route for branch {} on workload {}", + branch_environment_id, self.workload_id + ); + } else { + info!( + "No branch service route found for branch {} on workload {}", + branch_environment_id, self.workload_id + ); } - info!("Listed {} devbox routes", routes.len()); - Ok(routes) + Ok(()) } } fn devbox_connection_from_config(route: DevboxRouteConfig) -> Arc { Arc::new(DevboxConnection::new(DevboxRouteMetadata { intercept_id: route.intercept_id, + workload_id: route.workload_id, session_id: route.session_id, - target_port: route.target_port, auth_token: route.auth_token, websocket_url: route.websocket_url, path_pattern: route.path_pattern, @@ -225,8 +265,8 @@ fn devbox_route_config_from_connection( let metadata = connection.metadata(); DevboxRouteConfig { intercept_id: metadata.intercept_id, + workload_id: metadata.workload_id, session_id: metadata.session_id, - target_port: metadata.target_port, auth_token: metadata.auth_token.clone(), websocket_url: metadata.websocket_url.clone(), path_pattern: metadata.path_pattern.clone(), diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index 524472a..f36be00 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -14,8 +14,8 @@ use lapdev_db_entities::{ kube_app_catalog_workload_label, }; use lapdev_kube_rpc::{ - KubeClusterRpc, KubeManagerRpcClient, ProxyBranchRouteConfig, ProxyRouteAccessLevel, - ResourceChangeEvent, ResourceChangeType, ResourceType, + DevboxRouteConfig, KubeClusterRpc, KubeManagerRpcClient, ProxyBranchRouteConfig, + ProxyRouteAccessLevel, ResourceChangeEvent, ResourceChangeType, ResourceType, }; use sea_orm::prelude::{DateTimeWithTimeZone, Json}; use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; @@ -137,6 +137,46 @@ impl KubeClusterServer { .await?; self.send_namespace_watch_configuration(namespaces).await } + + pub async fn set_devbox_routes( + &self, + environment_id: Uuid, + routes: HashMap, + ) -> Result<(), String> { + match self + .rpc_client + .set_devbox_routes(tarpc::context::current(), environment_id, routes) + .await + { + Ok(result) => result, + Err(err) => Err(format!( + "Failed to send set_devbox_routes RPC to kube-manager: {}", + err + )), + } + } + + pub async fn clear_devbox_routes( + &self, + environment_id: Uuid, + branch_environment_id: Option, + ) -> Result<(), String> { + match self + .rpc_client + .clear_devbox_routes( + tarpc::context::current(), + environment_id, + branch_environment_id, + ) + .await + { + Ok(result) => result, + Err(err) => Err(format!( + "Failed to send clear_devbox_routes RPC to kube-manager: {}", + err + )), + } + } } impl KubeClusterRpc for KubeClusterServer { From 3a1b33db835f3f2c34ab73e37a86fc442110dd75 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Thu, 23 Oct 2025 20:03:00 +0000 Subject: [PATCH 170/334] update --- crates/api/src/kube_controller/workload.rs | 64 ++++++++-------------- crates/kube-rpc/src/lib.rs | 8 +++ 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/crates/api/src/kube_controller/workload.rs b/crates/api/src/kube_controller/workload.rs index 93293f1..d5cb459 100644 --- a/crates/api/src/kube_controller/workload.rs +++ b/crates/api/src/kube_controller/workload.rs @@ -13,8 +13,8 @@ use k8s_openapi::api::{ use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta}; use lapdev_common::kube::{KubeServiceDetails, KubeServiceWithYaml, KubeWorkloadKind}; use lapdev_kube_rpc::{ - KubeWorkloadYamlOnly, KubeWorkloadsWithResources, ProxyBranchRouteConfig, - ProxyRouteAccessLevel, + KubeWorkloadWithResources, KubeWorkloadYamlOnly, KubeWorkloadsWithResources, + ProxyBranchRouteConfig, ProxyRouteAccessLevel, }; use lapdev_rpc::error::ApiError; @@ -135,7 +135,7 @@ impl KubeController { )) })?; - let (workloads_with_resources, persisted_yaml, extra_labels) = + let (workload_with_resources, persisted_yaml, extra_labels) = if environment.base_environment_id.is_some() { let (manifest, yaml, labels) = self .build_branch_workload_manifest( @@ -174,7 +174,12 @@ impl KubeController { self.deploy_environment_resources( &cluster_server, &environment, - workloads_with_resources, + KubeWorkloadsWithResources { + workloads: vec![workload_with_resources.workload], + services: workload_with_resources.services, + configmaps: workload_with_resources.configmaps, + secrets: workload_with_resources.secrets, + }, extra_labels, ) .await?; @@ -285,10 +290,7 @@ impl KubeController { ); if let Err(err) = cluster_server .rpc_client - .refresh_branch_service_routes( - tarpc::context::current(), - base_environment_id, - ) + .refresh_branch_service_routes(tarpc::context::current(), base_environment_id) .await { tracing::warn!( @@ -367,7 +369,7 @@ impl KubeController { existing_workload: &lapdev_common::kube::KubeEnvironmentWorkload, kind: KubeWorkloadKind, containers: &[lapdev_common::kube::KubeContainerInfo], - ) -> Result<(KubeWorkloadsWithResources, String, HashMap), ApiError> { + ) -> Result<(KubeWorkloadWithResources, String, HashMap), ApiError> { let base_workload_name = existing_workload.name.clone(); let env_suffix = environment.id.to_string(); let branch_workload_name = if base_workload_name.ends_with(&env_suffix) { @@ -376,15 +378,17 @@ impl KubeController { format!("{}-{}", base_workload_name, environment.id) }; - let (mut workloads_with_resources, _) = Self::build_standard_workload_manifest( + let (mut workload_with_resources, persisted_yaml) = Self::build_standard_workload_manifest( kind, &existing_workload.workload_yaml, containers, )?; - for workload_yaml in &mut workloads_with_resources.workloads { - rename_workload_yaml(workload_yaml, &base_workload_name, &branch_workload_name)?; - } + rename_workload_yaml( + &mut workload_with_resources.workload, + &base_workload_name, + &branch_workload_name, + )?; let environment_services = self .db @@ -454,11 +458,9 @@ impl KubeController { }, ); } - workloads_with_resources.services = updated_services; - workloads_with_resources.configmaps.clear(); - workloads_with_resources.secrets.clear(); - - let persisted_yaml = extract_workload_yaml(&workloads_with_resources.workloads)?; + workload_with_resources.services = updated_services; + workload_with_resources.configmaps.clear(); + workload_with_resources.secrets.clear(); let mut extra_labels = HashMap::new(); extra_labels.insert( @@ -482,14 +484,14 @@ impl KubeController { existing_workload.id.to_string(), ); - Ok((workloads_with_resources, persisted_yaml, extra_labels)) + Ok((workload_with_resources, persisted_yaml, extra_labels)) } fn build_standard_workload_manifest( kind: KubeWorkloadKind, original_yaml: &str, containers: &[lapdev_common::kube::KubeContainerInfo], - ) -> Result<(KubeWorkloadsWithResources, String), ApiError> { + ) -> Result<(KubeWorkloadWithResources, String), ApiError> { let rebuilt_yaml = rebuild_workload_yaml(&kind, original_yaml, containers).map_err(|err| { ApiError::InvalidRequest(format!("Failed to rebuild workload manifest: {}", err)) @@ -508,8 +510,8 @@ impl KubeController { }; Ok(( - KubeWorkloadsWithResources { - workloads: vec![workload], + KubeWorkloadWithResources { + workload, services: HashMap::new(), configmaps: HashMap::new(), secrets: HashMap::new(), @@ -519,24 +521,6 @@ impl KubeController { } } -fn extract_workload_yaml(workloads: &[KubeWorkloadYamlOnly]) -> Result { - let workload = workloads.first().ok_or_else(|| { - ApiError::InvalidRequest("No workload manifest generated for deployment".to_string()) - })?; - - let yaml = match workload { - KubeWorkloadYamlOnly::Deployment(yaml) - | KubeWorkloadYamlOnly::StatefulSet(yaml) - | KubeWorkloadYamlOnly::DaemonSet(yaml) - | KubeWorkloadYamlOnly::ReplicaSet(yaml) - | KubeWorkloadYamlOnly::Pod(yaml) - | KubeWorkloadYamlOnly::Job(yaml) - | KubeWorkloadYamlOnly::CronJob(yaml) => yaml.clone(), - }; - - Ok(yaml) -} - fn rename_workload_yaml( workload: &mut KubeWorkloadYamlOnly, old_name: &str, diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index f94bbcb..68ad5f4 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -333,6 +333,14 @@ pub enum KubeWorkloadYamlOnly { CronJob(String), } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KubeWorkloadWithResources { + pub workload: KubeWorkloadYamlOnly, + pub services: HashMap, // name -> service with YAML and details + pub configmaps: HashMap, // name -> YAML content + pub secrets: HashMap, // name -> YAML content +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KubeWorkloadsWithResources { pub workloads: Vec, From d72c5ab4df63823eb8a48c022bed85d4ca467969 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Sun, 26 Oct 2025 19:02:48 +0000 Subject: [PATCH 171/334] update --- Cargo.lock | 1 + crates/api/src/cluster_events.rs | 92 +++ crates/api/src/kube_controller/app_catalog.rs | 12 +- crates/api/src/kube_controller/deployment.rs | 112 +++- crates/api/src/kube_controller/environment.rs | 176 ++++-- crates/api/src/kube_controller/resources.rs | 25 +- crates/api/src/kube_controller/workload.rs | 535 ++++++++---------- crates/api/src/lib.rs | 1 + crates/api/src/router.rs | 6 +- crates/api/src/state.rs | 39 +- crates/common/src/kube.rs | 18 +- .../dashboard/src/kube_environment_detail.rs | 2 +- crates/dashboard/src/kube_resource.rs | 70 ++- crates/db/Cargo.toml | 1 + ...20250809_000001_create_kube_environment.rs | 8 - crates/db/src/api.rs | 69 ++- crates/kube-manager/src/manager.rs | 126 +---- crates/kube-manager/src/manager_rpc.rs | 28 +- crates/kube-rpc/src/lib.rs | 4 +- crates/kube-sidecar-proxy/src/config.rs | 12 +- crates/kube-sidecar-proxy/src/error.rs | 4 +- crates/kube-sidecar-proxy/src/rpc.rs | 18 +- crates/kube-sidecar-proxy/src/server.rs | 41 +- crates/kube/src/server.rs | 101 +++- 24 files changed, 926 insertions(+), 575 deletions(-) create mode 100644 crates/api/src/cluster_events.rs diff --git a/Cargo.lock b/Cargo.lock index 18d0f07..be6efb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3332,6 +3332,7 @@ dependencies = [ "serde_yaml", "sqlx", "tokio", + "tracing", "uuid", ] diff --git a/crates/api/src/cluster_events.rs b/crates/api/src/cluster_events.rs new file mode 100644 index 0000000..0ffb2e5 --- /dev/null +++ b/crates/api/src/cluster_events.rs @@ -0,0 +1,92 @@ +use std::{convert::Infallible, str::FromStr, sync::Arc, time::Duration}; + +use axum::{ + extract::{Path, State}, + response::sse::{Event, KeepAlive, Sse}, +}; +use axum_extra::{headers, TypedHeader}; +use chrono::Utc; +use futures::{stream, Stream, StreamExt}; +use lapdev_common::kube::{ClusterStatusEvent, KubeClusterStatus}; +use lapdev_rpc::error::ApiError; +use tokio_stream::wrappers::BroadcastStream; +use tracing::warn; +use uuid::Uuid; + +use crate::state::CoreState; + +pub async fn stream_cluster_status_events( + Path((org_id, cluster_id)): Path<(Uuid, Uuid)>, + State(state): State>, + TypedHeader(cookies): TypedHeader, +) -> Result>>, ApiError> { + let user = state.authenticate(&cookies).await?; + state + .db + .get_organization_member(user.id, org_id) + .await + .map_err(|_| ApiError::Unauthorized)?; + + let cluster = state + .db + .get_kube_cluster(cluster_id) + .await? + .ok_or_else(|| ApiError::InvalidRequest("Cluster not found".to_string()))?; + + if cluster.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + let status = + KubeClusterStatus::from_str(&cluster.status).unwrap_or(KubeClusterStatus::Provisioning); + let updated_at = cluster + .last_reported_at + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); + + let initial_event = ClusterStatusEvent { + organization_id: cluster.organization_id, + cluster_id, + status, + cluster_version: cluster.cluster_version.clone(), + region: cluster.region.clone(), + updated_at, + }; + + let receiver = state.cluster_events.subscribe(); + + let initial_stream = stream::iter( + build_cluster_sse_event(&initial_event) + .into_iter() + .map(Ok::), + ); + + let target_org = org_id; + let target_cluster = cluster_id; + + let event_stream = BroadcastStream::new(receiver).filter_map(move |result| async move { + match result { + Ok(event) + if event.organization_id == target_org && event.cluster_id == target_cluster => + { + build_cluster_sse_event(&event).map(Ok) + } + Ok(_) => None, + Err(err) => { + warn!("cluster event stream lagged: {err}"); + None + } + } + }); + + let stream = initial_stream.chain(event_stream); + let keep_alive = KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("keep-alive"); + + Ok(Sse::new(stream).keep_alive(keep_alive)) +} + +fn build_cluster_sse_event(event: &ClusterStatusEvent) -> Option { + Event::default().event("cluster").json_data(event).ok() +} diff --git a/crates/api/src/kube_controller/app_catalog.rs b/crates/api/src/kube_controller/app_catalog.rs index 3ecf814..b70989a 100644 --- a/crates/api/src/kube_controller/app_catalog.rs +++ b/crates/api/src/kube_controller/app_catalog.rs @@ -1,4 +1,4 @@ -use k8s_openapi::api::core::v1::{ConfigMap, Secret}; +use k8s_openapi::api::core::v1::{ConfigMap, Secret, Service}; use lapdev_common::kube::{ KubeAppCatalog, KubeAppCatalogWorkload, KubeAppCatalogWorkloadCreate, KubeServiceDetails, KubeServiceWithYaml, PagePaginationParams, PaginatedInfo, PaginatedResult, @@ -13,6 +13,8 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, TransactionTrait}; use std::collections::{HashMap, HashSet}; use uuid::Uuid; +use crate::kube_controller::resources::clean_service; + use super::{ resources::{clean_configmap, clean_secret, rebuild_workload_yaml}, yaml_parser::build_workload_details_from_yaml, @@ -169,14 +171,16 @@ impl KubeController { for service_entity in service_entities { let ports: Vec = serde_json::from_value(service_entity.ports.clone()).unwrap_or_default(); - let selector_btree: std::collections::BTreeMap = + let selector: std::collections::BTreeMap = serde_json::from_value(service_entity.selector.clone()).unwrap_or_default(); - let selector: HashMap = selector_btree.into_iter().collect(); + let service: Service = serde_yaml::from_str(&service_entity.service_yaml)?; + let service = clean_service(service); + let service_yaml = serde_yaml::to_string(&service)?; services_map.insert( service_entity.name.clone(), KubeServiceWithYaml { - yaml: service_entity.service_yaml, + yaml: service_yaml, details: KubeServiceDetails { name: service_entity.name, ports, diff --git a/crates/api/src/kube_controller/deployment.rs b/crates/api/src/kube_controller/deployment.rs index d742d32..184e734 100644 --- a/crates/api/src/kube_controller/deployment.rs +++ b/crates/api/src/kube_controller/deployment.rs @@ -1,7 +1,13 @@ use std::collections::HashMap; +use k8s_openapi::api::apps::v1::Deployment; +use k8s_openapi::api::core::v1::{ + Container, ContainerPort, EnvVar, EnvVarSource, ObjectFieldSelector, +}; use lapdev_kube::server::KubeClusterServer; +use lapdev_kube_rpc::KubeWorkloadYamlOnly; use lapdev_rpc::error::ApiError; +use uuid::Uuid; use super::KubeController; @@ -13,6 +19,7 @@ impl KubeController { workloads_with_resources: lapdev_kube_rpc::KubeWorkloadsWithResources, extra_labels: Option>, ) -> Result<(), ApiError> { + let mut workloads_with_resources = workloads_with_resources; let namespace = &environment.namespace; let environment_name = &environment.name; @@ -33,6 +40,19 @@ impl KubeController { environment_name ); + if environment.base_environment_id.is_none() { + for workload in workloads_with_resources.workloads.iter_mut() { + if let KubeWorkloadYamlOnly::Deployment(yaml) = workload { + inject_sidecar_proxy_into_deployment_yaml( + yaml, + environment.id, + &environment.namespace, + &environment.auth_token, + )?; + } + } + } + // Prepare environment-specific labels let mut environment_labels = std::collections::HashMap::new(); environment_labels.insert( @@ -58,14 +78,10 @@ impl KubeController { "Environment auth token is required".to_string(), )); } - let auth_token = environment.auth_token.clone(); - match target_server .rpc_client .deploy_workload_yaml( tarpc::context::current(), - environment.id, - auth_token, namespace.to_string(), workloads_with_resources, environment_labels, @@ -88,3 +104,91 @@ impl KubeController { } } } + +fn inject_sidecar_proxy_into_deployment_yaml( + yaml: &mut String, + environment_id: Uuid, + namespace: &str, + auth_token: &str, +) -> Result<(), ApiError> { + let mut deployment: Deployment = serde_yaml::from_str(yaml).map_err(|err| { + ApiError::InvalidRequest(format!( + "Failed to parse deployment YAML for sidecar injection: {}", + err + )) + })?; + + if let Some(spec) = deployment.spec.as_mut() { + if let Some(template) = spec.template.spec.as_mut() { + let already_present = template + .containers + .iter() + .any(|container| container.name == "lapdev-sidecar-proxy"); + if !already_present { + let sidecar_container = Container { + name: "lapdev-sidecar-proxy".to_string(), + image: Some("lapdev/kube-sidecar-proxy:latest".to_string()), + ports: Some(vec![ + ContainerPort { + container_port: 8080, + name: Some("proxy".to_string()), + protocol: Some("TCP".to_string()), + ..Default::default() + }, + ContainerPort { + container_port: 9090, + name: Some("metrics".to_string()), + protocol: Some("TCP".to_string()), + ..Default::default() + }, + ]), + env: Some(vec![ + EnvVar { + name: "LAPDEV_ENVIRONMENT_ID".to_string(), + value: Some(environment_id.to_string()), + ..Default::default() + }, + EnvVar { + name: "LAPDEV_ENVIRONMENT_AUTH_TOKEN".to_string(), + value: Some(auth_token.to_string()), + ..Default::default() + }, + EnvVar { + name: "KUBERNETES_NAMESPACE".to_string(), + value: Some(namespace.to_string()), + ..Default::default() + }, + EnvVar { + name: "HOSTNAME".to_string(), + value_from: Some(EnvVarSource { + field_ref: Some(ObjectFieldSelector { + field_path: "metadata.name".to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }, + ]), + args: Some(vec![ + "--listen-addr".to_string(), + "0.0.0.0:8080".to_string(), + "--target-addr".to_string(), + "127.0.0.1:3000".to_string(), + ]), + ..Default::default() + }; + template.containers.push(sidecar_container); + } + } + } + + *yaml = serde_yaml::to_string(&deployment).map_err(|err| { + ApiError::InvalidRequest(format!( + "Failed to serialize deployment YAML after sidecar injection: {}", + err + )) + })?; + + Ok(()) +} diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index b7d92f0..498873c 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -24,6 +24,7 @@ use crate::environment_events::EnvironmentLifecycleEvent; use super::{ resources::{set_cronjob_suspend, set_daemonset_paused, set_workload_replicas}, + workload::{build_branch_service_selector, rename_service_yaml, rename_workload_yaml}, EnvironmentNamespaceKind, KubeController, }; @@ -402,6 +403,15 @@ impl KubeController { Ok(()) } Ok(Err(e)) => { + if e.contains("No devbox-proxy registered for base environment") { + tracing::warn!( + base_environment_id = %base_env_id, + branch_environment_id = %environment_id, + error = %e, + "No devbox-proxy registered for base environment; skipping branch deletion notification" + ); + return Ok(()); + } tracing::error!( "Failed to notify devbox-proxy about branch environment {} deletion: {}", environment_id, @@ -811,6 +821,8 @@ impl KubeController { ) .await?; + let environment_id = Uuid::new_v4(); + let services_map = workloads_with_resources.services.clone(); // Prepare workload details for database @@ -820,6 +832,7 @@ impl KubeController { let created_env = match self .db .create_kube_environment( + environment_id, org_id, user_id, app_catalog_id, @@ -983,11 +996,21 @@ impl KubeController { fn prepare_workload_details_from_base( base_workloads: Vec, namespace: &str, - ) -> Result, ApiError> { - base_workloads + branch_environment_id: Uuid, + ) -> Result< + ( + Vec, + HashMap, // base workload name -> branch workload name + ), + ApiError, + > { + let env_suffix = branch_environment_id.to_string(); + let mut branch_name_map = HashMap::new(); + + let workloads = base_workloads .into_iter() .map(|workload| { - let kind = workload.kind.parse().map_err(|_| { + let kind: KubeWorkloadKind = workload.kind.parse().map_err(|_| { ApiError::InvalidRequest(format!( "Invalid workload kind {} in base environment", workload.kind @@ -999,6 +1022,21 @@ impl KubeController { workload.name ))); } + + let base_name = workload.name.clone(); + let branch_name = if base_name.ends_with(&env_suffix) { + base_name.clone() + } else { + format!("{}-{}", base_name, branch_environment_id) + }; + branch_name_map.insert(base_name.clone(), branch_name.clone()); + + let mut workload_yaml = + Self::wrap_workload_yaml(kind.clone(), workload.workload_yaml.clone()); + let selector = build_branch_service_selector(&branch_name); + rename_workload_yaml(&mut workload_yaml, &branch_name, &selector)?; + let workload_yaml_string = Self::workload_yaml_to_string(&workload_yaml); + let mut containers = workload.containers; for container in &mut containers { // Preserve the original environment variables @@ -1017,17 +1055,98 @@ impl KubeController { } } } + Ok(lapdev_common::kube::KubeWorkloadDetails { - name: workload.name, + name: base_name, namespace: namespace.to_string(), kind, containers, ports: workload.ports, - workload_yaml: workload.workload_yaml, + workload_yaml: workload_yaml_string, base_workload_id: Some(workload.id), }) }) - .collect() + .collect::, ApiError>>()?; + + Ok((workloads, branch_name_map)) + } + + fn prepare_branch_services( + base_services: Vec, + workload_name_map: &HashMap, + branch_environment_id: Uuid, + ) -> Result, ApiError> { + let env_suffix = branch_environment_id.to_string(); + let mut services = HashMap::new(); + + for service in base_services { + let lapdev_common::kube::KubeEnvironmentService { + name: base_service_name, + yaml, + ports, + selector, + .. + } = service; + + let branch_service_name = if base_service_name.ends_with(&env_suffix) { + base_service_name.clone() + } else { + format!("{}-{}", base_service_name, branch_environment_id) + }; + + let branch_workload_name = + workload_name_map + .get(&base_service_name) + .cloned() + .or_else(|| { + selector + .values() + .find_map(|value| workload_name_map.get(value).cloned()) + }); + + if let Some(branch_workload_name) = branch_workload_name { + let branch_selector = build_branch_service_selector(&branch_workload_name); + let renamed_yaml = + rename_service_yaml(&yaml, &branch_service_name, &branch_selector)?; + services.insert( + base_service_name.clone(), + lapdev_common::kube::KubeServiceWithYaml { + yaml: renamed_yaml, + details: lapdev_common::kube::KubeServiceDetails { + name: base_service_name, + ports, + selector: branch_selector, + }, + }, + ); + } else { + services.insert( + base_service_name.clone(), + lapdev_common::kube::KubeServiceWithYaml { + yaml, + details: lapdev_common::kube::KubeServiceDetails { + name: base_service_name, + ports, + selector, + }, + }, + ); + } + } + + Ok(services) + } + + fn workload_yaml_to_string(workload: &KubeWorkloadYamlOnly) -> String { + match workload { + KubeWorkloadYamlOnly::Deployment(yaml) + | KubeWorkloadYamlOnly::StatefulSet(yaml) + | KubeWorkloadYamlOnly::DaemonSet(yaml) + | KubeWorkloadYamlOnly::ReplicaSet(yaml) + | KubeWorkloadYamlOnly::Pod(yaml) + | KubeWorkloadYamlOnly::Job(yaml) + | KubeWorkloadYamlOnly::CronJob(yaml) => yaml.clone(), + } } /// Notify devbox-proxy about new branch environment @@ -1112,43 +1231,28 @@ impl KubeController { .await .map_err(ApiError::from)?; - // Generate unique namespace - let namespace = self - .generate_unique_namespace( - base_environment.cluster_id, - EnvironmentNamespaceKind::Branch, - ) - .await?; + let branch_environment_id = Uuid::new_v4(); + let namespace = base_environment.namespace.clone(); - // Prepare services map - let services_map: std::collections::HashMap< - String, - lapdev_common::kube::KubeServiceWithYaml, - > = base_services - .into_iter() - .map(|service| { - ( - service.name.clone(), - lapdev_common::kube::KubeServiceWithYaml { - yaml: service.yaml, - details: lapdev_common::kube::KubeServiceDetails { - name: service.name, - ports: service.ports, - selector: service.selector, - }, - }, - ) - }) - .collect(); + // Prepare workload details and branch workload name mapping + let (workload_details, workload_name_map) = Self::prepare_workload_details_from_base( + base_workloads, + &namespace, + branch_environment_id, + )?; - // Prepare workload details - let workload_details = - Self::prepare_workload_details_from_base(base_workloads, &namespace)?; + // Prepare services map with branch-specific YAML while keeping base names for UI + let services_map = Self::prepare_branch_services( + base_services, + &workload_name_map, + branch_environment_id, + )?; // Create environment in database let mut created_env = match self .db .create_kube_environment( + branch_environment_id, org_id, user_id, base_environment.app_catalog_id, diff --git a/crates/api/src/kube_controller/resources.rs b/crates/api/src/kube_controller/resources.rs index bc8f2bc..3c5ad9d 100644 --- a/crates/api/src/kube_controller/resources.rs +++ b/crates/api/src/kube_controller/resources.rs @@ -10,7 +10,7 @@ use k8s_openapi::{ batch::v1::{CronJob, CronJobSpec, Job, JobSpec}, core::v1::{ ConfigMap, Container, ContainerPort, EnvVar, EnvVarSource, Pod, PodSpec, - PodTemplateSpec, Secret, + PodTemplateSpec, Secret, Service, }, }, apimachinery::pkg::{api::resource::Quantity, apis::meta::v1::ObjectMeta}, @@ -85,6 +85,29 @@ pub fn clean_secret(secret: Secret) -> Secret { } } +#[allow(dead_code)] +pub fn clean_service(service: Service) -> Service { + let clean_spec = service.spec.map(|mut original_spec| { + original_spec.cluster_ip = None; + original_spec.cluster_ips = None; + original_spec.health_check_node_port = None; + + if let Some(ports) = original_spec.ports.as_mut() { + for port in ports { + port.node_port = None; + } + } + + original_spec + }); + + Service { + metadata: clean_metadata(service.metadata), + spec: clean_spec, + status: None, + } +} + fn clean_metadata(metadata: ObjectMeta) -> ObjectMeta { ObjectMeta { name: metadata.name, diff --git a/crates/api/src/kube_controller/workload.rs b/crates/api/src/kube_controller/workload.rs index d5cb459..df65e64 100644 --- a/crates/api/src/kube_controller/workload.rs +++ b/crates/api/src/kube_controller/workload.rs @@ -12,9 +12,10 @@ use k8s_openapi::api::{ }; use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta}; use lapdev_common::kube::{KubeServiceDetails, KubeServiceWithYaml, KubeWorkloadKind}; +use lapdev_kube::server::KubeClusterServer; use lapdev_kube_rpc::{ KubeWorkloadWithResources, KubeWorkloadYamlOnly, KubeWorkloadsWithResources, - ProxyBranchRouteConfig, ProxyRouteAccessLevel, + ProxyBranchRouteConfig, }; use lapdev_rpc::error::ApiError; @@ -185,119 +186,74 @@ impl KubeController { .await?; if let Some(base_environment_id) = environment.base_environment_id { - if let Some(base_workload_id) = existing_workload.base_workload_id { - match self - .build_branch_service_route_config( - base_environment_id, - base_workload_id, - environment.id, - ) - .await - { - Ok(Some(route)) => { - match cluster_server - .rpc_client - .update_branch_service_route( - tarpc::context::current(), + match existing_workload.base_workload_id { + Some(base_workload_id) => { + let base_environment = self + .db + .get_kube_environment(base_environment_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| { + ApiError::InvalidRequest(format!( + "Base environment {} not found", + base_environment_id + )) + })?; + + match cluster_server + .build_branch_service_route_config( + &base_environment, + base_workload_id, + environment.id, + ) + .await + { + Ok(Some(route)) => { + Self::send_branch_service_route_update( + &cluster_server, base_environment_id, base_workload_id, + environment.id, route, ) - .await - { - Ok(Ok(())) => {} - Ok(Err(err)) => { - tracing::warn!( - base_environment_id = %base_environment_id, - branch_environment_id = %environment.id, - workload_id = %base_workload_id, - error = %err, - "KubeManager rejected branch service route update" - ); - } - Err(err) => { - tracing::warn!( - base_environment_id = %base_environment_id, - branch_environment_id = %environment.id, - workload_id = %base_workload_id, - error = %err, - "Failed to send branch service route update to KubeManager" - ); - } + .await; } - } - Ok(None) => { - match cluster_server - .rpc_client - .remove_branch_service_route( - tarpc::context::current(), + Ok(None) => { + Self::send_branch_service_route_removal( + &cluster_server, base_environment_id, base_workload_id, environment.id, ) - .await - { - Ok(Ok(())) => {} - Ok(Err(err)) => { - tracing::warn!( - base_environment_id = %base_environment_id, - branch_environment_id = %environment.id, - workload_id = %base_workload_id, - error = %err, - "KubeManager rejected branch service route removal" - ); - } - Err(err) => { - tracing::warn!( - base_environment_id = %base_environment_id, - branch_environment_id = %environment.id, - workload_id = %base_workload_id, - error = %err, - "Failed to send branch service route removal to KubeManager" - ); - } + .await; } - } - Err(err) => { - tracing::warn!( - base_environment_id = %base_environment_id, - branch_environment_id = %environment.id, - workload_id = %base_workload_id, - error = ?err, - "Failed to build branch service route config; falling back to full refresh" - ); - if let Err(err) = cluster_server - .rpc_client - .refresh_branch_service_routes( - tarpc::context::current(), - base_environment_id, - ) - .await - { + Err(err) => { tracing::warn!( base_environment_id = %base_environment_id, - error = %err, - "Failed to refresh branch service routes during fallback" + branch_environment_id = %environment.id, + workload_id = %base_workload_id, + error = ?err, + "Failed to build branch service route config; falling back to full refresh" ); + Self::refresh_branch_service_routes_with_logging( + &cluster_server, + base_environment_id, + ) + .await; } } } - } else { - tracing::warn!( - environment_id = %environment.id, - workload_id = %workload_id, - "Branch workload missing base_workload_id; falling back to full refresh" - ); - if let Err(err) = cluster_server - .rpc_client - .refresh_branch_service_routes(tarpc::context::current(), base_environment_id) - .await - { + None => { tracing::warn!( - base_environment_id = %base_environment_id, - error = %err, - "Failed to refresh branch service routes during fallback" + environment_id = %environment.id, + workload_id = %workload_id, + "Branch workload missing base_workload_id; falling back to full refresh" ); + Self::refresh_branch_service_routes_with_logging( + &cluster_server, + base_environment_id, + ) + .await; } } } @@ -310,57 +266,98 @@ impl KubeController { Ok(()) } - async fn build_branch_service_route_config( - &self, + async fn send_branch_service_route_update( + cluster_server: &KubeClusterServer, base_environment_id: Uuid, base_workload_id: Uuid, branch_environment_id: Uuid, - ) -> Result, ApiError> { - let base_environment = self - .db - .get_kube_environment(base_environment_id) - .await - .map_err(ApiError::from)? - .ok_or_else(|| { - ApiError::InvalidRequest(format!( - "Base environment {} not found", - base_environment_id - )) - })?; - - let workload_labels = self - .db - .get_environment_workload_labels(base_workload_id) + route: ProxyBranchRouteConfig, + ) { + match cluster_server + .rpc_client + .update_branch_service_route( + tarpc::context::current(), + base_environment_id, + base_workload_id, + route, + ) .await - .map_err(ApiError::from)?; + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::warn!( + base_environment_id = %base_environment_id, + branch_environment_id = %branch_environment_id, + workload_id = %base_workload_id, + error = %err, + "KubeManager rejected branch service route update" + ); + } + Err(err) => { + tracing::warn!( + base_environment_id = %base_environment_id, + branch_environment_id = %branch_environment_id, + workload_id = %base_workload_id, + error = %err, + "Failed to send branch service route update to KubeManager" + ); + } + } + } - let shared_services = self - .db - .get_matching_cluster_services( - base_environment.cluster_id, - &base_environment.namespace, - &workload_labels, + async fn send_branch_service_route_removal( + cluster_server: &KubeClusterServer, + base_environment_id: Uuid, + base_workload_id: Uuid, + branch_environment_id: Uuid, + ) { + match cluster_server + .rpc_client + .remove_branch_service_route( + tarpc::context::current(), + base_environment_id, + base_workload_id, + branch_environment_id, ) .await - .map_err(ApiError::from)?; - - let Some(service) = shared_services.first() else { - return Ok(None); - }; - - let branch_service_name = format!("{}-{}", service.name, branch_environment_id); - - let route = ProxyBranchRouteConfig { - branch_environment_id, - service_name: Some(branch_service_name), - headers: HashMap::new(), - requires_auth: true, - access_level: ProxyRouteAccessLevel::Personal, - timeout_ms: None, - devbox_route: None, - }; + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::warn!( + base_environment_id = %base_environment_id, + branch_environment_id = %branch_environment_id, + workload_id = %base_workload_id, + error = %err, + "KubeManager rejected branch service route removal" + ); + } + Err(err) => { + tracing::warn!( + base_environment_id = %base_environment_id, + branch_environment_id = %branch_environment_id, + workload_id = %base_workload_id, + error = %err, + "Failed to send branch service route removal to KubeManager" + ); + } + } + } - Ok(Some(route)) + async fn refresh_branch_service_routes_with_logging( + cluster_server: &KubeClusterServer, + base_environment_id: Uuid, + ) { + if let Err(err) = cluster_server + .rpc_client + .refresh_branch_service_routes(tarpc::context::current(), base_environment_id) + .await + { + tracing::warn!( + base_environment_id = %base_environment_id, + error = %err, + "Failed to refresh branch service routes during fallback" + ); + } } async fn build_branch_workload_manifest( @@ -377,6 +374,7 @@ impl KubeController { } else { format!("{}-{}", base_workload_name, environment.id) }; + let branch_selector = build_branch_service_selector(&branch_workload_name); let (mut workload_with_resources, persisted_yaml) = Self::build_standard_workload_manifest( kind, @@ -384,12 +382,6 @@ impl KubeController { containers, )?; - rename_workload_yaml( - &mut workload_with_resources.workload, - &base_workload_name, - &branch_workload_name, - )?; - let environment_services = self .db .get_environment_services(environment.id) @@ -420,40 +412,15 @@ impl KubeController { continue; } - let branch_service_name = if service.name.ends_with(&env_suffix) { - service.name.clone() - } else { - format!("{}-{}", service.name, environment.id) - }; - let renamed_yaml = rename_service_yaml( - &service.yaml, - &service.name, - &branch_service_name, - &base_workload_name, - &branch_workload_name, - )?; - - let mut selector = service.selector.clone(); - for value in selector.values_mut() { - if value == &base_workload_name || value == &branch_workload_name { - *value = branch_workload_name.clone(); - } - } - selector - .entry("app".to_string()) - .or_insert_with(|| branch_workload_name.clone()); - selector - .entry("lapdev.workload".to_string()) - .or_insert_with(|| branch_workload_name.clone()); - + let branch_service_name = format!("{}-{}", service.name, environment.id); updated_services.insert( branch_service_name.clone(), KubeServiceWithYaml { - yaml: renamed_yaml, + yaml: service.yaml.clone(), details: KubeServiceDetails { name: branch_service_name, ports: service.ports.clone(), - selector, + selector: branch_selector.clone(), }, }, ); @@ -521,60 +488,52 @@ impl KubeController { } } -fn rename_workload_yaml( +pub(super) fn rename_workload_yaml( workload: &mut KubeWorkloadYamlOnly, - old_name: &str, new_name: &str, + selector_labels: &BTreeMap, ) -> Result<(), ApiError> { match workload { - KubeWorkloadYamlOnly::Deployment(yaml) => rename_deployment_yaml(yaml, old_name, new_name), + KubeWorkloadYamlOnly::Deployment(yaml) => { + rename_deployment_yaml(yaml, new_name, selector_labels) + } KubeWorkloadYamlOnly::StatefulSet(yaml) => { - rename_statefulset_yaml(yaml, old_name, new_name) + rename_statefulset_yaml(yaml, new_name, selector_labels) } - KubeWorkloadYamlOnly::DaemonSet(yaml) => rename_daemonset_yaml(yaml, old_name, new_name), - KubeWorkloadYamlOnly::ReplicaSet(yaml) => rename_replicaset_yaml(yaml, old_name, new_name), - KubeWorkloadYamlOnly::Pod(yaml) => rename_pod_yaml(yaml, old_name, new_name), - KubeWorkloadYamlOnly::Job(yaml) => rename_job_yaml(yaml, old_name, new_name), - KubeWorkloadYamlOnly::CronJob(yaml) => rename_cronjob_yaml(yaml, old_name, new_name), + KubeWorkloadYamlOnly::DaemonSet(yaml) => { + rename_daemonset_yaml(yaml, new_name, selector_labels) + } + KubeWorkloadYamlOnly::ReplicaSet(yaml) => { + rename_replicaset_yaml(yaml, new_name, selector_labels) + } + KubeWorkloadYamlOnly::Pod(yaml) => rename_pod_yaml(yaml, new_name, selector_labels), + KubeWorkloadYamlOnly::Job(yaml) => rename_job_yaml(yaml, new_name, selector_labels), + KubeWorkloadYamlOnly::CronJob(yaml) => rename_cronjob_yaml(yaml, new_name, selector_labels), } } -fn rename_service_yaml( +pub(super) fn build_branch_service_selector(workload_name: &str) -> BTreeMap { + let mut selector = BTreeMap::new(); + selector.insert("app".to_string(), workload_name.to_string()); + selector +} + +pub(super) fn rename_service_yaml( yaml: &str, - old_service_name: &str, new_service_name: &str, - old_selector_value: &str, - new_selector_value: &str, + selector_labels: &BTreeMap, ) -> Result { let mut service: Service = serde_yaml::from_str(yaml).map_err(|err| { ApiError::InvalidRequest(format!("Failed to parse service YAML for rename: {}", err)) })?; - update_object_meta(&mut service.metadata, old_service_name, new_service_name); - ensure_label(&mut service.metadata.labels, "app", new_service_name); + service.metadata.name = Some(new_service_name.to_string()); + let mut labels = BTreeMap::new(); + labels.insert("app".to_string(), new_service_name.to_string()); + service.metadata.labels = Some(labels); if let Some(spec) = service.spec.as_mut() { - if let Some(selector) = spec.selector.as_mut() { - for value in selector.values_mut() { - if value == old_selector_value || value == old_service_name { - *value = new_selector_value.to_string(); - } - } - selector - .entry("app".to_string()) - .or_insert_with(|| new_selector_value.to_string()); - selector - .entry("lapdev.workload".to_string()) - .or_insert_with(|| new_selector_value.to_string()); - } else { - let mut selector = BTreeMap::new(); - selector.insert("app".to_string(), new_selector_value.to_string()); - selector.insert( - "lapdev.workload".to_string(), - new_selector_value.to_string(), - ); - spec.selector = Some(selector); - } + spec.selector = Some(selector_labels.clone()); } serde_yaml::to_string(&service).map_err(|err| { @@ -584,8 +543,8 @@ fn rename_service_yaml( fn rename_deployment_yaml( yaml: &mut String, - old_name: &str, new_name: &str, + selector_labels: &BTreeMap, ) -> Result<(), ApiError> { let mut deployment: Deployment = serde_yaml::from_str(yaml).map_err(|err| { ApiError::InvalidRequest(format!( @@ -594,21 +553,12 @@ fn rename_deployment_yaml( )) })?; - update_object_meta(&mut deployment.metadata, old_name, new_name); - ensure_label(&mut deployment.metadata.labels, "app", new_name); - ensure_label(&mut deployment.metadata.labels, "lapdev.workload", new_name); + update_object_meta(&mut deployment.metadata, new_name, selector_labels); if let Some(spec) = deployment.spec.as_mut() { - update_selector_labels(&mut spec.selector, old_name, new_name); + update_selector_labels(&mut spec.selector, selector_labels); if let Some(template) = spec.template.metadata.as_mut() { - update_object_meta(template, old_name, new_name); - ensure_label(&mut template.labels, "app", new_name); - ensure_label(&mut template.labels, "lapdev.workload", new_name); - } else { - let mut metadata = ObjectMeta::default(); - ensure_label(&mut metadata.labels, "app", new_name); - ensure_label(&mut metadata.labels, "lapdev.workload", new_name); - spec.template.metadata = Some(metadata); + update_object_meta(template, new_name, selector_labels); } } @@ -623,8 +573,8 @@ fn rename_deployment_yaml( fn rename_statefulset_yaml( yaml: &mut String, - old_name: &str, new_name: &str, + selector_labels: &BTreeMap, ) -> Result<(), ApiError> { let mut statefulset: StatefulSet = serde_yaml::from_str(yaml).map_err(|err| { ApiError::InvalidRequest(format!( @@ -633,20 +583,12 @@ fn rename_statefulset_yaml( )) })?; - update_object_meta(&mut statefulset.metadata, old_name, new_name); - ensure_label(&mut statefulset.metadata.labels, "app", new_name); - ensure_label( - &mut statefulset.metadata.labels, - "lapdev.workload", - new_name, - ); + update_object_meta(&mut statefulset.metadata, new_name, selector_labels); if let Some(spec) = statefulset.spec.as_mut() { - update_selector_labels(&mut spec.selector, old_name, new_name); + update_selector_labels(&mut spec.selector, selector_labels); if let Some(template) = spec.template.metadata.as_mut() { - update_object_meta(template, old_name, new_name); - ensure_label(&mut template.labels, "app", new_name); - ensure_label(&mut template.labels, "lapdev.workload", new_name); + update_object_meta(template, new_name, selector_labels); } } @@ -661,8 +603,8 @@ fn rename_statefulset_yaml( fn rename_daemonset_yaml( yaml: &mut String, - old_name: &str, new_name: &str, + selector_labels: &BTreeMap, ) -> Result<(), ApiError> { let mut daemonset: DaemonSet = serde_yaml::from_str(yaml).map_err(|err| { ApiError::InvalidRequest(format!( @@ -671,16 +613,12 @@ fn rename_daemonset_yaml( )) })?; - update_object_meta(&mut daemonset.metadata, old_name, new_name); - ensure_label(&mut daemonset.metadata.labels, "app", new_name); - ensure_label(&mut daemonset.metadata.labels, "lapdev.workload", new_name); + update_object_meta(&mut daemonset.metadata, new_name, selector_labels); if let Some(spec) = daemonset.spec.as_mut() { - update_selector_labels(&mut spec.selector, old_name, new_name); + update_selector_labels(&mut spec.selector, selector_labels); if let Some(template) = spec.template.metadata.as_mut() { - update_object_meta(template, old_name, new_name); - ensure_label(&mut template.labels, "app", new_name); - ensure_label(&mut template.labels, "lapdev.workload", new_name); + update_object_meta(template, new_name, selector_labels); } } @@ -695,8 +633,8 @@ fn rename_daemonset_yaml( fn rename_replicaset_yaml( yaml: &mut String, - old_name: &str, new_name: &str, + selector_labels: &BTreeMap, ) -> Result<(), ApiError> { let mut replicaset: ReplicaSet = serde_yaml::from_str(yaml).map_err(|err| { ApiError::InvalidRequest(format!( @@ -705,17 +643,13 @@ fn rename_replicaset_yaml( )) })?; - update_object_meta(&mut replicaset.metadata, old_name, new_name); - ensure_label(&mut replicaset.metadata.labels, "app", new_name); - ensure_label(&mut replicaset.metadata.labels, "lapdev.workload", new_name); + update_object_meta(&mut replicaset.metadata, new_name, selector_labels); if let Some(spec) = replicaset.spec.as_mut() { - update_selector_labels(&mut spec.selector, old_name, new_name); + update_selector_labels(&mut spec.selector, selector_labels); if let Some(template) = spec.template.as_mut() { if let Some(metadata) = template.metadata.as_mut() { - update_object_meta(metadata, old_name, new_name); - ensure_label(&mut metadata.labels, "app", new_name); - ensure_label(&mut metadata.labels, "lapdev.workload", new_name); + update_object_meta(metadata, new_name, selector_labels); } } } @@ -729,14 +663,16 @@ fn rename_replicaset_yaml( Ok(()) } -fn rename_pod_yaml(yaml: &mut String, old_name: &str, new_name: &str) -> Result<(), ApiError> { +fn rename_pod_yaml( + yaml: &mut String, + new_name: &str, + selector_labels: &BTreeMap, +) -> Result<(), ApiError> { let mut pod: Pod = serde_yaml::from_str(yaml).map_err(|err| { ApiError::InvalidRequest(format!("Failed to parse pod YAML for rename: {}", err)) })?; - update_object_meta(&mut pod.metadata, old_name, new_name); - ensure_label(&mut pod.metadata.labels, "app", new_name); - ensure_label(&mut pod.metadata.labels, "lapdev.workload", new_name); + update_object_meta(&mut pod.metadata, new_name, selector_labels); *yaml = serde_yaml::to_string(&pod).map_err(|err| { ApiError::InvalidRequest(format!("Failed to serialize renamed pod YAML: {}", err)) @@ -744,21 +680,21 @@ fn rename_pod_yaml(yaml: &mut String, old_name: &str, new_name: &str) -> Result< Ok(()) } -fn rename_job_yaml(yaml: &mut String, old_name: &str, new_name: &str) -> Result<(), ApiError> { +fn rename_job_yaml( + yaml: &mut String, + new_name: &str, + selector_labels: &BTreeMap, +) -> Result<(), ApiError> { let mut job: Job = serde_yaml::from_str(yaml).map_err(|err| { ApiError::InvalidRequest(format!("Failed to parse job YAML for rename: {}", err)) })?; - update_object_meta(&mut job.metadata, old_name, new_name); - ensure_label(&mut job.metadata.labels, "app", new_name); - ensure_label(&mut job.metadata.labels, "lapdev.workload", new_name); + update_object_meta(&mut job.metadata, new_name, selector_labels); if let Some(spec) = job.spec.as_mut() { - update_optional_selector_labels(&mut spec.selector, old_name, new_name); + update_optional_selector_labels(&mut spec.selector, selector_labels); if let Some(template) = spec.template.metadata.as_mut() { - update_object_meta(template, old_name, new_name); - ensure_label(&mut template.labels, "app", new_name); - ensure_label(&mut template.labels, "lapdev.workload", new_name); + update_object_meta(template, new_name, selector_labels); } } @@ -768,23 +704,23 @@ fn rename_job_yaml(yaml: &mut String, old_name: &str, new_name: &str) -> Result< Ok(()) } -fn rename_cronjob_yaml(yaml: &mut String, old_name: &str, new_name: &str) -> Result<(), ApiError> { +fn rename_cronjob_yaml( + yaml: &mut String, + new_name: &str, + selector_labels: &BTreeMap, +) -> Result<(), ApiError> { let mut cronjob: CronJob = serde_yaml::from_str(yaml).map_err(|err| { ApiError::InvalidRequest(format!("Failed to parse cronjob YAML for rename: {}", err)) })?; - update_object_meta(&mut cronjob.metadata, old_name, new_name); - ensure_label(&mut cronjob.metadata.labels, "app", new_name); - ensure_label(&mut cronjob.metadata.labels, "lapdev.workload", new_name); + update_object_meta(&mut cronjob.metadata, new_name, selector_labels); if let Some(spec) = cronjob.spec.as_mut() { let job_template = &mut spec.job_template; if let Some(job_spec) = job_template.spec.as_mut() { - update_optional_selector_labels(&mut job_spec.selector, old_name, new_name); + update_optional_selector_labels(&mut job_spec.selector, selector_labels); if let Some(template) = job_spec.template.metadata.as_mut() { - update_object_meta(template, old_name, new_name); - ensure_label(&mut template.labels, "app", new_name); - ensure_label(&mut template.labels, "lapdev.workload", new_name); + update_object_meta(template, new_name, selector_labels); } } } @@ -795,54 +731,43 @@ fn rename_cronjob_yaml(yaml: &mut String, old_name: &str, new_name: &str) -> Res Ok(()) } -fn update_object_meta(metadata: &mut ObjectMeta, old_value: &str, new_value: &str) { - metadata.name = Some(new_value.to_string()); - if let Some(labels) = metadata.labels.as_mut() { - for value in labels.values_mut() { - if value == old_value { - *value = new_value.to_string(); - } - } - } +fn update_object_meta( + metadata: &mut ObjectMeta, + new_name: &str, + selector_labels: &BTreeMap, +) { + metadata.name = Some(new_name.to_string()); + ensure_labels_from_map(&mut metadata.labels, selector_labels); } -fn ensure_label(labels: &mut Option>, key: &str, value: &str) { - let map = labels.get_or_insert_with(BTreeMap::new); - map.insert(key.to_string(), value.to_string()); +fn ensure_labels_from_map( + labels: &mut Option>, + desired: &BTreeMap, +) { + *labels = Some(desired.clone()); } -fn update_selector_labels(selector: &mut LabelSelector, old_value: &str, new_value: &str) { - if let Some(match_labels) = selector.match_labels.as_mut() { - for value in match_labels.values_mut() { - if value == old_value { - *value = new_value.to_string(); - } - } - match_labels - .entry("app".to_string()) - .or_insert_with(|| new_value.to_string()); - match_labels - .entry("lapdev.workload".to_string()) - .or_insert_with(|| new_value.to_string()); - } else { - let mut map = BTreeMap::new(); - map.insert("app".to_string(), new_value.to_string()); - map.insert("lapdev.workload".to_string(), new_value.to_string()); - selector.match_labels = Some(map); - } +fn update_selector_labels(selector: &mut LabelSelector, desired: &BTreeMap) { + selector.match_labels = Some(desired.clone()); } fn update_optional_selector_labels( selector: &mut Option, - old_value: &str, - new_value: &str, + desired: &BTreeMap, ) { match selector { - Some(existing) => update_selector_labels(existing, old_value, new_value), + Some(existing) => update_selector_labels(existing, desired), None => { let mut new_selector = LabelSelector::default(); - update_selector_labels(&mut new_selector, old_value, new_value); + update_selector_labels(&mut new_selector, desired); *selector = Some(new_selector); } } } + +fn to_btreemap(labels: &HashMap) -> BTreeMap { + labels + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 96d4b12..53d866d 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -3,6 +3,7 @@ pub mod admin; pub mod auth; pub mod cert; pub mod cli_auth; +pub mod cluster_events; pub mod devbox; pub mod devbox_auth; pub mod environment_events; diff --git a/crates/api/src/router.rs b/crates/api/src/router.rs index 24335b7..4b38148 100644 --- a/crates/api/src/router.rs +++ b/crates/api/src/router.rs @@ -17,7 +17,7 @@ use lapdev_proxy_http::{forward::ProxyForward, proxy::WorkspaceForwardError}; use lapdev_rpc::error::ApiError; use crate::{ - account, admin, cli_auth, + account, admin, cli_auth, cluster_events, devbox::{ devbox_client_tunnel_websocket, devbox_intercept_tunnel_websocket, devbox_rpc_websocket, devbox_whoami, @@ -145,6 +145,10 @@ fn v1_api_routes() -> Router> { "/organizations/{org_id}/kube/environments/{environment_id}/events", get(environment_events::stream_environment_events), ) + .route( + "/organizations/{org_id}/kube/clusters/{cluster_id}/events", + get(cluster_events::stream_cluster_status_events), + ) .route( "/organizations/{org_id}/projects", post(project::create_project), diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 74671bd..e5a485f 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -9,7 +9,7 @@ use axum_extra::{ }; use chrono::{DateTime, Utc}; use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor}; -use lapdev_common::{UserRole, LAPDEV_BASE_HOSTNAME}; +use lapdev_common::{kube::ClusterStatusEvent, UserRole, LAPDEV_BASE_HOSTNAME}; use lapdev_conductor::{scheduler::LAPDEV_CPU_OVERCOMMIT, Conductor}; use lapdev_db::api::DbApi; use lapdev_devbox_rpc::PortMapping; @@ -101,6 +101,7 @@ pub struct CoreState { pub active_devbox_sessions: Arc>>, // Lifecycle notifications for kube environments pub environment_events: broadcast::Sender, + pub cluster_events: broadcast::Sender, } /// Handle for an active devbox session @@ -135,6 +136,7 @@ impl CoreState { let db = conductor.db.clone(); let (environment_events, _) = broadcast::channel(128); + let (cluster_events, _) = broadcast::channel(128); let state = Self { db: db.clone(), conductor, @@ -151,6 +153,7 @@ impl CoreState { pending_cli_auth: Arc::new(RwLock::new(HashMap::new())), active_devbox_sessions: Arc::new(RwLock::new(HashMap::new())), environment_events, + cluster_events, }; { @@ -178,6 +181,15 @@ impl CoreState { }); } + { + let state = state.clone(); + tokio::spawn(async move { + if let Err(e) = state.monitor_cluster_events().await { + tracing::error!("api monitor cluster events error: {e:#}"); + } + }); + } + state } @@ -267,6 +279,31 @@ impl CoreState { } } + async fn monitor_cluster_events(&self) -> Result<()> { + let pool = self + .db + .pool + .clone() + .ok_or_else(|| anyhow!("db doesn't have pg pool"))?; + let mut listener = sqlx::postgres::PgListener::connect_with(&pool).await?; + listener.listen("cluster_status").await?; + loop { + let notification = listener.recv().await?; + match serde_json::from_str::(notification.payload()) { + Ok(event) => { + let _ = self.cluster_events.send(event); + } + Err(err) => { + tracing::error!( + payload = notification.payload(), + error = ?err, + "failed to deserialize cluster status notification" + ); + } + } + } + } + async fn websocket_base_url(&self) -> String { let from_env = std::env::var("LAPDEV_API_URL").ok(); let host = if let Some(url) = from_env.filter(|s| !s.trim().is_empty()) { diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index 6a5df70..79f4732 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -1,4 +1,6 @@ -use chrono::{DateTime, FixedOffset}; +use std::collections::BTreeMap; + +use chrono::{DateTime, FixedOffset, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -37,6 +39,16 @@ pub struct KubeClusterInfo { pub status: KubeClusterStatus, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClusterStatusEvent { + pub organization_id: Uuid, + pub cluster_id: Uuid, + pub status: KubeClusterStatus, + pub cluster_version: Option, + pub region: Option, + pub updated_at: DateTime, +} + #[derive(Debug, Clone, Serialize, Deserialize, strum_macros::EnumString, strum_macros::Display)] pub enum KubeClusterStatus { Ready, @@ -356,7 +368,7 @@ pub struct KubeServicePort { pub struct KubeServiceDetails { pub name: String, pub ports: Vec, - pub selector: std::collections::HashMap, + pub selector: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -374,7 +386,7 @@ pub struct KubeEnvironmentService { pub namespace: String, pub yaml: String, pub ports: Vec, - pub selector: std::collections::HashMap, + pub selector: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/dashboard/src/kube_environment_detail.rs b/crates/dashboard/src/kube_environment_detail.rs index e13b68d..c7e9deb 100644 --- a/crates/dashboard/src/kube_environment_detail.rs +++ b/crates/dashboard/src/kube_environment_detail.rs @@ -468,7 +468,7 @@ pub fn EnvironmentDetailView(environment_id: Uuid) -> impl IntoView { workloads .into_iter() .filter(|workload| { - workload.catalog_sync_version > env_version + workload.catalog_sync_version >= env_version && (search_term.trim().is_empty() || workload.name.to_lowercase().contains(&search_term)) }) diff --git a/crates/dashboard/src/kube_resource.rs b/crates/dashboard/src/kube_resource.rs index faf65ae..e76c8e3 100644 --- a/crates/dashboard/src/kube_resource.rs +++ b/crates/dashboard/src/kube_resource.rs @@ -1,6 +1,8 @@ use std::{collections::HashSet, str::FromStr}; use anyhow::{anyhow, Result}; +use futures::StreamExt; +use gloo_net::eventsource::futures::EventSource; use lapdev_common::{ console::Organization, kube::{ @@ -9,7 +11,7 @@ use lapdev_common::{ PaginationCursor, PaginationParams, }, }; -use leptos::prelude::*; +use leptos::{prelude::*, task::spawn_local_scoped_with_cancellation}; use leptos_router::hooks::{use_location, use_params_map}; use uuid::Uuid; @@ -53,7 +55,7 @@ pub fn KubeResource() -> impl IntoView { - + @@ -104,16 +106,21 @@ async fn get_cluster_info_from_api( } #[component] -pub fn ClusterInfo(cluster_id: Uuid) -> impl IntoView { +pub fn ClusterInfo( + cluster_id: Uuid, + update_counter: RwSignal, +) -> impl IntoView { let org = get_current_org(); let config = use_context::().unwrap(); - let cluster_info = + let cluster_info_resource = LocalResource::new( move || async move { get_cluster_info_from_api(org, cluster_id).await.ok() }, ); + + let cluster_info_signal = cluster_info_resource.clone(); let cluster_info = Signal::derive(move || { - let cluster_info = cluster_info.get(); + let cluster_info = cluster_info_signal.get(); if let Some(Some(info)) = cluster_info.as_ref() { config .current_page @@ -131,6 +138,59 @@ pub fn ClusterInfo(cluster_id: Uuid) -> impl IntoView { }) }); + let sse_started = RwSignal::new_local(false); + let cluster_info_for_sse = cluster_info_resource.clone(); + let org_for_sse = org; + Effect::new(move |_| { + if sse_started.get_value() { + return; + } + + if let Some(org) = org_for_sse.get() { + sse_started.set_value(true); + let org_id = org.id; + spawn_local_scoped_with_cancellation({ + let cluster_info_for_sse = cluster_info_for_sse.clone(); + async move { + let url = format!( + "/api/v1/organizations/{}/kube/clusters/{}/events", + org_id, cluster_id + ); + + match EventSource::new(&url) { + Ok(mut event_source) => { + match event_source.subscribe("cluster") { + Ok(mut stream) => { + while let Some(event) = stream.next().await { + if event.is_ok() { + cluster_info_for_sse.refetch(); + update_counter.update(|c| *c += 1); + } + } + } + Err(err) => { + web_sys::console::error_1( + &format!( + "Failed to subscribe to cluster status events: {err}" + ) + .into(), + ); + } + } + event_source.close(); + } + Err(err) => { + web_sys::console::error_1( + &format!("Failed to connect to cluster status events: {err}") + .into(), + ); + } + } + } + }); + } + }); + view! {

Cluster Information

diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index f586531..9a2c15b 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -24,3 +24,4 @@ lapdev-db-entities.workspace = true lapdev-common.workspace = true secrecy.workspace = true serde_yaml.workspace = true +tracing.workspace = true diff --git a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs index 06264e5..035bee0 100644 --- a/crates/db/migration/src/m20250809_000001_create_kube_environment.rs +++ b/crates/db/migration/src/m20250809_000001_create_kube_environment.rs @@ -165,14 +165,6 @@ impl MigrationTrait for Migration { ) .await?; - manager - .get_connection() - .execute_unprepared( - "CREATE UNIQUE INDEX kube_environment_cluster_namespace_unique_idx - ON kube_environment (cluster_id, namespace, deleted_at) NULLS NOT DISTINCT", - ) - .await?; - Ok(()) } } diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index a0ad71b..9195af8 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -4,8 +4,9 @@ use chrono::{DateTime, FixedOffset, Utc}; use lapdev_common::{ config::LAPDEV_CLUSTER_NOT_INITIATED, kube::{ - KubeAppCatalogWorkload, KubeContainerInfo, KubeEnvironmentWorkload, KubeServicePort, - KubeWorkloadDetails, KubeWorkloadKind, PagePaginationParams, + ClusterStatusEvent, KubeAppCatalogWorkload, KubeClusterStatus, KubeContainerInfo, + KubeEnvironmentWorkload, KubeServicePort, KubeWorkloadDetails, KubeWorkloadKind, + PagePaginationParams, }, AuthProvider, ProviderUser, UserRole, WorkspaceStatus, LAPDEV_BASE_HOSTNAME, LAPDEV_ISOLATE_CONTAINER, @@ -34,6 +35,8 @@ use serde_yaml::Value; use sqlx::PgPool; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::convert::TryFrom; +use std::str::FromStr; +use tracing::warn; use uuid::Uuid; // Custom result structure for multi-table join @@ -619,9 +622,57 @@ impl DbApi { ..Default::default() }; let updated = model.update(&self.conn).await?; + self.publish_cluster_status_event(&updated).await; Ok(updated) } + async fn publish_cluster_status_event( + &self, + cluster: &lapdev_db_entities::kube_cluster::Model, + ) { + let Some(pool) = self.pool.as_ref() else { + return; + }; + + let status = + KubeClusterStatus::from_str(&cluster.status).unwrap_or(KubeClusterStatus::NotReady); + let updated_at = cluster + .last_reported_at + .map(|ts| ts.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); + + let event = ClusterStatusEvent { + organization_id: cluster.organization_id, + cluster_id: cluster.id, + status, + cluster_version: cluster.cluster_version.clone(), + region: cluster.region.clone(), + updated_at, + }; + + match serde_json::to_string(&event) { + Ok(payload) => { + if let Err(err) = sqlx::query("SELECT pg_notify($1, $2)") + .bind("cluster_status") + .bind(payload) + .execute(pool) + .await + { + warn!( + error = %err, + "failed to publish cluster status event via pg_notify" + ); + } + } + Err(err) => { + warn!( + error = %err, + "failed to serialize cluster status event" + ); + } + } + } + pub async fn get_user_all_oauth( &self, user_id: Uuid, @@ -2627,6 +2678,7 @@ impl DbApi { /// This ensures atomicity - either both operations succeed or both are rolled back. pub async fn create_kube_environment( &self, + environment_id: Uuid, org_id: Uuid, user_id: Uuid, app_catalog_id: Uuid, @@ -2642,7 +2694,6 @@ impl DbApi { ) -> Result { let txn = self.conn.begin().await?; - let environment_id = Uuid::new_v4(); let created_at = Utc::now().into(); // Generate auth token for the environment @@ -2781,11 +2832,11 @@ impl DbApi { vec![] }; - let selector: std::collections::HashMap = + let selector: std::collections::BTreeMap = if let Ok(selector) = serde_json::from_value(service.selector.clone()) { selector } else { - std::collections::HashMap::new() + std::collections::BTreeMap::new() }; result.push(lapdev_common::kube::KubeEnvironmentService { @@ -2820,12 +2871,8 @@ impl DbApi { vec![] }; - let selector: std::collections::HashMap = - if let Ok(selector) = serde_json::from_value(service.selector.clone()) { - selector - } else { - std::collections::HashMap::new() - }; + let selector: std::collections::BTreeMap = + serde_json::from_value(service.selector.clone()).unwrap_or_default(); Ok(Some(lapdev_common::kube::KubeEnvironmentService { id: service.id, diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index 25c57aa..782094e 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -1143,8 +1143,6 @@ impl KubeManager { pub(crate) async fn apply_workloads_with_resources( &self, - environment_id: Option, - environment_auth_token: String, namespace: String, workloads_with_resources: KubeWorkloadsWithResources, labels: std::collections::HashMap, @@ -1172,15 +1170,8 @@ impl KubeManager { // Step 3: Apply all workloads for workload in &workloads_with_resources.workloads { tracing::info!("Applying workload to namespace '{}'", namespace); - self.apply_workload_only( - client, - environment_id, - environment_auth_token.clone(), - &namespace, - workload, - &labels, - ) - .await?; + self.apply_workload_only(client, &namespace, workload, &labels) + .await?; } // Step 4: Apply services last as they reference workloads @@ -1332,22 +1323,13 @@ impl KubeManager { async fn apply_workload_only( &self, client: &kube::Client, - environment_id: Option, - environment_auth_token: String, namespace: &str, workload: &KubeWorkloadYamlOnly, labels: &std::collections::HashMap, ) -> Result<()> { match workload { KubeWorkloadYamlOnly::Deployment(yaml) => { - self.apply_deployment( - environment_id, - environment_auth_token, - namespace, - yaml, - labels, - ) - .await?; + self.apply_deployment(namespace, yaml, labels).await?; } KubeWorkloadYamlOnly::StatefulSet(yaml) => { self.apply_statefulset(client, namespace, yaml, labels) @@ -1454,94 +1436,8 @@ impl KubeManager { Ok(()) } - fn inject_sidecar_proxy_into_deployment( - &self, - environment_id: Uuid, - environment_auth_token: String, - namespace: &str, - deployment: &mut k8s_openapi::api::apps::v1::Deployment, - ) -> Result<()> { - use k8s_openapi::api::core::v1::{Container, ContainerPort, EnvVar}; - - if let Some(ref mut spec) = deployment.spec { - if let Some(ref mut template) = spec.template.spec { - // Create environment variables - let env_vars = vec![ - EnvVar { - name: "LAPDEV_ENVIRONMENT_ID".to_string(), - value: Some(environment_id.to_string()), - ..Default::default() - }, - EnvVar { - name: "LAPDEV_ENVIRONMENT_AUTH_TOKEN".to_string(), - value: Some(environment_auth_token.to_string()), - ..Default::default() - }, - EnvVar { - name: "KUBERNETES_NAMESPACE".to_string(), - value: Some(namespace.to_string()), - ..Default::default() - }, - EnvVar { - name: "HOSTNAME".to_string(), - value_from: Some(k8s_openapi::api::core::v1::EnvVarSource { - field_ref: Some(k8s_openapi::api::core::v1::ObjectFieldSelector { - field_path: "metadata.name".to_string(), - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }, - ]; - - // Create the sidecar proxy container - let sidecar_container = Container { - name: "lapdev-sidecar-proxy".to_string(), - image: Some("lapdev/kube-sidecar-proxy:latest".to_string()), - ports: Some(vec![ - ContainerPort { - container_port: 8080, - name: Some("proxy".to_string()), - protocol: Some("TCP".to_string()), - ..Default::default() - }, - ContainerPort { - container_port: 9090, - name: Some("metrics".to_string()), - protocol: Some("TCP".to_string()), - ..Default::default() - }, - ]), - env: Some(env_vars), - args: Some(vec![ - "--listen-addr".to_string(), - "0.0.0.0:8080".to_string(), - "--target-addr".to_string(), - "127.0.0.1:3000".to_string(), // Assume main app is on port 3000 - ]), - ..Default::default() - }; - - // Add the sidecar container to the pod spec - template.containers.push(sidecar_container); - - tracing::info!( - "Injected sidecar proxy into deployment '{}' in namespace '{}' for environment '{}'", - deployment.metadata.name.as_ref().unwrap_or(&"unknown".to_string()), - namespace, - environment_id - ); - } - } - - Ok(()) - } - async fn apply_deployment( &self, - environment_id: Option, - environment_auth_token: String, namespace: &str, yaml_manifest: &str, labels: &std::collections::HashMap, @@ -1554,16 +1450,6 @@ impl KubeManager { // Force namespace deployment.metadata.namespace = Some(namespace.to_string()); - // Inject sidecar proxy if this is a base environment (not a branch) - if let Some(environment_id) = environment_id { - self.inject_sidecar_proxy_into_deployment( - environment_id, - environment_auth_token, - namespace, - &mut deployment, - )?; - } - let api: kube::Api = kube::Api::namespaced((*self.kube_client).clone(), namespace); @@ -1927,11 +1813,7 @@ impl KubeManager { branch_environment_id: Uuid, ) -> Result<(), String> { self.proxy_manager - .remove_branch_service_route( - base_environment_id, - workload_id, - branch_environment_id, - ) + .remove_branch_service_route(base_environment_id, workload_id, branch_environment_id) .await } diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index b21e0a2..3f34071 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -121,21 +121,13 @@ impl KubeManagerRpc for KubeManagerRpcServer { async fn deploy_workload_yaml( self, _context: ::tarpc::context::Context, - environment_id: uuid::Uuid, - environment_auth_token: String, namespace: String, workloads_with_resources: KubeWorkloadsWithResources, labels: std::collections::HashMap, ) -> Result<(), String> { match self .manager - .apply_workloads_with_resources( - Some(environment_id), - environment_auth_token, - namespace.clone(), - workloads_with_resources, - labels, - ) + .apply_workloads_with_resources(namespace.clone(), workloads_with_resources, labels) .await { Ok(()) => { @@ -227,11 +219,7 @@ impl KubeManagerRpc for KubeManagerRpcServer { branch_environment_id: Uuid, ) -> Result<(), String> { self.manager - .remove_branch_service_route( - base_environment_id, - workload_id, - branch_environment_id, - ) + .remove_branch_service_route(base_environment_id, workload_id, branch_environment_id) .await } @@ -357,17 +345,15 @@ impl KubeManagerRpc for KubeManagerRpcServer { ); // Get the devbox-proxy RPC client for the base environment - let proxy_client = self + let Some(proxy_client) = self .manager .devbox_proxy_manager .get_proxy_client(base_environment_id) .await - .ok_or_else(|| { - format!( - "No devbox-proxy registered for base environment {}", - base_environment_id - ) - })?; + else { + // if the devbox proxy doesn't connect, we don't need to do anything + return Ok(()); + }; // Forward the request to the devbox-proxy proxy_client diff --git a/crates/kube-rpc/src/lib.rs b/crates/kube-rpc/src/lib.rs index 68ad5f4..398c264 100644 --- a/crates/kube-rpc/src/lib.rs +++ b/crates/kube-rpc/src/lib.rs @@ -411,8 +411,6 @@ pub trait KubeManagerRpc { async fn get_namespaces() -> Result, String>; async fn deploy_workload_yaml( - environment_id: uuid::Uuid, - environment_auth_token: String, namespace: String, workloads_with_resources: KubeWorkloadsWithResources, labels: std::collections::HashMap, @@ -515,7 +513,7 @@ pub struct DevboxRouteConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProxyBranchRouteConfig { pub branch_environment_id: Uuid, - pub service_name: Option, + pub service_names: HashMap, pub headers: HashMap, pub requires_auth: bool, pub access_level: ProxyRouteAccessLevel, diff --git a/crates/kube-sidecar-proxy/src/config.rs b/crates/kube-sidecar-proxy/src/config.rs index 4542a6f..fc2d8c2 100644 --- a/crates/kube-sidecar-proxy/src/config.rs +++ b/crates/kube-sidecar-proxy/src/config.rs @@ -246,7 +246,7 @@ pub struct BranchRoute { #[derive(Debug, Clone)] pub struct BranchServiceRoute { - pub service_name: String, + pub service_names: HashMap, pub headers: HashMap, pub requires_auth: bool, pub access_level: AccessLevel, @@ -254,15 +254,21 @@ pub struct BranchServiceRoute { } impl BranchServiceRoute { - pub fn new_service(service_name: String) -> Self { + pub fn new_service(port: u16, service_name: String) -> Self { + let mut service_names = HashMap::new(); + service_names.insert(port, service_name); Self { - service_name, + service_names, headers: HashMap::new(), requires_auth: true, access_level: AccessLevel::Personal, timeout_ms: None, } } + + pub fn service_name_for_port(&self, port: u16) -> Option<&str> { + self.service_names.get(&port).map(String::as_str) + } } #[derive(Clone)] diff --git a/crates/kube-sidecar-proxy/src/error.rs b/crates/kube-sidecar-proxy/src/error.rs index d8d8c43..7ca57e5 100644 --- a/crates/kube-sidecar-proxy/src/error.rs +++ b/crates/kube-sidecar-proxy/src/error.rs @@ -20,8 +20,8 @@ pub enum SidecarProxyError { #[error("Service discovery error: {0}")] ServiceDiscovery(String), - #[error("Target service not found: {service_name}")] - TargetNotFound { service_name: String }, + #[error("Target service not found for port {port}")] + TargetNotFound { port: u16 }, #[error("Authorization error: {0}")] Authorization(String), diff --git a/crates/kube-sidecar-proxy/src/rpc.rs b/crates/kube-sidecar-proxy/src/rpc.rs index d8c568a..1564516 100644 --- a/crates/kube-sidecar-proxy/src/rpc.rs +++ b/crates/kube-sidecar-proxy/src/rpc.rs @@ -74,9 +74,9 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { for route in routes { let branch_id = route.branch_environment_id; - if let Some(service_name) = route.service_name.clone() { + if !route.service_names.is_empty() { let service_route = BranchServiceRoute { - service_name, + service_names: route.service_names.clone(), headers: route.headers.clone(), requires_auth: route.requires_auth, access_level: access_level_from_proxy(route.access_level), @@ -190,22 +190,20 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { _context: ::tarpc::context::Context, route: ProxyBranchRouteConfig, ) -> Result<(), String> { - let Some(service_name) = route.service_name.clone() else { - return Err("Missing service_name for branch service route update".to_string()); - }; - let branch_id = route.branch_environment_id; + if route.service_names.is_empty() { + return Err("Missing service_names for branch service route update".to_string()); + } + let service_route = BranchServiceRoute { - service_name, + service_names: route.service_names.clone(), headers: route.headers.clone(), requires_auth: route.requires_auth, access_level: access_level_from_proxy(route.access_level), timeout_ms: route.timeout_ms, }; - let devbox_override = route - .devbox_route - .map(devbox_connection_from_config); + let devbox_override = route.devbox_route.map(devbox_connection_from_config); let mut routing_table = self.routing_table.write().await; routing_table.upsert_branch_service_route(branch_id, service_route); diff --git a/crates/kube-sidecar-proxy/src/server.rs b/crates/kube-sidecar-proxy/src/server.rs index 2c6c74f..a0d700a 100644 --- a/crates/kube-sidecar-proxy/src/server.rs +++ b/crates/kube-sidecar-proxy/src/server.rs @@ -378,30 +378,29 @@ async fn handle_http_proxy( match decision { RouteDecision::BranchService { service } => { - let service_name = &service.service_name; - info!( - "HTTP {} {} routing to branch {:?} service {}:{}", - http_request.method, - http_request.path, - branch_id, - service_name, - original_dest.port() - ); + let port = original_dest.port(); + if let Some(service_name) = service.service_name_for_port(port) { + info!( + "HTTP {} {} routing to branch {:?} service {}:{}", + http_request.method, http_request.path, branch_id, service_name, port + ); - if let Err(err) = proxy_branch_stream( - &mut inbound_stream, - service_name.as_str(), - original_dest.port(), - &initial_data, - ) - .await - { + if let Err(err) = + proxy_branch_stream(&mut inbound_stream, service_name, port, &initial_data) + .await + { + warn!( + "Branch route {} for env {:?} failed: {}; falling back to shared target", + service_name, branch_id, err + ); + } else { + return Ok(()); + } + } else { warn!( - "Branch route {} for env {:?} failed: {}; falling back to shared target", - service_name, branch_id, err + "Missing branch service mapping for port {} in env {:?}; falling back to shared target", + port, branch_id ); - } else { - return Ok(()); } } RouteDecision::BranchDevbox { diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index f36be00..1ae92ad 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -17,10 +17,12 @@ use lapdev_kube_rpc::{ DevboxRouteConfig, KubeClusterRpc, KubeManagerRpcClient, ProxyBranchRouteConfig, ProxyRouteAccessLevel, ResourceChangeEvent, ResourceChangeType, ResourceType, }; +use lapdev_rpc::error::ApiError; use sea_orm::prelude::{DateTimeWithTimeZone, Json}; use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; use serde_json::json; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::convert::TryFrom; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; @@ -177,6 +179,83 @@ impl KubeClusterServer { )), } } + + pub async fn build_branch_service_route_config( + &self, + base_environment: &lapdev_db_entities::kube_environment::Model, + base_workload_id: Uuid, + branch_environment_id: Uuid, + ) -> Result, ApiError> { + let workload_labels = self + .db + .get_environment_workload_labels(base_workload_id) + .await + .map_err(ApiError::from)?; + + let shared_services = self + .db + .get_matching_cluster_services( + base_environment.cluster_id, + &base_environment.namespace, + &workload_labels, + ) + .await + .map_err(ApiError::from)?; + + if shared_services.is_empty() { + return Ok(None); + } + + Ok(Self::build_branch_service_route_config_from_services( + branch_environment_id, + &shared_services, + )) + } + + fn build_branch_service_route_config_from_services( + branch_environment_id: Uuid, + shared_services: &[CachedClusterService], + ) -> Option { + let mut service_names = HashMap::new(); + let branch_suffix = format!("-{}", branch_environment_id); + + for service in shared_services { + let branch_service_name = format!("{}{branch_suffix}", service.name); + for port in &service.ports { + match u16::try_from(port.port) { + Ok(port_number) => { + service_names.insert(port_number, branch_service_name.clone()); + } + Err(_) => { + tracing::warn!( + "Skipping branch service mapping for {} in env {} due to unsupported port {}", + branch_service_name, + branch_environment_id, + port.port + ); + } + } + } + } + + if service_names.is_empty() { + tracing::warn!( + "Found shared services for branch env {} but no valid ports; skipping branch route", + branch_environment_id + ); + return None; + } + + Some(ProxyBranchRouteConfig { + branch_environment_id, + service_names, + headers: HashMap::new(), + requires_auth: true, + access_level: ProxyRouteAccessLevel::Personal, + timeout_ms: None, + devbox_route: None, + }) + } } impl KubeClusterRpc for KubeClusterServer { @@ -335,6 +414,10 @@ impl KubeClusterRpc for KubeClusterServer { ) })?; + if shared_services.is_empty() { + return Ok(Vec::new()); + } + let branch_workloads = self .db .get_workloads_by_base_workload_id(workload_id) @@ -368,19 +451,11 @@ impl KubeClusterRpc for KubeClusterServer { continue; } - let branch_suffix = format!("-{}", branch_env_id); - - for service in &shared_services { - let branch_service_name = format!("{}{branch_suffix}", service.name); - routes.push(ProxyBranchRouteConfig { - branch_environment_id: branch_env_id, - service_name: Some(branch_service_name), - headers: HashMap::new(), - requires_auth: true, - access_level: ProxyRouteAccessLevel::Personal, - timeout_ms: None, - devbox_route: None, - }); + if let Some(route) = Self::build_branch_service_route_config_from_services( + branch_env_id, + &shared_services, + ) { + routes.push(route); } } From 0af292f0aaf9501e58a65d569967d439b403f349 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Mon, 27 Oct 2025 20:50:23 +0000 Subject: [PATCH 172/334] update --- crates/api/src/app_catalog_events.rs | 88 +++++++++++++++++++ crates/api/src/kube_controller/preview_url.rs | 8 +- crates/api/src/lib.rs | 1 + crates/api/src/router.rs | 6 +- crates/api/src/state.rs | 42 ++++++++- crates/common/src/kube.rs | 23 +++-- .../dashboard/src/kube_app_catalog_detail.rs | 58 +++++++++++- .../src/kube_environment_preview_url.rs | 15 ++-- crates/dashboard/src/kube_resource.rs | 4 +- crates/db/src/api.rs | 54 +++++++++++- crates/kube/src/preview_url.rs | 20 +++-- 11 files changed, 283 insertions(+), 36 deletions(-) create mode 100644 crates/api/src/app_catalog_events.rs diff --git a/crates/api/src/app_catalog_events.rs b/crates/api/src/app_catalog_events.rs new file mode 100644 index 0000000..80b9dc1 --- /dev/null +++ b/crates/api/src/app_catalog_events.rs @@ -0,0 +1,88 @@ +use std::{convert::Infallible, sync::Arc, time::Duration}; + +use axum::{ + extract::{Path, State}, + response::sse::{Event, KeepAlive, Sse}, +}; +use axum_extra::{headers, TypedHeader}; +use chrono::Utc; +use futures::{stream, Stream, StreamExt}; +use lapdev_common::kube::AppCatalogStatusEvent; +use lapdev_rpc::error::ApiError; +use tokio_stream::wrappers::BroadcastStream; +use tracing::warn; +use uuid::Uuid; + +use crate::state::CoreState; + +pub async fn stream_app_catalog_events( + Path((org_id, catalog_id)): Path<(Uuid, Uuid)>, + State(state): State>, + TypedHeader(cookies): TypedHeader, +) -> Result>>, ApiError> { + let user = state.authenticate(&cookies).await?; + state + .db + .get_organization_member(user.id, org_id) + .await + .map_err(|_| ApiError::Unauthorized)?; + + let catalog = state + .db + .get_app_catalog(catalog_id) + .await? + .ok_or_else(|| ApiError::InvalidRequest("App catalog not found".to_string()))?; + + if catalog.organization_id != org_id { + return Err(ApiError::Unauthorized); + } + + let initial_event = AppCatalogStatusEvent { + organization_id: catalog.organization_id, + catalog_id, + cluster_id: catalog.cluster_id, + sync_version: catalog.sync_version, + last_synced_at: catalog.last_synced_at.map(|dt| dt.to_string()), + last_sync_actor_id: catalog.last_sync_actor_id, + updated_at: catalog + .last_synced_at + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now), + }; + + let receiver = state.app_catalog_events.subscribe(); + let initial_stream = stream::iter( + build_app_catalog_sse_event(&initial_event) + .into_iter() + .map(Ok::), + ); + + let target_org = org_id; + let target_catalog = catalog_id; + + let event_stream = BroadcastStream::new(receiver).filter_map(move |result| async move { + match result { + Ok(event) + if event.organization_id == target_org && event.catalog_id == target_catalog => + { + build_app_catalog_sse_event(&event).map(Ok) + } + Ok(_) => None, + Err(err) => { + warn!("app catalog event stream lagged: {err}"); + None + } + } + }); + + let stream = initial_stream.chain(event_stream); + let keep_alive = KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("keep-alive"); + + Ok(Sse::new(stream).keep_alive(keep_alive)) +} + +fn build_app_catalog_sse_event(event: &AppCatalogStatusEvent) -> Option { + Event::default().event("catalog").json_data(event).ok() +} diff --git a/crates/api/src/kube_controller/preview_url.rs b/crates/api/src/kube_controller/preview_url.rs index 831eb4d..3e6a8fe 100644 --- a/crates/api/src/kube_controller/preview_url.rs +++ b/crates/api/src/kube_controller/preview_url.rs @@ -64,7 +64,7 @@ impl KubeController { let protocol = request.protocol.unwrap_or_else(|| "HTTP".to_string()); let access_level = request .access_level - .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal); + .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Organization); let url = format!("https://{auto_name}.app.lap.dev"); @@ -114,7 +114,7 @@ impl KubeController { access_level: preview_url .access_level .parse() - .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal), + .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Organization), created_by: preview_url.created_by, last_accessed_at: preview_url.last_accessed_at, url, @@ -168,7 +168,7 @@ impl KubeController { access_level: preview_url .access_level .parse() - .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal), + .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Organization), created_by: preview_url.created_by, last_accessed_at: preview_url.last_accessed_at, url, @@ -235,7 +235,7 @@ impl KubeController { access_level: updated_preview_url .access_level .parse() - .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Personal), + .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Organization), created_by: updated_preview_url.created_by, last_accessed_at: updated_preview_url.last_accessed_at, url, diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 53d866d..fb2f64c 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,5 +1,6 @@ pub mod account; pub mod admin; +pub mod app_catalog_events; pub mod auth; pub mod cert; pub mod cli_auth; diff --git a/crates/api/src/router.rs b/crates/api/src/router.rs index 4b38148..7ab39c5 100644 --- a/crates/api/src/router.rs +++ b/crates/api/src/router.rs @@ -17,7 +17,7 @@ use lapdev_proxy_http::{forward::ProxyForward, proxy::WorkspaceForwardError}; use lapdev_rpc::error::ApiError; use crate::{ - account, admin, cli_auth, cluster_events, + account, admin, app_catalog_events, cli_auth, cluster_events, devbox::{ devbox_client_tunnel_websocket, devbox_intercept_tunnel_websocket, devbox_rpc_websocket, devbox_whoami, @@ -149,6 +149,10 @@ fn v1_api_routes() -> Router> { "/organizations/{org_id}/kube/clusters/{cluster_id}/events", get(cluster_events::stream_cluster_status_events), ) + .route( + "/organizations/{org_id}/kube/catalogs/{catalog_id}/events", + get(app_catalog_events::stream_app_catalog_events), + ) .route( "/organizations/{org_id}/projects", post(project::create_project), diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index e5a485f..be0f1c0 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -9,7 +9,10 @@ use axum_extra::{ }; use chrono::{DateTime, Utc}; use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor}; -use lapdev_common::{kube::ClusterStatusEvent, UserRole, LAPDEV_BASE_HOSTNAME}; +use lapdev_common::{ + kube::{AppCatalogStatusEvent, ClusterStatusEvent}, + UserRole, LAPDEV_BASE_HOSTNAME, +}; use lapdev_conductor::{scheduler::LAPDEV_CPU_OVERCOMMIT, Conductor}; use lapdev_db::api::DbApi; use lapdev_devbox_rpc::PortMapping; @@ -102,6 +105,7 @@ pub struct CoreState { // Lifecycle notifications for kube environments pub environment_events: broadcast::Sender, pub cluster_events: broadcast::Sender, + pub app_catalog_events: broadcast::Sender, } /// Handle for an active devbox session @@ -137,6 +141,7 @@ impl CoreState { let db = conductor.db.clone(); let (environment_events, _) = broadcast::channel(128); let (cluster_events, _) = broadcast::channel(128); + let (app_catalog_events, _) = broadcast::channel(128); let state = Self { db: db.clone(), conductor, @@ -154,6 +159,7 @@ impl CoreState { active_devbox_sessions: Arc::new(RwLock::new(HashMap::new())), environment_events, cluster_events, + app_catalog_events, }; { @@ -190,6 +196,15 @@ impl CoreState { }); } + { + let state = state.clone(); + tokio::spawn(async move { + if let Err(e) = state.monitor_app_catalog_events().await { + tracing::error!("api monitor app catalog events error: {e:#}"); + } + }); + } + state } @@ -304,6 +319,31 @@ impl CoreState { } } + async fn monitor_app_catalog_events(&self) -> Result<()> { + let pool = self + .db + .pool + .clone() + .ok_or_else(|| anyhow!("db doesn't have pg pool"))?; + let mut listener = sqlx::postgres::PgListener::connect_with(&pool).await?; + listener.listen("app_catalog_status").await?; + loop { + let notification = listener.recv().await?; + match serde_json::from_str::(notification.payload()) { + Ok(event) => { + let _ = self.app_catalog_events.send(event); + } + Err(err) => { + tracing::error!( + payload = notification.payload(), + error = ?err, + "failed to deserialize app catalog status notification" + ); + } + } + } + } + async fn websocket_base_url(&self) -> String { let from_env = std::env::var("LAPDEV_API_URL").ok(); let host = if let Some(url) = from_env.filter(|s| !s.trim().is_empty()) { diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index 79f4732..f362c85 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -49,6 +49,17 @@ pub struct ClusterStatusEvent { pub updated_at: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppCatalogStatusEvent { + pub organization_id: Uuid, + pub catalog_id: Uuid, + pub cluster_id: Uuid, + pub sync_version: i64, + pub last_synced_at: Option, + pub last_sync_actor_id: Option, + pub updated_at: DateTime, +} + #[derive(Debug, Clone, Serialize, Deserialize, strum_macros::EnumString, strum_macros::Display)] pub enum KubeClusterStatus { Ready, @@ -137,16 +148,16 @@ pub enum KubeWorkloadStatus { Debug, Clone, Serialize, Deserialize, PartialEq, strum_macros::EnumString, strum_macros::Display, )] pub enum PreviewUrlAccessLevel { - Personal, // Only accessible by the owner with authentication - Shared, // Accessible by organization members with authentication - Public, // Accessible by anyone without authentication + Organization, // Accessible by authenticated organization members + Public, // Accessible by anyone without authentication } impl PreviewUrlAccessLevel { pub fn get_detailed_message(&self) -> &'static str { match self { - PreviewUrlAccessLevel::Personal => "Personal - Only you can access", - PreviewUrlAccessLevel::Shared => "Shared - Organization members can access", + PreviewUrlAccessLevel::Organization => { + "Organization - Members of your Lapdev org after login" + } PreviewUrlAccessLevel::Public => "Public - Anyone can access without authentication", } } @@ -413,7 +424,7 @@ pub struct CreateKubeEnvironmentPreviewUrlRequest { pub port: i32, pub port_name: Option, pub protocol: Option, // Default to "HTTP" - pub access_level: Option, // Default to Personal + pub access_level: Option, // Default to Organization } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/dashboard/src/kube_app_catalog_detail.rs b/crates/dashboard/src/kube_app_catalog_detail.rs index 52c6360..8db5914 100644 --- a/crates/dashboard/src/kube_app_catalog_detail.rs +++ b/crates/dashboard/src/kube_app_catalog_detail.rs @@ -1,6 +1,8 @@ use std::str::FromStr; use anyhow::{anyhow, Result}; +use futures::StreamExt; +use gloo_net::eventsource::futures::EventSource; use lapdev_api_hrpc::HrpcServiceClient; use lapdev_common::{ console::Organization, @@ -8,7 +10,7 @@ use lapdev_common::{ KubeAppCatalog, KubeAppCatalogWorkload, KubeCluster, KubeClusterStatus, KubeContainerInfo, }, }; -use leptos::prelude::*; +use leptos::{prelude::*, task::spawn_local_scoped_with_cancellation}; use leptos_router::hooks::use_params_map; use uuid::Uuid; @@ -172,6 +174,60 @@ pub fn WorkloadsList(catalog_id: Uuid) -> impl IntoView { } }); + let sse_started = RwSignal::new_local(false); + let catalog_result_for_sse = catalog_result.clone(); + let update_counter_for_sse = update_counter.clone(); + let org_for_sse = org; + Effect::new(move |_| { + if sse_started.get_untracked() { + return; + } + + if let Some(org) = org_for_sse.get() { + sse_started.set(true); + let org_id = org.id; + let catalog_result_for_sse = catalog_result_for_sse.clone(); + let update_counter = update_counter_for_sse.clone(); + spawn_local_scoped_with_cancellation({ + async move { + let url = format!( + "/api/v1/organizations/{}/kube/catalogs/{}/events", + org_id, catalog_id + ); + + match EventSource::new(&url) { + Ok(mut event_source) => { + match event_source.subscribe("catalog") { + Ok(mut stream) => { + while let Some(event) = stream.next().await { + if event.is_ok() { + catalog_result_for_sse.refetch(); + update_counter.update(|c| *c += 1); + } + } + } + Err(err) => { + web_sys::console::error_1( + &format!( + "Failed to subscribe to app catalog events: {err}" + ) + .into(), + ); + } + } + event_source.close(); + } + Err(err) => { + web_sys::console::error_1( + &format!("Failed to connect to app catalog events: {err}").into(), + ); + } + } + } + }); + } + }); + let catalog_info = Signal::derive(move || catalog_result.get().flatten()); let all_workloads = Signal::derive(move || workloads_result.get().unwrap_or_default()); diff --git a/crates/dashboard/src/kube_environment_preview_url.rs b/crates/dashboard/src/kube_environment_preview_url.rs index 756c697..4ab75bf 100644 --- a/crates/dashboard/src/kube_environment_preview_url.rs +++ b/crates/dashboard/src/kube_environment_preview_url.rs @@ -203,8 +203,7 @@ fn PreviewUrlRow( { let variant = match preview_url_for_view.access_level { lapdev_common::kube::PreviewUrlAccessLevel::Public => BadgeVariant::Default, - lapdev_common::kube::PreviewUrlAccessLevel::Shared => BadgeVariant::Secondary, - lapdev_common::kube::PreviewUrlAccessLevel::Personal => BadgeVariant::Outline, + lapdev_common::kube::PreviewUrlAccessLevel::Organization => BadgeVariant::Secondary, }; view! { @@ -259,7 +258,7 @@ pub fn CreatePreviewUrlModal( let description = RwSignal::new(String::new()); let selected_port = RwSignal::new(None::); let selected_port_name = RwSignal::new(None::); - let access_level = RwSignal::new(PreviewUrlAccessLevel::Personal); + let access_level = RwSignal::new(PreviewUrlAccessLevel::Organization); let select_open = RwSignal::new(false); // Available ports for the service @@ -281,7 +280,7 @@ pub fn CreatePreviewUrlModal( description.set(String::new()); selected_port.set(None); selected_port_name.set(None); - access_level.set(PreviewUrlAccessLevel::Personal); + access_level.set(PreviewUrlAccessLevel::Organization); } }); @@ -384,8 +383,7 @@ pub fn CreatePreviewUrlModal( {move || access_level.get().get_detailed_message() } - {PreviewUrlAccessLevel::Personal.get_detailed_message()} - {PreviewUrlAccessLevel::Shared.get_detailed_message()} + {PreviewUrlAccessLevel::Organization.get_detailed_message()} {PreviewUrlAccessLevel::Public.get_detailed_message()} @@ -406,7 +404,7 @@ fn EditPreviewUrlModal( // Form state let description = RwSignal::new(String::new()); - let access_level = RwSignal::new(PreviewUrlAccessLevel::Personal); + let access_level = RwSignal::new(PreviewUrlAccessLevel::Organization); let edit_select_open = RwSignal::new(false); // Initialize form when preview URL changes @@ -478,8 +476,7 @@ fn EditPreviewUrlModal( {move || access_level.get().get_detailed_message() } - {PreviewUrlAccessLevel::Personal.get_detailed_message()} - {PreviewUrlAccessLevel::Shared.get_detailed_message()} + {PreviewUrlAccessLevel::Organization.get_detailed_message()} {PreviewUrlAccessLevel::Public.get_detailed_message()} diff --git a/crates/dashboard/src/kube_resource.rs b/crates/dashboard/src/kube_resource.rs index e76c8e3..6199417 100644 --- a/crates/dashboard/src/kube_resource.rs +++ b/crates/dashboard/src/kube_resource.rs @@ -142,12 +142,12 @@ pub fn ClusterInfo( let cluster_info_for_sse = cluster_info_resource.clone(); let org_for_sse = org; Effect::new(move |_| { - if sse_started.get_value() { + if sse_started.get_untracked() { return; } if let Some(org) = org_for_sse.get() { - sse_started.set_value(true); + sse_started.set(true); let org_id = org.id; spawn_local_scoped_with_cancellation({ let cluster_info_for_sse = cluster_info_for_sse.clone(); diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index 9195af8..beb92e7 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -4,9 +4,9 @@ use chrono::{DateTime, FixedOffset, Utc}; use lapdev_common::{ config::LAPDEV_CLUSTER_NOT_INITIATED, kube::{ - ClusterStatusEvent, KubeAppCatalogWorkload, KubeClusterStatus, KubeContainerInfo, - KubeEnvironmentWorkload, KubeServicePort, KubeWorkloadDetails, KubeWorkloadKind, - PagePaginationParams, + AppCatalogStatusEvent, ClusterStatusEvent, KubeAppCatalogWorkload, KubeClusterStatus, + KubeContainerInfo, KubeEnvironmentWorkload, KubeServicePort, KubeWorkloadDetails, + KubeWorkloadKind, PagePaginationParams, }, AuthProvider, ProviderUser, UserRole, WorkspaceStatus, LAPDEV_BASE_HOSTNAME, LAPDEV_ISOLATE_CONTAINER, @@ -673,6 +673,47 @@ impl DbApi { } } + async fn publish_app_catalog_status_event( + &self, + catalog: &lapdev_db_entities::kube_app_catalog::Model, + ) { + let Some(pool) = self.pool.as_ref() else { + return; + }; + + let event = AppCatalogStatusEvent { + organization_id: catalog.organization_id, + catalog_id: catalog.id, + cluster_id: catalog.cluster_id, + sync_version: catalog.sync_version, + last_synced_at: catalog.last_synced_at.map(|ts| ts.to_string()), + last_sync_actor_id: catalog.last_sync_actor_id, + updated_at: Utc::now(), + }; + + match serde_json::to_string(&event) { + Ok(payload) => { + if let Err(err) = sqlx::query("SELECT pg_notify($1, $2)") + .bind("app_catalog_status") + .bind(payload) + .execute(pool) + .await + { + warn!( + error = %err, + "failed to publish app catalog status event via pg_notify" + ); + } + } + Err(err) => { + warn!( + error = %err, + "failed to serialize app catalog status event" + ); + } + } + } + pub async fn get_user_all_oauth( &self, user_id: Uuid, @@ -1252,6 +1293,9 @@ impl DbApi { .await?; txn.commit().await?; + if let Ok(Some(catalog)) = self.get_app_catalog(catalog_id).await { + self.publish_app_catalog_status_event(&catalog).await; + } Ok(catalog_id) } @@ -1299,6 +1343,9 @@ impl DbApi { .await?; txn.commit().await?; + if let Ok(Some(catalog)) = self.get_app_catalog(catalog_id).await { + self.publish_app_catalog_status_event(&catalog).await; + } Ok(catalog_id) } @@ -1472,6 +1519,7 @@ impl DbApi { .await?; if let Some(model) = updated.into_iter().next() { + self.publish_app_catalog_status_event(&model).await; Ok(model.sync_version) } else { Err(anyhow!( diff --git a/crates/kube/src/preview_url.rs b/crates/kube/src/preview_url.rs index f52cfd7..8a8c31d 100644 --- a/crates/kube/src/preview_url.rs +++ b/crates/kube/src/preview_url.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use lapdev_common::kube::PreviewUrlAccessLevel; use lapdev_db::api::DbApi; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -16,7 +17,7 @@ pub struct PreviewUrlTarget { pub namespace: String, pub service_name: String, pub service_port: u16, - pub access_level: String, + pub access_level: PreviewUrlAccessLevel, pub protocol: String, pub environment_id: Uuid, pub preview_url_id: Uuid, @@ -99,26 +100,27 @@ impl PreviewUrlResolver { .ok_or(PreviewUrlError::PreviewUrlNotConfigured)?; // 4. Validate access level and permissions (basic validation) - self.validate_access_level(&preview_url.access_level) - .await?; + let access_level = self.parse_access_level(&preview_url.access_level)?; Ok(PreviewUrlTarget { cluster_id: environment.cluster_id, namespace: environment.namespace, service_name: service.name, service_port: info.port, - access_level: preview_url.access_level, + access_level, protocol: preview_url.protocol, environment_id: environment.id, preview_url_id: preview_url.id, }) } - async fn validate_access_level(&self, access_level: &str) -> Result<(), PreviewUrlError> { - match access_level { - "public" | "shared" | "personal" => Ok(()), - _ => Err(PreviewUrlError::AccessDenied), - } + fn parse_access_level( + &self, + access_level: &str, + ) -> Result { + access_level + .parse::() + .map_err(|_| PreviewUrlError::AccessDenied) } /// Update last accessed timestamp for the preview URL From 423caa119564e58e5b30bc45e157eeede03e6d3b Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Mon, 27 Oct 2025 20:55:09 +0000 Subject: [PATCH 173/334] update --- crates/api/src/session.rs | 3 +-- crates/api/src/state.rs | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/api/src/session.rs b/crates/api/src/session.rs index 25ae643..b8f6cb6 100644 --- a/crates/api/src/session.rs +++ b/crates/api/src/session.rs @@ -22,10 +22,9 @@ use sea_orm::{ use serde::Deserialize; use uuid::Uuid; -use crate::state::{CoreState, RequestInfo, TOKEN_COOKIE_NAME}; +use crate::state::{CoreState, OAUTH_STATE_COOKIE, RequestInfo, TOKEN_COOKIE_NAME}; pub const OAUTH_STATE: &str = "oauth_state"; -pub const OAUTH_STATE_COOKIE: &str = "state"; pub const REDIRECT_URL: &str = "redirect_url"; pub const CONNECT_USER: &str = "connect_user"; pub const READ_REPO: &str = "read_repo"; diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index be0f1c0..693e36a 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -37,13 +37,13 @@ use crate::{ cert::{load_cert, CertStore}, github::GithubClient, kube_controller::KubeController, - session::OAUTH_STATE_COOKIE, tunnel_broker::TunnelBroker, }; use crate::environment_events::EnvironmentLifecycleEvent; -pub const TOKEN_COOKIE_NAME: &str = "token"; +pub const OAUTH_STATE_COOKIE: &str = "lapdev_auth_state"; +pub const TOKEN_COOKIE_NAME: &str = "lapdev_auth_token"; pub const LAPDEV_CERTS: &str = "lapdev-certs"; pub type HyperClient = hyper_util::client::legacy::Client; From 86cba1f942480dbe6859cda8e559c05ca4a25a67 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Mon, 27 Oct 2025 21:54:44 +0000 Subject: [PATCH 174/334] update --- Cargo.lock | 1 + crates/api/pages/generic_error.html | 22 +++ crates/api/src/state.rs | 6 +- crates/common/src/lib.rs | 2 + crates/kube/Cargo.toml | 1 + crates/kube/src/http_proxy.rs | 257 ++++++++++++++++++++-------- crates/kube/src/lib.rs | 2 +- crates/kube/src/preview_url.rs | 2 + 8 files changed, 222 insertions(+), 71 deletions(-) create mode 100644 crates/api/pages/generic_error.html diff --git a/Cargo.lock b/Cargo.lock index be6efb5..af27456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3437,6 +3437,7 @@ dependencies = [ "lapdev-kube-rpc", "lapdev-rpc", "lapdev-tunnel", + "pasetors", "pem", "reqwest", "sea-orm", diff --git a/crates/api/pages/generic_error.html b/crates/api/pages/generic_error.html new file mode 100644 index 0000000..c31ed5a --- /dev/null +++ b/crates/api/pages/generic_error.html @@ -0,0 +1,22 @@ + + + + Lapdev Dashboard + + + +
+
+ + + + + + + Lapdev + +

Something went wrong

+
+
+ + diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 693e36a..12daac3 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -11,7 +11,7 @@ use chrono::{DateTime, Utc}; use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor}; use lapdev_common::{ kube::{AppCatalogStatusEvent, ClusterStatusEvent}, - UserRole, LAPDEV_BASE_HOSTNAME, + UserRole, LAPDEV_AUTH_STATE_COOKIE, LAPDEV_AUTH_TOKEN_COOKIE, LAPDEV_BASE_HOSTNAME, }; use lapdev_conductor::{scheduler::LAPDEV_CPU_OVERCOMMIT, Conductor}; use lapdev_db::api::DbApi; @@ -42,8 +42,8 @@ use crate::{ use crate::environment_events::EnvironmentLifecycleEvent; -pub const OAUTH_STATE_COOKIE: &str = "lapdev_auth_state"; -pub const TOKEN_COOKIE_NAME: &str = "lapdev_auth_token"; +pub const OAUTH_STATE_COOKIE: &str = LAPDEV_AUTH_STATE_COOKIE; +pub const TOKEN_COOKIE_NAME: &str = LAPDEV_AUTH_TOKEN_COOKIE; pub const LAPDEV_CERTS: &str = "lapdev-certs"; pub type HyperClient = hyper_util::client::legacy::Client; diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 06aba48..8fbc712 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -18,6 +18,8 @@ use uuid::Uuid; pub const LAPDEV_DEFAULT_OSUSER: &str = "lapdev"; pub const LAPDEV_BASE_HOSTNAME: &str = "lapdev-base-hostname"; pub const LAPDEV_ISOLATE_CONTAINER: &str = "lapdev-isolate-container"; +pub const LAPDEV_AUTH_STATE_COOKIE: &str = "lapdev_auth_state"; +pub const LAPDEV_AUTH_TOKEN_COOKIE: &str = "lapdev_auth_token"; #[derive(Serialize, Deserialize, Debug)] pub struct NewProject { diff --git a/crates/kube/Cargo.toml b/crates/kube/Cargo.toml index 96c4d2d..607acb5 100644 --- a/crates/kube/Cargo.toml +++ b/crates/kube/Cargo.toml @@ -18,6 +18,7 @@ sea-orm.workspace = true serde_json.workspace = true serde.workspace = true serde_yaml.workspace = true +pasetors.workspace = true lapdev-rpc.workspace = true lapdev-kube-rpc.workspace = true lapdev-common.workspace = true diff --git a/crates/kube/src/http_proxy.rs b/crates/kube/src/http_proxy.rs index 3aaa79d..899672f 100644 --- a/crates/kube/src/http_proxy.rs +++ b/crates/kube/src/http_proxy.rs @@ -1,7 +1,6 @@ use anyhow::Result; use axum::{ - body::Body, - http::{Response, StatusCode}, + http::StatusCode, response::IntoResponse, }; use std::{io, sync::Arc}; @@ -16,19 +15,51 @@ use crate::{ preview_url::{PreviewUrlError, PreviewUrlResolver, PreviewUrlTarget}, tunnel::TunnelRegistry, }; +use lapdev_common::{kube::PreviewUrlAccessLevel, LAPDEV_AUTH_TOKEN_COOKIE}; use lapdev_db::api::DbApi; use lapdev_kube_rpc::http_parser; +use pasetors::{ + claims::ClaimsValidationRules, keys::SymmetricKey, local, token::UntrustedToken, version4::V4, +}; + +const PAGE_NOT_AUTHORISED: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../api/pages/not_authorised.html" +)); +const PAGE_NOT_FOUND: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../api/pages/not_found.html" +)); +const PAGE_NOT_FORWARDED: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../api/pages/not_forwarded.html" +)); +const PAGE_NOT_RUNNING: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../api/pages/not_running.html" +)); +const PAGE_GENERIC_ERROR: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../api/pages/generic_error.html" +)); pub struct PreviewUrlProxy { url_resolver: PreviewUrlResolver, tunnel_registry: Arc, + db: DbApi, + auth_token_key: Arc>, } impl PreviewUrlProxy { - pub fn new(db: DbApi, tunnel_registry: Arc) -> Self { + pub async fn new(db: DbApi, tunnel_registry: Arc) -> Self { + let auth_token_key = Arc::new(db.load_api_auth_token_key().await); + let url_resolver = PreviewUrlResolver::new(db.clone()); + Self { - url_resolver: PreviewUrlResolver::new(db), + url_resolver, tunnel_registry, + db, + auth_token_key, } } @@ -90,7 +121,19 @@ impl PreviewUrlProxy { debug!("Extracted subdomain: {} from host: {}", subdomain, host); // Resolve preview URL target - let target = self.resolve_preview_url_target(&subdomain).await?; + let target = match self.resolve_preview_url_target(&subdomain).await { + Ok(target) => target, + Err(err) => { + self.respond_with_proxy_error(&mut stream, err).await?; + return Ok(()); + } + }; + + // Enforce access controls based on preview URL configuration + if let Err(err) = self.authorize_request(&parsed_request, &target).await { + self.respond_with_proxy_error(&mut stream, err).await?; + return Ok(()); + } info!( "Resolved target: service={}:{} in cluster={}", @@ -113,100 +156,60 @@ impl PreviewUrlProxy { && e.to_string().contains("exceed maximum size") => { // Request headers are too large - reject it - self.send_error_response(&mut stream, 413, "Request Entity Too Large") - .await + self.send_error_page( + &mut stream, + StatusCode::PAYLOAD_TOO_LARGE, + PAGE_GENERIC_ERROR, + ) + .await } Err(_) => { - self.send_error_response(&mut stream, 400, "Bad Request") + self.send_error_page(&mut stream, StatusCode::BAD_REQUEST, PAGE_GENERIC_ERROR) .await } } } - /// Send HTTP response back to client - async fn send_http_response( + /// Send error response to client + async fn send_error_page( &self, stream: &mut TcpStream, - response: Response, + status: StatusCode, + body: &'static str, ) -> Result<(), ProxyError> { - let (parts, body) = response.into_parts(); - - // Write status line let status_line = format!( "HTTP/1.1 {} {}\r\n", - parts.status.as_u16(), - parts.status.canonical_reason().unwrap_or("Unknown") + status.as_u16(), + status.canonical_reason().unwrap_or("Unknown") ); stream .write_all(status_line.as_bytes()) .await .map_err(|e| ProxyError::Internal(format!("Failed to write status line: {e}")))?; - // Write headers - for (name, value) in parts.headers.iter() { - let header_line = format!( - "{}: {}\r\n", - name.as_str(), - value - .to_str() - .map_err(|e| ProxyError::Internal(format!("Invalid header value: {e}")))? - ); - stream - .write_all(header_line.as_bytes()) - .await - .map_err(|e| ProxyError::Internal(format!("Failed to write header: {e}")))?; - } + let headers = format!( + "Content-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); - // End headers stream - .write_all(b"\r\n") - .await - .map_err(|e| ProxyError::Internal(format!("Failed to write header separator: {e}")))?; - - // Write body - let body_bytes = axum::body::to_bytes(body, usize::MAX) + .write_all(headers.as_bytes()) .await - .map_err(|e| ProxyError::Internal(format!("Failed to read response body: {e}")))?; + .map_err(|e| ProxyError::Internal(format!("Failed to write headers: {e}")))?; stream - .write_all(&body_bytes) + .write_all(body.as_bytes()) .await - .map_err(|e| ProxyError::Internal(format!("Failed to write response body: {e}")))?; + .map_err(|e| ProxyError::Internal(format!("Failed to write body: {e}")))?; stream .flush() .await - .map_err(|e| ProxyError::Internal(format!("Failed to flush stream: {e}")))?; + .map_err(|e| ProxyError::Internal(format!("Failed to flush response: {e}")))?; Ok(()) } - /// Send error response to client - async fn send_error_response( - &self, - stream: &mut TcpStream, - status_code: u16, - reason: &str, - ) -> Result<(), ProxyError> { - use axum::body::Body; - - let response_body = format!("

{} {}

", status_code, reason); - - let status = StatusCode::from_u16(status_code) - .map_err(|_| ProxyError::Internal(format!("Invalid status code: {}", status_code)))?; - - let response = Response::builder() - .status(status) - .header("Content-Type", "text/html") - .header("Content-Length", response_body.len()) - .header("Connection", "close") - .body(Body::from(response_body)) - .map_err(|e| ProxyError::Internal(format!("Failed to build error response: {}", e)))?; - - // Reuse the existing send_http_response method - self.send_http_response(stream, response).await - } - /// Resolve preview URL target from subdomain async fn resolve_preview_url_target( &self, @@ -249,6 +252,126 @@ impl PreviewUrlProxy { Ok(target) } + async fn respond_with_proxy_error( + &self, + stream: &mut TcpStream, + error: ProxyError, + ) -> Result<(), ProxyError> { + let (status, page) = match &error { + ProxyError::Forbidden(_) => (StatusCode::FORBIDDEN, PAGE_NOT_AUTHORISED), + ProxyError::NotFound(_) => (StatusCode::NOT_FOUND, PAGE_NOT_FOUND), + ProxyError::TunnelNotAvailable(_) => { + (StatusCode::SERVICE_UNAVAILABLE, PAGE_NOT_FORWARDED) + } + ProxyError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, PAGE_NOT_RUNNING), + ProxyError::InvalidUrl(_) => (StatusCode::BAD_REQUEST, PAGE_GENERIC_ERROR), + ProxyError::NetworkError(_) => (StatusCode::BAD_GATEWAY, PAGE_GENERIC_ERROR), + ProxyError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, PAGE_GENERIC_ERROR), + }; + + warn!("Responding with error page: {:?} ({})", status, error); + + self.send_error_page(stream, status, page).await + } + + /// Ensure the caller is authorized to access the resolved preview target + async fn authorize_request( + &self, + request: &http_parser::ParsedHttpRequest, + target: &PreviewUrlTarget, + ) -> Result<(), ProxyError> { + match target.access_level { + PreviewUrlAccessLevel::Public => Ok(()), + PreviewUrlAccessLevel::Organization => { + let token = self + .extract_cookie_value(&request.headers, LAPDEV_AUTH_TOKEN_COOKIE) + .ok_or_else(|| ProxyError::Forbidden("Authentication required".to_string()))?; + + let user_id = self.user_id_from_token(&token)?; + + self.ensure_org_membership(user_id, target.organization_id) + .await?; + + debug!( + "Authorized organization member {} for preview {}", + user_id, target.preview_url_id + ); + + Ok(()) + } + } + } + + fn extract_cookie_value( + &self, + headers: &[(String, String)], + cookie_name: &str, + ) -> Option { + let prefix = format!("{cookie_name}="); + + headers + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("cookie")) + .flat_map(|(_, value)| value.split(';')) + .map(|cookie| cookie.trim()) + .find_map(|cookie| cookie.strip_prefix(&prefix).map(|value| value.to_string())) + } + + fn user_id_from_token(&self, token: &str) -> Result { + let untrusted = + UntrustedToken::try_from(token).map_err(|_| ProxyError::Forbidden("Invalid authentication token".to_string()))?; + + let trusted = local::decrypt( + self.auth_token_key.as_ref(), + &untrusted, + &ClaimsValidationRules::new(), + None, + None, + ) + .map_err(|_| ProxyError::Forbidden("Invalid authentication token".to_string()))?; + + let claims = trusted + .payload_claims() + .ok_or_else(|| ProxyError::Forbidden("Invalid authentication token".to_string()))?; + + let user_id_value = claims + .get_claim("user_id") + .ok_or_else(|| ProxyError::Forbidden("Invalid authentication token".to_string()))?; + + let user_id: String = serde_json::from_value(user_id_value.clone()) + .map_err(|_| ProxyError::Forbidden("Invalid authentication token".to_string()))?; + + Uuid::parse_str(&user_id) + .map_err(|_| ProxyError::Forbidden("Invalid authentication token".to_string())) + } + + async fn ensure_org_membership( + &self, + user_id: Uuid, + organization_id: Uuid, + ) -> Result<(), ProxyError> { + self.db + .get_organization_member(user_id, organization_id) + .await + .map(|_| ()) + .map_err(|err| { + if err + .to_string() + .contains("no organization member found") + { + ProxyError::Forbidden("Organization membership required".to_string()) + } else { + error!( + "Failed to verify organization membership for user {} in organization {}: {}", + user_id, organization_id, err + ); + ProxyError::Internal( + "Failed to verify organization membership".to_string(), + ) + } + }) + } + /// Start direct TCP proxying between client and target service async fn start_tcp_proxy( &self, diff --git a/crates/kube/src/lib.rs b/crates/kube/src/lib.rs index 56f117e..405435e 100644 --- a/crates/kube/src/lib.rs +++ b/crates/kube/src/lib.rs @@ -18,7 +18,7 @@ pub async fn start_preview_url_proxy_server( tunnel_registry: Arc, bind_addr: &str, ) -> Result<()> { - let proxy = Arc::new(PreviewUrlProxy::new(db, tunnel_registry)); + let proxy = Arc::new(PreviewUrlProxy::new(db, tunnel_registry).await); tracing::info!("Starting PreviewUrlProxy TCP server on {}", bind_addr); diff --git a/crates/kube/src/preview_url.rs b/crates/kube/src/preview_url.rs index 8a8c31d..f32948d 100644 --- a/crates/kube/src/preview_url.rs +++ b/crates/kube/src/preview_url.rs @@ -20,6 +20,7 @@ pub struct PreviewUrlTarget { pub access_level: PreviewUrlAccessLevel, pub protocol: String, pub environment_id: Uuid, + pub organization_id: Uuid, pub preview_url_id: Uuid, } @@ -110,6 +111,7 @@ impl PreviewUrlResolver { access_level, protocol: preview_url.protocol, environment_id: environment.id, + organization_id: environment.organization_id, preview_url_id: preview_url.id, }) } From a14afc73e3763a7bf9e316fade561569dc0a5a33 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Mon, 27 Oct 2025 22:01:09 +0000 Subject: [PATCH 175/334] update --- crates/api/src/server.rs | 77 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/api/src/server.rs b/crates/api/src/server.rs index 1d8ed99..6cd1cad 100644 --- a/crates/api/src/server.rs +++ b/crates/api/src/server.rs @@ -13,10 +13,10 @@ use hyper_util::rt::{TokioExecutor, TokioIo}; use lapdev_conductor::Conductor; use lapdev_db::api::DbApi; use serde::Deserialize; -use tokio::net::TcpListener; +use tokio::{net::TcpListener, time::Duration}; use tokio_rustls::TlsAcceptor; use tower::Service; -use tracing::error; +use tracing::{error, info, warn}; use crate::{ cert::tls_config, @@ -36,6 +36,7 @@ struct LapdevConfig { ssh_proxy_port: Option, ssh_proxy_display_port: Option, force_osuser: Option, + preview_url_proxy_port: Option, } #[derive(Parser)] @@ -63,6 +64,7 @@ pub struct ApiServer { pub app: Router, pub conductor: Conductor, ssh_proxy_port: u16, + preview_url_proxy_port: u16, _log: Result, } @@ -100,6 +102,7 @@ impl ApiServer { let ssh_proxy_port = config.ssh_proxy_port.unwrap_or(2222); let ssh_proxy_display_port = config.ssh_proxy_display_port.unwrap_or(2222); + let preview_url_proxy_port = config.preview_url_proxy_port.unwrap_or(8443); let state = Arc::new( CoreState::new( conductor.clone(), @@ -118,6 +121,7 @@ impl ApiServer { conductor, app, ssh_proxy_port, + preview_url_proxy_port, _log: log, }) } @@ -141,6 +145,41 @@ impl ApiServer { }); } + { + let db = self.conductor.db.clone(); + let tunnel_registry = self.state.kube_controller.tunnel_registry.clone(); + let host = self + .config + .bind + .clone() + .unwrap_or_else(|| "0.0.0.0".to_string()); + let port = self.preview_url_proxy_port; + tokio::spawn(async move { + loop { + let db_clone = db.clone(); + let tunnel_clone = tunnel_registry.clone(); + let bind = format!("{host}:{port}"); + match lapdev_kube::start_preview_url_proxy_server( + db_clone, + tunnel_clone, + &bind, + ) + .await + { + Ok(_) => { + info!("Preview URL proxy server exited gracefully"); + break; + } + Err(err) => { + error!("Preview URL proxy server failed: {err:?}"); + warn!("Retrying preview URL proxy server in 5 seconds"); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + }); + } + // start http server let bind = format!( "{}:{}", @@ -204,6 +243,7 @@ async fn run( let ssh_proxy_port = config.ssh_proxy_port.unwrap_or(2222); let ssh_proxy_display_port = config.ssh_proxy_display_port.unwrap_or(2222); + let preview_url_proxy_port = config.preview_url_proxy_port.unwrap_or(8443); { let conductor = conductor.clone(); let bind = config.bind.clone(); @@ -232,6 +272,39 @@ async fn run( let app = router::build_router(state.clone()); let certs = state.certs.clone(); + { + let db = state.db.clone(); + let tunnel_registry = state.kube_controller.tunnel_registry.clone(); + let host = config + .bind + .clone() + .unwrap_or_else(|| "0.0.0.0".to_string()); + tokio::spawn(async move { + loop { + let bind = format!("{host}:{preview_url_proxy_port}"); + let db_clone = db.clone(); + let tunnel_clone = tunnel_registry.clone(); + match lapdev_kube::start_preview_url_proxy_server( + db_clone, + tunnel_clone, + &bind, + ) + .await + { + Ok(_) => { + info!("Preview URL proxy server exited gracefully"); + break; + } + Err(err) => { + error!("Preview URL proxy server failed: {err:?}"); + warn!("Retrying preview URL proxy server in 5 seconds"); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + }); + } + { // start http server let bind = format!( From 6487f4384546bf16752061e2a42e5bf16ccf3405 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Mon, 27 Oct 2025 22:29:28 +0000 Subject: [PATCH 176/334] update --- crates/api/src/kube_controller/app_catalog.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/api/src/kube_controller/app_catalog.rs b/crates/api/src/kube_controller/app_catalog.rs index b70989a..57a3870 100644 --- a/crates/api/src/kube_controller/app_catalog.rs +++ b/crates/api/src/kube_controller/app_catalog.rs @@ -333,7 +333,8 @@ impl KubeController { .enrich_workloads_with_details(cluster_id, workloads) .await?; - self.db + let catalog_id = self + .db .create_app_catalog_with_enriched_workloads( org_id, user_id, @@ -343,7 +344,11 @@ impl KubeController { enriched_workloads, ) .await - .map_err(ApiError::from) + .map_err(ApiError::from)?; + + self.refresh_cluster_namespace_watches(cluster_id).await; + + Ok(catalog_id) } pub async fn get_all_app_catalogs( From 8ead07fa9380189964278c313a474e354a00abba Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Tue, 28 Oct 2025 22:46:06 +0000 Subject: [PATCH 177/334] update --- Cargo.lock | 1 + crates/api-hrpc/src/lib.rs | 10 +- crates/api/src/hrpc_service.rs | 8 +- crates/api/src/kube_controller/cluster.rs | 23 +- crates/api/src/server.rs | 21 +- crates/api/src/session.rs | 2 +- crates/common/src/kube.rs | 1 - crates/dashboard/src/kube_cluster.rs | 7 + .../dashboard/src/kube_environment_detail.rs | 4 +- crates/dashboard/src/kube_resource.rs | 76 +- crates/db/entities/src/kube_cluster.rs | 1 + .../m20250729_082625_create_kube_cluster.rs | 2 + crates/db/src/api.rs | 4 + crates/kube-manager/src/manager.rs | 95 ++- crates/kube-manager/src/manager_rpc.rs | 14 +- crates/kube-sidecar-proxy/Cargo.toml | 1 + crates/kube-sidecar-proxy/src/config.rs | 132 ++- crates/kube-sidecar-proxy/src/http2_client.rs | 307 +++++++ crates/kube-sidecar-proxy/src/http2_proxy.rs | 794 ++++++++++++++++++ crates/kube-sidecar-proxy/src/lib.rs | 2 + .../src/protocol_detector.rs | 224 ++++- crates/kube-sidecar-proxy/src/rpc.rs | 18 +- crates/kube-sidecar-proxy/src/server.rs | 76 +- crates/kube/src/http_proxy.rs | 11 +- crates/kube/src/server.rs | 1 + docs/README.md | 6 +- docs/core-concepts/architecture/README.md | 6 +- docs/core-concepts/environment.md | 6 +- docs/core-concepts/preview-url.md | 9 +- .../create-lapdev-environment.md | 4 +- 30 files changed, 1641 insertions(+), 225 deletions(-) create mode 100644 crates/kube-sidecar-proxy/src/http2_client.rs create mode 100644 crates/kube-sidecar-proxy/src/http2_proxy.rs diff --git a/Cargo.lock b/Cargo.lock index af27456..acd6504 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3531,6 +3531,7 @@ dependencies = [ "clap", "futures", "futures-util", + "h2 0.4.11", "http 1.3.1", "http-body-util", "httparse", diff --git a/crates/api-hrpc/src/lib.rs b/crates/api-hrpc/src/lib.rs index 1ea14f4..8fdbe47 100644 --- a/crates/api-hrpc/src/lib.rs +++ b/crates/api-hrpc/src/lib.rs @@ -6,10 +6,10 @@ use lapdev_common::{ }, kube::{ CreateKubeClusterResponse, CreateKubeEnvironmentPreviewUrlRequest, KubeAppCatalogWorkload, - KubeAppCatalogWorkloadCreate, KubeCluster, KubeClusterInfo, KubeContainerInfo, - KubeEnvironmentPreviewUrl, KubeEnvironmentService, KubeEnvironmentWorkload, KubeNamespace, - KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, PagePaginationParams, - PaginatedResult, PaginationParams, UpdateKubeEnvironmentPreviewUrlRequest, + KubeAppCatalogWorkloadCreate, KubeCluster, KubeContainerInfo, KubeEnvironmentPreviewUrl, + KubeEnvironmentService, KubeEnvironmentWorkload, KubeNamespace, KubeNamespaceInfo, + KubeWorkload, KubeWorkloadKind, KubeWorkloadList, PagePaginationParams, PaginatedResult, + PaginationParams, UpdateKubeEnvironmentPreviewUrlRequest, }, }; use uuid::Uuid; @@ -109,7 +109,7 @@ pub trait HrpcService { &self, org_id: Uuid, cluster_id: Uuid, - ) -> Result; + ) -> Result; async fn create_app_catalog( &self, diff --git a/crates/api/src/hrpc_service.rs b/crates/api/src/hrpc_service.rs index 9886cfa..c9839c3 100644 --- a/crates/api/src/hrpc_service.rs +++ b/crates/api/src/hrpc_service.rs @@ -11,9 +11,9 @@ use lapdev_common::{ hrpc::HrpcError, kube::{ CreateKubeClusterResponse, KubeAppCatalog, KubeAppCatalogWorkload, - KubeAppCatalogWorkloadCreate, KubeCluster, KubeClusterInfo, KubeEnvironment, - KubeEnvironmentWorkload, KubeNamespace, KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, - KubeWorkloadList, PagePaginationParams, PaginatedResult, PaginationParams, + KubeAppCatalogWorkloadCreate, KubeCluster, KubeEnvironment, KubeEnvironmentWorkload, + KubeNamespace, KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, + PagePaginationParams, PaginatedResult, PaginationParams, }, UserRole, }; @@ -614,7 +614,7 @@ impl HrpcService for CoreState { headers: &axum::http::HeaderMap, org_id: Uuid, cluster_id: Uuid, - ) -> Result { + ) -> Result { let _ = self.authorize(headers, org_id, None).await?; self.kube_controller diff --git a/crates/api/src/kube_controller/cluster.rs b/crates/api/src/kube_controller/cluster.rs index 3598c53..268d6ee 100644 --- a/crates/api/src/kube_controller/cluster.rs +++ b/crates/api/src/kube_controller/cluster.rs @@ -27,12 +27,11 @@ impl KubeController { can_deploy_personal: c.can_deploy_personal, can_deploy_shared: c.can_deploy_shared, info: KubeClusterInfo { - cluster_name: Some(c.name), cluster_version: c.cluster_version.unwrap_or("Unknown".to_string()), node_count: 0, // TODO: Get actual node count from kube-manager available_cpu: "N/A".to_string(), // TODO: Get actual CPU from kube-manager available_memory: "N/A".to_string(), // TODO: Get actual memory from kube-manager - provider: None, // TODO: Get provider info + provider: c.provider.clone(), region: c.region, status: KubeClusterStatus::from_str(&c.status) .unwrap_or(KubeClusterStatus::NotReady), @@ -358,7 +357,7 @@ impl KubeController { &self, org_id: Uuid, cluster_id: Uuid, - ) -> Result { + ) -> Result { // Get cluster from database let cluster = self .db @@ -373,17 +372,25 @@ impl KubeController { // Convert database cluster to KubeClusterInfo let cluster_info = KubeClusterInfo { - cluster_name: Some(cluster.name), - cluster_version: cluster.cluster_version.unwrap_or("Unknown".to_string()), + cluster_version: cluster + .cluster_version + .clone() + .unwrap_or_else(|| "Unknown".to_string()), node_count: 0, // TODO: Get actual node count from kube-manager available_cpu: "N/A".to_string(), // TODO: Get actual CPU from kube-manager available_memory: "N/A".to_string(), // TODO: Get actual memory from kube-manager - provider: None, // TODO: Get provider info - region: cluster.region, + provider: cluster.provider.clone(), + region: cluster.region.clone(), status: KubeClusterStatus::from_str(&cluster.status) .unwrap_or(KubeClusterStatus::NotReady), }; - Ok(cluster_info) + Ok(KubeCluster { + id: cluster.id, + name: cluster.name, + can_deploy_personal: cluster.can_deploy_personal, + can_deploy_shared: cluster.can_deploy_shared, + info: cluster_info, + }) } } diff --git a/crates/api/src/server.rs b/crates/api/src/server.rs index 6cd1cad..2dac233 100644 --- a/crates/api/src/server.rs +++ b/crates/api/src/server.rs @@ -159,12 +159,8 @@ impl ApiServer { let db_clone = db.clone(); let tunnel_clone = tunnel_registry.clone(); let bind = format!("{host}:{port}"); - match lapdev_kube::start_preview_url_proxy_server( - db_clone, - tunnel_clone, - &bind, - ) - .await + match lapdev_kube::start_preview_url_proxy_server(db_clone, tunnel_clone, &bind) + .await { Ok(_) => { info!("Preview URL proxy server exited gracefully"); @@ -275,21 +271,14 @@ async fn run( { let db = state.db.clone(); let tunnel_registry = state.kube_controller.tunnel_registry.clone(); - let host = config - .bind - .clone() - .unwrap_or_else(|| "0.0.0.0".to_string()); + let host = config.bind.clone().unwrap_or_else(|| "0.0.0.0".to_string()); tokio::spawn(async move { loop { let bind = format!("{host}:{preview_url_proxy_port}"); let db_clone = db.clone(); let tunnel_clone = tunnel_registry.clone(); - match lapdev_kube::start_preview_url_proxy_server( - db_clone, - tunnel_clone, - &bind, - ) - .await + match lapdev_kube::start_preview_url_proxy_server(db_clone, tunnel_clone, &bind) + .await { Ok(_) => { info!("Preview URL proxy server exited gracefully"); diff --git a/crates/api/src/session.rs b/crates/api/src/session.rs index b8f6cb6..3baedcb 100644 --- a/crates/api/src/session.rs +++ b/crates/api/src/session.rs @@ -22,7 +22,7 @@ use sea_orm::{ use serde::Deserialize; use uuid::Uuid; -use crate::state::{CoreState, OAUTH_STATE_COOKIE, RequestInfo, TOKEN_COOKIE_NAME}; +use crate::state::{CoreState, RequestInfo, OAUTH_STATE_COOKIE, TOKEN_COOKIE_NAME}; pub const OAUTH_STATE: &str = "oauth_state"; pub const REDIRECT_URL: &str = "redirect_url"; diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index f362c85..dda129d 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -29,7 +29,6 @@ pub struct KubeCluster { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KubeClusterInfo { - pub cluster_name: Option, pub cluster_version: String, pub node_count: u32, pub available_cpu: String, diff --git a/crates/dashboard/src/kube_cluster.rs b/crates/dashboard/src/kube_cluster.rs index 4766b54..0d40adf 100644 --- a/crates/dashboard/src/kube_cluster.rs +++ b/crates/dashboard/src/kube_cluster.rs @@ -110,6 +110,7 @@ pub fn KubeClusterList(update_counter: RwSignal) -> impl In Name Status Version + Provider Region Nodes Can Deploy Personal @@ -172,6 +173,11 @@ pub fn KubeClusterItem( let cluster_id_for_delete = cluster_id; let cluster_id_for_resources = cluster_id; let cluster_name = cluster.name.clone(); + let provider = cluster + .info + .provider + .clone() + .unwrap_or_else(|| "Unknown".to_string()); let cluster_name_clone = cluster_name.clone(); let delete_action = Action::new_local(move |_| { @@ -196,6 +202,7 @@ pub fn KubeClusterItem( {status_text} {cluster.info.cluster_version} + {provider} {cluster.info.region.unwrap_or("N/A".to_string())} {cluster.info.node_count.to_string()} diff --git a/crates/dashboard/src/kube_environment_detail.rs b/crates/dashboard/src/kube_environment_detail.rs index c7e9deb..1826d41 100644 --- a/crates/dashboard/src/kube_environment_detail.rs +++ b/crates/dashboard/src/kube_environment_detail.rs @@ -11,7 +11,7 @@ use lapdev_common::{ DevboxWorkloadInterceptSummary, }, kube::{ - KubeClusterInfo, KubeContainerInfo, KubeEnvironment, KubeEnvironmentService, + KubeCluster, KubeContainerInfo, KubeEnvironment, KubeEnvironmentService, KubeEnvironmentStatus, KubeEnvironmentSyncStatus, KubeEnvironmentWorkload, }, }; @@ -85,7 +85,7 @@ async fn get_environment_detail( async fn get_environment_cluster_info( org: Signal>, cluster_id: Uuid, -) -> Result { +) -> Result { let org = org.get().ok_or_else(|| anyhow!("can't get org"))?; let client = HrpcServiceClient::new("/api/rpc".to_string()); Ok(client.get_cluster_info(org.id, cluster_id).await??) diff --git a/crates/dashboard/src/kube_resource.rs b/crates/dashboard/src/kube_resource.rs index 6199417..bca4896 100644 --- a/crates/dashboard/src/kube_resource.rs +++ b/crates/dashboard/src/kube_resource.rs @@ -6,9 +6,9 @@ use gloo_net::eventsource::futures::EventSource; use lapdev_common::{ console::Organization, kube::{ - KubeAppCatalogWorkloadCreate, KubeClusterInfo, KubeClusterStatus, KubeNamespace, - KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, KubeWorkloadStatus, - PaginationCursor, PaginationParams, + KubeAppCatalogWorkloadCreate, KubeCluster, KubeClusterInfo, KubeClusterStatus, + KubeNamespace, KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, + KubeWorkloadStatus, PaginationCursor, PaginationParams, }, }; use leptos::{prelude::*, task::spawn_local_scoped_with_cancellation}; @@ -98,7 +98,7 @@ async fn get_namespaces_from_api( async fn get_cluster_info_from_api( org: Signal>, cluster_id: uuid::Uuid, -) -> Result { +) -> Result { let org = org.get().ok_or_else(|| anyhow!("can't get org"))?; let client = get_hrpc_client(); @@ -119,22 +119,25 @@ pub fn ClusterInfo( ); let cluster_info_signal = cluster_info_resource.clone(); - let cluster_info = Signal::derive(move || { - let cluster_info = cluster_info_signal.get(); - if let Some(Some(info)) = cluster_info.as_ref() { - config - .current_page - .set(info.cluster_name.clone().unwrap_or_default()); + let cluster = Signal::derive(move || { + let cluster_data = cluster_info_signal.get(); + if let Some(Some(cluster)) = cluster_data.as_ref() { + config.current_page.set(cluster.name.clone()); } - cluster_info.flatten().unwrap_or_else(|| KubeClusterInfo { - cluster_name: Some("Unknown".to_string()), - cluster_version: "Unknown".to_string(), - node_count: 0, - available_cpu: "N/A".to_string(), - available_memory: "N/A".to_string(), - provider: None, - region: None, - status: KubeClusterStatus::NotReady, + cluster_data.flatten().unwrap_or_else(|| KubeCluster { + id: cluster_id, + name: "Unknown".to_string(), + can_deploy_personal: false, + can_deploy_shared: false, + info: KubeClusterInfo { + cluster_version: "Unknown".to_string(), + node_count: 0, + available_cpu: "N/A".to_string(), + available_memory: "N/A".to_string(), + provider: None, + region: None, + status: KubeClusterStatus::NotReady, + }, }) }); @@ -195,39 +198,52 @@ pub fn ClusterInfo(

Cluster Information

{move || { - let info = cluster_info.get(); - - let status_variant = match info.status { + let cluster = cluster.get(); + let info = cluster.info.clone(); + + let cluster_name = cluster.name.clone(); + let cluster_version = info.cluster_version.clone(); + let status = info.status.clone(); + let status_label = status.to_string(); + let status_variant = match status { KubeClusterStatus::Ready => BadgeVariant::Secondary, KubeClusterStatus::NotReady => BadgeVariant::Destructive, KubeClusterStatus::Error => BadgeVariant::Destructive, KubeClusterStatus::Provisioning => BadgeVariant::Outline, }; + let region = info.region.clone().unwrap_or_else(|| "N/A".to_string()); + let provider = info.provider.clone().unwrap_or_else(|| "Unknown".to_string()); view! { -
+
-

{info.cluster_name.unwrap_or("N/A".to_string())}

+

{cluster_name}

-

{info.cluster_version}

+

{cluster_version}

- {info.status.to_string()} + {status_label} +
+
+
+
+ +

{provider}

-

{info.region.unwrap_or("N/A".to_string())}

+

{region}

@@ -270,15 +286,15 @@ pub fn KubeResourceList( let limit_select_value = RwSignal::new(20usize); let include_system_workloads = RwSignal::new(false); - let cluster_info = + let cluster_resource = LocalResource::new( move || async move { get_cluster_info_from_api(org, cluster_id).await.ok() }, ); let cluster_status = Signal::derive(move || { - cluster_info + cluster_resource .get() .flatten() - .map(|info| info.status) + .map(|cluster| cluster.info.status) .unwrap_or(KubeClusterStatus::NotReady) }); diff --git a/crates/db/entities/src/kube_cluster.rs b/crates/db/entities/src/kube_cluster.rs index 112eaa0..443cd28 100644 --- a/crates/db/entities/src/kube_cluster.rs +++ b/crates/db/entities/src/kube_cluster.rs @@ -14,6 +14,7 @@ pub struct Model { pub name: String, pub cluster_version: Option, pub status: String, + pub provider: Option, pub region: Option, pub last_reported_at: Option, pub can_deploy_personal: bool, diff --git a/crates/db/migration/src/m20250729_082625_create_kube_cluster.rs b/crates/db/migration/src/m20250729_082625_create_kube_cluster.rs index 3d6efe3..5012f99 100644 --- a/crates/db/migration/src/m20250729_082625_create_kube_cluster.rs +++ b/crates/db/migration/src/m20250729_082625_create_kube_cluster.rs @@ -32,6 +32,7 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(KubeCluster::Name).string().not_null()) .col(ColumnDef::new(KubeCluster::ClusterVersion).string()) .col(ColumnDef::new(KubeCluster::Status).string().not_null()) + .col(ColumnDef::new(KubeCluster::Provider).string()) .col(ColumnDef::new(KubeCluster::Region).string()) .col(ColumnDef::new(KubeCluster::LastReportedAt).timestamp_with_time_zone()) .col( @@ -75,6 +76,7 @@ pub enum KubeCluster { Name, ClusterVersion, Status, + Provider, Region, LastReportedAt, CanDeployPersonal, diff --git a/crates/db/src/api.rs b/crates/db/src/api.rs index beb92e7..a95fa85 100644 --- a/crates/db/src/api.rs +++ b/crates/db/src/api.rs @@ -607,6 +607,7 @@ impl DbApi { id: Uuid, cluster_version: Option, status: Option, + provider: Option, region: Option, ) -> Result { use lapdev_db_entities::kube_cluster; @@ -617,6 +618,7 @@ impl DbApi { id: ActiveValue::Set(id), cluster_version: ActiveValue::Set(cluster_version), status: status.map(ActiveValue::Set).unwrap_or(ActiveValue::NotSet), + provider: ActiveValue::Set(provider), region: ActiveValue::Set(region), last_reported_at: ActiveValue::Set(Some(now)), ..Default::default() @@ -1011,6 +1013,7 @@ impl DbApi { name: ActiveValue::Set(name), cluster_version: ActiveValue::Set(None), status: ActiveValue::Set(status), + provider: ActiveValue::Set(None), region: ActiveValue::Set(None), created_by: ActiveValue::Set(user_id), organization_id: ActiveValue::Set(org_id), @@ -1813,6 +1816,7 @@ impl DbApi { name, cluster_version: None, status: "Not Ready".to_string(), + provider: None, region: None, created_at: related.env_created_at, created_by: related.env_user_id, diff --git a/crates/kube-manager/src/manager.rs b/crates/kube-manager/src/manager.rs index 782094e..8202ee8 100644 --- a/crates/kube-manager/src/manager.rs +++ b/crates/kube-manager/src/manager.rs @@ -334,22 +334,16 @@ impl KubeManager { let nodes = self.get_cluster_nodes(client).await?; let (total_cpu_millicores, total_memory_bytes) = self.calculate_cluster_resources(&nodes); let node_count = nodes.len() as u32; - let status = self.determine_cluster_status(&nodes, node_count); + let status = KubeClusterStatus::Ready; // Detect provider and region from node labels let (detected_provider, detected_region) = self.detect_provider_and_region(&nodes); - // Get cluster identification - let cluster_name = Self::get_cluster_name(client) - .await - .or_else(|| std::env::var("CLUSTER_NAME").ok()); - // Use detected values with environment variable fallbacks let provider = detected_provider.or_else(|| std::env::var("CLUSTER_PROVIDER").ok()); let region = detected_region.or_else(|| std::env::var("CLUSTER_REGION").ok()); Ok(KubeClusterInfo { - cluster_name, cluster_version: format!("{}.{}", version.major, version.minor), node_count, available_cpu: format!("{}m", total_cpu_millicores), @@ -445,39 +439,19 @@ impl KubeManager { } } - fn determine_cluster_status( - &self, - nodes: &[k8s_openapi::api::core::v1::Node], - node_count: u32, - ) -> KubeClusterStatus { - let ready_nodes = nodes.iter().filter(|node| self.is_node_ready(node)).count(); - - if ready_nodes == nodes.len() && node_count > 0 { - KubeClusterStatus::Ready - } else if ready_nodes > 0 { - KubeClusterStatus::NotReady - } else { - KubeClusterStatus::Error - } - } - - fn is_node_ready(&self, node: &k8s_openapi::api::core::v1::Node) -> bool { - node.status - .as_ref() - .and_then(|s| s.conditions.as_ref()) - .map(|conditions| { - conditions - .iter() - .any(|c| c.type_ == "Ready" && c.status == "True") - }) - .unwrap_or(false) - } - fn detect_provider_and_region( &self, nodes: &[k8s_openapi::api::core::v1::Node], ) -> (Option, Option) { - let mut detected_provider = None; + let mut detected_provider = nodes + .iter() + .filter_map(|node| { + node.spec + .as_ref() + .and_then(|spec| spec.provider_id.as_deref()) + .and_then(Self::detect_provider_from_provider_id) + }) + .next(); let mut detected_region = None; for node in nodes { @@ -492,9 +466,11 @@ impl KubeManager { // Detect provider from instance type if detected_provider.is_none() { - if let Some(instance_type) = labels.get("node.kubernetes.io/instance-type") { - detected_provider = Self::detect_provider_from_instance_type(instance_type); - } + detected_provider = Self::detect_provider_from_labels(labels).or_else(|| { + labels + .get("node.kubernetes.io/instance-type") + .and_then(|value| Self::detect_provider_from_instance_type(value)) + }); } // Break early if we have both @@ -507,6 +483,18 @@ impl KubeManager { (detected_provider, detected_region) } + fn detect_provider_from_provider_id(provider_id: &str) -> Option { + if provider_id.starts_with("aws:///") { + Some("AWS".to_string()) + } else if provider_id.starts_with("gce://") { + Some("GCP".to_string()) + } else if provider_id.starts_with("azure:///") { + Some("Azure".to_string()) + } else { + None + } + } + fn detect_provider_from_instance_type(instance_type: &str) -> Option { if instance_type.starts_with('m') || instance_type.starts_with('c') @@ -528,16 +516,27 @@ impl KubeManager { } } - async fn get_cluster_name(client: &kube::Client) -> Option { - use k8s_openapi::api::core::v1::ConfigMap; - - let configmaps: kube::Api = kube::Api::namespaced(client.clone(), "kube-public"); - - if let Ok(Some(cluster_info)) = configmaps.get_opt("cluster-info").await { - return cluster_info.metadata.name.clone(); + fn detect_provider_from_labels( + labels: &std::collections::BTreeMap, + ) -> Option { + if labels.contains_key("eks.amazonaws.com/nodegroup") + || labels.contains_key("alpha.eksctl.io/nodegroup-name") + || labels.contains_key("karpenter.sh/nodepool") + { + Some("AWS".to_string()) + } else if labels.contains_key("cloud.google.com/gke-nodepool") + || labels.contains_key("cloud.google.com/machine-family") + || labels.contains_key("topology.gke.io/zone") + { + Some("GCP".to_string()) + } else if labels.contains_key("kubernetes.azure.com/nodepool-name") + || labels.contains_key("agentpool") + || labels.contains_key("azure.microsoft.com/machine") + { + Some("Azure".to_string()) + } else { + None } - - None } pub(crate) async fn collect_workloads( diff --git a/crates/kube-manager/src/manager_rpc.rs b/crates/kube-manager/src/manager_rpc.rs index 3f34071..18449d3 100644 --- a/crates/kube-manager/src/manager_rpc.rs +++ b/crates/kube-manager/src/manager_rpc.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use lapdev_common::kube::{ KubeNamespaceInfo, KubeWorkload, KubeWorkloadKind, KubeWorkloadList, PaginationParams, }; @@ -30,14 +30,16 @@ impl KubeManagerRpcServer { let cluster_info = self.manager.collect_cluster_info().await?; tracing::info!("Reporting cluster info: {:?}", cluster_info); - match self + let _ = self .rpc_client .report_cluster_info(tarpc::context::current(), cluster_info) .await - { - Ok(_) => tracing::info!("Successfully reported cluster info"), - Err(e) => tracing::error!("RPC call failed: {}", e), - } + .map_err(|e| { + tracing::error!("RPC call failed: {}", e); + anyhow!("report_cluster_info RPC failed: {e}") + })?; + + tracing::info!("Successfully reported cluster info"); Ok(()) } diff --git a/crates/kube-sidecar-proxy/Cargo.toml b/crates/kube-sidecar-proxy/Cargo.toml index 3525192..d0552de 100644 --- a/crates/kube-sidecar-proxy/Cargo.toml +++ b/crates/kube-sidecar-proxy/Cargo.toml @@ -20,6 +20,7 @@ hyper-util.workspace = true http-body-util.workspace = true tower.workspace = true tower-http = { workspace = true, features = ["cors", "timeout", "trace"] } +h2 = "0.4" # Async runtime tokio.workspace = true diff --git a/crates/kube-sidecar-proxy/src/config.rs b/crates/kube-sidecar-proxy/src/config.rs index fc2d8c2..1447165 100644 --- a/crates/kube-sidecar-proxy/src/config.rs +++ b/crates/kube-sidecar-proxy/src/config.rs @@ -3,17 +3,14 @@ use lapdev_tunnel::{ TunnelClient, TunnelError, TunnelTcpStream, WebSocketTransport as TunnelWebSocketTransport, }; use serde::{Deserialize, Serialize}; -use std::{ - collections::{hash_map::Entry, HashMap}, - io, - net::SocketAddr, - sync::Arc, -}; +use std::{collections::HashMap, io, net::SocketAddr, sync::Arc}; use tokio::sync::{OnceCell, RwLock}; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use uuid::Uuid; +use crate::http2_client::Http2ClientActor; + /// Immutable settings for the sidecar proxy determined at boot time. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SidecarSettings { @@ -109,18 +106,17 @@ impl RoutingTable { RouteDecision::DefaultLocal } - pub fn replace_branch_routes( + pub async fn replace_branch_routes( &mut self, routes: impl IntoIterator, ) { + let mut old_routes = std::mem::take(&mut self.branch_routes); let mut new_routes = HashMap::new(); - for (env_id, service_route) in routes { - if let Some(existing) = self.branch_routes.get(&env_id) { - let mode = match &existing.mode { - BranchMode::Devbox(connection) => BranchMode::Devbox(connection.clone()), - BranchMode::Service => BranchMode::Service, - }; + for (env_id, service_route) in routes.into_iter() { + if let Some(existing) = old_routes.remove(&env_id) { + reset_http2_clients(&existing.service.http2_clients).await; + let mode = existing.mode; new_routes.insert( env_id, BranchRoute { @@ -128,15 +124,19 @@ impl RoutingTable { mode, }, ); - continue; + } else { + new_routes.insert( + env_id, + BranchRoute { + service: service_route, + mode: BranchMode::Service, + }, + ); } - new_routes.insert( - env_id, - BranchRoute { - service: service_route, - mode: BranchMode::Service, - }, - ); + } + + for (_, route) in old_routes.into_iter() { + reset_http2_clients(&route.service.http2_clients).await; } self.branch_routes = new_routes; @@ -176,26 +176,39 @@ impl RoutingTable { } } - pub fn upsert_branch_service_route( + pub async fn upsert_branch_service_route( &mut self, branch_id: Uuid, service_route: BranchServiceRoute, ) { - match self.branch_routes.entry(branch_id) { - Entry::Occupied(mut entry) => { - entry.get_mut().service = service_route; - } - Entry::Vacant(entry) => { - entry.insert(BranchRoute { + if let Some(existing) = self.branch_routes.remove(&branch_id) { + reset_http2_clients(&existing.service.http2_clients).await; + let mode = existing.mode; + self.branch_routes.insert( + branch_id, + BranchRoute { + service: service_route, + mode, + }, + ); + } else { + self.branch_routes.insert( + branch_id, + BranchRoute { service: service_route, mode: BranchMode::Service, - }); - } + }, + ); } } - pub fn remove_branch_service_route(&mut self, branch_id: &Uuid) -> bool { - self.branch_routes.remove(branch_id).is_some() + pub async fn remove_branch_service_route(&mut self, branch_id: &Uuid) -> bool { + if let Some(route) = self.branch_routes.remove(branch_id) { + reset_http2_clients(&route.service.http2_clients).await; + true + } else { + false + } } pub fn remove_branch_devbox_by_intercept(&mut self, intercept_id: &Uuid) -> Option { @@ -244,13 +257,14 @@ pub struct BranchRoute { pub mode: BranchMode, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct BranchServiceRoute { pub service_names: HashMap, pub headers: HashMap, pub requires_auth: bool, pub access_level: AccessLevel, pub timeout_ms: Option, + pub http2_clients: Arc>>>, } impl BranchServiceRoute { @@ -263,6 +277,7 @@ impl BranchServiceRoute { requires_auth: true, access_level: AccessLevel::Personal, timeout_ms: None, + http2_clients: Arc::new(RwLock::new(HashMap::new())), } } @@ -313,6 +328,7 @@ impl DevboxRouteMetadata { pub struct DevboxConnection { metadata: DevboxRouteMetadata, client: RwLock>>, + http2_clients: RwLock>>, } impl DevboxConnection { @@ -320,6 +336,7 @@ impl DevboxConnection { Self { metadata, client: RwLock::new(OnceCell::new()), + http2_clients: RwLock::new(HashMap::new()), } } @@ -357,6 +374,7 @@ impl DevboxConnection { pub async fn clear_client(&self) { self.client.write().await.take(); + self.clear_http2_clients().await; } async fn ensure_client(&self) -> Result, TunnelError> { @@ -398,6 +416,40 @@ impl DevboxConnection { let transport = TunnelWebSocketTransport::new(stream); Ok(TunnelClient::connect(transport)) } + + pub async fn get_or_create_http2_client( + &self, + target_port: u16, + create: F, + ) -> Arc + where + F: FnOnce() -> Arc, + { + let mut guard = self.http2_clients.write().await; + guard.entry(target_port).or_insert_with(create).clone() + } + + pub async fn remove_http2_client(&self, target_port: u16) { + let client = { + let mut guard = self.http2_clients.write().await; + guard.remove(&target_port) + }; + + if let Some(client) = client { + client.shutdown().await; + } + } + + pub async fn clear_http2_clients(&self) { + let clients = { + let mut guard = self.http2_clients.write().await; + guard.drain().collect::>() + }; + + for (_, client) in clients { + client.shutdown().await; + } + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -493,3 +545,17 @@ where { TunnelError::Transport(io::Error::new(io::ErrorKind::Other, err.to_string())) } + +async fn reset_http2_clients(clients: &Arc>>>) { + let drained = { + let mut guard = clients.write().await; + guard + .drain() + .map(|(_, client)| client) + .collect::>>() + }; + + for client in drained { + client.shutdown().await; + } +} diff --git a/crates/kube-sidecar-proxy/src/http2_client.rs b/crates/kube-sidecar-proxy/src/http2_client.rs new file mode 100644 index 0000000..888b311 --- /dev/null +++ b/crates/kube-sidecar-proxy/src/http2_client.rs @@ -0,0 +1,307 @@ +use std::{collections::VecDeque, future::Future, io, sync::Arc}; + +use bytes::Bytes; +use futures::future::BoxFuture; +use h2::client; +use tokio::{ + runtime::Handle, + sync::{mpsc, oneshot}, +}; +use tracing::{debug, warn}; + +type ConnectFn = Arc BoxFuture<'static, io::Result> + Send + Sync>; + +pub struct ConnectResult { + pub sender: client::SendRequest, + pub driver: BoxFuture<'static, Result<(), h2::Error>>, +} + +impl ConnectResult { + pub fn new( + sender: client::SendRequest, + driver: BoxFuture<'static, Result<(), h2::Error>>, + ) -> Self { + Self { sender, driver } + } +} + +pub struct Http2ClientActor { + tx: mpsc::Sender, +} + +#[derive(Debug)] +pub struct Http2ClientLease { + sender: client::SendRequest, + tx: mpsc::Sender, + broken: bool, +} + +enum ActorMessage { + Acquire { + responder: oneshot::Sender>, + }, + Return { + broken: bool, + }, + Connected { + result: ConnectResult, + }, + ConnectFailed { + error: io::Error, + }, + DriverFinished { + result: Result<(), h2::Error>, + }, + Shutdown, +} + +struct ActorState { + label: String, + connect: ConnectFn, + sender: Option>, + waiters: VecDeque>>, + connecting: bool, +} + +impl Http2ClientActor { + pub fn spawn(label: impl Into, connect: ConnectFn) -> Arc { + let (tx, rx) = mpsc::channel(32); + let actor = Arc::new(Self { tx: tx.clone() }); + + let state = ActorState { + label: label.into(), + connect, + sender: None, + waiters: VecDeque::new(), + connecting: false, + }; + + tokio::spawn(run_actor(state, tx.clone(), rx)); + + actor + } + + pub fn connector(f: F) -> ConnectFn + where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + Arc::new(move || { + let fut = f(); + Box::pin(fut) + }) + } + + pub async fn acquire(&self) -> io::Result { + let (tx, rx) = oneshot::channel(); + self.tx + .send(ActorMessage::Acquire { responder: tx }) + .await + .map_err(|_| { + io::Error::new(io::ErrorKind::BrokenPipe, "HTTP/2 client actor stopped") + })?; + + rx.await + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "HTTP/2 client actor dropped"))? + } + + pub async fn shutdown(&self) { + let _ = self.tx.send(ActorMessage::Shutdown).await; + } +} + +impl Drop for Http2ClientActor { + fn drop(&mut self) { + match self.tx.try_send(ActorMessage::Shutdown) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(message)) => { + if let Ok(handle) = Handle::try_current() { + let tx = self.tx.clone(); + handle.spawn(async move { + let _ = tx.send(message).await; + }); + } + } + Err(mpsc::error::TrySendError::Closed(_)) => {} + } + } +} + +impl Http2ClientLease { + pub fn sender_mut(&mut self) -> &mut client::SendRequest { + &mut self.sender + } + + pub fn mark_broken(&mut self) { + self.broken = true; + } +} + +impl Drop for Http2ClientLease { + fn drop(&mut self) { + let broken = self.broken; + if let Err(err) = self.tx.try_send(ActorMessage::Return { broken }) { + let tx = self.tx.clone(); + tokio::spawn(async move { + if let Err(send_err) = tx.send(err.into_inner()).await { + debug!( + "HTTP/2 client actor dropped lease return message: {}", + send_err + ); + } + }); + } + } +} + +impl From> for ActorMessage { + fn from(err: mpsc::error::TrySendError) -> Self { + match err { + mpsc::error::TrySendError::Closed(message) + | mpsc::error::TrySendError::Full(message) => message, + } + } +} + +async fn run_actor( + mut state: ActorState, + tx: mpsc::Sender, + mut rx: mpsc::Receiver, +) { + while let Some(message) = rx.recv().await { + match message { + ActorMessage::Acquire { responder } => { + if let Some(sender) = state.sender.as_ref() { + deliver_lease(sender.clone(), &tx, responder); + } else { + state.waiters.push_back(responder); + ensure_connect(&mut state, &tx); + } + } + ActorMessage::Return { broken } => { + if broken { + debug!( + "HTTP/2 client {} reported broken lease; reconnecting", + state.label + ); + state.sender = None; + if !state.waiters.is_empty() { + ensure_connect(&mut state, &tx); + } + } + } + ActorMessage::Connected { result } => { + state.connecting = false; + let ConnectResult { sender, driver } = result; + spawn_driver(state.label.clone(), driver, tx.clone()); + state.sender = Some(sender); + + if let Some(base) = state.sender.as_ref() { + while let Some(responder) = state.waiters.pop_front() { + deliver_lease(base.clone(), &tx, responder); + } + } + } + ActorMessage::ConnectFailed { error } => { + state.connecting = false; + let kind = error.kind(); + let message = error.to_string(); + warn!( + "HTTP/2 client {} failed to connect: {}", + state.label, message + ); + + while let Some(responder) = state.waiters.pop_front() { + let _ = responder.send(Err(io::Error::new(kind, message.clone()))); + } + } + ActorMessage::DriverFinished { result } => { + state.sender = None; + match result { + Ok(()) => { + debug!("HTTP/2 client {} connection closed cleanly", state.label); + } + Err(err) => { + warn!( + "HTTP/2 client {} connection ended with error: {}", + state.label, err + ); + } + } + + if !state.waiters.is_empty() { + ensure_connect(&mut state, &tx); + } + } + ActorMessage::Shutdown => { + break; + } + } + } + + while let Some(responder) = state.waiters.pop_front() { + let _ = responder.send(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "HTTP/2 client actor shutdown", + ))); + } +} + +fn ensure_connect(state: &mut ActorState, tx: &mpsc::Sender) { + if !state.connecting { + state.connecting = true; + spawn_connect(state.label.clone(), Arc::clone(&state.connect), tx.clone()); + } +} + +fn deliver_lease( + sender: client::SendRequest, + tx: &mpsc::Sender, + responder: oneshot::Sender>, +) { + let lease = Http2ClientLease { + sender, + tx: tx.clone(), + broken: false, + }; + + if responder.send(Ok(lease)).is_err() { + let _ = tx.try_send(ActorMessage::Return { broken: false }); + } +} + +fn spawn_connect(label: String, connect: ConnectFn, tx: mpsc::Sender) { + tokio::spawn(async move { + let message = match connect().await { + Ok(result) => ActorMessage::Connected { result }, + Err(error) => ActorMessage::ConnectFailed { error }, + }; + + if tx.send(message).await.is_err() { + debug!( + "HTTP/2 client {} connect result dropped because actor stopped", + label + ); + } + }); +} + +fn spawn_driver( + label: String, + driver: BoxFuture<'static, Result<(), h2::Error>>, + tx: mpsc::Sender, +) { + tokio::spawn(async move { + let result = driver.await; + if tx + .send(ActorMessage::DriverFinished { result }) + .await + .is_err() + { + debug!( + "HTTP/2 client {} driver finished after actor stopped", + label + ); + } + }); +} diff --git a/crates/kube-sidecar-proxy/src/http2_proxy.rs b/crates/kube-sidecar-proxy/src/http2_proxy.rs new file mode 100644 index 0000000..667c91d --- /dev/null +++ b/crates/kube-sidecar-proxy/src/http2_proxy.rs @@ -0,0 +1,794 @@ +use std::{ + collections::HashMap, + io, + net::SocketAddr, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use bytes::Bytes; +use h2::{client, server, RecvStream, SendStream}; +use http::{ + header::{HeaderName, HeaderValue}, + HeaderMap, Request, Response, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + net::TcpStream, + sync::RwLock, +}; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +const MAX_HTTP2_FALLBACK_BODY_BYTES: usize = 4 * 1024 * 1024; + +use crate::{ + config::{DevboxConnection, RouteDecision, RoutingTable, SidecarSettings}, + http2_client::{ConnectResult, Http2ClientActor}, + otel_routing::{determine_routing_target, extract_routing_context, TRACESTATE_HEADER}, +}; + +/// Handle HTTP/2 traffic by establishing server and client handshakes. +/// +/// This initial version performs transparent proxying to the default local +/// destination. Future iterations will incorporate branch routing using HTTP/2 +/// header inspection. +#[allow(clippy::too_many_arguments)] +pub async fn handle_http2_proxy( + inbound_stream: TcpStream, + client_addr: SocketAddr, + original_dest: SocketAddr, + initial_data: Vec, + settings: Arc, + routing_table: Arc>, + _rpc_client: Arc>>, +) -> io::Result<()> { + let local_target = SocketAddr::new("127.0.0.1".parse().unwrap(), original_dest.port()); + + debug!( + "HTTP/2 proxy starting for {} -> {} (local target {})", + client_addr, original_dest, local_target + ); + + // Connect to the local target. Routing decisions will be applied per stream. + let outbound_stream = TcpStream::connect(&local_target).await?; + + // Replay any bytes already read during protocol detection before delegating + // the rest of the connection to `h2`. + let inbound_prefaced = PrefacedStream::new(inbound_stream, initial_data); + let mut inbound_connection = server::handshake(inbound_prefaced) + .await + .map_err(map_h2_err)?; + + let (mut outbound_sender, outbound_connection) = client::handshake(outbound_stream) + .await + .map_err(map_h2_err)?; + + // Drive the outbound connection in the background so response frames and + // settings are processed. + tokio::spawn(async move { + if let Err(err) = outbound_connection.await { + warn!("Outbound HTTP/2 connection ended with error: {}", err); + } + }); + + let _ = _rpc_client; + let environment_id = settings.environment_id; + + while let Some(result) = inbound_connection.accept().await { + let (request, mut respond) = match result { + Ok(stream) => stream, + Err(err) => { + warn!( + "HTTP/2 accept error for {} -> {}: {}", + client_addr, original_dest, err + ); + break; + } + }; + + let routing_table = Arc::clone(&routing_table); + + if let Err(err) = proxy_http2_stream( + &mut outbound_sender, + request, + &mut respond, + client_addr, + original_dest, + environment_id, + routing_table, + ) + .await + { + warn!( + "HTTP/2 stream proxy failure for {} -> {}: {}", + client_addr, original_dest, err + ); + break; + } + } + + Ok(()) +} + +async fn proxy_http2_stream( + outbound_sender: &mut client::SendRequest, + request: Request, + respond: &mut server::SendResponse, + client_addr: SocketAddr, + original_dest: SocketAddr, + default_environment_id: Uuid, + routing_table: Arc>, +) -> io::Result<()> { + let port = original_dest.port(); + let (mut parts, body) = request.into_parts(); + let mut body = ReplayableBody::new(body, MAX_HTTP2_FALLBACK_BODY_BYTES); + + let method = parts.method.clone(); + let path = parts.uri.path().to_string(); + let authority = parts + .uri + .authority() + .map(|auth| auth.to_string()) + .unwrap_or_default(); + + let header_pairs = header_map_to_vec(&parts.headers); + let routing_context = extract_routing_context(&header_pairs); + let branch_id = routing_context.lapdev_environment_id; + let target_environment = branch_id.unwrap_or(default_environment_id); + + ensure_environment_tracestate(&mut parts.headers, target_environment)?; + + let decision = { + let table = routing_table.read().await; + table.resolve_http(port, branch_id) + }; + + match decision { + RouteDecision::BranchService { service } => { + if let Some(service_name) = service.service_name_for_port(port) { + info!( + "HTTP/2 {} {} (authority {}) from {} routing to branch {:?} service {}:{}", + method, path, authority, client_addr, branch_id, service_name, port + ); + + let http2_clients = Arc::clone(&service.http2_clients); + if let Err(err) = proxy_http2_branch_service( + service_name, + port, + http2_clients, + &parts, + &mut body, + respond, + ) + .await + { + warn!( + "HTTP/2 branch route {} for env {:?} failed: {}; falling back to shared target", + service_name, branch_id, err + ); + if !body.supports_retry() { + warn!( + "HTTP/2 request from {} {} cannot be replayed for fallback", + method, path + ); + return Err(io::Error::new( + io::ErrorKind::Other, + "request body cannot be replayed for fallback", + )); + } + } else { + return Ok(()); + } + } else { + warn!( + "Missing branch service mapping for port {} in env {:?}; using shared target", + port, branch_id + ); + } + } + RouteDecision::BranchDevbox { + connection, + target_port, + } => { + let metadata = connection.metadata().clone(); + info!( + "HTTP/2 {} {} (authority {}) from {} intercepted by branch devbox (env {:?}, intercept_id={}, session_id={}, target_port={})", + method, + path, + authority, + client_addr, + branch_id, + metadata.intercept_id, + metadata.session_id, + target_port + ); + + if let Err(err) = proxy_http2_devbox( + Arc::clone(&connection), + target_port, + &parts, + &mut body, + respond, + ) + .await + { + warn!( + "HTTP/2 branch devbox route intercept_id={} failed: {}; falling back to shared target", + metadata.intercept_id, err + ); + connection.clear_client().await; + if !body.supports_retry() { + warn!( + "HTTP/2 request from {} {} cannot be replayed for fallback", + method, path + ); + return Err(io::Error::new( + io::ErrorKind::Other, + "request body cannot be replayed for fallback", + )); + } + } else { + return Ok(()); + } + } + RouteDecision::DefaultDevbox { + connection, + target_port, + } => { + let metadata = connection.metadata().clone(); + info!( + "HTTP/2 {} {} (authority {}) from {} intercepted by shared devbox (port {}, intercept_id={}, session_id={}, target_port={})", + method, + path, + authority, + client_addr, + port, + metadata.intercept_id, + metadata.session_id, + target_port + ); + + if let Err(err) = proxy_http2_devbox( + Arc::clone(&connection), + target_port, + &parts, + &mut body, + respond, + ) + .await + { + warn!( + "HTTP/2 shared devbox route intercept_id={} failed: {}; falling back to shared target", + metadata.intercept_id, err + ); + connection.clear_client().await; + if !body.supports_retry() { + warn!( + "HTTP/2 request from {} {} cannot be replayed for fallback", + method, path + ); + return Err(io::Error::new( + io::ErrorKind::Other, + "request body cannot be replayed for fallback", + )); + } + } else { + return Ok(()); + } + } + RouteDecision::DefaultLocal => {} + } + + let routing_target = determine_routing_target(&routing_context, port); + info!( + "HTTP/2 {} {} (authority {}) from {} -> shared target (routing: {}, trace_id: {:?})", + method, + path, + authority, + client_addr, + routing_target.get_metadata(), + routing_context.trace_context.trace_id + ); + + forward_http2_via_sender(outbound_sender, &parts, &mut body, respond).await +} + +async fn forward_http2_via_sender( + sender: &mut client::SendRequest, + parts: &http::request::Parts, + body: &mut ReplayableBody, + respond: &mut server::SendResponse, +) -> io::Result<()> { + let outbound_request = build_outbound_request(parts)?; + let end_of_stream = body.is_end_stream(); + + let mut ready_sender = sender.clone().ready().await.map_err(map_h2_err)?; + + let (response_future, mut outbound_stream) = ready_sender + .send_request(outbound_request, end_of_stream) + .map_err(map_h2_err)?; + *sender = ready_sender; + + if !end_of_stream { + body.forward(&mut outbound_stream).await?; + } + + let response = response_future.await.map_err(map_h2_err)?; + let (response_parts, mut response_body) = response.into_parts(); + let response_end_of_stream = response_body.is_end_stream(); + let head = Response::from_parts(response_parts, ()); + let mut inbound_stream = respond + .send_response(head, response_end_of_stream) + .map_err(map_h2_err)?; + + if !response_end_of_stream { + forward_response_body(&mut response_body, &mut inbound_stream).await?; + } + + Ok(()) +} + +enum ReplayableBodyState { + Streaming(RecvStream), + Buffered, + Unbuffered, +} + +struct ReplayableBody { + state: ReplayableBodyState, + buffered_chunks: Vec, + buffered_trailers: Option, + initial_end_of_stream: bool, + max_buffer_bytes: usize, + total_buffered_bytes: usize, + replayable: bool, + overflow_warned: bool, +} + +impl ReplayableBody { + fn new(stream: RecvStream, max_buffer_bytes: usize) -> Self { + let initial_end_of_stream = stream.is_end_stream(); + Self { + state: ReplayableBodyState::Streaming(stream), + buffered_chunks: Vec::new(), + buffered_trailers: None, + initial_end_of_stream, + max_buffer_bytes, + total_buffered_bytes: 0, + replayable: true, + overflow_warned: false, + } + } + + fn is_end_stream(&self) -> bool { + self.initial_end_of_stream + } + + fn supports_retry(&self) -> bool { + if self.initial_end_of_stream { + return true; + } + + if !self.replayable { + return false; + } + + !matches!(self.state, ReplayableBodyState::Unbuffered) + } + + async fn forward(&mut self, outbound_stream: &mut SendStream) -> io::Result<()> { + if self.initial_end_of_stream { + return Ok(()); + } + + for chunk in &self.buffered_chunks { + outbound_stream + .send_data(chunk.clone(), false) + .map_err(map_h2_err)?; + } + + match &mut self.state { + ReplayableBodyState::Streaming(body) => { + let mut trailers = None; + { + let body_ref = body; + while let Some(chunk_result) = body_ref.data().await { + let data = chunk_result.map_err(map_h2_err)?; + let len = data.len(); + + if self.replayable { + let attempted_total = self.total_buffered_bytes + len; + if attempted_total > self.max_buffer_bytes { + self.replayable = false; + self.buffered_chunks.clear(); + self.buffered_trailers = None; + self.total_buffered_bytes = 0; + if !self.overflow_warned { + warn!( + "HTTP/2 request body exceeded replay buffer ({} bytes > {}); fallback replay disabled", + attempted_total, + self.max_buffer_bytes + ); + self.overflow_warned = true; + } + } else { + self.total_buffered_bytes += len; + self.buffered_chunks.push(data.clone()); + } + } + + outbound_stream.send_data(data, false).map_err(map_h2_err)?; + if let Err(err) = body_ref.flow_control().release_capacity(len) { + warn!("Failed to release HTTP/2 request capacity: {}", err); + } + } + + trailers = body_ref.trailers().await.map_err(map_h2_err)?; + } + + if let Some(trailers) = trailers { + if self.replayable { + self.buffered_trailers = Some(trailers.clone()); + } + outbound_stream + .send_trailers(trailers) + .map_err(map_h2_err)?; + } else { + outbound_stream + .send_data(Bytes::new(), true) + .map_err(map_h2_err)?; + } + + self.state = if self.replayable { + ReplayableBodyState::Buffered + } else { + ReplayableBodyState::Unbuffered + }; + + Ok(()) + } + ReplayableBodyState::Buffered => { + if let Some(trailers) = &self.buffered_trailers { + outbound_stream + .send_trailers(trailers.clone()) + .map_err(map_h2_err)?; + } else { + outbound_stream + .send_data(Bytes::new(), true) + .map_err(map_h2_err)?; + } + Ok(()) + } + ReplayableBodyState::Unbuffered => Err(io::Error::new( + io::ErrorKind::Other, + "request body not replayable", + )), + } + } +} + +async fn proxy_http2_branch_service( + service_name: &str, + port: u16, + clients: Arc>>>, + parts: &http::request::Parts, + body: &mut ReplayableBody, + respond: &mut server::SendResponse, +) -> io::Result<()> { + let client = { + let guard = clients.read().await; + if let Some(existing) = guard.get(&port) { + Arc::clone(existing) + } else { + drop(guard); + + let service_name_arc = Arc::new(service_name.to_string()); + let label = format!("branch-http2:{}:{}", service_name, port); + let connect = Http2ClientActor::connector({ + let service_name = Arc::clone(&service_name_arc); + move || { + let service_name = Arc::clone(&service_name); + async move { + let branch_stream = + TcpStream::connect((service_name.as_str(), port)).await?; + let (sender, connection) = + client::handshake(branch_stream).await.map_err(map_h2_err)?; + let driver = Box::pin(async move { connection.await }); + Ok(ConnectResult::new(sender, driver)) + } + } + }); + + let mut guard = clients.write().await; + guard + .entry(port) + .or_insert_with(|| Http2ClientActor::spawn(label, connect)) + .clone() + } + }; + + let mut lease = match client.acquire().await { + Ok(lease) => lease, + Err(err) => { + warn!( + "Branch HTTP/2 acquire for {}:{} failed: {}", + service_name, port, err + ); + remove_branch_http2_client(&clients, port).await; + return Err(err); + } + }; + + match forward_http2_via_sender(lease.sender_mut(), parts, body, respond).await { + Ok(()) => Ok(()), + Err(err) => { + lease.mark_broken(); + warn!( + "HTTP/2 branch proxy to {}:{} failed mid-stream: {}", + service_name, port, err + ); + remove_branch_http2_client(&clients, port).await; + Err(err) + } + } +} + +async fn remove_branch_http2_client( + clients: &Arc>>>, + port: u16, +) { + let client = { + let mut guard = clients.write().await; + guard.remove(&port) + }; + + if let Some(client) = client { + client.shutdown().await; + } +} + +async fn proxy_http2_devbox( + connection: Arc, + target_port: u16, + parts: &http::request::Parts, + body: &mut ReplayableBody, + respond: &mut server::SendResponse, +) -> io::Result<()> { + let metadata = Arc::new(connection.metadata().clone()); + + let client = connection + .get_or_create_http2_client(target_port, { + let connection = Arc::clone(&connection); + let metadata = Arc::clone(&metadata); + move || { + let connection = Arc::clone(&connection); + let metadata = Arc::clone(&metadata); + let label = format!( + "devbox-http2:{}:{}", + metadata.intercept_id, target_port + ); + let connect = Http2ClientActor::connector(move || { + let connection = Arc::clone(&connection); + let metadata = Arc::clone(&metadata); + async move { + let devbox_stream = match connection + .connect_tcp_stream("127.0.0.1", target_port) + .await + { + Ok(stream) => stream, + Err(err) => { + warn!( + "Failed to connect to devbox intercept_id={} target_port={}: {}", + metadata.intercept_id, target_port, err + ); + connection.clear_client().await; + return Err(io::Error::from(err)); + } + }; + + let (sender, devbox_connection) = + match client::handshake(devbox_stream).await { + Ok(pair) => pair, + Err(err) => { + warn!( + "HTTP/2 handshake with devbox intercept_id={} failed: {}", + metadata.intercept_id, err + ); + connection.clear_client().await; + return Err(map_h2_err(err)); + } + }; + + let driver = Box::pin(async move { devbox_connection.await }); + Ok(ConnectResult::new(sender, driver)) + } + }); + + Http2ClientActor::spawn(label, connect) + } + }) + .await; + + let mut lease = match client.acquire().await { + Ok(lease) => lease, + Err(err) => { + warn!( + "Devbox HTTP/2 sender unavailable for intercept_id={}, removing pooled client: {}", + metadata.intercept_id, err + ); + connection.remove_http2_client(target_port).await; + return Err(err); + } + }; + + match forward_http2_via_sender(lease.sender_mut(), parts, body, respond).await { + Ok(()) => Ok(()), + Err(err) => { + lease.mark_broken(); + warn!( + "HTTP/2 proxy via devbox intercept_id={} failed mid-stream: {}", + metadata.intercept_id, err + ); + connection.remove_http2_client(target_port).await; + Err(err) + } + } +} + +fn build_outbound_request(parts: &http::request::Parts) -> io::Result> { + let mut builder = Request::builder() + .method(&parts.method) + .uri(&parts.uri) + .version(parts.version); + + for (name, value) in parts.headers.iter() { + builder = builder.header(name, value); + } + + builder + .body(()) + .map_err(|err| io::Error::new(io::ErrorKind::Other, err)) +} + +async fn forward_response_body( + body: &mut RecvStream, + inbound_stream: &mut SendStream, +) -> io::Result<()> { + while let Some(chunk) = body.data().await { + let data = chunk.map_err(map_h2_err)?; + let len = data.len(); + inbound_stream.send_data(data, false).map_err(map_h2_err)?; + if let Err(err) = body.flow_control().release_capacity(len) { + warn!("Failed to release HTTP/2 response capacity: {}", err); + } + } + + if let Some(trailers) = body.trailers().await.map_err(map_h2_err)? { + inbound_stream.send_trailers(trailers).map_err(map_h2_err)?; + } else { + inbound_stream + .send_data(Bytes::new(), true) + .map_err(map_h2_err)?; + } + + Ok(()) +} + +fn header_map_to_vec(headers: &HeaderMap) -> Vec<(String, String)> { + headers + .iter() + .map(|(name, value)| { + let value_str = value + .to_str() + .map(|s| s.to_string()) + .unwrap_or_else(|_| String::from_utf8_lossy(value.as_bytes()).to_string()); + (name.as_str().to_string(), value_str) + }) + .collect() +} + +fn ensure_environment_tracestate(headers: &mut HeaderMap, environment_id: Uuid) -> io::Result<()> { + let env_id_str = environment_id.to_string(); + let target_entry = format!("lapdev-env-id={}", env_id_str); + + if let Some(existing_value) = headers.get(TRACESTATE_HEADER) { + let existing = existing_value + .to_str() + .map(|s| s.to_string()) + .unwrap_or_else(|_| String::from_utf8_lossy(existing_value.as_bytes()).to_string()); + + let already_present = existing.split(',').any(|entry| { + let trimmed = entry.trim(); + if let Some((key, value)) = trimmed.split_once('=') { + key.eq_ignore_ascii_case("lapdev-env-id") + && value.trim().eq_ignore_ascii_case(&env_id_str) + } else { + false + } + }); + + if already_present { + return Ok(()); + } + + let new_value = if existing.trim().is_empty() { + target_entry + } else { + format!("{},{}", existing.trim(), target_entry) + }; + + let header_value = HeaderValue::from_str(&new_value) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + headers.insert(HeaderName::from_static(TRACESTATE_HEADER), header_value); + } else { + let header_value = HeaderValue::from_str(&target_entry) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + headers.insert(HeaderName::from_static(TRACESTATE_HEADER), header_value); + } + + Ok(()) +} + +fn map_h2_err(err: h2::Error) -> io::Error { + io::Error::new(io::ErrorKind::Other, err) +} + +struct PrefacedStream { + buffer: Vec, + position: usize, + inner: T, +} + +impl PrefacedStream { + fn new(inner: T, buffer: Vec) -> Self { + Self { + buffer, + position: 0, + inner, + } + } +} + +impl AsyncRead for PrefacedStream +where + T: AsyncRead + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.position < self.buffer.len() { + let remaining = self.buffer.len() - self.position; + let bytes_to_copy = remaining.min(buf.remaining()); + buf.put_slice(&self.buffer[self.position..self.position + bytes_to_copy]); + self.position += bytes_to_copy; + Poll::Ready(Ok(())) + } else { + Pin::new(&mut self.inner).poll_read(cx, buf) + } + } +} + +impl AsyncWrite for PrefacedStream +where + T: AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} diff --git a/crates/kube-sidecar-proxy/src/lib.rs b/crates/kube-sidecar-proxy/src/lib.rs index 616cbf9..3d0feaa 100644 --- a/crates/kube-sidecar-proxy/src/lib.rs +++ b/crates/kube-sidecar-proxy/src/lib.rs @@ -1,5 +1,7 @@ pub mod config; pub mod error; +pub mod http2_client; +pub mod http2_proxy; pub mod original_dest; pub mod otel_routing; pub mod protocol_detector; diff --git a/crates/kube-sidecar-proxy/src/protocol_detector.rs b/crates/kube-sidecar-proxy/src/protocol_detector.rs index 42a5a8d..1acf48d 100644 --- a/crates/kube-sidecar-proxy/src/protocol_detector.rs +++ b/crates/kube-sidecar-proxy/src/protocol_detector.rs @@ -1,63 +1,131 @@ -use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::{ + io::{AsyncRead, AsyncReadExt}, + time::{timeout, Duration, Instant}, +}; use tracing::debug; -/// HTTP methods that we recognize -const HTTP_METHODS: &[&[u8]] = &[ - b"GET ", - b"POST ", - b"PUT ", - b"DELETE ", - b"HEAD ", - b"OPTIONS ", - b"PATCH ", - b"TRACE ", - b"CONNECT ", -]; +/// Maximum bytes to read while attempting to detect HTTP. +const MAX_DETECTION_BYTES: usize = 8192; + +/// HTTP/2 client preface sent at the start of cleartext connections. +const HTTP2_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; /// Result of protocol detection #[derive(Debug, Clone)] pub enum ProtocolType { Http { method: String, path: String }, + Http2, Tcp, } -/// Detect if incoming data looks like HTTP -pub async fn detect_protocol(reader: &mut R) -> std::io::Result<(ProtocolType, Vec)> +/// Result of protocol detection attempt. +pub struct ProtocolDetectionResult { + pub protocol: ProtocolType, + pub buffer: Vec, + pub timed_out: bool, +} + +/// Detect if incoming data looks like HTTP, with an optional timeout budget. +pub async fn detect_protocol( + reader: &mut R, + timeout_duration: Option, +) -> std::io::Result where R: AsyncRead + Unpin, { - let mut buffer = vec![0u8; 1024]; // Read up to 1KB to detect protocol - let bytes_read = reader.read(&mut buffer).await?; + let mut buffer = Vec::with_capacity(1024); + let mut timed_out = false; + let deadline = timeout_duration.map(|d| Instant::now() + d); + + loop { + if buffer.len() >= MAX_DETECTION_BYTES { + break; + } + + let mut chunk = [0u8; 1024]; + let read_result = if let Some(deadline) = deadline { + let now = Instant::now(); + if now >= deadline { + timed_out = true; + break; + } + let remaining = deadline - now; + match timeout(remaining, reader.read(&mut chunk)).await { + Ok(result) => result, + Err(_) => { + timed_out = true; + break; + } + } + } else { + reader.read(&mut chunk).await + }; + + let bytes_read = match read_result { + Ok(n) => n, + Err(e) => return Err(e), + }; + + if bytes_read == 0 { + break; + } + + buffer.extend_from_slice(&chunk[..bytes_read]); + + // Stop once we've seen the end of the first line. + if buffer.iter().any(|&b| b == b'\n') { + break; + } + } - if bytes_read == 0 { - return Ok((ProtocolType::Tcp, buffer)); + if buffer.is_empty() { + return Ok(ProtocolDetectionResult { + protocol: ProtocolType::Tcp, + buffer, + timed_out, + }); } - buffer.truncate(bytes_read); + // Check if it starts with the HTTP/2 client connection preface + if buffer.starts_with(HTTP2_PREFACE) + || (buffer.len() < HTTP2_PREFACE.len() && HTTP2_PREFACE.starts_with(&buffer)) + // partial preface + { + debug!("Detected HTTP/2 protocol preface"); + return Ok(ProtocolDetectionResult { + protocol: ProtocolType::Http2, + buffer, + timed_out, + }); + } - // Check if it starts with an HTTP method + // Check if it starts with an HTTP method (HTTP/1.x) if let Some(protocol) = detect_http_in_buffer(&buffer) { debug!("Detected HTTP protocol: {:?}", protocol); - Ok((protocol, buffer)) + Ok(ProtocolDetectionResult { + protocol, + buffer, + timed_out, + }) } else { debug!("Detected TCP protocol (not HTTP)"); - Ok((ProtocolType::Tcp, buffer)) + Ok(ProtocolDetectionResult { + protocol: ProtocolType::Tcp, + buffer, + timed_out, + }) } } /// Check if the buffer contains HTTP request data fn detect_http_in_buffer(buffer: &[u8]) -> Option { - // Check if buffer starts with known HTTP method - for &method_bytes in HTTP_METHODS { - if buffer.starts_with(method_bytes) { - // Try to parse the HTTP request line - if let Some(request_line) = get_request_line(buffer) { - if let Some((method, path)) = parse_request_line(&request_line) { - return Some(ProtocolType::Http { - method: method.to_string(), - path: path.to_string(), - }); - } + if let Some(request_line) = get_request_line(buffer) { + if let Some((method, path)) = parse_request_line(&request_line) { + if method.chars().all(|c| c.is_ascii_uppercase()) { + return Some(ProtocolType::Http { + method: method.to_string(), + path: path.to_string(), + }); } } } @@ -87,6 +155,12 @@ fn parse_request_line(line: &str) -> Option<(String, String)> { #[cfg(test)] mod tests { use super::*; + use std::{ + io, + pin::Pin, + task::{Context, Poll}, + }; + use tokio::io::{AsyncRead, ReadBuf}; #[test] fn test_detect_http_get() { @@ -132,4 +206,86 @@ mod tests { assert!(result.is_none()); } + + #[tokio::test] + async fn test_detect_http2_preface() { + let buffer = super::HTTP2_PREFACE; + let detection = super::detect_protocol(&mut &buffer[..], None) + .await + .unwrap(); + + match detection.protocol { + ProtocolType::Http2 => {} + _ => panic!("Expected HTTP/2 detection"), + } + } + + #[tokio::test] + async fn test_detect_http_uncommon_verb() { + let request = b"PROPFIND /calendar HTTP/1.1\r\nHost: example.com\r\n\r\n"; + let detection = detect_protocol(&mut std::io::Cursor::new(request), None) + .await + .unwrap(); + + match detection.protocol { + ProtocolType::Http { method, path } => { + assert_eq!(method, "PROPFIND"); + assert_eq!(path, "/calendar"); + } + _ => panic!("Expected HTTP detection for PROPFIND"), + } + } + + #[tokio::test] + async fn test_detect_http_long_first_line() { + let long_path = format!("/{}", "a".repeat(5000)); + let request = format!( + "GET {} HTTP/1.1\r\nHost: example.com\r\nUser-Agent: test\r\n\r\n", + long_path + ); + let mut reader = ChunkedReader::new(request.into_bytes(), 512); + let detection = detect_protocol(&mut reader, None).await.unwrap(); + + match detection.protocol { + ProtocolType::Http { method, path } => { + assert_eq!(method, "GET"); + assert_eq!(path, long_path); + } + _ => panic!("Expected HTTP detection for long request line"), + } + } + + struct ChunkedReader { + data: Vec, + position: usize, + chunk_size: usize, + } + + impl ChunkedReader { + fn new(data: Vec, chunk_size: usize) -> Self { + Self { + data, + position: 0, + chunk_size, + } + } + } + + impl AsyncRead for ChunkedReader { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.position >= self.data.len() { + return Poll::Ready(Ok(())); + } + + let end = (self.position + self.chunk_size).min(self.data.len()); + let slice = &self.data[self.position..end]; + buf.put_slice(slice); + self.position = end; + Poll::Ready(Ok(())) + } + } } diff --git a/crates/kube-sidecar-proxy/src/rpc.rs b/crates/kube-sidecar-proxy/src/rpc.rs index 1564516..b560cb1 100644 --- a/crates/kube-sidecar-proxy/src/rpc.rs +++ b/crates/kube-sidecar-proxy/src/rpc.rs @@ -3,14 +3,13 @@ use lapdev_kube_rpc::{ DevboxRouteConfig, ProxyBranchRouteConfig, ProxyRouteAccessLevel, SidecarProxyManagerRpcClient, SidecarProxyRpc, }; -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use tokio::sync::RwLock; use tracing::{info, warn}; use uuid::Uuid; use crate::config::{ - AccessLevel, BranchMode, BranchServiceRoute, DevboxConnection, DevboxRouteMetadata, - RoutingTable, + AccessLevel, BranchServiceRoute, DevboxConnection, DevboxRouteMetadata, RoutingTable, }; #[derive(Clone)] @@ -81,6 +80,7 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { requires_auth: route.requires_auth, access_level: access_level_from_proxy(route.access_level), timeout_ms: route.timeout_ms, + http2_clients: Arc::new(RwLock::new(HashMap::new())), }; updates.push((branch_id, service_route)); } @@ -90,7 +90,7 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { } } - routing_table.replace_branch_routes(updates); + routing_table.replace_branch_routes(updates).await; for (branch_id, devbox) in devbox_overrides { if !routing_table.set_branch_devbox(&branch_id, devbox) { @@ -201,12 +201,15 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { requires_auth: route.requires_auth, access_level: access_level_from_proxy(route.access_level), timeout_ms: route.timeout_ms, + http2_clients: Arc::new(RwLock::new(HashMap::new())), }; let devbox_override = route.devbox_route.map(devbox_connection_from_config); let mut routing_table = self.routing_table.write().await; - routing_table.upsert_branch_service_route(branch_id, service_route); + routing_table + .upsert_branch_service_route(branch_id, service_route) + .await; if let Some(connection) = devbox_override { routing_table.set_branch_devbox(&branch_id, connection); @@ -226,7 +229,10 @@ impl SidecarProxyRpc for SidecarProxyRpcServer { branch_environment_id: Uuid, ) -> Result<(), String> { let mut routing_table = self.routing_table.write().await; - if routing_table.remove_branch_service_route(&branch_environment_id) { + if routing_table + .remove_branch_service_route(&branch_environment_id) + .await + { info!( "Removed branch service route for branch {} on workload {}", branch_environment_id, self.workload_id diff --git a/crates/kube-sidecar-proxy/src/server.rs b/crates/kube-sidecar-proxy/src/server.rs index a0d700a..8f693fb 100644 --- a/crates/kube-sidecar-proxy/src/server.rs +++ b/crates/kube-sidecar-proxy/src/server.rs @@ -1,9 +1,10 @@ use crate::{ - config::{BranchServiceRoute, DevboxConnection, RouteDecision, RoutingTable, SidecarSettings}, + config::{DevboxConnection, RouteDecision, RoutingTable, SidecarSettings}, error::Result, + http2_proxy::handle_http2_proxy, original_dest::get_original_destination, otel_routing::{determine_routing_target, extract_routing_context}, - protocol_detector::{detect_protocol, ProtocolType}, + protocol_detector::{detect_protocol, ProtocolDetectionResult, ProtocolType}, rpc::SidecarProxyRpcServer, }; use anyhow::anyhow; @@ -17,6 +18,7 @@ use tokio::{ io::copy_bidirectional, net::{TcpListener, TcpStream}, sync::RwLock, + time::{sleep, Duration}, }; use tracing::{debug, error, info, warn}; use uuid::Uuid; @@ -109,6 +111,7 @@ impl SidecarProxyServer { // Handle connections let routing_table_for_server = Arc::clone(&self.routing_table); let rpc_client_for_server = Arc::clone(&self.rpc_client); + let settings_for_server = Arc::clone(&self.settings); let server = async move { loop { match listener.accept().await { @@ -116,11 +119,13 @@ impl SidecarProxyServer { debug!("Accepted connection from {}", client_addr); let routing_table = Arc::clone(&routing_table_for_server); let rpc_client = Arc::clone(&rpc_client_for_server); + let settings = Arc::clone(&settings_for_server); tokio::spawn(async move { if let Err(e) = handle_connection( inbound_stream, client_addr, + settings, routing_table, rpc_client, ) @@ -130,10 +135,32 @@ impl SidecarProxyServer { } }); } - Err(e) => { - error!("Failed to accept connection: {}", e); - break; - } + Err(e) => match e.kind() { + io::ErrorKind::ConnectionAborted + | io::ErrorKind::ConnectionReset + | io::ErrorKind::Interrupted => { + warn!("Transient accept error: {}", e); + continue; + } + _ => { + if let Some(raw_os_error) = e.raw_os_error() { + const EMFILE: i32 = 24; + const WSAEMFILE: i32 = 10024; + + if raw_os_error == EMFILE || raw_os_error == WSAEMFILE { + error!( + "File descriptor limit hit while accepting connection: {} (errno={}), backing off before retrying", + e, raw_os_error + ); + sleep(Duration::from_millis(100)).await; + continue; + } + } + + error!("Failed to accept connection: {}", e); + break; + } + }, } } }; @@ -252,6 +279,7 @@ impl SidecarProxyServer { async fn handle_connection( mut inbound_stream: TcpStream, client_addr: SocketAddr, + settings: Arc, routing_table: Arc>, _rpc_client: Arc>>, ) -> io::Result<()> { @@ -269,8 +297,9 @@ async fn handle_connection( debug!("Original destination: {} -> {}", client_addr, original_dest); - // Detect protocol by reading initial data - let (protocol_type, initial_data) = match detect_protocol(&mut inbound_stream).await { + // Detect protocol by reading initial data with a bounded timeout + let detection = detect_protocol(&mut inbound_stream, Some(Duration::from_secs(10))).await; + let detection = match detection { Ok(result) => result, Err(e) => { warn!("Failed to detect protocol for {}: {}", client_addr, e); @@ -278,6 +307,19 @@ async fn handle_connection( } }; + let ProtocolDetectionResult { + protocol: protocol_type, + buffer: initial_data, + timed_out, + } = detection; + + if timed_out { + debug!( + "Protocol detection for {} timed out after 10s; falling back to {:?}", + client_addr, protocol_type + ); + } + match protocol_type { ProtocolType::Http { .. } => { handle_http_proxy( @@ -285,6 +327,19 @@ async fn handle_connection( client_addr, original_dest, initial_data, + Arc::clone(&settings), + Arc::clone(&routing_table), + Arc::clone(&_rpc_client), + ) + .await + } + ProtocolType::Http2 => { + handle_http2_proxy( + inbound_stream, + client_addr, + original_dest, + initial_data, + settings, Arc::clone(&routing_table), Arc::clone(&_rpc_client), ) @@ -344,6 +399,7 @@ async fn handle_http_proxy( client_addr: SocketAddr, original_dest: SocketAddr, mut initial_data: Vec, + _settings: Arc, routing_table: Arc>, _rpc_client: Arc>>, ) -> io::Result<()> { @@ -589,7 +645,7 @@ async fn handle_devbox_tunnel( "Failed to establish tunnel stream for intercept {}: {}", metadata.intercept_id, err ); - connection.clear_client(); + connection.clear_client().await; return Err(io::Error::from(err)); } }; @@ -610,7 +666,7 @@ async fn handle_devbox_tunnel( "Devbox tunnel error for intercept_id={}: {}", metadata.intercept_id, err ); - connection.clear_client(); + connection.clear_client().await; return Err(io::Error::from(err)); } } diff --git a/crates/kube/src/http_proxy.rs b/crates/kube/src/http_proxy.rs index 899672f..bd4e4a2 100644 --- a/crates/kube/src/http_proxy.rs +++ b/crates/kube/src/http_proxy.rs @@ -1,8 +1,5 @@ use anyhow::Result; -use axum::{ - http::StatusCode, - response::IntoResponse, -}; +use axum::{http::StatusCode, response::IntoResponse}; use std::{io, sync::Arc}; use tokio::{ io::{copy_bidirectional, AsyncWriteExt}, @@ -318,8 +315,8 @@ impl PreviewUrlProxy { } fn user_id_from_token(&self, token: &str) -> Result { - let untrusted = - UntrustedToken::try_from(token).map_err(|_| ProxyError::Forbidden("Invalid authentication token".to_string()))?; + let untrusted = UntrustedToken::try_from(token) + .map_err(|_| ProxyError::Forbidden("Invalid authentication token".to_string()))?; let trusted = local::decrypt( self.auth_token_key.as_ref(), @@ -328,7 +325,7 @@ impl PreviewUrlProxy { None, None, ) - .map_err(|_| ProxyError::Forbidden("Invalid authentication token".to_string()))?; + .map_err(|_| ProxyError::Forbidden("Invalid authentication token".to_string()))?; let claims = trusted .payload_claims() diff --git a/crates/kube/src/server.rs b/crates/kube/src/server.rs index 1ae92ad..42498bb 100644 --- a/crates/kube/src/server.rs +++ b/crates/kube/src/server.rs @@ -285,6 +285,7 @@ impl KubeClusterRpc for KubeClusterServer { self.cluster_id, Some(cluster_info.cluster_version), Some(status_str), + cluster_info.provider, cluster_info.region, ) .await diff --git a/docs/README.md b/docs/README.md index b41a273..055a8af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,12 +20,12 @@ Lapdev reads your **production Kubernetes manifests directly from your cluster** **Your production manifests become the single source of truth** - no duplicate YAML files to maintain, no config drift between prod and dev. -### Automatic Sync with Production +### Stay in Sync with Production * Lapdev continuously monitors your production manifests for changes * App Catalogs automatically update when their source workloads change -* All environments created from the catalog use the latest configuration -* **Never again:** "My dev environment is 3 months behind prod" +* Environments **notify you when updates are available** - sync with one click when you're ready +* No surprise interruptions during development - you control when to pull in changes ### Flexible Environment Models diff --git a/docs/core-concepts/architecture/README.md b/docs/core-concepts/architecture/README.md index 1842ef7..e65ce8f 100644 --- a/docs/core-concepts/architecture/README.md +++ b/docs/core-concepts/architecture/README.md @@ -22,7 +22,7 @@ Lapdev consists of three main components: The Lapdev cloud service handles: -* **User authentication and authorization** - GitHub/Google OAuth, team management +* **User authentication and authorization** - GitHub/GitLab OAuth, team management * **Environment orchestration** - Receives environment creation requests from users * **Secure tunnel management** - Establishes websocket tunnels between your cluster and Lapdev * **[Preview URL](../preview-url.md) routing** - Routes traffic from automatically generated HTTPS URLs to your cluster @@ -31,7 +31,9 @@ The Lapdev cloud service handles: * Communicates with your cluster via secure websocket tunnels (TLS encrypted) * No direct access to your cluster's API server -* Cannot read your application data or secrets +* Receives workload manifests (Deployments, StatefulSets, ConfigMaps, Secrets) from lapdev-kube-manager to build App Catalogs +* You control which workloads Lapdev can access through App Catalog selection +* No access to runtime application data (databases, logs, persistent volumes) #### Lapdev-Kube-Manager (In Your Cluster) diff --git a/docs/core-concepts/environment.md b/docs/core-concepts/environment.md index cb9551a..cf68dc6 100644 --- a/docs/core-concepts/environment.md +++ b/docs/core-concepts/environment.md @@ -12,7 +12,9 @@ When you create an environment: 1. Select an existing App Catalog that defines your app's workloads 2. Choose an environment type (Personal, Shared, or Branch) -3. Lapdev deploys the catalog's workloads into a dedicated namespace +3. Lapdev deploys the workloads: + - Personal/Shared: into a dedicated namespace + - Branch: into the shared base environment's namespace 4. ConfigMaps, Secrets, and Services are automatically included The result: a fully functional copy of your app that mirrors production exactly, with zero manual YAML management. @@ -129,7 +131,7 @@ When requests come in through a Preview URL in a branch environment, Lapdev auto Lapdev Kubernetes Environments let you: * Reproduce production exactly, without manual setup -* Keep all environments automatically synced with production +* Stay notified when production changes - sync with one click when you're ready * Choose between **full isolation**, **team-wide sharing**, or **cost-efficient branching** * Use **Devbox** for fast, local-style development in Kubernetes * Collaborate efficiently with consistent, shareable environments diff --git a/docs/core-concepts/preview-url.md b/docs/core-concepts/preview-url.md index 810127a..c03a558 100644 --- a/docs/core-concepts/preview-url.md +++ b/docs/core-concepts/preview-url.md @@ -33,13 +33,12 @@ Each Preview URL is unique and automatically managed by Lapdev, making it safe t ### Access Control -By default, Preview URLs are public, but Lapdev allows optional access control: +Preview URLs default to **Organization** access, but you can configure per Preview URL: -* **Authenticated access only:** Only Lapdev users in your organization can open the URL. -* **Public preview:** Anyone with the link can view it. -* **Custom rules (coming soon):** Integrate with your identity provider for fine-grained access policies. +* **Organization:** Only members of your Lapdev organization can access (after login) +* **Public:** Anyone with the link can access without authentication -Access settings are managed per environment in the Lapdev dashboard. +Access settings are managed per Preview URL in the Lapdev dashboard. ### Benefits diff --git a/docs/how-to-guides/create-lapdev-environment.md b/docs/how-to-guides/create-lapdev-environment.md index 4e3f30b..487834a 100644 --- a/docs/how-to-guides/create-lapdev-environment.md +++ b/docs/how-to-guides/create-lapdev-environment.md @@ -57,7 +57,7 @@ After creation: * Lapdev automatically provisions: * All workloads from the App Catalog * Associated ConfigMaps, Secrets, and Services - * Unique HTTPS preview URLs for accessing your services + * Kubernetes namespace and networking You can monitor status, logs, and sync state directly from the dashboard. @@ -65,6 +65,6 @@ You can monitor status, logs, and sync state directly from the dashboard. Your environment is ready! You can now: +* Create [Preview URLs](use-preview-urls.md) for HTTPS access to your services * Use [Devbox](local-development-with-devbox.md) to connect locally for live debugging -* Create [Preview URLs](use-preview-urls.md) to share your work * Learn more about [Environments](../core-concepts/environment.md) From 232e81982a7a3b27221b16d666f4faf886007309 Mon Sep 17 00:00:00 2001 From: Lu Yang Date: Wed, 29 Oct 2025 20:36:22 +0000 Subject: [PATCH 178/334] update --- .env.example | 15 + Cargo.lock | 6 +- Cargo.toml | 2 + crates/api/src/cert.rs | 1 + crates/api/src/kube_controller/cluster.rs | 2 + .../src/kube_controller/container_images.rs | 34 ++ crates/api/src/kube_controller/deployment.rs | 480 +++++++++++++++--- crates/api/src/kube_controller/environment.rs | 5 +- crates/api/src/kube_controller/mod.rs | 2 + crates/api/src/kube_controller/preview_url.rs | 8 +- crates/api/src/router.rs | 177 ++++++- crates/api/src/server.rs | 351 +++---------- crates/api/src/state.rs | 20 +- crates/cli/Cargo.toml | 1 + crates/cli/src/config.rs | 15 + crates/cli/src/devbox/mod.rs | 13 +- crates/cli/src/main.rs | 28 +- crates/common/src/kube.rs | 6 +- crates/common/src/lib.rs | 1 + crates/common/src/utils.rs | 45 ++ crates/conductor/src/server.rs | 44 +- crates/dashboard/src/kube_cluster.rs | 87 ++++ crates/dashboard/src/kube_resource.rs | 1 + crates/db/entities/src/kube_cluster.rs | 1 + .../m20250729_082625_create_kube_cluster.rs | 2 + crates/db/src/api.rs | 63 ++- crates/kube-devbox-proxy/Cargo.toml | 1 + crates/kube-devbox-proxy/src/branch_config.rs | 10 +- crates/kube-devbox-proxy/src/main.rs | 22 +- crates/kube-devbox-proxy/src/rpc_server.rs | 28 +- crates/kube-manager/Cargo.toml | 3 - crates/kube-manager/src/manager.rs | 141 +---- .../examples/deployment.yaml | 18 +- crates/kube-sidecar-proxy/src/main.rs | 30 +- crates/kube/src/server.rs | 1 + .../connect-your-kubernetes-cluster.md | 7 +- pkg/kube-container/Dockerfile | 104 ++++ pkg/kube-container/README.md | 93 ++++ pkg/kube-container/build_and_push.sh | 212 ++++++++ src/main.rs | 1 + 40 files changed, 1504 insertions(+), 577 deletions(-) create mode 100644 .env.example create mode 100644 crates/api/src/kube_controller/container_images.rs create mode 100644 crates/cli/src/config.rs create mode 100644 pkg/kube-container/Dockerfile create mode 100644 pkg/kube-container/README.md create mode 100755 pkg/kube-container/build_and_push.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7bd3dc7 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Lapdev API configuration + +# Required: PostgreSQL connection URL for the Lapdev database. +# Example: postgres://username:password@db-host:5432/lapdev +LAPDEV_DB_URL=postgres://username:password@localhost:5432/lapdev + +# Optional overrides with sensible defaults. +LAPDEV_BIND_ADDR=0.0.0.0 +LAPDEV_HTTP_PORT=8080 +LAPDEV_SSH_PROXY_PORT=2222 +LAPDEV_SSH_PROXY_DISPLAY_PORT=2222 +LAPDEV_PREVIEW_URL_PROXY_PORT=8443 + +# Set to force all workspaces to run under a specific OS user. +# LAPDEV_FORCE_OSUSER= diff --git a/Cargo.lock b/Cargo.lock index acd6504..72fd105 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3127,6 +3127,7 @@ name = "lapdev" version = "0.1.0" dependencies = [ "anyhow", + "dotenvy", "lapdev-api", "lapdev-cli", "lapdev-db", @@ -3225,6 +3226,7 @@ dependencies = [ "hostname", "http 1.3.1", "keyring", + "lapdev-common", "lapdev-devbox-rpc", "lapdev-rpc", "lapdev-tunnel", @@ -3461,6 +3463,7 @@ dependencies = [ "clap", "futures-util", "http 1.3.1", + "lapdev-common", "lapdev-kube-rpc", "lapdev-rpc", "lapdev-tunnel", @@ -3478,7 +3481,6 @@ name = "lapdev-kube-manager" version = "0.1.0" dependencies = [ "anyhow", - "base64 0.22.1", "bytes", "chrono", "futures", @@ -3489,7 +3491,6 @@ dependencies = [ "lapdev-kube-rpc", "lapdev-rpc", "lapdev-tunnel", - "pem", "reqwest", "serde", "serde_yaml", @@ -3501,7 +3502,6 @@ dependencies = [ "tracing-journald", "tracing-subscriber", "uuid", - "yup-oauth2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9183e7b..e2a6a32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ lapdev-cli.workspace = true lapdev-guest-agent.workspace = true lapdev-kube.workspace = true lapdev-kube-manager.workspace = true +dotenvy.workspace = true [workspace] members = [ @@ -71,6 +72,7 @@ members = [ [workspace.dependencies] rust_decimal = "1.37.1" toml = "0.8.11" +dotenvy = "0.15.7" clap = { version = "4.5.2", features = ["derive"] } tempfile = "3.8.1" zstd = "0.13.0" diff --git a/crates/api/src/cert.rs b/crates/api/src/cert.rs index d227356..104fdc5 100644 --- a/crates/api/src/cert.rs +++ b/crates/api/src/cert.rs @@ -15,6 +15,7 @@ use webpki::EndEntityCert; pub type CertStore = Arc>>>>; +#[allow(dead_code)] pub fn tls_config(certs: CertStore) -> Result { let mut config = ServerConfig::builder() .with_no_client_auth() diff --git a/crates/api/src/kube_controller/cluster.rs b/crates/api/src/kube_controller/cluster.rs index 268d6ee..2f56cd5 100644 --- a/crates/api/src/kube_controller/cluster.rs +++ b/crates/api/src/kube_controller/cluster.rs @@ -35,6 +35,7 @@ impl KubeController { region: c.region, status: KubeClusterStatus::from_str(&c.status) .unwrap_or(KubeClusterStatus::NotReady), + manager_namespace: None, }, }) .collect(); @@ -383,6 +384,7 @@ impl KubeController { region: cluster.region.clone(), status: KubeClusterStatus::from_str(&cluster.status) .unwrap_or(KubeClusterStatus::NotReady), + manager_namespace: None, }; Ok(KubeCluster { diff --git a/crates/api/src/kube_controller/container_images.rs b/crates/api/src/kube_controller/container_images.rs new file mode 100644 index 0000000..64272c9 --- /dev/null +++ b/crates/api/src/kube_controller/container_images.rs @@ -0,0 +1,34 @@ +/// Shared image tag for Lapdev Kubernetes components. +pub const CONTAINER_IMAGE_TAG: &str = "0.1.0"; + +/// Registry/repository for the Lapdev API image. +pub(crate) const API_IMAGE_REPO: &str = "ghcr.io/lapce/lapdev-api"; +/// Registry/repository for the kube-manager controller image. +pub(crate) const KUBE_MANAGER_IMAGE_REPO: &str = "ghcr.io/lapce/lapdev-kube-manager"; +/// Registry/repository for the sidecar proxy image. +pub(crate) const SIDECAR_PROXY_IMAGE_REPO: &str = "ghcr.io/lapce/lapdev-kube-sidecar-proxy"; +/// Registry/repository for the devbox proxy image. +pub(crate) const DEVBOX_PROXY_IMAGE_REPO: &str = "ghcr.io/lapce/lapdev-kube-devbox-proxy"; + +/// Helper returning the Lapdev API image reference with the shared tag. +#[allow(dead_code)] +pub(crate) fn api_image_reference() -> String { + format!("{}:{}", API_IMAGE_REPO, CONTAINER_IMAGE_TAG) +} + +/// Helper returning the kube-manager image reference with the shared tag. +#[allow(dead_code)] +pub(crate) fn kube_manager_image_reference() -> String { + format!("{}:{}", KUBE_MANAGER_IMAGE_REPO, CONTAINER_IMAGE_TAG) +} + +/// Helper returning the sidecar proxy image reference with the shared tag. +pub(crate) fn sidecar_proxy_image_reference() -> String { + format!("{}:{}", SIDECAR_PROXY_IMAGE_REPO, CONTAINER_IMAGE_TAG) +} + +/// Helper returning the devbox proxy image reference with the shared tag. +#[allow(dead_code)] +pub(crate) fn devbox_proxy_image_reference() -> String { + format!("{}:{}", DEVBOX_PROXY_IMAGE_REPO, CONTAINER_IMAGE_TAG) +} diff --git a/crates/api/src/kube_controller/deployment.rs b/crates/api/src/kube_controller/deployment.rs index 184e734..17d895c 100644 --- a/crates/api/src/kube_controller/deployment.rs +++ b/crates/api/src/kube_controller/deployment.rs @@ -1,15 +1,27 @@ -use std::collections::HashMap; +use std::{ + collections::{BTreeMap, HashMap}, + str::FromStr, +}; -use k8s_openapi::api::apps::v1::Deployment; +use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec}; use k8s_openapi::api::core::v1::{ - Container, ContainerPort, EnvVar, EnvVarSource, ObjectFieldSelector, + Container, ContainerPort, EnvVar, EnvVarSource, ObjectFieldSelector, PodSpec, PodTemplateSpec, +}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta}; +use lapdev_common::{ + kube::{ + KubeEnvironmentStatus, DEFAULT_SIDECAR_PROXY_BIND_ADDR, DEFAULT_SIDECAR_PROXY_METRICS_PORT, + DEFAULT_SIDECAR_PROXY_PORT, SIDECAR_PROXY_BIND_ADDR_ENV_VAR, + SIDECAR_PROXY_MANAGER_ADDR_ENV_VAR, SIDECAR_PROXY_PORT_ENV_VAR, + }, + utils::resolve_api_host, }; use lapdev_kube::server::KubeClusterServer; use lapdev_kube_rpc::KubeWorkloadYamlOnly; use lapdev_rpc::error::ApiError; use uuid::Uuid; -use super::KubeController; +use super::{container_images, KubeController}; impl KubeController { pub(super) async fn deploy_environment_resources( @@ -51,6 +63,22 @@ impl KubeController { )?; } } + + let status = KubeEnvironmentStatus::from_str(&environment.status) + .unwrap_or(KubeEnvironmentStatus::Running); + let desired_replicas = desired_devbox_proxy_replicas(status); + let api_host = resolve_devbox_proxy_api_host(); + let devbox_proxy_yaml = build_devbox_proxy_deployment_yaml( + &environment.namespace, + environment.id, + &environment.auth_token, + environment.is_shared, + desired_replicas, + &api_host, + )?; + workloads_with_resources + .workloads + .push(KubeWorkloadYamlOnly::Deployment(devbox_proxy_yaml)); } // Prepare environment-specific labels @@ -119,67 +147,8 @@ fn inject_sidecar_proxy_into_deployment_yaml( })?; if let Some(spec) = deployment.spec.as_mut() { - if let Some(template) = spec.template.spec.as_mut() { - let already_present = template - .containers - .iter() - .any(|container| container.name == "lapdev-sidecar-proxy"); - if !already_present { - let sidecar_container = Container { - name: "lapdev-sidecar-proxy".to_string(), - image: Some("lapdev/kube-sidecar-proxy:latest".to_string()), - ports: Some(vec![ - ContainerPort { - container_port: 8080, - name: Some("proxy".to_string()), - protocol: Some("TCP".to_string()), - ..Default::default() - }, - ContainerPort { - container_port: 9090, - name: Some("metrics".to_string()), - protocol: Some("TCP".to_string()), - ..Default::default() - }, - ]), - env: Some(vec![ - EnvVar { - name: "LAPDEV_ENVIRONMENT_ID".to_string(), - value: Some(environment_id.to_string()), - ..Default::default() - }, - EnvVar { - name: "LAPDEV_ENVIRONMENT_AUTH_TOKEN".to_string(), - value: Some(auth_token.to_string()), - ..Default::default() - }, - EnvVar { - name: "KUBERNETES_NAMESPACE".to_string(), - value: Some(namespace.to_string()), - ..Default::default() - }, - EnvVar { - name: "HOSTNAME".to_string(), - value_from: Some(EnvVarSource { - field_ref: Some(ObjectFieldSelector { - field_path: "metadata.name".to_string(), - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }, - ]), - args: Some(vec![ - "--listen-addr".to_string(), - "0.0.0.0:8080".to_string(), - "--target-addr".to_string(), - "127.0.0.1:3000".to_string(), - ]), - ..Default::default() - }; - template.containers.push(sidecar_container); - } + if let Some(pod_spec) = spec.template.spec.as_mut() { + ensure_sidecar_proxy_container(pod_spec, environment_id, namespace, auth_token); } } @@ -192,3 +161,384 @@ fn inject_sidecar_proxy_into_deployment_yaml( Ok(()) } + +fn ensure_sidecar_proxy_container( + pod_spec: &mut PodSpec, + environment_id: Uuid, + namespace: &str, + auth_token: &str, +) { + let already_present = pod_spec + .containers + .iter() + .any(|container| container.name == "lapdev-sidecar-proxy"); + if already_present { + return; + } + + pod_spec.containers.push(build_sidecar_proxy_container( + environment_id, + namespace, + auth_token, + )); +} + +fn build_sidecar_proxy_container( + environment_id: Uuid, + namespace: &str, + auth_token: &str, +) -> Container { + let sidecar_image = container_images::sidecar_proxy_image_reference(); + + Container { + name: "lapdev-sidecar-proxy".to_string(), + image: Some(sidecar_image), + ports: Some(vec![ + ContainerPort { + container_port: DEFAULT_SIDECAR_PROXY_PORT as i32, + name: Some("proxy".to_string()), + protocol: Some("TCP".to_string()), + ..Default::default() + }, + ContainerPort { + container_port: DEFAULT_SIDECAR_PROXY_METRICS_PORT as i32, + name: Some("metrics".to_string()), + protocol: Some("TCP".to_string()), + ..Default::default() + }, + ]), + env: Some(vec![ + EnvVar { + name: "LAPDEV_ENVIRONMENT_ID".to_string(), + value: Some(environment_id.to_string()), + ..Default::default() + }, + EnvVar { + name: "LAPDEV_ENVIRONMENT_AUTH_TOKEN".to_string(), + value: Some(auth_token.to_string()), + ..Default::default() + }, + EnvVar { + name: "KUBERNETES_NAMESPACE".to_string(), + value: Some(namespace.to_string()), + ..Default::default() + }, + EnvVar { + name: SIDECAR_PROXY_BIND_ADDR_ENV_VAR.to_string(), + value: Some(DEFAULT_SIDECAR_PROXY_BIND_ADDR.to_string()), + ..Default::default() + }, + EnvVar { + name: SIDECAR_PROXY_PORT_ENV_VAR.to_string(), + value: Some(DEFAULT_SIDECAR_PROXY_PORT.to_string()), + ..Default::default() + }, + EnvVar { + name: SIDECAR_PROXY_MANAGER_ADDR_ENV_VAR.to_string(), + value: Some("lapdev-kube-manager.lapdev.svc:5001".to_string()), + ..Default::default() + }, + EnvVar { + name: "HOSTNAME".to_string(), + value_from: Some(EnvVarSource { + field_ref: Some(ObjectFieldSelector { + field_path: "metadata.name".to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }, + ]), + ..Default::default() + } +} + +fn desired_devbox_proxy_replicas(status: KubeEnvironmentStatus) -> i32 { + match status { + KubeEnvironmentStatus::Pausing | KubeEnvironmentStatus::Paused => 0, + _ => 1, + } +} + +fn resolve_devbox_proxy_api_host() -> String { + let host_env = std::env::var("LAPDEV_API_HOST").ok(); + resolve_api_host(host_env.as_deref()) +} + +fn build_devbox_proxy_deployment_yaml( + namespace: &str, + environment_id: Uuid, + auth_token: &str, + is_shared_environment: bool, + replicas: i32, + api_host: &str, +) -> Result { + let mut selector_labels = BTreeMap::new(); + selector_labels.insert("app".to_string(), "lapdev-devbox-proxy".to_string()); + + let metadata = ObjectMeta { + name: Some("lapdev-devbox-proxy".to_string()), + namespace: Some(namespace.to_string()), + labels: Some(selector_labels.clone()), + ..Default::default() + }; + + let template_metadata = ObjectMeta { + labels: Some(selector_labels.clone()), + ..Default::default() + }; + + let container = Container { + name: "lapdev-devbox-proxy".to_string(), + image: Some(container_images::devbox_proxy_image_reference()), + env: Some(vec![ + EnvVar { + name: "LAPDEV_API_HOST".to_string(), + value: Some(api_host.to_string()), + ..Default::default() + }, + EnvVar { + name: "LAPDEV_ENVIRONMENT_ID".to_string(), + value: Some(environment_id.to_string()), + ..Default::default() + }, + EnvVar { + name: "LAPDEV_ENVIRONMENT_AUTH_TOKEN".to_string(), + value: Some(auth_token.to_string()), + ..Default::default() + }, + EnvVar { + name: "IS_SHARED_ENVIRONMENT".to_string(), + value: Some(is_shared_environment.to_string()), + ..Default::default() + }, + ]), + ..Default::default() + }; + + let pod_spec = PodSpec { + containers: vec![container], + ..Default::default() + }; + + let template = PodTemplateSpec { + metadata: Some(template_metadata), + spec: Some(pod_spec), + }; + + let deployment_spec = DeploymentSpec { + replicas: Some(replicas), + selector: LabelSelector { + match_labels: Some(selector_labels), + ..Default::default() + }, + template, + ..Default::default() + }; + + let deployment = Deployment { + metadata, + spec: Some(deployment_spec), + status: None, + }; + + serde_yaml::to_string(&deployment).map_err(|err| { + ApiError::InvalidRequest(format!( + "Failed to serialize devbox proxy deployment: {}", + err + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ensure_sidecar_proxy_container_appends_once() { + let mut pod_spec = PodSpec { + containers: vec![Container { + name: "primary".to_string(), + ..Default::default() + }], + ..Default::default() + }; + + let environment_id = Uuid::new_v4(); + ensure_sidecar_proxy_container( + &mut pod_spec, + environment_id, + "lapdev-namespace", + "auth-token", + ); + + let mut sidecars: Vec<&Container> = pod_spec + .containers + .iter() + .filter(|container| container.name == "lapdev-sidecar-proxy") + .collect(); + assert_eq!(sidecars.len(), 1, "sidecar should be appended exactly once"); + + ensure_sidecar_proxy_container( + &mut pod_spec, + environment_id, + "lapdev-namespace", + "auth-token", + ); + + sidecars = pod_spec + .containers + .iter() + .filter(|container| container.name == "lapdev-sidecar-proxy") + .collect(); + assert_eq!( + sidecars.len(), + 1, + "subsequent injections must not duplicate the sidecar" + ); + + let sidecar = sidecars.pop().expect("sidecar container should exist"); + let expected_image = super::container_images::sidecar_proxy_image_reference(); + assert_eq!( + sidecar.image.as_deref(), + Some(expected_image.as_str()), + "sidecar should use the shared repo:tag reference" + ); + assert!( + sidecar.args.is_none(), + "sidecar should not rely on CLI arguments once injected" + ); + + let env_vars = sidecar + .env + .as_ref() + .expect("sidecar should include required environment variables"); + let bind_addr_var = env_vars + .iter() + .find(|var| var.name == SIDECAR_PROXY_BIND_ADDR_ENV_VAR) + .expect("bind address environment variable should be set"); + assert_eq!( + bind_addr_var.value.as_deref(), + Some(DEFAULT_SIDECAR_PROXY_BIND_ADDR), + "bind address env var should carry the default proxy address" + ); + + let port_var = env_vars + .iter() + .find(|var| var.name == SIDECAR_PROXY_PORT_ENV_VAR) + .expect("port environment variable should be set"); + let expected_port = DEFAULT_SIDECAR_PROXY_PORT.to_string(); + assert_eq!( + port_var.value.as_deref(), + Some(expected_port.as_str()), + "port env var should reflect the default proxy port" + ); + } + + #[test] + fn desired_devbox_proxy_replicas_handles_pause_states() { + assert_eq!( + desired_devbox_proxy_replicas(KubeEnvironmentStatus::Running), + 1 + ); + assert_eq!( + desired_devbox_proxy_replicas(KubeEnvironmentStatus::Pausing), + 0 + ); + assert_eq!( + desired_devbox_proxy_replicas(KubeEnvironmentStatus::Paused), + 0 + ); + assert_eq!( + desired_devbox_proxy_replicas(KubeEnvironmentStatus::ResumeFailed), + 1 + ); + } + + #[test] + fn build_devbox_proxy_deployment_yaml_sets_expected_fields() { + let environment_id = Uuid::new_v4(); + let yaml = build_devbox_proxy_deployment_yaml( + "lapdev-namespace", + environment_id, + "auth-token", + true, + 0, + "api.example.dev", + ) + .expect("deployment yaml should build"); + + let deployment: Deployment = + serde_yaml::from_str(&yaml).expect("yaml should deserialize into Deployment"); + assert_eq!( + deployment.metadata.name.as_deref(), + Some("lapdev-devbox-proxy") + ); + assert_eq!( + deployment.metadata.namespace.as_deref(), + Some("lapdev-namespace") + ); + + let spec = deployment + .spec + .as_ref() + .expect("deployment spec should be present"); + assert_eq!(spec.replicas, Some(0)); + + let selector_labels = spec + .selector + .match_labels + .as_ref() + .expect("selector labels should be defined"); + assert_eq!( + selector_labels.get("app"), + Some(&"lapdev-devbox-proxy".to_string()) + ); + + let pod_spec = spec + .template + .spec + .as_ref() + .expect("pod spec should be present"); + assert_eq!(pod_spec.containers.len(), 1); + let container = &pod_spec.containers[0]; + assert_eq!( + container.name, + "lapdev-devbox-proxy".to_string(), + "container should be named consistently" + ); + let expected_image = super::container_images::devbox_proxy_image_reference(); + assert_eq!( + container.image.as_deref(), + Some(expected_image.as_str()), + "devbox container should use shared repo tag" + ); + + let env_vars = container + .env + .as_ref() + .expect("devbox container should have required env vars"); + let env_map: std::collections::HashMap<_, _> = env_vars + .iter() + .map(|var| (var.name.clone(), var.value.clone())) + .collect(); + + assert_eq!( + env_map.get("LAPDEV_API_HOST"), + Some(&Some("api.example.dev".to_string())) + ); + assert_eq!( + env_map.get("LAPDEV_ENVIRONMENT_ID"), + Some(&Some(environment_id.to_string())) + ); + assert_eq!( + env_map.get("LAPDEV_ENVIRONMENT_AUTH_TOKEN"), + Some(&Some("auth-token".to_string())) + ); + assert_eq!( + env_map.get("IS_SHARED_ENVIRONMENT"), + Some(&Some("true".to_string())) + ); + } +} diff --git a/crates/api/src/kube_controller/environment.rs b/crates/api/src/kube_controller/environment.rs index 498873c..74909b9 100644 --- a/crates/api/src/kube_controller/environment.rs +++ b/crates/api/src/kube_controller/environment.rs @@ -1780,15 +1780,14 @@ impl KubeController { match serde_json::to_string(&event) { Ok(payload) => { if let Some(pool) = self.db.pool.clone() { - if let Err(err) = sqlx::query("SELECT pg_notify($1, $2)") - .bind("environment_lifecycle") + if let Err(err) = sqlx::query("NOTIFY environment_lifecycle, $1") .bind(payload) .execute(&pool) .await { warn!( error = %err, - "failed to publish environment lifecycle event via pg_notify" + "failed to publish environment lifecycle event via NOTIFY" ); let _ = self.environment_events.send(event); } diff --git a/crates/api/src/kube_controller/mod.rs b/crates/api/src/kube_controller/mod.rs index 06f96ca..b2a23d4 100644 --- a/crates/api/src/kube_controller/mod.rs +++ b/crates/api/src/kube_controller/mod.rs @@ -11,6 +11,7 @@ use tracing::{debug, warn}; // Submodules mod app_catalog; mod cluster; +pub(crate) mod container_images; mod deployment; mod environment; mod preview_url; @@ -21,6 +22,7 @@ mod workload; pub mod yaml_parser; // Re-exports +pub use self::container_images::CONTAINER_IMAGE_TAG; pub use validation::*; pub use yaml_parser::*; diff --git a/crates/api/src/kube_controller/preview_url.rs b/crates/api/src/kube_controller/preview_url.rs index 3e6a8fe..8b38e65 100644 --- a/crates/api/src/kube_controller/preview_url.rs +++ b/crates/api/src/kube_controller/preview_url.rs @@ -1,6 +1,6 @@ use uuid::Uuid; -use lapdev_common::{kube::KubeEnvironmentPreviewUrl, utils::rand_string}; +use lapdev_common::{kube::KubeEnvironmentPreviewUrl, utils::rand_string, LAPDEV_API_HOST}; use lapdev_rpc::error::ApiError; use super::KubeController; @@ -66,7 +66,7 @@ impl KubeController { .access_level .unwrap_or(lapdev_common::kube::PreviewUrlAccessLevel::Organization); - let url = format!("https://{auto_name}.app.lap.dev"); + let url = format!("https://{auto_name}.{}", LAPDEV_API_HOST); // Create preview URL in database let preview_url = match self @@ -153,7 +153,7 @@ impl KubeController { Ok(preview_urls .into_iter() .map(|preview_url| { - let url = format!("https://{}.app.lap.dev", preview_url.name); + let url = format!("https://{}.{}", preview_url.name, LAPDEV_API_HOST); KubeEnvironmentPreviewUrl { id: preview_url.id, @@ -220,7 +220,7 @@ impl KubeController { .await .map_err(ApiError::from)?; - let url = format!("https://{}.app.lap.dev", updated_preview_url.name); + let url = format!("https://{}.{}", updated_preview_url.name, LAPDEV_API_HOST); Ok(KubeEnvironmentPreviewUrl { id: updated_preview_url.id, diff --git a/crates/api/src/router.rs b/crates/api/src/router.rs index 7ab39c5..1037ec2 100644 --- a/crates/api/src/router.rs +++ b/crates/api/src/router.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use axum::{ debug_handler, - extract::{FromRequestParts, Request, State, WebSocketUpgrade}, + extract::{FromRequestParts, Query, Request, State, WebSocketUpgrade}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{any, delete, get, post, put}, @@ -15,6 +15,7 @@ use lapdev_api_hrpc::{HrpcService, HrpcServiceResponse}; use lapdev_common::WorkspaceStatus; use lapdev_proxy_http::{forward::ProxyForward, proxy::WorkspaceForwardError}; use lapdev_rpc::error::ApiError; +use serde::Deserialize; use crate::{ account, admin, app_catalog_events, cli_auth, cluster_events, @@ -27,6 +28,7 @@ use crate::{ devbox_proxy_tunnel_websocket, kube_cluster_rpc_websocket, kube_data_plane_websocket, sidecar_tunnel_websocket, }, + kube_controller::container_images, machine_type, organization, project, session::{logout, new_session, session_authorize}, state::CoreState, @@ -324,12 +326,185 @@ pub fn build_router(state: Arc) -> Router { .route("/", any(handle_catch_all)) .route("/{*wildcard}", any(handle_catch_all)) .route("/health-check", get(health_check)) + .route( + "/install/lapdev-kube-manager.yaml", + get(kube_manager_install_manifest), + ) .nest("/api", main_routes()) .with_state(state) } async fn health_check() {} +#[derive(Debug, Deserialize)] +struct KubeManagerManifestQuery { + token: String, +} + +async fn kube_manager_install_manifest( + State(state): State>, + Query(query): Query, +) -> Result { + let token = query.token.trim(); + if token.is_empty() { + return Err(ApiError::InvalidRequest( + "token query parameter is required".to_string(), + )); + } + + let ws_base = state.websocket_base_url().await; + let ws_base = ws_base.trim_end_matches('/'); + let cluster_url = format!("{}/api/v1/kube/cluster/rpc", ws_base); + let tunnel_url = format!("{}/api/v1/kube/cluster/tunnel", ws_base); + let image = container_images::kube_manager_image_reference(); + + let manifest = format!( + r#"apiVersion: v1 +kind: Namespace +metadata: + name: lapdev +--- +apiVersion: v1 +kind: Secret +metadata: + name: lapdev-kube-manager + namespace: lapdev +type: Opaque +stringData: + LAPDEV_KUBE_CLUSTER_TOKEN: "{cluster_token}" + LAPDEV_KUBE_CLUSTER_URL: "{cluster_url}" + LAPDEV_KUBE_CLUSTER_TUNNEL_URL: "{tunnel_url}" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: lapdev-kube-manager + namespace: lapdev +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: lapdev-kube-manager +rules: + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "create", "update", "patch", "delete", "watch"] + - apiGroups: [""] + resources: ["pods", "services", "configmaps", "secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["nodes"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets", "daemonsets", "replicasets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["batch"] + resources: ["jobs", "cronjobs"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: lapdev-kube-manager +subjects: + - kind: ServiceAccount + name: lapdev-kube-manager + namespace: lapdev +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: lapdev-kube-manager +--- +apiVersion: v1 +kind: Service +metadata: + name: lapdev-kube-manager + namespace: lapdev + labels: + app.kubernetes.io/name: lapdev-kube-manager + app.kubernetes.io/component: controller +spec: + selector: + app.kubernetes.io/name: lapdev-kube-manager + ports: + - name: sidecar-rpc + port: 5001 + targetPort: sidecar-rpc + - name: devbox-rpc + port: 7771 + targetPort: devbox-rpc +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lapdev-kube-manager + namespace: lapdev + labels: + app.kubernetes.io/name: lapdev-kube-manager + app.kubernetes.io/component: controller +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: lapdev-kube-manager + template: + metadata: + labels: + app.kubernetes.io/name: lapdev-kube-manager + app.kubernetes.io/component: controller + spec: + serviceAccountName: lapdev-kube-manager + containers: + - name: manager + image: {image} + imagePullPolicy: Always + env: + - name: LAPDEV_KUBE_CLUSTER_TOKEN + valueFrom: + secretKeyRef: + name: lapdev-kube-manager + key: LAPDEV_KUBE_CLUSTER_TOKEN + - name: LAPDEV_KUBE_CLUSTER_URL + valueFrom: + secretKeyRef: + name: lapdev-kube-manager + key: LAPDEV_KUBE_CLUSTER_URL + - name: LAPDEV_KUBE_CLUSTER_TUNNEL_URL + valueFrom: + secretKeyRef: + name: lapdev-kube-manager + key: LAPDEV_KUBE_CLUSTER_TUNNEL_URL + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + ports: + - containerPort: 5001 + name: sidecar-rpc + - containerPort: 7771 + name: devbox-rpc + resources: + requests: + cpu: "100m" + memory: "256Mi" + limits: + cpu: "1" + memory: "512Mi" +"#, + cluster_token = token, + cluster_url = cluster_url, + tunnel_url = tunnel_url, + image = image, + ); + + let headers = [ + (axum::http::header::CONTENT_TYPE, "text/yaml"), + (axum::http::header::CACHE_CONTROL, "no-store"), + ]; + + Ok((StatusCode::OK, headers, manifest).into_response()) +} + #[debug_handler] async fn handle_catch_all( State(state): State>, diff --git a/crates/api/src/server.rs b/crates/api/src/server.rs index 2dac233..482a38c 100644 --- a/crates/api/src/server.rs +++ b/crates/api/src/server.rs @@ -1,71 +1,92 @@ -use std::{ - net::SocketAddr, - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{env, net::SocketAddr, path::PathBuf, sync::Arc}; use anyhow::{anyhow, Context, Result}; -use axum::{extract::Request, Router}; +use axum::Router; use clap::Parser; -use futures_util::pin_mut; -use hyper::body::Incoming; -use hyper_util::rt::{TokioExecutor, TokioIo}; use lapdev_conductor::Conductor; use lapdev_db::api::DbApi; -use serde::Deserialize; use tokio::{net::TcpListener, time::Duration}; -use tokio_rustls::TlsAcceptor; -use tower::Service; use tracing::{error, info, warn}; use crate::{ - cert::tls_config, router::{self, add_forward_middleware}, state::CoreState, }; pub const LAPDEV_VERSION: &str = env!("CARGO_PKG_VERSION"); -#[derive(Clone, Deserialize, Default)] -#[serde(rename_all = "kebab-case")] +#[derive(Clone)] struct LapdevConfig { - db: Option, - bind: Option, - http_port: Option, - https_port: Option, - ssh_proxy_port: Option, - ssh_proxy_display_port: Option, + db_url: String, + bind_addr: String, + http_port: u16, + ssh_proxy_port: u16, + ssh_proxy_display_port: u16, force_osuser: Option, - preview_url_proxy_port: Option, + preview_url_proxy_port: u16, +} + +const DB_URL_ENV: &str = "LAPDEV_DB_URL"; +const BIND_ADDR_ENV: &str = "LAPDEV_BIND_ADDR"; +const HTTP_PORT_ENV: &str = "LAPDEV_HTTP_PORT"; +const SSH_PROXY_PORT_ENV: &str = "LAPDEV_SSH_PROXY_PORT"; +const SSH_PROXY_DISPLAY_PORT_ENV: &str = "LAPDEV_SSH_PROXY_DISPLAY_PORT"; +const FORCE_OSUSER_ENV: &str = "LAPDEV_FORCE_OSUSER"; +const PREVIEW_PROXY_PORT_ENV: &str = "LAPDEV_PREVIEW_URL_PROXY_PORT"; + +impl LapdevConfig { + fn from_env() -> Result { + let db_url = env::var(DB_URL_ENV) + .map_err(|_| anyhow!("environment variable {DB_URL_ENV} is required"))?; + let bind_addr = env::var(BIND_ADDR_ENV).unwrap_or_else(|_| "0.0.0.0".to_string()); + let http_port = Self::parse_port(HTTP_PORT_ENV, 8080)?; + let ssh_proxy_port = Self::parse_port(SSH_PROXY_PORT_ENV, 2222)?; + let ssh_proxy_display_port = Self::parse_port(SSH_PROXY_DISPLAY_PORT_ENV, 2222)?; + let preview_url_proxy_port = Self::parse_port(PREVIEW_PROXY_PORT_ENV, 8443)?; + + let force_osuser = match env::var(FORCE_OSUSER_ENV) { + Ok(value) if value.trim().is_empty() => None, + Ok(value) => Some(value), + Err(env::VarError::NotPresent) => None, + Err(err) => return Err(anyhow!("error reading {FORCE_OSUSER_ENV}: {err}")), + }; + + Ok(Self { + db_url, + bind_addr, + http_port, + ssh_proxy_port, + ssh_proxy_display_port, + force_osuser, + preview_url_proxy_port, + }) + } + + fn parse_port(var: &str, default: u16) -> Result { + match env::var(var) { + Ok(value) => value + .parse::() + .map_err(|err| anyhow!("{var} must be a valid u16: {err}")), + Err(env::VarError::NotPresent) => Ok(default), + Err(err) => Err(anyhow!("error reading {var}: {err}")), + } + } } #[derive(Parser)] #[clap(name = "lapdev")] #[clap(version = env!("CARGO_PKG_VERSION"))] struct Cli { - /// The config file path - #[clap(short, long, action, value_hint = clap::ValueHint::AnyPath)] - config_file: Option, - /// The folder for putting logs - #[clap(short, long, action, value_hint = clap::ValueHint::AnyPath)] - logs_folder: Option, /// The folder for putting data #[clap(short, long, action, value_hint = clap::ValueHint::AnyPath)] data_folder: Option, - /// Don't run db migration on startup - #[clap(short, long, action)] - no_migration: bool, } pub struct ApiServer { config: LapdevConfig, - pub config_file: PathBuf, pub state: Arc, pub app: Router, pub conductor: Conductor, - ssh_proxy_port: u16, - preview_url_proxy_port: u16, - _log: Result, } impl ApiServer { @@ -76,22 +97,10 @@ impl ApiServer { .clone() .unwrap_or_else(|| PathBuf::from("/var/lib/lapdev")); - let log = setup_log(&cli, &data_folder).await; + init_tracing(); - let config_file = cli - .config_file - .clone() - .unwrap_or_else(|| PathBuf::from("/etc/lapdev.conf")); - let config_content = tokio::fs::read_to_string(&config_file) - .await - .with_context(|| format!("can't read config file {}", config_file.to_string_lossy()))?; - let config: LapdevConfig = - toml::from_str(&config_content).with_context(|| "wrong config file format")?; - let db_url = config - .db - .clone() - .ok_or_else(|| anyhow!("can't find database url in your config file"))?; - let db = DbApi::new(&db_url, cli.no_migration).await?; + let config = LapdevConfig::from_env()?; + let db = DbApi::new(&config.db_url).await?; let conductor = Conductor::new( LAPDEV_VERSION, db.clone(), @@ -100,14 +109,11 @@ impl ApiServer { ) .await?; - let ssh_proxy_port = config.ssh_proxy_port.unwrap_or(2222); - let ssh_proxy_display_port = config.ssh_proxy_display_port.unwrap_or(2222); - let preview_url_proxy_port = config.preview_url_proxy_port.unwrap_or(8443); let state = Arc::new( CoreState::new( conductor.clone(), - ssh_proxy_port, - ssh_proxy_display_port, + config.ssh_proxy_port, + config.ssh_proxy_display_port, static_dir, ) .await, @@ -116,44 +122,18 @@ impl ApiServer { let app = router::build_router(state.clone()); Ok(Self { config, - config_file, state, conductor, app, - ssh_proxy_port, - preview_url_proxy_port, - _log: log, }) } pub async fn run(&self) -> Result<()> { - { - // start ssh proxy - let conductor = self.conductor.clone(); - let bind = self.config.bind.clone(); - let ssh_proxy_port = self.ssh_proxy_port; - tokio::spawn(async move { - if let Err(e) = lapdev_proxy_ssh::server::run( - conductor, - bind.as_deref().unwrap_or("0.0.0.0"), - ssh_proxy_port, - ) - .await - { - error!("ssh proxy error: {e:?}"); - } - }); - } - { let db = self.conductor.db.clone(); let tunnel_registry = self.state.kube_controller.tunnel_registry.clone(); - let host = self - .config - .bind - .clone() - .unwrap_or_else(|| "0.0.0.0".to_string()); - let port = self.preview_url_proxy_port; + let host = self.config.bind_addr.clone(); + let port = self.config.preview_url_proxy_port; tokio::spawn(async move { loop { let db_clone = db.clone(); @@ -176,218 +156,47 @@ impl ApiServer { }); } - // start http server + let app_with_forward = add_forward_middleware(self.state.clone(), self.app.clone()); + let bind = format!( "{}:{}", - self.config - .bind - .clone() - .unwrap_or_else(|| "0.0.0.0".to_string()), - self.config.http_port.unwrap_or(80) + self.config.bind_addr.clone(), + self.config.http_port ); let tcp_listener = TcpListener::bind(&bind) .await .with_context(|| format!("bind to {bind}"))?; - let app = add_forward_middleware(self.state.clone(), self.app.clone()); + + info!(%bind, "Starting Axum HTTP server"); + if let Err(err) = axum::serve( tcp_listener, - app.into_make_service_with_connect_info::(), + app_with_forward.into_make_service_with_connect_info::(), ) .await { tracing::error!("http server stopped error: {err}"); } + Ok(()) } } pub async fn start(static_dir: Option>) { - let cli = Cli::parse(); - let data_folder = cli - .data_folder - .clone() - .unwrap_or_else(|| PathBuf::from("/var/lib/lapdev")); - - let _result = setup_log(&cli, &data_folder).await; - - if let Err(e) = run(&cli, data_folder, static_dir).await { - tracing::error!("lapdev api start server error: {e:#}"); - } -} - -async fn run( - cli: &Cli, - data_folder: PathBuf, - static_dir: Option>, -) -> Result<()> { - let config_file = cli - .config_file - .clone() - .unwrap_or_else(|| PathBuf::from("/etc/lapdev.conf")); - let config_content = tokio::fs::read_to_string(&config_file) - .await - .with_context(|| format!("can't read config file {}", config_file.to_string_lossy()))?; - let config: LapdevConfig = - toml::from_str(&config_content).with_context(|| "wrong config file format")?; - let db_url = config - .db - .ok_or_else(|| anyhow!("can't find database url in your config file"))?; - - let db = DbApi::new(&db_url, cli.no_migration).await?; - let conductor = - Conductor::new(LAPDEV_VERSION, db.clone(), data_folder, config.force_osuser).await?; - - let ssh_proxy_port = config.ssh_proxy_port.unwrap_or(2222); - let ssh_proxy_display_port = config.ssh_proxy_display_port.unwrap_or(2222); - let preview_url_proxy_port = config.preview_url_proxy_port.unwrap_or(8443); - { - let conductor = conductor.clone(); - let bind = config.bind.clone(); - tokio::spawn(async move { - if let Err(e) = lapdev_proxy_ssh::server::run( - conductor, - bind.as_deref().unwrap_or("0.0.0.0"), - ssh_proxy_port, - ) - .await - { - error!("ssh proxy error: {e:?}"); - } - }); - } - - let state = Arc::new( - CoreState::new( - conductor, - ssh_proxy_port, - ssh_proxy_display_port, - static_dir, - ) - .await, - ); - let app = router::build_router(state.clone()); - let certs = state.certs.clone(); - - { - let db = state.db.clone(); - let tunnel_registry = state.kube_controller.tunnel_registry.clone(); - let host = config.bind.clone().unwrap_or_else(|| "0.0.0.0".to_string()); - tokio::spawn(async move { - loop { - let bind = format!("{host}:{preview_url_proxy_port}"); - let db_clone = db.clone(); - let tunnel_clone = tunnel_registry.clone(); - match lapdev_kube::start_preview_url_proxy_server(db_clone, tunnel_clone, &bind) - .await - { - Ok(_) => { - info!("Preview URL proxy server exited gracefully"); - break; - } - Err(err) => { - error!("Preview URL proxy server failed: {err:?}"); - warn!("Retrying preview URL proxy server in 5 seconds"); - tokio::time::sleep(Duration::from_secs(5)).await; - } - } + match ApiServer::new(static_dir).await { + Ok(server) => { + if let Err(e) = server.run().await { + tracing::error!("lapdev api server error: {e:#}"); } - }); - } - - { - // start http server - let bind = format!( - "{}:{}", - config.bind.clone().unwrap_or_else(|| "0.0.0.0".to_string()), - config.http_port.unwrap_or(80) - ); - let tcp_listener = TcpListener::bind(&bind) - .await - .with_context(|| format!("bind to {bind}"))?; - let app = app.clone(); - tokio::spawn(async move { - if let Err(err) = axum::serve(tcp_listener, app.into_make_service()).await { - tracing::error!("http server stopped error: {err}"); - } - }); - } - - let bind = format!( - "{}:{}", - config.bind.unwrap_or_else(|| "0.0.0.0".to_string()), - config.https_port.unwrap_or(443) - ); - let tcp_listener = TcpListener::bind(&bind) - .await - .with_context(|| format!("bind to {bind}"))?; - let tls_acceptor = TlsAcceptor::from(Arc::new(tls_config(certs)?)); - - pin_mut!(tcp_listener); - loop { - let tower_service = app.clone(); - let tls_acceptor = tls_acceptor.clone(); - - // Wait for new tcp connection - let (cnx, addr) = tcp_listener.accept().await?; - - tokio::spawn(async move { - // Wait for tls handshake to happen - let stream = match tls_acceptor.accept(cnx).await { - Err(_) => { - return; - } - Ok(stream) => stream, - }; - - // Hyper has its own `AsyncRead` and `AsyncWrite` traits and doesn't use tokio. - // `TokioIo` converts between them. - let stream = TokioIo::new(stream); - - // Hyper also has its own `Service` trait and doesn't use tower. We can use - // `hyper::service::service_fn` to create a hyper `Service` that calls our app through - // `tower::Service::call`. - let hyper_service = hyper::service::service_fn(move |request: Request| { - // We have to clone `tower_service` because hyper's `Service` uses `&self` whereas - // tower's `Service` requires `&mut self`. - // - // We don't need to call `poll_ready` since `Router` is always ready. - tower_service.clone().call(request) - }); - - let ret = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) - .serve_connection_with_upgrades(stream, hyper_service) - .await; - - if let Err(err) = ret { - tracing::warn!("error serving connection from {}: {}", addr, err); - } - }); + } + Err(e) => tracing::error!("failed to initialize lapdev api server: {e:#}"), } } -async fn setup_log( - cli: &Cli, - data_folder: &Path, -) -> Result { - let folder = cli - .logs_folder - .clone() - .unwrap_or_else(|| data_folder.join("logs")); - tokio::fs::create_dir_all(&folder).await?; - let file_appender = tracing_appender::rolling::Builder::new() - .max_log_files(30) - .rotation(tracing_appender::rolling::Rotation::DAILY) - .filename_prefix("lapdev.log") - .build(folder)?; - let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); +fn init_tracing() { let var = std::env::var("RUST_LOG").unwrap_or_default(); let var = format!("error,lapdev=info,lapdev_api=info,lapdev_conductor=info,lapdev_rpc=info,lapdev_common=info,lapdev_db=info,lapdev_enterprise=info,lapdev_proxy_ssh=info,lapdev_proxy_http=info,lapdev_kube=info,lapdev_kube_manager=info,{var}"); let filter = tracing_subscriber::EnvFilter::builder().parse_lossy(var); - tracing_subscriber::fmt() - .with_ansi(false) - .with_env_filter(filter) - .with_writer(non_blocking) - .init(); - Ok(guard) + tracing_subscriber::fmt().with_env_filter(filter).init(); } diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 12daac3..8f44556 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -11,6 +11,7 @@ use chrono::{DateTime, Utc}; use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor}; use lapdev_common::{ kube::{AppCatalogStatusEvent, ClusterStatusEvent}, + utils::resolve_api_host, UserRole, LAPDEV_AUTH_STATE_COOKIE, LAPDEV_AUTH_TOKEN_COOKIE, LAPDEV_BASE_HOSTNAME, }; use lapdev_conductor::{scheduler::LAPDEV_CPU_OVERCOMMIT, Conductor}; @@ -344,19 +345,24 @@ impl CoreState { } } - async fn websocket_base_url(&self) -> String { - let from_env = std::env::var("LAPDEV_API_URL").ok(); - let host = if let Some(url) = from_env.filter(|s| !s.trim().is_empty()) { - url + pub async fn websocket_base_url(&self) -> String { + let host = if let Ok(value) = std::env::var("LAPDEV_API_HOST") { + resolve_api_host(Some(value.as_str())) } else { - self.conductor + let stored = self + .conductor .hostnames .read() .await .get("") .cloned() - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "https://app.lap.dev".to_string()) + .unwrap_or_default(); + let stored_ref = if stored.trim().is_empty() { + None + } else { + Some(stored.as_str()) + }; + resolve_api_host(stored_ref) }; CoreState::normalize_ws_base(&host) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 5268be1..18b5b6e 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -29,6 +29,7 @@ lapdev-devbox-rpc.workspace = true lapdev-rpc.workspace = true futures.workspace = true lapdev-tunnel.workspace = true +lapdev-common.workspace = true # CLI-specific dependencies keyring = { version = "3.6.1", features = ["apple-native", "windows-native", "linux-native"] } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs new file mode 100644 index 0000000..b851eac --- /dev/null +++ b/crates/cli/src/config.rs @@ -0,0 +1,15 @@ +use lapdev_common::utils::resolve_api_host; + +/// Resolve the Lapdev API host and base HTTPS URL. +/// +/// Preference order: +/// 1. CLI argument (`--api-host`) +/// 2. `LAPDEV_API_HOST` environment variable +/// 3. Built-in default (`lapdev_common::LAPDEV_API_HOST`) +pub fn resolve_api_base_url(arg_host: Option) -> (String, String) { + let candidate = arg_host.or_else(|| std::env::var("LAPDEV_API_HOST").ok()); + + let host = resolve_api_host(candidate.as_deref()); + let base_url = format!("https://{}", host); + (host, base_url) +} diff --git a/crates/cli/src/devbox/mod.rs b/crates/cli/src/devbox/mod.rs index cbb8929..54694e1 100644 --- a/crates/cli/src/devbox/mod.rs +++ b/crates/cli/src/devbox/mod.rs @@ -1,23 +1,24 @@ use clap::Subcommand; +use crate::config; + pub mod commands; pub mod dns; -const DEFAULT_LAPDEV_URL: &str = "https://app.lap.dev"; - #[derive(Subcommand)] pub enum DevboxCommand { /// Connect to Lapdev devbox and establish port forwarding tunnels Connect { - /// API server URL - #[arg(long, env = "LAPDEV_API_URL", default_value = DEFAULT_LAPDEV_URL)] - api_url: String, + /// API host (e.g. app.lap.dev) + #[arg(long = "api-host", alias = "api-url", env = "LAPDEV_API_HOST")] + api_host: Option, }, } pub async fn handle_command(command: DevboxCommand) -> anyhow::Result<()> { match command { - DevboxCommand::Connect { api_url } => { + DevboxCommand::Connect { api_host } => { + let (_, api_url) = config::resolve_api_base_url(api_host); commands::connect::execute(&api_url).await?; } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 337fd4f..e01df91 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -2,6 +2,7 @@ use clap::{Parser, Subcommand}; mod api; mod auth; +mod config; mod devbox; #[derive(Parser)] @@ -25,23 +26,23 @@ enum Commands { #[arg(long)] device_name: Option, - /// API server URL - #[arg(long, env = "LAPDEV_API_URL", default_value = "https://app.lap.dev")] - api_url: String, + /// API host (e.g. app.lap.dev) + #[arg(long = "api-host", alias = "api-url", env = "LAPDEV_API_HOST")] + api_host: Option, }, /// Sign out (deletes token from keychain) Logout { - /// API server URL - #[arg(long, env = "LAPDEV_API_URL", default_value = "https://app.lap.dev")] - api_url: String, + /// API host (e.g. app.lap.dev) + #[arg(long = "api-host", alias = "api-url", env = "LAPDEV_API_HOST")] + api_host: Option, }, /// Show current session info Whoami { - /// API server URL - #[arg(long, env = "LAPDEV_API_URL", default_value = "https://app.lap.dev")] - api_url: String, + /// API host (e.g. app.lap.dev) + #[arg(long = "api-host", alias = "api-url", env = "LAPDEV_API_HOST")] + api_host: Option, }, /// Devbox commands @@ -78,14 +79,17 @@ async fn main() -> anyhow::Result<()> { match cli.command { Commands::Login { device_name, - api_url, + api_host, } => { + let (_, api_url) = config::resolve_api_base_url(api_host); devbox::commands::login::execute(&api_url, device_name).await?; } - Commands::Logout { api_url } => { + Commands::Logout { api_host } => { + let (_, api_url) = config::resolve_api_base_url(api_host); devbox::commands::logout::execute(&api_url).await?; } - Commands::Whoami { api_url } => { + Commands::Whoami { api_host } => { + let (_, api_url) = config::resolve_api_base_url(api_host); devbox::commands::whoami::execute(&api_url).await?; } Commands::Devbox { command } => { diff --git a/crates/common/src/kube.rs b/crates/common/src/kube.rs index dda129d..ff805b7 100644 --- a/crates/common/src/kube.rs +++ b/crates/common/src/kube.rs @@ -15,8 +15,11 @@ pub const SIDECAR_PROXY_MANAGER_ADDR_ENV_VAR: &str = "LAPDEV_SIDECAR_PROXY_MANAG pub const SIDECAR_PROXY_MANAGER_PORT_ENV_VAR: &str = "LAPDEV_SIDECAR_PROXY_MANAGER_PORT"; pub const DEFAULT_SIDECAR_PROXY_MANAGER_PORT: u16 = 5001; pub const SIDECAR_PROXY_PORT_ENV_VAR: &str = "LAPDEV_SIDECAR_PROXY_PORT"; -pub const DEFAULT_SIDECAR_PROXY_PORT: u16 = 15001; +pub const DEFAULT_SIDECAR_PROXY_PORT: u16 = 25001; +pub const DEFAULT_SIDECAR_PROXY_METRICS_PORT: u16 = 25090; pub const SIDECAR_PROXY_WORKLOAD_ENV_VAR: &str = "LAPDEV_SIDECAR_PROXY_WORKLOAD"; +pub const SIDECAR_PROXY_BIND_ADDR_ENV_VAR: &str = "LAPDEV_SIDECAR_PROXY_BIND_ADDR"; +pub const DEFAULT_SIDECAR_PROXY_BIND_ADDR: &str = "0.0.0.0"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KubeCluster { @@ -36,6 +39,7 @@ pub struct KubeClusterInfo { pub provider: Option, pub region: Option, pub status: KubeClusterStatus, + pub manager_namespace: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 8fbc712..c85243b 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -17,6 +17,7 @@ use uuid::Uuid; pub const LAPDEV_DEFAULT_OSUSER: &str = "lapdev"; pub const LAPDEV_BASE_HOSTNAME: &str = "lapdev-base-hostname"; +pub const LAPDEV_API_HOST: &str = "app.lap.dev"; pub const LAPDEV_ISOLATE_CONTAINER: &str = "lapdev-isolate-container"; pub const LAPDEV_AUTH_STATE_COOKIE: &str = "lapdev_auth_state"; pub const LAPDEV_AUTH_TOKEN_COOKIE: &str = "lapdev_auth_token"; diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 5ca5065..07a3f94 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -1,5 +1,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use crate::LAPDEV_API_HOST; + use rand::{distr::Alphanumeric, Rng}; use sha2::{Digest, Sha256}; @@ -35,3 +37,46 @@ pub fn format_repo_url(repo: &str) -> String { pub fn unix_timestamp() -> anyhow::Result { Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()) } + +/// Resolve the Lapdev API host from an optional input string. +/// +/// Accepts raw values that may include schemes (http/https/ws/wss), +/// trailing slashes, or the `/api` & `/api/v1` suffixes. Returns the +/// canonical host form (no scheme, no path). Falls back to the default +/// host when the input is `None` or empty. +pub fn resolve_api_host(raw: Option<&str>) -> String { + let candidate = raw + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()); + + let mut host = match candidate { + Some(value) => value, + None => return LAPDEV_API_HOST.to_string(), + }; + + for prefix in ["https://", "http://", "wss://", "ws://"] { + if let Some(stripped) = host.strip_prefix(prefix) { + host = stripped.to_string(); + break; + } + } + + host = host + .trim_start_matches('/') + .trim_end_matches('/') + .to_string(); + + for suffix in ["/api/v1", "/api"] { + if let Some(stripped) = host.strip_suffix(suffix) { + host = stripped.trim_end_matches('/').to_string(); + break; + } + } + + if host.is_empty() { + LAPDEV_API_HOST.to_string() + } else { + host + } +} diff --git a/crates/conductor/src/server.rs b/crates/conductor/src/server.rs index 0146206..e4c3d8b 100644 --- a/crates/conductor/src/server.rs +++ b/crates/conductor/src/server.rs @@ -156,28 +156,28 @@ impl Conductor { db, }; - { - let conductor = conductor.clone(); - tokio::spawn(async move { - conductor.monitor_workspace_hosts().await; - }); - } - - { - let conductor = conductor.clone(); - tokio::spawn(async move { - if let Err(e) = conductor.monitor_status_updates().await { - tracing::error!("conductor monitor status updates error: {e}"); - } - }); - } - - { - let conductor = conductor.clone(); - tokio::spawn(async move { - conductor.monitor_auto_start_stop().await; - }); - } + // { + // let conductor = conductor.clone(); + // tokio::spawn(async move { + // conductor.monitor_workspace_hosts().await; + // }); + // } + + // { + // let conductor = conductor.clone(); + // tokio::spawn(async move { + // if let Err(e) = conductor.monitor_status_updates().await { + // tracing::error!("conductor monitor status updates error: {e}"); + // } + // }); + // } + + // { + // let conductor = conductor.clone(); + // tokio::spawn(async move { + // conductor.monitor_auto_start_stop().await; + // }); + // } Ok(conductor) } diff --git a/crates/dashboard/src/kube_cluster.rs b/crates/dashboard/src/kube_cluster.rs index 0d40adf..33f11f5 100644 --- a/crates/dashboard/src/kube_cluster.rs +++ b/crates/dashboard/src/kube_cluster.rs @@ -6,6 +6,7 @@ use lapdev_common::{ }; use leptos::prelude::*; use leptos::task::spawn_local; +use std::rc::Rc; use crate::{ component::{ @@ -431,6 +432,15 @@ pub fn TokenDisplayModal( cluster_response: RwSignal, LocalStorage>, ) -> impl IntoView { let copy_success = RwSignal::new_local(false); + let command_copy_success = RwSignal::new_local(false); + let origin = StoredValue::new( + window() + .location() + .origin() + .unwrap_or_else(|_| "".to_string()) + .trim_end_matches('/') + .to_string(), + ); let copy_token = move |_| { if let Some(response) = cluster_response.get_untracked() { @@ -447,6 +457,43 @@ pub fn TokenDisplayModal( } }; + let manifest_path = Memo::new(move |_| { + cluster_response + .get() + .map(|response| format!("/install/lapdev-kube-manager.yaml?token={}", response.token)) + }); + + let install_command = { + Memo::new(move |_| { + manifest_path + .get() + .map(|path| { + let origin = origin.get_value(); + if origin.is_empty() { + format!("kubectl apply -f {}", path) + } else { + format!("kubectl apply -f {origin}{path}") + } + }) + .unwrap_or_default() + }) + }; + + let copy_install_command = move |_| { + let command = install_command.get(); + if command.is_empty() { + return; + } + let navigator = window().navigator(); + let clipboard = navigator.clipboard(); + let _ = clipboard.write_text(&command); + command_copy_success.set(true); + set_timeout( + move || command_copy_success.set(false), + std::time::Duration::from_secs(2), + ); + }; + view! { +
+ +
+