From f37e9054ea3f000d25cc4ebaa464bbaa2a9c6513 Mon Sep 17 00:00:00 2001 From: Marc Schreiber Date: Thu, 23 Jan 2025 13:14:19 +0100 Subject: [PATCH] Configure Static Web Host Meta For services that are outside of the control of the team that uses PREvant and where is no possibility to create a resource route for .well-known/host-meta.json it is now possible to configure a static URL to the OpenAPI documentation that will be then integrated into the PREvant dashboard. This file will be massaged so that it points to the service service URL. Also, this commit makes sure that Kubernetes Ingresses with path prefix will be mapped correctly. Without this you won't be able to expose multiple prefixes in a bootstrapped environment. Additionally, PREVant provide a new CLI option to debug new config from the outside and reduces some network calls. --- README.md | 44 +- api/Cargo.lock | 59 +-- api/Cargo.toml | 1 + api/src/apps/host_meta_cache.rs | 238 ++++++++-- api/src/apps/mod.rs | 145 ++++-- api/src/apps/routes/create_app_payload.rs | 15 +- api/src/apps/routes/logs.rs | 35 +- api/src/apps/routes/mod.rs | 117 ++--- api/src/apps/routes/static_openapi_spec.rs | 90 ++++ api/src/config/app_selector.rs | 54 --- api/src/config/mod.rs | 181 +++++++- api/src/config/selectors.rs | 57 +++ api/src/deployment/hooks.rs | 1 + api/src/infrastructure/docker.rs | 29 +- .../infrastructure/dummy_infrastructure.rs | 28 ++ api/src/infrastructure/infrastructure.rs | 21 +- .../kubernetes/deployment_unit.rs | 1 + .../kubernetes/infrastructure.rs | 50 ++- api/src/infrastructure/kubernetes/payloads.rs | 423 +++++++++++++----- api/src/infrastructure/traefik.rs | 89 +++- api/src/main.rs | 22 +- api/src/models/image.rs | 3 +- api/src/models/service.rs | 8 +- api/src/models/web_host_meta.rs | 32 +- api/src/registry.rs | 1 + api/src/tickets.rs | 3 +- api/src/webhooks.rs | 3 +- docs/web-host-meta.md | 87 ++++ 28 files changed, 1387 insertions(+), 450 deletions(-) create mode 100644 api/src/apps/routes/static_openapi_spec.rs delete mode 100644 api/src/config/app_selector.rs create mode 100644 api/src/config/selectors.rs create mode 100644 docs/web-host-meta.md diff --git a/README.md b/README.md index a593183a..9ff3f2c3 100644 --- a/README.md +++ b/README.md @@ -109,44 +109,11 @@ In this section, you'll find examples of deploying PREvant in container environm To customize the behavior of PREvant, you can mount a TOML file into the container at `/app/config.toml`. More details about the configuration can be found [here](docs/configuration.md). -# Requirements for Your Services - -PREvant is able to show the version of your service (build time, version string, and git commit hash) and also to integrate your API specification into the frontend through [Swagger UI](https://swagger.io/tools/swagger-ui/). In order to show the information, PREvant tries to resolve it by using the web-based protocol proposed by [RFC 6415](https://tools.ietf.org/html/rfc6415). - -When you request the list of apps and services running through the frontend, PREvant makes a request for each service to the URL `.well-known/host-meta.json` and expects that the resource provides a [host-meta document](http://docs.oasis-open.org/xri/xrd/v1.0/xrd-1.0.html) serialized as JSON: - -```json -{ - "properties": { - "https://schema.org/softwareVersion": "0.9", - "https://schema.org/dateModified": "2019-04-09T15:31:01.363+0200", - "https://git-scm.com/docs/git-commit": "43de4c6edf3c7ed93cdf8983f1ea7d73115176cc" - }, - "links": [ - { - "rel": "https://github.com/OAI/OpenAPI-Specification", - "href": "https://example.com/master/service-name/swagger.json" - }, - { - "rel": "https://github.com/asyncapi/spec", - "href": "https://github.com/asyncapi/spec/blob/master/examples/streetlights-kafka-asyncapi.yml" - } - ] -} -``` - -This sample document contains the relevant information displayed in the frontend (each information is optional): - -- The software version of the service (see `https://schema.org/softwareVersion`) -- The build time of the service (see `https://schema.org/dateModified`) -- The git commit id of the service (see `https://git-scm.com/docs/git-commit`) -- The link to the OpenAPI specification (see `https://github.com/OAI/OpenAPI-Specification`) -- The link to the AsyncAPI specification (see `https://github.com/asyncapi/spec`) - -In order to generate the correct link to the API specification, PREvant adds following headers to each of these requests: - -- [`Forwarded` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) with `host` and `proto`. -- `X-Forwarded-Prefix` (used by some reverse proxies, cf. [Traefik](https://docs.traefik.io/basics/) and [Zuul](https://cloud.spring.io/spring-cloud-static/Finchley.SR1/multi/multi__router_and_filter_zuul.html)). +# Integration of Your Services into the dashboard + +As shown above in the screenshot, PREvant offers some integration into its +dashboard. How the integration can be achieved is documented +[here](docs/web-host-meta.md). # Development @@ -158,7 +125,6 @@ addressing issues, enhancing documentation, and submitting pull requests. The project's open-source nature encourages collaboration and innovation from the developer community. - # Further Readings PREvant's concept has been published in the [Joint Post-proceedings of the First and Second International Conference on Microservices (Microservices 2017/2019): PREvant (Preview Servant): Composing Microservices into Reviewable and Testable Applications](http://dx.doi.org/10.4230/OASIcs.Microservices.2017-2019.5). diff --git a/api/Cargo.lock b/api/Cargo.lock index ce347bac..eaead798 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -538,9 +538,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.26" +version = "4.5.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8eb5e908ef3a6efbe1ed62520fb7287959888c88485abe072543190ecc66783" +checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796" dependencies = [ "clap_builder", "clap_derive", @@ -548,9 +548,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.26" +version = "4.5.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b01801b5fc6a0a232407abc821660c9c6d25a1cafc0d4f85f29fb8d9afc121" +checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" dependencies = [ "anstream", "anstyle", @@ -621,9 +621,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] @@ -1115,9 +1115,9 @@ dependencies = [ [[package]] name = "getset" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f636605b743120a8d32ed92fc27b6cde1a769f8f936c065151eb66f88ded513c" +checksum = "eded738faa0e88d3abc9d1a13cb11adc2073c400969eeb8793cf7132589959fc" dependencies = [ "proc-macro-error2", "proc-macro2", @@ -1755,13 +1755,13 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "is-terminal" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37" dependencies = [ "hermit-abi 0.4.0", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1824,9 +1824,9 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.28.1" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2303ef9ebb6acd7afe7c48cbc06ab807349c429d4e47c4cde8b35400503198f" +checksum = "4b8f66fe41fa46a5c83ed1c717b7e0b4635988f427083108c8cf0a882cc13441" dependencies = [ "ahash", "base64 0.22.1", @@ -2103,9 +2103,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c" dependencies = [ "libc", "log", @@ -2339,9 +2339,9 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "openssl" -version = "0.10.68" +version = "0.10.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +checksum = "f5e534d133a060a3c19daec1eb3e98ec6f4685978834f2dbadfe2ec215bab64e" dependencies = [ "bitflags", "cfg-if", @@ -2365,9 +2365,9 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" @@ -2640,6 +2640,7 @@ dependencies = [ "pest_derive", "regex", "regex-syntax 0.8.5", + "reqwest", "rocket", "schemars", "secstr", @@ -2784,9 +2785,9 @@ dependencies = [ [[package]] name = "referencing" -version = "0.28.1" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fb7a1f338d8e32357ad1d7078454c248e5fdd2188fbb6966b400c2fa4d4f566" +checksum = "d0dcb5ab28989ad7c91eb1b9531a37a1a137cc69a0499aee4117cae4a107c464" dependencies = [ "ahash", "fluent-uri", @@ -2841,9 +2842,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "regress" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f56e622c2378013c6c61e2bd776604c46dc1087b2dc5293275a0c20a44f0771" +checksum = "78ef7fa9ed0256d64a688a3747d0fef7a88851c18a5e1d57f115f38ec2e09366" dependencies = [ "hashbrown 0.15.2", "memchr", @@ -3007,9 +3008,9 @@ checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" [[package]] name = "rustix" -version = "0.38.43" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ "bitflags", "errno", @@ -3966,9 +3967,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243" [[package]] name = "unicode-normalization" @@ -4035,9 +4036,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4" +checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" dependencies = [ "getrandom", "serde", diff --git a/api/Cargo.toml b/api/Cargo.toml index bfbb2973..fa5d6ff7 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -45,6 +45,7 @@ pest = "2.7" pest_derive = "2.7" regex = "1.11" regex-syntax = "0.8" +reqwest = "0.12" rocket = { version = "0.5", features = ["json"] } schemars = "0.8" secstr = { version = "0.5", features = ["serde"] } diff --git a/api/src/apps/host_meta_cache.rs b/api/src/apps/host_meta_cache.rs index e139dc7f..d814022d 100644 --- a/api/src/apps/host_meta_cache.rs +++ b/api/src/apps/host_meta_cache.rs @@ -25,6 +25,7 @@ */ use crate::apps::Apps; +use crate::config::Config; use crate::infrastructure::HttpForwarder; use crate::models::service::{ Service, ServiceStatus, ServiceWithHostMeta, Services, ServicesWithHostMeta, @@ -35,12 +36,15 @@ use evmap::{ReadHandleFactory, WriteHandle}; use futures::stream::FuturesUnordered; use futures::StreamExt; use http::header::{HOST, USER_AGENT}; +use log::{debug, error, info}; use rocket::outcome::Outcome; use rocket::request::{self, FromRequest, Request}; use std::collections::{HashMap, HashSet}; +use std::hash::Hash; use std::sync::Arc; use tokio::sync::watch::{self, Receiver, Sender}; use tokio_stream::wrappers::WatchStream; +use url::Url; use yansi::Paint; pub struct HostMetaCache { @@ -48,6 +52,7 @@ pub struct HostMetaCache { update_watch_rx: Receiver>, } pub struct HostMetaCrawler { + config: Config, writer: WriteHandle>, update_watch_tx: Sender>, } @@ -64,7 +69,7 @@ struct Value { web_host_meta: WebHostMeta, } -pub fn new() -> (HostMetaCache, HostMetaCrawler) { +pub fn new(config: Config) -> (HostMetaCache, HostMetaCrawler) { // TODO: eventually we should replace evmap with the watch channel or with another thread safe // alternative.. let (reader, writer) = evmap::new(); @@ -76,6 +81,7 @@ pub fn new() -> (HostMetaCache, HostMetaCrawler) { update_watch_rx, }, HostMetaCrawler { + config, writer, update_watch_tx, }, @@ -83,7 +89,7 @@ pub fn new() -> (HostMetaCache, HostMetaCrawler) { } impl HostMetaCache { - pub fn update_meta_data( + pub fn convert_services_into_services_with_host_meta( &self, services: HashMap, request_info: &RequestInfo, @@ -126,6 +132,32 @@ impl HostMetaCache { assigned_apps } + pub fn convert_service_into_service_with_host_meta( + &self, + app_name: &AppName, + service: Service, + request_info: &RequestInfo, + ) -> ServiceWithHostMeta { + let key = Key { + app_name: app_name.clone(), + service_id: service.id.clone(), + }; + + let web_host_meta = match self.reader_factory.handle().get_one(&key) { + Some(value) => value + .web_host_meta + .with_base_url(request_info.get_base_url()), + None => WebHostMeta::empty(), + }; + + ServiceWithHostMeta::from_service_and_web_host_meta( + service, + web_host_meta, + request_info.get_base_url().clone(), + app_name, + ) + } + pub fn cache_updates(&self) -> WatchStream> { WatchStream::from_changes(self.update_watch_rx.clone()) } @@ -188,6 +220,56 @@ impl HostMetaCrawler { }); } + fn static_web_host_config( + &self, + apps: &HashMap, + ) -> HashMap { + apps.iter() + .flat_map(|(app_name, services)| { + services.iter().map(move |service| (app_name, service)) + }) + .filter_map(|(app_name, service)| { + let service_name = service.service_name(); + let static_host_meta = match self + .config + .static_host_meta(service.config.image()) + .transpose()? + { + Ok(static_host_meta) => static_host_meta, + Err(err) => { + error!( + "Cannot get static host meta config for {service_name} in {app_name}: {err}", + ); + return None; + } + }; + + let mut open_api_spec_url = None; + let mut version = None; + + if static_host_meta.image_tag_as_version { + version = service.config.image().tag(); + } + if static_host_meta.open_api_spec.is_some() { + open_api_spec_url = Some( + Url::parse(&format!( + "http://localhost/api/apps/{app_name}/static-open-api-spec/{service_name}" + )) + .unwrap(), + ); + } + + Some(( + Key { + app_name: app_name.clone(), + service_id: service.id().to_string(), + }, + WebHostMeta::with_version_and_open_api_spec_link(version, open_api_spec_url), + )) + }) + .collect::>() + } + async fn crawl( &mut self, http_forwarder: Box, @@ -196,6 +278,17 @@ impl HostMetaCrawler { ) -> Option> { self.clear_stale_web_host_meta(apps); + let static_web_host_config = self.static_web_host_config(apps); + if !static_web_host_config.is_empty() { + debug!( + "Got static host config for: {}", + static_web_host_config + .keys() + .map(|key| format!("({}, {})", key.app_name, key.service_id)) + .fold(String::new(), |a, b| a + &b + ", ") + ); + } + let running_services_without_host_meta = apps .iter() .flat_map(|(app_name, services)| { @@ -210,37 +303,47 @@ impl HostMetaCrawler { (key, service.clone()) }) }) + .filter(|(key, _)| !static_web_host_config.contains_key(key)) .filter(|(_, service)| *service.status() == ServiceStatus::Running) .filter(|(key, _service)| !self.writer.contains_key(key)) .collect::>(); - if running_services_without_host_meta.is_empty() { - return None; - } - - debug!( - "Resolving web host meta data for {:?}.", - running_services_without_host_meta - .iter() - .map(|(k, service)| format!("({}, {})", k.app_name, service.service_name())) - .fold(String::new(), |a, b| a + &b + ", ") - ); - let now = Utc::now(); - let duration_prevant_startup = Utc::now().signed_duration_since(since_timestamp); - let resolved_host_meta_infos = Self::resolve_host_meta( - http_forwarder, - running_services_without_host_meta, - duration_prevant_startup, - ) - .await; let mut updated_host_meta_info_entries = 0; - for (key, _service, web_host_meta) in resolved_host_meta_infos { - if !web_host_meta.is_valid() { - continue; - } + let now = Utc::now(); + if !running_services_without_host_meta.is_empty() { + debug!( + "Resolving web host meta data for {:?}.", + running_services_without_host_meta + .iter() + .map(|(k, service)| format!("({}, {})", k.app_name, service.service_name())) + .fold(String::new(), |a, b| a + &b + ", ") + ); + let duration_prevant_startup = Utc::now().signed_duration_since(since_timestamp); + let resolved_host_meta_infos = Self::resolve_host_meta( + http_forwarder, + running_services_without_host_meta, + duration_prevant_startup, + ) + .await; + for (key, _service, web_host_meta) in resolved_host_meta_infos { + if !web_host_meta.is_valid() { + continue; + } - updated_host_meta_info_entries += 1; + updated_host_meta_info_entries += 1; + self.writer.insert( + key, + Arc::new(Value { + last_update_timestamp: now, + web_host_meta, + }), + ); + } + } + + updated_host_meta_info_entries += static_web_host_config.len(); + for (key, web_host_meta) in static_web_host_config.into_iter() { self.writer.insert( key, Arc::new(Value { @@ -434,14 +537,14 @@ impl HostMetaCrawler { #[cfg(test)] mod tests { use super::*; - use crate::models::service::State; + use crate::{config_from_str, models::service::State}; use anyhow::Result; use url::Url; #[derive(Clone)] struct DummyHttpForwarder {} - #[async_trait] + #[async_trait::async_trait] impl HttpForwarder for DummyHttpForwarder { async fn request_web_host_meta( &self, @@ -470,10 +573,13 @@ mod tests { Services::from(vec![nginx_service.clone()]), )]); - let (cache, mut crawler) = super::new(); + let (cache, mut crawler) = super::new(Config::default()); crawler.crawl(forwarder, &apps, Utc::now()).await; - let apps = cache.update_meta_data(apps, &RequestInfo::new(base_url.clone())); + let apps = cache.convert_services_into_services_with_host_meta( + apps, + &RequestInfo::new(base_url.clone()), + ); assert_eq!( apps, HashMap::from([( @@ -490,6 +596,59 @@ mod tests { ) } + #[tokio::test] + async fn do_not_crawl_host_meta_for_static_host_meta_config() { + let base_url = Url::parse("https://example.com").unwrap(); + let kafka_rest_service = Service { + id: String::from("kafka-rest"), + state: State { + status: ServiceStatus::Running, + started_at: Some(Utc::now()), + }, + config: crate::sc!("kafka-rest", "confluentinc/cp-kafka-rest"), + }; + let forwarder = Box::new(DummyHttpForwarder {}); + let apps = HashMap::from([( + AppName::master(), + Services::from(vec![kafka_rest_service.clone()]), + )]); + + let (cache, mut crawler) = super::new(config_from_str!( + r#" + [[staticHostMeta]] + imageSelector = 'docker.io/confluentinc/cp-kafka-rest:.+' + imageTagAsVersion = true + openApiSpec = "https://raw.githubusercontent.com/confluentinc/kafka-rest/refs/tags/v{{image.tag}}/api/v3/openapi.yaml" + "# + )); + crawler.crawl(forwarder, &apps, Utc::now()).await; + + let apps = cache.convert_services_into_services_with_host_meta( + apps, + &RequestInfo::new(base_url.clone()), + ); + assert_eq!( + apps, + HashMap::from([( + AppName::master(), + ServicesWithHostMeta::from(vec![ + ServiceWithHostMeta::from_service_and_web_host_meta( + kafka_rest_service, + WebHostMeta::with_version_and_open_api_spec_link( + Some(String::from("latest")), + Url::parse( + "https://example.com/api/apps/master/static-open-api-spec/kafka-rest" + ) + .ok() + ), + base_url, + &AppName::master() + ) + ]), + )]) + ) + } + #[tokio::test] async fn crawl_no_host_meta_for_paused_service() { let base_url = Url::parse("https://example.com").unwrap(); @@ -508,10 +667,13 @@ mod tests { Services::from(vec![nginx_service.clone()]), )]); - let (cache, mut crawler) = super::new(); + let (cache, mut crawler) = super::new(Config::default()); crawler.crawl(forwarder, &apps, Utc::now()).await; - let apps = cache.update_meta_data(apps, &RequestInfo::new(base_url.clone())); + let apps = cache.convert_services_into_services_with_host_meta( + apps, + &RequestInfo::new(base_url.clone()), + ); assert_eq!( apps, HashMap::from([( @@ -543,12 +705,9 @@ mod tests { }; let forwarder = Box::new(DummyHttpForwarder {}); - let apps = HashMap::from([( - AppName::master(), - Services::from(vec![nginx_service]), - )]); + let apps = HashMap::from([(AppName::master(), Services::from(vec![nginx_service]))]); - let (cache, mut crawler) = super::new(); + let (cache, mut crawler) = super::new(Config::default()); crawler.crawl(forwarder, &apps, Utc::now()).await; // recrawl data for paused nginx @@ -569,7 +728,10 @@ mod tests { crawler.crawl(forwarder, &apps, Utc::now()).await; - let apps = cache.update_meta_data(apps, &RequestInfo::new(base_url.clone())); + let apps = cache.convert_services_into_services_with_host_meta( + apps, + &RequestInfo::new(base_url.clone()), + ); assert_eq!( apps, HashMap::from([( diff --git a/api/src/apps/mod.rs b/api/src/apps/mod.rs index 422e9001..0152c126 100644 --- a/api/src/apps/mod.rs +++ b/api/src/apps/mod.rs @@ -32,6 +32,7 @@ use crate::config::{Config, ConfigError}; use crate::deployment::deployment_unit::DeploymentUnitBuilder; use crate::infrastructure::HttpForwarder; use crate::infrastructure::Infrastructure; +use crate::infrastructure::TraefikIngressRoute; use crate::models::service::Services; use crate::models::service::{ContainerType, Service, ServiceStatus}; use crate::models::user_defined_parameters::UserDefinedParameters; @@ -44,6 +45,9 @@ use futures::StreamExt; use handlebars::RenderError; pub use host_meta_cache::new as host_meta_crawling; pub use host_meta_cache::HostMetaCache; +use log::debug; +use log::error; +use log::trace; pub use routes::{apps_routes, delete_app_sync}; use std::collections::{HashMap, HashSet}; use std::convert::From; @@ -146,6 +150,22 @@ impl AppsService { self.infrastructure.http_forwarder().await } + pub async fn fetch_service_of_app( + &self, + app_name: &AppName, + service_name: &str, + ) -> Result, AppsServiceError> { + Ok(self + .infrastructure + .fetch_services_of_app(app_name) + .await? + .and_then(|services| { + services + .into_iter() + .find(|service| service.service_name() == service_name) + })) + } + pub async fn fetch_app_names(&self) -> Result, AppsServiceError> { Ok(self.infrastructure.fetch_app_names().await?) } @@ -212,15 +232,19 @@ impl AppsService { async fn configs_to_replicate( &self, services_to_deploy: &[ServiceConfig], - app_name: &AppName, + running_services: &Option, replicate_from_app_name: &AppName, ) -> Result, AppsServiceError> { - let running_services = self.infrastructure.get_configs_of_app(app_name).await?; - let running_service_names = running_services - .iter() - .filter(|c| c.container_type() == &ContainerType::Instance) - .map(|c| c.service_name()) - .collect::>(); + let running_instances_names = running_services + .as_ref() + .map(|running_services| { + running_services + .iter() + .filter(|c| c.container_type() == &ContainerType::Instance) + .map(|c| c.service_name()) + .collect::>() + }) + .unwrap_or_else(HashSet::new); let service_names = services_to_deploy .iter() @@ -229,11 +253,18 @@ impl AppsService { Ok(self .infrastructure - .get_configs_of_app(replicate_from_app_name) + .fetch_services_of_app(replicate_from_app_name) .await? .into_iter() + .flat_map(|services| services.into_iter().map(|service| service.config)) + .filter(|config| { + matches!( + config.container_type(), + ContainerType::Instance | ContainerType::Replica + ) + }) .filter(|config| !service_names.contains(config.service_name())) - .filter(|config| !running_service_names.contains(config.service_name())) + .filter(|config| !running_instances_names.contains(config.service_name())) .map(|config| { let mut replicated_config = config; replicated_config.set_container_type(ContainerType::Replica); @@ -322,10 +353,10 @@ impl AppsService { user_defined_parameters: Option, ) -> Result { if let Some(app_limit) = self.config.app_limit() { - let apps = self.fetch_apps().await?; + let apps = self.fetch_app_names().await?; if apps - .keys() + .iter() // filtering the app_name that is send because otherwise clients wouldn't be able // to update an existing application. .filter(|existing_app_name| *existing_app_name != app_name) @@ -339,26 +370,34 @@ impl AppsService { let mut configs = service_configs.to_vec(); + let running_services = self.infrastructure.fetch_services_of_app(app_name).await?; + let replicate_from_app_name = replicate_from.unwrap_or_else(AppName::master); if &replicate_from_app_name != app_name { configs.extend( - self.configs_to_replicate(service_configs, app_name, &replicate_from_app_name) - .await?, + self.configs_to_replicate( + service_configs, + &running_services, + &replicate_from_app_name, + ) + .await?, ); } - let configs_for_templating = self - .infrastructure - .get_configs_of_app(app_name) - .await? - .into_iter() - .filter(|config| config.container_type() == &ContainerType::Instance) - .filter(|config| { - !service_configs - .iter() - .any(|c| c.service_name() == config.service_name()) + let configs_for_templating = running_services + .map(|running_services| { + running_services + .into_iter() + .filter(|service| service.container_type() == &ContainerType::Instance) + .filter(|service| { + !service_configs + .iter() + .any(|c| c.service_name() == service.service_name()) + }) + .map(|service| service.config) + .collect::>() }) - .collect::>(); + .unwrap_or_else(Vec::new); let deployment_unit_builder = DeploymentUnitBuilder::init(app_name.clone(), configs) .extend_with_config(&self.config) @@ -369,12 +408,15 @@ impl AppsService { .resolve_image_infos(&images) .await?; - let base_traefik_ingress_route = self - .infrastructure - .base_traefik_ingress_route() - .await - .ok() - .flatten(); + let base_traefik_ingress_route = if let Some(base_url) = &self.config.base_url { + Some(TraefikIngressRoute::from(base_url)) + } else { + self.infrastructure + .base_traefik_ingress_route() + .await + .ok() + .flatten() + }; let deployment_unit_builder = deployment_unit_builder .extend_with_image_infos(image_infos) @@ -738,11 +780,13 @@ mod tests { let configs = apps .infrastructure - .get_configs_of_app(&AppName::master()) - .await?; - assert_eq!(configs.len(), 1); + .fetch_services_of_app(&AppName::master()) + .await? + .unwrap(); + assert_eq!(configs.iter().count(), 1); - let files = configs.get(0).unwrap().files().unwrap(); + let config = configs.into_iter().next().map(|s| s.config).unwrap(); + let files = config.files().unwrap(); assert_eq!( files.get(&PathBuf::from("/run/secrets/user")).unwrap(), &SecUtf8::from("Hello") @@ -778,12 +822,13 @@ mod tests { let configs = apps .infrastructure - .get_configs_of_app(&AppName::from_str("master-1x").unwrap()) - .await?; + .fetch_services_of_app(&AppName::from_str("master-1x").unwrap()) + .await? + .unwrap(); assert_eq!(configs.len(), 1); - let files = configs.get(0).unwrap().files(); - assert_eq!(files, None); + let config = configs.into_iter().next().map(|s| s.config).unwrap(); + assert_eq!(config.files(), None); Ok(()) } @@ -954,10 +999,12 @@ Log msg 3 of service-a of app master let openid_configs: Vec = apps .infrastructure - .get_configs_of_app(&app_name) + .fetch_services_of_app(&app_name) .await? + .unwrap() .into_iter() - .filter(|config| config.service_name() == "openid") + .filter(|service| service.service_name() == "openid") + .map(|service| service.config) .collect(); assert_eq!(openid_configs.len(), 1); assert_eq!(openid_configs[0].image(), configs[0].image()); @@ -1004,10 +1051,12 @@ Log msg 3 of service-a of app master let openid_configs: Vec = apps .infrastructure - .get_configs_of_app(&app_name) + .fetch_services_of_app(&app_name) .await? + .unwrap() .into_iter() - .filter(|config| config.service_name() == "openid") + .filter(|service| service.service_name() == "openid") + .map(|service| service.config) .collect(); assert_eq!(openid_configs.len(), 1); @@ -1200,18 +1249,22 @@ Log msg 3 of service-a of app master let db_config1: Vec = apps .infrastructure - .get_configs_of_app(&app_name) + .fetch_services_of_app(&app_name) .await? + .unwrap() .into_iter() - .filter(|config| config.service_name() == "db1") + .filter(|service| service.service_name() == "db1") + .map(|service| service.config) .collect(); let db_config2: Vec = apps .infrastructure - .get_configs_of_app(&app_name) + .fetch_services_of_app(&app_name) .await? + .unwrap() .into_iter() - .filter(|config| config.service_name() == "db2") + .filter(|service| service.service_name() == "db2") + .map(|service| service.config) .collect(); let services = deployed_apps.get(&app_name).unwrap(); diff --git a/api/src/apps/routes/create_app_payload.rs b/api/src/apps/routes/create_app_payload.rs index 7c9702c8..53e8ff11 100644 --- a/api/src/apps/routes/create_app_payload.rs +++ b/api/src/apps/routes/create_app_payload.rs @@ -98,14 +98,15 @@ mod tests { use rocket::{http::ContentType, local::asynchronous::Client}; use serde_json::json; + #[rocket::post("/", data = "")] + fn test_route( + data: Result, + ) -> Result<&'static str, HttpApiError> { + data.map(|_| "dummy").map_err(HttpApiError::from) + } + async fn create_client() -> Client { - #[post("/", data = "")] - fn test_route( - data: Result, - ) -> Result<&'static str, HttpApiError> { - data.map(|_| "dummy").map_err(HttpApiError::from) - } - let rocket = rocket::build().mount("/", routes![test_route]); + let rocket = rocket::build().mount("/", rocket::routes![test_route]); Client::tracked(rocket).await.expect("valid rocket") } diff --git a/api/src/apps/routes/logs.rs b/api/src/apps/routes/logs.rs index 8114868a..92ce6326 100644 --- a/api/src/apps/routes/logs.rs +++ b/api/src/apps/routes/logs.rs @@ -6,7 +6,10 @@ use crate::{ use chrono::DateTime; use futures::stream::StreamExt; use http_api_problem::HttpApiProblem; -use rocket::http::hyper::header::{ACCEPT, CONTENT_DISPOSITION, LINK}; +use rocket::{ + http::hyper::header::{ACCEPT, CONTENT_DISPOSITION, LINK}, + FromForm, +}; use rocket::{ http::{Accept, ContentType, RawStr, Status}, request::FromRequest, @@ -16,7 +19,7 @@ use rocket::{ }; use std::{str::FromStr, sync::Arc}; -#[get("//logs/?", rank = 1)] +#[rocket::get("//logs/?", rank = 1)] pub(super) async fn logs<'r>( _apt: AcceptingPlainText, app_name: Result, @@ -53,7 +56,7 @@ pub(super) async fn logs<'r>( }) } -#[get( +#[rocket::get( "//logs/?", format = "text/event-stream", rank = 2 @@ -197,10 +200,13 @@ impl<'r> FromRequest<'r> for AcceptingPlainText { #[cfg(test)] mod test { use super::*; - use crate::{apps::HostMetaCache, infrastructure::Dummy, models::AppStatusChangeId, sc}; + use crate::{ + apps::HostMetaCache, config::Config, infrastructure::Dummy, models::AppStatusChangeId, sc, + }; use rocket::{ http::{hyper::header::CONTENT_TYPE, Accept, Header}, local::asynchronous::Client, + routes, }; async fn set_up_rocket_with_dummy_infrastructure_and_a_running_app( @@ -227,7 +233,8 @@ mod test { #[tokio::test] async fn log_weblink_with_no_limit() -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut _host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut _host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; @@ -249,7 +256,8 @@ mod test { #[tokio::test] async fn log_weblink_with_some_limit() -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut _host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut _host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; @@ -270,7 +278,8 @@ mod test { #[tokio::test] async fn log_content_disposition_for_downloading_as_attachment( ) -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut _host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut _host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; @@ -291,7 +300,8 @@ mod test { #[tokio::test] async fn log_content_disposition_for_displaying_as_inline( ) -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut _host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut _host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; @@ -310,7 +320,8 @@ mod test { #[tokio::test] async fn log_content_type_when_accepting_text_star() -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut _host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut _host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; @@ -333,7 +344,8 @@ mod test { #[tokio::test] async fn respond_with_plain_log_content_type_when_accepting_with_firefox_accept_default_value( ) -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut _host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut _host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; @@ -356,7 +368,8 @@ mod test { #[tokio::test] async fn log_content_type_when_accepting_text_stream( ) -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut _host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut _host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; diff --git a/api/src/apps/routes/mod.rs b/api/src/apps/routes/mod.rs index 11ff5cbe..0b85b713 100644 --- a/api/src/apps/routes/mod.rs +++ b/api/src/apps/routes/mod.rs @@ -33,13 +33,14 @@ use crate::models::{AppName, AppNameError}; use crate::models::{AppStatusChangeId, AppStatusChangeIdError}; use create_app_payload::CreateAppPayload; use http_api_problem::{HttpApiProblem, StatusCode}; +use log::{debug, error}; use regex::Regex; use rocket::http::Status; use rocket::request::{FromRequest, Outcome, Request}; use rocket::response::stream::{Event, EventStream}; use rocket::response::{Responder, Response}; use rocket::serde::json::Json; -use rocket::{Shutdown, State}; +use rocket::{FromForm, Shutdown, State}; use std::collections::HashMap; use std::future::Future; use std::sync::Arc; @@ -52,6 +53,7 @@ use tokio_stream::StreamExt; mod create_app_payload; mod logs; +mod static_openapi_spec; pub fn apps_routes() -> Vec { rocket::routes![ @@ -63,10 +65,11 @@ pub fn apps_routes() -> Vec { logs::stream_logs, change_status, status_change, + static_openapi_spec::static_open_api_spec, ] } -#[get("/", format = "application/json", rank = 1)] +#[rocket::get("/", format = "application/json", rank = 1)] async fn apps( apps: &State>, request_info: RequestInfo, @@ -74,11 +77,11 @@ async fn apps( ) -> HttpResult>> { let services = apps.fetch_apps().await?; Ok(Json( - host_meta_cache.update_meta_data(services, &request_info), + host_meta_cache.convert_services_into_services_with_host_meta(services, &request_info), )) } -#[get("/", format = "text/event-stream", rank = 2)] +#[rocket::get("/", format = "text/event-stream", rank = 2)] async fn stream_apps( apps_updates: &State>>, mut end: Shutdown, @@ -92,7 +95,7 @@ async fn stream_apps( let mut host_meta_cache_updates = host_meta_cache.cache_updates(); EventStream! { - yield Event::json(&host_meta_cache.update_meta_data(services.clone(), &request_info)); + yield Event::json(&host_meta_cache.convert_services_into_services_with_host_meta(services.clone(), &request_info)); loop { select! { @@ -106,12 +109,12 @@ async fn stream_apps( _ = &mut end => break, }; - yield Event::json(&host_meta_cache.update_meta_data(services.clone(), &request_info)); + yield Event::json(&host_meta_cache.convert_services_into_services_with_host_meta(services.clone(), &request_info)); } } } -#[get("//status-changes/", format = "application/json")] +#[rocket::get("//status-changes/", format = "application/json")] async fn status_change( app_name: Result, status_id: Result, @@ -133,7 +136,7 @@ async fn status_change( } } -#[delete("/")] +#[rocket::delete("/")] pub async fn delete_app( app_name: Result, apps: &State>, @@ -165,7 +168,7 @@ pub async fn delete_app_sync( } } -#[post( +#[rocket::post( "/?", format = "application/json", data = "" @@ -203,7 +206,7 @@ pub async fn create_app( } } -#[put( +#[rocket::put( "//states/", format = "application/json", data = "" @@ -486,6 +489,7 @@ mod tests { mod url_rendering { use crate::apps::{AppsService, HostMetaCache}; + use crate::config::Config; use crate::infrastructure::Dummy; use crate::models::service::Services; use crate::models::{AppName, AppStatusChangeId}; @@ -495,6 +499,7 @@ mod tests { use rocket::http::Header; use rocket::http::Status; use rocket::local::asynchronous::Client; + use rocket::routes; use serde_json::json; use serde_json::Value; use std::collections::HashMap; @@ -519,6 +524,7 @@ mod tests { let rocket = rocket::build() .manage(host_meta_cache) .manage(apps) + .manage(Config::default()) .manage(tokio::sync::watch::channel::>(HashMap::new()).1) .mount("/", routes![crate::apps::routes::apps]) .mount("/api/apps", crate::apps::apps_routes()); @@ -528,7 +534,8 @@ mod tests { #[tokio::test] async fn host_header_response_with_xforwardedhost_xforwardedproto_and_xforwardedport( ) -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; host_meta_crawler.fake_empty_host_meta_info(AppName::master(), "service-a".to_string()); @@ -561,7 +568,8 @@ mod tests { #[tokio::test] async fn host_header_response_with_xforwardedproto_and_other_default_values( ) -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; host_meta_crawler.fake_empty_host_meta_info(AppName::master(), "service-a".to_string()); @@ -592,7 +600,8 @@ mod tests { #[tokio::test] async fn host_header_response_with_xforwardedhost_and_other_default_values( ) -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; host_meta_crawler.fake_empty_host_meta_info(AppName::master(), "service-a".to_string()); @@ -622,7 +631,8 @@ mod tests { #[tokio::test] async fn host_header_response_with_xforwardedport_and_default_values( ) -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; host_meta_crawler.fake_empty_host_meta_info(AppName::master(), "service-a".to_string()); @@ -653,7 +663,8 @@ mod tests { #[tokio::test] async fn host_header_response_with_all_default_values( ) -> Result<(), crate::apps::AppsServiceError> { - let (host_meta_cache, mut host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, mut host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let client = set_up_rocket_with_dummy_infrastructure_and_a_running_app(host_meta_cache).await?; host_meta_crawler.fake_empty_host_meta_info(AppName::master(), "service-a".to_string()); @@ -681,7 +692,8 @@ mod tests { #[tokio::test] async fn bad_request_without_host_header() { - let (host_meta_cache, _host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, _host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let infrastructure = Box::new(Dummy::new()); let apps = Arc::new(AppsService::new(Default::default(), infrastructure).unwrap()); @@ -698,7 +710,8 @@ mod tests { #[tokio::test] async fn with_invalid_headers() { - let (host_meta_cache, _host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, _host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let infrastructure = Box::new(Dummy::new()); let apps = Arc::new(AppsService::new(Default::default(), infrastructure).unwrap()); @@ -717,7 +730,8 @@ mod tests { #[tokio::test] async fn with_invalid_proto() { - let (host_meta_cache, _host_meta_crawler) = crate::host_meta_crawling(); + let (host_meta_cache, _host_meta_crawler) = + crate::host_meta_crawling(Config::default()); let infrastructure = Box::new(Dummy::new()); let apps = Arc::new(AppsService::new(Default::default(), infrastructure).unwrap()); @@ -743,7 +757,7 @@ mod tests { registry::RegistryError, }; use assert_json_diff::assert_json_eq; - use rocket::{http::ContentType, local::asynchronous::Client}; + use rocket::{http::ContentType, local::asynchronous::Client, routes}; #[tokio::test] async fn invalid_service_payload() { @@ -782,18 +796,19 @@ mod tests { ); } + #[rocket::get("/")] + fn image_auth_failed() -> HttpResult<&'static str> { + Err(AppsError::UnableToResolveImage { + error: Arc::new(RegistryError::AuthenticationFailure { + image: String::from("private-registry.example.com/_/postgres"), + failure: String::from("403: invalid user name and password"), + }), + } + .into()) + } + #[tokio::test] async fn image_registry_authentication_error() { - #[get("/")] - fn image_auth_failed() -> HttpResult<&'static str> { - Err(AppsError::UnableToResolveImage { - error: Arc::new(RegistryError::AuthenticationFailure { - image: String::from("private-registry.example.com/_/postgres"), - failure: String::from("403: invalid user name and password"), - }), - } - .into()) - } let rocket = rocket::build().mount("/", routes![image_auth_failed]); let client = Client::tracked(rocket).await.expect("valid rocket"); @@ -813,19 +828,20 @@ mod tests { ); } + #[rocket::get("/")] + fn registry_unexpected() -> HttpResult<&'static str> { + Err(AppsError::UnableToResolveImage { + error: Arc::new(RegistryError::UnexpectedError { + image: String::from("private-registry.example.com/_/postgres"), + err: anyhow::Error::msg("unexpected"), + }), + } + .into()) + } + #[tokio::test] async fn image_registry_unexpected_error() { - #[get("/")] - fn image_not_found() -> HttpResult<&'static str> { - Err(AppsError::UnableToResolveImage { - error: Arc::new(RegistryError::UnexpectedError { - image: String::from("private-registry.example.com/_/postgres"), - err: anyhow::Error::msg("unexpected"), - }), - } - .into()) - } - let rocket = rocket::build().mount("/", routes![image_not_found]); + let rocket = rocket::build().mount("/", routes![registry_unexpected]); let client = Client::tracked(rocket).await.expect("valid rocket"); let response = client.get("/").dispatch().await; @@ -844,17 +860,18 @@ mod tests { ); } + #[rocket::get("/")] + fn image_not_found() -> HttpResult<&'static str> { + Err(AppsError::UnableToResolveImage { + error: Arc::new(RegistryError::ImageNotFound { + image: String::from("private-registry.example.com/_/postgres"), + }), + } + .into()) + } + #[tokio::test] async fn image_registry_not_found_error() { - #[get("/")] - fn image_not_found() -> HttpResult<&'static str> { - Err(AppsError::UnableToResolveImage { - error: Arc::new(RegistryError::ImageNotFound { - image: String::from("private-registry.example.com/_/postgres"), - }), - } - .into()) - } let rocket = rocket::build().mount("/", routes![image_not_found]); let client = Client::tracked(rocket).await.expect("valid rocket"); @@ -879,7 +896,7 @@ mod tests { use super::super::*; use crate::{apps::AppsService, config_from_str, infrastructure::Dummy}; use assert_json_diff::assert_json_include; - use rocket::{http::ContentType, local::asynchronous::Client}; + use rocket::{http::ContentType, local::asynchronous::Client, routes}; macro_rules! config_from_str { ( $config_str:expr ) => { diff --git a/api/src/apps/routes/static_openapi_spec.rs b/api/src/apps/routes/static_openapi_spec.rs new file mode 100644 index 00000000..53c511b5 --- /dev/null +++ b/api/src/apps/routes/static_openapi_spec.rs @@ -0,0 +1,90 @@ +use crate::{ + apps::{Apps, HostMetaCache}, + config::Config, + http_result::HttpResult, + models::{AppName, AppNameError, RequestInfo}, +}; +use http::StatusCode; +use http_api_problem::HttpApiProblem; +use rocket::State; +use serde_yaml::Value; +use std::sync::Arc; + +#[rocket::get("//static-open-api-spec/", rank = 1)] +pub(super) async fn static_open_api_spec( + apps: &State>, + config: &State, + app_name: Result, + service_name: &str, + request_info: RequestInfo, + host_meta_cache: HostMetaCache, +) -> HttpResult { + let app_name = app_name?; + + let Some(service) = apps.fetch_service_of_app(&app_name, service_name).await? else { + return Err(HttpApiProblem::with_title_and_type(StatusCode::NOT_FOUND).into()); + }; + + let Some(static_host_config) = + config + .static_host_meta(service.config.image()) + .map_err(|e| { + HttpApiProblem::with_title_and_type(StatusCode::INTERNAL_SERVER_ERROR) + .detail(e.to_string()) + })? + else { + return Err(HttpApiProblem::with_title_and_type(StatusCode::NOT_FOUND).into()); + }; + + let Some(open_api_spec) = static_host_config.open_api_spec.as_ref() else { + return Err(HttpApiProblem::with_title_and_type(StatusCode::NOT_FOUND).into()); + }; + + let service = host_meta_cache.convert_service_into_service_with_host_meta( + &app_name, + service, + &request_info, + ); + let Some(mut public_service_url) = service.service_url else { + return Err( + HttpApiProblem::with_title_and_type(StatusCode::PRECONDITION_REQUIRED) + .detail("The service has no public UR.") + .into(), + ); + }; + + public_service_url = if let Some(path) = open_api_spec.sub_path { + public_service_url.join(path).map_err(|e| { + HttpApiProblem::with_title_and_type(StatusCode::INTERNAL_SERVER_ERROR) + .detail(e.to_string()) + })? + } else { + public_service_url + }; + + let body = reqwest::get(open_api_spec.source_url.to_string()) + .await + .map_err(|e| { + HttpApiProblem::with_title_and_type(StatusCode::INTERNAL_SERVER_ERROR) + .detail(e.to_string()) + })? + .text() + .await + .map_err(|e| { + HttpApiProblem::with_title_and_type(StatusCode::INTERNAL_SERVER_ERROR) + .detail(e.to_string()) + })?; + + let mut v: Value = serde_yaml::from_str(&body).map_err(|e| { + HttpApiProblem::with_title_and_type(StatusCode::INTERNAL_SERVER_ERROR).detail(e.to_string()) + })?; + + v["servers"] = serde_yaml::from_str(&format!( + r#" + - url: {public_service_url} + "# + )) + .unwrap(); + + Ok(serde_yaml::to_string(&v).unwrap()) +} diff --git a/api/src/config/app_selector.rs b/api/src/config/app_selector.rs deleted file mode 100644 index 6922eaf3..00000000 --- a/api/src/config/app_selector.rs +++ /dev/null @@ -1,54 +0,0 @@ -/*- - * ========================LICENSE_START================================= - * PREvant REST API - * %% - * Copyright (C) 2018 - 2020 aixigo AG - * %% - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * 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: &AppName) -> bool { - match self.0.captures(app_name) { - None => false, - Some(captures) => captures.get(0).map_or("", |m| m.as_str()) == app_name.as_str(), - } - } -} - -impl Default for AppSelector { - fn default() -> Self { - AppSelector(Regex::new(".+").unwrap()) - } -} - -impl<'de> serde::Deserialize<'de> for AppSelector { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - serde_regex::deserialize(deserializer).map(AppSelector) - } -} diff --git a/api/src/config/mod.rs b/api/src/config/mod.rs index e21e6670..6d6a5c6c 100644 --- a/api/src/config/mod.rs +++ b/api/src/config/mod.rs @@ -33,14 +33,19 @@ pub use self::container::ContainerConfig; pub use self::runtime::Runtime; use crate::models::user_defined_parameters::UserDefinedParameters; use crate::models::AppName; +use crate::models::Image; use crate::models::ServiceConfig; -use app_selector::AppSelector; use clap::Parser; use figment::providers::{Env, Format, Toml}; use figment::value::{Dict, Map, Tag, Value}; use figment::{Metadata, Profile}; +use handlebars::Handlebars; +use handlebars::RenderError; +use handlebars::RenderErrorReason; use jsonschema::Validator; use secstr::SecUtf8; +use selectors::AppSelector; +use selectors::ImageSelector; use serde::Deserialize; use std::collections::BTreeMap; use std::convert::From; @@ -49,12 +54,13 @@ use std::io::Error as IOError; use std::path::PathBuf; use std::str::FromStr; use toml::de::Error as TomlError; +use url::Url; -mod app_selector; mod companion; mod container; mod runtime; mod secret; +mod selectors; #[derive(Default, Parser)] #[clap(author, version, about, long_about = None)] @@ -66,6 +72,10 @@ pub struct CliArgs { /// Sets the container backend type, e.g. Docker or Kubernetes #[clap(short, long)] runtime_type: Option, + + /// Sets the base URL where PREvant is hosted. Useful if your are debugging a remote cluster. + #[clap(long)] + base_url: Option, } #[derive(Clone)] @@ -113,6 +123,13 @@ impl figment::Provider for CliArgs { ); } + if let Some(base_url) = &self.base_url { + dict.insert( + String::from("baseUrl"), + figment::value::Value::String(Tag::Default, base_url.to_string()), + ); + } + let mut data = Map::new(); data.insert(Profile::Default, dict); @@ -147,6 +164,8 @@ struct Service { #[derive(Clone, Default, Deserialize)] pub struct Config { + #[serde(default, rename = "baseUrl")] + pub base_url: Option, #[serde(default)] runtime: Runtime, #[serde(default)] @@ -159,6 +178,9 @@ pub struct Config { hooks: Option>, #[serde(default)] registries: BTreeMap, + #[serde(default)] + #[serde(rename = "staticHostMeta")] + static_host_meta: Vec, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -286,6 +308,109 @@ impl Config { pub fn app_limit(&self) -> Option { self.applications.max } + + pub fn static_host_meta<'a, 'b: 'a>( + &'b self, + image: &Image, + ) -> Result>, RenderError> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Img { + tag: String, + } + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Data { + image: Img, + } + + let handlebars = Handlebars::new(); + let data = Data { + image: Img { + tag: image.tag().unwrap_or_default(), + }, + }; + + self.static_host_meta + .iter() + .find(|static_host_meta| static_host_meta.image_selector.matches(image)) + .map(|static_host_meta| { + Ok(StaticHostMeta { + image_tag_as_version: static_host_meta.image_tag_as_version, + open_api_spec: static_host_meta + .open_api_spec + .as_ref() + .map(|spec| -> Result<(String, Option<&String>), RenderError> { + Ok(( + handlebars.render_template(&spec.source_url, &data)?, + spec.sub_path.as_ref(), + )) + }) + .transpose()? + .map(|(url, sub_path)| -> Result { + Ok(OpenApiSpec { + source_url: Url::parse(&url) + .map_err(|e| RenderErrorReason::Other(e.to_string()))?, + sub_path, + }) + }) + .transpose()?, + }) + }) + .transpose() + } +} + +#[derive(Clone, Default, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct StaticHostMetaRaw { + image_selector: ImageSelector, + #[serde(default)] + image_tag_as_version: bool, + open_api_spec: Option, +} + +#[derive(Clone, Default, Debug)] +struct OpenApiSpecRaw { + source_url: String, + sub_path: Option, +} + +impl<'de> serde::Deserialize<'de> for OpenApiSpecRaw { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::String(source_url) => Ok(OpenApiSpecRaw { + source_url, + sub_path: None, + }), + serde_json::Value::Object(mut map) => Ok(OpenApiSpecRaw { + source_url: map + .remove("sourceUrl") + .and_then(|url| url.as_str().map(|url| url.to_string())) + .ok_or_else(|| serde::de::Error::custom("sourceUrl is required"))?, + sub_path: map + .remove("subPath") + .and_then(|url| url.as_str().map(|url| url.to_string())) + .map(|url| url.to_string()), + }), + _ => Err(serde::de::Error::custom("Unexpect format.")), + } + } +} + +#[derive(Debug, PartialEq)] +pub struct StaticHostMeta<'a> { + pub image_tag_as_version: bool, + pub open_api_spec: Option>, +} + +#[derive(Debug, PartialEq)] +pub struct OpenApiSpec<'a> { + pub source_url: Url, + pub sub_path: Option<&'a String>, } impl JiraConfig { @@ -789,4 +914,56 @@ mod tests { } ); } + + #[test] + fn should_parse_static_web_host_with_url() { + let config = config_from_str!( + r#" + [[staticHostMeta]] + imageSelector = "docker.io/bitnami/schema-registry:.+" + openApiSpec = "https://raw.githubusercontent.com/confluentinc/schema-registry/refs/tags/v{{image.tag}}/core/generated/swagger-ui/schema-registry-api-spec.yaml" + "# + ); + + let static_host_meta = config + .static_host_meta(&Image::from_str("docker.io/bitnami/schema-registry:7.8.0").unwrap()) + .unwrap(); + assert_eq!( + static_host_meta, + Some(StaticHostMeta { + image_tag_as_version: false, + open_api_spec: Some(OpenApiSpec { + source_url: Url::parse("https://raw.githubusercontent.com/confluentinc/schema-registry/refs/tags/v7.8.0/core/generated/swagger-ui/schema-registry-api-spec.yaml").unwrap(), + sub_path: None + }) + }) + ); + } + + #[test] + fn should_parse_static_web_host_with_url_and_subpath() { + let config = config_from_str!( + r#" + [[staticHostMeta]] + imageSelector = "docker.io/confluentinc/cp-kafka-rest:.+" + openApiSpec = { sourceUrl = "https://raw.githubusercontent.com/confluentinc/kafka-rest/refs/tags/v{{image.tag}}/api/v3/openapi.yaml", subPath = "v3" } + "# + ); + + let static_host_meta = config + .static_host_meta( + &Image::from_str("docker.io/confluentinc/cp-kafka-rest:7.8.0").unwrap(), + ) + .unwrap(); + assert_eq!( + static_host_meta, + Some(StaticHostMeta { + image_tag_as_version: false, + open_api_spec: Some(OpenApiSpec { + source_url: Url::parse("https://raw.githubusercontent.com/confluentinc/kafka-rest/refs/tags/v7.8.0/api/v3/openapi.yaml").unwrap(), + sub_path: Some(&String::from("v3")), + }) + }) + ); + } } diff --git a/api/src/config/selectors.rs b/api/src/config/selectors.rs new file mode 100644 index 00000000..37dedc7b --- /dev/null +++ b/api/src/config/selectors.rs @@ -0,0 +1,57 @@ +use crate::models::{AppName, Image}; +use regex::Regex; + +#[derive(Clone)] +pub(super) struct AppSelector(Regex); + +impl AppSelector { + 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.as_str(), + } + } +} + +impl Default for AppSelector { + fn default() -> Self { + Self(Regex::new(".+").unwrap()) + } +} + +impl<'de> serde::Deserialize<'de> for AppSelector { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + serde_regex::deserialize(deserializer).map(Self) + } +} + +#[derive(Clone, Debug)] +pub(super) struct ImageSelector(Regex); + +impl ImageSelector { + pub fn matches(&self, image: &Image) -> bool { + let image = image.to_string(); + match self.0.captures(&image) { + None => false, + Some(captures) => captures.get(0).map_or("", |m| m.as_str()) == image, + } + } +} + +impl Default for ImageSelector { + fn default() -> Self { + Self(Regex::new(".+").unwrap()) + } +} + +impl<'de> serde::Deserialize<'de> for ImageSelector { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + serde_regex::deserialize(deserializer).map(Self) + } +} diff --git a/api/src/deployment/hooks.rs b/api/src/deployment/hooks.rs index 465b822d..1b416a66 100644 --- a/api/src/deployment/hooks.rs +++ b/api/src/deployment/hooks.rs @@ -28,6 +28,7 @@ use crate::config::Config; use crate::models::{AppName, ContainerType, Environment, EnvironmentVariable, Image}; use boa_engine::property::Attribute; use boa_engine::{Context, JsValue, Source}; +use log::error; use secstr::SecUtf8; use std::collections::BTreeMap; use std::iter::IntoIterator; diff --git a/api/src/infrastructure/docker.rs b/api/src/infrastructure/docker.rs index 0394fa6c..c7d1508b 100644 --- a/api/src/infrastructure/docker.rs +++ b/api/src/infrastructure/docker.rs @@ -62,6 +62,7 @@ use futures::stream::FuturesUnordered; use futures::{StreamExt, TryStreamExt}; use http_body_util::BodyExt; use hyper_util::rt::TokioIo; +use log::{debug, error, info, trace, warn}; use multimap::MultiMap; use rocket::form::validate::Contains; use std::collections::HashMap; @@ -194,7 +195,11 @@ impl DockerInfrastructure { let traefik_container_id = containers .into_iter() - .find(|c| c.image.as_ref().map_or(false, |s| s.contains("traefik"))) + .find(|c| { + c.image + .as_ref() + .is_some_and(|image| image.contains("traefik")) + }) .and_then(|c| c.id); if let Some(id) = traefik_container_id { @@ -222,7 +227,11 @@ impl DockerInfrastructure { .await?; let traefik_container_id = containers .into_iter() - .find(|c| c.image.as_ref().map_or(false, |s| s.contains("traefik"))) + .find(|c| { + c.image + .as_ref() + .is_some_and(|image| image.contains("traefik")) + }) .and_then(|c| c.id); if let Some(id) = traefik_container_id { @@ -840,6 +849,22 @@ impl Infrastructure for DockerInfrastructure { Ok(apps) } + async fn fetch_services_of_app(&self, app_name: &AppName) -> Result> { + let container_details = self.get_container_details(Some(app_name), None).await?; + + if container_details.is_empty() { + return Ok(None); + } + + Ok(Some(Services::from( + container_details + .into_iter() + .flat_map(|(_, details)| details.into_iter()) + .filter_map(|details| Service::try_from(details).ok()) + .collect::>(), + ))) + } + async fn deploy_services( &self, status_id: &str, diff --git a/api/src/infrastructure/dummy_infrastructure.rs b/api/src/infrastructure/dummy_infrastructure.rs index dcb8bce7..cf91207c 100644 --- a/api/src/infrastructure/dummy_infrastructure.rs +++ b/api/src/infrastructure/dummy_infrastructure.rs @@ -34,6 +34,7 @@ use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, FixedOffset, Utc}; use futures::stream::{self, BoxStream}; +use log::info; use multimap::MultiMap; use std::collections::{HashMap, HashSet}; use std::str::FromStr; @@ -127,6 +128,33 @@ impl Infrastructure for DummyInfrastructure { Ok(s) } + async fn fetch_services_of_app(&self, app_name: &AppName) -> Result> { + let lock = self.services.lock().unwrap(); + let Some(configs) = lock.get_vec(app_name) else { + return Ok(None); + }; + + let mut services = Vec::::with_capacity(configs.len()); + for config in configs { + let service = Service { + id: config.service_name().clone(), + config: ServiceConfig::clone(&config), + state: State { + status: ServiceStatus::Running, + started_at: Some( + DateTime::parse_from_rfc3339("2019-07-18T07:30:00.000000000Z") + .unwrap() + .with_timezone(&Utc), + ), + }, + }; + + services.push(service); + } + + Ok(Some(Services::from(services))) + } + async fn deploy_services( &self, _status_id: &str, diff --git a/api/src/infrastructure/infrastructure.rs b/api/src/infrastructure/infrastructure.rs index 1056822d..614257ae 100644 --- a/api/src/infrastructure/infrastructure.rs +++ b/api/src/infrastructure/infrastructure.rs @@ -28,7 +28,7 @@ use super::traefik::TraefikIngressRoute; use crate::config::ContainerConfig; use crate::deployment::DeploymentUnit; use crate::models::service::{Service, ServiceStatus, Services}; -use crate::models::{AppName, ContainerType, ServiceConfig, WebHostMeta}; +use crate::models::{AppName, WebHostMeta}; use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, FixedOffset}; @@ -41,6 +41,8 @@ pub trait Infrastructure: Send + Sync + DynClone { /// Returns a `map` of `app-name` and the running services for this app. async fn fetch_services(&self) -> Result>; + async fn fetch_services_of_app(&self, app_name: &AppName) -> Result>; + async fn fetch_app_names(&self) -> Result> { Ok(self .fetch_services() @@ -116,20 +118,3 @@ pub trait HttpForwarder: Send + Sync + DynClone { request: http::Request>, ) -> Result>; } - -impl dyn Infrastructure { - /// Returns the configuration of all services running for the given application name. - pub async fn get_configs_of_app(&self, app_name: &AppName) -> Result> { - let mut services = self.fetch_services().await?; - Ok(services.remove(app_name).map_or_else(Vec::new, |services| { - services - .into_iter() - .filter(|service| { - *service.container_type() == ContainerType::Instance - || *service.container_type() == ContainerType::Replica - }) - .map(|service| service.config) - .collect() - })) - } -} diff --git a/api/src/infrastructure/kubernetes/deployment_unit.rs b/api/src/infrastructure/kubernetes/deployment_unit.rs index a900bd72..6a0ef804 100644 --- a/api/src/infrastructure/kubernetes/deployment_unit.rs +++ b/api/src/infrastructure/kubernetes/deployment_unit.rs @@ -33,6 +33,7 @@ use kube::{ core::{DynamicObject, ObjectMeta, WatchEvent}, Api, Client, ResourceExt, }; +use log::{debug, error, trace, warn}; use serde::Deserialize; use std::{ borrow::Borrow, diff --git a/api/src/infrastructure/kubernetes/infrastructure.rs b/api/src/infrastructure/kubernetes/infrastructure.rs index 609239cf..127ae419 100644 --- a/api/src/infrastructure/kubernetes/infrastructure.rs +++ b/api/src/infrastructure/kubernetes/infrastructure.rs @@ -64,7 +64,7 @@ use kube::{ config::Config, error::{Error as KubeError, ErrorResponse}, }; -use log::{debug, warn}; +use log::{debug, error, warn}; use secstr::SecUtf8; use std::collections::{BTreeMap, HashMap, HashSet}; use std::convert::{From, TryFrom}; @@ -170,7 +170,7 @@ impl KubernetesInfrastructure { async fn get_services_of_app( &self, app_name: &AppName, - ) -> Result { + ) -> Result, KubernetesInfrastructureError> { let client = self.client().await?; let namespace = app_name.to_rfc1123_namespace_id(); @@ -224,7 +224,11 @@ impl KubernetesInfrastructure { services.push(service); } - Ok(services.into()) + if services.is_empty() { + Ok(None) + } else { + Ok(Some(Services::from(services))) + } } async fn create_namespace_if_necessary( @@ -442,12 +446,21 @@ impl Infrastructure for KubernetesInfrastructure { let mut apps = HashMap::new(); while let Some(res) = app_name_and_services.next().await { let (app_name, services) = res?; - apps.insert(app_name, services); + if let Some(services) = services { + apps.insert(app_name, services); + } } Ok(apps) } + async fn fetch_services_of_app(&self, app_name: &AppName) -> Result> { + Ok(self + .get_services_of_app(app_name) + .await? + .map(Services::from)) + } + async fn fetch_app_names(&self) -> Result> { let client = self.client().await?; Ok(Api::::all(client) @@ -508,17 +521,17 @@ impl Infrastructure for KubernetesInfrastructure { .map(|s| s.service_name()) .collect::>(); - let services = self.get_services_of_app(app_name).await?; - - k8s_deployment_unit.filter_by_instances_and_replicas( - services - .iter() - // We must exclude the services that are provided by the deployment_unit - // because without that filter a second update of the service would create an - // additional Kubernetes deployment instead of updating/merging the existing one - // that had been created by the bootstrap containers. - .filter(|s| !deployment_unit_service_names.contains(s.service_name())), - ); + if let Some(services) = self.get_services_of_app(app_name).await? { + k8s_deployment_unit.filter_by_instances_and_replicas( + services + .iter() + // We must exclude the services that are provided by the deployment_unit + // because without that filter a second update of the service would create an + // additional Kubernetes deployment instead of updating/merging the existing one + // that had been created by the bootstrap containers. + .filter(|s| !deployment_unit_service_names.contains(s.service_name())), + ); + } for deployable_service in deployment_unit.services() { let (secret, service, deployment, ingress_route, middlewares) = self @@ -546,10 +559,9 @@ impl Infrastructure for KubernetesInfrastructure { } async fn stop_services(&self, _status_id: &str, app_name: &AppName) -> Result { - let services = self.get_services_of_app(app_name).await?; - if services.is_empty() { - return Ok(services); - } + let Some(services) = self.get_services_of_app(app_name).await? else { + return Ok(Services::from(Vec::new())); + }; Api::::all(self.client().await?) .delete( diff --git a/api/src/infrastructure/kubernetes/payloads.rs b/api/src/infrastructure/kubernetes/payloads.rs index c8435b1e..5d175ff0 100644 --- a/api/src/infrastructure/kubernetes/payloads.rs +++ b/api/src/infrastructure/kubernetes/payloads.rs @@ -169,6 +169,13 @@ pub fn convert_k8s_ingress_to_traefik_ingress( let (rule, middleware) = match &spec.ingress_class_name { Some(ingress_class_name) if ingress_class_name == "nginx" => { + let route = match path.path_type.as_str() { + "Prefix" => Some(TraefikIngressRoute::with_rule( + TraefikRouterRule::path_prefix_rule([path_value.clone()]), + )), + _ => None, + }; + let middleware = ingress .metadata .annotations @@ -185,10 +192,22 @@ pub fn convert_k8s_ingress_to_traefik_ingress( .and_then(|_rewrite_target| { let hir = regex_syntax::parse(&path_value).ok()?; let got = regex_syntax::hir::literal::Extractor::new().extract(&hir); + + let base_path = base_route + .routes() + .first() + .and_then(|route| route.rule().first_path_prefix()); + let prefixes = got .literals()? .iter() .map(|l| String::from_utf8_lossy(l.as_bytes()).to_string()) + .map(|path| match base_path { + Some(base_path) => { + TraefikRouterRule::path_prefix_from_segments([base_path, &path]) + } + None => path, + }) .map(serde_json::Value::from) .collect::>(); @@ -205,7 +224,7 @@ pub fn convert_k8s_ingress_to_traefik_ingress( }) }); - (None, middleware) + (route, middleware) } _ => { // TODO warn that ingress class is unknown @@ -847,7 +866,6 @@ pub fn persistent_volume_claim_payload( #[cfg(test)] mod tests { use super::*; - use crate::infrastructure::traefik::TraefikMiddleware; use crate::infrastructure::{TraefikIngressRoute, TraefikRouterRule}; use crate::models::{AppName, Environment, EnvironmentVariable}; use crate::sc; @@ -1630,9 +1648,13 @@ mod tests { ) } - #[test] - fn convert_k8s_ingress_to_traefik_ingress() { - let (route, middlewares) = super::convert_k8s_ingress_to_traefik_ingress( + mod convert_k8s_ingress_to_traefik_ingress { + use super::super::*; + use crate::infrastructure::TraefikMiddleware; + + #[test] + fn nginx_rewrite_without_path_type() { + let (route, middlewares) = super::convert_k8s_ingress_to_traefik_ingress( Ingress { metadata: ObjectMeta { name: Some(String::from("my-ingress")), @@ -1690,54 +1712,247 @@ mod tests { ), ).unwrap(); - assert_eq!( - route, - IngressRoute { + assert_eq!( + route, + IngressRoute { + metadata: ObjectMeta { + name: Some(String::from("my-ingress")), + ..Default::default() + }, + spec: IngressRouteSpec { + entry_points: Some(vec![]), + routes: Some(vec![TraefikRuleSpec { + kind: String::from("Rule"), + r#match: String::from("Host(`my.machine`)"), + services: vec![TraefikRuleService { + kind: Some(String::from("Service")), + name: String::from("backend-service"), + port: Some(8080) + }], + middlewares: Some(vec![ + TraefikRuleMiddlewareRef { + name: String::from("auth"), + namespace: None + }, + TraefikRuleMiddlewareRef { + name: String::from("my-ingress-middleware"), + namespace: None + } + ]) + }]), + tls: None + } + } + ); + + assert_eq!( + middlewares, + vec![ + Middleware { + metadata: ObjectMeta { + name: Some(String::from("auth")), + ..Default::default() + }, + spec: MiddlewareSpec(serde_json::json!({ + "forwardAuth": { + "address": "http://traefik-forward-auth.my-namespace.svc.cluster.local:4181" + } + })) + }, + Middleware { + metadata: ObjectMeta { + name: Some(String::from("my-ingress-middleware")), + ..Default::default() + }, + spec: MiddlewareSpec(serde_json::json!({ + "stripPrefix": { + "prefixes": [ + "/my-service/" + ] + } + })) + } + ] + ); + } + + #[test] + fn nginx_rewrite_with_path_type() { + let (route, middlewares) = super::convert_k8s_ingress_to_traefik_ingress( + Ingress { metadata: ObjectMeta { name: Some(String::from("my-ingress")), + annotations: Some(BTreeMap::from([ + ( + String::from("nginx.ingress.kubernetes.io/use-regex"), + String::from("true"), + ), + ( + String::from("nginx.ingress.kubernetes.io/rewrite-target"), + String::from("/$2"), + ), + ])), ..Default::default() }, - spec: IngressRouteSpec { - entry_points: Some(vec![]), - routes: Some(vec![TraefikRuleSpec { - kind: String::from("Rule"), - r#match: String::from("Host(`my.machine`)"), - services: vec![TraefikRuleService { - kind: Some(String::from("Service")), - name: String::from("backend-service"), - port: Some(8080) - }], - middlewares: Some(vec![ - TraefikRuleMiddlewareRef { - name: String::from("auth"), - namespace: None - }, - TraefikRuleMiddlewareRef { + spec: Some(k8s_openapi::api::networking::v1::IngressSpec { + ingress_class_name: Some(String::from("nginx")), + rules: Some(vec![k8s_openapi::api::networking::v1::IngressRule { + http: Some(k8s_openapi::api::networking::v1::HTTPIngressRuleValue { + paths: vec![k8s_openapi::api::networking::v1::HTTPIngressPath { + path: Some(String::from("/my-service/")), + path_type: String::from("Prefix"), + backend: k8s_openapi::api::networking::v1::IngressBackend { + service: Some( + k8s_openapi::api::networking::v1::IngressServiceBackend { + name: String::from("backend-service"), + port: Some(k8s_openapi::api::networking::v1::ServiceBackendPort { + number: Some(8080), + ..Default::default() + }) + }, + ), + ..Default::default() + }, + ..Default::default() + }], + }), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + }, + TraefikIngressRoute::with_rule(TraefikRouterRule::from_str("Host(`my.machine`)").unwrap()), + ).unwrap(); + + assert_eq!( + route, + IngressRoute { + metadata: ObjectMeta { + name: Some(String::from("my-ingress")), + ..Default::default() + }, + spec: IngressRouteSpec { + entry_points: Some(vec![]), + routes: Some(vec![TraefikRuleSpec { + kind: String::from("Rule"), + r#match: String::from( + "Host(`my.machine`) && PathPrefix(`/my-service/`)" + ), + services: vec![TraefikRuleService { + kind: Some(String::from("Service")), + name: String::from("backend-service"), + port: Some(8080) + }], + middlewares: Some(vec![TraefikRuleMiddlewareRef { name: String::from("my-ingress-middleware"), namespace: None - } - ]) - }]), - tls: None + }]) + }]), + tls: None + } } - } - ); + ); - assert_eq!( - middlewares, - vec![ - Middleware { + assert_eq!( + middlewares, + vec![Middleware { metadata: ObjectMeta { - name: Some(String::from("auth")), + name: Some(String::from("my-ingress-middleware")), ..Default::default() }, spec: MiddlewareSpec(serde_json::json!({ - "forwardAuth": { - "address": "http://traefik-forward-auth.my-namespace.svc.cluster.local:4181" + "stripPrefix": { + "prefixes": [ + "/my-service/" + ] } })) + }] + ); + } + + #[test] + fn nginx_rewrite_with_path_prefix_and_with_base_path_prefix() { + let (route, middlewares) = super::convert_k8s_ingress_to_traefik_ingress( + Ingress { + metadata: ObjectMeta { + name: Some(String::from("my-ingress")), + annotations: Some(BTreeMap::from([ + ( + String::from("nginx.ingress.kubernetes.io/use-regex"), + String::from("true"), + ), + ( + String::from("nginx.ingress.kubernetes.io/rewrite-target"), + String::from("/$2"), + ), + ])), + ..Default::default() }, - Middleware { + spec: Some(k8s_openapi::api::networking::v1::IngressSpec { + ingress_class_name: Some(String::from("nginx")), + rules: Some(vec![k8s_openapi::api::networking::v1::IngressRule { + http: Some(k8s_openapi::api::networking::v1::HTTPIngressRuleValue { + paths: vec![k8s_openapi::api::networking::v1::HTTPIngressPath { + path: Some(String::from("/my-service/")), + path_type: String::from("Prefix"), + backend: k8s_openapi::api::networking::v1::IngressBackend { + service: Some( + k8s_openapi::api::networking::v1::IngressServiceBackend { + name: String::from("backend-service"), + port: Some(k8s_openapi::api::networking::v1::ServiceBackendPort { + number: Some(8080), + ..Default::default() + }) + }, + ), + ..Default::default() + }, + ..Default::default() + }], + }), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + }, + TraefikIngressRoute::with_rule(TraefikRouterRule::from_str("Host(`my.machine`) && PathPrefix(`/PREvant/`)").unwrap()), + ).unwrap(); + + assert_eq!( + route, + IngressRoute { + metadata: ObjectMeta { + name: Some(String::from("my-ingress")), + ..Default::default() + }, + spec: IngressRouteSpec { + entry_points: Some(vec![]), + routes: Some(vec![TraefikRuleSpec { + kind: String::from("Rule"), + r#match: String::from( + "Host(`my.machine`) && PathPrefix(`/PREvant/my-service/`)" + ), + services: vec![TraefikRuleService { + kind: Some(String::from("Service")), + name: String::from("backend-service"), + port: Some(8080) + }], + middlewares: Some(vec![TraefikRuleMiddlewareRef { + name: String::from("my-ingress-middleware"), + namespace: None + }]) + }]), + tls: None + } + } + ); + + assert_eq!( + middlewares, + vec![Middleware { metadata: ObjectMeta { name: Some(String::from("my-ingress-middleware")), ..Default::default() @@ -1745,18 +1960,17 @@ mod tests { spec: MiddlewareSpec(serde_json::json!({ "stripPrefix": { "prefixes": [ - "/my-service/" + "/PREvant/my-service/" ] } })) - } - ] - ); - } + }] + ); + } - #[test] - fn convert_k8s_ingress_to_traefik_ingress_with_existing_path_prefixes() { - let (route, middlewares) = super::convert_k8s_ingress_to_traefik_ingress( + #[test] + fn convert_k8s_ingress_to_traefik_ingress_with_existing_path_prefixes() { + let (route, middlewares) = super::convert_k8s_ingress_to_traefik_ingress( Ingress { metadata: ObjectMeta { name: Some(String::from("my-ingress")), @@ -1821,67 +2035,68 @@ mod tests { ), ).unwrap(); - assert_eq!( - route, - IngressRoute { - metadata: ObjectMeta { - name: Some(String::from("my-ingress")), - ..Default::default() - }, - spec: IngressRouteSpec { - entry_points: Some(vec![]), - routes: Some(vec![TraefikRuleSpec { - kind: String::from("Rule"), - r#match: String::from("Host(`my.machine`)"), - services: vec![TraefikRuleService { - kind: Some(String::from("Service")), - name: String::from("backend-service"), - port: Some(8080) - }], - middlewares: Some(vec![ - TraefikRuleMiddlewareRef { - name: String::from("auth"), - namespace: None - }, - TraefikRuleMiddlewareRef { - name: String::from("my-ingress-middleware"), - namespace: None - } - ]) - }]), - tls: None - } - } - ); - - assert_eq!( - middlewares, - vec![ - Middleware { + assert_eq!( + route, + IngressRoute { metadata: ObjectMeta { - name: Some(String::from("auth")), + name: Some(String::from("my-ingress")), ..Default::default() }, - spec: MiddlewareSpec(serde_json::json!({ - "forwardAuth": { - "address": "http://traefik-forward-auth.my-namespace.svc.cluster.local:4181" - } - })) - }, - Middleware { - metadata: ObjectMeta { - name: Some(String::from("my-ingress-middleware")), - ..Default::default() - }, - spec: MiddlewareSpec(serde_json::json!({ - "stripPrefix": { - "prefixes": [ - "/my-service/" - ] - } - })) + spec: IngressRouteSpec { + entry_points: Some(vec![]), + routes: Some(vec![TraefikRuleSpec { + kind: String::from("Rule"), + r#match: String::from("Host(`my.machine`)"), + services: vec![TraefikRuleService { + kind: Some(String::from("Service")), + name: String::from("backend-service"), + port: Some(8080) + }], + middlewares: Some(vec![ + TraefikRuleMiddlewareRef { + name: String::from("auth"), + namespace: None + }, + TraefikRuleMiddlewareRef { + name: String::from("my-ingress-middleware"), + namespace: None + } + ]) + }]), + tls: None + } } - ] - ); + ); + + assert_eq!( + middlewares, + vec![ + Middleware { + metadata: ObjectMeta { + name: Some(String::from("auth")), + ..Default::default() + }, + spec: MiddlewareSpec(serde_json::json!({ + "forwardAuth": { + "address": "http://traefik-forward-auth.my-namespace.svc.cluster.local:4181" + } + })) + }, + Middleware { + metadata: ObjectMeta { + name: Some(String::from("my-ingress-middleware")), + ..Default::default() + }, + spec: MiddlewareSpec(serde_json::json!({ + "stripPrefix": { + "prefixes": [ + "/my-service/" + ] + } + })) + } + ] + ); + } } } diff --git a/api/src/infrastructure/traefik.rs b/api/src/infrastructure/traefik.rs index ced3e0e7..38ef8c60 100644 --- a/api/src/infrastructure/traefik.rs +++ b/api/src/infrastructure/traefik.rs @@ -198,6 +198,39 @@ impl TraefikIngressRoute { } } +impl From for TraefikIngressRoute { + fn from(url: Url) -> Self { + Self::from(&url) + } +} + +impl From<&Url> for TraefikIngressRoute { + fn from(url: &Url) -> Self { + let mut matches = Vec::with_capacity(2); + matches.push(Matcher::Host { + domains: vec![url.host().map(|host| host.to_string()).unwrap_or_default()], + }); + + if url.path() != "/" { + matches.push(Matcher::PathPrefix { + paths: vec![url.path().to_string()], + }); + } + + Self { + entry_points: match url.scheme() { + "https" => vec![String::from("websecure")], + _ => Vec::new(), + }, + routes: vec![TraefikRoute { + rule: TraefikRouterRule { matches }, + middlewares: Vec::new(), + }], + tls: None, + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct TraefikRoute { rule: TraefikRouterRule, @@ -220,7 +253,7 @@ pub struct TraefikRouterRule { } impl TraefikRouterRule { - fn path_prefix_from_segments(segments: S) -> String + pub fn path_prefix_from_segments(segments: S) -> String where S: IntoIterator, S::Item: AsRef, @@ -267,6 +300,13 @@ impl TraefikRouterRule { } } + pub fn first_path_prefix(&self) -> Option<&String> { + self.matches.iter().find_map(|m| match m { + Matcher::PathPrefix { paths } => paths.first(), + _ => None, + }) + } + pub fn merge_with(&mut self, other: TraefikRouterRule) { for other_match in other.matches { match other_match { @@ -344,15 +384,10 @@ impl TraefikMiddleware { } pub fn is_strip_prefix(&self) -> bool { - match &self.spec { + matches!(&self.spec, serde_value::Value::Map(m) if m.get(&serde_value::Value::String(String::from("stripPrefix"))) - .is_some() => - { - true - } - _ => false, - } + .is_some()) } } @@ -448,11 +483,11 @@ impl Display for TraefikRouterRule { Matcher::PathPrefix { paths } => { write!(f, "PathPrefix(")?; - for (i, domain) in paths.iter().enumerate() { + for (i, path) in paths.iter().enumerate() { if i > 0 { - write!(f, ", `{domain}`")?; + write!(f, ", `{path}`")?; } else { - write!(f, "`{domain}`")?; + write!(f, "`{path}`")?; } } @@ -917,6 +952,38 @@ mod test { ); } + mod from_url { + use super::*; + + #[test] + fn with_host_and_path() { + let url = Url::parse("http://prevant.example.com/master/whoami/").unwrap(); + + assert_eq!( + TraefikIngressRoute::from(url), + TraefikIngressRoute::with_rule( + TraefikRouterRule::from_str( + "Host(`prevant.example.com`) && PathPrefix(`/master/whoami/`)", + ) + .unwrap(), + ) + ); + } + + #[test] + fn with_https() { + let url = Url::parse("https://prevant.example.com/").unwrap(); + + let mut route = + TraefikIngressRoute::with_rule(TraefikRouterRule::host_rule(vec![String::from( + "prevant.example.com", + )])); + route.entry_points.push(String::from("websecure")); + + assert_eq!(TraefikIngressRoute::from(url), route); + } + } + mod to_url { use super::*; diff --git a/api/src/main.rs b/api/src/main.rs index d876b5de..742f2396 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -27,8 +27,6 @@ #[macro_use] extern crate lazy_static; #[macro_use] -extern crate rocket; -#[macro_use] extern crate serde_derive; use crate::apps::host_meta_crawling; @@ -38,10 +36,12 @@ use crate::infrastructure::{Docker, Infrastructure, Kubernetes}; use crate::models::request_info::RequestInfo; use clap::Parser; use rocket::fs::{FileServer, Options}; -use serde_yaml::{from_reader, to_string, Value}; -use std::fs::File; +use rocket::routes; +use serde_yaml::{to_string, Value}; use std::path::Path; use std::sync::Arc; +use tokio::fs::File; +use tokio::io::AsyncReadExt as _; mod apps; mod config; @@ -53,18 +53,20 @@ mod registry; mod tickets; mod webhooks; -#[get("/")] -fn openapi(request_info: RequestInfo) -> Option { +#[rocket::get("/")] +async fn openapi(request_info: RequestInfo) -> Option { let openapi_path = Path::new("res").join("openapi.yml"); - let mut f = match File::open(openapi_path) { + let mut f = match File::open(openapi_path).await { Ok(f) => f, Err(e) => { - error!("Cannot find API documentation: {}", e); + log::error!("Cannot find API documentation: {}", e); return None; } }; - let mut v: Value = from_reader(&mut f).unwrap(); + let mut contents = vec![]; + f.read_to_end(&mut contents).await.ok()?; + let mut v: Value = serde_yaml::from_slice(&contents).ok()?; let mut url = request_info.get_base_url().clone(); url.set_path("/api"); @@ -107,7 +109,7 @@ async fn main() -> Result<(), StartUpError> { let app_updates = apps.app_updates().await; - let (host_meta_cache, host_meta_crawler) = host_meta_crawling(); + let (host_meta_cache, host_meta_crawler) = host_meta_crawling(config.clone()); host_meta_crawler.spawn(apps.clone(), app_updates.clone()); let _rocket = rocket::build() diff --git a/api/src/models/image.rs b/api/src/models/image.rs index 19038e01..63a54096 100644 --- a/api/src/models/image.rs +++ b/api/src/models/image.rs @@ -126,7 +126,6 @@ impl PartialEq for Image { } impl Image { - #[cfg(test)] pub fn tag(&self) -> Option { match &self { Image::Digest { .. } => None, @@ -271,7 +270,7 @@ impl<'de> Deserialize<'de> for Image { D: Deserializer<'de>, { struct ImageVisitor; - impl<'de> serde::de::Visitor<'de> for ImageVisitor { + impl serde::de::Visitor<'_> for ImageVisitor { type Value = Image; fn expecting(&self, formatter: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { write!(formatter, "a string containing a docker image reference") diff --git a/api/src/models/service.rs b/api/src/models/service.rs index 44ab92b9..ea327b7b 100644 --- a/api/src/models/service.rs +++ b/api/src/models/service.rs @@ -147,10 +147,10 @@ impl Serialize for Service { pub struct ServiceWithHostMeta { /// An unique identifier of the service, e.g. the container id id: String, - service_url: Option, - web_host_meta: WebHostMeta, - state: State, - config: ServiceConfig, + pub service_url: Option, + pub web_host_meta: WebHostMeta, + pub state: State, + pub config: ServiceConfig, } impl ServiceWithHostMeta { diff --git a/api/src/models/web_host_meta.rs b/api/src/models/web_host_meta.rs index 6a12c813..237b1390 100644 --- a/api/src/models/web_host_meta.rs +++ b/api/src/models/web_host_meta.rs @@ -76,7 +76,36 @@ impl WebHostMeta { } } - #[cfg(test)] + pub fn with_version_and_open_api_spec_link( + version: Option, + open_api_spec_url: Option, + ) -> Self { + match (version, open_api_spec_url) { + (None, None) => Self::empty(), + (None, Some(open_api_spec_url)) => Self { + properties: None, + links: Some(vec![Link { + rel: String::from("https://github.com/OAI/OpenAPI-Specification"), + href: open_api_spec_url, + }]), + valid: true, + }, + (Some(version), None) => Self::with_version(version), + (Some(version), Some(open_api_spec_url)) => Self { + properties: Some(Properties { + version: Some(version), + commit: None, + date_modified: None, + }), + links: Some(vec![Link { + rel: String::from("https://github.com/OAI/OpenAPI-Specification"), + href: open_api_spec_url, + }]), + valid: true, + }, + } + } + pub fn with_version(version: String) -> Self { Self { properties: Some(Properties { @@ -114,7 +143,6 @@ impl WebHostMeta { } } - pub fn asyncapi(&self) -> Option<&Url> { match self.links.as_ref() { None => None, diff --git a/api/src/registry.rs b/api/src/registry.rs index 776a40f2..d84c00b6 100644 --- a/api/src/registry.rs +++ b/api/src/registry.rs @@ -28,6 +28,7 @@ use crate::config::Config; use crate::models::Image; use futures::stream::FuturesUnordered; use futures::StreamExt; +use log::{debug, warn}; use oci_client::client::ClientConfig; use oci_client::errors::OciDistributionError; use oci_client::secrets::RegistryAuth; diff --git a/api/src/tickets.rs b/api/src/tickets.rs index da765446..853f3ea5 100644 --- a/api/src/tickets.rs +++ b/api/src/tickets.rs @@ -32,6 +32,7 @@ use futures::stream::FuturesUnordered; use futures::StreamExt; use http_api_problem::{HttpApiProblem, StatusCode}; use jira_query::{JiraInstance, JiraQueryError}; +use log::debug; use rocket::serde::json::Json; use rocket::State; use std::collections::HashMap; @@ -40,7 +41,7 @@ use std::sync::Arc; /// Analyzes running containers and returns a map of `review-app-name` with the /// corresponding `TicketInfo`. -#[get("/apps/tickets", format = "application/json")] +#[rocket::get("/apps/tickets", format = "application/json")] pub async fn tickets( config_state: &State, apps_service: &State>, diff --git a/api/src/webhooks.rs b/api/src/webhooks.rs index d0817b41..b466c9f4 100644 --- a/api/src/webhooks.rs +++ b/api/src/webhooks.rs @@ -30,12 +30,13 @@ use crate::http_result::HttpResult; use crate::models::service::Services; use crate::models::web_hook_info::WebHookInfo; use crate::models::AppName; +use log::info; use rocket::serde::json::Json; use rocket::State; use std::str::FromStr; use std::sync::Arc; -#[post("/webhooks", format = "application/json", data = "")] +#[rocket::post("/webhooks", format = "application/json", data = "")] pub async fn webhooks( apps: &State>, web_hook_info: WebHookInfo, diff --git a/docs/web-host-meta.md b/docs/web-host-meta.md new file mode 100644 index 00000000..d8732b04 --- /dev/null +++ b/docs/web-host-meta.md @@ -0,0 +1,87 @@ +# Service Integration via Host Meta + +PREvant is able to show the version of your services (build time, version +string, and git commit hash). It also integrate your [OpenAPI +specification][OpenAPI] into the frontend through [Swagger UI] and the +[AsyncAPI specification][AsyncAPI] through the [asyncapi-react +component][AsyncAPI UI]. + +## Dynamic Web Host Meta + +In order to show the information, PREvant tries to resolve it by using the +web-based protocol proposed by [RFC 6415](https://tools.ietf.org/html/rfc6415). + +When you request the list of apps and services running through the frontend, +PREvant makes a request for each service to the URL +`.well-known/host-meta.json` and expects that the resource provides a +[host-meta document](http://docs.oasis-open.org/xri/xrd/v1.0/xrd-1.0.html) +serialized as JSON: + +```json +{ + "properties": { + "https://schema.org/softwareVersion": "0.9", + "https://schema.org/dateModified": "2019-04-09T15:31:01.363+0200", + "https://git-scm.com/docs/git-commit": "43de4c6edf3c7ed93cdf8983f1ea7d73115176cc" + }, + "links": [ + { + "rel": "https://github.com/OAI/OpenAPI-Specification", + "href": "https://example.com/master/service-name/swagger.json" + }, + { + "rel": "https://github.com/asyncapi/spec", + "href": "https://github.com/asyncapi/spec/blob/master/examples/streetlights-kafka-asyncapi.yml" + } + ] +} +``` + +This sample document contains the relevant information displayed in the frontend (each information is optional): + +- The software version of the service (see `https://schema.org/softwareVersion`) +- The build time of the service (see `https://schema.org/dateModified`) +- The git commit id of the service (see `https://git-scm.com/docs/git-commit`) +- The link to the OpenAPI specification (see `https://github.com/OAI/OpenAPI-Specification`) +- The link to the AsyncAPI specification (see `https://github.com/asyncapi/spec`) + +In order to generate the correct link to the API specification, PREvant adds +following headers to each of these requests: + +- [`Forwarded` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) + with `host` and `proto`. +- `X-Forwarded-Prefix` (used by some reverse proxies, cf. [Traefik](https://docs.traefik.io/basics/) and + [Zuul](https://cloud.spring.io/spring-cloud-static/Finchley.SR1/multi/multi__router_and_filter_zuul.html)). + +## Static Web Host Meta via Configuration + +If you are not in control of the service that PREvant hosts, you could expand +the configuration so that PREvant provides the dashboard integration even if +the service does not provide `.well-known/host-meta.json`. This configuration +is helpful, if you want to deploy some companions for debugging purposes. For +example, [Kafka REST Proxy](https://github.com/confluentinc/kafka-rest) that +let's you interact with your Kafka cluster in each application. + +```toml +[[staticHostMeta]] +# A regex that filters all services based on the image +imageSelector = "docker.io/conuentinc/cp-kafka-rest:.+" +# Optional: PREvant displays the tag as version in the dashboard. +imageTagAsVersion = true +# Optional: The OpenAPI specification that should be accessible in the +# dashboard. The servers section will be updated so that it points to the running +# service. +openApiSpec = { sourceUrl = "https://raw.githubusercontent.com/confluentinc/kafka-rest/refs/tags/v{{image.tag}}/api/v3/openapi.yaml", subPath = "v3" } +# Could be also just a string if you don't have to set the path. +# openApiSpec = "https://raw.githubusercontent.com/confluentinc/kafka-rest/refs/tags/v{{image.tag}}/api/v3/openapi.yaml" +``` + +The `openApiSpecUrl` can be templated with following [handlebars] template variables: + +- `image.tag`: the OCI image tag. + +[handlebars]: https://handlebarsjs.com/ +[AsyncAPI]: https://github.com/asyncapi/spec +[AsyncAPI UI]: https://github.com/asyncapi/asyncapi-react +[OpenAPI]: https://github.com/OAI/OpenAPI-Specification +[Swagger UI]: https://swagger.io/tools/swagger-ui/