1use std::collections::BTreeMap;
10use std::ffi::OsString;
11use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream, ToSocketAddrs, UdpSocket};
12use std::path::Path;
13use std::process::Stdio;
14use std::sync::Mutex;
15use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
16use std::time::{Duration, Instant};
17
18use anyhow::Result;
19use command_group::CommandGroup;
20use serde_json::{Map, Value};
21
22use crate::tokens::{closing_brace, env_name, fallbacks};
23
24pub const DEFAULT_TIMEOUT_MS: u64 = 600_000;
26const DEFAULT_INTERVAL_MS: u64 = 250;
29const MAX_DURATION_MS: u64 = 2_147_483_647;
30const POLL: Duration = Duration::from_millis(20);
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum Probe {
36 Url(String),
38 Port { port: u16, host: Option<String> },
40 Command {
44 command: String,
45 env: BTreeMap<OsString, OsString>,
46 },
47 LogMatches(Vec<String>),
49 Signal,
52}
53
54#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct ReadyCheck {
57 pub probe: Probe,
58 pub timeout: Duration,
59 pub interval: Duration,
60}
61
62impl ReadyCheck {
63 pub fn describe(&self) -> String {
64 match &self.probe {
65 Probe::Url(url) => format!("url {url}"),
66 Probe::Port {
67 port,
68 host: Some(host),
69 } => format!("port {host}:{port}"),
70 Probe::Port { port, host: None } => format!("port {port}"),
71 Probe::Command { command, .. } => format!("command \"{command}\""),
72 Probe::LogMatches(matches) => format!(
73 "logMatches {}",
74 matches
75 .iter()
76 .map(|text| format!("\"{text}\""))
77 .collect::<Vec<_>>()
78 .join(", ")
79 ),
80 Probe::Signal => "signal".into(),
81 }
82 }
83}
84
85const PENDING: u8 = 0;
86const READY: u8 = 1;
87const FAILED: u8 = 2;
88
89#[derive(Debug, Default)]
92pub struct Verdict {
93 state: AtomicU8,
94 reason: Mutex<Option<String>>,
95 url: Mutex<Option<String>>,
97}
98
99impl Verdict {
100 pub fn is_ready(&self) -> bool {
101 self.state.load(Ordering::SeqCst) == READY
102 }
103
104 pub fn is_decided(&self) -> bool {
105 self.state.load(Ordering::SeqCst) != PENDING
106 }
107
108 pub fn failure(&self) -> Option<String> {
110 (self.state.load(Ordering::SeqCst) == FAILED)
111 .then(|| self.reason.lock().unwrap().clone().unwrap_or_default())
112 }
113
114 pub fn set_ready(&self) {
115 let _ = self
116 .state
117 .compare_exchange(PENDING, READY, Ordering::SeqCst, Ordering::SeqCst);
118 }
119
120 pub fn set_ready_at(&self, url: Option<String>) {
123 let mut stored = self.url.lock().unwrap();
124 if self
125 .state
126 .compare_exchange(PENDING, READY, Ordering::SeqCst, Ordering::SeqCst)
127 .is_ok()
128 {
129 *stored = url;
130 }
131 }
132
133 pub fn url(&self) -> Option<String> {
135 self.is_ready()
136 .then(|| self.url.lock().unwrap().clone())
137 .flatten()
138 }
139
140 pub fn fail(&self, reason: String) -> bool {
143 let mut stored = self.reason.lock().unwrap();
144 let decided = self
145 .state
146 .compare_exchange(PENDING, FAILED, Ordering::SeqCst, Ordering::SeqCst)
147 .is_ok();
148 if decided {
149 *stored = Some(reason);
150 }
151 decided
152 }
153}
154
155pub struct Scope<'a> {
157 pub workspace_root: &'a str,
158 pub project_root: &'a str,
159 pub project_name: &'a str,
160 pub options: &'a Map<String, Value>,
162 pub env: &'a BTreeMap<String, String>,
164}
165
166#[derive(Clone, Copy, PartialEq, Eq)]
168enum Field {
169 Text,
170 Host,
172 Port,
174}
175
176pub fn resolve(task: &str, ready_when: &Value, scope: &Scope) -> Result<Option<ReadyCheck>> {
179 let invalid = |reason: String| -> anyhow::Error {
180 anyhow::anyhow!("Task \"{task}\" has an invalid \"readyWhen\": {reason}.")
181 };
182 let Value::Object(config) = ready_when else {
183 return Err(invalid("expected an object".into()));
184 };
185 for key in config.keys() {
186 if !matches!(
187 key.as_str(),
188 "url" | "port" | "host" | "command" | "logMatches" | "signal" | "timeout" | "interval"
189 ) {
190 return Err(invalid(format!("unknown key \"{key}\"")));
191 }
192 }
193 let present: Vec<&str> = ["url", "port", "command", "logMatches", "signal"]
194 .into_iter()
195 .filter(|key| config.get(*key).is_some_and(|value| !value.is_null()))
196 .collect();
197 let [kind] = present[..] else {
198 return Err(invalid(
199 "expected exactly one of \"url\", \"port\", \"command\", \"logMatches\", \"signal\""
200 .into(),
201 ));
202 };
203 let duration = |key: &str, default: Option<u64>| -> Result<Option<Duration>> {
204 match config.get(key).filter(|value| !value.is_null()) {
205 None => Ok(default.map(Duration::from_millis)),
206 Some(value) => match value
207 .as_u64()
208 .filter(|ms| (1..=MAX_DURATION_MS).contains(ms))
209 {
210 Some(ms) => Ok(Some(Duration::from_millis(ms))),
211 None => Err(invalid(format!(
212 "\"{key}\" must be an integer from 1 to {MAX_DURATION_MS}"
213 ))),
214 },
215 }
216 };
217 let timeout = duration("timeout", Some(DEFAULT_TIMEOUT_MS))?.unwrap();
218 let interval = duration("interval", Some(DEFAULT_INTERVAL_MS))?.unwrap();
219 let text = |key: &str, field: Field| -> Result<Option<Value>> {
220 match config.get(key) {
221 None | Some(Value::Null) => Ok(None),
222 Some(Value::String(template)) => render(template, scope, field)
223 .map(|text| Some(Value::String(text)))
224 .map_err(invalid),
225 Some(other) => Ok(Some(other.clone())),
226 }
227 };
228 let probe = match kind {
229 "url" => match text("url", Field::Text)? {
230 Some(Value::String(url))
231 if (url.starts_with("http://") || url.starts_with("https://"))
232 && url.parse::<ureq::http::Uri>().is_ok() =>
233 {
234 Probe::Url(url)
235 }
236 _ => return Err(invalid("\"url\" must be an http or https URL".into())),
237 },
238 "port" => {
239 let port = text("port", Field::Port)?;
240 let host = match text("host", Field::Host)? {
241 None => None,
242 Some(Value::String(host)) if !host.is_empty() && !host.contains(['{', '}']) => {
243 Some(host)
244 }
245 Some(Value::String(host)) if host.contains(['{', '}']) => {
246 return Err(invalid("\"host\" contains an unknown placeholder".into()));
247 }
248 _ => return Err(invalid("\"host\" must be a non-empty string".into())),
249 };
250 let port = match port {
251 Some(Value::String(port)) if port == "auto" => 0,
252 Some(Value::String(port)) if port.contains(['{', '}']) => {
253 return Err(invalid("\"port\" contains an unknown placeholder".into()));
254 }
255 Some(Value::String(port)) => port.parse::<u64>().unwrap_or(u64::MAX),
256 Some(Value::Number(port)) => port.as_u64().unwrap_or(u64::MAX),
257 _ => u64::MAX,
258 };
259 match port {
260 0 => return Ok(None),
263 1..=65535 => Probe::Port {
264 port: port as u16,
265 host,
266 },
267 _ => {
268 return Err(invalid(
269 "\"port\" must be an integer from 0 to 65535, or \"auto\"".into(),
270 ));
271 }
272 }
273 }
274 "signal" => {
275 if config["signal"] != Value::Bool(true) {
276 return Err(invalid("\"signal\" must be true".into()));
277 }
278 if config.get("interval").is_some_and(|value| !value.is_null()) {
279 return Err(invalid("\"interval\" is not used by \"signal\"".into()));
280 }
281 Probe::Signal
282 }
283 "command" => match text("command", Field::Text)? {
284 Some(Value::String(command)) if !command.is_empty() => Probe::Command {
285 command,
286 env: BTreeMap::new(),
287 },
288 _ => return Err(invalid("\"command\" must be a non-empty string".into())),
289 },
290 _ => {
291 let templates = match &config["logMatches"] {
292 Value::Array(templates) => templates.clone(),
293 single => vec![single.clone()],
294 };
295 let mut matches = Vec::new();
296 for template in &templates {
297 match template {
298 Value::String(template) if !template.is_empty() => {
299 matches.push(render(template, scope, Field::Text).map_err(invalid)?);
300 }
301 _ => {
302 return Err(invalid(
303 "\"logMatches\" must be a non-empty string or an array of them".into(),
304 ));
305 }
306 }
307 }
308 if matches.is_empty() || matches.iter().any(String::is_empty) {
309 return Err(invalid(
310 "\"logMatches\" must be a non-empty string or an array of them".into(),
311 ));
312 }
313 Probe::LogMatches(matches)
314 }
315 };
316 Ok(Some(ReadyCheck {
317 probe,
318 timeout,
319 interval,
320 }))
321}
322
323fn render(template: &str, scope: &Scope, field: Field) -> std::result::Result<String, String> {
326 let mut result = String::new();
327 let mut index = 0;
328 while let Some(offset) = template[index..].find('{') {
329 let open = index + offset;
330 let resolved = match closing_brace(template, open) {
331 Some(close) => {
332 placeholder(&template[open..=close], scope, field)?.map(|value| (value, close))
333 }
334 None => None,
335 };
336 match resolved {
337 Some((value, close)) => {
338 result.push_str(&template[index..open]);
339 result.push_str(&value);
340 index = close + 1;
341 }
342 None => {
343 result.push_str(&template[index..=open]);
344 index = open + 1;
345 }
346 }
347 }
348 result.push_str(&template[index..]);
349 Ok(result)
350}
351
352fn placeholder(
355 raw: &str,
356 scope: &Scope,
357 field: Field,
358) -> std::result::Result<Option<String>, String> {
359 let sources = fallbacks(&raw[1..raw.len() - 1]);
360 let read = |source: &str| -> Option<Option<Value>> {
361 if let Some(name) = env_name(source) {
362 return Some(scope.env.get(name).cloned().map(Value::String));
363 }
364 if let Some(path) = source.strip_prefix("options.") {
365 let mut value = Some(&Value::Object(scope.options.clone()));
366 let mut owned = None;
367 for key in path.split('.') {
368 owned = value.and_then(|value| value.get(key)).cloned();
369 value = owned.as_ref();
370 }
371 return Some(owned);
372 }
373 match source {
374 "workspaceRoot" => Some(Some(Value::String(scope.workspace_root.into()))),
375 "projectRoot" => Some(Some(Value::String(scope.project_root.into()))),
376 "projectName" => Some(Some(Value::String(scope.project_name.into()))),
377 _ => None,
378 }
379 };
380 if read(sources[0]).is_none() {
381 return Ok(None);
382 }
383 for (index, source) in sources.iter().enumerate() {
384 let value = match read(source) {
385 Some(value) => value,
386 None if index + 1 == sources.len() => {
387 Some(Value::String(render(source, scope, Field::Text)?))
388 }
389 None => {
390 return Err(format!(
391 "\"{raw}\" has a fallback after the literal \"{source}\" that can never be used"
392 ));
393 }
394 };
395 let value = match value {
396 Some(Value::String(text)) if text.is_empty() && field == Field::Port => {
397 Value::Number(0.into())
398 }
399 Some(value) => value,
400 None => continue,
401 };
402 let unset = match &value {
403 Value::Null => true,
404 Value::String(text) => {
405 text.is_empty()
406 || (field != Field::Text && matches!(text.as_str(), "true" | "false"))
407 }
408 Value::Bool(_) => field != Field::Text,
409 _ => false,
410 };
411 if unset {
412 continue;
413 }
414 return match value {
415 Value::String(text) => Ok(Some(text)),
416 Value::Number(number) => Ok(Some(number.to_string())),
417 Value::Bool(flag) => Ok(Some(flag.to_string())),
418 _ => Err(format!("\"{raw}\" is not a string or a number")),
419 };
420 }
421 Err(format!("\"{raw}\" has no value"))
422}
423
424pub fn wait(task: &str, check: &ReadyCheck, root: &Path, verdict: &Verdict, stop: &AtomicBool) {
428 let deadline = Instant::now() + check.timeout;
429 loop {
430 if verdict.is_decided() || stop.load(Ordering::SeqCst) {
431 return;
432 }
433 let ready = match &check.probe {
434 Probe::Url(url) => probe_url(url, deadline),
435 Probe::Port { port, host } => probe_port(*port, host.as_deref(), deadline),
436 Probe::Command { command, env } => probe_command(command, env, root, deadline, stop),
437 Probe::LogMatches(_) | Probe::Signal => false,
439 };
440 if ready {
441 verdict.set_ready();
442 return;
443 }
444 let now = Instant::now();
445 if now >= deadline {
446 let longer = (check.timeout.as_millis() as u64 * 2).min(MAX_DURATION_MS);
447 verdict.fail(format!(
448 "Task \"{task}\" did not become ready within {}ms (readyWhen: {}). If it needs \
449 longer, set \"timeout\": {longer} in the target's \"readyWhen\", as \
450 {{ \"...\": true, \"timeout\": {longer} }} to keep a check a target default \
451 sets. To change the check, set \"readyWhen\" on the target or in \
452 \"targetDefaults\" in nx.json, or set it to null to turn it off.",
453 check.timeout.as_millis(),
454 check.describe()
455 ));
456 return;
457 }
458 let pause = if matches!(check.probe, Probe::LogMatches(_) | Probe::Signal) {
459 deadline - now
460 } else {
461 check.interval.min(deadline - now)
462 };
463 let until = now + pause;
464 while Instant::now() < until {
465 if verdict.is_decided() || stop.load(Ordering::SeqCst) {
466 return;
467 }
468 std::thread::sleep(POLL.min(until.saturating_duration_since(Instant::now())));
469 }
470 }
471}
472
473fn unbracketed(host: &str) -> &str {
474 host.trim_start_matches('[').trim_end_matches(']')
475}
476
477fn destination(ip: IpAddr) -> IpAddr {
479 match ip.to_canonical() {
480 IpAddr::V4(v4) if v4.is_unspecified() => Ipv4Addr::LOCALHOST.into(),
481 IpAddr::V6(v6) if v6.is_unspecified() => Ipv6Addr::LOCALHOST.into(),
482 _ => ip,
483 }
484}
485
486fn is_loopback(ip: IpAddr) -> bool {
487 match ip {
488 IpAddr::V4(ip) => ip.is_loopback(),
489 IpAddr::V6(ip) => {
490 ip.is_loopback() || ip.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
491 }
492 }
493}
494
495fn local_ip(v6: bool) -> IpAddr {
499 let (bind, probe): (SocketAddr, SocketAddr) = if v6 {
500 (
501 (Ipv6Addr::UNSPECIFIED, 0).into(),
502 "[2001:4860:4860::8888]:53".parse().unwrap(),
503 )
504 } else {
505 (
506 (Ipv4Addr::UNSPECIFIED, 0).into(),
507 "8.8.8.8:53".parse().unwrap(),
508 )
509 };
510 UdpSocket::bind(bind)
512 .and_then(|socket| socket.connect(probe).and_then(|()| socket.local_addr()))
513 .map(|address| address.ip())
514 .unwrap_or(if v6 {
515 Ipv6Addr::LOCALHOST.into()
516 } else {
517 Ipv4Addr::LOCALHOST.into()
518 })
519}
520
521fn connects(address: SocketAddr, deadline: Instant) -> bool {
522 let left = deadline.saturating_duration_since(Instant::now());
523 !left.is_zero() && TcpStream::connect_timeout(&address, left).is_ok()
524}
525
526fn probe_port(port: u16, host: Option<&str>, deadline: Instant) -> bool {
529 let addresses: Vec<SocketAddr> = match host.map(unbracketed) {
530 None => vec![
531 (Ipv4Addr::LOCALHOST, port).into(),
532 (Ipv6Addr::LOCALHOST, port).into(),
533 ],
534 Some("local-ip") => {
535 let ip = local_ip(false);
536 let ip = if ip.is_loopback() { local_ip(true) } else { ip };
537 vec![(ip, port).into()]
538 }
539 Some("local-ipv4") => vec![(local_ip(false), port).into()],
540 Some("local-ipv6") => vec![(local_ip(true), port).into()],
541 Some(host) => match host.parse::<IpAddr>() {
542 Ok(ip) => vec![(destination(ip), port).into()],
543 Err(_) => match (host, port).to_socket_addrs() {
544 Ok(addresses) => addresses.collect(),
545 Err(_) => return false,
546 },
547 },
548 };
549 addresses
550 .into_iter()
551 .any(|address| connects(address, deadline))
552}
553
554fn target(url: &str) -> Option<(String, bool)> {
558 let uri: ureq::http::Uri = url.parse().ok()?;
559 let host = uri.host()?;
560 let bare = unbracketed(host);
561 if let Ok(ip) = bare.parse::<IpAddr>() {
562 let ip = destination(ip);
563 let written = match ip {
564 IpAddr::V4(v4) => v4.to_string(),
565 IpAddr::V6(v6) => format!("[{v6}]"),
566 };
567 let url = url.replacen(host, &written, 1);
568 return Some((url, is_loopback(ip)));
569 }
570 let name = bare.trim_end_matches('.');
571 let local = (name == "localhost" || name.ends_with(".localhost"))
572 && (bare, uri.port_u16().unwrap_or(80))
573 .to_socket_addrs()
574 .is_ok_and(|addresses| {
575 let addresses: Vec<_> = addresses.collect();
576 !addresses.is_empty() && addresses.iter().all(|address| is_loopback(address.ip()))
577 });
578 Some((url.to_owned(), local))
579}
580
581fn probe_url(url: &str, deadline: Instant) -> bool {
585 let Some((url, local)) = target(url) else {
586 return false;
587 };
588 let (proxy, tls) = if local {
591 (
592 None,
593 ureq::tls::TlsConfig::builder().disable_verification(true),
594 )
595 } else {
596 (
597 proxy_for(&url, &env_var),
598 ureq::tls::TlsConfig::builder().root_certs(roots().clone()),
599 )
600 };
601 answers(&url, proxy, tls.build(), deadline)
602}
603
604fn answers(
607 url: &str,
608 proxy: Option<ureq::Proxy>,
609 tls: ureq::tls::TlsConfig,
610 deadline: Instant,
611) -> bool {
612 let status = |url: &str| -> Option<u16> {
613 let left = deadline.saturating_duration_since(Instant::now());
614 if left.is_zero() {
615 return None;
616 }
617 let config = ureq::Agent::config_builder()
618 .max_redirects(0)
619 .http_status_as_error(false)
620 .timeout_global(Some(left))
621 .proxy(proxy.clone())
622 .tls_config(tls.clone());
623 let agent: ureq::Agent = config.build().into();
624 agent
625 .get(url)
626 .call()
627 .ok()
628 .map(|response| response.status().as_u16())
629 };
630 let ready = |status: u16| (200..=403).contains(&status);
631 match status(url) {
632 Some(404) => {
633 let uri: Option<ureq::http::Uri> = url.parse().ok();
634 if uri.as_ref().is_some_and(|uri| uri.path() == "/") {
635 let index = format!("{}/index.html", url.trim_end_matches('/'));
636 status(&index).is_some_and(ready)
637 } else {
638 false
639 }
640 }
641 Some(found) => ready(found),
642 None => false,
643 }
644}
645
646fn env_var(names: &[&str]) -> String {
647 names
648 .iter()
649 .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
650 .unwrap_or_default()
651}
652
653fn proxy_for(url: &str, var: &dyn Fn(&[&str]) -> String) -> Option<ureq::Proxy> {
658 let uri: ureq::http::Uri = url.parse().ok()?;
659 let https = uri.scheme_str() == Some("https");
660 let port = uri.port_u16().unwrap_or(if https { 443 } else { 80 });
661 if no_proxy_matches(&var(&["NO_PROXY", "no_proxy"]), uri.host()?, port) {
662 return None;
663 }
664 let proxy = if https {
665 var(&["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"])
666 } else {
667 var(&["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"])
668 };
669 ureq::Proxy::new(&proxy).ok()
670}
671
672fn no_proxy_matches(no_proxy: &str, host: &str, port: u16) -> bool {
677 let host = unbracketed(host).trim_end_matches('.');
678 let ip = host.parse::<IpAddr>().ok();
679 no_proxy.split(',').map(str::trim).any(|entry| {
680 if let Some((network, bits)) = entry.split_once('/') {
681 return ip.is_some_and(|ip| in_range(ip, network, bits));
682 }
683 let (pattern, entry_port) = split_port(entry);
684 if entry_port.is_some_and(|entry_port| entry_port != port) {
685 return false;
686 }
687 if pattern == "*" {
688 return true;
689 }
690 match ip {
691 Some(ip) => unbracketed(pattern)
692 .parse::<IpAddr>()
693 .is_ok_and(|entry| entry.to_canonical() == ip.to_canonical()),
694 None => match pattern.strip_prefix("*.") {
695 Some(domain) => is_subdomain(host, domain),
696 None => {
697 let domain = pattern.strip_prefix('.').unwrap_or(pattern);
698 !domain.is_empty()
699 && (host.eq_ignore_ascii_case(domain) || is_subdomain(host, domain))
700 }
701 },
702 }
703 })
704}
705
706fn split_port(entry: &str) -> (&str, Option<u16>) {
708 match entry.rsplit_once(':') {
709 Some((host, port)) if host.starts_with('[') || !host.contains(':') => match port.parse() {
710 Ok(port) => (host, Some(port)),
711 Err(_) => (entry, None),
712 },
713 _ => (entry, None),
714 }
715}
716
717fn is_subdomain(host: &str, domain: &str) -> bool {
718 let (host, domain) = (host.to_ascii_lowercase(), domain.to_ascii_lowercase());
719 !domain.is_empty()
720 && host
721 .strip_suffix(&domain)
722 .is_some_and(|rest| rest.ends_with('.'))
723}
724
725fn in_range(ip: IpAddr, network: &str, bits: &str) -> bool {
726 let (Ok(network), Ok(bits)) = (unbracketed(network).parse::<IpAddr>(), bits.parse::<u32>())
727 else {
728 return false;
729 };
730 match (ip.to_canonical(), network.to_canonical()) {
731 (IpAddr::V4(ip), IpAddr::V4(network)) if bits <= 32 => {
732 let mask = u32::MAX.checked_shl(32 - bits).unwrap_or(0);
733 u32::from(ip) & mask == u32::from(network) & mask
734 }
735 (IpAddr::V6(ip), IpAddr::V6(network)) if bits <= 128 => {
736 let mask = u128::MAX.checked_shl(128 - bits).unwrap_or(0);
737 u128::from(ip) & mask == u128::from(network) & mask
738 }
739 _ => false,
740 }
741}
742
743fn roots() -> &'static ureq::tls::RootCerts {
749 static ROOTS: std::sync::OnceLock<ureq::tls::RootCerts> = std::sync::OnceLock::new();
750 ROOTS.get_or_init(|| {
751 let mut certs: Vec<ureq::tls::Certificate<'static>> =
752 rustls_native_certs::load_native_certs()
753 .certs
754 .iter()
755 .map(|der| ureq::tls::Certificate::from_der(der).to_owned())
756 .collect();
757 if let Some(path) = std::env::var_os("NODE_EXTRA_CA_CERTS") {
758 certs.extend(extra_roots(Path::new(&path)));
759 }
760 if certs.is_empty() {
761 ureq::tls::RootCerts::WebPki
762 } else {
763 ureq::tls::RootCerts::new_with_certs(&certs)
764 }
765 })
766}
767
768fn extra_roots(path: &Path) -> Vec<ureq::tls::Certificate<'static>> {
770 let Ok(pem) = std::fs::read(path) else {
771 return Vec::new();
772 };
773 ureq::tls::parse_pem(&pem)
774 .map_while(Result::ok)
775 .filter_map(|item| match item {
776 ureq::tls::PemItem::Certificate(cert) => Some(cert),
777 _ => None,
778 })
779 .collect()
780}
781
782fn probe_command(
786 command: &str,
787 env: &BTreeMap<OsString, OsString>,
788 root: &Path,
789 deadline: Instant,
790 stop: &AtomicBool,
791) -> bool {
792 let mut shell = crate::shell(command);
793 if !env.is_empty() {
794 shell.env_clear().envs(env);
795 }
796 shell
797 .current_dir(root)
798 .stdin(Stdio::null())
799 .stdout(Stdio::null())
800 .stderr(Stdio::null());
801 let Ok(mut child) = shell.group_spawn() else {
802 return false;
803 };
804 loop {
805 match child.try_wait() {
806 Ok(Some(status)) => return status.success(),
807 Ok(None) => {}
808 Err(_) => break,
809 }
810 if Instant::now() >= deadline || stop.load(Ordering::SeqCst) {
811 break;
812 }
813 std::thread::sleep(POLL);
814 }
815 let _ = child.kill();
816 let _ = child.wait();
817 false
818}
819
820#[derive(Debug)]
824pub struct LogMatcher {
825 pending: Vec<String>,
826 tails: [String; 2],
828 states: [Escape; 2],
830}
831
832#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
833enum Escape {
834 #[default]
835 Text,
836 Start,
838 Csi,
840 Control,
842 ControlEnd,
844}
845
846impl LogMatcher {
847 pub fn new(patterns: Vec<String>) -> Self {
848 Self {
849 pending: patterns,
850 tails: Default::default(),
851 states: Default::default(),
852 }
853 }
854
855 pub fn feed(&mut self, stderr: bool, bytes: &[u8]) -> bool {
857 let stream = usize::from(stderr);
858 let text = String::from_utf8_lossy(bytes);
859 let (tail, state) = (&mut self.tails[stream], &mut self.states[stream]);
860 for c in text.chars() {
861 *state = match (*state, c) {
862 (Escape::Text, '\u{1b}') => Escape::Start,
863 (Escape::Text, c) => {
864 tail.push(c);
865 Escape::Text
866 }
867 (Escape::Start, '[') => Escape::Csi,
868 (Escape::Start, ']' | 'P' | 'X' | '^' | '_') => Escape::Control,
869 (Escape::Start, _) => Escape::Text,
870 (Escape::Csi, '\u{40}'..='\u{7e}') => Escape::Text,
871 (Escape::Csi, _) => Escape::Csi,
872 (Escape::Control, '\u{7}') => Escape::Text,
873 (Escape::Control, '\u{1b}') => Escape::ControlEnd,
874 (Escape::Control, _) => Escape::Control,
875 (Escape::ControlEnd, '\\') => Escape::Text,
876 (Escape::ControlEnd, _) => Escape::Control,
877 };
878 }
879 self.pending
880 .retain(|pattern| !tail.contains(pattern.as_str()));
881 let keep = self
882 .pending
883 .iter()
884 .map(|pattern| pattern.len().saturating_sub(1))
885 .max()
886 .unwrap_or(0);
887 if tail.len() > keep {
888 let mut start = tail.len() - keep;
889 while !tail.is_char_boundary(start) {
890 start += 1;
891 }
892 tail.drain(..start);
893 }
894 self.pending.is_empty()
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use serde_json::json;
902
903 fn scope<'a>(options: &'a Map<String, Value>, env: &'a BTreeMap<String, String>) -> Scope<'a> {
904 Scope {
905 workspace_root: "/repo",
906 project_root: "apps/app",
907 project_name: "app",
908 options,
909 env,
910 }
911 }
912
913 fn resolved(
914 ready_when: Value,
915 options: Value,
916 env: &[(&str, &str)],
917 ) -> Result<Option<ReadyCheck>> {
918 let options = options.as_object().unwrap().clone();
919 let env = env
920 .iter()
921 .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
922 .collect();
923 resolve("app:serve", &ready_when, &scope(&options, &env))
924 }
925
926 #[test]
927 fn placeholders_read_options_env_and_fallbacks() {
928 let check = resolved(
929 json!({"url": "http://localhost:{options.port ?? env.PORT ?? 4200}/{projectName}"}),
930 json!({}),
931 &[("PORT", "5001")],
932 )
933 .unwrap()
934 .unwrap();
935 assert_eq!(check.probe, Probe::Url("http://localhost:5001/app".into()));
936 assert_eq!(check.timeout, Duration::from_millis(600_000));
937 assert_eq!(check.interval, Duration::from_millis(250));
938 let check = resolved(
939 json!({"port": "{options.port}"}),
940 json!({"port": 3000}),
941 &[],
942 )
943 .unwrap()
944 .unwrap();
945 assert_eq!(
946 check.probe,
947 Probe::Port {
948 port: 3000,
949 host: None
950 }
951 );
952 let check = resolved(
953 json!({"logMatches": ["ready on {options.server.port}", "{other}"], "timeout": 5}),
954 json!({"server": {"port": 1}}),
955 &[],
956 )
957 .unwrap()
958 .unwrap();
959 assert_eq!(
960 check.probe,
961 Probe::LogMatches(vec!["ready on 1".into(), "{other}".into()])
962 );
963 assert_eq!(check.timeout, Duration::from_millis(5));
964 }
965
966 #[test]
967 fn a_port_the_server_picks_leaves_nothing_to_check() {
968 for port in [json!(0), json!("auto"), json!("{options.port}")] {
969 let options = json!({"port": ""});
970 assert_eq!(resolved(json!({"port": port}), options, &[]).unwrap(), None);
971 }
972 }
973
974 #[test]
975 fn a_flag_without_a_value_leaves_host_and_port_unset() {
976 let check = resolved(
977 json!({"port": "{options.port ?? 4200}", "host": "{options.host ?? env.HOST}"}),
978 json!({"port": "true", "host": "true"}),
979 &[],
980 );
981 assert!(format!("{:#}", check.unwrap_err()).contains("has no value"));
982 let check = resolved(
983 json!({"port": "{options.port ?? 4200}"}),
984 json!({"port": true}),
985 &[],
986 )
987 .unwrap()
988 .unwrap();
989 assert_eq!(
990 check.probe,
991 Probe::Port {
992 port: 4200,
993 host: None
994 }
995 );
996 }
997
998 #[test]
999 fn a_signal_waits_for_the_task_to_report() {
1000 let check = resolved(json!({"signal": true, "timeout": 5}), json!({}), &[])
1001 .unwrap()
1002 .unwrap();
1003 assert_eq!(
1004 (check.probe.clone(), check.describe()),
1005 (Probe::Signal, "signal".into())
1006 );
1007 let verdict = Verdict::default();
1009 wait(
1010 "app:serve",
1011 &check,
1012 Path::new("."),
1013 &verdict,
1014 &AtomicBool::new(false),
1015 );
1016 assert!(
1017 verdict
1018 .failure()
1019 .unwrap()
1020 .contains("did not become ready within 5ms")
1021 );
1022 let verdict = Verdict::default();
1023 verdict.set_ready_at(Some("http://localhost:1/".into()));
1024 verdict.set_ready_at(Some("http://localhost:2/".into()));
1025 assert_eq!(verdict.url().as_deref(), Some("http://localhost:1/"));
1026 }
1027
1028 #[test]
1029 fn invalid_settings_say_why() {
1030 for (ready_when, reason) in [
1031 (json!({}), "exactly one of"),
1032 (json!({"url": "a", "port": 1}), "exactly one of"),
1033 (json!({"url": "ftp://x"}), "http or https"),
1034 (json!({"port": 70000}), "from 0 to 65535"),
1035 (json!({"port": "{options.missing}"}), "has no value"),
1036 (json!({"command": ""}), "non-empty"),
1037 (json!({"logMatches": []}), "logMatches"),
1038 (json!({"port": 1, "timeout": 0}), "\"timeout\""),
1039 (json!({"port": 1, "other": 0}), "unknown key"),
1040 (json!({"signal": false}), "\"signal\" must be true"),
1041 (json!({"signal": "yes"}), "\"signal\" must be true"),
1042 (
1043 json!({"signal": true, "interval": 10}),
1044 "not used by \"signal\"",
1045 ),
1046 (json!({"signal": true, "port": 1}), "exactly one of"),
1047 (
1048 json!({"url": "http://{options.a ?? x ?? options.b}"}),
1049 "can never be used",
1050 ),
1051 ] {
1052 let error = resolved(ready_when.clone(), json!({}), &[]).unwrap_err();
1053 assert!(
1054 format!("{error:#}").contains(reason),
1055 "{ready_when}: {error:#}"
1056 );
1057 }
1058 }
1059
1060 #[test]
1061 fn log_matcher_needs_every_pattern_without_control_sequences() {
1062 let mut matcher = LogMatcher::new(vec!["ready".into(), "listening".into()]);
1063 assert!(!matcher.feed(false, b"server listening on 3000"));
1064 assert!(matcher.feed(true, b"\x1b[32mrea\x1b[0mdy"));
1065 let mut matcher = LogMatcher::new(vec!["ready".into()]);
1066 assert!(!matcher.feed(false, b"rea\x1b["));
1067 assert!(matcher.feed(false, b"32mdy"));
1068 let mut matcher = LogMatcher::new(vec!["ready".into()]);
1069 assert!(!matcher.feed(false, b"\x1b]0;ready\x07"));
1070 assert!(matcher.feed(false, b"rea\x1b]8;;http://x\x1b\\dy"));
1071 let mut matcher = LogMatcher::new(vec!["éready".into()]);
1072 assert!(!matcher.feed(false, "éééa".as_bytes()));
1073 assert!(matcher.feed(false, "éready".as_bytes()));
1074 }
1075
1076 #[test]
1077 fn port_and_command_probes_wait_for_readiness() {
1078 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1079 let port = listener.local_addr().unwrap().port();
1080 let stop = AtomicBool::new(false);
1081 for host in [None, Some("127.0.0.1"), Some("0.0.0.0")] {
1082 let verdict = Verdict::default();
1083 let check = ReadyCheck {
1084 probe: Probe::Port {
1085 port,
1086 host: host.map(str::to_owned),
1087 },
1088 timeout: Duration::from_secs(5),
1089 interval: Duration::from_millis(10),
1090 };
1091 wait("app:serve", &check, Path::new("."), &verdict, &stop);
1092 assert!(verdict.is_ready(), "{host:?}");
1093 }
1094 drop(listener);
1095 let verdict = Verdict::default();
1096 let check = ReadyCheck {
1097 probe: Probe::Command {
1098 command: "exit 1".into(),
1099 env: BTreeMap::new(),
1100 },
1101 timeout: Duration::from_millis(100),
1102 interval: Duration::from_millis(10),
1103 };
1104 wait("app:serve", &check, Path::new("."), &verdict, &stop);
1105 assert!(
1106 verdict
1107 .failure()
1108 .unwrap()
1109 .contains("did not become ready within 100ms")
1110 );
1111 let verdict = Verdict::default();
1112 let check = ReadyCheck {
1113 probe: Probe::Command {
1114 command: "exit 0".into(),
1115 env: BTreeMap::new(),
1116 },
1117 ..check
1118 };
1119 wait("app:serve", &check, Path::new("."), &verdict, &stop);
1120 assert!(verdict.is_ready());
1121 }
1122
1123 #[test]
1124 fn url_probes_take_statuses_up_to_403_and_try_index_html() {
1125 use std::io::{Read, Write};
1126 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1127 let port = listener.local_addr().unwrap().port();
1128 std::thread::spawn(move || {
1129 for mut stream in listener.incoming().flatten() {
1130 let mut request = Vec::new();
1131 let mut byte = [0u8; 1];
1132 while stream.read(&mut byte).is_ok_and(|n| n == 1) {
1133 request.push(byte[0]);
1134 if request.ends_with(b"\r\n\r\n") {
1135 break;
1136 }
1137 }
1138 let request = String::from_utf8_lossy(&request);
1139 let status = if request.starts_with("GET /index.html") {
1140 "200 OK"
1141 } else if request.starts_with("GET /missing") || request.starts_with("GET / ") {
1142 "404 Not Found"
1143 } else {
1144 "302 Found\r\nlocation: /elsewhere"
1145 };
1146 let _ = stream.write_all(
1147 format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\n\r\n").as_bytes(),
1148 );
1149 }
1150 });
1151 let deadline = Instant::now() + Duration::from_secs(5);
1152 assert!(probe_url(&format!("http://127.0.0.1:{port}/"), deadline));
1153 assert!(probe_url(
1154 &format!("http://0.0.0.0:{port}/redirects"),
1155 deadline
1156 ));
1157 assert!(!probe_url(
1158 &format!("http://127.0.0.1:{port}/missing"),
1159 deadline
1160 ));
1161 }
1162
1163 #[test]
1164 fn no_proxy_takes_every_form_nx_documents() {
1165 for (no_proxy, host, port, matches) in [
1166 ("*", "app.example.com", 80, true),
1167 ("*", "10.0.0.5", 80, true),
1168 ("example.com", "example.com", 80, true),
1169 ("example.com", "a.b.example.com", 80, true),
1170 (".Example.COM", "app.example.com", 80, true),
1171 ("example.com", "badexample.com", 80, false),
1172 ("*.example.com", "app.example.com", 80, true),
1173 ("*.example.com", "example.com", 80, false),
1174 ("localhost, *.example.com", "app.example.com.", 80, true),
1175 ("app.example.com:8080", "app.example.com", 8080, true),
1176 ("app.example.com:8080", "app.example.com", 9090, false),
1177 ("*.example.com:443", "app.example.com", 443, true),
1178 ("10.0.0.5", "10.0.0.5", 80, true),
1179 ("10.0.0.5:8080", "10.0.0.6", 8080, false),
1180 ("[fd00::1]:8080", "[fd00::1]", 8080, true),
1181 ("fd00::1", "[fd00::1]", 80, true),
1182 ("fd00::1:2:3", "[fd00::1:2]", 3, false),
1183 ("10.0.0.0/8", "10.200.3.4", 80, true),
1184 ("10.0.0.0/8", "11.0.0.1", 80, false),
1185 ("10.0.0.0/8", "ten.example.com", 80, false),
1186 ("fd00::/16", "[fd00:1::5]", 80, true),
1187 ("0.0.0.0/0", "192.0.2.1", 80, true),
1188 ("", "example.com", 80, false),
1189 ("a.test, ", "example.com", 80, false),
1190 ] {
1191 assert_eq!(
1192 no_proxy_matches(no_proxy, host, port),
1193 matches,
1194 "{no_proxy:?} {host}:{port}"
1195 );
1196 }
1197 }
1198
1199 fn vars<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&[&str]) -> String + 'a {
1200 move |names: &[&str]| {
1201 names
1202 .iter()
1203 .find_map(|name| set.iter().find(|(set, _)| set == name))
1204 .map(|(_, value)| value.to_string())
1205 .unwrap_or_default()
1206 }
1207 }
1208
1209 #[test]
1210 fn a_url_takes_the_proxy_for_its_scheme_unless_no_proxy_covers_it() {
1211 let proxy = |url: &str, set: &[(&str, &str)]| {
1212 proxy_for(url, &vars(set)).map(|proxy| proxy.uri().to_string())
1213 };
1214 let both = [
1215 ("HTTP_PROXY", "http://plain:3128"),
1216 ("https_proxy", "http://secure:3128"),
1217 ];
1218 assert_eq!(
1219 proxy("http://app.example.com/", &both).as_deref(),
1220 Some("http://plain:3128/")
1221 );
1222 assert_eq!(
1223 proxy("https://app.example.com/", &both).as_deref(),
1224 Some("http://secure:3128/")
1225 );
1226 assert_eq!(
1227 proxy(
1228 "https://app.example.com/",
1229 &[("HTTP_PROXY", "http://plain:3128")]
1230 ),
1231 None
1232 );
1233 assert_eq!(
1234 proxy(
1235 "https://app.example.com/",
1236 &[("ALL_PROXY", "http://any:3128")]
1237 )
1238 .as_deref(),
1239 Some("http://any:3128/")
1240 );
1241 let skipped = [
1242 ("HTTP_PROXY", "http://plain:3128"),
1243 ("NO_PROXY", "*.example.com"),
1244 ];
1245 assert_eq!(proxy("http://app.example.com/", &skipped), None);
1246 let other_port = [
1247 ("HTTP_PROXY", "http://plain:3128"),
1248 ("no_proxy", "app.example.com:8080"),
1249 ];
1250 assert!(proxy("http://app.example.com/", &other_port).is_some());
1251 }
1252
1253 fn proxy_server(connections: std::sync::Arc<AtomicU8>) -> u16 {
1256 use std::io::{Read, Write};
1257 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1258 let port = listener.local_addr().unwrap().port();
1259 std::thread::spawn(move || {
1260 for mut stream in listener.incoming().flatten() {
1261 connections.fetch_add(1, Ordering::SeqCst);
1262 let head = |stream: &mut std::net::TcpStream| {
1263 let mut head = Vec::new();
1264 let mut byte = [0u8; 1];
1265 while stream.read(&mut byte).is_ok_and(|n| n == 1) {
1266 head.push(byte[0]);
1267 if head.ends_with(b"\r\n\r\n") {
1268 break;
1269 }
1270 }
1271 head
1272 };
1273 if head(&mut stream).starts_with(b"CONNECT ") {
1274 let _ = stream.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n");
1275 head(&mut stream);
1276 }
1277 let _ = stream.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n");
1278 }
1279 });
1280 port
1281 }
1282
1283 #[test]
1284 fn a_remote_url_is_requested_through_the_proxy() {
1285 let requests = std::sync::Arc::new(AtomicU8::new(0));
1286 let port = proxy_server(requests.clone());
1287 let proxy = format!("http://127.0.0.1:{port}");
1288 let url = "http://app.example.invalid:8080/";
1290 let deadline = || Instant::now() + Duration::from_secs(5);
1291 let tls = || ureq::tls::TlsConfig::builder().build();
1292 let through = proxy_for(url, &vars(&[("HTTP_PROXY", &proxy)]));
1293 assert!(answers(url, through, tls(), deadline()));
1294 assert_eq!(requests.load(Ordering::SeqCst), 1);
1295 let around = proxy_for(
1296 url,
1297 &vars(&[("HTTP_PROXY", &proxy), ("NO_PROXY", "*.example.invalid")]),
1298 );
1299 assert!(!answers(url, around, tls(), deadline()));
1300 assert_eq!(requests.load(Ordering::SeqCst), 1);
1301 }
1302
1303 #[test]
1304 fn a_url_trusts_the_certificates_node_extra_ca_certs_names() {
1305 use rustls::pki_types::pem::PemObject;
1306 use rustls::pki_types::{CertificateDer, PrivateKeyDer};
1307 use std::io::{Read, Write};
1308 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/readiness/fixtures");
1309 let config = std::sync::Arc::new(
1310 rustls::ServerConfig::builder_with_provider(std::sync::Arc::new(
1311 rustls::crypto::ring::default_provider(),
1312 ))
1313 .with_safe_default_protocol_versions()
1314 .unwrap()
1315 .with_no_client_auth()
1316 .with_single_cert(
1317 vec![CertificateDer::from_pem_file(fixtures.join("server.pem")).unwrap()],
1318 PrivateKeyDer::from_pem_file(fixtures.join("server.key")).unwrap(),
1319 )
1320 .unwrap(),
1321 );
1322 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1323 let port = listener.local_addr().unwrap().port();
1324 std::thread::spawn(move || {
1325 for mut stream in listener.incoming().flatten() {
1326 let Ok(connection) = rustls::ServerConnection::new(config.clone()) else {
1327 continue;
1328 };
1329 let mut tls = rustls::StreamOwned::new(connection, &mut stream);
1330 let mut request = Vec::new();
1331 let mut byte = [0u8; 1];
1332 while tls.read(&mut byte).is_ok_and(|n| n == 1) {
1333 request.push(byte[0]);
1334 if request.ends_with(b"\r\n\r\n") {
1335 let _ = tls.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n");
1336 let _ = tls.flush();
1337 break;
1338 }
1339 }
1340 }
1341 });
1342 let url = format!("https://127.0.0.1:{port}/");
1343 let deadline = || Instant::now() + Duration::from_secs(5);
1344 let trusting =
1345 |roots: ureq::tls::RootCerts| ureq::tls::TlsConfig::builder().root_certs(roots).build();
1346 let extra = extra_roots(&fixtures.join("ca.pem"));
1347 assert_eq!(extra.len(), 1);
1348 assert!(answers(
1349 &url,
1350 None,
1351 trusting(ureq::tls::RootCerts::new_with_certs(&extra)),
1352 deadline()
1353 ));
1354 assert!(!answers(
1355 &url,
1356 None,
1357 trusting(ureq::tls::RootCerts::WebPki),
1358 deadline()
1359 ));
1360 assert!(extra_roots(&fixtures.join("absent.pem")).is_empty());
1361 }
1362}