swiftide_docker_executor/
running_docker_executor.rs

1use anyhow::Context as _;
2use async_trait::async_trait;
3use bollard::{
4    container::RemoveContainerOptions,
5    secret::{ContainerState, ContainerStateStatusEnum},
6};
7use shell::shell_executor_client::ShellExecutorClient;
8use std::{path::Path, sync::Arc};
9pub use swiftide_core::ToolExecutor;
10use swiftide_core::{Command, CommandError, CommandOutput};
11use uuid::Uuid;
12
13use crate::{
14    client::Client, container_configurator::ContainerConfigurator,
15    container_starter::ContainerStarter, dockerfile_manager::DockerfileManager,
16    image_builder::ImageBuilder, ContextBuilder, DockerExecutorError,
17};
18
19pub mod shell {
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
30impl From<RunningDockerExecutor> for Arc<dyn ToolExecutor> {
31    fn from(val: RunningDockerExecutor) -> Self {
32        Arc::new(val) as Arc<dyn ToolExecutor>
33    }
34}
35
36#[async_trait]
37impl ToolExecutor for RunningDockerExecutor {
38    #[tracing::instrument(skip(self), err)]
39    async fn exec_cmd(&self, cmd: &Command) -> Result<CommandOutput, CommandError> {
40        match cmd {
41            Command::Shell(cmd) => self.exec_shell(cmd).await,
42            Command::ReadFile(path) => self.read_file(path).await,
43            Command::WriteFile(path, content) => self.write_file(path, content).await,
44            _ => unimplemented!(),
45        }
46    }
47}
48
49impl RunningDockerExecutor {
50    /// Starts a docker container with a given context and image name
51    pub async fn start(
52        container_uuid: Uuid,
53        context_path: &Path,
54        dockerfile: &Path,
55        image_name: &str,
56    ) -> Result<RunningDockerExecutor, DockerExecutorError> {
57        let docker = Client::lazy_client().await?;
58
59        // Prepare dockerfile
60        let dockerfile_manager = DockerfileManager::new(context_path);
61        let tmp_dockerfile = dockerfile_manager.prepare_dockerfile(dockerfile).await?;
62
63        // Build context
64        tracing::warn!(
65            "Creating archive for context from {}",
66            context_path.display()
67        );
68        let context = ContextBuilder::from_path(context_path)?.build_tar().await?;
69
70        // Build image
71        let tag = container_uuid
72            .to_string()
73            .split_once('-')
74            .map(|(tag, _)| tag)
75            .unwrap_or("latest")
76            .to_string();
77
78        let image_builder = ImageBuilder::new(docker.clone());
79        let image_name_with_tag = image_builder
80            .build_image(
81                context_path,
82                context,
83                tmp_dockerfile.path(),
84                image_name,
85                &tag,
86            )
87            .await?;
88
89        // Configure container
90        let container_config = ContainerConfigurator::new(docker.socket_path.clone())
91            .create_container_config(&image_name_with_tag);
92
93        // Start container
94        let container_starter = ContainerStarter::new(docker.clone());
95        let (container_id, host_port) = container_starter
96            .start_container(image_name, &container_uuid, container_config)
97            .await?;
98
99        // Remove the temporary dockerfile from the container
100
101        let executor = RunningDockerExecutor {
102            container_id,
103            docker,
104            host_port,
105        };
106
107        // we only want the filename
108        let Some(tmp_dockerfile_path) = tmp_dockerfile
109            .path()
110            .file_name()
111            .map(|s| s.to_string_lossy().to_string())
112        else {
113            return Ok(executor);
114        };
115
116        drop(tmp_dockerfile); // Make sure the temporary file is removed right away
117        executor
118            .exec_shell(&format!("rm {}", tmp_dockerfile_path))
119            .await
120            .context("failed to remove temporary dockerfile")
121            .map_err(DockerExecutorError::Start)?;
122
123        Ok(executor)
124    }
125
126    /// Returns the underlying bollard status of the container
127    ///
128    /// Useful for checking if the executor is running or not
129    pub async fn container_state(&self) -> Result<ContainerState, DockerExecutorError> {
130        let container = self
131            .docker
132            .inspect_container(&self.container_id, None)
133            .await?;
134
135        container.state.ok_or_else(|| {
136            DockerExecutorError::ContainerStateMissing(self.container_id.to_string())
137        })
138    }
139
140    /// Check if the executor and its underlying container is running
141    ///
142    /// Will ignore any errors and assume it is not if there are
143    pub async fn is_running(&self) -> bool {
144        self.container_state()
145            .await
146            .map(|state| state.status == Some(ContainerStateStatusEnum::RUNNING))
147            .unwrap_or(false)
148    }
149
150    async fn exec_shell(&self, cmd: &str) -> Result<CommandOutput, CommandError> {
151        let mut client =
152            ShellExecutorClient::connect(format!("http://127.0.0.1:{}", self.host_port))
153                .await
154                .map_err(anyhow::Error::from)?;
155
156        let request = tonic::Request::new(shell::ShellRequest {
157            command: cmd.to_string(),
158        });
159
160        let response = client
161            .exec_shell(request)
162            .await
163            .map_err(anyhow::Error::from)?;
164
165        let shell::ShellResponse {
166            stdout,
167            stderr,
168            exit_code,
169        } = response.into_inner();
170
171        // // Trim both stdout and stderr to remove surrounding whitespace and newlines
172        let output = stdout.trim().to_string() + stderr.trim();
173        //
174        if exit_code == 0 {
175            Ok(output.into())
176        } else {
177            Err(CommandError::NonZeroExit(output.into()))
178        }
179    }
180
181    #[tracing::instrument(skip(self))]
182    async fn read_file(&self, path: &Path) -> Result<CommandOutput, CommandError> {
183        self.exec_shell(&format!("cat {}", path.display())).await
184    }
185
186    #[tracing::instrument(skip(self, content))]
187    async fn write_file(&self, path: &Path, content: &str) -> Result<CommandOutput, CommandError> {
188        let cmd = indoc::formatdoc! {r#"
189            cat << 'EOFKWAAK' > {path}
190            {content}
191            EOFKWAAK"#,
192            path = path.display(),
193            content = content.trim_end()
194
195        };
196
197        let write_file_result = self.exec_shell(&cmd).await;
198
199        // If the directory or file does not exist, create it
200        if let Err(CommandError::NonZeroExit(write_file)) = &write_file_result {
201            if [
202                "no such file or directory",
203                "directory nonexistent",
204                "nonexistent directory",
205            ]
206            .iter()
207            .any(|&s| write_file.output.to_lowercase().contains(s))
208            {
209                let path = path.parent().context("No parent directory")?;
210                let mkdircmd = format!("mkdir -p {}", path.display());
211                let _ = self.exec_shell(&mkdircmd).await?;
212
213                return self.exec_shell(&cmd).await;
214            }
215        }
216
217        write_file_result
218    }
219}
220
221impl Drop for RunningDockerExecutor {
222    fn drop(&mut self) {
223        tracing::warn!(
224            "Stopping container {container_id}",
225            container_id = self.container_id
226        );
227        let result = tokio::task::block_in_place(|| {
228            tokio::runtime::Handle::current().block_on(async {
229                self.docker
230                    .remove_container(
231                        &self.container_id,
232                        Some(RemoveContainerOptions {
233                            force: true,
234                            v: true,
235                            ..Default::default()
236                        }),
237                    )
238                    .await
239            })
240        });
241
242        if let Err(e) = result {
243            tracing::warn!(error = %e, "Error stopping container, might not be stopped");
244        }
245    }
246}