Skip to main content

dagger_sdk/core/
engine.rs

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        // A failed warning write shouldn't turn a usable fallback into an
141        // error; go/python/typescript ignore it too.
142        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}