mj_controller/worker_client/
connect.rs1use super::*;
2
3impl RelayClient {
4 pub async fn connect(spec: &CommandSpec, expected_session_id: &str) -> Result<Self> {
5 Self::connect_with_timeouts(
6 spec,
7 expected_session_id,
8 RELAY_RPC_TIMEOUT,
9 RELAY_HANDSHAKE_TIMEOUT,
10 )
11 .await
12 }
13
14 #[cfg(all(test, unix))]
15 pub(super) async fn connect_with_timeout(
16 spec: &CommandSpec,
17 expected_session_id: &str,
18 request_timeout: Duration,
19 ) -> Result<Self> {
20 Self::connect_with_timeouts(spec, expected_session_id, request_timeout, request_timeout)
21 .await
22 }
23
24 pub(super) async fn connect_with_timeouts(
32 spec: &CommandSpec,
33 expected_session_id: &str,
34 request_timeout: Duration,
35 handshake_timeout: Duration,
36 ) -> Result<Self> {
37 for attempt in 1..=SSH_RETRY_ATTEMPTS {
38 let outcome = Self::connect_attempt(
39 spec,
40 expected_session_id,
41 request_timeout,
42 handshake_timeout,
43 )
44 .await;
45 let error = match outcome {
46 Ok(client) => return Ok(client),
47 Err(ConnectFailure {
48 error,
49 transport_rejected,
50 }) => {
51 if attempt == SSH_RETRY_ATTEMPTS || !transport_rejected {
52 return Err(error);
53 }
54 error
55 }
56 };
57 let delay = mj_core::targets::ssh_retry_delay(attempt);
58 tracing::warn!(
59 session_id = %expected_session_id,
60 destination = spec.ssh_destination.as_deref().unwrap_or_default(),
61 purpose = %spec.purpose,
62 attempt,
63 attempts = SSH_RETRY_ATTEMPTS,
64 delay_ms = delay.as_millis() as u64,
65 error = %error,
66 "relay proxy was refused by the SSH server before authentication; retrying"
67 );
68 tokio::time::sleep(delay).await;
69 }
70 unreachable!("the final attempt always returns");
71 }
72
73 pub(super) async fn connect_attempt(
82 spec: &CommandSpec,
83 expected_session_id: &str,
84 request_timeout: Duration,
85 handshake_timeout: Duration,
86 ) -> std::result::Result<Self, ConnectFailure> {
87 let Some(destination) = spec.ssh_destination.clone() else {
88 return Self::spawn_and_handshake(
89 spec,
90 expected_session_id,
91 request_timeout,
92 handshake_timeout,
93 None,
94 None,
95 )
96 .await;
97 };
98 let requested = spec.clone();
99 let prepared = tokio::task::spawn_blocking(move || {
100 let (spec, lease) = requested
103 .open_ssh_session(&BoundedProcessExecutor::new(SSH_MASTER_OPEN_TIMEOUT))?
104 .into_parts();
105 let permit = SshAdmission::acquire(&destination);
106 Ok::<_, anyhow::Error>((spec, lease, permit))
107 })
108 .await;
109 let (spec, lease, permit) = match prepared {
110 Ok(Ok(prepared)) => prepared,
111 Ok(Err(error)) => {
112 return Err(ConnectFailure::plain(
113 error.context("open the SSH session for the relay proxy"),
114 ));
115 }
116 Err(error) => {
117 return Err(ConnectFailure::plain(anyhow!(
118 "SSH admission for the relay proxy was cancelled: {error}"
119 )));
120 }
121 };
122 Self::spawn_and_handshake(
123 &spec,
124 expected_session_id,
125 request_timeout,
126 handshake_timeout,
127 Some(permit),
128 lease,
129 )
130 .await
131 }
132
133 pub(super) async fn spawn_and_handshake(
134 spec: &CommandSpec,
135 expected_session_id: &str,
136 request_timeout: Duration,
137 handshake_timeout: Duration,
138 permit: Option<SshPermit>,
139 ssh_session: Option<SshSessionLease>,
140 ) -> std::result::Result<Self, ConnectFailure> {
141 let mut child = Command::new(&spec.program)
142 .args(&spec.args)
143 .envs(&spec.env)
144 .stdin(Stdio::piped())
145 .stdout(Stdio::piped())
146 .stderr(Stdio::piped())
150 .kill_on_drop(true)
151 .spawn()
152 .with_context(|| format!("start session relay proxy for {}", spec.purpose))
153 .map_err(|error| {
154 tracing::warn!(
155 session_id = %expected_session_id,
156 operation = "connect",
157 purpose = %spec.purpose,
158 error = %error,
159 "could not start relay proxy"
160 );
161 error
162 })?;
163 let stderr_tail: ProxyStderrTail = Default::default();
164 let draining = child.stderr.take().map(|errors| {
165 let purpose = spec.purpose.clone();
166 let session_id = expected_session_id.to_owned();
167 let tail = stderr_tail.clone();
168 tokio::spawn(drain_proxy_stderr(errors, purpose, session_id, tail))
169 });
170 let input = child
171 .stdin
172 .take()
173 .context("relay proxy stdin unavailable")
174 .map_err(|error| {
175 tracing::warn!(
176 session_id = %expected_session_id,
177 operation = "connect",
178 purpose = %spec.purpose,
179 error = %error,
180 "relay proxy did not provide stdin"
181 );
182 error
183 })?;
184 let output = child
185 .stdout
186 .take()
187 .context("relay proxy stdout unavailable")
188 .map_err(|error| {
189 tracing::warn!(
190 session_id = %expected_session_id,
191 operation = "connect",
192 purpose = %spec.purpose,
193 error = %error,
194 "relay proxy did not provide stdout"
195 );
196 error
197 })?;
198 let mut nonce_bytes = [0_u8; 8];
199 getrandom::fill(&mut nonce_bytes).map_err(|error| {
200 let error = anyhow!("generate relay request nonce: {error}");
201 tracing::warn!(
202 session_id = %expected_session_id,
203 operation = "connect",
204 error = %error,
205 "could not initialize relay request nonce"
206 );
207 error
208 })?;
209 let mut client = Self {
210 child: Some(child),
211 input: Some(input),
212 output: BufReader::new(output),
213 request_timeout,
214 abandoned: None,
215 next_request: 1,
216 connection_nonce: u64::from_le_bytes(nonce_bytes),
217 protocol_version: RELAY_PROTOCOL_VERSION,
218 session_id: expected_session_id.to_owned(),
222 relay_version: String::new(),
223 worker_build: None,
224 latest_ordinal: 0,
225 latest_digest: RELAY_EVENT_GENESIS_DIGEST.to_owned(),
226 ssh_session,
227 };
228 match client
229 .complete_handshake(expected_session_id, handshake_timeout)
230 .await
231 {
232 Ok(()) => {
233 drop(permit);
236 Ok(client)
238 }
239 Err(error) => {
240 let status = match client.child.as_mut() {
244 Some(child) => {
245 match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
246 Ok(Ok(status)) => status.code(),
247 _ => {
254 let _ = child.start_kill();
255 None
256 }
257 }
258 }
259 None => None,
260 };
261 let tail = Self::proxy_stderr_tail(draining, &stderr_tail).await;
262 let transport_rejected = permit.is_some()
263 && status
264 .is_some_and(|status| is_transport_rejection(status, &tail.join("\n")));
265 drop(permit);
266 if transport_rejected && let Some(lease) = &client.ssh_session {
269 lease.invalidate();
270 }
271 Err(ConnectFailure {
272 error: Self::attach_proxy_stderr(error, tail),
273 transport_rejected,
274 })
275 }
276 }
277 }
278
279 pub(super) async fn proxy_stderr_tail(
287 draining: Option<tokio::task::JoinHandle<()>>,
288 tail: &ProxyStderrTail,
289 ) -> Vec<String> {
290 if let Some(handle) = draining
291 && tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, handle)
292 .await
293 .is_err()
294 {
295 tracing::debug!("relay proxy stderr is still open; reporting the lines read so far");
296 }
297 tail.lock()
298 .unwrap_or_else(PoisonError::into_inner)
299 .iter()
300 .cloned()
301 .collect()
302 }
303
304 pub(super) fn attach_proxy_stderr(error: anyhow::Error, lines: Vec<String>) -> anyhow::Error {
308 if lines.is_empty() {
309 return error;
310 }
311 error.context(format!(
312 "relay proxy stderr (last {} lines):\n{}",
313 lines.len(),
314 lines.join("\n")
315 ))
316 }
317
318 pub(super) async fn complete_handshake(
320 &mut self,
321 expected_session_id: &str,
322 handshake_timeout: Duration,
323 ) -> Result<()> {
324 let response = self
325 .call_hello(
326 RelayRequest::Hello {
327 controller_version: env!("CARGO_PKG_VERSION").to_owned(),
328 supported: RelayVersionRange::CURRENT,
329 },
330 handshake_timeout,
331 )
332 .await?;
333 let RelayResponsePayload::Hello {
334 negotiated,
335 relay_version,
336 session_id,
337 worker_build,
338 } = response
339 else {
340 let error = anyhow!("relay returned an unexpected hello response");
341 log_relay_client_failure(self, "hello", "relay-hello", &error);
342 return Err(error);
343 };
344 if session_id != expected_session_id {
345 let error = anyhow!("relay belongs to session {session_id}, not {expected_session_id}");
346 log_relay_client_failure(self, "hello", "relay-hello", &error);
347 return Err(error);
348 }
349 if !RelayVersionRange::CURRENT.contains(negotiated) {
350 let error = anyhow!(
351 "relay negotiated unsupported protocol {negotiated}; this controller supports {}-{}",
352 RELAY_MIN_PROTOCOL_VERSION,
353 RELAY_PROTOCOL_VERSION
354 );
355 log_relay_client_failure(self, "hello", "relay-hello", &error);
356 return Err(error);
357 }
358 self.protocol_version = negotiated;
359 self.session_id = session_id;
360 self.relay_version = relay_version;
361 self.worker_build = worker_build;
362 Ok(())
363 }
364}