use std::{
collections::{BTreeMap, HashMap},
fmt::Debug,
str::FromStr,
};
use crate::{
error::{Error, ErrorContext},
http::{
BackendRef, HeaderMatch, Method, PathMatch, QueryParamMatch, Route, RouteMatch, RouteRetry,
RouteRule, RouteTimeouts,
},
shared::{Duration, Regex},
Name,
};
use xds_api::pb::{
envoy::{
config::{
core::v3 as xds_core,
route::v3::{self as xds_route, query_parameter_matcher::QueryParameterMatchSpecifier},
},
r#type::matcher::v3::{string_matcher::MatchPattern, StringMatcher},
},
google::{
self,
protobuf::{self, UInt32Value},
},
};
use crate::xds::shared::{parse_xds_regex, regex_matcher};
impl TryInto<Route> for &xds_route::RouteConfiguration {
type Error = Error;
fn try_into(self) -> Result<Route, Self::Error> {
Route::from_xds(self)
}
}
impl From<&Route> for xds_route::RouteConfiguration {
fn from(route: &Route) -> Self {
route.to_xds()
}
}
impl Route {
pub fn from_xds(xds: &xds_route::RouteConfiguration) -> Result<Self, Error> {
let id = Name::from_str(&xds.name).with_field("name")?;
let tags = tags_from_xds(&xds.metadata)?;
let [vhost] = &xds.virtual_hosts[..] else {
return Err(Error::new_static(
"RouteConfiguration must have exactly one VirtualHost",
));
};
let mut hostnames = Vec::new();
let mut ports = Vec::new();
for (i, domain) in vhost.domains.iter().enumerate() {
let (name, port) = crate::parse_port(domain).with_field_index("domains", i)?;
let name = crate::http::HostnameMatch::from_str(name)
.with_field_index("domains", i)
.with_field_index("virtual_hosts", 0)?;
hostnames.push(name);
if let Some(port) = port {
ports.push(port);
}
}
hostnames.sort();
hostnames.dedup();
ports.sort();
ports.dedup();
let mut rules = vec![];
let actions_and_matches = vhost.routes.iter().enumerate().map(|(route_idx, route)| {
let key = (route.name.as_str(), route.action.as_ref());
(key, (route_idx, route))
});
for ((name, action), matches) in group_by(actions_and_matches) {
let action_idx = matches
.first()
.map(|(idx, _)| *idx)
.expect("missing route index");
let Some(action) = &action else {
return Err(Error::new_static("route has no route action"))
.with_field_index("routes", action_idx)
.with_field_index("virtual_hosts", 0);
};
rules.push(
RouteRule::from_xds_grouped(name, action, &matches)
.with_field_index("virtual_hosts", 0)?,
);
}
Ok(Route {
id,
hostnames,
ports,
tags,
rules,
})
}
pub fn to_xds(&self) -> xds_route::RouteConfiguration {
let routes = self.rules.iter().flat_map(|rule| rule.to_xds()).collect();
let mut domains = Vec::with_capacity(self.hostnames.len() * self.ports.len().min(1));
for hostname in &self.hostnames {
if self.ports.is_empty() {
domains.push(hostname.to_string());
} else {
for port in &self.ports {
domains.push(format!("{hostname}:{port}"));
}
}
}
let virtual_hosts = vec![xds_route::VirtualHost {
domains,
routes,
..Default::default()
}];
let name = self.id.to_string();
let metadata = tags_to_xds(&self.tags);
xds_route::RouteConfiguration {
name,
metadata,
virtual_hosts,
..Default::default()
}
}
}
const JUNCTION_ROUTE_TAGS: &str = "io.junctionlabs.route.tags";
fn tags_to_xds(tags: &BTreeMap<String, String>) -> Option<xds_core::Metadata> {
if tags.is_empty() {
return None;
}
let fields: HashMap<_, _> = tags
.iter()
.map(|(k, v)| {
let v = protobuf::Value {
kind: Some(protobuf::value::Kind::StringValue(v.clone())),
};
(k.clone(), v)
})
.collect();
let mut metadata = xds_core::Metadata::default();
metadata
.filter_metadata
.insert(JUNCTION_ROUTE_TAGS.to_string(), protobuf::Struct { fields });
Some(metadata)
}
fn tags_from_xds(metadata: &Option<xds_core::Metadata>) -> Result<BTreeMap<String, String>, Error> {
let Some(metadata) = metadata else {
return Ok(Default::default());
};
let Some(route_tags) = metadata.filter_metadata.get(JUNCTION_ROUTE_TAGS) else {
return Ok(Default::default());
};
let mut tags = BTreeMap::new();
for (k, v) in route_tags.fields.iter() {
let v = match &v.kind {
Some(protobuf::value::Kind::StringValue(v)) => v.clone(),
_ => {
return Err(Error::new_static("invalid tag"))
.with_fields("filter_metadata", JUNCTION_ROUTE_TAGS)
}
};
tags.insert(k.clone(), v);
}
Ok(tags)
}
impl RouteRule {
fn from_xds_grouped(
name: &str,
action: &xds_route::route::Action,
routes: &[(usize, &xds_route::Route)],
) -> Result<Self, Error> {
let mut matches = vec![];
for (route_idx, route) in routes {
if let Some(route_match) = &route.r#match {
let m = RouteMatch::from_xds(route_match)
.with_field("match")
.with_field_index("route", *route_idx)?;
matches.push(m);
}
}
let first_route_idx = routes
.first()
.map(|(idx, _)| *idx)
.expect("no Routes grouped with action. this is a bug in Junction");
let name = if !name.is_empty() {
Some(
Name::from_str(name)
.with_field("name")
.with_field_index("route", first_route_idx)?,
)
} else {
None
};
let action = match action {
xds_route::route::Action::Route(action) => action,
_ => {
return Err(Error::new_static("unsupported route action").with_field("action"))
.with_field_index("route", first_route_idx)
}
};
let timeouts = RouteTimeouts::from_xds(action)?;
let retry = action.retry_policy.as_ref().and_then(RouteRetry::from_xds);
let backends = BackendRef::from_xds(action.cluster_specifier.as_ref())
.with_field("action")
.with_field_index("route", first_route_idx)?;
Ok(RouteRule {
name,
matches,
retry,
filters: vec![],
timeouts,
backends,
})
}
pub fn to_xds(&self) -> Vec<xds_route::Route> {
let mut retry_policy = self.retry.as_ref().map(RouteRetry::to_xds);
let (timeout, per_try_timeout) = self
.timeouts
.as_ref()
.map(RouteTimeouts::to_xds)
.unwrap_or((None, None));
if let Some(per_try_timeout) = per_try_timeout {
retry_policy
.get_or_insert_with(Default::default)
.per_try_timeout = Some(per_try_timeout);
}
let cluster_specifier = BackendRef::to_xds(&self.backends);
let route_action = xds_route::route::Action::Route(xds_route::RouteAction {
timeout,
retry_policy,
cluster_specifier,
..Default::default()
});
let name = self
.name
.as_ref()
.map(|name| name.to_string())
.unwrap_or_default();
if self.matches.is_empty() {
vec![xds_route::Route {
name,
r#match: Some(xds_route::RouteMatch {
path_specifier: Some(xds_route::route_match::PathSpecifier::Prefix(
"".to_string(),
)),
..Default::default()
}),
action: Some(route_action),
..Default::default()
}]
} else {
self.matches
.iter()
.map(|route_match| {
let r#match = Some(route_match.to_xds());
xds_route::Route {
name: name.clone(),
r#match,
action: Some(route_action.clone()),
..Default::default()
}
})
.collect()
}
}
}
impl RouteTimeouts {
pub fn from_xds(r: &xds_route::RouteAction) -> Result<Option<Self>, Error> {
let request = r.timeout.map(Duration::try_from).transpose()?;
let backend_request = r
.retry_policy
.as_ref()
.and_then(|retry_policy| retry_policy.per_try_timeout.map(Duration::try_from))
.transpose()?;
if request.is_some() || backend_request.is_some() {
Ok(Some(RouteTimeouts {
request,
backend_request,
}))
} else {
Ok(None)
}
}
pub fn to_xds(
&self,
) -> (
Option<google::protobuf::Duration>,
Option<google::protobuf::Duration>,
) {
let request_timeout = self.request.map(|d| d.try_into().unwrap());
let per_try_timeout = self.backend_request.map(|d| d.try_into().unwrap());
(request_timeout, per_try_timeout)
}
}
impl RouteMatch {
pub fn from_xds(r: &xds_route::RouteMatch) -> Result<Self, Error> {
let path = r
.path_specifier
.as_ref()
.map(PathMatch::from_xds)
.transpose()?;
let mut method: Option<Method> = None;
let mut headers = vec![];
for (i, header) in r.headers.iter().enumerate() {
let header_match = HeaderMatch::from_xds(header).with_field_index("headers", i)?;
match header_match {
HeaderMatch::Exact { name, value } if name == ":method" => {
method = Some(value);
}
_ => {
headers.push(header_match);
}
}
}
let query_params = r
.query_parameters
.iter()
.enumerate()
.map(|(i, e)| QueryParamMatch::from_xds(e).with_field_index("query_parameters", i))
.collect::<Result<Vec<_>, _>>()?;
Ok(RouteMatch {
headers,
method,
path,
query_params,
})
}
fn to_xds(&self) -> xds_route::RouteMatch {
let path_specifier = self.path.as_ref().map(|p| p.to_xds());
let mut headers = vec![];
if let Some(method) = &self.method {
headers.push(xds_route::HeaderMatcher {
name: ":method".to_string(),
header_match_specifier: Some(
xds_route::header_matcher::HeaderMatchSpecifier::ExactMatch(method.to_string()),
),
..Default::default()
})
}
for header_match in &self.headers {
headers.push(header_match.to_xds());
}
let query_parameters = self
.query_params
.iter()
.map(QueryParamMatch::to_xds)
.collect();
xds_route::RouteMatch {
headers,
path_specifier,
query_parameters,
..Default::default()
}
}
}
impl QueryParamMatch {
pub fn from_xds(matcher: &xds_route::QueryParameterMatcher) -> Result<Self, Error> {
let name = matcher.name.clone();
match matcher.query_parameter_match_specifier.as_ref() {
Some(QueryParameterMatchSpecifier::StringMatch(s)) => {
let match_pattern = match s.match_pattern.as_ref() {
Some(MatchPattern::Exact(s)) => Ok(QueryParamMatch::Exact {
name,
value: s.clone(),
}),
Some(MatchPattern::SafeRegex(pfx)) => Ok(QueryParamMatch::RegularExpression {
name,
value: parse_xds_regex(pfx)?,
}),
Some(_) => Err(Error::new_static("unsupported string match type")),
None => Err(Error::new_static("missing string match")),
};
match_pattern.with_field("string_match")
}
Some(QueryParameterMatchSpecifier::PresentMatch(true)) => {
Ok(QueryParamMatch::RegularExpression {
name,
value: Regex::from_str(".*").unwrap(),
})
}
Some(QueryParameterMatchSpecifier::PresentMatch(false)) => {
Err(Error::new_static("absent matches are not supported")
.with_field("present_match"))
}
None => Ok(QueryParamMatch::RegularExpression {
name,
value: Regex::from_str(".*").unwrap(),
}),
}
}
pub fn to_xds(&self) -> xds_route::QueryParameterMatcher {
let (name, matcher) = match self {
QueryParamMatch::RegularExpression { name, value } => {
let name = name.clone();
let matcher = MatchPattern::SafeRegex(regex_matcher(value));
(name, matcher)
}
QueryParamMatch::Exact { name, value } => {
let name = name.clone();
let matcher = MatchPattern::Exact(value.to_string());
(name, matcher)
}
};
xds_route::QueryParameterMatcher {
name,
query_parameter_match_specifier: Some(QueryParameterMatchSpecifier::StringMatch(
StringMatcher {
match_pattern: Some(matcher),
ignore_case: false,
},
)),
}
}
}
impl HeaderMatch {
fn from_xds(header_matcher: &xds_route::HeaderMatcher) -> Result<Self, Error> {
use xds_route::header_matcher::HeaderMatchSpecifier;
let name = header_matcher.name.clone();
match header_matcher.header_match_specifier.as_ref() {
Some(HeaderMatchSpecifier::ExactMatch(value)) => Ok(HeaderMatch::Exact {
name,
value: value.clone(),
}),
Some(HeaderMatchSpecifier::SafeRegexMatch(regex)) => {
Ok(HeaderMatch::RegularExpression {
name,
value: parse_xds_regex(regex)?,
})
}
Some(HeaderMatchSpecifier::PresentMatch(true)) => Ok(HeaderMatch::RegularExpression {
name,
value: Regex::from_str(".*").unwrap(),
}),
Some(_) => Err(Error::new_static("unsupported matcher")),
None => Ok(HeaderMatch::RegularExpression {
name,
value: Regex::from_str(".*").unwrap(),
}),
}
}
fn to_xds(&self) -> xds_route::HeaderMatcher {
match self {
HeaderMatch::RegularExpression { name, value } => xds_route::HeaderMatcher {
name: name.clone(),
header_match_specifier: Some(
xds_route::header_matcher::HeaderMatchSpecifier::SafeRegexMatch(regex_matcher(
value,
)),
),
..Default::default()
},
HeaderMatch::Exact { name, value } => xds_route::HeaderMatcher {
name: name.clone(),
header_match_specifier: Some(
xds_route::header_matcher::HeaderMatchSpecifier::ExactMatch(value.to_string()),
),
..Default::default()
},
}
}
}
impl PathMatch {
fn from_xds(path_spec: &xds_route::route_match::PathSpecifier) -> Result<Self, Error> {
match path_spec {
xds_route::route_match::PathSpecifier::Prefix(p) => {
Ok(PathMatch::Prefix { value: p.clone() })
}
xds_route::route_match::PathSpecifier::Path(p) => {
Ok(PathMatch::Exact { value: p.clone() })
}
xds_route::route_match::PathSpecifier::SafeRegex(p) => {
Ok(PathMatch::RegularExpression {
value: parse_xds_regex(p).with_field("safe_regex")?,
})
}
_ => Err(Error::new_static("unsupported path specifier")),
}
}
pub fn to_xds(&self) -> xds_route::route_match::PathSpecifier {
match self {
PathMatch::Prefix { value } => {
xds_route::route_match::PathSpecifier::Prefix(value.to_string())
}
PathMatch::RegularExpression { value } => {
xds_route::route_match::PathSpecifier::SafeRegex(regex_matcher(value))
}
PathMatch::Exact { value } => {
xds_route::route_match::PathSpecifier::Path(value.clone())
}
}
}
}
impl RouteRetry {
pub fn from_xds(r: &xds_route::RetryPolicy) -> Option<Self> {
if r.retriable_status_codes.is_empty()
&& r.num_retries.is_none()
&& r.retry_back_off.is_none()
{
return None;
}
let codes = r
.retriable_status_codes
.iter()
.map(|code| *code as u16)
.collect();
let attempts = r.num_retries.map(|v| u32::from(v) + 1);
let backoff = r
.retry_back_off
.as_ref()
.and_then(|r2| r2.base_interval.map(|x| x.try_into().unwrap()));
Some(Self {
codes,
attempts,
backoff,
})
}
pub fn to_xds(&self) -> xds_route::RetryPolicy {
let retriable_status_codes = self.codes.iter().map(|&code| code as u32).collect();
let num_retries = self
.attempts
.map(|attempts| UInt32Value::from(attempts.saturating_sub(1)));
let retry_back_off = self.backoff.map(|b| xds_route::retry_policy::RetryBackOff {
base_interval: Some(b.try_into().unwrap()),
max_interval: None,
});
xds_route::RetryPolicy {
retriable_status_codes,
num_retries,
retry_back_off,
..Default::default()
}
}
}
fn group_by<I, K, V>(iter: I) -> GroupBy<<I as IntoIterator>::IntoIter, K, V>
where
I: IntoIterator<Item = (K, V)>,
K: PartialEq,
{
GroupBy {
iter: iter.into_iter(),
current_key: None,
current_values: Vec::new(),
}
}
struct GroupBy<I, K, V> {
iter: I,
current_key: Option<K>,
current_values: Vec<V>,
}
impl<I, K, V> Iterator for GroupBy<I, K, V>
where
I: Iterator<Item = (K, V)>,
K: PartialEq + Debug,
V: Debug,
{
type Item = (K, Vec<V>);
fn next(&mut self) -> Option<Self::Item> {
loop {
match (self.current_key.take(), self.iter.next()) {
(None, Some((k, v))) => {
self.current_key = Some(k);
self.current_values.push(v);
}
(Some(current_key), Some((next_key, v))) if next_key == current_key => {
self.current_key = Some(current_key);
self.current_values.push(v)
}
(Some(current_key), Some((next_key, v))) => {
let values = std::mem::take(&mut self.current_values);
self.current_key = Some(next_key);
self.current_values.push(v);
return Some((current_key, values));
}
(Some(key), None) => {
let values = std::mem::take(&mut self.current_values);
return Some((key, values));
}
(None, None) => return None,
}
}
}
}
impl BackendRef {
pub(crate) fn to_xds(wbs: &[Self]) -> Option<xds_route::route_action::ClusterSpecifier> {
match wbs {
[] => None,
[backend] => Some(xds_route::route_action::ClusterSpecifier::Cluster(
backend.name(),
)),
targets => {
let clusters = targets
.iter()
.map(|wb| xds_route::weighted_cluster::ClusterWeight {
name: wb.name(),
weight: Some(wb.weight.into()),
..Default::default()
})
.collect();
Some(xds_route::route_action::ClusterSpecifier::WeightedClusters(
xds_route::WeightedCluster {
clusters,
..Default::default()
},
))
}
}
}
pub(crate) fn from_xds(
xds: Option<&xds_route::route_action::ClusterSpecifier>,
) -> Result<Vec<Self>, Error> {
match xds {
Some(xds_route::route_action::ClusterSpecifier::Cluster(name)) => {
BackendRef::from_str(name)
.map(|br| vec![br])
.with_field("cluster")
}
Some(xds_route::route_action::ClusterSpecifier::WeightedClusters(
weighted_clusters,
)) => {
let clusters = weighted_clusters.clusters.iter().enumerate().map(|(i, w)| {
let backend_ref = BackendRef::from_str(&w.name).with_field_index("name", i)?;
let weight = crate::value_or_default!(w.weight, 1);
Ok(Self {
weight,
..backend_ref
})
});
clusters
.collect::<Result<Vec<_>, _>>()
.with_fields("weighted_clusters", "clusters")
}
Some(_) => Err(Error::new_static("unsupported cluster specifier")),
None => Ok(Vec::new()),
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{http::HostnameMatch, Hostname, Service};
#[test]
fn test_group_by() {
let groups: Vec<_> = group_by([(1, "a"), (2, "b"), (3, "c")]).collect();
assert_eq!(vec![(1, vec!["a"]), (2, vec!["b"]), (3, vec!["c"])], groups);
let groups: Vec<_> = group_by([
(1, "a"),
(1, "a"),
(2, "b"),
(3, "c"),
(3, "c"),
(3, "c"),
(1, "a"),
])
.collect();
assert_eq!(
vec![
(1, vec!["a", "a"]),
(2, vec!["b"]),
(3, vec!["c", "c", "c"]),
(1, vec!["a"]),
],
groups
);
}
#[test]
fn test_simple_route() {
let web = Service::kube("prod", "web").unwrap();
let original = Route {
id: Name::from_static("my-route"),
hostnames: vec![web.hostname().into()],
ports: vec![],
tags: Default::default(),
rules: vec![RouteRule {
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: None,
}],
..Default::default()
}],
};
let round_tripped = Route::from_xds(&original.to_xds()).unwrap();
let expected = Route {
id: Name::from_static("my-route"),
hostnames: vec![web.hostname().into()],
ports: vec![],
tags: Default::default(),
rules: vec![RouteRule {
matches: vec![RouteMatch {
path: Some(PathMatch::empty_prefix()),
..Default::default()
}],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: None,
}],
..Default::default()
}],
};
assert_eq!(round_tripped, expected)
}
#[test]
fn test_wildcard_hostname() {
let web = Service::kube("prod", "web").unwrap();
let original = Route {
id: Name::from_static("my-route"),
hostnames: vec![
HostnameMatch::from_str("*.prod.web.svc.cluster.local").unwrap(),
HostnameMatch::from_str("*.staging.web.svc.cluster.local").unwrap(),
],
ports: vec![80, 443],
tags: Default::default(),
rules: vec![RouteRule {
matches: vec![RouteMatch {
path: Some(PathMatch::empty_prefix()),
..Default::default()
}],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: None,
}],
..Default::default()
}],
};
let round_tripped = Route::from_xds(&original.to_xds()).unwrap();
assert_eq!(round_tripped, original)
}
#[test]
fn test_route_no_rules() {
let original = Route {
id: Name::from_static("no-rules"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: Default::default(),
rules: vec![],
};
let round_tripped = Route::from_xds(&original.to_xds()).unwrap();
assert_eq!(round_tripped, original)
}
#[test]
fn test_route_rule_no_backend() {
let original = Route {
id: Name::from_static("no-backends"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: Default::default(),
rules: vec![RouteRule::default()],
};
let normalized = Route {
id: Name::from_static("no-backends"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: Default::default(),
rules: vec![RouteRule {
matches: vec![RouteMatch {
path: Some(PathMatch::Prefix {
value: "".to_string(),
}),
..Default::default()
}],
..Default::default()
}],
};
let round_tripped = Route::from_xds(&original.to_xds()).unwrap();
assert_eq!(round_tripped, normalized)
}
#[test]
fn test_metadata_roundtrip() {
let web = Service::kube("prod", "web").unwrap();
assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
id: Name::from_static("metadata"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: BTreeMap::from_iter([("foo".to_string(), "bar".to_string())]),
rules: vec![RouteRule {
matches: vec![RouteMatch {
path: Some(PathMatch::Prefix {
value: "".to_string(),
}),
..Default::default()
}],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: Some(8778),
}],
..Default::default()
}],
});
}
#[test]
fn test_multiple_rules_roundtrip() {
let web = Service::kube("prod", "web").unwrap();
let staging = Service::kube("staging", "web").unwrap();
assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
id: Name::from_static("multiple-targets"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: Default::default(),
rules: vec![
RouteRule {
name: Some(Name::from_static("split-web")),
matches: vec![RouteMatch {
path: Some(PathMatch::Exact {
value: "/foo/feature-test".to_string(),
}),
..Default::default()
}],
backends: vec![
BackendRef {
weight: 3,
service: staging.clone(),
port: Some(80),
},
BackendRef {
weight: 1,
service: web.clone(),
port: Some(80),
},
],
..Default::default()
},
RouteRule {
name: Some(Name::from_static("one-web")),
matches: vec![RouteMatch {
path: Some(PathMatch::Prefix {
value: "/foo".to_string(),
}),
..Default::default()
}],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: Some(80),
}],
..Default::default()
},
],
});
assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
id: Name::from_static("same-target-multiple-timeouts"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: Default::default(),
rules: vec![
RouteRule {
name: Some(Name::from_static("no-timeouts")),
matches: vec![RouteMatch {
path: Some(PathMatch::Exact {
value: "/foo/feature-test".to_string(),
}),
..Default::default()
}],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: None,
}],
..Default::default()
},
RouteRule {
name: Some(Name::from_static("with-timeouts")),
matches: vec![RouteMatch {
path: Some(PathMatch::Prefix {
value: "/foo".to_string(),
}),
..Default::default()
}],
timeouts: Some(RouteTimeouts {
request: Some(Duration::from_secs(123)),
backend_request: None,
}),
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: None,
}],
..Default::default()
},
],
});
assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
id: Name::from_static("same-target-multiple-retries"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: Default::default(),
rules: vec![
RouteRule {
matches: vec![RouteMatch {
path: Some(PathMatch::Exact {
value: "/foo/feature-test".to_string(),
}),
..Default::default()
}],
retry: Some(RouteRetry {
codes: vec![500, 503],
attempts: Some(123),
backoff: Some(Duration::from_secs(1)),
}),
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: None,
}],
..Default::default()
},
RouteRule {
matches: vec![RouteMatch {
path: Some(PathMatch::Prefix {
value: "/foo".to_string(),
}),
..Default::default()
}],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: None,
}],
..Default::default()
},
],
});
assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
id: Name::from_static("different-names"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: Default::default(),
rules: vec![
RouteRule {
name: Some(Name::from_static("rule-1")),
matches: vec![RouteMatch {
path: Some(PathMatch::Prefix {
value: "/foo".to_string(),
}),
..Default::default()
}],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: None,
}],
..Default::default()
},
RouteRule {
name: Some(Name::from_static("rule-2")),
matches: vec![RouteMatch {
path: Some(PathMatch::Prefix {
value: "/foo".to_string(),
}),
..Default::default()
}],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: None,
}],
..Default::default()
},
],
});
}
#[test]
fn test_condense_rules() {
let web = Service::kube("prod", "web").unwrap();
let original = Route {
id: Name::from_static("will-be-condensed"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: Default::default(),
rules: vec![
RouteRule {
matches: vec![RouteMatch {
path: Some(PathMatch::Exact {
value: "/foo/feature-test".to_string(),
}),
..Default::default()
}],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: Some(80),
}],
..Default::default()
},
RouteRule {
matches: vec![RouteMatch {
path: Some(PathMatch::Prefix {
value: "/foo".to_string(),
}),
..Default::default()
}],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: Some(80),
}],
..Default::default()
},
],
};
let converted = Route::from_xds(&original.to_xds()).unwrap();
assert_eq!(
converted,
Route {
id: Name::from_static("will-be-condensed"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: Default::default(),
rules: vec![RouteRule {
matches: vec![
RouteMatch {
path: Some(PathMatch::Exact {
value: "/foo/feature-test".to_string(),
}),
..Default::default()
},
RouteMatch {
path: Some(PathMatch::Prefix {
value: "/foo".to_string(),
}),
..Default::default()
}
],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: Some(80),
}],
..Default::default()
},],
}
)
}
#[test]
fn test_multiple_matches_roundtrip() {
let web = Service::kube("prod", "web").unwrap();
assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
id: Name::from_static("multiple-matches"),
hostnames: vec![Hostname::from_static("web.internal").into()],
ports: vec![],
tags: Default::default(),
rules: vec![RouteRule {
matches: vec![
RouteMatch {
path: Some(PathMatch::Prefix {
value: "/foo".to_string(),
}),
..Default::default()
},
RouteMatch {
path: Some(PathMatch::Prefix {
value: "/bar".to_string(),
}),
..Default::default()
},
RouteMatch {
query_params: vec![QueryParamMatch::Exact {
name: "param".to_string(),
value: "an_value".to_string(),
}],
..Default::default()
},
],
backends: vec![BackendRef {
weight: 1,
service: web.clone(),
port: Some(80),
}],
..Default::default()
}],
});
}
#[test]
fn test_full_route_match_roundtrips() {
let web = Service::kube("prod", "web").unwrap();
assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
id: Name::from_static("full-send"),
hostnames: vec![
HostnameMatch::from_str("*.web.internal").unwrap(),
Hostname::from_static("potato.tomato").into(),
Hostname::from_static("web.internal").into(),
],
ports: vec![80, 443, 8080],
tags: [("foo".to_string(), "bar".to_string())]
.into_iter()
.collect(),
rules: vec![RouteRule {
matches: vec![RouteMatch {
path: Some(PathMatch::Prefix {
value: "/potato".to_string(),
}),
headers: vec![HeaderMatch::RegularExpression {
name: "x-one".to_string(),
value: ".*".parse().unwrap(),
}],
query_params: vec![
QueryParamMatch::RegularExpression {
name: "foo".to_string(),
value: r"\w+".parse().unwrap(),
},
QueryParamMatch::Exact {
name: "bar".to_string(),
value: "baz".to_string(),
},
],
method: Some("CONNECT".to_string()),
}],
backends: vec![
BackendRef {
weight: 1,
service: web.clone(),
port: Some(8080),
},
BackendRef {
weight: 0,
service: web.clone(),
port: None,
},
],
..Default::default()
}],
});
}
#[track_caller]
fn assert_roundtrip<T, Xds>(v: T)
where
T: PartialEq + std::fmt::Debug,
for<'a> &'a T: Into<Xds>,
for<'a> &'a Xds: TryInto<T, Error = Error>,
{
let xds: Xds = (&v).into();
let back: T = (&xds).try_into().unwrap();
assert_eq!(v, back);
}
}