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 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}