swiftide_docker_executor/
running_docker_executor.rs

1use anyhow::Context as _;
2use async_trait::async_trait;
3use bollard::{
4    container::LogOutput,
5    query_parameters::{InspectContainerOptions, RemoveContainerOptions},
6    secret::{ContainerState, ContainerStateStatusEnum},
7};
8use codegen::shell_executor_client::ShellExecutorClient;
9use std::{collections::HashMap, path::Path, sync::Arc};
10pub use swiftide_core::ToolExecutor;
11use swiftide_core::{Command, CommandError, CommandOutput, Loader as _, prelude::StreamExt as _};
12
13use crate::{
14    ContextBuilder, ContextError, DockerExecutor, DockerExecutorError, client::Client,
15    container_configurator::ContainerConfigurator, container_starter::ContainerStarter,
16    dockerfile_manager::DockerfileManager, image_builder::ImageBuilder,
17};
18
19pub mod codegen {
20    tonic::include_proto!("shell");
21}
22
23#[derive(Clone, Debug)]
24pub struct RunningDockerExecutor {
25    pub container_id: String,
26    pub(crate) docker: Arc<Client>,
27    pub host_port: String,
28
29    // Default environment configuration for the executor
30    pub(crate) env_clear: bool,
31    pub(crate) remove_env: Vec<String>,
32    pub(crate) env: HashMap<String, String>,
33}
34
35impl From<RunningDockerExecutor> for Arc<dyn ToolExecutor> {
36    fn from(val: RunningDockerExecutor) -> Self {
37        Arc::new(val) as Arc<dyn ToolExecutor>
38    }
39}
40
41#[async_trait]
42impl ToolExecutor for RunningDockerExecutor {
43    #[tracing::instrument(skip(self), err)]
44    async fn exec_cmd(&self, cmd: &Command) -> Result<CommandOutput, CommandError> {
45        match cmd {
46            Command::Shell(cmd) => self.exec_shell(cmd).await,
47            Command::ReadFile(path) => self.read_file(path).await,
48            Command::WriteFile(path, content) => self.write_file(path, content).await,
49            _ => unimplemented!(),
50        }
51    }
52
53    async fn stream_files(
54        &self,
55        path: &Path,
56        extensions: Option<Vec<String>>,
57    ) -> anyhow::Result<swiftide_core::indexing::IndexingStream> {
58        let extensions = extensions.unwrap_or_default();
59        Ok(self.as_file_loader(path, extensions).into_stream())
60    }
61}
62
63impl RunningDockerExecutor {
64    /// Starts a docker container with a given context and image name
65    pub async fn start(
66        builder: &DockerExecutor,
67    ) -> Result<RunningDockerExecutor, DockerExecutorError> {
68        let docker = Client::lazy_client().await?;
69
70        // Any temporary dockrerfile created during the build process
71        let mut tmp_dockerfile_name = None;
72        let mut image_name = builder.image_name.clone();
73        let dockerfile = &builder.dockerfile;
74        let context_path = &builder.context_path;
75        let user = builder.user.as_deref();
76        let container_uuid = builder.container_uuid;
77
78        // Only build if a dockerfile is provided
79        if let Some(dockerfile) = dockerfile {
80            // Prepare dockerfile
81            let dockerfile_manager = DockerfileManager::new(context_path);
82            let tmp_dockerfile = dockerfile_manager.prepare_dockerfile(dockerfile).await?;
83
84            // Build context
85            tracing::warn!(
86                "Creating archive for context from {}",
87                context_path.display()
88            );
89            let context = ContextBuilder::from_path(context_path, tmp_dockerfile.path())?
90                .build_tar()
91                .await?;
92
93            tracing::debug!("Context build with size: {} bytes", context.len());
94
95            let tmp_dockerfile_name_inner = tmp_dockerfile
96                .path()
97                .file_name()
98                .ok_or_else(|| {
99                    ContextError::CustomDockerfile("Could not read custom dockerfile".to_string())
100                })
101                .map(|s| s.to_string_lossy().to_string())?;
102
103            drop(tmp_dockerfile); // Make sure the temporary file is removed right away
104
105            // Build image
106            let tag = container_uuid
107                .to_string()
108                .split_once('-')
109                .map(|(tag, _)| tag)
110                .unwrap_or("latest")
111                .to_string();
112
113            let image_builder = ImageBuilder::new(docker.clone());
114            let image_name_with_tag = image_builder
115                .build_image(
116                    context,
117                    tmp_dockerfile_name_inner.as_ref(),
118                    &image_name,
119                    &tag,
120                )
121                .await?;
122
123            image_name = image_name_with_tag;
124            tmp_dockerfile_name = Some(tmp_dockerfile_name_inner);
125        }
126
127        // Configure container
128        let container_config = ContainerConfigurator::new(docker.socket_path.clone())
129            .create_container_config(&image_name, user);
130
131        // Start container
132        tracing::info!("Starting container with image: {image_name} and uuid: {container_uuid}");
133        let container_starter = ContainerStarter::new(docker.clone());
134        let (container_id, host_port) = container_starter
135            .start_container(&image_name, &container_uuid, container_config)
136            .await?;
137
138        // Remove the temporary dockerfile from the container
139
140        let executor = RunningDockerExecutor {
141            container_id,
142            docker,
143            host_port,
144            env_clear: builder.env_clear,
145            remove_env: builder.remove_env.clone(),
146            env: builder.env.clone(),
147        };
148
149        if let Some(tmp_dockerfile_name) = tmp_dockerfile_name {
150            executor
151                .exec_shell(&format!("rm {}", tmp_dockerfile_name.as_str()))
152                .await
153                .context("failed to remove temporary dockerfile")
154                .map_err(DockerExecutorError::Start)?;
155        }
156
157        Ok(executor)
158    }
159
160    /// Returns the underlying bollard status of the container
161    ///
162    /// Useful for checking if the executor is running or not
163    pub async fn container_state(&self) -> Result<ContainerState, DockerExecutorError> {
164        let container = self
165            .docker
166            .inspect_container(&self.container_id, None::<InspectContainerOptions>)
167            .await?;
168
169        container.state.ok_or_else(|| {
170            DockerExecutorError::ContainerStateMissing(self.container_id.to_string())
171        })
172    }
173
174    /// Check if the executor and its underlying container is running
175    ///
176    /// Will ignore any errors and assume it is not if there are
177    pub async fn is_running(&self) -> bool {
178        self.container_state()
179            .await
180            .map(|state| state.status == Some(ContainerStateStatusEnum::RUNNING))
181            .unwrap_or(false)
182    }
183
184    /// Returns the logs of the container
185    pub async fn logs(&self) -> Result<Vec<String>, DockerExecutorError> {
186        let mut logs = Vec::new();
187        let mut stream = self.docker.logs(
188            &self.container_id,
189            Some(bollard::query_parameters::LogsOptions {
190                follow: false,
191                stdout: true,
192                stderr: true,
193                tail: "all".to_string(),
194                ..Default::default()
195            }),
196        );
197
198        while let Some(log_result) = stream.next().await {
199            match log_result {
200                Ok(log_output) => match log_output {
201                    LogOutput::Console { message }
202                    | LogOutput::StdOut { message }
203                    | LogOutput::StdErr { message } => {
204                        logs.push(String::from_utf8_lossy(&message).trim().to_string());
205                    }
206                    _ => {}
207                },
208                Err(e) => tracing::error!("Error retrieving logs: {e}"), // Error handling
209            }
210        }
211
212        Ok(logs)
213    }
214
215    async fn exec_shell(&self, cmd: &str) -> Result<CommandOutput, CommandError> {
216        let mut client =
217            ShellExecutorClient::connect(format!("http://127.0.0.1:{}", self.host_port))
218                .await
219                .map_err(anyhow::Error::from)?;
220
221        let request = tonic::Request::new(codegen::ShellRequest {
222            command: cmd.to_string(),
223            env_clear: self.env_clear,
224            env_remove: self.remove_env.clone(),
225            envs: self.env.clone(),
226        });
227
228        let response = client
229            .exec_shell(request)
230            .await
231            .map_err(anyhow::Error::from)?;
232
233        let codegen::ShellResponse {
234            stdout,
235            stderr,
236            exit_code,
237        } = response.into_inner();
238
239        // // Trim both stdout and stderr to remove surrounding whitespace and newlines
240        let output = stdout.trim().to_string() + stderr.trim();
241        //
242        if exit_code == 0 {
243            Ok(output.into())
244        } else {
245            Err(CommandError::NonZeroExit(output.into()))
246        }
247    }
248
249    #[tracing::instrument(skip(self))]
250    async fn read_file(&self, path: &Path) -> Result<CommandOutput, CommandError> {
251        self.exec_shell(&format!("cat {}", path.display())).await
252    }
253
254    #[tracing::instrument(skip(self, content))]
255    async fn write_file(&self, path: &Path, content: &str) -> Result<CommandOutput, CommandError> {
256        let cmd = indoc::formatdoc! {r#"
257            cat << 'EOFKWAAK' > {path}
258            {content}
259            EOFKWAAK"#,
260            path = path.display(),
261            content = content.trim_end()
262
263        };
264
265        let write_file_result = self.exec_shell(&cmd).await;
266
267        // If the directory or file does not exist, create it
268        if let Err(CommandError::NonZeroExit(write_file)) = &write_file_result {
269            if [
270                "no such file or directory",
271                "directory nonexistent",
272                "nonexistent directory",
273            ]
274            .iter()
275            .any(|&s| write_file.output.to_lowercase().contains(s))
276            {
277                let path = path.parent().context("No parent directory")?;
278                let mkdircmd = format!("mkdir -p {}", path.display());
279                let _ = self.exec_shell(&mkdircmd).await?;
280
281                return self.exec_shell(&cmd).await;
282            }
283        }
284
285        write_file_result
286    }
287}
288
289impl Drop for RunningDockerExecutor {
290    fn drop(&mut self) {
291        tracing::warn!(
292            "Stopping container {container_id}",
293            container_id = self.container_id
294        );
295        let result = tokio::task::block_in_place(|| {
296            tokio::runtime::Handle::current().block_on(async {
297                self.docker
298                    .remove_container(
299                        &self.container_id,
300                        Some(RemoveContainerOptions {
301                            force: true,
302                            v: true,
303                            ..Default::default()
304                        }),
305                    )
306                    .await
307            })
308        });
309
310        if let Err(e) = result {
311            tracing::warn!(error = %e, "Error stopping container, might not be stopped");
312        }
313    }
314}