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