1use async_trait::async_trait;
32use bytes::Bytes;
33use std::collections::HashMap;
34use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
35use std::sync::Arc;
36use std::time::Duration;
37
38use crate::context::{Context, GatewayError};
39use crate::outbound::{OutboundClient, OutboundError, OutboundRequest};
40use crate::plugins::resources::PluginResources;
41use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
42use crate::vars::Expr;
43
44pub struct TrafficSplitPlugin {
47 rules: Vec<Rule>,
48 timeout: Duration,
50 client: Arc<OutboundClient>,
52}
53
54struct Rule {
55 matcher: Option<Expr>,
57 slots: Vec<Slot>,
58 total_weight: u64,
60 cursor: AtomicU64,
62}
63
64struct Slot {
65 weight: u64,
66 targets: Option<Vec<Target>>,
69 target_cursor: AtomicUsize,
71}
72
73#[derive(Debug, Clone)]
75struct Target {
76 host: String,
77 port: u16,
78}
79
80fn parse_targets(v: &serde_json::Value, field: &str) -> Result<Vec<Target>, String> {
84 let obj = v
85 .as_object()
86 .ok_or_else(|| format!("{field} must be an object"))?;
87 let targets = obj
88 .get("targets")
89 .or_else(|| obj.get("nodes"))
90 .and_then(|v| v.as_array())
91 .map(|seq| {
92 seq.iter()
93 .filter_map(|t| {
94 let m = t.as_object()?;
95 let host = m.get("host")?.as_str()?.to_string();
96 let port = m.get("port")?.as_u64()? as u16;
97 Some(Target { host, port })
98 })
99 .collect::<Vec<_>>()
100 })
101 .unwrap_or_default();
102 if targets.is_empty() {
103 return Err(format!(
104 "{field}.targets must contain at least one {{host, port}} entry"
105 ));
106 }
107 Ok(targets)
108}
109
110impl TrafficSplitPlugin {
111 pub fn from_config(
149 config: &HashMap<String, serde_json::Value>,
150 resources: &Arc<PluginResources>,
151 ) -> Result<Self, String> {
152 let raw_rules = config
153 .get("rules")
154 .and_then(|v| v.as_array())
155 .filter(|r| !r.is_empty())
156 .ok_or("traffic-split requires a non-empty 'rules' array")?;
157
158 let mut rules = Vec::with_capacity(raw_rules.len());
159 for (idx, raw) in raw_rules.iter().enumerate() {
160 let obj = raw
161 .as_object()
162 .ok_or_else(|| format!("rules[{idx}] must be an object"))?;
163
164 let matcher = match obj.get("match") {
165 None | Some(serde_json::Value::Null) => None,
166 Some(v) => Some(Expr::parse(v).map_err(|e| format!("rules[{idx}].match: {e}"))?),
167 };
168
169 let raw_slots = obj
170 .get("weighted_upstreams")
171 .and_then(|v| v.as_array())
172 .filter(|s| !s.is_empty())
173 .ok_or_else(|| {
174 format!("rules[{idx}] requires a non-empty 'weighted_upstreams' array")
175 })?;
176
177 let mut slots = Vec::with_capacity(raw_slots.len());
178 for (sidx, raw_slot) in raw_slots.iter().enumerate() {
179 let sobj = raw_slot.as_object().ok_or_else(|| {
180 format!("rules[{idx}].weighted_upstreams[{sidx}] must be an object")
181 })?;
182
183 let weight = match sobj.get("weight") {
184 None => 1,
185 Some(v) => v.as_u64().ok_or_else(|| {
186 format!(
187 "rules[{idx}].weighted_upstreams[{sidx}].weight must be a non-negative integer"
188 )
189 })?,
190 };
191
192 let targets = match sobj.get("upstream") {
193 None | Some(serde_json::Value::Null) => None,
194 Some(v) => Some(parse_targets(
195 v,
196 &format!("rules[{idx}].weighted_upstreams[{sidx}].upstream"),
197 )?),
198 };
199
200 slots.push(Slot {
201 weight,
202 targets,
203 target_cursor: AtomicUsize::new(0),
204 });
205 }
206
207 let total_weight: u64 = slots.iter().map(|s| s.weight).sum();
208 if total_weight == 0 {
209 return Err(format!(
210 "rules[{idx}] needs at least one weighted_upstream with weight > 0"
211 ));
212 }
213
214 rules.push(Rule {
215 matcher,
216 slots,
217 total_weight,
218 cursor: AtomicU64::new(0),
219 });
220 }
221
222 let timeout = Duration::from_millis(
223 config
224 .get("timeout_ms")
225 .and_then(|v| v.as_u64())
226 .unwrap_or(60_000),
227 );
228
229 Ok(Self {
230 rules,
231 timeout,
232 client: resources.outbound.clone(),
233 })
234 }
235
236 fn select_rule(&self, ctx: &Context) -> Option<&Rule> {
239 self.rules
240 .iter()
241 .find(|rule| rule.matcher.as_ref().is_none_or(|e| e.eval(ctx)))
242 }
243
244 async fn proxy_to_target(
250 &self,
251 ctx: &mut Context,
252 target: &Target,
253 ) -> Result<(), (&'static str, String)> {
254 let url = format!("http://{}:{}{}", target.host, target.port, ctx.request.path);
255 let method: http::Method = ctx.request.method.parse().unwrap_or(http::Method::GET);
256
257 let mut headers: Vec<(String, String)> = Vec::new();
258 for (key, values) in &ctx.request.headers {
259 if key.eq_ignore_ascii_case("host") {
260 continue;
261 }
262 for value in values {
263 headers.push((key.clone(), value.clone()));
264 }
265 }
266 headers.push((
267 "host".to_string(),
268 format!("{}:{}", target.host, target.port),
269 ));
270
271 let outbound = OutboundRequest {
272 method,
273 url,
274 headers,
275 body: ctx.request.body.clone(),
276 timeout: self.timeout,
277 ssl_verify: true,
278 tls: None,
279 };
280
281 match self.client.request(outbound).await {
282 Ok(resp) => {
283 ctx.response.status_code = resp.status;
284 ctx.response.headers = resp.headers;
285 ctx.response.body = resp.body;
286 Ok(())
287 }
288 Err(e) => {
289 let (code, message) = match &e {
290 OutboundError::Timeout(d) => (
291 "TRAFFIC_SPLIT_UPSTREAM_ERROR",
292 format!(
293 "traffic-split target {}:{} timed out after {:?}",
294 target.host, target.port, d
295 ),
296 ),
297 OutboundError::InvalidRequest(m) => (
298 "TRAFFIC_SPLIT_UPSTREAM_ERROR",
299 format!("traffic-split failed to build request: {m}"),
300 ),
301 OutboundError::Transport(m) => (
302 "TRAFFIC_SPLIT_UPSTREAM_ERROR",
303 format!(
304 "traffic-split failed to reach target {}:{}: {m}",
305 target.host, target.port
306 ),
307 ),
308 };
309 Err((code, message))
310 }
311 }
312 }
313}
314
315impl Rule {
316 fn pick_slot(&self) -> &Slot {
321 let mark = self.cursor.fetch_add(1, Ordering::Relaxed) % self.total_weight;
322 let mut acc = 0;
323 for slot in &self.slots {
324 acc += slot.weight;
325 if mark < acc {
326 return slot;
327 }
328 }
329 self.slots.last().expect("slots is non-empty")
331 }
332}
333
334impl Slot {
335 fn pick_target<'a>(&self, targets: &'a [Target]) -> &'a Target {
338 let idx = self.target_cursor.fetch_add(1, Ordering::Relaxed) % targets.len();
339 &targets[idx]
340 }
341}
342
343#[async_trait]
344impl Plugin for TrafficSplitPlugin {
345 fn plugin_type(&self) -> &str {
346 "traffic-split"
347 }
348
349 async fn execute(&self, mut ctx: Context) -> PluginResult {
350 let rule = match self.select_rule(&ctx) {
352 Some(rule) => rule,
353 None => return Ok(PluginOutput::success(ctx)),
354 };
355
356 let slot = rule.pick_slot();
357 let targets = match &slot.targets {
358 None => return Ok(PluginOutput::success(ctx)),
360 Some(targets) => targets,
361 };
362
363 let target = slot.pick_target(targets).clone();
365 match self.proxy_to_target(&mut ctx, &target).await {
366 Ok(()) => Ok(PluginOutput::on_port(ctx, "routed")),
367 Err((code, message)) => {
368 ctx.response.status_code = 502;
370 ctx.response.body = Bytes::from(
371 r#"{"error": "bad_gateway", "message": "traffic-split target unreachable"}"#,
372 );
373 ctx.response.headers.insert(
374 "content-type".to_string(),
375 vec!["application/json".to_string()],
376 );
377 Err(PluginExecutionError {
378 context: ctx,
379 error: GatewayError {
380 node_id: String::new(),
381 code: code.to_string(),
382 message,
383 metadata: HashMap::new(),
384 },
385 })
386 }
387 }
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
395
396 fn test_ctx(canary: Option<&str>) -> Context {
397 let mut query = HashMap::new();
398 if let Some(c) = canary {
399 query.insert("canary".to_string(), vec![c.to_string()]);
400 }
401 Context {
402 request: GatewayRequest {
403 method: "GET".to_string(),
404 path: "/api".to_string(),
405 host: "example.com".to_string(),
406 scheme: "http".to_string(),
407 headers: HashMap::new(),
408 query_params: query,
409 body: Bytes::new(),
410 remote_addr: "10.1.2.3:44321".to_string(),
411 protocol: Protocol::Http1,
412 },
413 response: GatewayResponse {
414 status_code: 0,
415 headers: HashMap::new(),
416 body: Bytes::new(),
417 stream: None,
418 },
419 message: HashMap::new(),
420 errors: Vec::new(),
421 }
422 }
423
424 fn plugin(config: serde_json::Value) -> Result<TrafficSplitPlugin, String> {
425 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
426 TrafficSplitPlugin::from_config(&map, &PluginResources::empty())
427 }
428
429 #[test]
430 fn test_select_rule_matches_first_passing() {
431 let p = plugin(serde_json::json!({
432 "rules": [
433 {
434 "match": [["arg_canary", "==", "1"]],
435 "weighted_upstreams": [{ "weight": 1 }]
436 },
437 {
438 "weighted_upstreams": [{ "weight": 1 }]
439 }
440 ]
441 }))
442 .unwrap();
443
444 let ctx = test_ctx(Some("1"));
446 assert!(std::ptr::eq(p.select_rule(&ctx).unwrap(), &p.rules[0]));
447 let ctx = test_ctx(None);
449 assert!(std::ptr::eq(p.select_rule(&ctx).unwrap(), &p.rules[1]));
450 }
451
452 #[test]
453 fn test_no_rule_matches_returns_none() {
454 let p = plugin(serde_json::json!({
455 "rules": [{
456 "match": [["arg_canary", "==", "1"]],
457 "weighted_upstreams": [{ "weight": 1 }]
458 }]
459 }))
460 .unwrap();
461 assert!(p.select_rule(&test_ctx(Some("0"))).is_none());
462 }
463
464 #[test]
465 fn test_weighted_round_robin_distribution() {
466 let p = plugin(serde_json::json!({
469 "rules": [{
470 "weighted_upstreams": [
471 { "weight": 3 },
472 { "upstream": { "targets": [{ "host": "canary", "port": 80 }] }, "weight": 1 }
473 ]
474 }]
475 }))
476 .unwrap();
477 let rule = &p.rules[0];
478
479 let mut default_hits = 0;
480 let mut target_hits = 0;
481 for _ in 0..8 {
482 match &rule.pick_slot().targets {
483 None => default_hits += 1,
484 Some(_) => target_hits += 1,
485 }
486 }
487 assert_eq!(default_hits, 6);
488 assert_eq!(target_hits, 2);
489 }
490
491 #[test]
492 fn test_pick_target_round_robins_within_set() {
493 let p = plugin(serde_json::json!({
494 "rules": [{
495 "weighted_upstreams": [{
496 "upstream": { "targets": [
497 { "host": "a", "port": 80 },
498 { "host": "b", "port": 80 }
499 ] },
500 "weight": 1
501 }]
502 }]
503 }))
504 .unwrap();
505 let slot = &p.rules[0].slots[0];
506 let targets = slot.targets.as_ref().unwrap();
507 let picks: Vec<&str> = (0..4)
508 .map(|_| slot.pick_target(targets).host.as_str())
509 .collect();
510 assert_eq!(picks, vec!["a", "b", "a", "b"]);
511 }
512
513 #[tokio::test]
514 async fn test_default_slot_returns_ok_passthrough() {
515 let p = plugin(serde_json::json!({
518 "rules": [{ "weighted_upstreams": [{ "weight": 1 }] }]
519 }))
520 .unwrap();
521 let out = p.execute(test_ctx(None)).await.unwrap();
522 assert!(out.port.is_none());
523 assert_eq!(out.context.response.status_code, 0);
524 }
525
526 #[tokio::test]
527 async fn test_no_match_returns_ok_passthrough() {
528 let p = plugin(serde_json::json!({
529 "rules": [{
530 "match": [["arg_canary", "==", "yes"]],
531 "weighted_upstreams": [{
532 "upstream": { "targets": [{ "host": "canary", "port": 80 }] },
533 "weight": 1
534 }]
535 }]
536 }))
537 .unwrap();
538 let out = p.execute(test_ctx(Some("no"))).await.unwrap();
540 assert!(out.port.is_none());
541 assert_eq!(out.context.response.status_code, 0);
542 }
543
544 async fn spawn_status_server(status_line: &'static str, body: &'static str) -> u16 {
547 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
548 let port = listener.local_addr().unwrap().port();
549 tokio::spawn(async move {
550 if let Ok((mut stream, _)) = listener.accept().await {
551 use tokio::io::{AsyncReadExt, AsyncWriteExt};
552 let mut buf = [0u8; 4096];
553 let _ = stream.read(&mut buf).await;
554 let _ = stream
555 .write_all(
556 format!(
557 "HTTP/1.1 {status_line}\r\ncontent-length: {}\r\n\r\n{}",
558 body.len(),
559 body
560 )
561 .as_bytes(),
562 )
563 .await;
564 let _ = stream.shutdown().await;
565 }
566 });
567 port
568 }
569
570 #[tokio::test]
574 async fn test_target_slot_proxies_and_exits_on_routed_port() {
575 let port = spawn_status_server("200 OK", "canary-response").await;
576 let resources = PluginResources::empty();
577 let mut config: HashMap<String, serde_json::Value> =
578 serde_json::from_value(serde_json::json!({
579 "rules": [{
580 "weighted_upstreams": [{
581 "upstream": { "targets": [{ "host": "127.0.0.1", "port": port }] },
582 "weight": 1
583 }]
584 }]
585 }))
586 .unwrap();
587 config.insert("timeout_ms".to_string(), serde_json::json!(2000));
588 let p = TrafficSplitPlugin::from_config(&config, &resources).unwrap();
589
590 let out = p.execute(test_ctx(None)).await.unwrap();
591 assert_eq!(out.port, Some("routed"));
592 assert_eq!(out.context.response.status_code, 200);
593 assert_eq!(
594 out.context.response.body,
595 Bytes::from_static(b"canary-response")
596 );
597 }
598
599 #[tokio::test]
602 async fn test_target_unreachable_stays_err() {
603 let resources = PluginResources::empty();
604 let mut config: HashMap<String, serde_json::Value> =
605 serde_json::from_value(serde_json::json!({
606 "rules": [{
607 "weighted_upstreams": [{
608 "upstream": { "targets": [{ "host": "127.0.0.1", "port": 1 }] },
609 "weight": 1
610 }]
611 }]
612 }))
613 .unwrap();
614 config.insert("timeout_ms".to_string(), serde_json::json!(500));
615 let p = TrafficSplitPlugin::from_config(&config, &resources).unwrap();
616
617 let err = p.execute(test_ctx(None)).await.unwrap_err();
618 assert_eq!(err.error.code, "TRAFFIC_SPLIT_UPSTREAM_ERROR");
619 assert_eq!(err.context.response.status_code, 502);
620 }
621
622 #[test]
623 fn test_target_slot_prepares_proxy_path() {
624 let p = plugin(serde_json::json!({
627 "rules": [{
628 "weighted_upstreams": [{
629 "upstream": { "targets": [{ "host": "canary-backend", "port": 8080 }] },
630 "weight": 1
631 }]
632 }]
633 }))
634 .unwrap();
635 let slot = p.rules[0].pick_slot();
636 let targets = slot.targets.as_ref().expect("target slot");
637 let target = slot.pick_target(targets);
638 assert_eq!(target.host, "canary-backend");
639 assert_eq!(target.port, 8080);
640 }
641
642 #[test]
643 fn test_config_errors() {
644 assert!(plugin(serde_json::json!({})).is_err());
646 assert!(plugin(serde_json::json!({ "rules": [] })).is_err());
647 assert!(plugin(serde_json::json!({
649 "rules": [{ "weighted_upstreams": [] }]
650 }))
651 .is_err());
652 assert!(plugin(serde_json::json!({
654 "rules": [{ "weighted_upstreams": [{ "weight": 0 }] }]
655 }))
656 .is_err());
657 assert!(plugin(serde_json::json!({
659 "rules": [{ "weighted_upstreams": [{ "weight": -1 }] }]
660 }))
661 .is_err());
662 assert!(plugin(serde_json::json!({
664 "rules": [{ "weighted_upstreams": [{ "upstream": {}, "weight": 1 }] }]
665 }))
666 .is_err());
667 assert!(plugin(serde_json::json!({
669 "rules": [{
670 "match": [["uri", "bogus", "/x"]],
671 "weighted_upstreams": [{ "weight": 1 }]
672 }]
673 }))
674 .is_err());
675 }
676}