1use std::io::{Read, Write};
12use std::net::{SocketAddr, TcpListener, TcpStream};
13use std::time::Duration;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Probe {
18 Free,
20 Ours(ServiceInfo),
22 Foreign(String),
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
28pub struct ServiceInfo {
29 pub service: String,
30 pub version: Option<String>,
31 pub capabilities: Vec<String>,
32 pub pid: Option<u64>,
33 pub control_token_file: Option<String>,
35}
36
37impl ServiceInfo {
38 pub fn older_than(&self, minimum: &str) -> bool {
40 match (self.version.as_deref().and_then(parse_version), parse_version(minimum)) {
41 (Some(found), Some(min)) => found < min,
42 _ => false,
43 }
44 }
45
46 pub fn supports(&self, capability: &str) -> bool {
48 self.capabilities.iter().any(|c| c == capability)
49 }
50}
51
52pub fn parse_version(raw: &str) -> Option<(u64, u64, u64)> {
54 let core = raw.trim().trim_start_matches('v').split(['-', '+']).next()?;
55 let mut parts = core.split('.').map(|p| p.parse::<u64>().ok());
56 Some((parts.next()??, parts.next().flatten().unwrap_or(0), parts.next().flatten().unwrap_or(0)))
57}
58
59pub fn probe(addr: SocketAddr, service: &str, timeout: Duration) -> Probe {
61 if addr.port() == 0 {
62 return Probe::Free;
63 }
64 match TcpListener::bind(addr) {
65 Ok(listener) => {
66 drop(listener);
67 return Probe::Free;
68 }
69 Err(error) if error.kind() != std::io::ErrorKind::AddrInUse => {
70 return Probe::Foreign(format!("cannot bind: {error}"));
71 }
72 Err(_) => {}
73 }
74 match read_health(addr, timeout) {
75 Ok(json) => {
76 let name = json.get("service").and_then(|v| v.as_str()).unwrap_or("");
77 if name != service {
78 return Probe::Foreign(format!("health names {name:?}, not {service:?}"));
79 }
80 Probe::Ours(ServiceInfo {
81 service: name.to_owned(),
82 version: json.get("version").and_then(|v| v.as_str()).map(str::to_owned),
83 capabilities: json
84 .get("capabilities")
85 .and_then(|v| v.as_array())
86 .map(|a| a.iter().filter_map(|c| c.as_str().map(str::to_owned)).collect())
87 .unwrap_or_default(),
88 pid: json.get("pid").and_then(|v| v.as_u64()),
89 control_token_file: json.get("control_token_file").and_then(|v| v.as_str()).map(str::to_owned),
90 })
91 }
92 Err(detail) => Probe::Foreign(detail),
93 }
94}
95
96fn read_health(addr: SocketAddr, timeout: Duration) -> Result<serde_json::Value, String> {
98 let mut stream = TcpStream::connect_timeout(&addr, timeout).map_err(|e| format!("connect: {e}"))?;
99 let _ = stream.set_read_timeout(Some(timeout));
100 let _ = stream.set_write_timeout(Some(timeout));
101 stream
102 .write_all(format!("GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n").as_bytes())
103 .map_err(|e| format!("write: {e}"))?;
104 let mut raw = Vec::new();
105 let _ = (&mut stream).take(64 * 1024).read_to_end(&mut raw);
106 let text = String::from_utf8_lossy(&raw);
107 let (head, body) = text.split_once("\r\n\r\n").ok_or_else(|| "no HTTP response".to_owned())?;
108 let status = head.lines().next().unwrap_or("");
109 if !(status.starts_with("HTTP/1.1 200") || status.starts_with("HTTP/1.0 200")) {
110 return Err(format!("health status: {status}"));
111 }
112 let start = body.find('{').ok_or_else(|| "health body is not JSON".to_owned())?;
113 let end = body.rfind('}').ok_or_else(|| "health body is not JSON".to_owned())?;
114 serde_json::from_str(&body[start..=end]).map_err(|e| format!("health JSON: {e}"))
115}
116
117pub mod control {
119 pub const DRAIN: &str = "control.drain";
120 pub const RELOAD: &str = "control.reload";
121 pub const HANDOFF: &str = "control.handoff";
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum Upgrade {
127 NotNeeded,
128 Handoff,
130 ManualOnly,
132}
133
134pub fn upgrade_plan(info: &ServiceInfo, minimum: &str, force: bool) -> Upgrade {
136 if !force && !info.older_than(minimum) {
137 Upgrade::NotNeeded
138 } else if info.supports(control::HANDOFF) && info.control_token_file.is_some() {
139 Upgrade::Handoff
140 } else if force || info.older_than(minimum) {
141 Upgrade::ManualOnly
142 } else {
143 Upgrade::NotNeeded
144 }
145}
146
147pub fn auto_upgrade_allowed() -> bool {
149 std::env::var("HATCHERY_LOCAL_UPGRADE").map_or(true, |v| !v.eq_ignore_ascii_case("off"))
150}
151
152pub fn handoff(addr: SocketAddr, info: &ServiceInfo, release_after_ms: u64, wait: Duration) -> Result<(), String> {
155 let file = info.control_token_file.as_deref().ok_or_else(|| "service published no control token file".to_owned())?;
156 let token = std::fs::read_to_string(file).map_err(|e| format!("control token file: {}", e.kind()))?;
157 let token = token.trim();
158 let body = format!("{{\"release_after_ms\":{release_after_ms},\"successor\":\"pid:{}\"}}", std::process::id());
159 let timeout = Duration::from_secs(3);
160 let mut stream = TcpStream::connect_timeout(&addr, timeout).map_err(|e| format!("connect: {e}"))?;
161 let _ = stream.set_read_timeout(Some(timeout));
162 let request = format!(
163 "POST /control/handoff?release_after_ms={release_after_ms} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
164 body.len()
165 );
166 stream.write_all(request.as_bytes()).map_err(|e| format!("write: {e}"))?;
167 let mut raw = Vec::new();
168 let _ = (&mut stream).take(16 * 1024).read_to_end(&mut raw);
169 let status = String::from_utf8_lossy(&raw).lines().next().unwrap_or("").to_owned();
170 if !status.contains(" 202") {
171 return Err(format!("handoff refused: {status}"));
172 }
173 wait_port_free(addr, wait)
174}
175
176pub fn wait_port_free(addr: SocketAddr, wait: Duration) -> Result<(), String> {
178 let deadline = std::time::Instant::now() + wait;
179 loop {
180 if TcpListener::bind(addr).is_ok() {
181 return Ok(());
182 }
183 if std::time::Instant::now() >= deadline {
184 return Err(format!("{addr} still held after {}ms", wait.as_millis()));
185 }
186 std::thread::sleep(Duration::from_millis(100));
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 pub(crate) fn serve_once(body: &'static str, status: &'static str) -> SocketAddr {
195 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
196 let addr = listener.local_addr().unwrap();
197 std::thread::spawn(move || {
198 if let Ok((mut s, _)) = listener.accept() {
199 let mut buf = [0u8; 1024];
200 let _ = s.read(&mut buf);
201 let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len());
202 }
203 });
204 addr
205 }
206
207 const T: Duration = Duration::from_secs(2);
208
209 #[test]
210 fn free_and_zero_ports() {
211 let l = TcpListener::bind("127.0.0.1:0").unwrap();
212 let addr = l.local_addr().unwrap();
213 drop(l);
214 assert_eq!(probe(addr, "svc", T), Probe::Free);
215 assert_eq!(probe("127.0.0.1:0".parse().unwrap(), "svc", T), Probe::Free);
216 }
217
218 #[test]
219 fn ours_reports_version_and_capabilities() {
220 let addr = serve_once(r#"{"ok":true,"service":"svc","version":"1.2.3","pid":7,"capabilities":["control.drain"]}"#, "200 OK");
221 let Probe::Ours(info) = probe(addr, "svc", T) else { panic!("not ours") };
222 assert_eq!(info.version.as_deref(), Some("1.2.3"));
223 assert_eq!(info.pid, Some(7));
224 assert!(info.supports(control::DRAIN));
225 assert!(!info.supports(control::HANDOFF));
226 }
227
228 #[test]
229 fn other_service_or_status_is_foreign() {
230 let addr = serve_once(r#"{"service":"other"}"#, "200 OK");
231 assert!(matches!(probe(addr, "svc", T), Probe::Foreign(_)));
232 let addr = serve_once(r#"{"service":"svc"}"#, "500 Internal Server Error");
233 assert!(matches!(probe(addr, "svc", T), Probe::Foreign(_)));
234 }
235
236 #[test]
237 fn silent_socket_is_foreign() {
238 let l = TcpListener::bind("127.0.0.1:0").unwrap();
239 let addr = l.local_addr().unwrap();
240 assert!(matches!(probe(addr, "svc", Duration::from_millis(300)), Probe::Foreign(_)));
241 }
242
243 #[test]
244 fn version_and_upgrade_plan() {
245 assert_eq!(parse_version("0.4.14"), Some((0, 4, 14)));
246 assert_eq!(parse_version("v1.2-rc1"), Some((1, 2, 0)));
247 let old = ServiceInfo { version: Some("0.4.9".into()), ..Default::default() };
248 assert_eq!(upgrade_plan(&old, "0.4.14", false), Upgrade::ManualOnly);
249 let old_ctl = ServiceInfo { version: Some("0.4.9".into()), capabilities: vec![control::HANDOFF.into()], control_token_file: Some("t".into()), ..Default::default() };
250 assert_eq!(upgrade_plan(&old_ctl, "0.4.14", false), Upgrade::Handoff);
251 let new = ServiceInfo { version: Some("0.5.0".into()), ..Default::default() };
252 assert_eq!(upgrade_plan(&new, "0.4.14", false), Upgrade::NotNeeded);
253 let unknown = ServiceInfo::default();
254 assert_eq!(upgrade_plan(&unknown, "0.4.14", false), Upgrade::NotNeeded);
255 }
256
257 #[test]
258 fn forced_takeover_of_same_version() {
259 let same = ServiceInfo { version: Some("1.0.0".into()), capabilities: vec![control::HANDOFF.into()], control_token_file: Some("t".into()), ..Default::default() };
260 assert_eq!(upgrade_plan(&same, "1.0.0", false), Upgrade::NotNeeded);
261 assert_eq!(upgrade_plan(&same, "1.0.0", true), Upgrade::Handoff);
262 let bare = ServiceInfo { version: Some("1.0.0".into()), ..Default::default() };
263 assert_eq!(upgrade_plan(&bare, "1.0.0", true), Upgrade::ManualOnly);
264 }
265
266 #[test]
269 fn handoff_releases_the_port() {
270 let dir = std::env::temp_dir().join(format!("li-handoff-{}", std::process::id()));
271 std::fs::create_dir_all(&dir).unwrap();
272 let token_file = dir.join("control.token");
273 std::fs::write(&token_file, "secret-token-for-test").unwrap();
274 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
275 let addr = listener.local_addr().unwrap();
276 let tf = token_file.display().to_string().replace('\\', "\\\\");
277 std::thread::spawn(move || {
278 for stream in listener.incoming() {
279 let mut s = stream.unwrap();
280 let mut buf = [0u8; 4096];
281 let n = s.read(&mut buf).unwrap_or(0);
282 let req = String::from_utf8_lossy(&buf[..n]).to_string();
283 if req.starts_with("GET /health") {
284 let body = format!(r#"{{"service":"svc","version":"0.1.0","capabilities":["control.handoff"],"control_token_file":"{tf}"}}"#);
285 let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len());
286 } else if req.starts_with("POST /control/handoff") && req.contains("Bearer secret-token-for-test") {
287 let _ = write!(s, "HTTP/1.1 202 Accepted\r\nContent-Length: 2\r\n\r\n{{}}");
288 return; } else {
290 let _ = write!(s, "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n");
291 }
292 }
293 });
294 let Probe::Ours(info) = probe(addr, "svc", T) else { panic!("not ours") };
295 assert_eq!(upgrade_plan(&info, "0.4.0", false), Upgrade::Handoff);
296 handoff(addr, &info, 50, Duration::from_secs(5)).unwrap();
297 assert_eq!(probe(addr, "svc", T), Probe::Free);
298 let _ = std::fs::remove_dir_all(dir);
299 }
300}