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