Skip to main content

tatara_engine/drivers/
oci.rs

1use anyhow::{bail, Context, Result};
2use async_trait::async_trait;
3use chrono::Utc;
4use std::path::Path;
5use std::time::Duration;
6use tokio::process::Command;
7use tokio::sync::mpsc;
8use tracing::{debug, info, warn};
9
10use tatara_core::domain::allocation::TaskRunState;
11use tatara_core::domain::job::{DriverType, Task, TaskConfig};
12
13use super::{Driver, LogEntry, TaskHandle};
14
15#[derive(Debug)]
16enum OciBackend {
17    AppleContainer,
18    Docker,
19    Podman,
20    None,
21}
22
23pub struct OciDriver {
24    backend: OciBackend,
25}
26
27impl OciDriver {
28    pub async fn detect() -> Self {
29        // Priority:
30        // 1. macOS + `container` CLI (Apple macOS 26+)
31        // 2. `docker` CLI
32        // 3. `podman` CLI
33
34        if cfg!(target_os = "macos") {
35            if which("container").await {
36                info!("OCI backend: Apple container CLI");
37                return Self {
38                    backend: OciBackend::AppleContainer,
39                };
40            }
41        }
42
43        if which("docker").await {
44            info!("OCI backend: Docker");
45            return Self {
46                backend: OciBackend::Docker,
47            };
48        }
49
50        if which("podman").await {
51            info!("OCI backend: Podman");
52            return Self {
53                backend: OciBackend::Podman,
54            };
55        }
56
57        warn!("No OCI runtime available");
58        Self {
59            backend: OciBackend::None,
60        }
61    }
62
63    fn cli_command(&self) -> &str {
64        match &self.backend {
65            OciBackend::AppleContainer => "container",
66            OciBackend::Docker => "docker",
67            OciBackend::Podman => "podman",
68            OciBackend::None => unreachable!("OCI driver used without available backend"),
69        }
70    }
71}
72
73#[async_trait]
74impl Driver for OciDriver {
75    fn name(&self) -> &str {
76        "oci"
77    }
78
79    async fn available(&self) -> bool {
80        !matches!(self.backend, OciBackend::None)
81    }
82
83    async fn start(&self, task: &Task, _alloc_dir: &Path) -> Result<TaskHandle> {
84        let (image, ports, volumes, entrypoint, command) = match &task.config {
85            TaskConfig::Oci {
86                image,
87                ports,
88                volumes,
89                entrypoint,
90                command,
91            } => (
92                image.clone(),
93                ports.clone(),
94                volumes.clone(),
95                entrypoint.clone(),
96                command.clone(),
97            ),
98            _ => bail!("OciDriver received non-oci task config"),
99        };
100
101        let container_name = format!("tatara-{}", task.name);
102        let cli = self.cli_command();
103
104        let mut args = vec!["run".to_string(), "-d".to_string()];
105        args.push("--name".to_string());
106        args.push(container_name.clone());
107
108        for (host_port, container_port) in &ports {
109            args.push("-p".to_string());
110            args.push(format!("{}:{}", host_port, container_port));
111        }
112
113        for (host_path, container_path) in &volumes {
114            args.push("-v".to_string());
115            args.push(format!("{}:{}", host_path, container_path));
116        }
117
118        for (key, value) in &task.env {
119            args.push("-e".to_string());
120            args.push(format!("{}={}", key, value));
121        }
122
123        args.push(image);
124
125        if let Some(ep) = &entrypoint {
126            args.push("--entrypoint".to_string());
127            args.extend(ep.clone());
128        }
129
130        if let Some(cmd) = &command {
131            args.extend(cmd.clone());
132        }
133
134        let output = Command::new(cli)
135            .args(&args)
136            .output()
137            .await
138            .context("Failed to start OCI container")?;
139
140        if !output.status.success() {
141            let stderr = String::from_utf8_lossy(&output.stderr);
142            bail!("Failed to start container: {}", stderr);
143        }
144
145        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
146        debug!(
147            task = %task.name,
148            container_id = %container_id,
149            backend = ?self.backend,
150            "OCI driver started container"
151        );
152
153        Ok(TaskHandle {
154            driver: DriverType::Oci,
155            pid: None,
156            container_id: Some(container_id),
157            started_at: Utc::now(),
158        })
159    }
160
161    async fn stop(&self, handle: &TaskHandle, timeout: Duration) -> Result<()> {
162        let container_id = handle
163            .container_id
164            .as_ref()
165            .context("No container ID in OCI task handle")?;
166
167        let cli = self.cli_command();
168        let timeout_secs = timeout.as_secs().to_string();
169
170        let output = Command::new(cli)
171            .args(["stop", "-t", &timeout_secs, container_id])
172            .output()
173            .await
174            .context("Failed to stop container")?;
175
176        if !output.status.success() {
177            warn!(
178                container_id = %container_id,
179                stderr = %String::from_utf8_lossy(&output.stderr),
180                "Container stop returned error"
181            );
182        }
183
184        // Remove container
185        let _ = Command::new(cli)
186            .args(["rm", "-f", container_id])
187            .output()
188            .await;
189
190        Ok(())
191    }
192
193    async fn status(&self, handle: &TaskHandle) -> Result<TaskRunState> {
194        let container_id = handle
195            .container_id
196            .as_ref()
197            .context("No container ID in OCI task handle")?;
198
199        let cli = self.cli_command();
200        let output = Command::new(cli)
201            .args(["inspect", "--format", "{{.State.Status}}", container_id])
202            .output()
203            .await
204            .context("Failed to inspect container")?;
205
206        if !output.status.success() {
207            return Ok(TaskRunState::Dead);
208        }
209
210        let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
211        match status.as_str() {
212            "running" => Ok(TaskRunState::Running),
213            "created" | "restarting" => Ok(TaskRunState::Pending),
214            _ => Ok(TaskRunState::Dead),
215        }
216    }
217
218    async fn logs(&self, handle: &TaskHandle) -> Result<mpsc::Receiver<LogEntry>> {
219        let (tx, rx) = mpsc::channel(256);
220        let container_id = handle.container_id.clone().unwrap_or_default();
221        let cli = self.cli_command().to_string();
222
223        tokio::spawn(async move {
224            use tokio::io::{AsyncBufReadExt, BufReader};
225
226            let mut child = match Command::new(&cli)
227                .args(["logs", "-f", "--timestamps", &container_id])
228                .stdout(std::process::Stdio::piped())
229                .stderr(std::process::Stdio::piped())
230                .spawn()
231            {
232                Ok(child) => child,
233                Err(e) => {
234                    tracing::warn!(error = %e, "failed to spawn container log stream");
235                    return;
236                }
237            };
238
239            if let Some(stdout) = child.stdout.take() {
240                let tx_out = tx.clone();
241                let cid = container_id.clone();
242                tokio::spawn(async move {
243                    let mut lines = BufReader::new(stdout).lines();
244                    while let Ok(Some(line)) = lines.next_line().await {
245                        let entry = LogEntry {
246                            task_name: cid.clone(),
247                            message: line,
248                            stream: "stdout".to_string(),
249                            timestamp: chrono::Utc::now(),
250                        };
251                        if tx_out.send(entry).await.is_err() {
252                            break;
253                        }
254                    }
255                });
256            }
257
258            if let Some(stderr) = child.stderr.take() {
259                let tx_err = tx;
260                let cid = container_id;
261                tokio::spawn(async move {
262                    let mut lines = BufReader::new(stderr).lines();
263                    while let Ok(Some(line)) = lines.next_line().await {
264                        let entry = LogEntry {
265                            task_name: cid.clone(),
266                            message: line,
267                            stream: "stderr".to_string(),
268                            timestamp: chrono::Utc::now(),
269                        };
270                        if tx_err.send(entry).await.is_err() {
271                            break;
272                        }
273                    }
274                });
275            }
276
277            let _ = child.wait().await;
278        });
279
280        Ok(rx)
281    }
282}
283
284async fn which(name: &str) -> bool {
285    Command::new("which")
286        .arg(name)
287        .output()
288        .await
289        .map(|o| o.status.success())
290        .unwrap_or(false)
291}