1use crate::Workspace;
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use tempfile::{TempDir, tempdir};
6use testcontainers::core::{ExecCommand, ExecResult, Mount};
7use testcontainers::runners::AsyncRunner;
8use testcontainers::{ContainerAsync, GenericImage, ImageExt};
9use tokio::io::AsyncBufRead;
10use tokio::runtime::Handle;
11use tokio::task::spawn_blocking;
12
13use super::{ContainerError, Image};
14
15#[derive(Clone)]
16pub struct Container {
17 inner: Arc<ContainerInner>,
18}
19
20struct ContainerInner {
21 container: ContainerAsync<GenericImage>,
22 workspace_root: PathBuf,
23 cwd: PathBuf,
24 _ephemeral_tempdirs: Vec<TempDir>,
25}
26
27pub struct ContainerBuilder {
28 image: Image,
29 env_vars: BTreeMap<String, String>,
30 mounts: Vec<Mount>,
31 ephemeral_mounts: Vec<String>,
32 privileged: bool,
33}
34
35pub struct ExecOutput {
36 pub exit_code: i64,
37 pub stdout: String,
38 pub stderr: String,
39}
40
41pub(crate) struct ExecHandle {
42 exec: ExecResult,
43}
44
45impl Container {
46 pub fn builder(image: Image) -> ContainerBuilder {
47 ContainerBuilder {
48 image,
49 env_vars: BTreeMap::new(),
50 mounts: Vec::new(),
51 ephemeral_mounts: Vec::new(),
52 privileged: false,
53 }
54 }
55
56 pub fn workspace_root(&self) -> &Path {
57 &self.inner.workspace_root
58 }
59
60 pub fn cwd(&self) -> &Path {
61 &self.inner.cwd
62 }
63
64 pub async fn exec_shell(&self, script: impl Into<String>) -> Result<ExecOutput, ContainerError> {
65 let command = vec!["/bin/sh".to_string(), "-lc".to_string(), script.into()];
66 self.exec_streaming(command, BTreeMap::new()).await?.collect().await
67 }
68
69 pub(crate) async fn exec_streaming(
70 &self,
71 command: Vec<String>,
72 env_vars: BTreeMap<String, String>,
73 ) -> Result<ExecHandle, ContainerError> {
74 let exec = self
75 .inner
76 .container
77 .exec(ExecCommand::new(wrap_command(&self.inner.cwd, &command)).with_env_vars(env_vars))
78 .await?;
79 Ok(ExecHandle { exec })
80 }
81}
82
83impl ContainerBuilder {
84 pub fn with_privileged(mut self, privileged: bool) -> Self {
86 self.privileged = privileged;
87 self
88 }
89
90 pub fn with_env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
91 self.env_vars.insert(key.into(), value.into());
92 self
93 }
94
95 pub fn with_env_vars(mut self, env_vars: impl IntoIterator<Item = (String, String)>) -> Self {
96 self.env_vars.extend(env_vars);
97 self
98 }
99
100 pub fn with_mount(mut self, mount: Mount) -> Self {
101 self.mounts.push(mount);
102 self
103 }
104
105 pub fn with_ephemeral_mount(mut self, container_path: impl Into<String>) -> Self {
106 self.ephemeral_mounts.push(container_path.into());
107 self
108 }
109
110 pub async fn start(self, workspace: &Workspace) -> Result<Container, ContainerError> {
111 let container_workspace_root = PathBuf::from("/workspace");
112 let container_cwd = container_cwd(&container_workspace_root, workspace.relative_cwd());
113 let mut image = GenericImage::new(&self.image.name, &self.image.tag)
114 .with_entrypoint("/bin/sh")
115 .with_privileged(self.privileged)
116 .with_cmd(["-c", "sleep infinity"])
117 .with_mount(Mount::bind_mount(workspace.root_path().display().to_string(), "/workspace"))
118 .with_working_dir(container_cwd.display().to_string());
119
120 for mount in &self.mounts {
121 image = image.with_mount(mount.clone());
122 }
123
124 for (key, value) in &self.env_vars {
125 image = image.with_env_var(key.clone(), value.clone());
126 }
127
128 let mut ephemeral_tempdirs = Vec::with_capacity(self.ephemeral_mounts.len());
129 for container_path in &self.ephemeral_mounts {
130 let tempdir = tempdir().map_err(|source| ContainerError::EphemeralMountTempDir { source })?;
131 image = image.with_mount(Mount::bind_mount(tempdir.path().display().to_string(), container_path.clone()));
132 ephemeral_tempdirs.push(tempdir);
133 }
134
135 let container = image.start().await?;
136 Ok(Container {
137 inner: Arc::new(ContainerInner {
138 container,
139 workspace_root: container_workspace_root,
140 cwd: container_cwd,
141 _ephemeral_tempdirs: ephemeral_tempdirs,
142 }),
143 })
144 }
145}
146
147impl ExecHandle {
148 pub fn stdout(&mut self) -> impl AsyncBufRead + Send + '_ {
149 self.exec.stdout()
150 }
151
152 pub async fn stderr_to_string(&mut self) -> Result<String, ContainerError> {
153 let stderr = self.exec.stderr_to_vec().await?;
154 Ok(String::from_utf8_lossy(&stderr).into_owned())
155 }
156
157 pub async fn exit_code(self) -> Result<i64, ContainerError> {
158 let runtime = Handle::current();
159 spawn_blocking(move || runtime.block_on(self.exec.exit_code()))
160 .await
161 .map_err(|source| ContainerError::ExecInspection { source })??
162 .ok_or(ContainerError::MissingExecExitCode)
163 }
164
165 pub async fn collect(mut self) -> Result<ExecOutput, ContainerError> {
166 let stdout = String::from_utf8_lossy(&self.exec.stdout_to_vec().await?).into_owned();
167 let stderr = String::from_utf8_lossy(&self.exec.stderr_to_vec().await?).into_owned();
168 let exit_code = self.exit_code().await?;
169 Ok(ExecOutput { exit_code, stdout, stderr })
170 }
171}
172
173fn container_cwd(container_workspace_root: &Path, relative_cwd: Option<&Path>) -> PathBuf {
174 relative_cwd.map_or_else(
175 || container_workspace_root.to_path_buf(),
176 |relative_cwd| container_workspace_root.join(relative_cwd),
177 )
178}
179
180fn wrap_command(container_cwd: &Path, command: &[String]) -> Vec<String> {
181 let mut argv = vec![
182 "/bin/sh".to_string(),
183 "-c".to_string(),
184 "cd \"$1\" && shift && exec \"$@\"".to_string(),
185 "aether-container-exec".to_string(),
186 container_cwd.display().to_string(),
187 ];
188 argv.extend(command.iter().cloned());
189 argv
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn container_cwd_uses_workspace_root_when_no_relative_cwd() {
198 assert_eq!(container_cwd(Path::new("/workspace"), None), Path::new("/workspace"));
199 }
200
201 #[test]
202 fn container_cwd_joins_relative_cwd() {
203 assert_eq!(container_cwd(Path::new("/workspace"), Some(Path::new("subdir"))), Path::new("/workspace/subdir"));
204 }
205
206 #[test]
207 fn wrap_command_cds_to_container_cwd_then_execs_command() {
208 let argv =
209 wrap_command(Path::new("/workspace/subdir"), &["node".to_string(), "/app/eval-agent.js".to_string()]);
210
211 assert_eq!(
212 argv,
213 vec![
214 "/bin/sh".to_string(),
215 "-c".to_string(),
216 "cd \"$1\" && shift && exec \"$@\"".to_string(),
217 "aether-container-exec".to_string(),
218 "/workspace/subdir".to_string(),
219 "node".to_string(),
220 "/app/eval-agent.js".to_string(),
221 ]
222 );
223 }
224
225 #[test]
226 fn wrap_command_preserves_command_args_after_cwd_arg() {
227 let argv = wrap_command(
228 Path::new("/workspace"),
229 &["node".to_string(), "/app/eval agent.js".to_string(), "--city".to_string(), "New York".to_string()],
230 );
231
232 assert_eq!(&argv[5..], ["node", "/app/eval agent.js", "--city", "New York"]);
233 }
234}