Skip to main content

opendev_docker/
local_runtime.rs

1//! Local Docker runtime — manages bash sessions and file operations
2//! inside a container by shelling out to `docker exec`.
3//!
4//! Ports the Python `LocalRuntime` class.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use tracing::info;
10
11use crate::errors::{DockerError, Result};
12use crate::models::{
13    BashAction, BashObservation, CheckMode, CloseSessionRequest, CloseSessionResponse,
14    CreateSessionRequest, CreateSessionResponse, IsAliveResponse, ReadFileRequest,
15    ReadFileResponse, WriteFileRequest, WriteFileResponse,
16};
17use crate::session::DockerSession;
18
19/// Local runtime that manages bash sessions and file operations.
20///
21/// Each session is backed by a persistent `docker exec` process.
22pub struct LocalRuntime {
23    container_id: String,
24    sessions: HashMap<String, DockerSession>,
25    closed: bool,
26}
27
28impl LocalRuntime {
29    /// Create a new local runtime attached to the given container.
30    pub fn new(container_id: impl Into<String>) -> Self {
31        Self {
32            container_id: container_id.into(),
33            sessions: HashMap::new(),
34            closed: false,
35        }
36    }
37
38    /// Health check.
39    pub fn is_alive(&self) -> IsAliveResponse {
40        if self.closed {
41            return IsAliveResponse {
42                status: "error".into(),
43                message: "Runtime is closed".into(),
44            };
45        }
46        IsAliveResponse::default()
47    }
48
49    /// Create a new bash session.
50    pub async fn create_session(
51        &mut self,
52        request: &CreateSessionRequest,
53    ) -> Result<CreateSessionResponse> {
54        if self.sessions.contains_key(&request.session) {
55            return Err(DockerError::SessionExists(request.session.clone()));
56        }
57
58        let session = DockerSession::new(&self.container_id, &request.session);
59
60        self.sessions.insert(request.session.clone(), session);
61        info!("Created session '{}'", request.session);
62
63        Ok(CreateSessionResponse {
64            success: true,
65            session: request.session.clone(),
66            message: String::new(),
67        })
68    }
69
70    /// Run a command in an existing session.
71    pub async fn run_in_session(&mut self, action: &BashAction) -> Result<BashObservation> {
72        if !self.sessions.contains_key(&action.session) {
73            if action.session == "default" {
74                self.create_session(&CreateSessionRequest::default())
75                    .await?;
76            } else {
77                return Err(DockerError::SessionNotFound(action.session.clone()));
78            }
79        }
80
81        let session = self.sessions.get(&action.session).unwrap();
82        session
83            .exec_command(&action.command, action.timeout, action.check)
84            .await
85    }
86
87    /// Close a session.
88    pub async fn close_session(&mut self, request: &CloseSessionRequest) -> CloseSessionResponse {
89        if let Some(_session) = self.sessions.remove(&request.session) {
90            info!("Closed session '{}'", request.session);
91            CloseSessionResponse {
92                success: true,
93                message: String::new(),
94            }
95        } else {
96            CloseSessionResponse {
97                success: false,
98                message: format!("Session '{}' not found", request.session),
99            }
100        }
101    }
102
103    /// Read a file from inside the container via `docker exec cat`.
104    pub async fn read_file(&self, request: &ReadFileRequest) -> Result<ReadFileResponse> {
105        let session = self.get_or_default_session()?;
106        let obs = session
107            .exec_command(&format!("cat '{}'", request.path), 30.0, CheckMode::Silent)
108            .await?;
109
110        if obs.exit_code == Some(0) || obs.exit_code.is_none() {
111            Ok(ReadFileResponse {
112                success: true,
113                content: obs.output,
114                error: None,
115            })
116        } else {
117            Ok(ReadFileResponse {
118                success: false,
119                content: String::new(),
120                error: Some(obs.output),
121            })
122        }
123    }
124
125    /// Write a file inside the container via `docker exec`.
126    pub async fn write_file(&self, request: &WriteFileRequest) -> Result<WriteFileResponse> {
127        let session = self.get_or_default_session()?;
128        // Create parent dirs then write via heredoc
129        let parent = Path::new(&request.path)
130            .parent()
131            .map(|p| p.to_string_lossy().to_string())
132            .unwrap_or_else(|| ".".into());
133
134        let escaped_content = request.content.replace('\'', "'\\''");
135        let cmd = format!(
136            "mkdir -p '{}' && printf '%s' '{}' > '{}'",
137            parent, escaped_content, request.path
138        );
139
140        let obs = session.exec_command(&cmd, 30.0, CheckMode::Silent).await?;
141
142        if obs.exit_code == Some(0) || obs.exit_code.is_none() {
143            Ok(WriteFileResponse {
144                success: true,
145                error: None,
146            })
147        } else {
148            Ok(WriteFileResponse {
149                success: false,
150                error: Some(obs.output),
151            })
152        }
153    }
154
155    /// Close all sessions and mark runtime closed.
156    pub async fn close(&mut self) {
157        let names: Vec<String> = self.sessions.keys().cloned().collect();
158        for name in names {
159            self.close_session(&CloseSessionRequest { session: name })
160                .await;
161        }
162        self.closed = true;
163        info!("Runtime closed");
164    }
165
166    /// Get the container ID.
167    pub fn container_id(&self) -> &str {
168        &self.container_id
169    }
170
171    /// Helper: get any existing session or return an error.
172    fn get_or_default_session(&self) -> Result<&DockerSession> {
173        self.sessions
174            .get("default")
175            .or_else(|| self.sessions.values().next())
176            .ok_or_else(|| DockerError::SessionNotFound("default".into()))
177    }
178}
179
180#[cfg(test)]
181#[path = "local_runtime_tests.rs"]
182mod tests;