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