1use std::collections::HashMap;
2use std::sync::Arc;
3
4use eggress_config::compile::{
5 AdminConfig, GroupFallback, ListenerConfig, RuntimeConfig, UpstreamConfig,
6};
7use eggress_routing::upstream::{UpstreamGroup, UpstreamRuntime};
8use eggress_routing::{RouteActionSpec, Router};
9
10pub struct CompiledRuntimeSnapshot {
11 pub generation: u64,
12 pub upstreams: HashMap<String, Arc<UpstreamRuntime>>,
13 pub router: Arc<Router>,
14 pub health_config: eggress_routing::health::HealthConfig,
15 pub listeners: Vec<ListenerConfig>,
16 pub admin: Option<AdminConfig>,
17 pub reverse_servers: Vec<eggress_config::compile::CompiledReverseServerConfig>,
18 pub reverse_clients: Vec<eggress_config::compile::CompiledReverseClientConfig>,
19}
20
21fn upstream_runtime_compatible(old: &UpstreamRuntime, new: &UpstreamConfig) -> bool {
24 *old.chain == new.chain && old.health_config == new.health
25}
26
27pub fn compile_runtime_snapshot(
32 rt: &RuntimeConfig,
33 previous: Option<&CompiledRuntimeSnapshot>,
34) -> Result<CompiledRuntimeSnapshot, Box<dyn std::error::Error + Send + Sync>> {
35 let empty_map = HashMap::new();
36 let previous_upstreams = previous.map(|p| &p.upstreams).unwrap_or(&empty_map);
37
38 let mut upstreams: HashMap<String, Arc<UpstreamRuntime>> = HashMap::new();
39
40 for u in &rt.upstreams {
41 let runtime = if let Some(existing) = previous_upstreams.get(&u.id) {
42 if upstream_runtime_compatible(existing, u) {
43 existing.clone()
44 } else {
45 build_one_upstream_runtime(u)
46 }
47 } else {
48 build_one_upstream_runtime(u)
49 };
50 upstreams.insert(u.id.clone(), runtime);
51 }
52
53 let group_ids: std::collections::HashSet<_> = rt.groups.iter().map(|g| g.id.clone()).collect();
54
55 let mut groups = Vec::new();
56 for g in &rt.groups {
57 let mut members = Vec::new();
58 for m in &g.members {
59 let member = upstreams
60 .get(m)
61 .ok_or_else(|| format!("group '{}' references unknown upstream '{}'", g.id, m))?;
62 members.push(member.clone());
63 }
64 if members.is_empty() {
65 return Err(format!("group '{}' has no valid members", g.id).into());
66 }
67
68 let fallback = match g.fallback {
69 GroupFallback::Reject => eggress_routing::upstream::GroupFallback::Reject,
70 GroupFallback::Direct => eggress_routing::upstream::GroupFallback::Direct,
71 GroupFallback::UseUnhealthy => eggress_routing::upstream::GroupFallback::UseUnhealthy,
72 };
73
74 groups.push((
75 g.id.clone(),
76 UpstreamGroup::new(g.id.clone(), g.scheduler, Arc::from(members), fallback),
77 ));
78 }
79
80 let mut rules = Vec::new();
81 for r in &rt.rules {
82 let action = match &r.action {
83 RouteActionSpec::Direct => RouteActionSpec::Direct,
84 RouteActionSpec::UpstreamGroup(gid) => {
85 if !group_ids.contains(gid) {
86 return Err(
87 format!("rule '{}' references unknown group '{}'", r.id, gid).into(),
88 );
89 }
90 RouteActionSpec::UpstreamGroup(gid.clone())
91 }
92 RouteActionSpec::Reject(reason) => RouteActionSpec::Reject(reason.clone()),
93 };
94 rules.push(eggress_routing::CompiledRule {
95 id: r.id.clone(),
96 matcher: r.matcher.clone(),
97 action,
98 });
99 }
100
101 let router = Router::with_groups(rules, rt.default_action.clone(), groups);
102 let gen = previous.map(|p| p.generation + 1).unwrap_or(0);
103
104 Ok(CompiledRuntimeSnapshot {
105 generation: gen,
106 upstreams,
107 router: Arc::new(router),
108 health_config: eggress_routing::health::HealthConfig::default(),
109 listeners: rt.listeners.clone(),
110 admin: rt.admin.clone(),
111 reverse_servers: rt.reverse_servers.clone(),
112 reverse_clients: rt.reverse_clients.clone(),
113 })
114}
115
116fn build_one_upstream_runtime(u: &UpstreamConfig) -> Arc<UpstreamRuntime> {
117 let id = eggress_core::UpstreamId::new(u.id.clone());
118 let mut runtime =
119 UpstreamRuntime::new(id, u.chain.clone()).with_health_config(u.health.clone());
120
121 if let Some(first_hop) = u.chain.hops.first() {
122 let addr: Result<std::net::SocketAddr, _> =
123 format!("{}:{}", first_hop.endpoint.host, first_hop.endpoint.port).parse();
124 if let Ok(addr) = addr {
125 runtime = runtime.with_health_probe(eggress_routing::health::HealthProbe::TcpConnect {
126 target: addr,
127 timeout: u.health.timeout,
128 });
129 }
130 }
131
132 Arc::new(runtime)
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use eggress_config::compile::{
139 GroupFallback, ProcessConfig, RuntimeConfig, TimeoutConfig, UpstreamConfig,
140 };
141 use eggress_routing::scheduler::SchedulerKind;
142 use eggress_routing::UpstreamGroupId;
143 use eggress_uri::ProxyChainSpec;
144 use std::time::Duration;
145
146 fn default_health() -> eggress_routing::health::HealthConfig {
147 eggress_routing::health::HealthConfig::default()
148 }
149
150 fn empty_config() -> RuntimeConfig {
151 RuntimeConfig {
152 process: ProcessConfig::default(),
153 timeouts: TimeoutConfig::default(),
154 listeners: vec![],
155 upstreams: vec![],
156 groups: vec![],
157 rules: vec![],
158 default_action: RouteActionSpec::Direct,
159 admin: None,
160 reverse_servers: vec![],
161 reverse_clients: vec![],
162 }
163 }
164
165 #[test]
166 fn snapshot_empty_config() {
167 let snap = compile_runtime_snapshot(&empty_config(), None).unwrap();
168 assert_eq!(snap.generation, 0);
169 assert!(snap.upstreams.is_empty());
170 assert!(snap.router.rules().is_empty());
171 }
172
173 #[test]
174 fn snapshot_single_upstream() {
175 let mut cfg = empty_config();
176 cfg.upstreams = vec![UpstreamConfig {
177 id: "proxy1".to_string(),
178 chain: ProxyChainSpec { hops: vec![] },
179 health: default_health(),
180 h2: None,
181 }];
182 let snap = compile_runtime_snapshot(&cfg, None).unwrap();
183 assert_eq!(snap.upstreams.len(), 1);
184 assert!(snap.upstreams.contains_key("proxy1"));
185 }
186
187 #[test]
188 fn snapshot_group_uses_shared_upstream_arc() {
189 let mut cfg = empty_config();
190 cfg.upstreams = vec![UpstreamConfig {
191 id: "proxy1".to_string(),
192 chain: ProxyChainSpec { hops: vec![] },
193 health: default_health(),
194 h2: None,
195 }];
196 cfg.groups = vec![eggress_config::compile::UpstreamGroupConfig {
197 id: UpstreamGroupId(Arc::from("main")),
198 scheduler: SchedulerKind::RoundRobin,
199 members: vec!["proxy1".to_string()],
200 fallback: GroupFallback::Reject,
201 }];
202 let snap = compile_runtime_snapshot(&cfg, None).unwrap();
203 let upstream_arc = snap.upstreams.get("proxy1").unwrap();
204 let group = snap
205 .router
206 .groups()
207 .get(&UpstreamGroupId(Arc::from("main")))
208 .unwrap();
209 let group_member = &group.members[0];
210 assert!(Arc::ptr_eq(upstream_arc, group_member));
211 }
212
213 #[test]
214 fn unchanged_upstream_retains_arc_identity_after_reload() {
215 let mut cfg = empty_config();
216 cfg.upstreams = vec![UpstreamConfig {
217 id: "proxy1".to_string(),
218 chain: ProxyChainSpec { hops: vec![] },
219 health: default_health(),
220 h2: None,
221 }];
222 let snap1 = compile_runtime_snapshot(&cfg, None).unwrap();
223 let original_arc = snap1.upstreams.get("proxy1").unwrap().clone();
224
225 let snap2 = compile_runtime_snapshot(&cfg, Some(&snap1)).unwrap();
226 let reused_arc = snap2.upstreams.get("proxy1").unwrap().clone();
227
228 assert!(Arc::ptr_eq(&original_arc, &reused_arc));
229 }
230
231 #[test]
232 fn changed_upstream_gets_fresh_arc() {
233 let mut cfg1 = empty_config();
234 cfg1.upstreams = vec![UpstreamConfig {
235 id: "proxy1".to_string(),
236 chain: ProxyChainSpec { hops: vec![] },
237 health: default_health(),
238 h2: None,
239 }];
240 let snap1 = compile_runtime_snapshot(&cfg1, None).unwrap();
241 let original_arc = snap1.upstreams.get("proxy1").unwrap().clone();
242
243 let mut cfg2 = empty_config();
244 cfg2.upstreams = vec![UpstreamConfig {
245 id: "proxy1".to_string(),
246 chain: ProxyChainSpec {
247 hops: vec![eggress_uri::ProxyHopSpec {
248 protocols: vec![eggress_uri::ProtocolSpec::Socks5],
249 endpoint: eggress_uri::EndpointSpec {
250 host: "newhost".to_string(),
251 port: 1080,
252 },
253 credentials: None,
254 rule: None,
255 local_bind: None,
256 tls: false,
257 server_name: None,
258 insecure: false,
259 plugins: Vec::new(),
260 auth_prefix: None,
261 }],
262 },
263 health: default_health(),
264 h2: None,
265 }];
266 let snap2 = compile_runtime_snapshot(&cfg2, Some(&snap1)).unwrap();
267 let new_arc = snap2.upstreams.get("proxy1").unwrap().clone();
268
269 assert!(!Arc::ptr_eq(&original_arc, &new_arc));
270 }
271
272 #[test]
273 fn no_duplicate_upstream_runtime_objects_for_one_id() {
274 let mut cfg = empty_config();
275 cfg.upstreams = vec![UpstreamConfig {
276 id: "proxy1".to_string(),
277 chain: ProxyChainSpec { hops: vec![] },
278 health: default_health(),
279 h2: None,
280 }];
281 cfg.groups = vec![eggress_config::compile::UpstreamGroupConfig {
282 id: UpstreamGroupId(Arc::from("main")),
283 scheduler: SchedulerKind::RoundRobin,
284 members: vec!["proxy1".to_string()],
285 fallback: GroupFallback::Reject,
286 }];
287 let snap = compile_runtime_snapshot(&cfg, None).unwrap();
288 let upstream_arc = snap.upstreams.get("proxy1").unwrap();
289 let group = snap
290 .router
291 .groups()
292 .get(&UpstreamGroupId(Arc::from("main")))
293 .unwrap();
294 let group_member = &group.members[0];
295
296 assert!(Arc::ptr_eq(upstream_arc, group_member));
297 }
298
299 #[test]
300 fn generation_increments_on_reload() {
301 let cfg = empty_config();
302 let snap1 = compile_runtime_snapshot(&cfg, None).unwrap();
303 assert_eq!(snap1.generation, 0);
304 let snap2 = compile_runtime_snapshot(&cfg, Some(&snap1)).unwrap();
305 assert_eq!(snap2.generation, 1);
306 let snap3 = compile_runtime_snapshot(&cfg, Some(&snap2)).unwrap();
307 assert_eq!(snap3.generation, 2);
308 }
309
310 #[test]
311 fn group_references_unknown_upstream() {
312 let mut cfg = empty_config();
313 cfg.groups = vec![eggress_config::compile::UpstreamGroupConfig {
314 id: UpstreamGroupId(Arc::from("main")),
315 scheduler: SchedulerKind::RoundRobin,
316 members: vec!["nonexistent".to_string()],
317 fallback: GroupFallback::Reject,
318 }];
319 let result = compile_runtime_snapshot(&cfg, None);
320 assert!(result.is_err());
321 let err_msg = result.err().unwrap().to_string();
322 assert!(err_msg.contains("nonexistent"));
323 }
324
325 #[test]
326 fn rule_references_unknown_group() {
327 let mut cfg = empty_config();
328 cfg.rules = vec![eggress_routing::CompiledRule {
329 id: eggress_routing::RuleId(Arc::from("r1")),
330 matcher: eggress_routing::MatchExpr::Any,
331 action: RouteActionSpec::UpstreamGroup(UpstreamGroupId(Arc::from("missing"))),
332 }];
333 let result = compile_runtime_snapshot(&cfg, None);
334 assert!(result.is_err());
335 let err_msg = result.err().unwrap().to_string();
336 assert!(err_msg.contains("missing"));
337 }
338
339 #[test]
340 fn multiple_upstreams_all_shared() {
341 let mut cfg = empty_config();
342 cfg.upstreams = vec![
343 UpstreamConfig {
344 id: "p1".to_string(),
345 chain: ProxyChainSpec { hops: vec![] },
346 health: default_health(),
347 h2: None,
348 },
349 UpstreamConfig {
350 id: "p2".to_string(),
351 chain: ProxyChainSpec { hops: vec![] },
352 health: default_health(),
353 h2: None,
354 },
355 ];
356 cfg.groups = vec![eggress_config::compile::UpstreamGroupConfig {
357 id: UpstreamGroupId(Arc::from("grp")),
358 scheduler: SchedulerKind::RoundRobin,
359 members: vec!["p1".to_string(), "p2".to_string()],
360 fallback: GroupFallback::Reject,
361 }];
362 let snap = compile_runtime_snapshot(&cfg, None).unwrap();
363 let group = snap
364 .router
365 .groups()
366 .get(&UpstreamGroupId(Arc::from("grp")))
367 .unwrap();
368 assert!(Arc::ptr_eq(
369 snap.upstreams.get("p1").unwrap(),
370 &group.members[0]
371 ));
372 assert!(Arc::ptr_eq(
373 snap.upstreams.get("p2").unwrap(),
374 &group.members[1]
375 ));
376 }
377
378 #[test]
379 fn changed_health_config_gets_fresh_arc() {
380 let mut cfg1 = empty_config();
381 cfg1.upstreams = vec![UpstreamConfig {
382 id: "proxy1".to_string(),
383 chain: ProxyChainSpec { hops: vec![] },
384 health: eggress_routing::health::HealthConfig {
385 failures_to_unhealthy: 3,
386 ..default_health()
387 },
388 h2: None,
389 }];
390 let snap1 = compile_runtime_snapshot(&cfg1, None).unwrap();
391 let original_arc = snap1.upstreams.get("proxy1").unwrap().clone();
392
393 let mut cfg2 = empty_config();
394 cfg2.upstreams = vec![UpstreamConfig {
395 id: "proxy1".to_string(),
396 chain: ProxyChainSpec { hops: vec![] },
397 health: eggress_routing::health::HealthConfig {
398 failures_to_unhealthy: 1,
399 ..default_health()
400 },
401 h2: None,
402 }];
403 let snap2 = compile_runtime_snapshot(&cfg2, Some(&snap1)).unwrap();
404 let new_arc = snap2.upstreams.get("proxy1").unwrap().clone();
405
406 assert!(
407 !Arc::ptr_eq(&original_arc, &new_arc),
408 "changing health config should produce a fresh upstream runtime ARC"
409 );
410 }
411
412 #[test]
413 fn unchanged_health_config_retains_arc_identity() {
414 let mut cfg1 = empty_config();
415 cfg1.upstreams = vec![UpstreamConfig {
416 id: "proxy1".to_string(),
417 chain: ProxyChainSpec { hops: vec![] },
418 health: eggress_routing::health::HealthConfig {
419 failures_to_unhealthy: 2,
420 successes_to_healthy: 1,
421 interval: Duration::from_secs(10),
422 timeout: Duration::from_secs(2),
423 initial_state: eggress_routing::health::HealthState::Unknown,
424 },
425 h2: None,
426 }];
427 let snap1 = compile_runtime_snapshot(&cfg1, None).unwrap();
428 let original_arc = snap1.upstreams.get("proxy1").unwrap().clone();
429
430 let mut cfg2 = empty_config();
431 cfg2.upstreams = vec![UpstreamConfig {
432 id: "proxy1".to_string(),
433 chain: ProxyChainSpec { hops: vec![] },
434 health: eggress_routing::health::HealthConfig {
435 failures_to_unhealthy: 2,
436 successes_to_healthy: 1,
437 interval: Duration::from_secs(10),
438 timeout: Duration::from_secs(2),
439 initial_state: eggress_routing::health::HealthState::Unknown,
440 },
441 h2: None,
442 }];
443 let snap2 = compile_runtime_snapshot(&cfg2, Some(&snap1)).unwrap();
444 let reused_arc = snap2.upstreams.get("proxy1").unwrap().clone();
445
446 assert!(
447 Arc::ptr_eq(&original_arc, &reused_arc),
448 "identical health config should retain the upstream runtime ARC"
449 );
450 }
451
452 #[test]
453 fn partial_upstream_change_preserves_others() {
454 let mut cfg1 = empty_config();
455 cfg1.upstreams = vec![
456 UpstreamConfig {
457 id: "p1".to_string(),
458 chain: ProxyChainSpec { hops: vec![] },
459 health: default_health(),
460 h2: None,
461 },
462 UpstreamConfig {
463 id: "p2".to_string(),
464 chain: ProxyChainSpec { hops: vec![] },
465 health: default_health(),
466 h2: None,
467 },
468 ];
469 let snap1 = compile_runtime_snapshot(&cfg1, None).unwrap();
470 let p1_original = snap1.upstreams.get("p1").unwrap().clone();
471 let p2_original = snap1.upstreams.get("p2").unwrap().clone();
472
473 let mut cfg2 = empty_config();
474 cfg2.upstreams = vec![
475 UpstreamConfig {
476 id: "p1".to_string(),
477 chain: ProxyChainSpec { hops: vec![] },
478 health: default_health(),
479 h2: None,
480 },
481 UpstreamConfig {
482 id: "p2".to_string(),
483 chain: ProxyChainSpec {
484 hops: vec![eggress_uri::ProxyHopSpec {
485 protocols: vec![eggress_uri::ProtocolSpec::Http],
486 endpoint: eggress_uri::EndpointSpec {
487 host: "changed".to_string(),
488 port: 8080,
489 },
490 credentials: None,
491 rule: None,
492 local_bind: None,
493 tls: false,
494 server_name: None,
495 insecure: false,
496 plugins: Vec::new(),
497 auth_prefix: None,
498 }],
499 },
500 health: default_health(),
501 h2: None,
502 },
503 ];
504 let snap2 = compile_runtime_snapshot(&cfg2, Some(&snap1)).unwrap();
505
506 assert!(Arc::ptr_eq(
507 &p1_original,
508 snap2.upstreams.get("p1").unwrap()
509 ));
510 assert!(!Arc::ptr_eq(
511 &p2_original,
512 snap2.upstreams.get("p2").unwrap()
513 ));
514 }
515}