soma_fleet/
openssh_driver.rs1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use tokio::io::AsyncWriteExt;
6use tokio_util::sync::CancellationToken;
7
8use crate::{
9 CommandExecutor, CommandOutput, CommandRequest, ConnectionPool, FleetError, FleetResult,
10 HostEndpoint, HostId, HostRecord, OpenSshConnection, OpenSshConnector, TopologySnapshot,
11 io::drain_bounded,
12};
13
14pub struct OpenSshDriver {
16 pool: ConnectionPool<OpenSshConnector>,
17}
18
19impl Default for OpenSshDriver {
20 fn default() -> Self {
21 Self::new(OpenSshConnector::default())
22 }
23}
24
25impl OpenSshDriver {
26 #[must_use]
28 pub fn new(connector: OpenSshConnector) -> Self {
29 Self {
30 pool: ConnectionPool::new(Arc::new(connector)),
31 }
32 }
33
34 pub async fn invalidate_host(&self, host: &HostId) -> FleetResult<usize> {
36 self.pool.invalidate_host(host).await
37 }
38
39 pub async fn retain_snapshot(&self, snapshot: &TopologySnapshot) -> FleetResult<usize> {
41 self.pool.retain_snapshot(snapshot).await
42 }
43
44 pub async fn shutdown(&self) -> FleetResult<usize> {
46 self.pool.shutdown().await
47 }
48
49 pub async fn connection(
51 &self,
52 host: &HostRecord,
53 cancellation: &CancellationToken,
54 ) -> FleetResult<Arc<OpenSshConnection>> {
55 self.pool.get_or_connect(host, cancellation).await
56 }
57}
58
59#[async_trait]
60impl CommandExecutor for OpenSshDriver {
61 async fn execute(
62 &self,
63 host: &HostRecord,
64 request: &CommandRequest,
65 cancellation: &CancellationToken,
66 ) -> FleetResult<CommandOutput> {
67 if !matches!(host.endpoint(), HostEndpoint::Ssh(_)) {
68 return Err(FleetError::Command {
69 host: host.id().clone(),
70 message: "OpenSSH driver requires an SSH endpoint".into(),
71 });
72 }
73 if request.working_dir().is_some() {
74 return Err(FleetError::Command {
75 host: host.id().clone(),
76 message:
77 "remote working directories are unsupported without a typed remote launcher"
78 .into(),
79 });
80 }
81 if cancellation.is_cancelled() {
82 return Err(FleetError::Cancelled);
83 }
84 request.validate_at(soma_ops::Timestamp::now())?;
85 let connection = self.connection(host, cancellation).await?;
86 if connection.revision() != host.revision() {
87 return Err(FleetError::StaleTopology {
88 host: host.id().clone(),
89 expected: connection.revision().clone(),
90 actual: host.revision().clone(),
91 });
92 }
93 let permit_timeout = remaining(request.deadline())?;
94 let permit = tokio::select! {
95 () = cancellation.cancelled() => return Err(FleetError::Cancelled),
96 result = tokio::time::timeout(permit_timeout, connection.acquire_permit()) => match result {
97 Err(_) => return Err(FleetError::DeadlineExceeded),
98 Ok(Err(_)) => return Err(FleetError::Connection {
99 host: host.id().clone(),
100 message: "OpenSSH execution semaphore is closed".into(),
101 }),
102 Ok(Ok(permit)) => permit,
103 }
104 };
105 let session = connection.session().await?;
106 let mut command = session.arc_command(request.program().to_owned());
107 command.args(request.args());
108 command
109 .stdin(if request.stdin().is_some() {
110 openssh::Stdio::piped()
111 } else {
112 openssh::Stdio::null()
113 })
114 .stdout(openssh::Stdio::piped())
115 .stderr(openssh::Stdio::piped());
116 let mut child = command.spawn().await.map_err(|error| FleetError::Command {
117 host: host.id().clone(),
118 message: format!("OpenSSH spawn failed: {error}"),
119 })?;
120 let input = match request.stdin() {
121 Some(bytes) => Some((
122 child.stdin().take().ok_or_else(|| FleetError::Command {
123 host: host.id().clone(),
124 message: "OpenSSH stdin pipe unavailable".into(),
125 })?,
126 bytes.to_vec(),
127 )),
128 None => None,
129 };
130 let stdout = child.stdout().take().ok_or_else(|| FleetError::Command {
131 host: host.id().clone(),
132 message: "OpenSSH stdout pipe unavailable".into(),
133 })?;
134 let stderr = child.stderr().take().ok_or_else(|| FleetError::Command {
135 host: host.id().clone(),
136 message: "OpenSSH stderr pipe unavailable".into(),
137 })?;
138 let timeout = remaining(request.deadline())?;
139 let completion = async move {
140 let streams = async {
141 tokio::try_join!(
142 drain_bounded(stdout, request.max_stdout_bytes()),
143 drain_bounded(stderr, request.max_stderr_bytes())
144 )
145 .map_err(openssh::Error::ChildIo)
146 };
147 let input = async move {
148 if let Some((mut stdin, bytes)) = input {
149 stdin
150 .write_all(&bytes)
151 .await
152 .map_err(openssh::Error::ChildIo)?;
153 stdin.shutdown().await.map_err(openssh::Error::ChildIo)?;
154 }
155 Ok::<_, openssh::Error>(())
156 };
157 let (status, (stdout, stderr), ()) = tokio::try_join!(child.wait(), streams, input)?;
158 Ok::<_, openssh::Error>((status, stdout, stderr))
159 };
160 let mut completion = Box::pin(completion);
161 let result = tokio::select! {
162 () = cancellation.cancelled() => Err(FleetError::RemoteCommandDetached {
163 host: host.id().clone(),
164 reason: "cancellation",
165 }),
166 result = tokio::time::timeout(timeout, &mut completion) => match result {
167 Err(_) => Err(FleetError::RemoteCommandDetached {
168 host: host.id().clone(),
169 reason: "deadline",
170 }),
171 Ok(Err(error)) => Err(FleetError::Command {
172 host: host.id().clone(),
173 message: format!("OpenSSH command I/O failed: {error}"),
174 }),
175 Ok(Ok((status, (stdout, stdout_truncated), (stderr, stderr_truncated)))) => {
176 Ok(CommandOutput::new(
177 stdout,
178 stderr,
179 status.code(),
180 stdout_truncated || stderr_truncated,
181 ))
182 }
183 }
184 };
185 drop(completion);
186 drop(permit);
187 if result.is_err() {
188 let _ = self.invalidate_host(host.id()).await;
189 }
190 result
191 }
192}
193
194fn remaining(deadline: soma_ops::Timestamp) -> FleetResult<Duration> {
195 let millis = deadline
196 .unix_millis()
197 .saturating_sub(soma_ops::Timestamp::now().unix_millis());
198 if millis <= 0 {
199 Err(FleetError::DeadlineExceeded)
200 } else {
201 Ok(Duration::from_millis(millis as u64))
202 }
203}
204
205#[cfg(test)]
206#[path = "openssh_driver_tests.rs"]
207mod tests;