1use std::{collections::BTreeMap, fmt, net::IpAddr, time::Duration};
11
12use async_trait::async_trait;
13use configuration::{types::Port, CustomProcess};
14use provider::types::ProcessStatus;
15use serde::{Deserialize, Serialize};
16use tracing::debug;
17
18use super::{
19 core::NodeCore,
20 spawned::{NodeKind, SpawnedNode},
21};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ProcessPort {
26 pub port: Port,
28 pub external: String,
32 pub internal: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(tag = "state", rename_all = "lowercase")]
43pub enum CustomProcessState {
44 Starting,
46 Ready,
48 Completed,
50 Failed {
54 #[serde(rename = "state_reason")]
55 reason: String,
56 },
57}
58
59impl fmt::Display for CustomProcessState {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.write_str(match self {
62 Self::Starting => "starting",
63 Self::Ready => "ready",
64 Self::Completed => "completed",
65 Self::Failed { .. } => "failed",
66 })
67 }
68}
69
70#[derive(Clone, Serialize)]
72pub struct CustomProcessNode {
73 #[serde(flatten)]
74 pub(crate) core: NodeCore,
75 pub(crate) spec: CustomProcess,
76 pub(crate) ip: IpAddr,
78 pub(crate) ports: BTreeMap<String, ProcessPort>,
80 #[serde(flatten)]
81 pub(crate) state: CustomProcessState,
82}
83
84#[derive(Deserialize)]
86pub(crate) struct RawCustomProcessNode {
87 pub(crate) name: String,
88 pub(crate) spec: CustomProcess,
89 pub(crate) ip: IpAddr,
90 #[serde(default)]
91 pub(crate) ports: BTreeMap<String, ProcessPort>,
92 #[serde(flatten)]
95 pub(crate) state: Option<CustomProcessState>,
96 pub(crate) inner: serde_json::Value,
98}
99
100impl CustomProcessNode {
101 pub(crate) fn new(
102 name: impl Into<String>,
103 inner: provider::DynNode,
104 spec: CustomProcess,
105 ip: IpAddr,
106 ports: BTreeMap<String, ProcessPort>,
107 state: CustomProcessState,
108 ) -> Self {
109 Self {
110 core: NodeCore::new(name, inner, NodeKind::CustomProcess),
111 spec,
112 ip,
113 ports,
114 state,
115 }
116 }
117
118 pub fn core(&self) -> &NodeCore {
120 &self.core
121 }
122
123 pub fn name(&self) -> &str {
124 self.core.name()
125 }
126
127 pub fn spec(&self) -> &CustomProcess {
129 &self.spec
130 }
131
132 pub fn ip(&self) -> IpAddr {
134 self.ip
135 }
136
137 pub fn ports(&self) -> &BTreeMap<String, ProcessPort> {
139 &self.ports
140 }
141
142 pub fn port(&self, name: &str) -> Option<&ProcessPort> {
144 self.ports.get(name)
145 }
146
147 pub fn get_uri_for_name(&self, name: &str) -> Option<&str> {
150 self.ports.get(name).map(|p| p.external.as_str())
151 }
152
153 pub fn state(&self) -> &CustomProcessState {
156 &self.state
157 }
158
159 fn first_port(&self) -> Option<&ProcessPort> {
161 self.spec
162 .ports()
163 .first()
164 .and_then(|declared| self.ports.get(&declared.name))
165 }
166
167 pub async fn wait_ready(&self, timeout: Duration) -> CustomProcessState {
174 if self.spec.one_shot()
175 && matches!(
176 self.state,
177 CustomProcessState::Completed | CustomProcessState::Failed { .. }
178 )
179 {
180 return self.state.clone();
181 }
182 match self.await_condition(timeout).await {
183 Ok(state) => state,
184 Err(reason) => CustomProcessState::Failed { reason },
185 }
186 }
187
188 async fn await_condition(&self, timeout: Duration) -> Result<CustomProcessState, String> {
189 let interval = Duration::from_secs(u64::from(
190 self.spec.ready_check().map(|c| c.interval).unwrap_or(1),
191 ));
192 let check = self.spec.effective_check();
193 let target = check
194 .as_ref()
195 .and_then(|check| Some((self.ports.get(check.port_name())?, check.http_path())));
196 if !self.spec.one_shot() && target.is_none() {
197 return Ok(CustomProcessState::Ready);
198 }
199 let client = reqwest::Client::builder()
200 .no_proxy()
201 .build()
202 .map_err(|e| format!("could not build the http probe: {e}"))?;
203
204 let mut problem = String::from("no status read yet");
208 let waited = tokio::time::timeout(timeout, async {
209 loop {
210 let status = match self.core.inner().status().await {
211 Ok(status) => status,
212 Err(err) => {
213 problem = format!("could not read the process status: {err}");
214 tokio::time::sleep(interval).await;
215 continue;
216 },
217 };
218 if self.spec.one_shot() {
219 match status {
220 ProcessStatus::Exited { code: Some(0) } => {
221 return Ok(CustomProcessState::Completed)
222 },
223 ProcessStatus::Exited { code: Some(code) } => {
224 return Err(format!("exited with code {code}"))
225 },
226 ProcessStatus::Exited { code: None } => {
227 return Err("exited, with its code unknown".into())
228 },
229 ProcessStatus::Running { .. } => problem = "still running".into(),
230 }
231 } else {
232 let (port, path) = target.expect("checked above; qed");
233 let kind = if path.is_some() { "http" } else { "tcp" };
234 match status {
235 ProcessStatus::Exited { code } => {
236 return Err(match code {
237 Some(code) => format!("exited with code {code}"),
238 None => "exited".into(),
239 })
240 },
241 ProcessStatus::Running { ready: Some(true) } => {
243 return Ok(CustomProcessState::Ready)
244 },
245 ProcessStatus::Running { ready: Some(false) } => {
246 problem = format!(
247 "the provider's {kind} probe on port {}{} did not pass",
248 port.port,
249 path.unwrap_or("")
250 );
251 },
252 ProcessStatus::Running { ready: None } => {
254 let passed = match path {
255 None => tcp_answers(&port.external).await,
256 Some(path) => http_answers(&client, &port.external, path).await,
257 };
258 if passed {
259 return Ok(CustomProcessState::Ready);
260 }
261 problem = format!(
262 "{kind} {}{} did not pass",
263 port.external,
264 path.unwrap_or("")
265 );
266 },
267 }
268 }
269 tokio::time::sleep(interval).await;
270 }
271 })
272 .await;
273
274 match waited {
275 Ok(outcome) => outcome,
276 Err(_elapsed) => Err(format!(
277 "{} after {}s ({problem})",
278 if self.spec.one_shot() {
279 "not finished"
280 } else {
281 "not ready"
282 },
283 timeout.as_secs()
284 )),
285 }
286 }
287
288 pub async fn is_responsive(&self) -> bool {
298 match self.first_port() {
299 Some(port) => tcp_answers(&port.external).await,
300 None => true,
301 }
302 }
303}
304
305async fn tcp_answers(addr: &str) -> bool {
307 tokio::time::timeout(Duration::from_secs(2), tokio::net::TcpStream::connect(addr))
308 .await
309 .is_ok_and(|connected| connected.is_ok())
310}
311
312async fn http_answers(client: &reqwest::Client, addr: &str, path: &str) -> bool {
314 client
315 .get(format!("http://{addr}{path}"))
316 .timeout(Duration::from_secs(5))
317 .send()
318 .await
319 .is_ok_and(|res| res.status().is_success())
320}
321
322#[async_trait]
323impl SpawnedNode for CustomProcessNode {
324 fn core(&self) -> &NodeCore {
325 &self.core
326 }
327
328 async fn wait_until_is_up(&self, _timeout_secs: u64) -> Result<(), anyhow::Error> {
333 debug!(
334 "[{}] custom process, not waited on; state {}",
335 self.name(),
336 self.state
337 );
338 Ok(())
339 }
340}
341
342impl fmt::Debug for CustomProcessNode {
343 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344 f.debug_struct("CustomProcessNode")
345 .field("inner", &"inner_skipped")
346 .field("name", &self.name())
347 .field("spec", &self.spec)
348 .field("ip", &self.ip)
349 .field("ports", &self.ports)
350 .field("state", &self.state)
351 .finish()
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use std::sync::Arc;
358
359 use configuration::{CustomProcessBuilder, ReadyCheck};
360
361 use super::*;
362 use crate::network::node::mock::MockNode;
363
364 fn node(spec: CustomProcess, mock: MockNode, state: CustomProcessState) -> CustomProcessNode {
365 let ports = spec
366 .ports()
367 .iter()
368 .map(|p| {
369 (
370 p.name.clone(),
371 ProcessPort {
372 port: 8080,
373 external: "127.0.0.1:1".into(),
374 internal: "x:8080".into(),
375 },
376 )
377 })
378 .collect();
379 CustomProcessNode::new(
380 "p",
381 Arc::new(mock),
382 spec,
383 "127.0.0.1".parse().unwrap(),
384 ports,
385 state,
386 )
387 }
388
389 fn one_shot() -> CustomProcess {
390 CustomProcessBuilder::new()
391 .with_name("p")
392 .with_command("sh")
393 .with_one_shot()
394 .build()
395 .unwrap()
396 }
397
398 fn with_check() -> CustomProcess {
399 CustomProcessBuilder::new()
400 .with_name("p")
401 .with_command("srv")
402 .with_named_port("http", 8080)
403 .with_ready_check(ReadyCheck::tcp("http"))
404 .build()
405 .unwrap()
406 }
407
408 #[tokio::test]
409 async fn wait_ready_follows_what_the_provider_reports() {
410 let running = ProcessStatus::Running { ready: None };
411 let secs = Duration::from_secs;
412
413 let n = node(
415 one_shot(),
416 MockNode::new().with_statuses(vec![
417 Ok(running.clone()),
418 Ok(ProcessStatus::Exited { code: Some(0) }),
419 ]),
420 CustomProcessState::Starting,
421 );
422 assert_eq!(n.wait_ready(secs(10)).await, CustomProcessState::Completed);
423
424 let n = node(
426 one_shot(),
427 MockNode::new().with_statuses(vec![Ok(ProcessStatus::Exited { code: Some(3) })]),
428 CustomProcessState::Starting,
429 );
430 assert_eq!(
431 n.wait_ready(secs(10)).await,
432 CustomProcessState::Failed {
433 reason: "exited with code 3".into()
434 }
435 );
436
437 let n = node(
439 one_shot(),
440 MockNode::new().with_statuses(vec![Err("not asked")]),
441 CustomProcessState::Completed,
442 );
443 assert_eq!(n.wait_ready(secs(10)).await, CustomProcessState::Completed);
444
445 let n = node(
447 with_check(),
448 MockNode::new().with_statuses(vec![
449 Err("api down"),
450 Ok(ProcessStatus::Running { ready: Some(false) }),
451 Ok(ProcessStatus::Running { ready: Some(true) }),
452 ]),
453 CustomProcessState::Starting,
454 );
455 assert_eq!(n.wait_ready(secs(10)).await, CustomProcessState::Ready);
456
457 let n = node(
459 with_check(),
460 MockNode::new().with_statuses(vec![Ok(ProcessStatus::Running { ready: Some(false) })]),
461 CustomProcessState::Starting,
462 );
463 assert_eq!(
464 n.wait_ready(secs(2)).await,
465 CustomProcessState::Failed {
466 reason: "not ready after 2s (the provider's tcp probe on port 8080 did not pass)"
467 .into()
468 }
469 );
470
471 let n = node(
473 with_check(),
474 MockNode::new().with_statuses(vec![Ok(ProcessStatus::Exited { code: Some(9) })]),
475 CustomProcessState::Starting,
476 );
477 assert_eq!(
478 n.wait_ready(secs(10)).await,
479 CustomProcessState::Failed {
480 reason: "exited with code 9".into()
481 }
482 );
483 }
484
485 #[test]
486 fn the_state_is_two_plain_fields_and_an_old_record_is_ready() {
487 for (state, json) in [
488 (CustomProcessState::Starting, r#"{"state":"starting"}"#),
489 (CustomProcessState::Ready, r#"{"state":"ready"}"#),
490 (CustomProcessState::Completed, r#"{"state":"completed"}"#),
491 (
492 CustomProcessState::Failed {
493 reason: "exited with code 3".into(),
494 },
495 r#"{"state":"failed","state_reason":"exited with code 3"}"#,
496 ),
497 ] {
498 assert_eq!(serde_json::to_string(&state).unwrap(), json);
499 assert_eq!(
500 serde_json::from_str::<CustomProcessState>(json).unwrap(),
501 state
502 );
503 }
504
505 let with_state = serde_json::json!({
506 "name": "web", "spec": {"name": "web", "command": "web"}, "ip": "10.0.0.1",
507 "state": "failed", "state_reason": "x", "inner": {}
508 });
509 let raw: RawCustomProcessNode = serde_json::from_value(with_state).unwrap();
510 assert_eq!(
511 raw.state,
512 Some(CustomProcessState::Failed { reason: "x".into() })
513 );
514
515 let without_state = serde_json::json!({
516 "name": "web", "spec": {"name": "web", "command": "web"}, "ip": "10.0.0.1", "inner": {}
517 });
518 let raw: RawCustomProcessNode = serde_json::from_value(without_state).unwrap();
519 assert_eq!(raw.state, None);
520 }
521
522 #[test]
523 fn a_raw_process_without_ports_still_deserializes_but_needs_its_inner() {
524 let json = serde_json::json!({
525 "name": "eth-rpc",
526 "spec": { "name": "eth-rpc", "command": "eth-rpc" },
527 "ip": "10.0.0.1",
528 "inner": { "name": "eth-rpc" }
529 });
530 let raw: RawCustomProcessNode = serde_json::from_value(json).unwrap();
531 assert_eq!(raw.name, "eth-rpc");
532 assert!(raw.ports.is_empty());
533
534 let json = serde_json::json!({
535 "name": "eth-rpc",
536 "spec": { "name": "eth-rpc", "command": "eth-rpc" },
537 "ip": "10.0.0.1"
538 });
539 assert!(serde_json::from_value::<RawCustomProcessNode>(json).is_err());
540 }
541
542 #[test]
543 fn ports_round_trip_by_name() {
544 let port = ProcessPort {
545 port: 8545,
546 external: "127.0.0.1:53421".into(),
547 internal: "eth-rpc:8545".into(),
548 };
549 let json = serde_json::to_value(&port).unwrap();
550 assert_eq!(json["external"], "127.0.0.1:53421");
551 let back: ProcessPort = serde_json::from_value(json).unwrap();
552 assert_eq!(back, port);
553 }
554}