1use std::{
2 collections::BTreeMap,
3 env,
4 ffi::CStr,
5 path::{Path, PathBuf},
6};
7
8use serde::Serialize;
9
10use crate::config::{BuildConfig, EnvironmentConfig, ProjectConfig};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13pub struct ResolvedEnvironment {
14 pub project_name: String,
15 pub environment_name: String,
16 pub container_name: String,
17 pub image: String,
18 pub project_root: PathBuf,
19 pub project_mount: String,
20 pub workdir: String,
21 pub env: BTreeMap<String, String>,
22 pub mounts: Vec<ResolvedMount>,
23 pub user: ResolvedUser,
24 pub shell: Vec<String>,
25 pub startup_command: Vec<String>,
26 pub default_command: Option<Vec<String>>,
27 pub build: Option<ResolvedBuild>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct ResolvedBuild {
32 pub context: PathBuf,
33 pub file: Option<PathBuf>,
34 pub tag: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct ResolvedMount {
39 pub source: PathBuf,
40 pub target: String,
41 pub readonly: bool,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct ResolvedUser {
46 pub username: String,
47 pub uid: u32,
48 pub gid: u32,
49 pub home: String,
50}
51
52impl ResolvedEnvironment {
53 pub fn resolve(
54 project_root: &Path,
55 project_config: &ProjectConfig,
56 env_name: &str,
57 config: &EnvironmentConfig,
58 ) -> Self {
59 let project_name = project_config
60 .project
61 .name
62 .clone()
63 .unwrap_or_else(|| fallback_project_name(project_root));
64 let project_slug = slugify(&project_name);
65 let project_mount = config
66 .project_mount
67 .clone()
68 .unwrap_or_else(|| format!("/workspace/{project_slug}"));
69 let workdir = config
70 .workdir
71 .clone()
72 .unwrap_or_else(|| project_mount.clone());
73 let container_name = config.container_name.clone().unwrap_or_else(|| {
74 format!(
75 "orodruin-{}-{}",
76 slugify(&project_name),
77 stable_suffix(project_root, env_name)
78 )
79 });
80
81 let mut env_map = config.env.clone();
82 for key in &config.preserve_env {
83 if let Ok(value) = env::var(key) {
84 env_map.entry(key.clone()).or_insert(value);
85 }
86 }
87
88 let mut mounts = Vec::with_capacity(config.mounts.len() + 1);
89 mounts.push(ResolvedMount {
90 source: project_root.to_path_buf(),
91 target: project_mount.clone(),
92 readonly: false,
93 });
94 mounts.extend(config.mounts.iter().map(|mount| ResolvedMount {
95 source: resolve_relative_path(project_root, &mount.source),
96 target: mount.target.clone(),
97 readonly: mount.readonly,
98 }));
99
100 let build = config
101 .build
102 .as_ref()
103 .map(|build| resolve_build(project_root, &container_name, build));
104 let user = resolve_current_user();
105
106 Self {
107 project_name,
108 environment_name: env_name.to_string(),
109 container_name,
110 image: config
111 .image
112 .clone()
113 .or_else(|| build.as_ref().map(|value| value.tag.clone()))
114 .expect("validated config always provides image or build"),
115 project_root: project_root.to_path_buf(),
116 project_mount,
117 workdir,
118 env: env_map,
119 mounts,
120 user,
121 shell: config
122 .shell
123 .clone()
124 .unwrap_or_else(|| vec!["/bin/sh".to_string()]),
125 startup_command: config
126 .startup_command
127 .clone()
128 .unwrap_or_else(|| vec!["sleep".to_string(), "infinity".to_string()]),
129 default_command: config.default_command.clone(),
130 build,
131 }
132 }
133}
134
135fn resolve_build(project_root: &Path, container_name: &str, build: &BuildConfig) -> ResolvedBuild {
136 let context = resolve_relative_path(project_root, &build.context);
137 let file = build
138 .file
139 .as_ref()
140 .map(|value| resolve_relative_path(project_root, value));
141 let tag = build
142 .tag
143 .clone()
144 .unwrap_or_else(|| format!("{container_name}:dev"));
145 ResolvedBuild { context, file, tag }
146}
147
148fn resolve_relative_path(project_root: &Path, value: &str) -> PathBuf {
149 let path = PathBuf::from(value);
150 if path.is_absolute() {
151 path
152 } else {
153 project_root.join(path)
154 }
155}
156
157fn fallback_project_name(project_root: &Path) -> String {
158 project_root
159 .file_name()
160 .and_then(|value| value.to_str())
161 .unwrap_or("project")
162 .to_string()
163}
164
165fn stable_suffix(project_root: &Path, env_name: &str) -> String {
166 let mut hash = 0xcbf29ce484222325u64;
167 for byte in project_root
168 .display()
169 .to_string()
170 .bytes()
171 .chain(std::iter::once(b':'))
172 .chain(env_name.bytes())
173 {
174 hash ^= u64::from(byte);
175 hash = hash.wrapping_mul(0x100000001b3);
176 }
177 format!("{hash:016x}")
178}
179
180fn slugify(value: &str) -> String {
181 let mut slug = String::new();
182 let mut last_was_dash = false;
183 for character in value.chars() {
184 let next = if character.is_ascii_alphanumeric() {
185 Some(character.to_ascii_lowercase())
186 } else {
187 None
188 };
189 if let Some(character) = next {
190 slug.push(character);
191 last_was_dash = false;
192 } else if !last_was_dash && !slug.is_empty() {
193 slug.push('-');
194 last_was_dash = true;
195 }
196 }
197 slug.trim_matches('-').to_string()
198}
199
200fn resolve_current_user() -> ResolvedUser {
201 let uid = unsafe { libc::getuid() };
202 let gid = unsafe { libc::getgid() };
203 let username = current_username(uid).unwrap_or_else(|| uid.to_string());
204 let home = if uid == 0 {
205 "/root".to_string()
206 } else {
207 format!("/home/{username}")
208 };
209 ResolvedUser {
210 username,
211 uid,
212 gid,
213 home,
214 }
215}
216
217fn current_username(uid: u32) -> Option<String> {
218 let mut buffer = vec![0; 4096];
219 let mut passwd = std::mem::MaybeUninit::<libc::passwd>::uninit();
220 let mut result = std::ptr::null_mut();
221 let status = unsafe {
222 libc::getpwuid_r(
223 uid,
224 passwd.as_mut_ptr(),
225 buffer.as_mut_ptr().cast(),
226 buffer.len(),
227 &mut result,
228 )
229 };
230 if status == 0 && !result.is_null() {
231 let passwd = unsafe { passwd.assume_init() };
232 return Some(
233 unsafe { CStr::from_ptr(passwd.pw_name) }
234 .to_string_lossy()
235 .into_owned(),
236 );
237 }
238
239 env::var("USER")
240 .ok()
241 .filter(|value| !value.trim().is_empty())
242 .or_else(|| {
243 env::var("LOGNAME")
244 .ok()
245 .filter(|value| !value.trim().is_empty())
246 })
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::config::ProjectMetadata;
253
254 #[test]
255 fn derives_stable_container_names() {
256 let project = ProjectConfig {
257 project: ProjectMetadata {
258 name: Some("My App".into()),
259 default_env: None,
260 },
261 envs: Default::default(),
262 };
263 let env = EnvironmentConfig {
264 image: Some("ubuntu:latest".into()),
265 build: None,
266 container_name: None,
267 project_mount: None,
268 workdir: None,
269 env: Default::default(),
270 preserve_env: vec![],
271 mounts: vec![],
272 shell: None,
273 startup_command: None,
274 default_command: None,
275 };
276
277 let first = ResolvedEnvironment::resolve(Path::new("/tmp/example"), &project, "dev", &env);
278 let second = ResolvedEnvironment::resolve(Path::new("/tmp/example"), &project, "dev", &env);
279
280 assert_eq!(first.container_name, second.container_name);
281 assert!(first.container_name.starts_with("orodruin-my-app-"));
282 assert!(!first.user.username.is_empty());
283 assert_eq!(first.user.home, format!("/home/{}", first.user.username));
284 }
285
286 #[test]
287 fn applies_project_mount_and_workdir_defaults() {
288 let project = ProjectConfig {
289 project: ProjectMetadata::default(),
290 envs: Default::default(),
291 };
292 let env = EnvironmentConfig {
293 image: Some("ubuntu:latest".into()),
294 build: None,
295 container_name: None,
296 project_mount: None,
297 workdir: None,
298 env: Default::default(),
299 preserve_env: vec![],
300 mounts: vec![],
301 shell: None,
302 startup_command: None,
303 default_command: None,
304 };
305
306 let resolved = ResolvedEnvironment::resolve(Path::new("/tmp/app"), &project, "dev", &env);
307
308 assert_eq!(resolved.project_mount, "/workspace/app");
309 assert_eq!(resolved.workdir, "/workspace/app");
310 assert_eq!(resolved.mounts[0].source, Path::new("/tmp/app"));
311 assert_eq!(resolved.user.uid, unsafe { libc::getuid() });
312 assert_eq!(resolved.user.gid, unsafe { libc::getgid() });
313 }
314
315 #[test]
316 fn merges_preserved_and_explicit_env() {
317 let project = ProjectConfig {
318 project: ProjectMetadata::default(),
319 envs: Default::default(),
320 };
321 let env = EnvironmentConfig {
322 image: Some("ubuntu:latest".into()),
323 build: None,
324 container_name: None,
325 project_mount: None,
326 workdir: None,
327 env: BTreeMap::from([(String::from("LANG"), String::from("C.UTF-8"))]),
328 preserve_env: vec!["SHOULD_NOT_EXIST".into()],
329 mounts: vec![],
330 shell: None,
331 startup_command: None,
332 default_command: Some(vec!["cargo".into(), "test".into()]),
333 };
334
335 let resolved = ResolvedEnvironment::resolve(Path::new("/tmp/app"), &project, "dev", &env);
336
337 assert_eq!(
338 resolved.env.get("LANG").map(String::as_str),
339 Some("C.UTF-8")
340 );
341 assert_eq!(
342 resolved.default_command,
343 Some(vec![String::from("cargo"), String::from("test")])
344 );
345 assert_eq!(
346 resolved.startup_command,
347 vec![String::from("sleep"), String::from("infinity")]
348 );
349 }
350}