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 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 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}