diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index 30f657b..1531c47 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -228,6 +228,8 @@ engine.compare.no_operator engine.duplicate_group engine.duplicate_route engine.empty_group +engine.group_unknown_upstream +engine.group_upstream_twice engine.no_action engine.no_match engine.phase_two_with_to diff --git a/crates/tw-config/src/validate.rs b/crates/tw-config/src/validate.rs index 20c263a..a4a05ff 100644 --- a/crates/tw-config/src/validate.rs +++ b/crates/tw-config/src/validate.rs @@ -778,6 +778,42 @@ mod tests { assert!(e.to_string().contains("__all__"), "{e}"); } + /// 手写的配置里一个组把同一家写了几遍:加载时就拒绝。控制面保存时本来就拦着, + /// 拦不着的是手改的文件 —— 那时候选不去重,故障转移会把同一家再试几遍 + #[test] + fn a_group_member_written_twice_or_misspelled_is_refused_at_load_time() { + let text = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 +clients: + - name: c + key: tw-k +providers: + - name: a + base_url: https://a.example + key: sk-a + - name: b + base_url: https://b.example + key: sk-b +groups: + - name: pool + type: load-balance + providers: [a, a, a, b] +"; + let m = crate::try_parse(text).unwrap_err().msg(); + assert_eq!(m.code, "engine.group_upstream_twice", "{m:?}"); + assert_eq!((m.arg("group"), m.arg("upstream")), ("pool", "a")); + // 每家写一次就收下 + let once = text.replace("[a, a, a, b]", "[a, b]"); + assert!(crate::try_parse(&once).is_ok(), "{once}"); + // 写了一个不是上游的名字也拒绝 + let typo = text.replace("[a, a, a, b]", "[a, typo]"); + let m = crate::try_parse(&typo).unwrap_err().msg(); + assert_eq!(m.code, "engine.group_unknown_upstream", "{m:?}"); + assert_eq!((m.arg("group"), m.arg("upstream")), ("pool", "typo")); + } + #[test] fn error_messages_say_what_to_do_next() { // 错误信息是降低使用难度最有效的杠杆。判据不是「说清 diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index dd0838e..51169a9 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -1236,7 +1236,10 @@ pub fn sections() -> Vec
{ "providers", Kind::Strs, Def::Required, - t("Member upstreams, by name.", "成员上游的名字。"), + t( + "Member upstreams, by name; not groups. Each upstream appears once in a group.", + "成员上游的名字,不能是策略组。同一个上游在一个策略组中只出现一次。", + ), ), row( "selected", diff --git a/crates/tw-engine/src/engine.rs b/crates/tw-engine/src/engine.rs index 8880001..1bab7be 100644 --- a/crates/tw-engine/src/engine.rs +++ b/crates/tw-engine/src/engine.rs @@ -555,6 +555,10 @@ pub enum RouteError { #[error("{}", self.msg())] DuplicateGroup(String), #[error("{}", self.msg())] + GroupUpstreamTwice { group: String, provider: String }, + #[error("{}", self.msg())] + GroupUnknownUpstream { group: String, provider: String }, + #[error("{}", self.msg())] DuplicateRoute(String), #[error("{}", self.msg())] UnknownDefaultRoute(String), @@ -606,6 +610,16 @@ impl RouteError { "there is more than one group named `{group}`. Rules refer to a group by name, so \ names have to be unique" ), + RouteError::GroupUpstreamTwice { group, provider } => msg!( + "engine.group_upstream_twice", group = group, upstream = provider => + "group `{group}` lists upstream `{upstream}` more than once. Each upstream appears \ + once in a group" + ), + RouteError::GroupUnknownUpstream { group, provider } => msg!( + "engine.group_unknown_upstream", group = group, upstream = provider => + "group `{group}` lists `{upstream}`, which is not an upstream. A group's members are \ + upstreams, by name" + ), RouteError::DuplicateRoute(route) => msg!( "engine.duplicate_route", route = route => "there is more than one route named `{route}`. A gateway key binds to a route by \ @@ -876,6 +890,26 @@ impl Engine { if g.providers.is_empty() { return Err(RouteError::EmptyGroup(g.name.clone())); } + // 同一家写两次:候选不去重,那一家失败之后故障转移会再试它一遍;`load-balance` + // 还会多轮到它几次 —— 一个没人写下、也看不出来的权重。控制面保存时就拦着 + // (`control.group.upstream_twice`),手写的配置在这里拦 + let mut members = std::collections::HashSet::new(); + for p in &g.providers { + // 不认识的名字(拼错了、或者写了另一个组):候选里它对不上任何上游, + // 这一位就静默地没了。控制面保存时拦着(`control.group.no_such_upstream`) + if !self.providers.contains(p) { + return Err(RouteError::GroupUnknownUpstream { + group: g.name.clone(), + provider: p.clone(), + }); + } + if !members.insert(p.as_str()) { + return Err(RouteError::GroupUpstreamTwice { + group: g.name.clone(), + provider: p.clone(), + }); + } + } } for set in &self.sets { self.check_rules(&set.rules)?; @@ -2228,6 +2262,71 @@ mod builtin_tests { ); assert_eq!(e.validate(), Err(RouteError::DuplicateGroup("pool".into()))); } + + /// 手写的配置里同一家写了几遍:拒绝,说出是哪个组、哪一家。不拦的话候选里它出现 + /// 几次,故障转移就试它几次,`load-balance` 也多轮到它几次 + #[test] + fn a_group_that_lists_an_upstream_twice_is_rejected() { + let g: Group = + serde_yaml_ng::from_str("name: pool\ntype: load-balance\nproviders: [a, a, a, b]\n") + .unwrap(); + let e = Engine::with_default_rules( + vec!["a".into(), "b".into()], + vec![g], + vec![rule("兜底", "{}", "pool")], + ); + let err = e.validate().unwrap_err(); + assert_eq!( + err, + RouteError::GroupUpstreamTwice { + group: "pool".into(), + provider: "a".into(), + } + ); + let m = err.msg(); + assert_eq!(m.code, "engine.group_upstream_twice"); + assert_eq!((m.arg("group"), m.arg("upstream")), ("pool", "a")); + // 每种类型都一样:不只是 `load-balance` 会多轮到它,故障转移也会再试它 + let g = Group { + name: "pool".into(), + kind: GroupType::Fallback, + providers: vec!["a".into(), "b".into(), "a".into()], + selected: None, + }; + let e = Engine::with_default_rules( + vec!["a".into(), "b".into()], + vec![g], + vec![rule("兜底", "{}", "pool")], + ); + assert!(matches!( + e.validate(), + Err(RouteError::GroupUpstreamTwice { .. }) + )); + } + + /// 组里写了一个不是上游的名字(拼错了,或者写了另一个组):拒绝,说出是哪个组、 + /// 哪个名字。不拦的话候选里对不上它,组静默地少了一位 + #[test] + fn a_group_member_that_is_not_an_upstream_is_rejected() { + let g: Group = + serde_yaml_ng::from_str("name: pool\ntype: fallback\nproviders: [a, typo]\n").unwrap(); + let e = Engine::with_default_rules( + vec!["a".into(), "b".into()], + vec![g], + vec![rule("兜底", "{}", "pool")], + ); + let err = e.validate().unwrap_err(); + assert_eq!( + err, + RouteError::GroupUnknownUpstream { + group: "pool".into(), + provider: "typo".into(), + } + ); + let m = err.msg(); + assert_eq!(m.code, "engine.group_unknown_upstream"); + assert_eq!((m.arg("group"), m.arg("upstream")), ("pool", "typo")); + } } #[cfg(test)] @@ -2673,6 +2772,14 @@ mod msg_codes { }, RouteError::EmptyGroup("g".into()), RouteError::DuplicateGroup("g".into()), + RouteError::GroupUpstreamTwice { + group: "g".into(), + provider: "p".into(), + }, + RouteError::GroupUnknownUpstream { + group: "g".into(), + provider: "p".into(), + }, RouteError::DuplicateRoute("x".into()), RouteError::UnknownDefaultRoute("x".into()), RouteError::UnknownRoute { diff --git a/docs/config.md b/docs/config.md index 9066764..ff747ca 100644 --- a/docs/config.md +++ b/docs/config.md @@ -953,7 +953,7 @@ group with `to`. |---|---|---|---| | `name` | string | **required** | Name of the group; unique, and not the name of an upstream. | | `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`: the first healthy member, in order. `select`: the member named in `selected`. `load-balance`: take turns between new conversations. `url-test`: the fastest by measured time to first byte. `cheapest`: the lowest input price. | -| `providers` | list of strings | **required** | Member upstreams, by name. | +| `providers` | list of strings | **required** | Member upstreams, by name; not groups. Each upstream appears once in a group. | | `selected` | string | — | For `select`: the chosen member. | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index a0b9ad7..deac482 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -748,7 +748,7 @@ aliases: |---|---|---|---| | `name` | 字符串 | **必填** | 策略组的名字,不能重复,也不能和上游同名。 | | `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`:按顺序取第一个健康的。`select`:取 `selected` 指定的那个。`load-balance`:新对话轮流。`url-test`:按实测首字节时间取最快的。`cheapest`:取输入单价最低的。 | -| `providers` | 字符串列表 | **必填** | 成员上游的名字。 | +| `providers` | 字符串列表 | **必填** | 成员上游的名字,不能是策略组。同一个上游在一个策略组中只出现一次。 | | `selected` | 字符串 | — | `select` 类型选中的成员。 |