Skip to main content

cuenv_dagger/
lib.rs

1//! Dagger backend for cuenv task execution
2//!
3//! This crate provides the `DaggerBackend` implementation that executes tasks
4//! inside containers using the Dagger SDK.
5
6use async_trait::async_trait;
7use cuenv_core::config::BackendConfig;
8use cuenv_core::tasks::{TaskBackend, TaskResult};
9use cuenv_core::{Error, Result};
10use dagger_sdk::{Config, ContainerId, connect_opts};
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13
14type DaggerReport = Box<dyn std::error::Error + Send + Sync + 'static>;
15
16/// Dagger backend - executes tasks inside containers using Dagger
17pub struct DaggerBackend {
18    default_image: Option<String>,
19    project_root: std::path::PathBuf,
20    container_cache: Arc<Mutex<HashMap<String, ContainerId>>>,
21}
22
23impl DaggerBackend {
24    pub fn new(default_image: Option<String>, project_root: std::path::PathBuf) -> Self {
25        Self {
26            default_image,
27            project_root,
28            container_cache: Arc::new(Mutex::new(HashMap::new())),
29        }
30    }
31
32    /// Get the container cache for storing/retrieving container IDs
33    pub fn container_cache(&self) -> &Arc<Mutex<HashMap<String, ContainerId>>> {
34        &self.container_cache
35    }
36}
37
38#[async_trait]
39impl TaskBackend for DaggerBackend {
40    async fn execute(
41        &self,
42        ctx: &cuenv_core::tasks::backend::TaskExecutionContext<'_>,
43    ) -> Result<TaskResult> {
44        let name = ctx.name;
45        let task = ctx.task;
46        let env = ctx.environment;
47        let capture_output = ctx.capture_output;
48
49        let dagger_config = task.dagger.as_ref();
50
51        // Determine if we're using container chaining (from) or a base image
52        let from_task = dagger_config.and_then(|d| d.from.clone());
53        let image = dagger_config
54            .and_then(|d| d.image.clone())
55            .or_else(|| self.default_image.clone());
56
57        // Validate: must have either 'from' or 'image'
58        if from_task.is_none() && image.is_none() {
59            return Err(Error::configuration(
60                "Dagger backend requires either 'image' or 'from' (task reference). \
61                 Set tasks.<name>.dagger.image, tasks.<name>.dagger.from, or config.backend.options.image"
62                    .to_string(),
63            ));
64        }
65
66        let command: Vec<String> = std::iter::once(task.command.clone())
67            .chain(task.args.clone())
68            .collect();
69
70        if command.is_empty() {
71            return Err(Error::configuration(
72                "Dagger task requires a command to execute".to_string(),
73            ));
74        }
75
76        // Resolve secrets before entering the Dagger closure
77        let mut resolved_secrets: Vec<(String, Option<String>, Option<String>, String)> =
78            Vec::new();
79        if let Some(secrets) = dagger_config.and_then(|d| d.secrets.as_ref()) {
80            for secret in secrets {
81                let plaintext = secret.resolver.resolve().await?;
82                resolved_secrets.push((
83                    secret.name.clone(),
84                    secret.path.clone(),
85                    secret.env_var.clone(),
86                    plaintext,
87                ));
88            }
89        }
90
91        // Get cache mounts
92        let cache_mounts: Vec<(String, String)> = dagger_config
93            .and_then(|d| d.cache.as_ref())
94            .map(|caches| {
95                caches
96                    .iter()
97                    .map(|c| (c.path.clone(), c.name.clone()))
98                    .collect()
99            })
100            .unwrap_or_default();
101
102        // Get container ID from cache if using 'from'
103        let cached_container_id = if let Some(ref from_name) = from_task {
104            let cache = self.container_cache.lock().map_err(|_| {
105                Error::configuration("Failed to acquire container cache lock".to_string())
106            })?;
107            cache.get(from_name).cloned()
108        } else {
109            None
110        };
111
112        // Validate that referenced task exists in cache when using 'from'
113        if let Some(ref from_name) = from_task
114            && cached_container_id.is_none()
115        {
116            return Err(Error::configuration(format!(
117                "Task '{}' references container from task '{}', but no container was found. \
118                 Ensure the referenced task runs first (use dependsOn).",
119                name, from_name
120            )));
121        }
122
123        let env_map = env.vars.clone();
124        let project_root = self.project_root.clone();
125        let task_name = name.to_string();
126        let task_name_for_cache = task_name.clone();
127        let container_cache = self.container_cache.clone();
128
129        // Result store: (exit_code, stdout, stderr, container_id)
130        type ResultType = (i32, String, String, Option<ContainerId>);
131        let result_store: Arc<Mutex<Option<std::result::Result<ResultType, DaggerReport>>>> =
132            Arc::new(Mutex::new(None));
133        let result_store_clone = result_store.clone();
134
135        let cfg = Config::default();
136
137        connect_opts(cfg, move |client| {
138            let project_root = project_root.clone();
139            let image = image.clone();
140            let command = command.clone();
141            let env_map = env_map.clone();
142            let result_store = result_store_clone.clone();
143            let resolved_secrets = resolved_secrets.clone();
144            let cache_mounts = cache_mounts.clone();
145            let cached_container_id = cached_container_id.clone();
146            let task_name_inner = task_name.clone();
147
148            async move {
149                let host_dir = client
150                    .host()
151                    .directory(project_root.to_string_lossy().to_string());
152
153                // Create base container: either from cached container or from image
154                // IMPORTANT: Only mount host directory when starting fresh (not chaining)
155                // to preserve files created in /workspace by previous tasks
156                let mut container = if let Some(container_id) = cached_container_id {
157                    // Continue from previous task's container
158                    // DO NOT re-mount /workspace - it would overwrite files from previous tasks
159                    client
160                        .load_container_from_id(container_id)
161                        .with_workdir("/workspace")
162                } else if let Some(img) = image {
163                    // Start from base image - mount host directory at /workspace
164                    client
165                        .container()
166                        .from(img)
167                        .with_mounted_directory("/workspace", host_dir)
168                        .with_workdir("/workspace")
169                } else {
170                    // This shouldn't happen due to earlier validation
171                    if let Ok(mut guard) = result_store.lock() {
172                        *guard = Some(Err("No image or container reference provided".into()));
173                    }
174                    return Ok(());
175                };
176
177                // Mount cache volumes
178                for (path, cache_name) in &cache_mounts {
179                    let cache_vol = client.cache_volume(cache_name);
180                    container = container.with_mounted_cache(path, cache_vol);
181                }
182
183                // Set up secrets
184                for (secret_name, path, env_var, plaintext) in &resolved_secrets {
185                    let dagger_secret = client.set_secret(secret_name, plaintext);
186
187                    if let Some(file_path) = path {
188                        container = container.with_mounted_secret(file_path, dagger_secret.clone());
189                    }
190                    if let Some(var_name) = env_var {
191                        container = container.with_secret_variable(var_name, dagger_secret);
192                    }
193                }
194
195                // Set environment variables
196                for (k, v) in env_map {
197                    container = container.with_env_variable(k, v);
198                }
199
200                // Execute command
201                let exec = container.with_exec(command);
202
203                // Get results
204                let stdout_res = exec.stdout().await;
205                let stderr_res = exec.stderr().await;
206                let exit_code_res = exec.exit_code().await;
207                let container_id_res = exec.id().await;
208
209                let res = match (stdout_res, stderr_res, exit_code_res, container_id_res) {
210                    (Ok(stdout), Ok(stderr), Ok(exit_code), Ok(container_id)) => {
211                        Ok((exit_code as i32, stdout, stderr, Some(container_id)))
212                    }
213                    (Ok(stdout), Ok(stderr), Ok(exit_code), Err(_)) => {
214                        // Container ID fetch failed but execution succeeded
215                        tracing::warn!(
216                            task = %task_name_inner,
217                            "Failed to get container ID for caching"
218                        );
219                        Ok((exit_code as i32, stdout, stderr, None))
220                    }
221                    (Err(e), _, _, _) => Err(e.into()),
222                    (_, Err(e), _, _) => Err(e.into()),
223                    (_, _, Err(e), _) => Err(e.into()),
224                };
225
226                if let Ok(mut guard) = result_store.lock() {
227                    *guard = Some(res);
228                }
229                Ok(())
230            }
231        })
232        .await
233        .map_err(|err| Error::execution(format!("Dagger backend failed: {err}")))?;
234
235        // Extract result
236        let mut guard = result_store
237            .lock()
238            .map_err(|_| Error::execution("Failed to acquire lock on task result".to_string()))?;
239
240        let inner_result = guard
241            .take()
242            .ok_or_else(|| Error::execution("Task completed but produced no result".to_string()))?;
243
244        let (exit_code, stdout, stderr, container_id) = inner_result
245            .map_err(|e: DaggerReport| Error::execution(format!("Dagger execution failed: {e}")))?;
246
247        // Cache the container ID for potential use by subsequent tasks
248        if let Some(cid) = container_id
249            && let Ok(mut cache) = container_cache.lock()
250        {
251            cache.insert(task_name_for_cache.clone(), cid);
252        }
253
254        // Print output if not capturing
255        if !capture_output.should_capture() {
256            if !stdout.is_empty() {
257                print!("{}", stdout);
258            }
259            if !stderr.is_empty() {
260                eprint!("{}", stderr);
261            }
262        }
263
264        Ok(TaskResult {
265            name: task_name_for_cache,
266            exit_code: Some(exit_code),
267            stdout: if capture_output.should_capture() {
268                stdout
269            } else {
270                String::new()
271            },
272            stderr: if capture_output.should_capture() {
273                stderr
274            } else {
275                String::new()
276            },
277            success: exit_code == 0,
278        })
279    }
280
281    fn name(&self) -> &'static str {
282        "dagger"
283    }
284}
285
286/// Create a Dagger backend from configuration
287pub fn create_dagger_backend(
288    config: Option<&BackendConfig>,
289    project_root: std::path::PathBuf,
290) -> Arc<dyn TaskBackend> {
291    let image = config
292        .and_then(|c| c.options.as_ref())
293        .and_then(|o| o.image.clone());
294    Arc::new(DaggerBackend::new(image, project_root))
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use cuenv_core::config::BackendOptions;
301
302    #[test]
303    fn test_dagger_backend_new() {
304        let backend = DaggerBackend::new(Some("alpine:latest".to_string()), "/tmp".into());
305        assert_eq!(backend.default_image, Some("alpine:latest".to_string()));
306        assert_eq!(backend.project_root, std::path::PathBuf::from("/tmp"));
307    }
308
309    #[test]
310    fn test_dagger_backend_new_no_image() {
311        let backend = DaggerBackend::new(None, "/workspace".into());
312        assert!(backend.default_image.is_none());
313        assert_eq!(backend.project_root, std::path::PathBuf::from("/workspace"));
314    }
315
316    #[test]
317    fn test_dagger_backend_container_cache_empty() {
318        let backend = DaggerBackend::new(None, "/tmp".into());
319        let cache = backend.container_cache();
320        let guard = cache.lock().unwrap();
321        assert!(guard.is_empty());
322    }
323
324    #[test]
325    fn test_dagger_backend_name() {
326        let backend = DaggerBackend::new(None, "/tmp".into());
327        assert_eq!(backend.name(), "dagger");
328    }
329
330    #[test]
331    fn test_create_dagger_backend_with_config() {
332        let config = BackendConfig {
333            backend_type: "dagger".to_string(),
334            options: Some(BackendOptions {
335                image: Some("rust:latest".to_string()),
336                platform: None,
337            }),
338        };
339        let backend = create_dagger_backend(Some(&config), "/project".into());
340        assert_eq!(backend.name(), "dagger");
341    }
342
343    #[test]
344    fn test_create_dagger_backend_no_config() {
345        let backend = create_dagger_backend(None, "/project".into());
346        assert_eq!(backend.name(), "dagger");
347    }
348
349    #[test]
350    fn test_create_dagger_backend_config_no_options() {
351        let config = BackendConfig {
352            backend_type: "dagger".to_string(),
353            options: None,
354        };
355        let backend = create_dagger_backend(Some(&config), "/project".into());
356        assert_eq!(backend.name(), "dagger");
357    }
358
359    #[test]
360    fn test_create_dagger_backend_with_platform() {
361        let config = BackendConfig {
362            backend_type: "dagger".to_string(),
363            options: Some(BackendOptions {
364                image: Some("alpine:latest".to_string()),
365                platform: Some("linux/amd64".to_string()),
366            }),
367        };
368        let backend = create_dagger_backend(Some(&config), "/project".into());
369        assert_eq!(backend.name(), "dagger");
370    }
371
372    #[test]
373    fn test_dagger_backend_container_cache_is_shared() {
374        let backend = DaggerBackend::new(None, "/tmp".into());
375        let cache1 = backend.container_cache().clone();
376        let cache2 = backend.container_cache().clone();
377
378        // Insert into cache via first reference
379        {
380            let guard = cache1.lock().unwrap();
381            // ContainerId is a complex type, but we can verify the Arc is shared
382            assert!(guard.is_empty());
383        }
384
385        // Verify second reference sees same state
386        {
387            let guard = cache2.lock().unwrap();
388            assert!(guard.is_empty());
389        }
390    }
391
392    #[test]
393    fn test_dagger_backend_project_root_paths() {
394        let paths = vec![
395            "/home/user/project",
396            "/tmp/build",
397            ".",
398            "./relative/path",
399            "/var/lib/data",
400        ];
401
402        for path in paths {
403            let backend = DaggerBackend::new(None, path.into());
404            assert_eq!(backend.project_root, std::path::PathBuf::from(path));
405        }
406    }
407
408    #[test]
409    fn test_dagger_backend_default_image_variants() {
410        let images = vec![
411            "alpine:latest",
412            "rust:1.75",
413            "node:20-slim",
414            "ghcr.io/owner/image:tag",
415            "registry.example.com:5000/my-image:v1.2.3",
416        ];
417
418        for image in images {
419            let backend = DaggerBackend::new(Some(image.to_string()), "/tmp".into());
420            assert_eq!(backend.default_image, Some(image.to_string()));
421        }
422    }
423
424    #[test]
425    fn test_create_dagger_backend_extracts_image_from_options() {
426        let config = BackendConfig {
427            backend_type: "dagger".to_string(),
428            options: Some(BackendOptions {
429                image: Some("custom/image:tag".to_string()),
430                platform: None,
431            }),
432        };
433
434        // The factory function creates the backend
435        let backend = create_dagger_backend(Some(&config), "/project".into());
436        // We can only verify the name since we can't access private fields through the trait
437        assert_eq!(backend.name(), "dagger");
438    }
439
440    #[test]
441    fn test_dagger_backend_cache_multiple_containers() {
442        let backend = DaggerBackend::new(None, "/tmp".into());
443
444        // Verify we can work with the cache
445        {
446            let cache = backend.container_cache();
447            let guard = cache.lock().unwrap();
448            assert_eq!(guard.len(), 0);
449        }
450    }
451
452    #[test]
453    fn test_backend_options_with_no_image() {
454        let config = BackendConfig {
455            backend_type: "dagger".to_string(),
456            options: Some(BackendOptions {
457                image: None,
458                platform: Some("linux/arm64".to_string()),
459            }),
460        };
461
462        let backend = create_dagger_backend(Some(&config), "/project".into());
463        assert_eq!(backend.name(), "dagger");
464    }
465
466    #[test]
467    fn test_dagger_backend_cloned_cache() {
468        let backend = DaggerBackend::new(Some("alpine".to_string()), "/project".into());
469
470        // Get cache and clone the Arc
471        let cache = backend.container_cache().clone();
472
473        // Verify the Arc clone works
474        let guard = cache.lock().unwrap();
475        assert!(guard.is_empty());
476    }
477
478    #[test]
479    fn test_dagger_backend_with_empty_image() {
480        // Empty string is technically valid as a default_image field value
481        let backend = DaggerBackend::new(Some(String::new()), "/tmp".into());
482        assert_eq!(backend.default_image, Some(String::new()));
483    }
484
485    #[test]
486    fn test_backend_config_type_field() {
487        let config = BackendConfig {
488            backend_type: "dagger".to_string(),
489            options: None,
490        };
491        assert_eq!(config.backend_type, "dagger");
492    }
493
494    #[test]
495    fn test_backend_options_both_fields() {
496        let options = BackendOptions {
497            image: Some("node:latest".to_string()),
498            platform: Some("linux/amd64".to_string()),
499        };
500        assert_eq!(options.image, Some("node:latest".to_string()));
501        assert_eq!(options.platform, Some("linux/amd64".to_string()));
502    }
503}