Skip to main content

orodruin_cli/
env_model.rs

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}