Skip to main content

opendev_docker/
session.rs

1//! Docker session — execute commands and copy files inside a container
2//! using `docker exec` and `docker cp`.
3//!
4//! Ports the Python `BashSession` / `session.py`.
5
6use regex::Regex;
7use tracing::debug;
8
9use crate::errors::{DockerError, Result};
10use crate::models::{BashObservation, CheckMode};
11
12/// Strip ANSI escape sequences from a string.
13fn strip_ansi(s: &str) -> String {
14    let re = Regex::new(r"\x1B\[[\d;]*[A-Za-z]|\x1B[@-_][0-?]*[ -/]*[@-~]").unwrap();
15    re.replace_all(s, "").replace("\r\n", "\n")
16}
17
18/// A session representing a working context inside a Docker container.
19///
20/// Commands are executed via `docker exec`; files are transferred via `docker cp`.
21pub struct DockerSession {
22    container_id: String,
23    name: String,
24    working_dir: Option<String>,
25}
26
27impl DockerSession {
28    /// Create a new session for the given container.
29    pub fn new(container_id: &str, name: &str) -> Self {
30        Self {
31            container_id: container_id.to_string(),
32            name: name.to_string(),
33            working_dir: None,
34        }
35    }
36
37    /// Set the working directory for commands in this session.
38    pub fn set_working_dir(&mut self, dir: impl Into<String>) {
39        self.working_dir = Some(dir.into());
40    }
41
42    /// Session name.
43    pub fn name(&self) -> &str {
44        &self.name
45    }
46
47    /// Container ID this session is attached to.
48    pub fn container_id(&self) -> &str {
49        &self.container_id
50    }
51
52    /// Execute a command inside the container via `docker exec`.
53    pub async fn exec_command(
54        &self,
55        command: &str,
56        timeout_secs: f64,
57        check: CheckMode,
58    ) -> Result<BashObservation> {
59        let mut args: Vec<String> = vec!["exec".into()];
60
61        if let Some(ref wd) = self.working_dir {
62            args.extend(["--workdir".into(), wd.clone()]);
63        }
64
65        args.push(self.container_id.clone());
66        args.extend(["bash".into(), "-c".into(), command.into()]);
67
68        debug!("docker exec in '{}': {}", self.name, command);
69
70        let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
71        let output = tokio::time::timeout(
72            std::time::Duration::from_secs_f64(timeout_secs),
73            tokio::process::Command::new("docker")
74                .args(&arg_refs)
75                .output(),
76        )
77        .await
78        .map_err(|_| DockerError::Timeout {
79            seconds: timeout_secs,
80            operation: format!("docker exec: {command}"),
81        })?
82        .map_err(|e| DockerError::CommandFailed {
83            message: format!("docker exec failed: {e}"),
84            stderr: String::new(),
85        })?;
86
87        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
88        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
89        let exit_code = output.status.code().unwrap_or(-1);
90
91        let combined = strip_ansi(&if stderr.is_empty() {
92            stdout
93        } else if stdout.is_empty() {
94            stderr
95        } else {
96            format!("{stdout}{stderr}")
97        });
98
99        let failure_reason = if exit_code != 0 {
100            Some(format!("Exit code {exit_code}"))
101        } else {
102            None
103        };
104
105        if check == CheckMode::Raise && exit_code != 0 {
106            return Err(DockerError::NonZeroExit {
107                exit_code,
108                command: command.to_string(),
109                output: combined,
110            });
111        }
112
113        Ok(BashObservation {
114            output: combined.trim().to_string(),
115            exit_code: if check == CheckMode::Ignore {
116                None
117            } else {
118                Some(exit_code)
119            },
120            failure_reason,
121        })
122    }
123
124    /// Copy a file from the host into the container.
125    pub async fn copy_file_in(&self, host_path: &str, container_path: &str) -> Result<()> {
126        let dest = format!("{}:{}", self.container_id, container_path);
127        let output = tokio::process::Command::new("docker")
128            .args(["cp", host_path, &dest])
129            .output()
130            .await
131            .map_err(|e| DockerError::CommandFailed {
132                message: format!("docker cp failed: {e}"),
133                stderr: String::new(),
134            })?;
135
136        if !output.status.success() {
137            return Err(DockerError::CommandFailed {
138                message: "docker cp into container failed".into(),
139                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
140            });
141        }
142
143        Ok(())
144    }
145
146    /// Copy a file from the container to the host.
147    pub async fn copy_file_out(&self, container_path: &str, host_path: &str) -> Result<()> {
148        let src = format!("{}:{}", self.container_id, container_path);
149        let output = tokio::process::Command::new("docker")
150            .args(["cp", &src, host_path])
151            .output()
152            .await
153            .map_err(|e| DockerError::CommandFailed {
154                message: format!("docker cp failed: {e}"),
155                stderr: String::new(),
156            })?;
157
158        if !output.status.success() {
159            return Err(DockerError::CommandFailed {
160                message: "docker cp from container failed".into(),
161                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
162            });
163        }
164
165        Ok(())
166    }
167
168    /// Send an interrupt (kill -INT) to all processes in the container.
169    pub async fn interrupt(&self) -> Result<String> {
170        let obs = self
171            .exec_command("kill -INT -1 2>/dev/null; true", 5.0, CheckMode::Ignore)
172            .await?;
173        Ok(obs.output)
174    }
175}
176
177#[cfg(test)]
178#[path = "session_tests.rs"]
179mod tests;