junction_core/
lib.rs

1//! The core implementation for Junction - an xDS dynamically-configurable API load-balancer library.
2//!
3//! * [Getting Started](https://docs.junctionlabs.io/getting-started/rust)
4
5mod error;
6mod url;
7pub use crate::error::{Error, Result};
8pub use crate::url::Url;
9
10pub(crate) mod hash;
11pub(crate) mod rand;
12
13mod endpoints;
14pub use endpoints::Endpoint;
15use endpoints::EndpointGroup;
16
17mod client;
18mod dns;
19mod load_balancer;
20mod xds;
21
22pub use client::{
23    Client, HttpRequest, HttpResult, LbContext, ResolvedRoute, SearchConfig, SelectedEndpoint,
24};
25use error::Trace;
26use futures::FutureExt;
27use junction_api::Name;
28pub use xds::{ResourceVersion, XdsConfig};
29
30use junction_api::backend::BackendId;
31use junction_api::http::Route;
32use std::collections::{HashMap, HashSet};
33use std::future::Future;
34use std::sync::Arc;
35
36pub use crate::load_balancer::{BackendLb, LoadBalancer};
37use junction_api::backend::{Backend, LbPolicy};
38
39/// Check route resolution.
40///
41/// Resolves a route against a table of routes, returning the chosen [Route],
42/// the index of the rule that matched, and the [BackendId] selected based on
43/// the route.
44///
45/// Use this function to test routing configuration without requiring a full
46/// client or a live connection to a control plane. For actual route resolution,
47/// see [Client::resolve_http].
48pub fn check_route(
49    routes: Vec<Route>,
50    method: &http::Method,
51    url: &crate::Url,
52    headers: &http::HeaderMap,
53    search_config: Option<&SearchConfig>,
54) -> Result<ResolvedRoute> {
55    let request = client::HttpRequest::from_parts(method, url, headers)?;
56    // resolve with an empty cache and the passed config used as defaults and a
57    // no-op subscribe fn.
58    //
59    // TODO: do we actually want that or do we want to treat the passed routes
60    // as the primary config?
61    let config = StaticConfig::new(routes, Vec::new());
62    let search_config = search_config.cloned().unwrap_or_default();
63
64    // resolve_routes is async but we know that with StaticConfig, fetching
65    // config should NEVER block. now-or-never just calls Poll with a noop
66    // waker and unwraps the result ASAP.
67    client::resolve_routes(&config, Trace::new(), request, None, &search_config)
68        .now_or_never()
69        .expect("check_route yielded unexpectedly. this is a bug in Junction, please file an issue")
70}
71
72pub(crate) trait ConfigCache {
73    async fn get_route<S: AsRef<str>>(&self, authority: S) -> Option<Arc<Route>>;
74    async fn get_backend(&self, target: &BackendId) -> Option<Arc<BackendLb>>;
75    async fn get_endpoints(&self, backend: &BackendId) -> Option<Arc<EndpointGroup>>;
76}
77
78#[derive(Clone, Debug, Default)]
79pub(crate) struct StaticConfig {
80    pub routes: Vec<Arc<Route>>,
81    pub backends: HashMap<BackendId, Arc<BackendLb>>,
82}
83
84impl StaticConfig {
85    pub(crate) fn new(routes: Vec<Route>, backends: Vec<Backend>) -> Self {
86        let routes = routes.into_iter().map(Arc::new).collect();
87
88        let backends: HashMap<_, _> = backends
89            .into_iter()
90            .map(|config| {
91                let load_balancer = LoadBalancer::from_config(&config.lb);
92                let backend_id = config.id.clone();
93                let backend_lb = Arc::new(BackendLb {
94                    config,
95                    load_balancer,
96                });
97                (backend_id, backend_lb)
98            })
99            .collect();
100
101        Self { routes, backends }
102    }
103
104    pub(crate) fn with_inferred(routes: Vec<Route>, backends: Vec<Backend>) -> Self {
105        let mut routes: Vec<_> = routes.into_iter().map(Arc::new).collect();
106        let mut backends: HashMap<_, _> = backends
107            .into_iter()
108            .map(|config| {
109                let load_balancer = LoadBalancer::from_config(&config.lb);
110                let backend_id = config.id.clone();
111                let backend_lb = Arc::new(BackendLb {
112                    config,
113                    load_balancer,
114                });
115                (backend_id, backend_lb)
116            })
117            .collect();
118
119        // infer default backends for Routes with no specified backends.  we can
120        // only infer a backend for a backendref with a port
121        let mut inferred_backends = vec![];
122        for route in &routes {
123            for rule in &route.rules {
124                for backend_ref in &rule.backends {
125                    let Some(backend_id) = backend_ref.as_backend_id() else {
126                        continue;
127                    };
128
129                    if backends.contains_key(&backend_id) {
130                        continue;
131                    }
132
133                    let config = Backend {
134                        id: backend_id.clone(),
135                        lb: LbPolicy::default(),
136                    };
137                    let load_balancer = LoadBalancer::from_config(&config.lb);
138
139                    inferred_backends.push((
140                        backend_id,
141                        Arc::new(BackendLb {
142                            config,
143                            load_balancer,
144                        }),
145                    ))
146                }
147            }
148        }
149
150        // infer default Routes for Backends. Track the set of Services
151        // referenced all Routes, and create a new passthrough for every
152        // Service that doesn't have one.
153        let mut inferred_routes = vec![];
154        let mut route_refs = HashSet::new();
155        for route in &routes {
156            for rule in &route.rules {
157                for backend_ref in &rule.backends {
158                    route_refs.insert(backend_ref.service.clone());
159                }
160            }
161        }
162        for backend in backends.values() {
163            if !route_refs.contains(&backend.config.id.service) {
164                let route = Route::passthrough_route(
165                    Name::from_static("inferred"),
166                    backend.config.id.service.clone(),
167                );
168                inferred_routes.push(Arc::new(route));
169                route_refs.insert(backend.config.id.service.clone());
170            }
171        }
172
173        routes.extend(inferred_routes);
174        backends.extend(inferred_backends);
175
176        Self { routes, backends }
177    }
178}
179
180impl ConfigCache for StaticConfig {
181    async fn get_route<S: AsRef<str>>(&self, authority: S) -> Option<Arc<Route>> {
182        let (host, port) = authority.as_ref().split_once(":")?;
183        let port = port.parse().ok()?;
184
185        self.routes
186            .iter()
187            .find(|r| route_matches(r, host, port))
188            .map(Arc::clone)
189    }
190
191    fn get_backend(&self, target: &BackendId) -> impl Future<Output = Option<Arc<BackendLb>>> {
192        std::future::ready(self.backends.get(target).cloned())
193    }
194
195    fn get_endpoints(&self, _: &BackendId) -> impl Future<Output = Option<Arc<EndpointGroup>>> {
196        std::future::ready(None)
197    }
198}
199
200fn route_matches(route: &Route, host: &str, port: u16) -> bool {
201    if !route.hostnames.iter().any(|h| h.matches_str(host)) {
202        return false;
203    }
204
205    if !(route.ports.is_empty() || route.ports.contains(&port)) {
206        return false;
207    }
208
209    true
210}
211
212#[cfg(test)]
213mod test {
214    use super::*;
215    use junction_api::{
216        http::{BackendRef, RouteRule},
217        Hostname, Service,
218    };
219    use std::str::FromStr;
220
221    #[test]
222    fn test_check_routes_resolves_ndots_no_match() {
223        let backend = Service::kube("web", "svc1").unwrap();
224
225        let wont_match = ["http://not.example.com", "http://notexample.com"];
226
227        let route = Route {
228            id: Name::from_static("ndots-match"),
229            hostnames: vec![Hostname::from_static("example.foo.bar.com").into()],
230            ports: vec![],
231            tags: Default::default(),
232            rules: vec![RouteRule {
233                matches: vec![],
234                backends: vec![BackendRef {
235                    weight: 1,
236                    service: backend.clone(),
237                    port: Some(8910),
238                }],
239                ..Default::default()
240            }],
241        };
242
243        let routes = vec![route];
244
245        for url in wont_match {
246            let url = crate::Url::from_str(url).unwrap();
247            let headers = &http::HeaderMap::default();
248
249            let resolved_route = check_route(
250                routes.clone(),
251                &http::Method::GET,
252                &url,
253                headers,
254                Some(&SearchConfig::new(3, vec![])),
255            );
256
257            match resolved_route {
258                Ok(_) => panic!("succeeded for {} should have failed.", url.authority()),
259                Err(e) => assert_eq!(
260                    format!("{}", e),
261                    format!("no route matched: '{}'", url.authority())
262                ),
263            }
264        }
265    }
266
267    #[test]
268    fn test_check_routes_resolves_ndots_match_without_search() {
269        let backend = Service::kube("web", "svc1").unwrap();
270
271        let route = Route {
272            id: Name::from_static("ndots-match"),
273            hostnames: vec![
274                Hostname::from_static("example.com").into(),
275                Hostname::from_static("example.foo.com").into(),
276                Hostname::from_static("example.foo.bar.com").into(),
277            ],
278            ports: vec![],
279            tags: Default::default(),
280            rules: vec![RouteRule {
281                matches: vec![],
282                backends: vec![BackendRef {
283                    weight: 1,
284                    service: backend.clone(),
285                    port: Some(8910),
286                }],
287                ..Default::default()
288            }],
289        };
290
291        let routes = vec![route];
292
293        let will_match = [
294            "http://example.com",
295            "http://example.foo.com",
296            "http://example.foo.bar.com",
297        ];
298
299        for url in will_match {
300            let url = crate::Url::from_str(url).unwrap();
301            let headers = &http::HeaderMap::default();
302
303            let resolved = check_route(
304                routes.clone(),
305                &http::Method::GET,
306                &url,
307                headers,
308                Some(&SearchConfig::new(3, vec![])),
309            )
310            .unwrap();
311            // should match one of the query matches
312            assert_eq!(
313                (resolved.rule, &resolved.backend),
314                (0, &backend.as_backend_id(8910)),
315                "should match the first rule: {url}"
316            );
317        }
318    }
319
320    #[test]
321    fn test_check_routes_resolves_ndots() {
322        let backend = Service::kube("web", "svc1").unwrap();
323
324        let route = Route {
325            id: Name::from_static("ndots-match"),
326            hostnames: vec![Hostname::from_static("example.foo.bar.com").into()],
327            ports: vec![],
328            tags: Default::default(),
329            rules: vec![RouteRule {
330                matches: vec![],
331                backends: vec![BackendRef {
332                    weight: 1,
333                    service: backend.clone(),
334                    port: Some(8910),
335                }],
336                ..Default::default()
337            }],
338        };
339
340        let routes = vec![route];
341
342        let will_match = [
343            "http://example",
344            "http://example.foo",
345            "http://example.foo.bar",
346            "http://example.foo.bar.com",
347        ];
348        let will_match_hostnames = vec![
349            Hostname::from_static("foo.bar.com"),
350            Hostname::from_static("bar.com"),
351            Hostname::from_static("com"),
352        ];
353
354        for url in will_match {
355            let url = crate::Url::from_str(url).unwrap();
356            let headers = &http::HeaderMap::default();
357
358            let resolved = check_route(
359                routes.clone(),
360                &http::Method::GET,
361                &url,
362                headers,
363                Some(&SearchConfig::new(3, will_match_hostnames.clone())),
364            )
365            .unwrap();
366            // should match one of the query matches
367            assert_eq!(
368                (resolved.rule, &resolved.backend),
369                (0, &backend.as_backend_id(8910)),
370                "should match the first rule: {url}"
371            );
372        }
373    }
374}