1use 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
16pub 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 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 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 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 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 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 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 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 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 let mut container = if let Some(container_id) = cached_container_id {
157 client
160 .load_container_from_id(container_id)
161 .with_workdir("/workspace")
162 } else if let Some(img) = image {
163 client
165 .container()
166 .from(img)
167 .with_mounted_directory("/workspace", host_dir)
168 .with_workdir("/workspace")
169 } else {
170 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 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 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 for (k, v) in env_map {
197 container = container.with_env_variable(k, v);
198 }
199
200 let exec = container.with_exec(command);
202
203 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 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 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 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 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
286pub 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 {
380 let guard = cache1.lock().unwrap();
381 assert!(guard.is_empty());
383 }
384
385 {
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 let backend = create_dagger_backend(Some(&config), "/project".into());
436 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 {
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 let cache = backend.container_cache().clone();
472
473 let guard = cache.lock().unwrap();
475 assert!(guard.is_empty());
476 }
477
478 #[test]
479 fn test_dagger_backend_with_empty_image() {
480 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}