swiftide_docker_executor/
running_docker_executor.rs1use 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 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 let mut tmp_dockerfile_name = None;
65
66 if let Some(dockerfile) = dockerfile {
68 let dockerfile_manager = DockerfileManager::new(context_path);
70 let tmp_dockerfile = dockerfile_manager.prepare_dockerfile(dockerfile).await?;
71
72 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); 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 let container_config = ContainerConfigurator::new(docker.socket_path.clone())
117 .create_container_config(&image_name, user);
118
119 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 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 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 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 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}"), }
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 let output = stdout.trim().to_string() + stderr.trim();
223 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 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}