1use crate::core::{
2 cli_session::CliSession,
3 config::Config,
4 connect_params::ConnectParams,
5 downloader::{has_cli_release_unavailable_error, Downloader},
6};
7use crate::core::{logger::DynLogger, DAGGER_ENGINE_VERSION};
8use crate::errors::DaggerError;
9use std::path::PathBuf;
10use thiserror::Error;
11
12use super::cli_session::DaggerSessionProc;
13
14#[derive(Default)]
15pub struct Engine {}
16
17impl Engine {
18 pub fn new() -> Self {
19 Self {}
20 }
21
22 #[allow(clippy::wrong_self_convention)]
23 async fn from_cli(&self, cfg: &Config) -> eyre::Result<(ConnectParams, DaggerSessionProc)> {
24 let cli = Downloader::new(DAGGER_ENGINE_VERSION.into())
25 .get_cli()
26 .await;
27
28 self.connect_provisioned_cli(cfg, DAGGER_ENGINE_VERSION, cli)
29 .await
30 }
31
32 async fn connect_provisioned_cli(
33 &self,
34 cfg: &Config,
35 version: &str,
36 cli: Result<PathBuf, DaggerError>,
37 ) -> eyre::Result<(ConnectParams, DaggerSessionProc)> {
38 let (cli, download_error) = match cli {
39 Ok(cli) => (cli, None),
40 Err(download_error) => {
41 let (cli, download_error) =
42 fallback_to_local_cli(download_error, version, cfg.logger.as_ref())?;
43 (cli, Some(download_error))
44 }
45 };
46 let cli_session = CliSession::new();
47
48 match cli_session.connect(cfg, &cli).await {
49 Ok(result) => Ok(result),
50 Err(fallback_error) => match download_error {
51 Some(download_error) => Err(CliPathFallbackError {
52 download_error,
53 fallback_context: format!("failed to use CLI from PATH {cli:?}"),
54 fallback_error,
55 }
56 .into()),
57 None => Err(fallback_error),
58 },
59 }
60 }
61
62 pub async fn start(
63 &self,
64 cfg: &Config,
65 ) -> eyre::Result<(ConnectParams, Option<DaggerSessionProc>)> {
66 tracing::info!("starting dagger-engine");
67
68 if let Ok(conn) = self.from_session_env().await {
69 return Ok((conn, None));
70 }
71
72 if let Ok((conn, child)) = self.from_local_cli(cfg).await {
73 return Ok((conn, Some(child)));
74 }
75
76 let (conn, proc) = self.from_cli(cfg).await?;
77
78 Ok((conn, Some(proc)))
79 }
80
81 #[allow(clippy::wrong_self_convention)]
82 async fn from_session_env(&self) -> eyre::Result<ConnectParams> {
83 let port = std::env::var("DAGGER_SESSION_PORT").map(|p| p.parse::<u64>())??;
84 let token = std::env::var("DAGGER_SESSION_TOKEN")?;
85
86 Ok(ConnectParams {
87 port,
88 session_token: token,
89 })
90 }
91
92 #[allow(clippy::wrong_self_convention)]
93 async fn from_local_cli(
94 &self,
95 cfg: &Config,
96 ) -> eyre::Result<(ConnectParams, DaggerSessionProc)> {
97 let bin: PathBuf = std::env::var("_EXPERIMENTAL_DAGGER_CLI_BIN")?.into();
98 let cli_session = CliSession::new();
99
100 cli_session.connect(cfg, &bin).await
101 }
102}
103
104#[derive(Debug, Error)]
105#[error("{download_error:#}\n{fallback_context}: {fallback_error:#}")]
106struct CliPathFallbackError {
107 download_error: eyre::Error,
108 fallback_context: String,
109 #[source]
110 fallback_error: eyre::Error,
111}
112
113fn fallback_to_local_cli(
114 download_error: DaggerError,
115 version: &str,
116 logger: Option<&DynLogger>,
117) -> eyre::Result<(PathBuf, eyre::Error)> {
118 if !has_cli_release_unavailable_error(&download_error) {
119 return Err(download_error.into());
120 }
121
122 let download_error = eyre::Report::new(download_error);
123 let bin_path = match which::which("dagger") {
124 Ok(bin_path) => bin_path,
125 Err(fallback_error) => {
126 return Err(CliPathFallbackError {
127 download_error,
128 fallback_context: "dagger CLI not found in PATH".into(),
129 fallback_error: fallback_error.into(),
130 }
131 .into())
132 }
133 };
134
135 let warning = format!(
136 "CLI version {version} is unavailable; using {} from PATH (version compatibility is not guaranteed).",
137 bin_path.display()
138 );
139 if let Some(logger) = logger {
140 let _ = logger.stderr(&warning);
143 } else {
144 eprintln!("{warning}");
145 }
146
147 Ok((bin_path, download_error))
148}
149
150#[cfg(test)]
151mod tests {
152 use std::{
153 ffi::OsString,
154 fs::File,
155 io::Write,
156 path::{Path, PathBuf},
157 sync::{Arc, Mutex, MutexGuard},
158 };
159
160 #[cfg(unix)]
161 use std::os::unix::fs::PermissionsExt;
162
163 use eyre::eyre;
164 use reqwest::StatusCode;
165 use tempfile::TempDir;
166
167 use crate::{
168 core::{
169 config::Config,
170 downloader::CliReleaseUnavailableError,
171 logger::{DynLogger, Logger},
172 },
173 errors::DaggerError,
174 };
175
176 use super::{fallback_to_local_cli, CliPathFallbackError, Engine};
177
178 static PATH_LOCK: Mutex<()> = Mutex::new(());
179
180 #[test]
181 fn fallback_to_local_cli_uses_dagger_in_path() {
182 let temp_dir = TempDir::new().unwrap();
183 let bin_path = create_dagger_executable(&temp_dir);
184 let _path_lock = path_lock();
185 let _path = PathGuard::set(temp_dir.path());
186 let logger = Arc::new(TestLogger::default());
187 let dyn_logger: DynLogger = logger.clone();
188
189 let (actual, _) = fallback_to_local_cli(
190 unavailable_download_error(),
191 "unreleased",
192 Some(&dyn_logger),
193 )
194 .unwrap();
195
196 assert_eq!(actual, bin_path);
197 assert_eq!(
198 logger.stderr.lock().unwrap().as_str(),
199 format!(
200 "CLI version unreleased is unavailable; using {} from PATH (version compatibility is not guaranteed).",
201 bin_path.display()
202 )
203 );
204 }
205
206 #[test]
207 fn no_fallback_to_local_cli_for_other_errors() {
208 let download_error = DaggerError::DownloadClient(eyre!("download failed"));
209
210 let error = fallback_to_local_cli(download_error, "unreleased", None).unwrap_err();
211
212 assert!(error.downcast_ref::<DaggerError>().is_some());
213 assert!(format!("{error:#}").contains("download failed"));
214 }
215
216 #[test]
217 fn fallback_preserves_download_and_path_errors() {
218 let temp_dir = TempDir::new().unwrap();
219 let _path_lock = path_lock();
220 let _path = PathGuard::set(temp_dir.path());
221
222 let error =
223 fallback_to_local_cli(unavailable_download_error(), "unreleased", None).unwrap_err();
224
225 let fallback_error = error.downcast_ref::<CliPathFallbackError>().unwrap();
226 assert!(fallback_error
227 .download_error
228 .downcast_ref::<DaggerError>()
229 .is_some());
230 assert!(fallback_error
231 .fallback_error
232 .downcast_ref::<which::Error>()
233 .is_some());
234 let rendered = format!("{error}");
235 assert!(rendered.contains("CLI release unavailable"));
236 assert!(rendered.contains("https://example.test/checksums.txt"));
237 assert!(rendered.contains("dagger CLI not found in PATH"));
238 }
239
240 #[tokio::test]
241 async fn fallback_session_error_preserves_download_error() {
242 let temp_dir = TempDir::new().unwrap();
243 let bin_path = create_dagger_executable(&temp_dir);
244 let _path_lock = path_lock();
245 let _path = PathGuard::set(temp_dir.path());
246 let logger: DynLogger = Arc::new(TestLogger::default());
247 let cfg = Config::builder().logger(logger).build();
248
249 let result = Engine::new()
250 .connect_provisioned_cli(&cfg, "unreleased", Err(unavailable_download_error()))
251 .await;
252 let error = match result {
253 Ok(_) => panic!("expected fallback session to fail"),
254 Err(error) => error,
255 };
256
257 let fallback_error = error.downcast_ref::<CliPathFallbackError>().unwrap();
258 assert!(fallback_error
259 .download_error
260 .downcast_ref::<DaggerError>()
261 .is_some());
262 assert!(fallback_error
263 .fallback_error
264 .downcast_ref::<which::Error>()
265 .is_none());
266 let rendered = format!("{error}");
267 assert!(rendered.contains("CLI release unavailable"));
268 assert!(rendered.contains("https://example.test/checksums.txt"));
269 assert!(rendered.contains(&format!("failed to use CLI from PATH {bin_path:?}")));
270 }
271
272 #[cfg(unix)]
273 #[tokio::test]
274 async fn fallback_session_exiting_without_params_errors_instead_of_hanging() {
275 let temp_dir = TempDir::new().unwrap();
276 let bin_path = temp_dir.path().join("dagger");
277 let mut file = File::create(&bin_path).unwrap();
278 file.write_all(b"#!/bin/sh\nexit 1\n").unwrap();
279 let mut permissions = file.metadata().unwrap().permissions();
280 permissions.set_mode(0o700);
281 file.set_permissions(permissions).unwrap();
282 drop(file);
283
284 let _path_lock = path_lock();
285 let _path = PathGuard::set(temp_dir.path());
286 let logger: DynLogger = Arc::new(TestLogger::default());
287 let cfg = Config::builder().logger(logger).build();
288
289 let result = tokio::time::timeout(
290 std::time::Duration::from_secs(30),
291 Engine::new().connect_provisioned_cli(
292 &cfg,
293 "unreleased",
294 Err(unavailable_download_error()),
295 ),
296 )
297 .await
298 .expect("a session exiting without connect params must error, not hang");
299
300 let error = match result {
301 Ok(_) => panic!("expected fallback session to fail"),
302 Err(error) => error,
303 };
304 assert!(format!("{error:#}").contains("could not receive ok signal"));
305 }
306
307 fn unavailable_download_error() -> DaggerError {
308 let error = CliReleaseUnavailableError {
309 url: "https://example.test/checksums.txt".into(),
310 status: StatusCode::NOT_FOUND,
311 };
312 let error = eyre::Report::new(error).wrap_err("failed to download CLI from archive");
313 DaggerError::DownloadClient(error)
314 }
315
316 fn create_dagger_executable(temp_dir: &TempDir) -> PathBuf {
317 let bin_name = if cfg!(windows) {
318 "dagger.exe"
319 } else {
320 "dagger"
321 };
322 let bin_path = temp_dir.path().join(bin_name);
323 let mut file = File::create(&bin_path).unwrap();
324 file.write_all(b"#!/definitely/missing/dagger-test-interpreter\n")
325 .unwrap();
326
327 #[cfg(unix)]
328 {
329 let mut permissions = file.metadata().unwrap().permissions();
330 permissions.set_mode(0o700);
331 file.set_permissions(permissions).unwrap();
332 }
333
334 bin_path
335 }
336
337 fn path_lock() -> MutexGuard<'static, ()> {
338 PATH_LOCK
339 .lock()
340 .unwrap_or_else(|poisoned| poisoned.into_inner())
341 }
342
343 struct PathGuard(Option<OsString>);
344
345 impl PathGuard {
346 fn set(path: &Path) -> Self {
347 let previous = std::env::var_os("PATH");
348 std::env::set_var("PATH", path);
349 Self(previous)
350 }
351 }
352
353 impl Drop for PathGuard {
354 fn drop(&mut self) {
355 match &self.0 {
356 Some(path) => std::env::set_var("PATH", path),
357 None => std::env::remove_var("PATH"),
358 }
359 }
360 }
361
362 #[derive(Default)]
363 struct TestLogger {
364 stderr: Mutex<String>,
365 }
366
367 impl Logger for TestLogger {
368 fn stdout(&self, _output: &str) -> eyre::Result<()> {
369 Ok(())
370 }
371
372 fn stderr(&self, output: &str) -> eyre::Result<()> {
373 self.stderr.lock().unwrap().push_str(output);
374 Ok(())
375 }
376 }
377}