Skip to main content

arcbox_container/
exec.rs

1//! Exec instance management.
2//!
3//! Manages exec instances for running commands inside containers.
4
5use crate::state::ContainerId;
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use std::collections::HashMap;
9use std::sync::{Arc, RwLock};
10
11/// Exec instance ID.
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub struct ExecId(String);
14
15impl ExecId {
16    /// Creates a new exec ID.
17    #[must_use]
18    pub fn new() -> Self {
19        Self(uuid::Uuid::new_v4().to_string().replace('-', ""))
20    }
21
22    /// Creates an exec ID from a string.
23    #[must_use]
24    pub fn from_string(s: &str) -> Self {
25        Self(s.to_string())
26    }
27}
28
29impl Default for ExecId {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl std::fmt::Display for ExecId {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(f, "{}", self.0)
38    }
39}
40
41/// Exec instance configuration.
42#[derive(Debug, Clone)]
43pub struct ExecConfig {
44    /// Container ID.
45    pub container_id: ContainerId,
46    /// Command to run.
47    pub cmd: Vec<String>,
48    /// Environment variables.
49    pub env: Vec<String>,
50    /// Working directory.
51    pub working_dir: Option<String>,
52    /// Attach stdin.
53    pub attach_stdin: bool,
54    /// Attach stdout.
55    pub attach_stdout: bool,
56    /// Attach stderr.
57    pub attach_stderr: bool,
58    /// Allocate a TTY.
59    pub tty: bool,
60    /// Run as user.
61    pub user: Option<String>,
62    /// Privileged mode.
63    pub privileged: bool,
64}
65
66impl Default for ExecConfig {
67    fn default() -> Self {
68        Self {
69            container_id: ContainerId::from_string(""),
70            cmd: vec![],
71            env: vec![],
72            working_dir: None,
73            attach_stdin: false,
74            attach_stdout: true,
75            attach_stderr: true,
76            tty: false,
77            user: None,
78            privileged: false,
79        }
80    }
81}
82
83/// Exec instance state.
84#[derive(Debug, Clone)]
85pub struct ExecInstance {
86    /// Exec ID.
87    pub id: ExecId,
88    /// Configuration.
89    pub config: ExecConfig,
90    /// Whether the exec is running.
91    pub running: bool,
92    /// Exit code (if completed).
93    pub exit_code: Option<i32>,
94    /// Process ID (if running).
95    pub pid: Option<u32>,
96    /// Created timestamp.
97    pub created: DateTime<Utc>,
98}
99
100impl ExecInstance {
101    /// Creates a new exec instance.
102    #[must_use]
103    pub fn new(config: ExecConfig) -> Self {
104        Self {
105            id: ExecId::new(),
106            config,
107            running: false,
108            exit_code: None,
109            pid: None,
110            created: Utc::now(),
111        }
112    }
113}
114
115/// Exec start request for agent communication.
116#[derive(Debug, Clone)]
117pub struct ExecStartParams {
118    /// Exec ID.
119    pub exec_id: String,
120    /// Container ID.
121    pub container_id: String,
122    /// Command to execute.
123    pub cmd: Vec<String>,
124    /// Environment variables.
125    pub env: Vec<(String, String)>,
126    /// Working directory.
127    pub working_dir: Option<String>,
128    /// User to run as.
129    pub user: Option<String>,
130    /// Allocate TTY.
131    pub tty: bool,
132    /// Detach mode.
133    pub detach: bool,
134    /// Initial TTY width.
135    pub tty_width: u32,
136    /// Initial TTY height.
137    pub tty_height: u32,
138}
139
140/// Exec start result from agent.
141#[derive(Debug, Clone)]
142pub struct ExecStartResult {
143    /// Process ID in guest.
144    pub pid: u32,
145    /// Standard output (if not detached and not TTY).
146    pub stdout: Vec<u8>,
147    /// Standard error (if not detached and not TTY).
148    pub stderr: Vec<u8>,
149    /// Exit code (if not detached).
150    pub exit_code: Option<i32>,
151}
152
153/// Trait for exec agent communication.
154///
155/// Abstracts communication with the guest VM agent for exec operations.
156#[async_trait]
157pub trait ExecAgentConnection: Send + Sync {
158    /// Starts an exec instance in the guest VM.
159    async fn exec_start(&self, params: ExecStartParams) -> Result<ExecStartResult, String>;
160
161    /// Resizes an exec instance's TTY.
162    async fn exec_resize(&self, exec_id: &str, width: u32, height: u32) -> Result<(), String>;
163}
164
165/// Exec manager.
166pub struct ExecManager {
167    /// Exec instances by ID.
168    execs: RwLock<HashMap<String, ExecInstance>>,
169    /// Agent connection for exec operations.
170    agent: Option<Arc<dyn ExecAgentConnection>>,
171}
172
173impl ExecManager {
174    /// Creates a new exec manager.
175    #[must_use]
176    pub fn new() -> Self {
177        Self {
178            execs: RwLock::new(HashMap::new()),
179            agent: None,
180        }
181    }
182
183    /// Creates a new exec manager with an agent connection.
184    #[must_use]
185    pub fn with_agent(agent: Arc<dyn ExecAgentConnection>) -> Self {
186        Self {
187            execs: RwLock::new(HashMap::new()),
188            agent: Some(agent),
189        }
190    }
191
192    /// Sets the agent connection.
193    pub fn set_agent(&mut self, agent: Arc<dyn ExecAgentConnection>) {
194        self.agent = Some(agent);
195    }
196
197    /// Creates a new exec instance.
198    ///
199    /// # Errors
200    ///
201    /// Returns `ContainerError::LockPoisoned` if the internal lock is poisoned.
202    pub fn create(&self, config: ExecConfig) -> crate::error::Result<ExecId> {
203        let exec = ExecInstance::new(config);
204        let id = exec.id.clone();
205
206        let mut execs = self
207            .execs
208            .write()
209            .map_err(|_| crate::error::ContainerError::LockPoisoned)?;
210        execs.insert(id.to_string(), exec);
211
212        Ok(id)
213    }
214
215    /// Gets an exec instance by ID.
216    #[must_use]
217    pub fn get(&self, id: &ExecId) -> Option<ExecInstance> {
218        self.execs.read().ok()?.get(&id.to_string()).cloned()
219    }
220
221    /// Starts an exec instance.
222    ///
223    /// Sends an `ExecStartRequest` to the agent and waits for completion.
224    /// If detach=true, returns immediately after the process starts.
225    ///
226    /// # Arguments
227    ///
228    /// * `id` - The exec instance ID
229    /// * `detach` - If true, run in background and return immediately
230    /// * `tty_width` - Initial TTY width (if TTY mode)
231    /// * `tty_height` - Initial TTY height (if TTY mode)
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the exec cannot be started.
236    pub async fn start(
237        &self,
238        id: &ExecId,
239        detach: bool,
240        tty_width: u32,
241        tty_height: u32,
242    ) -> crate::Result<ExecStartResult> {
243        // First validate state and get config (holding lock briefly).
244        let (config, exec_id_str) = {
245            let mut execs = self
246                .execs
247                .write()
248                .map_err(|_| crate::ContainerError::Runtime("lock poisoned".to_string()))?;
249
250            let exec = execs
251                .get_mut(&id.to_string())
252                .ok_or_else(|| crate::ContainerError::not_found(id.to_string()))?;
253
254            if exec.running {
255                return Err(crate::ContainerError::invalid_state(
256                    "exec is already running".to_string(),
257                ));
258            }
259
260            exec.running = true;
261            (exec.config.clone(), exec.id.to_string())
262        };
263
264        // Build the exec start params.
265        let params = ExecStartParams {
266            exec_id: exec_id_str.clone(),
267            container_id: config.container_id.to_string(),
268            cmd: config.cmd.clone(),
269            env: config
270                .env
271                .iter()
272                .filter_map(|s| {
273                    let parts: Vec<&str> = s.splitn(2, '=').collect();
274                    if parts.len() == 2 {
275                        Some((parts[0].to_string(), parts[1].to_string()))
276                    } else {
277                        None
278                    }
279                })
280                .collect(),
281            working_dir: config.working_dir.clone(),
282            user: config.user.clone(),
283            tty: config.tty,
284            detach,
285            tty_width,
286            tty_height,
287        };
288
289        // Send request to agent if connected.
290        let result = if let Some(ref agent) = self.agent {
291            agent.exec_start(params).await.map_err(|e| {
292                crate::ContainerError::Runtime(format!("agent exec_start failed: {e}"))
293            })?
294        } else {
295            // No agent - return empty result (for testing without VM).
296            ExecStartResult {
297                pid: 0,
298                stdout: Vec::new(),
299                stderr: Vec::new(),
300                exit_code: Some(0),
301            }
302        };
303
304        // Update local state after agent call.
305        {
306            let mut execs = self
307                .execs
308                .write()
309                .map_err(|_| crate::ContainerError::Runtime("lock poisoned".to_string()))?;
310
311            if let Some(exec) = execs.get_mut(&exec_id_str) {
312                exec.pid = Some(result.pid);
313
314                if let Some(exit_code) = result.exit_code {
315                    // Process has exited.
316                    exec.running = false;
317                    exec.exit_code = Some(exit_code);
318                }
319                // If detached, process is still running (no exit_code yet).
320            }
321        }
322
323        Ok(result)
324    }
325
326    /// Resizes the exec TTY.
327    ///
328    /// Sends a resize request to the agent to update the PTY window size.
329    ///
330    /// # Errors
331    ///
332    /// Returns an error if the resize fails.
333    pub async fn resize(&self, id: &ExecId, width: u32, height: u32) -> crate::Result<()> {
334        // Validate exec exists and has TTY.
335        {
336            let execs = self
337                .execs
338                .read()
339                .map_err(|_| crate::ContainerError::Runtime("lock poisoned".to_string()))?;
340
341            let exec = execs
342                .get(&id.to_string())
343                .ok_or_else(|| crate::ContainerError::not_found(id.to_string()))?;
344
345            if !exec.config.tty {
346                return Err(crate::ContainerError::invalid_state(
347                    "exec does not have a TTY".to_string(),
348                ));
349            }
350
351            if !exec.running {
352                return Err(crate::ContainerError::invalid_state(
353                    "exec is not running".to_string(),
354                ));
355            }
356        }
357
358        // Send resize to agent if connected.
359        if let Some(ref agent) = self.agent {
360            agent
361                .exec_resize(&id.to_string(), width, height)
362                .await
363                .map_err(|e| {
364                    crate::ContainerError::Runtime(format!("agent exec_resize failed: {e}"))
365                })?;
366        }
367
368        Ok(())
369    }
370
371    /// Marks an exec instance as completed.
372    ///
373    /// Called when the agent notifies that an exec process has exited.
374    pub fn notify_exit(&self, id: &ExecId, exit_code: i32) {
375        if let Ok(mut execs) = self.execs.write() {
376            if let Some(exec) = execs.get_mut(&id.to_string()) {
377                exec.running = false;
378                exec.exit_code = Some(exit_code);
379            }
380        }
381    }
382
383    /// Lists all exec instances for a container.
384    #[must_use]
385    pub fn list_for_container(&self, container_id: &ContainerId) -> Vec<ExecInstance> {
386        self.execs
387            .read()
388            .map(|execs| {
389                execs
390                    .values()
391                    .filter(|e| e.config.container_id == *container_id)
392                    .cloned()
393                    .collect()
394            })
395            .unwrap_or_default()
396    }
397}
398
399impl Default for ExecManager {
400    fn default() -> Self {
401        Self::new()
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_create_exec() {
411        let manager = ExecManager::new();
412        let config = ExecConfig {
413            container_id: ContainerId::from_string("test-container"),
414            cmd: vec!["ls".to_string(), "-la".to_string()],
415            ..Default::default()
416        };
417
418        let id = manager.create(config).unwrap();
419        let exec = manager.get(&id).unwrap();
420
421        assert_eq!(exec.config.cmd, vec!["ls", "-la"]);
422        assert!(!exec.running);
423        assert!(exec.exit_code.is_none());
424    }
425
426    #[tokio::test]
427    async fn test_start_exec() {
428        let manager = ExecManager::new();
429        let config = ExecConfig {
430            container_id: ContainerId::from_string("test-container"),
431            cmd: vec!["echo".to_string(), "hello".to_string()],
432            ..Default::default()
433        };
434
435        let id = manager.create(config).unwrap();
436        // Default width/height for non-TTY mode.
437        let result = manager.start(&id, false, 80, 24).await.unwrap();
438
439        let exec = manager.get(&id).unwrap();
440        assert!(!exec.running);
441        assert_eq!(exec.exit_code, Some(0));
442        assert_eq!(result.exit_code, Some(0));
443    }
444
445    #[tokio::test]
446    async fn test_start_exec_detached() {
447        let manager = ExecManager::new();
448        let config = ExecConfig {
449            container_id: ContainerId::from_string("test-container"),
450            cmd: vec!["sleep".to_string(), "10".to_string()],
451            ..Default::default()
452        };
453
454        let id = manager.create(config).unwrap();
455        // Without agent, detach mode still returns immediately with exit_code=0.
456        let result = manager.start(&id, true, 80, 24).await.unwrap();
457
458        // Without agent, it returns exit_code=0 immediately.
459        assert_eq!(result.exit_code, Some(0));
460    }
461
462    #[tokio::test]
463    async fn test_resize_without_tty() {
464        let manager = ExecManager::new();
465        let config = ExecConfig {
466            container_id: ContainerId::from_string("test-container"),
467            cmd: vec!["echo".to_string(), "hello".to_string()],
468            tty: false,
469            ..Default::default()
470        };
471
472        let id = manager.create(config).unwrap();
473
474        // Resize should fail because exec doesn't have TTY.
475        let result = manager.resize(&id, 100, 40).await;
476        assert!(result.is_err());
477    }
478
479    #[test]
480    fn test_notify_exit() {
481        let manager = ExecManager::new();
482        let config = ExecConfig {
483            container_id: ContainerId::from_string("test-container"),
484            cmd: vec!["sleep".to_string(), "10".to_string()],
485            ..Default::default()
486        };
487
488        let id = manager.create(config).unwrap();
489
490        // Manually set running state.
491        {
492            let mut execs = manager.execs.write().unwrap();
493            if let Some(exec) = execs.get_mut(&id.to_string()) {
494                exec.running = true;
495                exec.pid = Some(12345);
496            }
497        }
498
499        // Notify exit.
500        manager.notify_exit(&id, 42);
501
502        let exec = manager.get(&id).unwrap();
503        assert!(!exec.running);
504        assert_eq!(exec.exit_code, Some(42));
505    }
506}