From 9fb15a731d54404fb1baf08a4f90df62b16f4e1f Mon Sep 17 00:00:00 2001 From: Marc Schreiber Date: Thu, 11 Jan 2024 10:55:41 +0100 Subject: [PATCH] Bootstrap Companions in Kubernetes Environments This commit introduces the configuration `bootstrapping.containers` which provides a way to parse the configuration of application wide companions because the current available configuration of companions if quite limiting (Current backends, Docker and Kubernetes, offer way more options than the PREvant configuration object allows). For example, PREvant was limited to self-contained applications where each microservice only relies on interactions via network API calls (REST, database connections, messaging, etc.) With this commit PREvant is now able to deploy application companions that are more powerful than the PREvant configuration in Kubernetes backends. If `bootstrapping.containers` is defined, PREvant will start one or more containers on the infrastructure backend that are expected to generate Kubernetes manifests as output on standard out (stdout) that will be parsed by PREvant and supported are: - roles and role bindings - config maps and secrets - service accounts - persistent volume claims - services - pods, deployments, stateful sets, and jobs Then before deploying these manifests PREvant merges all objects with the objects generated from the HTTP request payload. Thus you can add or overwrite configurations. For example, you can change the image used or an environment variable. If you overwrite any configuration the companion will be turned into an instance (as PREvant did before). Ingresses won't be deployed if the bootstrap container outputs one of these. Instead they will be parsed and if they use the ingress class `nginx` they will be transformed into Traefik ingresses and middlewares so that the microservices will be available via web interface. This approach make #143 obsolete and fixes #123 and contributes to #146. --- api/Cargo.lock | 355 +++-- api/Cargo.toml | 11 +- api/src/apps/mod.rs | 28 +- api/src/apps/routes.rs | 6 +- api/src/config/app_selector.rs | 5 +- api/src/config/companion.rs | 158 ++- api/src/config/mod.rs | 75 +- api/src/config/secret.rs | 4 +- api/src/deployment/deployment_unit.rs | 44 +- api/src/deployment/hooks.rs | 17 +- .../infrastructure/dummy_infrastructure.rs | 6 +- .../kubernetes/deployment_unit.rs | 1194 +++++++++++++++++ .../kubernetes/infrastructure.rs | 509 +++---- api/src/infrastructure/kubernetes/mod.rs | 3 +- api/src/infrastructure/kubernetes/payloads.rs | 318 +++-- api/src/infrastructure/traefik.rs | 157 ++- api/src/models/app_name.rs | 6 + api/src/models/service.rs | 8 - assets/bootstrap-companions.svg | 1146 ++++++++++++++++ docs/companions.md | 107 ++ 20 files changed, 3460 insertions(+), 697 deletions(-) create mode 100644 api/src/infrastructure/kubernetes/deployment_unit.rs create mode 100644 assets/bootstrap-companions.svg create mode 100644 docs/companions.md diff --git a/api/Cargo.lock b/api/Cargo.lock index 84ae9848..6cc37187 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -25,9 +25,9 @@ checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" [[package]] name = "ahash" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" +checksum = "77c3a9648d43b9cd48db467b3f87fdd6e146bcc88ab0180006cef2179fe11d01" dependencies = [ "cfg-if", "once_cell", @@ -136,18 +136,18 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] name = "async-trait" -version = "0.1.75" +version = "0.1.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf6721fb0140e4f897002dd086c06f6c27775df19cfe1fccb21181a48fd2c98" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -192,12 +192,6 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" -[[package]] -name = "base64" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" - [[package]] name = "base64" version = "0.21.5" @@ -334,7 +328,7 @@ checksum = "005fa0c5bd20805466dda55eb34cd709bb31a2592bb26927b47714eeed6914d8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", "synstructure 0.13.0", ] @@ -440,9 +434,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.4.11" +version = "4.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfaff671f6b22ca62406885ece523383b9b64022e341e53e009a62ebc47a45f2" +checksum = "dcfab8ba68f3668e89f6ff60f5b205cea56aa7b769451a59f34b8682f51c056d" dependencies = [ "clap_builder", "clap_derive", @@ -450,9 +444,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.4.11" +version = "4.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a216b506622bb1d316cd51328dce24e07bdff4a6128a47c7e7fad11878d5adbb" +checksum = "fb7fb5e4e979aec3be7791562fcba452f94ad85e954da024396433e0e25a79e9" dependencies = [ "anstream", "anstyle", @@ -469,7 +463,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -553,9 +547,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.14.4" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" dependencies = [ "darling_core", "darling_macro", @@ -563,27 +557,27 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.14.4" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 1.0.109", + "syn 2.0.46", ] [[package]] name = "darling_macro" -version = "0.14.4" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" dependencies = [ "darling_core", "quote", - "syn 1.0.109", + "syn 2.0.46", ] [[package]] @@ -601,9 +595,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eb30d70a07a3b04884d2677f06bec33509dc67ca60d92949e5535352d3191dc" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", ] @@ -638,7 +632,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -651,27 +645,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - [[package]] name = "displaydoc" version = "0.2.4" @@ -680,7 +653,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -691,7 +664,7 @@ dependencies = [ "async-stream", "base64 0.13.1", "bytes 1.5.0", - "futures 0.3.29", + "futures 0.3.30", "http", "libflate", "log", @@ -814,16 +787,16 @@ checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "figment" -version = "0.10.12" +version = "0.10.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "649f3e5d826594057e9a519626304d8da859ea8a0b18ce99500c586b8d45faee" +checksum = "7629b8c7bcd214a072c2c88b263b5bb3ceb54c34365d8c41c1665461aeae0993" dependencies = [ "atomic 0.6.0", "parking_lot", "pear", "serde", "tempfile", - "toml 0.8.8", + "toml", "uncased", "version_check", ] @@ -888,9 +861,9 @@ checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" [[package]] name = "futures" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" dependencies = [ "futures-channel", "futures-core", @@ -903,9 +876,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", "futures-sink", @@ -913,15 +886,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" [[package]] name = "futures-executor" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" dependencies = [ "futures-core", "futures-task", @@ -930,38 +903,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" [[package]] name = "futures-macro" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] name = "futures-sink" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures 0.1.31", "futures-channel", @@ -983,7 +956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce54d63f8b0c75023ed920d46fd71d0cbbb830b0ee012726b5b4f506fb6dea5b" dependencies = [ "bytes 0.5.6", - "futures 0.3.29", + "futures 0.3.30", "memchr", "pin-project 0.4.30", ] @@ -1115,6 +1088,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "http" version = "0.2.11" @@ -1252,9 +1234,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.58" +version = "0.1.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +checksum = "b6a67363e2aa4443928ce15e57ebae94fd8949958fd1223c4cfc0cd473ad7539" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1430,13 +1412,13 @@ checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" [[package]] name = "is-terminal" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +checksum = "0bad00257d07be169d870ab665980b06cdb366d792ad690bf2e76876dc503455" dependencies = [ "hermit-abi", "rustix", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -1478,21 +1460,23 @@ dependencies = [ ] [[package]] -name = "jsonpath_lib" -version = "0.3.0" +name = "jsonpath-rust" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaa63191d68230cccb81c5aa23abd53ed64d83337cacbb25a7b8c7979523774f" +checksum = "06cc127b7c3d270be504572364f9569761a180b981919dd0d87693a7f5fb7829" dependencies = [ - "log", - "serde", + "pest", + "pest_derive", + "regex", "serde_json", + "thiserror", ] [[package]] name = "k8s-openapi" -version = "0.18.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd990069640f9db34b3b0f7a1afc62a05ffaa3be9b66aa3c313f58346df7f788" +checksum = "edc3606fd16aca7989db2f84bb25684d0270c6d6fa1dbcd0025af7b4130523a6" dependencies = [ "base64 0.21.5", "bytes 1.5.0", @@ -1504,9 +1488,9 @@ dependencies = [ [[package]] name = "kube" -version = "0.84.0" +version = "0.87.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14bd236a6f6ddeac3fefa2863eb4e363cb3a2c49d66619e181b5b8f8f0787575" +checksum = "3499c8d60c763246c7a213f51caac1e9033f46026904cb89bc8951ae8601f26e" dependencies = [ "k8s-openapi", "kube-client", @@ -1516,22 +1500,22 @@ dependencies = [ [[package]] name = "kube-client" -version = "0.84.0" +version = "0.87.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04a28620131ca89b2509e52f5e1b71bfa3e61a50321836b2ae373bc18e0309e6" +checksum = "033450dfa0762130565890dadf2f8835faedf749376ca13345bcd8ecd6b5f29f" dependencies = [ - "base64 0.20.0", + "base64 0.21.5", "bytes 1.5.0", "chrono", - "dirs-next", "either", - "futures 0.3.29", + "futures 0.3.30", + "home", "http", "http-body", "hyper", "hyper-rustls", "hyper-timeout", - "jsonpath_lib", + "jsonpath-rust", "k8s-openapi", "kube-core", "pem", @@ -1552,9 +1536,9 @@ dependencies = [ [[package]] name = "kube-core" -version = "0.84.0" +version = "0.87.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8227a989f1eeee3bcbf045165d6aca462af3744ecd4dfdcfba81051fb7de428e" +checksum = "b5bba93d054786eba7994d03ce522f368ef7d48c88a1826faa28478d85fb63ae" dependencies = [ "chrono", "form_urlencoded", @@ -1569,15 +1553,15 @@ dependencies = [ [[package]] name = "kube-derive" -version = "0.84.0" +version = "0.87.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d227fcf3e12f53ea1a38d4766a8c29f8b27795579e4146464effb88d52dd99" +checksum = "91e98dd5e5767c7b894c1f0e41fd628b145f808e981feb8b08ed66455d47f1a4" dependencies = [ "darling", "proc-macro2", "quote", "serde_json", - "syn 1.0.109", + "syn 2.0.46", ] [[package]] @@ -1612,17 +1596,6 @@ dependencies = [ "rle-decode-fast", ] -[[package]] -name = "libredox" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" -dependencies = [ - "bitflags 2.4.1", - "libc", - "redox_syscall", -] - [[package]] name = "linux-raw-sys" version = "0.4.12" @@ -1677,9 +1650,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" [[package]] name = "mime" @@ -1823,14 +1796,14 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] name = "object" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -1868,7 +1841,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -1953,16 +1926,17 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] name = "pem" -version = "1.1.1" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" +checksum = "1b8fcc794035347fb64beda2d3b462595dd2753e3f268d89c5aae77e8cf2c310" dependencies = [ - "base64 0.13.1", + "base64 0.21.5", + "serde", ] [[package]] @@ -2002,7 +1976,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -2046,7 +2020,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -2095,7 +2069,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -2167,7 +2141,7 @@ dependencies = [ "evmap", "failure", "figment", - "futures 0.3.29", + "futures 0.3.30", "handlebars", "http-api-problem", "jira_query", @@ -2179,6 +2153,7 @@ dependencies = [ "pest", "pest_derive", "regex", + "regex-syntax 0.8.2", "reqwest", "rocket", "schemars", @@ -2193,7 +2168,7 @@ dependencies = [ "shiplift", "tempfile", "tokio", - "toml 0.7.8", + "toml", "url", "uuid", "yansi 0.5.1", @@ -2211,9 +2186,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.71" +version = "1.0.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75cb1540fadbd5b8fbccc4dddad2734eba435053f725621c070711a14bb5f4b8" +checksum = "2de98502f212cfcea8d0bb305bd0f49d7ebdd75b64ba0a68f937d888f4e0d6db" dependencies = [ "unicode-ident", ] @@ -2226,16 +2201,16 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", "version_check", "yansi 1.0.0-rc.1", ] [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -2279,35 +2254,24 @@ dependencies = [ "bitflags 1.3.2", ] -[[package]] -name = "redox_users" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" -dependencies = [ - "getrandom", - "libredox", - "thiserror", -] - [[package]] name = "ref-cast" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53313ec9f12686aeeffb43462c3ac77aa25f590a5f630eb2cde0de59417b29c7" +checksum = "c4846d4c50d1721b1a3bef8af76924eef20d5e723647333798c1b519b3a9473f" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2566c4bf6845f2c2e83b27043c3f5dfcd5ba8f2937d6c00dc009bfb51a079dc4" +checksum = "5fddb4f8d99b0a2ebafc65a87a69a7b9875e4b1ae1f00db265d300ef7f28bccc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -2437,7 +2401,7 @@ dependencies = [ "bytes 1.5.0", "either", "figment", - "futures 0.3.29", + "futures 0.3.30", "indexmap", "log", "memchr", @@ -2474,7 +2438,7 @@ dependencies = [ "proc-macro2", "quote", "rocket_http", - "syn 2.0.42", + "syn 2.0.46", "unicode-xid", "version_check", ] @@ -2487,7 +2451,7 @@ checksum = "37a1663694d059fe5f943ea5481363e48050acedd241d46deb2e27f71110389e" dependencies = [ "cookie", "either", - "futures 0.3.29", + "futures 0.3.30", "http", "hyper", "indexmap", @@ -2594,11 +2558,11 @@ checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" [[package]] name = "schannel" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -2692,9 +2656,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.193" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +checksum = "0b114498256798c94a0689e1a15fec6005dee8ac1f41de56404b67afc2a4b773" dependencies = [ "serde_derive", ] @@ -2711,13 +2675,13 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.193" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +checksum = "a3385e45322e8f9931410f01b3031ec534c3947d0e94c18049af4d9f9907d4e0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -2742,11 +2706,10 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.108" +version = "1.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +checksum = "6fbd975230bada99c8bb618e0c365c2eefa219158d5c6c29610fd09ff1833257" dependencies = [ - "indexmap", "itoa", "ryu", "serde", @@ -2785,9 +2748,9 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.9.29" +version = "0.9.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15e0ef66bf939a7c890a0bf6d5a733c70202225f9888a89ed5c62298b019129" +checksum = "b1bf28c79a99f70ee1f1d83d10c875d2e70618417fda01ad1785e027579d9d38" dependencies = [ "indexmap", "itoa", @@ -2961,9 +2924,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.42" +version = "2.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b7d0a2c048d661a1a59fcd7355baa232f7ed34e0ee4df2eef3c1c1c0d3852d8" +checksum = "89456b690ff72fddcecf231caedbe615c59480c93358a93dfae7fc29e3ebbf0e" dependencies = [ "proc-macro2", "quote", @@ -2990,7 +2953,7 @@ checksum = "285ba80e733fac80aa4270fbcdf83772a79b80aa35c97075320abfee4a915b06" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", "unicode-xid", ] @@ -3034,15 +2997,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.8.1" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" +checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" dependencies = [ "cfg-if", "fastrand", "redox_syscall", "rustix", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -3062,22 +3025,22 @@ checksum = "a38c90d48152c236a3ab59271da4f4ae63d678c5d7ad6b7714d7cb9760be5e4b" [[package]] name = "thiserror" -version = "1.0.51" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f11c217e1416d6f036b870f14e0413d480dbf28edbee1f877abaf0206af43bb7" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.51" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -3181,7 +3144,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -3229,18 +3192,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "toml" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit 0.19.15", -] - [[package]] name = "toml" version = "0.8.8" @@ -3269,8 +3220,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap", - "serde", - "serde_spanned", "toml_datetime", "winnow", ] @@ -3358,7 +3307,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -3576,7 +3525,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", "wasm-bindgen-shared", ] @@ -3610,7 +3559,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3686,11 +3635,11 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.51.1" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.48.5", + "windows-targets 0.52.0", ] [[package]] @@ -3827,9 +3776,9 @@ checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" [[package]] name = "winnow" -version = "0.5.30" +version = "0.5.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b5c3db89721d50d0e2a673f5043fc4722f76dcc352d7b1ab8b8288bed4ed2c5" +checksum = "97a4882e6b134d6c28953a387571f1acdd3496830d5e36c5e3a1075580ea641c" dependencies = [ "memchr", ] @@ -3858,9 +3807,9 @@ checksum = "dad7bb64b8ef9c0aa27b6da38b452b0ee9fd82beaf276a87dd796fb55cbae14e" [[package]] name = "xattr" -version = "1.1.3" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7dae5072fe1f8db8f8d29059189ac175196e410e40ba42d5d4684ae2f750995" +checksum = "914566e6413e7fa959cc394fb30e563ba80f3541fbd40816d4c05a0fc3f2a0f1" dependencies = [ "libc", "linux-raw-sys", @@ -3902,7 +3851,7 @@ checksum = "9e6936f0cce458098a201c245a11bef556c6a0181129c7034d10d76d1ec3a2b8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", "synstructure 0.13.0", ] @@ -3923,7 +3872,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] [[package]] @@ -3943,7 +3892,7 @@ checksum = "e6a647510471d372f2e6c2e6b7219e44d8c574d24fdc11c610a61455782f18c3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", "synstructure 0.13.0", ] @@ -3973,5 +3922,5 @@ checksum = "7a4a1638a1934450809c2266a70362bfc96cd90550c073f5b8a55014d1010157" dependencies = [ "proc-macro2", "quote", - "syn 2.0.42", + "syn 2.0.46", ] diff --git a/api/Cargo.toml b/api/Cargo.toml index 954f2e2c..0ba5d7f9 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -26,14 +26,15 @@ futures = { version = "0.3", features = ["compat"] } handlebars = "4.5" http-api-problem = "0.57" jira_query = "1.3" -k8s-openapi = { version = "0.18", default-features = false, features = ["v1_24"] } -kube = { version = "0.84", default-features = false, features = ["client", "derive", "rustls-tls"] } +k8s-openapi = { version = "0.20", default-features = false, features = ["v1_24"] } +kube = { version = "0.87", default-features = false, features = ["client", "derive", "rustls-tls"] } lazy_static = "1.4" log = "0.4" multimap = "0.9" pest = "2.6" pest_derive = "2.6" -regex = "1.9" +regex = "1.10" +regex-syntax = "0.8" reqwest = { version = "0.11", features = ["json"] } rocket = { version = "0.5", features = ["json"] } schemars = "0.8" @@ -45,9 +46,9 @@ serde_json = "1.0" serde_regex = "1.1" serde_yaml = "0.9" tokio = { version = "1.29", features = ["macros", "rt", "rt-multi-thread", "sync", "time"] } -toml = "0.7" +toml = "0.8" url = { version = "2.4", features = ["serde"] } -uuid = { version = "1.3", features = ["serde", "v4"] } +uuid = { version = "1.5", features = ["serde", "v4"] } yansi = "0.5" diff --git a/api/src/apps/mod.rs b/api/src/apps/mod.rs index 12dd0204..9744d581 100644 --- a/api/src/apps/mod.rs +++ b/api/src/apps/mod.rs @@ -285,6 +285,12 @@ impl AppsService { let deployment_unit = if let Ok(Some(base_traefik_ingress_route)) = self.infrastructure.base_traefik_ingress_route().await { + trace!( + "The base URL for {app_name} is: {:?}", + base_traefik_ingress_route + .to_url() + .map(|url| url.to_string()) + ); deployment_unit_builder .apply_base_traefik_ingress_route(base_traefik_ingress_route) .build() @@ -511,7 +517,7 @@ mod tests { let apps = AppsService::new(config, infrastructure)?; apps.create_or_update( - &AppName::from_str("master").unwrap(), + &AppName::master(), &AppStatusChangeId::new(), None, &vec![sc!("service-a"), sc!("service-b")], @@ -521,7 +527,7 @@ mod tests { apps.create_or_update( &AppName::from_str("branch").unwrap(), &AppStatusChangeId::new(), - Some(AppName::from_str("master").unwrap()), + Some(AppName::master()), &vec![sc!("service-b")], ) .await?; @@ -563,7 +569,7 @@ mod tests { apps.create_or_update( &AppName::from_str("branch").unwrap(), &AppStatusChangeId::new(), - Some(AppName::from_str("master").unwrap()), + Some(AppName::master()), &vec![sc!("service-a")], ) .await?; @@ -596,7 +602,7 @@ mod tests { let apps = AppsService::new(config, infrastructure)?; apps.create_or_update( - &AppName::from_str("master").unwrap(), + &AppName::master(), &AppStatusChangeId::new(), None, &vec![sc!("mariadb")], @@ -660,7 +666,7 @@ mod tests { let infrastructure = Box::new(Dummy::new()); let apps = AppsService::new(config, infrastructure)?; - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); apps.create_or_update( &app_name, @@ -857,7 +863,7 @@ Log msg 3 of service-a of app master let infrastructure = Box::new(Dummy::new()); let apps = AppsService::new(config, infrastructure)?; - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); apps.create_or_update( &app_name, @@ -942,7 +948,7 @@ Log msg 3 of service-a of app master let infrastructure = Box::new(Dummy::with_delay(std::time::Duration::from_millis(500))); let apps = Arc::new(AppsService::new(config, infrastructure)?); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); apps.create_or_update( &app_name, &AppStatusChangeId::new(), @@ -959,7 +965,7 @@ Log msg 3 of service-a of app master .unwrap(); rt.block_on(apps_clone.delete_app(&app_name, &AppStatusChangeId::new())) }); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let handle2 = std::thread::spawn(move || { let rt = runtime::Builder::new_current_thread() .enable_time() @@ -1055,7 +1061,7 @@ Log msg 3 of service-a of app master "#; let (_temp_js_file, config) = config_with_deployment_hook(script); - let app_name = &AppName::from_str("master").unwrap(); + let app_name = &AppName::master(); let infrastructure = Box::new(Dummy::new()); let apps = AppsService::new(config, infrastructure)?; @@ -1087,7 +1093,7 @@ Log msg 3 of service-a of app master ))); let apps = AppsService::new(Config::default(), infrastructure)?; - let app_name = &AppName::from_str("master").unwrap(); + let app_name = &AppName::master(); apps.create_or_update( &app_name, &AppStatusChangeId::new(), @@ -1129,7 +1135,7 @@ Log msg 3 of service-a of app master let infrastructure = Box::new(Dummy::new()); let apps = AppsService::new(Config::default(), infrastructure)?; - let app_name = &AppName::from_str("master").unwrap(); + let app_name = &AppName::master(); apps.create_or_update( &app_name, &AppStatusChangeId::new(), diff --git a/api/src/apps/routes.rs b/api/src/apps/routes.rs index 1cb6ed41..ef153878 100644 --- a/api/src/apps/routes.rs +++ b/api/src/apps/routes.rs @@ -182,7 +182,7 @@ async fn change_status( )] async fn logs( app_name: Result, - service_name: String, + service_name: &str, since: Option, limit: Option, apps: &State>, @@ -205,13 +205,13 @@ async fn logs( let limit = limit.unwrap_or(20_000); let log_chunk = apps - .get_logs(&app_name, &service_name, &since, limit) + .get_logs(&app_name, &service_name.to_string(), &since, limit) .await?; Ok(LogsResponse { log_chunk, app_name, - service_name, + service_name: service_name.to_string(), limit, }) } diff --git a/api/src/config/app_selector.rs b/api/src/config/app_selector.rs index 1f6ddb8f..6922eaf3 100644 --- a/api/src/config/app_selector.rs +++ b/api/src/config/app_selector.rs @@ -23,16 +23,17 @@ * THE SOFTWARE. * =========================LICENSE_END================================== */ +use crate::models::AppName; use regex::Regex; #[derive(Clone)] pub(super) struct AppSelector(Regex); impl AppSelector { - pub fn matches(&self, app_name: &str) -> bool { + pub fn matches(&self, app_name: &AppName) -> bool { match self.0.captures(app_name) { None => false, - Some(captures) => captures.get(0).map_or("", |m| m.as_str()) == app_name, + Some(captures) => captures.get(0).map_or("", |m| m.as_str()) == app_name.as_str(), } } } diff --git a/api/src/config/companion.rs b/api/src/config/companion.rs index aa150b5f..7753b98d 100644 --- a/api/src/config/companion.rs +++ b/api/src/config/companion.rs @@ -25,11 +25,21 @@ */ use crate::config::AppSelector; use crate::models::service::ContainerType; -use crate::models::{Environment, Image, Router, ServiceConfig}; +use crate::models::{AppName, Environment, Image, Router, ServiceConfig}; +use handlebars::Handlebars; use secstr::SecUtf8; use serde_value::Value; use std::collections::BTreeMap; use std::path::PathBuf; +use url::Url; + +#[derive(Clone, Default, Deserialize)] +pub(super) struct Companions { + #[serde(default)] + bootstrapping: Bootstrapping, + #[serde(flatten)] + companions: BTreeMap, +} #[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -78,12 +88,52 @@ pub enum DeploymentStrategy { RedeployNever, } +#[derive(Clone, Default, Deserialize)] +struct Bootstrapping { + containers: Vec, +} + +#[derive(Clone, Deserialize)] +pub struct BootstrappingContainer { + image: Image, + #[serde(default)] + args: Vec, +} + +impl Companions { + pub(super) fn companion_configs

( + &self, + app_name: &AppName, + predicate: P, + ) -> Vec<(ServiceConfig, DeploymentStrategy, StorageStrategy)> + where + P: Fn(&Companion) -> bool, + { + self.companions + .iter() + .filter(|(_, companion)| companion.matches_app_name(app_name)) + .filter(|(_, companion)| predicate(companion)) + .map(|(_, companion)| { + ( + ServiceConfig::from(companion.clone()), + companion.deployment_strategy().clone(), + companion.storage_strategy().clone(), + ) + }) + .collect() + } + + pub(super) fn companion_bootstrapping_containers(&self) -> &Vec { + &self.bootstrapping.containers + } +} + impl Companion { pub fn companion_type(&self) -> &CompanionType { &self.companion_type } - pub fn matches_app_name(&self, app_name: &str) -> bool { + pub fn matches_app_name(&self, app_name: &AppName) -> bool { self.app_selector.matches(app_name) } @@ -149,6 +199,42 @@ impl Default for StorageStrategy { } } +impl BootstrappingContainer { + pub fn image(&self) -> &Image { + &self.image + } + + pub fn templated_args(&self, app_name: &AppName, base_url: &Option) -> Vec { + let handlebars = Handlebars::new(); + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct AppData<'a> { + name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + base_url: &'a Option, + } + // TODO: apply same pattern as for companions. {{application.name}}, {{service.…}}… + #[derive(Serialize)] + struct Data<'a> { + application: AppData<'a>, + } + + let data = Data { + application: AppData { + name: &app_name, + base_url, + }, + }; + + self.args + .iter() + // TODO: handle result + .map(|arg| handlebars.render_template(&arg, &data).unwrap()) + .collect() + } +} + #[cfg(test)] mod tests { use super::*; @@ -160,6 +246,12 @@ mod tests { }; } + macro_rules! companions_from_str { + ( $config_str:expr ) => { + toml::de::from_str::($config_str).unwrap() + }; + } + #[test] fn should_parse_companion_with_required_fields() { let companion = companion_from_str!( @@ -181,4 +273,66 @@ mod tests { DeploymentStrategy::RedeployAlways ); } + + #[test] + fn should_parse_companion_bootstrap_containers() { + let companions = companions_from_str!( + r#" + [[bootstrapping.containers]] + image = "busybox" + "# + ); + + let container = &companions.bootstrapping.containers[0]; + + assert_eq!(container.image, Image::from_str("busybox").unwrap()); + assert_eq!( + container.templated_args(&AppName::master(), &None), + Vec::::new() + ); + } + + #[test] + fn should_parse_companion_bootstrap_containers_and_template_args() { + let companions = companions_from_str!( + r#" + [[bootstrapping.containers]] + image = "busybox" + args = [ "echo", "Hello {{application.name}}" ] + "# + ); + + let container = &companions.bootstrapping.containers[0]; + + assert_eq!(container.image, Image::from_str("busybox").unwrap()); + assert_eq!( + container.templated_args(&AppName::master(), &None), + vec![String::from("echo"), String::from("Hello master")] + ); + } + + #[test] + fn should_parse_companion_bootstrap_containers_and_template_url_args() { + let companions = companions_from_str!( + r#" + [[bootstrapping.containers]] + image = "busybox" + args = [ "echo", "Hello {{application.baseUrl}}" ] + "# + ); + + let container = &companions.bootstrapping.containers[0]; + + assert_eq!(container.image, Image::from_str("busybox").unwrap()); + assert_eq!( + container.templated_args( + &AppName::master(), + &Some(Url::parse("http://example.com").unwrap()) + ), + vec![ + String::from("echo"), + String::from("Hello http://example.com/") + ] + ); + } } diff --git a/api/src/config/mod.rs b/api/src/config/mod.rs index beb054a5..98cfadfd 100644 --- a/api/src/config/mod.rs +++ b/api/src/config/mod.rs @@ -24,11 +24,13 @@ * =========================LICENSE_END================================== */ +pub use self::companion::BootstrappingContainer; pub use self::companion::DeploymentStrategy; pub use self::companion::StorageStrategy; -use self::companion::{Companion, CompanionType}; +use self::companion::{Companion, CompanionType, Companions}; pub use self::container::ContainerConfig; pub use self::runtime::Runtime; +use crate::models::AppName; use crate::models::ServiceConfig; pub(self) use app_selector::AppSelector; use clap::Parser; @@ -146,7 +148,8 @@ pub struct Config { runtime: Runtime, containers: Option, jira: Option, - companions: Option>, + #[serde(default)] + companions: Companions, services: Option>, hooks: Option>, #[serde(default)] @@ -189,7 +192,7 @@ impl Config { pub fn service_companion_configs( &self, - app_name: &str, + app_name: &AppName, ) -> Vec<(ServiceConfig, DeploymentStrategy, StorageStrategy)> { self.companion_configs(app_name, |companion| { companion.companion_type() == &CompanionType::Service @@ -198,39 +201,29 @@ impl Config { pub fn application_companion_configs( &self, - app_name: &str, + app_name: &AppName, ) -> Vec<(ServiceConfig, DeploymentStrategy, StorageStrategy)> { self.companion_configs(app_name, |companion| { companion.companion_type() == &CompanionType::Application }) } + pub fn companion_bootstrapping_containers(&self) -> &Vec { + self.companions.companion_bootstrapping_containers() + } + fn companion_configs

( &self, - app_name: &str, + app_name: &AppName, predicate: P, ) -> Vec<(ServiceConfig, DeploymentStrategy, StorageStrategy)> where P: Fn(&Companion) -> bool, { - match &self.companions { - None => vec![], - Some(companions_map) => companions_map - .iter() - .filter(|(_, companion)| companion.matches_app_name(app_name)) - .filter(|(_, companion)| predicate(companion)) - .map(|(_, companion)| { - ( - companion.clone().into(), - companion.deployment_strategy().clone(), - companion.storage_strategy().clone(), - ) - }) - .collect(), - } + self.companions.companion_configs(app_name, predicate) } - pub fn add_secrets_to(&self, service_config: &mut ServiceConfig, app_name: &str) { + pub fn add_secrets_to(&self, service_config: &mut ServiceConfig, app_name: &AppName) { if let Some(services) = &self.services { if let Some(service) = services.get(service_config.service_name()) { service.add_secrets_to(service_config, app_name); @@ -262,7 +255,7 @@ impl JiraConfig { } impl Service { - pub fn add_secrets_to(&self, service_config: &mut ServiceConfig, app_name: &str) { + pub fn add_secrets_to(&self, service_config: &mut ServiceConfig, app_name: &AppName) { if let Some(secrets) = &self.secrets { for s in secrets.iter().filter(|s| s.matches_app_name(app_name)) { let (path, sec) = s.clone().into(); @@ -340,7 +333,7 @@ mod tests { "# ); - let companion_configs = config.application_companion_configs("master"); + let companion_configs = config.application_companion_configs(&AppName::master()); assert_eq!(companion_configs.len(), 1); companion_configs.iter().for_each(|(config, _, _)| { @@ -375,7 +368,7 @@ mod tests { "# ); - let companion_configs = config.service_companion_configs("master"); + let companion_configs = config.service_companion_configs(&AppName::master()); assert_eq!(companion_configs.len(), 1); companion_configs.iter().for_each(|(config, _, _)| { @@ -401,7 +394,7 @@ mod tests { "# ); - let companion_configs = config.service_companion_configs("master"); + let companion_configs = config.service_companion_configs(&AppName::master()); assert_eq!(companion_configs.len(), 1); companion_configs.iter().for_each(|(_, strategy, _)| { @@ -424,7 +417,7 @@ mod tests { "# ); - let companion_configs = config.application_companion_configs("master"); + let companion_configs = config.application_companion_configs(&AppName::master()); assert_eq!(companion_configs.len(), 1); companion_configs.iter().for_each(|(config, _, _)| { @@ -446,7 +439,7 @@ mod tests { "# ); - let companion_configs = config.application_companion_configs("master"); + let companion_configs = config.application_companion_configs(&AppName::master()); assert_eq!(companion_configs.len(), 1); companion_configs.iter().for_each(|(config, _, _)| { @@ -470,7 +463,7 @@ mod tests { "# ); - let companion_configs = config.application_companion_configs("master"); + let companion_configs = config.application_companion_configs(&AppName::master()); assert_eq!(companion_configs.len(), 1); companion_configs.iter().for_each(|(config, _, _)| { @@ -500,7 +493,8 @@ mod tests { "# ); - let companion_configs = config.application_companion_configs("random-name"); + let companion_configs = + config.application_companion_configs(&AppName::from_str("random-name").unwrap()); assert_eq!(companion_configs.len(), 0); } @@ -517,7 +511,7 @@ mod tests { ); let mut service_config = service_config!("mariadb"); - config.add_secrets_to(&mut service_config, &String::from("master")); + config.add_secrets_to(&mut service_config, &AppName::master()); let secret_file_content = service_config .files() .expect("File content is missing") @@ -539,7 +533,7 @@ mod tests { ); let mut service_config = service_config!("mariadb"); - config.add_secrets_to(&mut service_config, &String::from("master")); + config.add_secrets_to(&mut service_config, &AppName::master()); let secret_file_content = service_config .files() @@ -562,7 +556,10 @@ mod tests { ); let mut service_config = service_config!("mariadb"); - config.add_secrets_to(&mut service_config, &String::from("master-1.x")); + config.add_secrets_to( + &mut service_config, + &AppName::from_str("master-1.x").unwrap(), + ); let secret_file_content = service_config .files() @@ -585,7 +582,10 @@ mod tests { ); let mut service_config = service_config!("mariadb"); - config.add_secrets_to(&mut service_config, &String::from("random-app-name")); + config.add_secrets_to( + &mut service_config, + &AppName::from_str("random-app-name").unwrap(), + ); assert!(service_config.files().is_none()); } @@ -603,7 +603,10 @@ mod tests { ); let mut service_config = service_config!("mariadb"); - config.add_secrets_to(&mut service_config, &String::from("master-1.x")); + config.add_secrets_to( + &mut service_config, + &AppName::from_str("master-1.x").unwrap(), + ); assert_eq!(service_config.files(), None); } @@ -668,7 +671,7 @@ mod tests { "# ); - let companion_configs = config.application_companion_configs("master"); + let companion_configs = config.application_companion_configs(&AppName::master()); assert_eq!(companion_configs.len(), 1); companion_configs.iter().for_each(|(config, _, _)| { @@ -689,7 +692,7 @@ mod tests { "# ); - let companion_configs = config.application_companion_configs("master"); + let companion_configs = config.application_companion_configs(&AppName::master()); assert_eq!(companion_configs.len(), 1); companion_configs diff --git a/api/src/config/secret.rs b/api/src/config/secret.rs index a573a60d..ac46155d 100644 --- a/api/src/config/secret.rs +++ b/api/src/config/secret.rs @@ -23,7 +23,7 @@ * THE SOFTWARE. * =========================LICENSE_END================================== */ -use crate::config::AppSelector; +use crate::{config::AppSelector, models::AppName}; use base64::{engine::general_purpose, Engine}; use secstr::SecUtf8; use serde::{de, Deserialize, Deserializer}; @@ -53,7 +53,7 @@ impl Secret { Ok(SecUtf8::from(sec_value)) } - pub fn matches_app_name(&self, app_name: &str) -> bool { + pub fn matches_app_name(&self, app_name: &AppName) -> bool { self.app_selector.matches(app_name) } } diff --git a/api/src/deployment/deployment_unit.rs b/api/src/deployment/deployment_unit.rs index dd47f41b..50710f67 100644 --- a/api/src/deployment/deployment_unit.rs +++ b/api/src/deployment/deployment_unit.rs @@ -97,6 +97,7 @@ pub struct WithAppliedHooks { pub struct WithAppliedIngressRoute { app_name: AppName, services: Vec, + route: TraefikIngressRoute, } pub struct DeploymentUnitBuilder { @@ -106,6 +107,7 @@ pub struct DeploymentUnitBuilder { pub struct DeploymentUnit { app_name: AppName, services: Vec, + route: TraefikIngressRoute, } #[derive(Clone, Debug)] @@ -174,6 +176,10 @@ impl DeploymentUnit { pub fn app_name(&self) -> &AppName { &self.app_name } + + pub fn app_base_route(&self) -> &TraefikIngressRoute { + &self.route + } } impl DeploymentUnitBuilder { @@ -553,18 +559,26 @@ impl DeploymentUnitBuilder { service.ingress_route.merge_with(service_route); } + let mut route = route; + route.merge_with(TraefikIngressRoute::with_app_only_defaults( + &self.stage.app_name, + )); + DeploymentUnitBuilder { stage: WithAppliedIngressRoute { app_name: self.stage.app_name, services: self.stage.services, + route, }, } } pub fn build(self) -> DeploymentUnit { + let route = TraefikIngressRoute::with_app_only_defaults(&self.stage.app_name); DeploymentUnit { app_name: self.stage.app_name, services: self.stage.services, + route, } } } @@ -574,6 +588,7 @@ impl DeploymentUnitBuilder { DeploymentUnit { app_name: self.stage.app_name, services: self.stage.services, + route: self.stage.route, } } } @@ -585,13 +600,12 @@ mod tests { use crate::models::{Environment, EnvironmentVariable}; use crate::{config_from_str, sc}; use secstr::SecUtf8; - use std::str::FromStr; #[tokio::test] async fn should_return_unique_images() -> Result<(), AppsServiceError> { let config = Config::default(); let unit = DeploymentUnitBuilder::init( - AppName::from_str("master").unwrap(), + AppName::master(), vec![ sc!("http1", "nginx:1.13"), sc!("wordpress1", "wordpress:alpine"), @@ -622,7 +636,7 @@ mod tests { "# ); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![sc!("http1", "nginx:1.13")]; let unit = DeploymentUnitBuilder::init(app_name, service_configs) @@ -656,7 +670,7 @@ mod tests { "# ); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![sc!( "openid", labels = (), @@ -712,7 +726,7 @@ mod tests { "# ); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![sc!( "openid", labels = (), @@ -762,7 +776,7 @@ mod tests { image = 'postgres:11' "# ); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![ sc!("wordpress", "wordpress:alpine"), sc!("nextcloud", "nextcloud:alpine"), @@ -811,7 +825,7 @@ mod tests { ]))); let config = Config::default(); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let unit = DeploymentUnitBuilder::init(app_name, vec![service_config]) .extend_with_config(&config) @@ -846,7 +860,7 @@ mod tests { SecUtf8::from("{{application.name}}"), )]))); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let config = Config::default(); let unit = DeploymentUnitBuilder::init(app_name, vec![service_configs]) @@ -886,7 +900,7 @@ mod tests { "# ); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![sc!("wordpress", "wordpress:latest")]; let unit = DeploymentUnitBuilder::init(app_name, service_configs) @@ -941,7 +955,7 @@ mod tests { "# ); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![sc!("wordpress", "wordpress:alpine")]; let unit = DeploymentUnitBuilder::init(app_name, service_configs) @@ -996,7 +1010,7 @@ mod tests { "# ); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![sc!("openid", "private.example.com/library/openid:backup")]; let unit = DeploymentUnitBuilder::init(app_name, service_configs) @@ -1046,7 +1060,7 @@ mod tests { "# ); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![ sc!("wordpress", "wordpress:alpine"), sc!("wordpress-db", "postgres:11-alpine"), @@ -1096,7 +1110,7 @@ mod tests { #[tokio::test] async fn should_determine_deployment_strategy_for_requested_service( ) -> Result<(), AppsServiceError> { - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![ sc!("wordpress", "wordpress:alpine"), sc!("wordpress-db", "postgres:11-alpine"), @@ -1147,7 +1161,7 @@ mod tests { "# ); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![sc!("wordpress", "wordpress:alpine")]; let unit = DeploymentUnitBuilder::init(app_name, service_configs) @@ -1178,7 +1192,7 @@ mod tests { async fn apply_base_traefik_router_rule() -> Result<(), AppsServiceError> { let config = config_from_str!(""); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![sc!("wordpress")]; let unit = DeploymentUnitBuilder::init(app_name, service_configs) diff --git a/api/src/deployment/hooks.rs b/api/src/deployment/hooks.rs index 939101e0..ee4b5251 100644 --- a/api/src/deployment/hooks.rs +++ b/api/src/deployment/hooks.rs @@ -235,7 +235,6 @@ mod tests { use crate::apps::*; use crate::deployment::deployment_unit::DeploymentUnitBuilder; use std::io::Write; - use std::str::FromStr; use std::vec; use tempfile::NamedTempFile; @@ -268,7 +267,7 @@ mod tests { let (_temp_js_file, config) = config_with_deployment_hook(script); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_configs = vec![crate::sc!("service-a")]; let unit = DeploymentUnitBuilder::init(app_name, service_configs) @@ -314,7 +313,7 @@ mod tests { let (_temp_js_file, config) = config_with_deployment_hook(script); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let mut service_config = crate::sc!("service-a"); let mut files = BTreeMap::new(); @@ -360,7 +359,7 @@ mod tests { "#; let (_temp_js_file, config) = config_with_deployment_hook(script); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let service_config = crate::sc!("service-a"); let unit = DeploymentUnitBuilder::init(app_name, vec![service_config]) @@ -403,7 +402,7 @@ mod tests { "#; let (_temp_js_file, config) = config_with_deployment_hook(script); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let mut service_config = crate::sc!("service-a"); service_config.set_env(Some(Environment::new(vec![EnvironmentVariable::new( @@ -455,7 +454,7 @@ mod tests { let (_temp_js_file, config) = config_with_deployment_hook(script); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let mut service_config = crate::sc!("service-a"); service_config.set_env(Some(Environment::new(vec![EnvironmentVariable::new( String::from("VARIABLE_X"), @@ -501,7 +500,7 @@ mod tests { "#; let service_config = crate::sc!("service-a"); let (_temp_js_file, config) = config_with_deployment_hook(script); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let unit = DeploymentUnitBuilder::init(app_name, vec![service_config]) .extend_with_config(&config) @@ -538,7 +537,7 @@ mod tests { let service_config = crate::sc!("service-a"); let (_temp_js_file, config) = config_with_deployment_hook(script); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); let unit = DeploymentUnitBuilder::init(app_name, vec![service_config]) .extend_with_config(&config) @@ -567,7 +566,7 @@ mod tests { let mut deployed_services_error = String::new(); let service_config = crate::sc!("service-a"); let (_temp_js_file, config) = config_with_deployment_hook(script); - let app_name = AppName::from_str("master").unwrap(); + let app_name = AppName::master(); match DeploymentUnitBuilder::init(app_name, vec![service_config]) .extend_with_config(&config) .extend_with_templating_only_service_configs(Vec::new()) diff --git a/api/src/infrastructure/dummy_infrastructure.rs b/api/src/infrastructure/dummy_infrastructure.rs index 9c089e48..8b6abc55 100644 --- a/api/src/infrastructure/dummy_infrastructure.rs +++ b/api/src/infrastructure/dummy_infrastructure.rs @@ -34,6 +34,7 @@ use async_trait::async_trait; use chrono::{DateTime, FixedOffset, Utc}; use multimap::MultiMap; use std::collections::HashSet; +use std::str::FromStr; use std::sync::Mutex; use std::time::Duration; @@ -113,7 +114,7 @@ impl Infrastructure for DummyInfrastructure { .build() .unwrap(); - s.insert(app.clone(), service); + s.insert(AppName::from_str(app).unwrap(), service); } } @@ -155,7 +156,8 @@ impl Infrastructure for DummyInfrastructure { self.delay_if_configured().await; let mut services = self.services.lock().unwrap(); - match services.remove(app_name) { + + match services.remove(&app_name) { Some(services) => Ok(services .into_iter() .map(|sc| { diff --git a/api/src/infrastructure/kubernetes/deployment_unit.rs b/api/src/infrastructure/kubernetes/deployment_unit.rs new file mode 100644 index 00000000..919bf961 --- /dev/null +++ b/api/src/infrastructure/kubernetes/deployment_unit.rs @@ -0,0 +1,1194 @@ +use super::{ + infrastructure::KubernetesInfrastructureError, + payloads::{ + convert_k8s_ingress_to_traefik_ingress, IngressRoute as TraefikIngressRoute, + Middleware as TraefikMiddleware, + }, +}; +use crate::{ + config::BootstrappingContainer, + deployment::DeploymentUnit, + infrastructure::{APP_NAME_LABEL, CONTAINER_TYPE_LABEL, SERVICE_NAME_LABEL}, + models::{AppName, ContainerType, Image}, +}; +use failure::Error; +use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, TryStreamExt}; +use k8s_openapi::{ + api::{ + apps::v1::{Deployment, StatefulSet}, + batch::v1::Job, + core::v1::{ + ConfigMap, Container, LocalObjectReference, PersistentVolumeClaim, Pod, PodSpec, + Secret, Service, ServiceAccount, + }, + networking::v1::Ingress, + rbac::v1::{Role, RoleBinding}, + }, + apimachinery::pkg::apis::meta::v1::LabelSelector, + DeepMerge, Metadata, Resource, +}; +use kube::{ + api::{LogParams, Patch, PatchParams, PostParams, WatchParams}, + core::{DynamicObject, ObjectMeta, WatchEvent}, + Api, Client, ResourceExt, +}; +use serde::Deserialize; +use std::{ + collections::{BTreeMap, HashSet}, + str::FromStr, +}; +use url::Url; + +#[derive(Default)] +pub(super) struct K8sDeploymentUnit { + roles: Vec, + role_bindings: Vec, + stateful_sets: Vec, + config_maps: Vec, + secrets: Vec, + pvcs: Vec, + services: Vec, + pods: Vec, + deployments: Vec, + jobs: Vec, + service_accounts: Vec, + traefik_ingresses: Vec, + traefik_middlewares: Vec, +} + +impl K8sDeploymentUnit { + async fn start_bootstrapping_pods( + app_name: &AppName, + client: Client, + bootstrapping_containers: &[BootstrappingContainer], + image_pull_secret: Option, + base_url: Option, + ) -> Result<(String, Vec), Error> { + let image_pull_secrets = match image_pull_secret { + Some(image_pull_secret) => { + let image_pull_secrets = vec![LocalObjectReference { + name: Some(image_pull_secret.metadata.name.clone().unwrap_or_default()), + }]; + create_or_patch(client.clone(), app_name, image_pull_secret).await?; + Some(image_pull_secrets) + } + None => None, + }; + + let containers = bootstrapping_containers + .iter() + .enumerate() + .map(|(i, bc)| Container { + name: format!("bootstrap-{i}"), + image: Some(bc.image().to_string()), + image_pull_policy: Some(String::from("Always")), + args: Some(bc.templated_args(app_name, &base_url)), + ..Default::default() + }) + .collect::>(); + + let pod_name = format!( + "{}-bootstrap-{}", + app_name.to_rfc1123_namespace_id(), + uuid::Uuid::new_v4() + ); + + let pod = Pod { + metadata: ObjectMeta { + name: Some(pod_name.clone()), + labels: Some(BTreeMap::from([( + APP_NAME_LABEL.to_string(), + app_name.to_string(), + )])), + ..Default::default() + }, + spec: Some(PodSpec { + containers, + image_pull_secrets, + restart_policy: Some(String::from("Never")), + ..Default::default() + }), + ..Default::default() + }; + create_or_patch(client.clone(), app_name, pod).await?; + + let api: Api = Api::namespaced(client, &app_name.to_rfc1123_namespace_id()); + + // Wait for a bookmark event to be sure that the log is ready to be consumed + let wp = WatchParams::default() + .fields(&format!("metadata.name={pod_name}")) + .timeout(10); + let mut stream = api.watch(&wp, "0").await?.boxed(); + while let Some(status) = stream.try_next().await? { + trace!("Saw watch event for bootstrapping pod {pod_name} in {app_name}: {status:?}"); + + if let WatchEvent::Bookmark(_bookmark) = status { + debug!("Boot strapping pod {pod_name} for {app_name} ready."); + break; + } + } + + loop { + let pod = api.get_status(&pod_name).await?; + + if let Some(phase) = pod.status.and_then(|status| status.phase) { + match phase.as_str() { + "Running" | "Succeeded" => { + break; + } + "Failed" | "Unknown" => { + return Err(KubernetesInfrastructureError::UnexpectedError { + internal_message: format!( + "Bootstrap pod {pod_name} for {app_name} failed" + ), + } + .into()); + } + phase => { + trace!("Boot strapping pod {pod_name} for {app_name} still not in running phase. Currently in {phase}."); + } + } + } + + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + + let mut log_streams = Vec::with_capacity(bootstrapping_containers.len()); + + for i in 0..bootstrapping_containers.len() { + log_streams.push( + api.log_stream( + &pod_name, + &LogParams { + container: Some(format!("bootstrap-{i}")), + follow: true, + ..Default::default() + }, + ) + .await?, + ); + } + + Ok((pod_name, log_streams)) + } + + pub(super) async fn bootstrap( + deployment_unit: &DeploymentUnit, + client: Client, + bootstrapping_container: &[BootstrappingContainer], + image_pull_secret: Option, + ) -> Result { + if bootstrapping_container.is_empty() { + return Ok(Default::default()); + } + + let app_name = deployment_unit.app_name(); + + let (bootstrapping_pod_name, mut log_streams) = Self::start_bootstrapping_pods( + app_name, + client.clone(), + bootstrapping_container, + image_pull_secret, + deployment_unit.app_base_route().to_url(), + ) + .await?; + + let result = Self::parse_from_log_streams(deployment_unit, &mut log_streams).await; + + let pod_api: Api = Api::namespaced(client, &app_name.to_rfc1123_namespace_id()); + pod_api + .delete(&bootstrapping_pod_name, &Default::default()) + .await?; + + result + } + + async fn parse_from_log_streams( + deployment_unit: &DeploymentUnit, + log_streams: L, + ) -> Result + where + L: IntoIterator, + ::Item: AsyncBufReadExt, + ::Item: Unpin, + { + let app_name = deployment_unit.app_name(); + let mut roles = Vec::new(); + let mut role_bindings = Vec::new(); + let mut stateful_sets = Vec::new(); + let mut config_maps = Vec::new(); + let mut secrets = Vec::new(); + let mut pvcs = Vec::new(); + let mut services = Vec::new(); + let mut pods = Vec::new(); + let mut deployments = Vec::new(); + let mut jobs = Vec::new(); + let mut service_accounts = Vec::new(); + let mut ingresses = Vec::new(); + + for mut log_stream in log_streams.into_iter() { + let mut stdout = String::new(); + log_stream.read_to_string(&mut stdout).await?; + + trace!( + "Received YAML from bootstrapping container in {app_name}: {}…", + stdout.lines().next().unwrap_or(&stdout) + ); + + for doc in serde_yaml::Deserializer::from_str(&stdout) { + match DynamicObject::deserialize(doc) { + Ok(mut dy) => { + dy.metadata.namespace = Some(app_name.to_rfc1123_namespace_id()); + dy.labels_mut() + .insert(APP_NAME_LABEL.to_string(), app_name.to_string()); + + let api_version = dy + .types + .as_ref() + .map(|t| t.api_version.as_str()) + .unwrap_or_default(); + let kind = dy + .types + .as_ref() + .map(|t| t.kind.as_str()) + .unwrap_or_default(); + + trace!( + "Parsed {} ({api_version}, {kind}) for {app_name} as a bootstrap application element.", + dy.metadata + .name + .as_deref() + .unwrap_or_default() + ); + + match (api_version, kind) { + (Role::API_VERSION, Role::KIND) => match dy.clone().try_parse::() + { + Ok(role) => { + roles.push(role); + } + Err(e) => { + error!("Cannot parse {:?} as Role: {e}", dy.metadata.name); + } + }, + + (RoleBinding::API_VERSION, RoleBinding::KIND) => { + match dy.clone().try_parse::() { + Ok(role_binding) => { + role_bindings.push(role_binding); + } + Err(e) => { + error!( + "Cannot parse {:?} as RoleBinding: {e}", + dy.metadata.name + ); + } + } + } + (StatefulSet::API_VERSION, StatefulSet::KIND) => { + match dy.clone().try_parse::() { + Ok(stateful_set) => { + stateful_sets.push(stateful_set); + } + Err(e) => { + error!( + "Cannot parse {:?} as StatefulSet: {e}", + dy.metadata.name + ); + } + } + } + (ConfigMap::API_VERSION, ConfigMap::KIND) => { + match dy.clone().try_parse::() { + Ok(config_map) => { + config_maps.push(config_map); + } + Err(e) => { + error!( + "Cannot parse {:?} as ConfigMap: {e}", + dy.metadata.name + ); + } + } + } + (Secret::API_VERSION, Secret::KIND) => { + if let serde_json::Value::Object(obj) = &mut dy.data { + obj.entry("data").and_modify(|obj| { + if let serde_json::Value::Object(obj) = obj { + for (_k, v) in obj.iter_mut() { + if let serde_json::Value::String(str) = v { + // replacing new lines here because it is assumed + // that the data is base64 encoded and thus there + // must be no new lines + *v = str.replace('\n', "").into(); + } + } + } + }); + } + + match dy.clone().try_parse::() { + Ok(secret) => { + secrets.push(secret); + } + Err(e) => { + error!( + "Cannot parse {:?} as Secret: {e}", + dy.metadata.name + ); + } + } + } + (PersistentVolumeClaim::API_VERSION, PersistentVolumeClaim::KIND) => { + match dy.clone().try_parse::() { + Ok(pvc) => { + pvcs.push(pvc); + } + Err(e) => { + error!( + "Cannot parse {:?} as PersistentVolumeClaim: {e}", + dy.metadata.name + ); + } + } + } + (Service::API_VERSION, Service::KIND) => { + match dy.clone().try_parse::() { + Ok(service) => { + services.push(service); + } + Err(e) => { + error!( + "Cannot parse {:?} as Service: {e}", + dy.metadata.name + ); + } + } + } + (Deployment::API_VERSION, Deployment::KIND) => { + match dy.clone().try_parse::() { + Ok(mut deployment) => { + let service_name = deployment + .labels() + .get("app.kubernetes.io/component") + .cloned() + .unwrap_or_else(|| { + deployment.metadata.name.clone().unwrap_or_default() + }); + + deployment + .labels_mut() + .insert(SERVICE_NAME_LABEL.to_string(), service_name); + deployment.labels_mut().insert( + CONTAINER_TYPE_LABEL.to_string(), + ContainerType::ApplicationCompanion.to_string(), + ); + + deployments.push(deployment); + } + Err(e) => { + error!( + "Cannot parse {:?} as Deployment: {e}", + dy.metadata.name + ); + } + } + } + (Pod::API_VERSION, Pod::KIND) => match dy.clone().try_parse::() { + Ok(pod) => { + pods.push(pod); + } + Err(e) => { + error!("Cannot parse {:?} as Pod: {e}", dy.metadata.name); + } + }, + (Job::API_VERSION, Job::KIND) => match dy.clone().try_parse::() { + Ok(job) => { + jobs.push(job); + } + Err(e) => { + error!("Cannot parse {:?} as Job: {e}", dy.metadata.name); + } + }, + (ServiceAccount::API_VERSION, ServiceAccount::KIND) => { + match dy.clone().try_parse::() { + Ok(service_account) => { + service_accounts.push(service_account); + } + Err(e) => { + error!( + "Cannot parse {:?} as ServiceAccount: {e}", + dy.metadata.name + ); + } + } + } + (Ingress::API_VERSION, Ingress::KIND) => { + match dy.clone().try_parse::() { + Ok(ingress) => { + ingresses.push(ingress); + } + Err(e) => { + error!( + "Cannot parse {:?} as Ingress: {e}", + dy.metadata.name + ); + } + } + } + _ => { + warn!( + "Cannot parse {name} ({api_version}, {kind}) for {app_name} because its kind is unknown", + name=dy.metadata.name.unwrap_or_default() + ); + } + } + } + Err(err) => { + warn!("The output of a bootstrap container for {app_name} could not be parsed: {stdout}"); + return Err(err.into()); + } + } + } + } + + let mut traefik_ingresses = Vec::new(); + let mut traefik_middlewares = Vec::new(); + + for ingress in ingresses { + let Ok((route, middlewares)) = convert_k8s_ingress_to_traefik_ingress( + ingress, + deployment_unit.app_base_route().clone(), + ) else { + continue; + }; + + traefik_ingresses.push(route); + traefik_middlewares.extend(middlewares); + } + + Ok(Self { + roles, + role_bindings, + stateful_sets, + config_maps, + secrets, + pvcs, + services, + pods, + deployments, + jobs, + service_accounts, + traefik_ingresses, + traefik_middlewares, + }) + } + + pub(super) fn merge( + &mut self, + secret: Option, + service: Service, + deployment: Deployment, + ingress: TraefikIngressRoute, + middlewares: Vec, + ) { + let mut deployment = deployment; + + let service_name = deployment + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(SERVICE_NAME_LABEL)) + .expect("There must be label providing the service name"); + + let stateful_sets = self + .stateful_sets + .iter_mut() + .filter(|set| Some(service_name) == set.metadata().name.as_ref()) + .filter_map(|set| { + let spec = set.spec.as_mut()?; + Some(( + &mut set.metadata, + spec.template.metadata.as_mut(), + spec.template.spec.as_mut()?, + )) + }); + let deployments = self + .deployments + .iter_mut() + .filter(|set| Some(service_name) == set.metadata().name.as_ref()) + .filter_map(|deployment| { + let spec = deployment.spec.as_mut()?; + Some(( + &mut deployment.metadata, + spec.template.metadata.as_mut(), + spec.template.spec.as_mut()?, + )) + }); + let pods = self + .pods + .iter_mut() + .filter(|pod| Some(service_name) == pod.metadata().name.as_ref()) + .filter_map(|pod| Some((&mut pod.metadata, None, pod.spec.as_mut()?))); + + match stateful_sets.chain(deployments).chain(pods).next() { + Some((metadata, pod_meta, pod_spec)) => { + // Clean everything that might interfere with the original definitions of + // bootstrapped companion before calling merge_from down below. + deployment.metadata.name = None; + + metadata.merge_from(deployment.metadata); + + let mut deployment_spec = deployment + .spec + .expect("There should be a deployment spec created for the deployable service"); + + deployment_spec.selector = LabelSelector::default(); + + let template_to_be_merged = deployment_spec.template; + + if let Some(pod_meta) = pod_meta { + pod_meta.merge_from( + template_to_be_merged.metadata.expect( + "There should be a pod meta created for the deployable service", + ), + ); + } + + let mut pod_spec_to_be_merged = template_to_be_merged + .spec + .expect("There should be a pod spec created for the deployable service"); + pod_spec_to_be_merged.containers[0].name = pod_spec.containers[0].name.clone(); + pod_spec_to_be_merged.containers[0].ports = None; + + pod_spec.merge_from(pod_spec_to_be_merged); + + if let Some(secret) = secret { + self.secrets.push(secret); + } + // Ingress, Service, and Middlewares will be ignored because at this point it can + // be assumed that these configurations are covered by the Kubernetes objects that + // were used for bootstrapping the application. + } + None => { + self.secrets.extend(secret); + self.services.push(service); + self.deployments.push(deployment); + self.traefik_ingresses.push(ingress); + self.traefik_middlewares.extend(middlewares); + } + } + } + + /// This filters bootstrapped [Deployments](Deployment), [Stateful Sets](StatefulSet), or + /// [Pods](Pod) by the existing [services](Service) in already deployed application to avoid + /// that deployments of instances overwrite each other + pub(super) fn filter_by_instances_and_replicas( + &mut self, + services: &[crate::models::service::Service], + ) { + let service_not_to_be_retained = services + .iter() + .filter(|s| { + s.container_type() == &ContainerType::Instance + || s.container_type() == &ContainerType::Replica + }) + .map(|s| s.service_name()) + .collect::>(); + + self.deployments.retain(|deployment| { + let Some(service_name) = deployment + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(SERVICE_NAME_LABEL)) + else { + return false; + }; + + !service_not_to_be_retained.contains(service_name) + }); + } + + fn images_of_pod_spec(spec: &PodSpec) -> HashSet { + let mut images = HashSet::new(); + + if let Some(init_containers) = &spec.init_containers { + for init_container in init_containers { + if let Some(image) = init_container + .image + .as_ref() + .and_then(|image| Image::from_str(image).ok()) + { + images.insert(image); + } + } + } + + for container in &spec.containers { + if let Some(image) = container + .image + .as_ref() + .and_then(|image| Image::from_str(image).ok()) + { + images.insert(image); + } + } + + images + } + + pub(super) fn images(&self) -> HashSet { + let mut images = HashSet::new(); + + for deployment in &self.deployments { + let Some(spec) = &deployment.spec else { + continue; + }; + let Some(spec) = &spec.template.spec else { + continue; + }; + + images.extend(Self::images_of_pod_spec(spec)); + } + for job in &self.jobs { + let Some(spec) = &job.spec else { + continue; + }; + let Some(spec) = &spec.template.spec else { + continue; + }; + + images.extend(Self::images_of_pod_spec(spec)); + } + for stateful_set in &self.stateful_sets { + let Some(spec) = &stateful_set.spec else { + continue; + }; + let Some(spec) = &spec.template.spec else { + continue; + }; + + images.extend(Self::images_of_pod_spec(spec)); + } + for pod in &self.pods { + let Some(spec) = &pod.spec else { + continue; + }; + + images.extend(Self::images_of_pod_spec(spec)); + } + + images + } + + pub(super) fn apply_image_pull_secret(&mut self, image_pull_secret: Secret) { + let pull_secret_reference = LocalObjectReference { + name: Some(image_pull_secret.metadata.name.clone().unwrap_or_default()), + }; + self.secrets.push(image_pull_secret); + + for deployment in self.deployments.iter_mut() { + let Some(spec) = &mut deployment.spec else { + continue; + }; + let Some(spec) = &mut spec.template.spec else { + continue; + }; + + spec.image_pull_secrets = Some(vec![pull_secret_reference.clone()]); + } + for job in self.jobs.iter_mut() { + let Some(spec) = &mut job.spec else { + continue; + }; + let Some(spec) = &mut spec.template.spec else { + continue; + }; + + spec.image_pull_secrets = Some(vec![pull_secret_reference.clone()]); + } + for stateful_set in self.stateful_sets.iter_mut() { + let Some(spec) = &mut stateful_set.spec else { + continue; + }; + let Some(spec) = &mut spec.template.spec else { + continue; + }; + + spec.image_pull_secrets = Some(vec![pull_secret_reference.clone()]); + } + for pod in self.pods.iter_mut() { + let Some(spec) = &mut pod.spec else { + continue; + }; + + spec.image_pull_secrets = Some(vec![pull_secret_reference.clone()]); + } + } + + pub(super) async fn deploy( + self, + client: Client, + app_name: &AppName, + ) -> Result, Error> { + let mut deployments = Vec::with_capacity(self.deployments.len()); + + for role in self.roles { + create_or_patch(client.clone(), app_name, role).await?; + } + for role_binding in self.role_bindings { + create_or_patch(client.clone(), app_name, role_binding).await?; + } + for config_map in self.config_maps { + create_or_patch(client.clone(), app_name, config_map).await?; + } + for secret in self.secrets { + create_or_patch(client.clone(), app_name, secret).await?; + } + for pvc in self.pvcs { + create_or_patch(client.clone(), app_name, pvc).await?; + } + for service in self.services { + create_or_patch(client.clone(), app_name, service).await?; + } + for service_account in self.service_accounts { + create_or_patch(client.clone(), app_name, service_account).await?; + } + for deployment in self.deployments { + let deployment = create_or_patch(client.clone(), app_name, deployment).await?; + deployments.push(deployment); + } + for job in self.jobs { + create_or_patch(client.clone(), app_name, job).await?; + } + for stateful_set in self.stateful_sets { + create_or_patch(client.clone(), app_name, stateful_set).await?; + } + for ingress in self.traefik_ingresses { + create_or_patch(client.clone(), app_name, ingress).await?; + } + for middleware in self.traefik_middlewares { + create_or_patch(client.clone(), app_name, middleware).await?; + } + for pod in self.pods { + create_or_patch(client.clone(), app_name, pod).await?; + } + + Ok(deployments) + } +} + +async fn create_or_patch(client: Client, app_name: &AppName, payload: T) -> Result +where + T: serde::Serialize + Clone + std::fmt::Debug + for<'a> serde::Deserialize<'a>, + T: kube::core::Resource, + ::DynamicType: std::default::Default, +{ + let api = Api::namespaced(client.clone(), &app_name.to_rfc1123_namespace_id()); + match api.create(&PostParams::default(), &payload).await { + Ok(result) => Ok(result), + Err(kube::error::Error::Api(kube::error::ErrorResponse { code, .. })) if code == 409 => { + let name = payload.meta().name.clone().unwrap_or_default(); + match api + .patch(&name, &PatchParams::default(), &Patch::Merge(&payload)) + .await + { + Ok(result) => Ok(result), + Err(_e) => { + // TODO: how to handle the case? e.g. patching a job may fails + Ok(payload) + } + } + } + Err(e) => Err(e.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{deployment::deployment_unit::DeploymentUnitBuilder, models::ServiceBuilder}; + use k8s_openapi::api::{ + apps::v1::DeploymentSpec, + core::v1::{ContainerPort, EnvVar, PodTemplateSpec}, + }; + + async fn parse_unit(stdout: &'static str) -> K8sDeploymentUnit { + let log_streams = vec![stdout.as_bytes()]; + + // TODO simplify + let deployment_unit = DeploymentUnitBuilder::init(AppName::master(), Vec::new()) + .extend_with_config(&Default::default()) + .extend_with_templating_only_service_configs(Vec::new()) + .resolve_image_manifest(&Default::default()) + .await + .unwrap() + .apply_templating() + .unwrap() + .apply_hooks(&Default::default()) + .await + .unwrap() + .apply_base_traefik_ingress_route( + crate::infrastructure::TraefikIngressRoute::with_app_only_defaults( + &AppName::master(), + ), + ) + .build(); + + K8sDeploymentUnit::parse_from_log_streams(&deployment_unit, log_streams) + .await + .unwrap() + } + + #[tokio::test] + async fn parse_unit_from_secret_stdout_where_value_is_base64_encoded() { + let unit = parse_unit( + r#" + apiVersion: v1 + kind: Secret + metadata: + name: secret-tls + type: kubernetes.io/tls + data: + # values are base64 encoded, which obscures them but does NOT provide + # any useful level of confidentiality + tls.crt: | + LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNVakNDQWJzQ0FnMytNQTBHQ1NxR1NJYjNE + UUVCQlFVQU1JR2JNUXN3Q1FZRFZRUUdFd0pLVURFT01Bd0cKQTFVRUNCTUZWRzlyZVc4eEVEQU9C + Z05WQkFjVEIwTm9kVzh0YTNVeEVUQVBCZ05WQkFvVENFWnlZVzVyTkVSRQpNUmd3RmdZRFZRUUxF + dzlYWldKRFpYSjBJRk4xY0hCdmNuUXhHREFXQmdOVkJBTVREMFp5WVc1ck5FUkVJRmRsCllpQkRR + VEVqTUNFR0NTcUdTSWIzRFFFSkFSWVVjM1Z3Y0c5eWRFQm1jbUZ1YXpSa1pDNWpiMjB3SGhjTk1U + TXcKTVRFeE1EUTFNVE01V2hjTk1UZ3dNVEV3TURRMU1UTTVXakJMTVFzd0NRWURWUVFHREFKS1VE + RVBNQTBHQTFVRQpDQXdHWEZSdmEzbHZNUkV3RHdZRFZRUUtEQWhHY21GdWF6UkVSREVZTUJZR0Ex + VUVBd3dQZDNkM0xtVjRZVzF3CmJHVXVZMjl0TUlHYU1BMEdDU3FHU0liM0RRRUJBUVVBQTRHSUFE + Q0JoQUo5WThFaUhmeHhNL25PbjJTbkkxWHgKRHdPdEJEVDFKRjBReTliMVlKanV2YjdjaTEwZjVN + Vm1UQllqMUZTVWZNOU1vejJDVVFZdW4yRFljV29IcFA4ZQpqSG1BUFVrNVd5cDJRN1ArMjh1bklI + QkphVGZlQ09PekZSUFY2MEdTWWUzNmFScG04L3dVVm16eGFLOGtCOWVaCmhPN3F1TjdtSWQxL2pW + cTNKODhDQXdFQUFUQU5CZ2txaGtpRzl3MEJBUVVGQUFPQmdRQU1meTQzeE15OHh3QTUKVjF2T2NS + OEtyNWNaSXdtbFhCUU8xeFEzazlxSGtyNFlUY1JxTVQ5WjVKTm1rWHYxK2VSaGcwTi9WMW5NUTRZ + RgpnWXcxbnlESnBnOTduZUV4VzQyeXVlMFlHSDYyV1hYUUhyOVNVREgrRlowVnQvRGZsdklVTWRj + UUFEZjM4aU9zCjlQbG1kb3YrcE0vNCs5a1h5aDhSUEkzZXZ6OS9NQT09Ci0tLS0tRU5EIENFUlRJ + RklDQVRFLS0tLS0K + # In this example, the key data is not a real PEM-encoded private key + tls.key: | + RXhhbXBsZSBkYXRhIGZvciB0aGUgVExTIGNydCBmaWVsZA== + "#, + ) + .await; + + assert_json_diff::assert_json_eq!( + unit.secrets, + serde_json::json!([{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "secret-tls", + "namespace": "master", + "labels": { + APP_NAME_LABEL: "master" + } + }, + "type": "kubernetes.io/tls", + "data": { + "tls.crt": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNVakNDQWJzQ0FnMytNQTBHQ1NxR1NJYjNEUUVCQlFVQU1JR2JNUXN3Q1FZRFZRUUdFd0pLVURFT01Bd0cKQTFVRUNCTUZWRzlyZVc4eEVEQU9CZ05WQkFjVEIwTm9kVzh0YTNVeEVUQVBCZ05WQkFvVENFWnlZVzVyTkVSRQpNUmd3RmdZRFZRUUxFdzlYWldKRFpYSjBJRk4xY0hCdmNuUXhHREFXQmdOVkJBTVREMFp5WVc1ck5FUkVJRmRsCllpQkRRVEVqTUNFR0NTcUdTSWIzRFFFSkFSWVVjM1Z3Y0c5eWRFQm1jbUZ1YXpSa1pDNWpiMjB3SGhjTk1UTXcKTVRFeE1EUTFNVE01V2hjTk1UZ3dNVEV3TURRMU1UTTVXakJMTVFzd0NRWURWUVFHREFKS1VERVBNQTBHQTFVRQpDQXdHWEZSdmEzbHZNUkV3RHdZRFZRUUtEQWhHY21GdWF6UkVSREVZTUJZR0ExVUVBd3dQZDNkM0xtVjRZVzF3CmJHVXVZMjl0TUlHYU1BMEdDU3FHU0liM0RRRUJBUVVBQTRHSUFEQ0JoQUo5WThFaUhmeHhNL25PbjJTbkkxWHgKRHdPdEJEVDFKRjBReTliMVlKanV2YjdjaTEwZjVNVm1UQllqMUZTVWZNOU1vejJDVVFZdW4yRFljV29IcFA4ZQpqSG1BUFVrNVd5cDJRN1ArMjh1bklIQkphVGZlQ09PekZSUFY2MEdTWWUzNmFScG04L3dVVm16eGFLOGtCOWVaCmhPN3F1TjdtSWQxL2pWcTNKODhDQXdFQUFUQU5CZ2txaGtpRzl3MEJBUVVGQUFPQmdRQU1meTQzeE15OHh3QTUKVjF2T2NSOEtyNWNaSXdtbFhCUU8xeFEzazlxSGtyNFlUY1JxTVQ5WjVKTm1rWHYxK2VSaGcwTi9WMW5NUTRZRgpnWXcxbnlESnBnOTduZUV4VzQyeXVlMFlHSDYyV1hYUUhyOVNVREgrRlowVnQvRGZsdklVTWRjUUFEZjM4aU9zCjlQbG1kb3YrcE0vNCs5a1h5aDhSUEkzZXZ6OS9NQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", + "tls.key": "RXhhbXBsZSBkYXRhIGZvciB0aGUgVExTIGNydCBmaWVsZA==" + } + }]) + ) + } + + #[tokio::test] + async fn parse_unit_from_deploymen_stdout() { + let unit = parse_unit( + r#" + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx-deployment + labels: + app: nginx + spec: + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 + "#, + ) + .await; + + assert_json_diff::assert_json_eq!( + unit.deployments, + serde_json::json!([{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "nginx-deployment", + "namespace": "master", + "labels": { + "app": "nginx", + APP_NAME_LABEL: "master", + SERVICE_NAME_LABEL: "nginx-deployment", + CONTAINER_TYPE_LABEL: "app-companion" + } + }, + "spec": { + "selector": { + "matchLabels": { + "app": "nginx" + } + }, + "template": { + "metadata": { + "labels": { + "app": "nginx" + } + }, + "spec": { + "containers": [{ + "name": "nginx", + "image": "nginx:1.14.2", + "ports": [{ + "containerPort": 80 + }] + }] + } + } + } + }]) + ) + } + + #[tokio::test] + async fn merge_deployment_into_bootstrapped_deployment() { + let mut unit = parse_unit( + r#" + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx + labels: + app: nginx + spec: + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 + "#, + ) + .await; + + unit.merge( + None, + Service { + ..Default::default() + }, + Deployment { + metadata: ObjectMeta { + name: Some(String::from("random-name")), + labels: Some(BTreeMap::from([ + (SERVICE_NAME_LABEL.to_string(), String::from("nginx")), + (CONTAINER_TYPE_LABEL.to_string(), String::from("instance")), + ])), + annotations: Some(BTreeMap::from([( + String::from("my-important-annotation"), + String::from("test data"), + )])), + ..Default::default() + }, + spec: Some(DeploymentSpec { + selector: LabelSelector { + match_labels: Some(BTreeMap::from([( + SERVICE_NAME_LABEL.to_string(), + String::from("random-name"), + )])), + ..Default::default() + }, + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + annotations: Some(BTreeMap::from([( + String::from("date"), + String::from("2024-01-01"), + )])), + ..Default::default() + }), + spec: Some(PodSpec { + containers: vec![Container { + name: String::from("random-name"), + image: Some(String::from("nginx:1.29.0")), + env: Some(vec![EnvVar { + name: String::from("NGINX_HOST"), + value: Some(String::from("example.com")), + ..Default::default() + }]), + ports: Some(vec![ContainerPort { + container_port: 4711, + ..Default::default() + }]), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }, + TraefikIngressRoute { + metadata: Default::default(), + spec: Default::default(), + }, + Vec::new(), + ); + + assert!(unit.secrets.is_empty()); + assert!(unit.services.is_empty()); + assert!(unit.traefik_ingresses.is_empty()); + assert!(unit.traefik_middlewares.is_empty()); + assert_json_diff::assert_json_eq!( + unit.deployments, + serde_json::json!([{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "nginx", + "namespace": "master", + "labels": { + "app": "nginx", + APP_NAME_LABEL: "master", + SERVICE_NAME_LABEL: "nginx", + CONTAINER_TYPE_LABEL: "instance" + }, + "annotations": { + "my-important-annotation": "test data" + } + }, + "spec": { + "selector": { + "matchLabels": { + "app": "nginx" + } + }, + "template": { + "metadata": { + "labels": { + "app": "nginx" + }, + "annotations": { + "date": "2024-01-01" + } + }, + "spec": { + "containers": [{ + "name": "nginx", + "image": "nginx:1.29.0", + "env": [{ + "name": "NGINX_HOST", + "value": "example.com" + }], + "ports": [{ + "containerPort": 80 + }] + }] + } + } + } + }]) + ) + } + + #[tokio::test] + async fn filter_by_instances_and_replicas() { + let mut unit = parse_unit( + r#" + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx + labels: + app: nginx + spec: + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 + "#, + ) + .await; + + unit.filter_by_instances_and_replicas(dbg!(&[ServiceBuilder::new() + .app_name(AppName::master().to_string()) + .id(String::from("test")) + .config(crate::sc!("nginx", "nginx:1.15")) + .build() + .unwrap()])); + + assert!(unit.deployments.is_empty()); + } + + #[tokio::test] + async fn filter_not_by_instances_and_replicas() { + let mut unit = parse_unit( + r#" + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx + labels: + app: nginx + spec: + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 + "#, + ) + .await; + + unit.filter_by_instances_and_replicas(dbg!(&[ServiceBuilder::new() + .app_name(AppName::master().to_string()) + .id(String::from("test")) + .config(crate::sc!("postgres", "postgres")) + .build() + .unwrap()])); + + assert!(!unit.deployments.is_empty()); + } +} diff --git a/api/src/infrastructure/kubernetes/infrastructure.rs b/api/src/infrastructure/kubernetes/infrastructure.rs index 56bca54c..d86b4f30 100644 --- a/api/src/infrastructure/kubernetes/infrastructure.rs +++ b/api/src/infrastructure/kubernetes/infrastructure.rs @@ -27,10 +27,11 @@ use super::super::{ APP_NAME_LABEL, CONTAINER_TYPE_LABEL, IMAGE_LABEL, REPLICATED_ENV_LABEL, SERVICE_NAME_LABEL, STORAGE_TYPE_LABEL, }; +use super::deployment_unit::K8sDeploymentUnit; use super::payloads::{ - deployment_payload, deployment_replicas_payload, image_pull_secret_payload, - ingress_route_payload, middleware_payload, namespace_payload, persistent_volume_claim_payload, - secrets_payload, service_payload, IngressRoute, + deployment_payload, image_pull_secret_payload, ingress_route_payload, middleware_payload, + namespace_payload, persistent_volume_claim_payload, secrets_payload, service_payload, + IngressRoute, Middleware, }; use crate::config::{Config as PREvantConfig, ContainerConfig, Runtime}; use crate::deployment::deployment_unit::{DeployableService, DeploymentUnit}; @@ -43,12 +44,13 @@ use crate::models::{ use async_trait::async_trait; use chrono::{DateTime, FixedOffset, Utc}; use failure::Error; -use futures::future::join_all; +use futures::stream::{FuturesUnordered, Stream}; +use futures::StreamExt; +use k8s_openapi::api::core::v1::PersistentVolumeClaim; use k8s_openapi::api::storage::v1::StorageClass; use k8s_openapi::api::{ apps::v1::Deployment as V1Deployment, core::v1::Namespace as V1Namespace, - core::v1::PersistentVolumeClaim, core::v1::Pod as V1Pod, core::v1::Secret as V1Secret, - core::v1::Service as V1Service, + core::v1::Pod as V1Pod, core::v1::Secret as V1Secret, core::v1::Service as V1Service, }; use kube::{ api::{Api, DeleteParams, ListParams, LogParams, Patch, PatchParams, PostParams}, @@ -62,7 +64,7 @@ use secstr::SecUtf8; use std::collections::{BTreeMap, HashMap}; use std::convert::{From, TryFrom}; use std::net::IpAddr; -use std::path::PathBuf; +use std::pin::Pin; use std::str::FromStr; pub struct KubernetesInfrastructure { @@ -76,11 +78,6 @@ pub enum KubernetesInfrastructureError { internal_message )] UnexpectedError { internal_message: String }, - #[fail( - display = "The deployment {} does not provide a label for service name.", - deployment_name - )] - MissingServiceNameLabel { deployment_name: String }, #[fail( display = "The deployment {} does not provide a label for app name.", deployment_name @@ -118,34 +115,72 @@ impl KubernetesInfrastructure { }) } - async fn create_service_from( + async fn get_deployment( &self, - deployment: V1Deployment, - ) -> Result { - let namespace = deployment.metadata.namespace.clone().unwrap_or_default(); - let mut builder = ServiceBuilder::try_from(deployment)?; + app_name: &AppName, + service_name: &str, + ) -> Result, KubernetesInfrastructureError> { + let client = self.client().await?; + let namespace = app_name.to_rfc1123_namespace_id(); let p = ListParams { - label_selector: Some(format!( - "{}={},{}={}", - APP_NAME_LABEL, - builder - .current_app_name() - .map_or_else(|| "", |name| name.as_str()), - SERVICE_NAME_LABEL, - builder - .current_config() - .map_or_else(|| "", |config| config.service_name()), - )), + label_selector: Some(format!("{SERVICE_NAME_LABEL}={service_name}",)), ..Default::default() }; - if let Some(pod) = Api::::namespaced(self.client().await?, &namespace) + + match Api::::namespaced(client.clone(), &namespace) .list(&p) .await? - .items .into_iter() .next() { + Some(deployment) => Ok(Some(deployment)), + None => Ok(None), + } + } + + async fn get_pod_of_deployment( + &self, + deployment: &V1Deployment, + ) -> Result, KubernetesInfrastructureError> { + let Some(spec) = &deployment.spec else { + return Ok(None); + }; + + match Api::::namespaced( + self.client().await?, + deployment + .metadata + .namespace + .as_ref() + .expect("A namespace should be present for a deployment"), + ) + .list(&ListParams { + label_selector: spec.selector.match_labels.as_ref().map(|labels| { + labels + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(",") + }), + ..Default::default() + }) + .await? + .into_iter() + .next() + { + Some(pod) => Ok(Some(pod)), + None => Ok(None), + } + } + + async fn create_service_from( + &self, + deployment: V1Deployment, + ) -> Result { + let mut builder = ServiceBuilder::try_from(deployment.clone())?; + + if let Some(pod) = self.get_pod_of_deployment(&deployment).await? { if let Some(container) = pod.spec.as_ref().and_then(|spec| spec.containers.first()) { builder = builder.started_at( pod.status @@ -179,8 +214,7 @@ impl KubernetesInfrastructure { &self, app_name: &AppName, ) -> Result, KubernetesInfrastructureError> { - let mut services = Vec::new(); - let futures = Api::::namespaced( + let mut futures = Api::::namespaced( self.client().await?, &app_name.to_rfc1123_namespace_id(), ) @@ -188,11 +222,14 @@ impl KubernetesInfrastructure { .await? .items .into_iter() + // FIXME: this performs many network requests to inspect the IPs ip addresses. .map(|deployment| self.create_service_from(deployment)) - .collect::>(); + .map(Box::pin) + .collect::>>>(); - for create_service_result in join_all(futures).await { - let service = match create_service_result { + let mut services = Vec::with_capacity(futures.size_hint().0); + while let Some(service) = futures.next().await { + let service = match service { Ok(service) => service, Err(e) => { debug!("Deployment does not provide required data: {:?}", e); @@ -211,54 +248,14 @@ impl KubernetesInfrastructure { app_name: &AppName, service_name: &str, ) -> Result, KubernetesInfrastructureError> { - let p = ListParams { - label_selector: Some(format!("{SERVICE_NAME_LABEL}={service_name}")), - ..Default::default() - }; + let deployment = self.get_deployment(app_name, service_name).await?; - match Api::::namespaced( - self.client().await?, - &app_name.to_rfc1123_namespace_id(), - ) - .list(&p) - .await? - .items - .into_iter() - .next() - .map(|deployment| self.create_service_from(deployment)) - { - None => Ok(None), + match deployment.map(|deployment| self.create_service_from(deployment)) { Some(service) => Ok(Some(service.await?)), + None => Ok(None), } } - async fn post_service_and_custom_resource_definitions( - &self, - app_name: &AppName, - service: &DeployableService, - ) -> Result<(), KubernetesInfrastructureError> { - let client = self.client().await?; - - Api::namespaced(client.clone(), &app_name.to_rfc1123_namespace_id()) - .create(&PostParams::default(), &service_payload(app_name, service)) - .await?; - - Api::namespaced(client.clone(), &app_name.to_rfc1123_namespace_id()) - .create( - &PostParams::default(), - &ingress_route_payload(app_name, service), - ) - .await?; - - for middleware in middleware_payload(app_name, service) { - Api::namespaced(client.clone(), &app_name.to_rfc1123_namespace_id()) - .create(&PostParams::default(), &middleware) - .await?; - } - - Ok(()) - } - async fn create_namespace_if_necessary( &self, app_name: &AppName, @@ -291,15 +288,13 @@ impl KubernetesInfrastructure { } } - async fn create_pull_secrets_if_necessary( - &self, - app_name: &AppName, - service: &[DeployableService], - ) -> Result<(), KubernetesInfrastructureError> { - let registries_and_credentials: BTreeMap = service - .iter() - .filter_map(|strategy| { - strategy.image().registry().and_then(|registry| { + fn image_pull_secret<'a, I>(&self, app_name: &AppName, images: I) -> Option + where + I: Iterator, + { + let registries_and_credentials: BTreeMap = images + .filter_map(|image| { + image.registry().and_then(|registry| { self.config .registry_credentials(®istry) .map(|(username, password)| (registry, (username, password))) @@ -308,163 +303,49 @@ impl KubernetesInfrastructure { .collect(); if registries_and_credentials.is_empty() { - return Ok(()); + return None; } - match Api::namespaced(self.client().await?, &app_name.to_rfc1123_namespace_id()) - .create( - &PostParams::default(), - &image_pull_secret_payload(app_name, registries_and_credentials), - ) - .await - { - Ok(result) => { - debug!( - "Successfully created image pull secret {}", - result - .metadata - .name - .unwrap_or_else(|| String::from("")) - ); - Ok(()) - } - Err(KubeError::Api(ErrorResponse { code, .. })) if code == 409 => { - debug!("Secrets already exists for {}", app_name); - Ok(()) - } - Err(e) => { - error!("Cannot deploy namespace: {}", e); - Err(e.into()) - } - } + Some(image_pull_secret_payload( + app_name, + registries_and_credentials, + )) } - async fn deploy_service<'a>( + async fn create_payloads( &self, app_name: &AppName, - service: &'a DeployableService, + deployabel_service: &DeployableService, container_config: &ContainerConfig, - ) -> Result<&'a DeployableService, KubernetesInfrastructureError> { - if let Some(files) = service.files() { - self.deploy_secret(app_name, service, files).await?; - } - - let client = self.client().await?; - - let persistence_volume_map = self - .create_persistent_volume_claim(app_name, service) - .await?; - - match Api::namespaced(client.clone(), &app_name.to_rfc1123_namespace_id()) - .create( - &PostParams::default(), - &deployment_payload( - app_name, - service, - container_config, - self.config - .registry_credentials(&service.image().registry().unwrap_or_default()) - .is_some(), - &persistence_volume_map, - ), - ) - .await - { - Ok(result) => { - debug!( - "Successfully deployed {}", - result - .metadata - .name - .unwrap_or_else(|| String::from("")) - ); - self.post_service_and_custom_resource_definitions(app_name, service) - .await?; - Ok(service) - } - - Err(KubeError::Api(ErrorResponse { code, .. })) if code == 409 => { - Api::::namespaced( - client.clone(), - &app_name.to_rfc1123_namespace_id(), - ) - .patch( - &format!( - "{}-{}-deployment", - app_name.to_rfc1123_namespace_id(), - service.service_name() - ), - &PatchParams::default(), - &Patch::Merge(deployment_payload( - app_name, - service, - container_config, - self.config - .registry_credentials(&service.image().registry().unwrap_or_default()) - .is_some(), - &persistence_volume_map, - )), - ) - .await?; - Ok(service) - } - Err(e) => { - error!("Cannot deploy service: {}", e); - Err(e.into()) - } - } - } - - async fn deploy_secret( - &self, - app_name: &AppName, - service_config: &ServiceConfig, - volumes: &BTreeMap, - ) -> Result<(), KubernetesInfrastructureError> { - debug!( - "Deploying volumes as secrets for {} in app {}", - service_config.service_name(), - app_name + ) -> Result< + ( + Option, + V1Service, + V1Deployment, + IngressRoute, + Vec, + ), + KubernetesInfrastructureError, + > { + let secret = deployabel_service + .files() + .map(|files| secrets_payload(app_name, deployabel_service, files)); + + let service = service_payload(app_name, deployabel_service); + + let deployment = deployment_payload( + app_name, + deployabel_service, + container_config, + &self + .create_persistent_volume_claim(app_name, deployabel_service) + .await?, ); - let client = self.client().await?; + let ingress_route = ingress_route_payload(app_name, deployabel_service); + let middlewares = middleware_payload(app_name, deployabel_service.ingress_route()); - match Api::namespaced(client.clone(), &app_name.to_rfc1123_namespace_id()) - .create( - &PostParams::default(), - &secrets_payload(app_name, service_config, volumes), - ) - .await - { - Ok(result) => { - debug!( - "Successfully deployed {}", - result - .metadata - .name - .unwrap_or_else(|| String::from("")) - ); - Ok(()) - } - Err(KubeError::Api(ErrorResponse { code, .. })) if code == 409 => { - Api::::namespaced(client.clone(), &app_name.to_rfc1123_namespace_id()) - .patch( - &format!( - "{}-{}-secret", - app_name.to_rfc1123_namespace_id(), - service_config.service_name() - ), - &PatchParams::default(), - &Patch::Merge(secrets_payload(app_name, service_config, volumes)), - ) - .await?; - Ok(()) - } - Err(e) => { - error!("Cannot deploy secret: {}", e); - Err(e.into()) - } - } + Ok((secret, service, deployment, ingress_route, middlewares)) } async fn create_persistent_volume_claim<'a>( @@ -600,7 +481,6 @@ impl Infrastructure for KubernetesInfrastructure { .collect::>(); let mut apps = MultiMap::new(); - for app_name in app_names { let services = self.get_services_of_app(&app_name).await?; apps.insert_many(app_name, services); @@ -615,24 +495,58 @@ impl Infrastructure for KubernetesInfrastructure { deployment_unit: &DeploymentUnit, container_config: &ContainerConfig, ) -> Result, Error> { - let services = deployment_unit.services(); let app_name = deployment_unit.app_name(); - self.create_namespace_if_necessary(app_name).await?; - self.create_pull_secrets_if_necessary(app_name, services) - .await?; - let futures = services - .iter() - .map(|service| self.deploy_service(app_name, service, container_config)) - .collect::>(); + let client = self.client().await?; + + let bootstrap_image_pull_secret = self.image_pull_secret( + app_name, + self.config + .companion_bootstrapping_containers() + .iter() + .map(|bc| bc.image()), + ); + let mut k8s_deployment_unit = K8sDeploymentUnit::bootstrap( + deployment_unit, + client.clone(), + self.config.companion_bootstrapping_containers(), + bootstrap_image_pull_secret, + ) + .await?; + + let services = self.get_services_of_app(app_name).await?; + k8s_deployment_unit.filter_by_instances_and_replicas(&services); + + // TODO: things like cloning data from existing deployments have to be considered + for deployable_service in deployment_unit.services() { + let (secret, service, deployment, ingress_route, middlewares) = self + .create_payloads(app_name, deployable_service, container_config) + .await?; + + k8s_deployment_unit.merge(secret, service, deployment, ingress_route, middlewares); + } - for deploy_result in join_all(futures).await { - trace!("deployed {:?}", deploy_result); - deploy_result?; + if let Some(image_pull_secret) = + self.image_pull_secret(app_name, k8s_deployment_unit.images().iter()) + { + k8s_deployment_unit.apply_image_pull_secret(image_pull_secret); } - Ok(self.get_services_of_app(app_name).await?) + let deployments = k8s_deployment_unit.deploy(client, app_name).await?; + let mut services = Vec::with_capacity(deployments.len()); + for deployment in deployments.into_iter() { + let service = match self.create_service_from(deployment).await { + Ok(service) => service, + Err(e) => { + debug!("Deployment does not provide required data: {:?}", e); + continue; + } + }; + services.push(service); + } + + Ok(services) } async fn stop_services( @@ -662,19 +576,17 @@ impl Infrastructure for KubernetesInfrastructure { from: &Option>, limit: usize, ) -> Result, String)>>, Error> { - let p = ListParams { - label_selector: Some(format!("{SERVICE_NAME_LABEL}={service_name}",)), - ..Default::default() + let client = self.client().await?; + let namespace = app_name.to_rfc1123_namespace_id(); + + let deployment = match self.get_deployment(app_name, service_name).await? { + Some(deployment) => deployment, + None => { + return Ok(None); + } }; - let pod = match Api::::namespaced( - self.client().await?, - &app_name.to_rfc1123_namespace_id(), - ) - .list(&p) - .await? - .into_iter() - .next() - { + + let pod = match self.get_pod_of_deployment(&deployment).await? { Some(pod) => pod, None => { return Ok(None); @@ -700,10 +612,9 @@ impl Infrastructure for KubernetesInfrastructure { ..Default::default() }; - let logs = - Api::::namespaced(self.client().await?, &app_name.to_rfc1123_namespace_id()) - .logs(&pod.metadata.name.unwrap(), &p) - .await?; + let logs = Api::::namespaced(client, &namespace) + .logs(&pod.metadata.name.unwrap(), &p) + .await?; let logs = logs .split('\n') @@ -745,15 +656,23 @@ impl Infrastructure for KubernetesInfrastructure { None => return Ok(None), }; + let mut deployment = match self.get_deployment(app_name, service_name).await? { + Some(deployment) => deployment, + None => { + return Ok(None); + } + }; + + let Some(spec) = deployment.spec.as_mut() else { + return Ok(None); + }; + spec.replicas = Some(replicas); + Api::::namespaced(self.client().await?, &app_name.to_rfc1123_namespace_id()) .patch( - &format!( - "{}-{}-deployment", - app_name.to_rfc1123_namespace_id(), - service_name - ), + &deployment.metadata.name.clone().unwrap(), &PatchParams::default(), - &Patch::Merge(deployment_replicas_payload(app_name, &service, replicas)), + &Patch::Merge(deployment), ) .await?; @@ -866,9 +785,9 @@ impl TryFrom for ServiceBuilder { deployment .spec .as_ref() - .map(|spec| match (spec.paused, spec.replicas) { - (Some(true), _) => ServiceStatus::Paused, - (Some(false), Some(replicas)) if replicas <= 0 => ServiceStatus::Paused, + .map(|spec| match spec.replicas { + None => ServiceStatus::Paused, + Some(replicas) if replicas <= 0 => ServiceStatus::Paused, _ => ServiceStatus::Running, }) .unwrap_or(ServiceStatus::Paused), @@ -892,24 +811,24 @@ impl TryFrom<&V1Deployment> for ServiceConfig { &deployment.metadata.labels, &deployment.metadata.annotations, ) { - let service_name = match labels.get(SERVICE_NAME_LABEL) { - Some(service_name) => service_name, - None => { - return Err(KubernetesInfrastructureError::MissingServiceNameLabel { - deployment_name: deployment_name.clone(), - }); - } - }; + let service_name = labels.get(SERVICE_NAME_LABEL).unwrap_or(deployment_name); - let image = annotations + let image = match annotations .get(IMAGE_LABEL) - .map(|image| { - Image::from_str(image) - .expect("Kubernetes API should provide valid image string") - }) - .ok_or_else(|| KubernetesInfrastructureError::MissingImageLabel { - deployment_name: deployment_name.clone(), - })?; + .and_then(|image| Image::from_str(image).ok()) + { + Some(img) => img, + None => deployment + .spec + .as_ref() + .and_then(|spec| spec.template.spec.as_ref()) + .and_then(|pod_spec| pod_spec.containers.first()) + .and_then(|container| container.image.as_ref()) + .and_then(|image| Image::from_str(image).ok()) + .ok_or_else(|| KubernetesInfrastructureError::MissingImageLabel { + deployment_name: deployment_name.clone(), + })?, + }; let mut config = ServiceConfig::new(service_name.clone(), image); @@ -1111,7 +1030,7 @@ mod tests { } #[test] - fn should_not_parse_service_from_deployment_spec_missing_service_name_label() { + fn should_parse_service_from_deployment_spec_with_missing_service_name_label() { let deployment = deployment_object!( "master-nginx", Some(String::from("master")), @@ -1120,13 +1039,11 @@ mod tests { None, ); - let err = ServiceBuilder::try_from(deployment).unwrap_err(); - assert_eq!( - err, - KubernetesInfrastructureError::MissingServiceNameLabel { - deployment_name: "master-nginx".to_string() - } - ); + let service = ServiceBuilder::try_from(deployment) + .unwrap() + .build() + .unwrap(); + assert_eq!(service.service_name(), "master-nginx"); } #[test] diff --git a/api/src/infrastructure/kubernetes/mod.rs b/api/src/infrastructure/kubernetes/mod.rs index 7ba75388..2f2d1744 100644 --- a/api/src/infrastructure/kubernetes/mod.rs +++ b/api/src/infrastructure/kubernetes/mod.rs @@ -23,7 +23,8 @@ * THE SOFTWARE. * =========================LICENSE_END================================== */ -pub use infrastructure::{KubernetesInfrastructure, KubernetesInfrastructureError}; +pub use infrastructure::KubernetesInfrastructure; +mod deployment_unit; mod infrastructure; mod payloads; diff --git a/api/src/infrastructure/kubernetes/payloads.rs b/api/src/infrastructure/kubernetes/payloads.rs index aac3830b..ff343405 100644 --- a/api/src/infrastructure/kubernetes/payloads.rs +++ b/api/src/infrastructure/kubernetes/payloads.rs @@ -31,17 +31,17 @@ use crate::config::{Config, ContainerConfig}; use crate::deployment::deployment_unit::{DeployableService, DeploymentStrategy}; use crate::infrastructure::traefik::TraefikMiddleware; use crate::infrastructure::{TraefikIngressRoute, TraefikRouterRule}; -use crate::models::service::Service; use crate::models::{AppName, ServiceConfig}; use base64::{engine::general_purpose, Engine}; use bytesize::ByteSize; use chrono::Utc; use k8s_openapi::api::apps::v1::DeploymentSpec; use k8s_openapi::api::core::v1::{ - Container, ContainerPort, EnvVar, KeyToPath, LocalObjectReference, PersistentVolumeClaim, - PersistentVolumeClaimSpec, PersistentVolumeClaimVolumeSource, PodSpec, PodTemplateSpec, - ResourceRequirements, SecretVolumeSource, Volume, VolumeMount, + Container, ContainerPort, EnvVar, KeyToPath, PersistentVolumeClaim, PersistentVolumeClaimSpec, + PersistentVolumeClaimVolumeSource, PodSpec, PodTemplateSpec, ResourceRequirements, + SecretVolumeSource, Volume, VolumeMount, }; +use k8s_openapi::api::networking::v1::Ingress; use k8s_openapi::api::{ apps::v1::Deployment as V1Deployment, core::v1::Namespace as V1Namespace, core::v1::Secret as V1Secret, core::v1::Service as V1Service, @@ -56,8 +56,10 @@ use schemars::JsonSchema; use secstr::SecUtf8; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::collections::hash_map::DefaultHasher; use std::collections::{BTreeMap, HashMap, HashSet}; use std::convert::TryFrom; +use std::hash::Hasher; use std::iter::FromIterator; use std::path::{Component, PathBuf}; use std::str::FromStr; @@ -72,7 +74,7 @@ use std::string::ToString; )] #[serde(rename_all = "camelCase")] pub struct IngressRouteSpec { - pub entrypoints: Option>, + pub entry_points: Option>, pub routes: Option>, pub tls: Option, } @@ -94,13 +96,13 @@ pub struct TraefikRuleService { #[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)] pub struct TraefikRuleMiddleware { - name: String, + pub name: String, } #[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct TraefikTls { - cert_resolver: Option, + pub cert_resolver: Option, } #[derive(CustomResource, Clone, Debug, Deserialize, Serialize, JsonSchema)] @@ -111,7 +113,7 @@ pub struct TraefikTls { namespaced )] #[serde(rename_all = "camelCase")] -pub struct MiddlewareSpec(Value); +pub struct MiddlewareSpec(pub Value); macro_rules! secret_name_from_path { ($path:expr) => {{ @@ -139,14 +141,23 @@ macro_rules! secret_name_from_name { } impl TryFrom for TraefikIngressRoute { - type Error = &'static str; + type Error = String; fn try_from(value: IngressRoute) -> Result { - let k8s_route = value.spec.routes.unwrap().into_iter().next().unwrap(); - let rule = TraefikRouterRule::from_str(&k8s_route.r#match).unwrap(); + let Some(routes) = value.spec.routes else { + return Err(String::from( + "The ingress route does not provide any routes", + )); + }; + let Some(k8s_route) = routes.into_iter().next() else { + return Err(String::from( + "The ingress route does not provide any routes", + )); + }; + let rule = TraefikRouterRule::from_str(&k8s_route.r#match)?; Ok(TraefikIngressRoute::with_existing_routing_rules( - value.spec.entrypoints.unwrap_or_default(), + value.spec.entry_points.unwrap_or_default(), rule, k8s_route .middlewares @@ -159,6 +170,134 @@ impl TryFrom for TraefikIngressRoute { } } +pub fn convert_k8s_ingress_to_traefik_ingress( + ingress: Ingress, + base_route: TraefikIngressRoute, +) -> Result<(IngressRoute, Option), &'static str> { + let Some(spec) = ingress.spec else { + return Err("Ingress does not provide spec"); + }; + let Some(rules) = spec.rules else { + return Err("Ingress' spec does not provide rules"); + }; + + let Some(path) = rules + .into_iter() + .filter_map(|rule| rule.http) + .find_map(|http| http.paths.into_iter().next()) + else { + return Err("Ingress' rule does not a provide http paths object"); + }; + + let Some(path_value) = path.path else { + return Err("Ingress' path does not provide a HTTP path value"); + }; + + let (rule, middleware) = match &spec.ingress_class_name { + Some(ingress_class_name) if ingress_class_name == "nginx" => { + let middleware = ingress + .metadata + .annotations + .as_ref() + .filter(|annotations| { + annotations.get("nginx.ingress.kubernetes.io/use-regex") + == Some(&String::from("true")) + }) + .and_then(|annotations| { + annotations + .get("nginx.ingress.kubernetes.io/rewrite-target") + .cloned() + }) + .and_then(|_rewrite_target| { + let hir = regex_syntax::parse(&path_value).ok()?; + let got = regex_syntax::hir::literal::Extractor::new().extract(&hir); + let prefixes = got + .literals()? + .iter() + .map(|l| String::from_utf8_lossy(l.as_bytes()).to_string()) + .map(serde_json::Value::from) + .collect::>(); + + Some(Middleware { + metadata: kube::core::ObjectMeta { + name: Some(uuid::Uuid::new_v4().to_string()), + ..Default::default() + }, + spec: MiddlewareSpec(serde_json::json!({ + "stripPrefix": { + "prefixes": serde_json::Value::from(prefixes) + } + })), + }) + }); + + (None, middleware) + } + _ => { + // TODO warn that ingress class is unknown + ( + Some(TraefikIngressRoute::with_rule( + TraefikRouterRule::path_prefix_rule([path_value.clone()]), + )), + None, + ) + } + }; + + let mut route = base_route; + if let Some(rule) = rule { + route.merge_with(rule); + } + + let mut middlewares = route + .routes() + .iter() + .flat_map(|route| route.middlewares().iter()) + .filter_map(|middleware| match middleware { + crate::infrastructure::traefik::TraefikMiddleware::Ref(name) => { + Some(TraefikRuleMiddleware { name: name.clone() }) + } + crate::infrastructure::traefik::TraefikMiddleware::Spec { .. } => None, + }) + .collect::>(); + middlewares.extend(middleware.as_ref().map(|m| TraefikRuleMiddleware { + name: m.metadata.name.clone().unwrap_or_default(), + })); + + let routes = vec![TraefikRuleSpec { + kind: String::from("Rule"), + r#match: route.routes()[0].rule().to_string(), + middlewares: Some(middlewares), + services: vec![TraefikRuleService { + kind: Some(String::from("Service")), + name: path.backend.service.clone().unwrap().name, + port: Some( + path.backend + .service + .as_ref() + .and_then(|service| service.port.as_ref()) + .and_then(|port| port.number) + .map(|p| p as u16) + // TODO: how to get the if missing + .unwrap_or(80), + ), + }], + }]; + + let route = IngressRoute { + metadata: ingress.metadata, + spec: IngressRouteSpec { + routes: Some(routes), + entry_points: Some(route.entry_points().clone()), + tls: route.tls().as_ref().map(|tls| TraefikTls { + cert_resolver: Some(tls.cert_resolver.clone()), + }), + }, + }; + + Ok((route, middleware)) +} + /// Creates a JSON payload suitable for [Kubernetes' /// Namespaces](https://kubernetes.io/docs/tasks/administer-cluster/namespaces/) pub fn namespace_payload(app_name: &AppName, config: &Config) -> V1Namespace { @@ -202,7 +341,6 @@ pub fn deployment_payload( app_name: &AppName, service: &DeployableService, container_config: &ContainerConfig, - use_image_pull_secret: bool, persistent_volume_map: &Option>, ) -> V1Deployment { let env = service.env().map(|env| { @@ -346,7 +484,7 @@ pub fn deployment_payload( template: PodTemplateSpec { metadata: Some(ObjectMeta { labels: Some(labels), - annotations: Some(deployment_annotations(service)), + annotations: Some(deployment_annotations(service.strategy())), ..Default::default() }), spec: Some(PodSpec { @@ -364,16 +502,6 @@ pub fn deployment_payload( resources, ..Default::default() }], - image_pull_secrets: if use_image_pull_secret { - Some(vec![LocalObjectReference { - name: Some(format!( - "{}-image-pull-secret", - app_name.to_rfc1123_namespace_id() - )), - }]) - } else { - None - }, ..Default::default() }), }, @@ -389,8 +517,8 @@ pub fn deployment_payload( /// For example, this [popular workaround](https://stackoverflow.com/a/55221174/5088458) will be /// applied to ensure that a pod will be recreated everytime a deployment with /// [`DeploymentStrategy::RedeployAlways`] has been initiated. -fn deployment_annotations(service: &DeployableService) -> BTreeMap { - match service.strategy() { +fn deployment_annotations(strategy: &DeploymentStrategy) -> BTreeMap { + match strategy { DeploymentStrategy::RedeployOnImageUpdate(image_id) => { BTreeMap::from([(String::from("imageHash"), image_id.clone())]) } @@ -401,37 +529,6 @@ fn deployment_annotations(service: &DeployableService) -> BTreeMap V1Deployment { - serde_json::from_value(serde_json::json!({ - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": { - "name": format!("{}-{}-deployment", app_name.to_rfc1123_namespace_id(), service.service_name()), - "namespace": app_name.to_rfc1123_namespace_id(), - "labels": { - APP_NAME_LABEL: app_name, - SERVICE_NAME_LABEL: service.service_name(), - CONTAINER_TYPE_LABEL: service.container_type().to_string() - } - }, - "spec": { - "replicas": replicas, - "selector": { - "matchLabels": { - APP_NAME_LABEL: app_name, - SERVICE_NAME_LABEL: service.service_name(), - CONTAINER_TYPE_LABEL: service.container_type().to_string() - } - } - } - })) - .expect("Cannot convert value to apps/v1/Deployment") -} - /// Creates a JSON payload suitable for [Kubernetes' Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) pub fn secrets_payload( app_name: &AppName, @@ -468,6 +565,14 @@ pub fn image_pull_secret_payload( app_name: &AppName, registries_and_credentials: BTreeMap, ) -> V1Secret { + // Hashing over all registries ensures that the same secret name will be generated for the same + // registries. Thus, password or user can change and will be updated. Additionally, it will be + // idempontent to the Kubernetes API. + let mut registry_hasher = DefaultHasher::new(); + for registry in registries_and_credentials.keys() { + registry_hasher.write(registry.as_bytes()); + } + let data = ByteString( serde_json::json!({ "auths": @@ -490,8 +595,9 @@ pub fn image_pull_secret_payload( V1Secret { metadata: ObjectMeta { name: Some(format!( - "{}-image-pull-secret", - app_name.to_rfc1123_namespace_id() + "{}-image-pull-secret-{:#010x}", + app_name.to_rfc1123_namespace_id(), + registry_hasher.finish() )), namespace: Some(app_name.to_rfc1123_namespace_id()), labels: Some(BTreeMap::from([( @@ -542,8 +648,9 @@ pub fn service_payload(app_name: &AppName, service_config: &ServiceConfig) -> V1 /// See [Traefik Routers](https://docs.traefik.io/v2.0/user-guides/crd-acme/#traefik-routers) /// for more information. pub fn ingress_route_payload(app_name: &AppName, service: &DeployableService) -> IngressRoute { - let rules = service - .ingress_route() + let route = service.ingress_route(); + + let rules = route .routes() .iter() .map(|route| { @@ -606,18 +713,21 @@ pub fn ingress_route_payload(app_name: &AppName, service: &DeployableService) -> }, spec: IngressRouteSpec { routes: Some(rules), - ..Default::default() + entry_points: Some(route.entry_points().clone()), + tls: route.tls().as_ref().map(|tls| TraefikTls { + cert_resolver: Some(tls.cert_resolver.clone()), + }), }, } } -/// Creates a payload that ensures that Traefik strips out the path prefix. -/// /// See [Traefik Routers](https://docs.traefik.io/v2.0/user-guides/crd-acme/#traefik-routers) /// for more information. -pub fn middleware_payload(app_name: &AppName, service: &DeployableService) -> Vec { - service - .ingress_route() +pub fn middleware_payload( + app_name: &AppName, + ingress_route: &TraefikIngressRoute, +) -> Vec { + ingress_route .routes() .iter() .flat_map(|r| { @@ -758,7 +868,6 @@ mod tests { Vec::new(), ), &ContainerConfig::default(), - false, &None, ); @@ -837,7 +946,6 @@ mod tests { Vec::new(), ), &ContainerConfig::default(), - false, &None, ); @@ -919,7 +1027,6 @@ mod tests { Vec::new(), ), &ContainerConfig::default(), - false, &None, ); @@ -1002,7 +1109,6 @@ mod tests { Vec::new(), ), &ContainerConfig::default(), - false, &None, ); @@ -1157,15 +1263,11 @@ mod tests { #[test] fn should_create_middleware_with_default_prefix() { let app_name = AppName::master(); - let config = sc!("db", "mariadb:10.3.17"); - let service = DeployableService::new( - config, - DeploymentStrategy::RedeployAlways, - TraefikIngressRoute::with_defaults(&app_name, "db"), - Vec::new(), - ); - let payload = middleware_payload(&app_name, &service); + let payload = middleware_payload( + &app_name, + &TraefikIngressRoute::with_defaults(&app_name, "db"), + ); assert_json_diff::assert_json_include!( actual: payload, @@ -1190,15 +1292,11 @@ mod tests { #[test] fn should_create_middleware_with_default_prefix_with_name_rfc1123_app_name() { let app_name = AppName::from_str("MY-APP").unwrap(); - let config = sc!("db", "mariadb:10.3.17"); - let service = DeployableService::new( - config, - DeploymentStrategy::RedeployAlways, - TraefikIngressRoute::with_defaults(&app_name, "db"), - Vec::new(), - ); - let payload = middleware_payload(&app_name, &service); + let payload = middleware_payload( + &app_name, + &TraefikIngressRoute::with_defaults(&app_name, "db"), + ); assert_json_diff::assert_json_include!( actual: payload, @@ -1260,7 +1358,6 @@ mod tests { vec![String::from("/var/lib/data")], ), &ContainerConfig::default(), - false, &Some(HashMap::from([( &String::from("/var/lib/data"), persistent_volume_claim, @@ -1359,7 +1456,6 @@ mod tests { Vec::new(), ), &ContainerConfig::default(), - false, &None, ); @@ -1489,4 +1585,48 @@ mod tests { } ); } + + #[test] + fn create_image_pull_secrets() { + let payload = image_pull_secret_payload( + &AppName::from_str("MY-APP").unwrap(), + BTreeMap::from([( + String::from("registry.gitlab.com"), + ("oauth2", &SecUtf8::from_str("some-random-token").unwrap()), + )]), + ); + + assert_eq!( + payload, + V1Secret { + metadata: ObjectMeta { + name: Some(String::from("my-app-image-pull-secret-0x7a2952c7a89d3fd0")), + namespace: Some(String::from("my-app")), + labels: Some(BTreeMap::from([( + String::from("com.aixigo.preview.servant.app-name"), + String::from("MY-APP") + )])), + ..Default::default() + }, + immutable: Some(true), + data: Some(BTreeMap::from([( + String::from(".dockerconfigjson"), + ByteString( + serde_json::json!({ + "auths": { + "registry.gitlab.com": { + "username": "oauth2", + "password": "some-random-token" + } + } + }) + .to_string() + .into_bytes() + ) + )])), + type_: Some(String::from("kubernetes.io/dockerconfigjson")), + ..Default::default() + } + ) + } } diff --git a/api/src/infrastructure/traefik.rs b/api/src/infrastructure/traefik.rs index dd3edd3d..cc06f641 100644 --- a/api/src/infrastructure/traefik.rs +++ b/api/src/infrastructure/traefik.rs @@ -13,10 +13,18 @@ pub struct TraefikIngressRoute { } impl TraefikIngressRoute { + pub fn entry_points(&self) -> &Vec { + &self.entry_points + } + pub fn routes(&self) -> &Vec { &self.routes } + pub fn tls(&self) -> &Option { + &self.tls + } + #[cfg(test)] pub fn empty() -> Self { Self { @@ -26,14 +34,37 @@ impl TraefikIngressRoute { } } + pub fn with_app_only_defaults(app_name: &AppName) -> Self { + let mut prefixes = BTreeMap::new(); + prefixes.insert( + Value::String(String::from("prefixes")), + Value::Seq(vec![Value::String(format!("/{app_name}/",))]), + ); + + let mut middlewares = BTreeMap::new(); + middlewares.insert( + Value::String(String::from("stripPrefix")), + Value::Map(prefixes), + ); + + Self { + entry_points: Vec::new(), + routes: vec![TraefikRoute { + rule: TraefikRouterRule::path_prefix_rule([app_name.as_str()]), + middlewares: vec![TraefikMiddleware::Spec { + name: format!("{app_name}-middleware"), + spec: Value::Map(middlewares), + }], + }], + tls: None, + } + } + pub fn with_defaults(app_name: &AppName, service_name: &str) -> Self { let mut prefixes = BTreeMap::new(); prefixes.insert( Value::String(String::from("prefixes")), - Value::Seq(vec![Value::String(format!( - "/{}/{}/", - app_name, service_name - ))]), + Value::Seq(vec![Value::String(format!("/{app_name}/{service_name}/",))]), ); let mut middlewares = BTreeMap::new(); @@ -55,7 +86,6 @@ impl TraefikIngressRoute { } } - #[cfg(test)] pub fn with_rule(rule: TraefikRouterRule) -> Self { Self::with_existing_routing_rules(Vec::new(), rule, Vec::new(), None) } @@ -83,7 +113,7 @@ impl TraefikIngressRoute { } pub fn merge_with(&mut self, other: Self) { - self.entry_points.extend(other.entry_points.into_iter()); + self.entry_points.extend(other.entry_points); // FIXME: at the moment there is no handling of multiple routes which needs to be addessed // in the future when it is required. @@ -109,6 +139,47 @@ impl TraefikIngressRoute { (Some(_), Some(tls)) => Some(tls), }; } + + pub fn to_url(&self) -> Option { + let mut domain = None; + let mut path = None; + + match self.routes.first() { + Some(route) => { + let rule = &route.rule; + for m in &rule.matches { + match m { + Matcher::Host { domains } => { + domain = Some(&domains[0]); + } + Matcher::PathPrefix { paths } => { + path = Some(&paths[0]); + } + _ => {} + } + } + } + None => return None, + } + + let scheme = if self.tls.is_some() + || self + .entry_points + .iter() + .any(|entry_point| entry_point == "websecure") + { + "https" + } else { + "http" + }; + + Url::parse(&format!( + "{scheme}://{}{}", + domain?, + path.as_ref().map(|p| p.as_str()).unwrap_or_default() + )) + .ok() + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -361,7 +432,7 @@ impl Display for TraefikRouterRule { #[derive(Clone, Debug, Eq, PartialEq)] pub struct TraefikTLS { - cert_resolver: String, + pub cert_resolver: String, } #[cfg(test)] @@ -652,8 +723,7 @@ mod test { cert_resolver: String::from("letsencrypt"), }), }; - let route2 = - TraefikIngressRoute::with_defaults(&AppName::from_str("master").unwrap(), "whoami"); + let route2 = TraefikIngressRoute::with_defaults(&AppName::master(), "whoami"); route1.merge_with(route2); @@ -701,8 +771,7 @@ mod test { cert_resolver: String::from("letsencrypt"), }), }; - let mut route2 = - TraefikIngressRoute::with_defaults(&AppName::from_str("master").unwrap(), "whoami"); + let mut route2 = TraefikIngressRoute::with_defaults(&AppName::master(), "whoami"); route2.merge_with(route1); @@ -753,13 +822,13 @@ mod test { let mut route1 = TraefikIngressRoute::empty(); route1.merge_with(TraefikIngressRoute::with_defaults( - &AppName::from_str("master").unwrap(), + &AppName::master(), "test", )); assert_eq!( route1, - TraefikIngressRoute::with_defaults(&AppName::from_str("master").unwrap(), "test",) + TraefikIngressRoute::with_defaults(&AppName::master(), "test",) ); } @@ -787,4 +856,66 @@ mod test { } ); } + + mod to_url { + use super::*; + + #[test] + fn empty_route() { + assert_eq!(TraefikIngressRoute::empty().to_url(), None); + } + + #[test] + fn with_host_rule() { + let url = + TraefikIngressRoute::with_rule(TraefikRouterRule::host_rule(vec![String::from( + "example.com", + )])) + .to_url(); + + assert_eq!(url, Url::parse("http://example.com").ok()); + } + + #[test] + fn with_host_and_path_rule() { + let url = TraefikIngressRoute::with_rule( + TraefikRouterRule::from_str( + "PathPrefix(`/master/whoami/`) && Host(`prevant.example.com`)", + ) + .unwrap(), + ) + .to_url(); + + assert_eq!( + url, + Url::parse("http://prevant.example.com/master/whoami/").ok() + ); + } + + #[test] + fn with_host_rule_and_tls() { + let mut route = + TraefikIngressRoute::with_rule(TraefikRouterRule::host_rule(vec![String::from( + "example.com", + )])); + route.tls = Some(TraefikTLS { + cert_resolver: String::from("first"), + }); + let url = route.to_url(); + + assert_eq!(url, Url::parse("https://example.com").ok()); + } + + #[test] + fn with_host_rule_and_websecure_entrypoint() { + let mut route = + TraefikIngressRoute::with_rule(TraefikRouterRule::host_rule(vec![String::from( + "example.com", + )])); + route.entry_points.push(String::from("websecure")); + let url = route.to_url(); + + assert_eq!(url, Url::parse("https://example.com").ok()); + } + } } diff --git a/api/src/models/app_name.rs b/api/src/models/app_name.rs index 1f87ccf6..d04f31c9 100644 --- a/api/src/models/app_name.rs +++ b/api/src/models/app_name.rs @@ -65,6 +65,12 @@ impl std::fmt::Display for AppName { } } +impl AsRef for AppName { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} + impl FromStr for AppName { type Err = AppNameError; diff --git a/api/src/models/service.rs b/api/src/models/service.rs index 8f67d70c..00807f59 100644 --- a/api/src/models/service.rs +++ b/api/src/models/service.rs @@ -240,14 +240,6 @@ impl ServiceBuilder { self } - pub fn current_app_name(&self) -> Option<&String> { - self.app_name.as_ref() - } - - pub fn current_config(&self) -> Option<&ServiceConfig> { - self.config.as_ref() - } - pub fn started_at(mut self, started_at: DateTime) -> Self { self.started_at = Some(started_at); self diff --git a/assets/bootstrap-companions.svg b/assets/bootstrap-companions.svg new file mode 100644 index 00000000..34bcf0cf --- /dev/null +++ b/assets/bootstrap-companions.svg @@ -0,0 +1,1146 @@ + + + + + + image/svg+xml + + in-a-nutshell + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + in-a-nutshell + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/companions.md b/docs/companions.md new file mode 100644 index 00000000..15350ffb --- /dev/null +++ b/docs/companions.md @@ -0,0 +1,107 @@ +# Companions + +A companion is a service that is running inside an application + +It is possible to start containers that will be started when the client requests to create a new service. For example, if the application requires an [OpenID](https://en.wikipedia.org/wiki/OpenID_Connect) provider, it is possible to create a configuration that starts the provider for each application. Another use case might be a Kafka services that is required by the application. + +Furthermore, it is also possible to create containers for each service. For example, for each service a database container could be started. + +For these use cases following sections provide example configurations. + +## Application Wide + +If you want to include an OpenID provider for every application, you could use following configuration. + +```toml +[companions.openid] +type = 'application' +image = 'private.example.com/library/openid:latest' +env = [ 'KEY=VALUE' ] +``` + +The provided values of `serviceName` and `env` can include the [handlebars syntax](https://handlebarsjs.com/) in order to access dynamic values. + +Additionally, you could mount files that are generated from handlebars templates (example contains a properties generation): + +```toml +[companions.openid.volumes] +"/path/to/volume.properties" = """ +remote.services={{#each services~}} + {{~#if (eq type 'instance')~}} + {{name}}:{{port}}, + {{~/if~}} +{{~/each~}} +""" +``` + +Furthermore, you can provide labels through handlebars templating: + +```toml +[companions.openid.labels] +"com.github.prevant" = "bar-{{application.name}}" +``` + +### Template Variables + +The list of available handlebars variables: + +- `application`: The companion's application information + - `name`: The application name +- `services`: An array of the services of the application. Each element has following structure: + - `name`: The service name which is equivalent to the network alias + - `port`: The exposed port of the service + - `type`: The type of service. For example, `instance`, `replica`, `app-companion`, or `service-companion`. + +### Handlebar Helpers + +PREvant provides some handlebars helpers which can be used to generate more complex configuration files. See handlerbar's [block helper documentation](https://handlebarsjs.com/block_helpers.html) for more details. + +- `{{#isCompanion }}` A conditional handlerbars block helper that checks if the given service type matches any companion type. +- `isNotCompanion ` A conditional handlerbars block helper that checks if the given service type does not match any companion type. + +## Service Based + +The service-based companions works the in the same way as the application-based services. Make sure, that the `serviceName` is unique by using the handlebars templating. + +```toml +[companions.service-name] +serviceName = '{{service.name}}-db' +image = 'postgres:11' +env = [ 'KEY=VALUE' ] + +[companions.service-name.postgres.volumes] +"/path/to/volume.properties" == "…" +[companions.openid.labels] +"com.github.prevant" = "bar-{{application.name}}" +``` + + +### Template Variables + +The list of available handlebars variables: + +- `application`: The companion's application information + - `name`: The application name +- `service`: The companion's service containing following fields: + - `name`: The service name which is equivalent to the network alias + - `port`: The exposed port of the service + - `type`: The type of service. For example, `instance`, `replica`, `app-companion`, or `service-companion`. + +## Deployment Strategy + +Companions offer different deployment strategies so that a companion could be restarted or not under certain conditions. Therefore, PREvant offers following configuration flags: + +```toml +[companions.openid] +type = 'application' +image = 'private.example.com/library/openid:latest' +deploymentStrategy = 'redeploy-on-image-update' +``` + +`deploymentStrategy` offers following values and if a companion exists for an app following strategy will be applied: + +- `redeploy-always` (_default_): Re-deploys the companion every time there is a new deployment request. +- `redeploy-on-image-update`: Re-deploys the companion if there is a more rescent image available. +- `redeploy-never`: Even if there is a new deployment request the companion won't be redeployed and stays running. + +a